Godot で mruby を使うメモ
Link
mruby
gdextension
2025/7/3 追記。↓成果物
godot-cpp-template をクローン。
その後、mruby を git submodule として追加。
$ ls -1
LICENSE.md
README.md
SConstruct
__pycache__
bin
demo
godot-cpp
icon
methods.py
mruby
src
tmp
以下を書いた。
#include <mruby.h>
#include <mruby/compile.h>
void ReDScribe::test_ruby() {
mrb_state* mrb = mrb_open();
if (!mrb) {
// handle error
return;
}
mrb_load_string(mrb, "1+1");
mrb_close(mrb);
}
mruby の build_config を修正。
conf.cc do |cc|
cc.flags << '/MT'
end
64 Native Tools Command Prompt for VS 2022 を起動して rake コマンドを実行
> cd mruby
> rake
すると、mruby\build\host\lib
フォルダに libmruby.lib
などが生成される。
SConstruct に以下追加。
mruby_include_path = "mruby/build/host/include"
mruby_library_path = "mruby/build/host/lib"
# mruby
env.Append(CPPPATH=[mruby_include_path])
env.Append(LIBPATH=[mruby_library_path])
env.Append(LIBS=["libmruby"])
# エラーが出たので追加
env.Append(LIBS=["Ws2_32"])
scons
コマンドを実行するととりあえずエラーは出なくなった。(よくわかってない。copilot に聞いて言われるがまま試した)
上記を Rakefile にした。rake
コマンドでビルドできた。
task :default => :all
task :all => :mruby_build do
sh 'scons'
end
task :mruby_build do
cd 'mruby' do
ENV['CFLAGS'] = '/MT'
sh 'rake clean'
sh 'rake'
end
end
#ifndef REDSCRIBE_H
#define REDSCRIBE_H
#include <godot_cpp/classes/resource.hpp>
#include <mruby.h>
namespace godot {
class ReDScribe : public Resource {
GDCLASS(ReDScribe, Resource)
private:
void set_dsl_error(const String &p_dsl_error);
String get_dsl_error() const;
protected:
static void _bind_methods();
public:
ReDScribe();
~ReDScribe();
String dsl = "";
String dsl_error = "";
void set_dsl(const String &p_dsl);
String get_dsl() const;
void execute_dsl();
};
}
#endif
#include "redscribe.h"
#include <godot_cpp/core/class_db.hpp>
#include <mruby.h>
#include <mruby/compile.h>
#include <mruby/data.h>
#include <mruby/string.h>
using namespace godot;
void ReDScribe::_bind_methods() {
ClassDB::bind_method(D_METHOD("execute_dsl"), &ReDScribe::execute_dsl);
ClassDB::bind_method(D_METHOD("set_dsl", "dsl"), &ReDScribe::set_dsl);
ClassDB::bind_method(D_METHOD("get_dsl"), &ReDScribe::get_dsl);
ADD_PROPERTY(PropertyInfo(Variant::STRING, "dsl"), "set_dsl", "get_dsl");
ClassDB::bind_method(D_METHOD("set_dsl_error", "dsl_error"), &ReDScribe::set_dsl_error);
ClassDB::bind_method(D_METHOD("get_dsl_error"), &ReDScribe::get_dsl_error);
ADD_PROPERTY(PropertyInfo(Variant::STRING, "dsl_error"), "set_dsl_error", "get_dsl_error");
}
ReDScribe::ReDScribe() {
// initialize
}
ReDScribe::~ReDScribe() {
// cleanup
}
void ReDScribe::set_dsl(const String &p_dsl) {
dsl = p_dsl;
}
String ReDScribe::get_dsl() const {
return dsl;
}
void ReDScribe::set_dsl_error(const String &p_dsl_error) {
dsl_error = p_dsl_error;
}
String ReDScribe::get_dsl_error() const {
return dsl_error;
}
static mrb_value
method_missing(mrb_state *mrb, mrb_value self)
{
/* ここの関数内はよくわかっていない copilot に聞いたコード。これから調べる */
mrb_sym method_name;
mrb_value *args;
mrb_int arg_count;
mrb_get_args(mrb, "n*", &method_name, &args, &arg_count);
const char *method_name_str = mrb_sym2name(mrb, method_name);
return mrb_str_new_cstr(mrb, "method not found: ");
}
void ReDScribe::execute_dsl() {
mrb_state* mrb = mrb_open();
if (!mrb) {
// handle error
return;
}
struct RClass* base_class = mrb->object_class;
mrb_define_method(mrb, base_class, "method_missing", method_missing, MRB_ARGS_ANY());
mrb_load_string(mrb, dsl.utf8().get_data());
if (mrb->exc) {
dsl_error = "error";
} else {
dsl_error = "";
}
mrb_close(mrb);
}
で、とりあえずビルド。method_missing で godot 側のメソッドを呼ぶように今後実装予定。
いまは dsl
に入れた文字列を mruby で評価して、エラーが出たら、dsl_error
に "error" という文字を入れるところまではできた。
Godot 側で、
extends ReDScribe
class_name SimpleDSL
とすると、以下は動いた。
extends GutTest
func test_method_missing():
var res = SimpleDSL.new()
res.dsl = 'bar'
res.execute_dsl()
assert_eq(res.dsl_error, '')
func test_raise():
var res = SimpleDSL.new()
res.dsl = 'raise'
res.execute_dsl()
assert_eq(res.dsl_error, 'error')
ReDScribe *gd_context = NULL;
static mrb_value
method_missing(mrb_state *mrb, mrb_value self)
{
mrb_sym method_name;
mrb_value *args;
mrb_int arg_count;
mrb_get_args(mrb, "n*", &method_name, &args, &arg_count);
const char *method_name_str = mrb_sym2name(mrb, method_name);
ReDScribe *instance = get_gdcontext();
if (instance) {
Array godot_args;
Variant result = instance->call(method_name_str, godot_args);
if (result.get_type() == Variant::STRING) {
instance->dsl_result = String(result);
} else {
instance->dsl_result = "method missing";
}
}
return mrb_str_new_cstr(mrb, "method not found: ");
}
void set_gdcontext(ReDScribe *r) {
gd_context = r;
}
static ReDScribe* get_gdcontext(void) {
return gd_context;
}
void ReDScribe::execute_dsl() {
mrb_state* mrb = mrb_open();
if (!mrb) {
// handle error
return;
}
set_gdcontext(this);
struct RClass* base_class = mrb->object_class;
mrb_define_method(mrb, base_class, "method_missing", method_missing, MRB_ARGS_ANY());
mrb_load_string(mrb, dsl.utf8().get_data());
if (mrb->exc) {
dsl_error = "error";
} else {
dsl_error = "";
}
mrb_close(mrb);
}
で、instance->call(method_name_str, godot_args)
のところで gdscript のメソッドを一応呼ぶことはできたが結果は取得できなかった。
extends ReDScribe
class_name SimpleDSL
func foo() -> String:
return 'foo called'
func bar():
return 'bar called'
var res = SimpleDSL.new()
res.dsl = 'foo'
res.execute_dsl()
res.dsl_result # => ''
res.dsl = 'bar'
res.execute_dsl()
res.dsl_result # => 'method missing'
-> String
を書くと cpp 側で String を返す関数として宣言だけされるのか、
Variant result = instance->call(method_name_str, godot_args);
if (result.get_type() == Variant::STRING) {
instance->dsl_result = String(result);
} else {
instance->dsl_result = "method missing";
}
で、result.get_type()
は Variant::STRING
を満たしていた。
gdextension の中で gdscript で定義した関数を実行したかったが、難しそうなので
gdextension 側で定義した関数を gdscript の中で実行する形にするしかないか。
でも、ここに書いてあるように c++ から gdscript の関数は呼べるのか?
method_missing で Signal だけ emit して、gdscript 側で受け取って eval すれば、mruby 側で呼んだメソッドを gdscript 側で実行できるか。一方通行になるけど。
他言語での gdextension バインディング例
Scheme (s7)
Lua (Luau)
Javascript (QuickJS)
mruby => gdscript への型変換
#include <godot_cpp/core/class_db.hpp>
#include <mruby.h>
#include <mruby/compile.h>
#include <mruby/data.h>
#include <mruby/variable.h>
#include <mruby/string.h>
#include <mruby/hash.h>
#include <mruby/array.h>
static int
mrb_hash_variant_set(mrb_state *mrb, mrb_value key, mrb_value value, void *data)
{
Dictionary *dict = static_cast<Dictionary *>(data);
Variant godot_key = mrb_variant(mrb, key);
Variant godot_value = mrb_variant(mrb, value);
dict->operator[](godot_key) = godot_value;
return 0;
}
Dictionary
mrb_hash_variant(mrb_state *mrb, mrb_value hash)
{
Dictionary dict;
if (mrb_hash_p(hash)) {
struct RHash *hash_ptr = mrb_hash_ptr(hash);
mrb_hash_foreach(mrb, hash_ptr, mrb_hash_variant_set, &dict);
}
return dict;
}
Variant
mrb_variant(mrb_state *mrb, mrb_value value)
{
switch (mrb_type(value)) {
case MRB_TT_TRUE:
return true;
case MRB_TT_FALSE:
if (mrb_nil_p(value)) {
return Variant();
}
return false;
case MRB_TT_INTEGER:
return mrb_integer(value);
case MRB_TT_FLOAT:
return mrb_float(value);
case MRB_TT_SYMBOL:
return String(mrb_sym2name(mrb, mrb_symbol(value)));
case MRB_TT_STRING:
return String(mrb_str_to_cstr(mrb, value));
case MRB_TT_HASH:
return mrb_hash_variant(mrb, value);
case MRB_TT_ARRAY: {
Array godot_array;
mrb_int len = RARRAY_LEN(value);
for (mrb_int i = 0; i < len; i++) {
mrb_value element = mrb_ary_entry(value, i);
godot_array.append(mrb_variant(mrb, element));
}
return godot_array;
}
default:
return Variant();
}
}
method_missing を受け取って gdscript を実行するところまでできた。
using namespace godot;
ReDScribe *gd_context = nullptr;
void ReDScribe::_bind_methods() {
ClassDB::bind_method(D_METHOD("perform", "dsl"), &ReDScribe::perform);
ClassDB::bind_method(D_METHOD("set_exception", "exception"), &ReDScribe::set_exception);
ClassDB::bind_method(D_METHOD("get_exception"), &ReDScribe::get_exception);
ADD_PROPERTY(PropertyInfo(Variant::STRING, "exception"), "set_exception", "get_exception");
ADD_SIGNAL(MethodInfo("method_missing",
PropertyInfo(Variant::STRING, "method_name"),
PropertyInfo(Variant::ARRAY, "args"))
);
}
ReDScribe::ReDScribe() {
mrb = mrb_open();
if (!mrb) {
// handle error
return;
}
set_gdcontext(this);
struct RClass* base_class = mrb->object_class;
mrb_define_method(mrb, base_class, "method_missing", method_missing, MRB_ARGS_ANY());
}
static mrb_value
method_missing(mrb_state *mrb, mrb_value self)
{
mrb_sym method_name;
mrb_value *args;
mrb_int arg_count;
mrb_get_args(mrb, "n*", &method_name, &args, &arg_count);
String method_name_str = String(mrb_sym2name(mrb, method_name));
ReDScribe *instance = get_gdcontext();
if (instance) {
Array godot_args;
for (mrb_int i = 0; i < arg_count; i++) {
godot_args.append(mrb_variant(mrb, args[i]));
}
instance->emit_signal("method_missing", method_name_str, godot_args);
}
return mrb_nil_value();
}
void set_gdcontext(ReDScribe *r) {
gd_context = r;
}
static ReDScribe* get_gdcontext(void) {
return gd_context;
}
void clear_gdcontext(void) {
gd_context = nullptr;
}
void ReDScribe::perform(const String &dsl) {
mrb_load_string(mrb, dsl.utf8().get_data());
if (mrb->exc) {
exception = "error";
} else {
exception = "";
}
}
やりたいこと
# ここは gdextension 内で定義
module Godot
class << self
def emit_signal(name, payload)
# TODO
puts "#{name}: #{payload}"
end
end
end
# これはユーザが定義。require メソッドを実装予定。 perform の前に実行
class Player
ATTRIBUTES = %i[name level job]
def initialize(name)
@name = name
end
def attributes
ATTRIBUTES.map{|a| [a, self[a]] }.to_h
end
def [](key)
instance_variable_get(:"@#{key}")
end
def []=(key, value)
instance_variable_set(:"@#{key}", value)
end
def emit
Godot.emit_signal(:add_player, attributes)
end
def method_missing(name, *args)
if ATTRIBUTES.include? name
self[name] = args[0]
else
super
end
end
end
def player(name, &block)
player = Player.new(name)
player.instance_exec(&block)
player.emit
end
func _ready() -> void:
var res = ReDScribe.new()
res.subscribe('add_player', add_player)
res.perform("""
player 'Alice' do
level 1
job :magician
end
""")
func add_player(attributes: Dictionary) -> void:
var player = Player.new()
player.set_attributes(attributes)
add_child(player)
任意のタイミングでシグナルを発行できるようにした
void ReDScribe::_bind_methods() {
ADD_SIGNAL(MethodInfo("channel",
PropertyInfo(Variant::STRING, "key"),
PropertyInfo(Variant::NIL, "payload"))
);
}
static mrb_value
emit_signal(mrb_state *mrb, mrb_value self)
{
mrb_sym key;
mrb_value payload;
mrb_get_args(mrb, "no", &key, &payload);
ReDScribe *instance = get_gdcontext();
if (instance) {
instance->emit_signal("channel",
String(mrb_sym2name(mrb, key)),
mrb_variant(mrb, payload));
}
return mrb_true_value();
}
static void
mrb_define_godot_module(mrb_state *mrb)
{
struct RClass *godot_module;
godot_module = mrb_define_module(mrb, "Godot");
mrb_define_class_method(mrb, godot_module, "emit_signal", emit_signal, MRB_ARGS_REQ(2));
}
と定義。以下のように呼び出し可能になった。
extends Control
@onready var res := ReDScribe.new()
func _ready() -> void:
res.channel.connect(_subscribe)
res.perform("""
Godot.emit_signal :foo, [1, 2]
""")
func _subscribe(key: String, payload: Variant) -> void:
print_debug(key, ': ', payload)
文字列をUTF-8として受け取れるように修正
before
case MRB_TT_STRING:
return String(mrb_str_to_cstr(mrb, value));
after
case MRB_TT_STRING:
return String::utf8(mrb_string_value_ptr(mrb, value));
rb ファイルを事前に実行できるようにする。require も定義
#include <godot_cpp/classes/file_access.hpp>
void ReDScribe::_bind_methods() {
ClassDB::bind_method(D_METHOD("set_boot_file", "boot_file"), &ReDScribe::set_boot_file);
ClassDB::bind_method(D_METHOD("get_boot_file"), &ReDScribe::get_boot_file);
ADD_PROPERTY(PropertyInfo(Variant::STRING, "boot_file", PROPERTY_HINT_FILE, "*.rb"), "set_boot_file", "get_boot_file");
}
void ReDScribe::set_boot_file(const String &p_boot_file) {
boot_file = p_boot_file;
mrb_execute_file(mrb, p_boot_file);
}
static bool
mrb_execute_file(mrb_state *mrb, String path)
{
Ref<FileAccess> file = FileAccess::open(path, FileAccess::ModeFlags::READ);
if (file.is_valid()) {
String content = file->get_as_text();
mrb_load_string(mrb, content.utf8().get_data());
return true;
} else {
PRINT("cannot load such file -- " + path);
return false;
}
}
static mrb_value
require(mrb_state *mrb, mrb_value self)
{
mrb_value path;
mrb_get_args(mrb, "S", &path);
String gd_path = "res://" + String(mrb_variant(mrb, path));
if (mrb_execute_file(mrb, gd_path)) {
return mrb_true_value();
}
return mrb_false_value();
}
static void
mrb_define_utility_methods(mrb_state *mrb)
{
struct RClass* base_class = mrb->object_class;
mrb_define_method(mrb, base_class, "require", require, MRB_ARGS_REQ(1));
}
だいぶ動くところまでできた。
mrubyのビルド
Windows と Mac でビルドして gdextension で使えるところまでできた。
環境変数 MRUBY_CONFIG
には相対パスも指定できたので、mruby フォルダ以下はそのままに build_config を指定できた。
MRuby::Build.new do |conf|
conf.toolchain :visualcpp
conf.gembox 'default'
conf.cc do |cc|
cc.flags = ['/MT']
end
conf.enable_bintest
conf.enable_test
end
MRuby::Build.new do |conf|
conf.toolchain :clang
conf.gembox 'default'
conf.cc do |cc|
cc.flags = ['-arch x86_64']
end
conf.linker do |linker|
linker.flags = ['-arch x86_64']
end
conf.enable_bintest
conf.enable_test
end
MRuby::Build.new do |conf|
conf.toolchain :clang
conf.gembox 'default'
conf.cc do |cc|
cc.flags = ['-arch arm64']
end
conf.linker do |linker|
linker.flags = ['-arch arm64']
end
conf.enable_bintest
conf.enable_test
end
macOS の場合は、x86_64 と arm64 のどちらでもビルドして、最後に lipo
コマンドでユニバーサルバイナリにする必要があった。
task :mruby_build do
def build_config(name = nil)
if name
ENV['MRUBY_CONFIG'] = "../../build_config/#{name}"
else
ENV['MRUBY_CONFIG'] = nil
end
sh 'rake'
end
cd 'mruby' do
case RbConfig::CONFIG['host_os']
when /mswin|mingw|cygwin/
build_config 'windows'
when /darwin/
libmruby_path = 'build/host/lib/libmruby.a'
next if File.exist? libmruby_path
arm64_file = Tempfile.new('libmruby_arm64')
x86_file = Tempfile.new('libmruby_x86')
build_config 'macos_arm64'
mv libmruby_path, arm64_file.path
sh 'rake clean'
build_config 'macos_x86'
mv libmruby_path, x86_file.path
sh "lipo -create -output #{libmruby_path} #{arm64_file.path} #{x86_file.path}"
when /linux/
build_config # TODO
else
build_config # TODO
end
end
end
rb ファイルを読み込ませるにはこの辺が参考になるか?
rb ファイルをファイルシステムのツリーに表示してダブルクリックでメインスクリーンに表示する
プラグインの登録
@tool
extends EditorPlugin
const Main = preload("res://addons/redscribe/src/main/main.tscn")
var main
func _enter_tree() -> void:
main = Main.instantiate()
EditorInterface.get_editor_main_screen().add_child(main)
_make_visible(false)
func _exit_tree() -> void:
if main: main.queue_free()
func _has_main_screen() -> bool:
return true
func _handles(object: Object) -> bool:
return object is ReDScribeEntry
func _edit(object: Object) -> void:
if object is ReDScribeEntry:
main.load_file(object.resource_path)
func _make_visible(visible: bool) -> void:
if main: main.visible = visible
func _get_plugin_name() -> String:
return "ReDScribe"
func _get_plugin_icon() -> Texture2D:
return preload("res://addons/redscribe/assets/icons/editor_icon.svg")
リソースの定義
@tool
extends Resource
class_name ReDScribeEntry
func get_class():
return "ReDScribeEntry"
func is_class(value: String):
return value == "ReDScribeEntry"
@tool
extends ResourceFormatLoader
class_name ReDScribeEntryLoader
const ReDScribeEntry = preload("./redscribe_entry.gd")
var extension = 'rb'
func _get_recognized_extensions() -> PackedStringArray:
return PackedStringArray([extension])
func _get_resource_type(path: String) -> String:
var ext = path.get_extension().to_lower()
if ext == extension:
return "ReDScribeEntry"
return ""
func _handles_type(typename: StringName) -> bool:
return typename == &"ReDScribeEntry"
func _load(path: String, original_path: String, use_sub_threads: bool, cache_mode: int) -> Variant:
var res = ReDScribeEntry.new()
return res
メインスクリーンに表示するシーンの作成
@tool
extends VBoxContainer
func load_file(path: String) -> void:
%Editor.load_file(path)
@tool
extends VBoxContainer
var current_file : String : set = set_current_file
func load_file(path: String) -> void:
current_file = path
var f = FileAccess.open(path, FileAccess.READ)
%CodeArea.text = f.get_as_text()
f.close()
perform
で変数が保持されてないので、
mrb_load_string
ではなく、
mrb_load_string_cxt
を使う必要がありそう。
=> 違った。
どうもローカル変数を保持するには、mrb_gc_arena_save
とかも呼ばないといけない?
簡易REPLができたが、インスタンス変数とメソッドは引き継ぎできているので、ローカル変数はなくてよいか。
実装は以下。
@tool
extends VBoxContainer
var session : ReDScribe
const RELOAD_COMMAND = 'reload!'
var last_result = null : set = set_last_result
var input_histories : PackedStringArray
var history_back_idx := 0
func _ready() -> void:
init_session()
func init_session() -> void:
session = ReDScribe.new()
session.method_missing.connect(_method_missing)
session.channel.connect(_subscribe)
func perform() -> void:
var code = str(%Input.text).strip_edges()
if not code: return
delete_input()
match code:
RELOAD_COMMAND: reload_repl()
_: execute(code)
func execute(code: String) -> void:
input_histories.push_back(code)
output(code)
session.perform("Godot.emit_signal :input, (%s)" % code)
if session.exception:
output("Error: %s" % session.exception)
func reload_repl() -> void:
output("Session reloading...")
init_session()
output("Session reloaded!", true)
func set_last_result(val) -> void:
last_result = val
output("=> " + format_output_val(val))
func format_output_val(val) -> String:
if val is String:
return '"%s"' % val
else:
return str(val)
func output(str: String, clear: bool = false) -> void:
if clear: %Result.text = ''
%Result.text += str + "\n"
func history_back() -> void:
history_back_idx += 1
%Input.text = input_histories.get(
input_histories.size() - history_back_idx)
func delete_input() -> void:
history_back_idx = 0
%Input.text = ''
func delete_following_input() -> void:
%Input.text = '' # TODO
func _method_missing(method_name: String, args: Array) -> void:
output('[method_missing]: ' + method_name)
func _subscribe(key: StringName, payload: Variant) -> void:
match key:
'input': last_result = payload
_: output(
("[ %s ] signal emitted: " % key) + format_output_val(payload))
func _on_input_gui_input(event: InputEvent) -> void:
if event is InputEventKey:
var k := event as InputEventKey
if k.pressed:
match k.keycode:
KEY_ENTER:
if k.ctrl_pressed: perform()
KEY_U:
if k.ctrl_pressed: delete_input()
KEY_K:
if k.ctrl_pressed: delete_following_input()
KEY_L:
if k.ctrl_pressed: output('', true)
KEY_UP:
pass # TODO consider multi line
# history_back()
出力結果は以下
x = 1
=> 1
x
[method_missing]: x
=> <null>
@x = 1
=> 1
@x
=> 1
def y
1
end
=> y
y
=> 1
REPLでローカル変数を保持できない。
⇒ binding をインスタンス変数に入れて eval したら解決した。
⇒ と思ったが、文字列の式展開で undefind method になった。ローカル変数は諦める。