Godot で DSL を書きたかったので、Ruby (mruby) が動くアドオンを作った。
きっかけ
- Sonic Pi や Netlogo みたいな、ユーザがスクリプトを書いて動かす GUI ツールを Godot で作りたかった。
- Godot のリソースファイルを DSL で生成したかった。
- Rubyist なので Ruby を触りたかった。
できたもの
ReDScribe という gdextension アドオンを作成しました。レッドスクライブと読みます。
Ruby(赤=レッド)で書く(Scribe)、よりよく記述する(re-describe)というニュアンスでつけました。
Ruby(mruby) が動かせるのに加えて、外部エディタを使わなくても Godot エディタ上で Ruby を書けるようにしてます。
できること
extends Node
@onready var res := ReDScribe.new()
func _ready() -> void:
res.method_missing.connect(_method_missing)
res.channel.connect(_subscribe)
res.perform("""
アリス "こんにちは Ruby ❤️"
puts "Ruby v#{RUBY_VERSION}, powered by #{RUBY_ENGINE} 💎"
Godot.emit_signal :spawn, { name: 'アリス', job: 'wizard', level: 1 }
""")
func _method_missing(method_name: String, args: Array) -> void:
print_debug('[method_missing] ', method_name, ': ', args)
func _subscribe(key: StringName, payload: Variant) -> void:
print_debug('[subscribe] ', key, ': ', payload)
# -- Output --
#
# [method_missing] アリス: ["こんにちは Ruby ❤️"]
# Ruby v3.4, powered by mruby 💎
# [subscribe] spawn: { &"name": "アリス", &"job": "wizard", &"level": 1 }
#
ReDScribe.new()
でインスタンスを生成して、perform
メソッドで Ruby コードを実行します。
Object#method_missing が発生すると method_missing
シグナルが発行されて、Godot.emit_signal を実行すると channel
シグナルが発行されます。
利用イメージとしては Ruby で DSL を書いて perform
で実行、それに対応する処理を gdscript で書きます。一般的な他言語バインディングの gdextension と違って、Godot の全機能を Ruby で書けるようにするのは目指さず、メインは gdscript、ちょっとしたことに Ruby を使う方針で作成しています。
概要図
ReDScribe
インスタンスごとに mrb_state を生成します。なので名前空間は各インスタンスごとに閉じています。
ReDScribe
インスタンスには boot_file
プロパティを用意しているので、そこに DSL に使用するファイルを指定して予めロードします。
ReDScribe クラス概要
-
プロパティ
- String boot_file : 予めロードするファイルを指定します。
- String exception :
perform
メソッドで例外が発生するとエラー内容を格納します。
-
メソッド
- void set_boot_file(path: String)
- void perform(dsl: String)
-
シグナル
-
channel(key: StringName, payload: Variant)
-
Godot.emit_signal :key, payload
でシグナルを発行します。
-
-
method_missing(method_name: String, args: Array)
- Object#method_missing が発生するとシグナルを発行します。
-
channel(key: StringName, payload: Variant)
組み込みメソッド
CRuby と違って require
は mruby には本来無いんですが、Godotプロジェクトのルートから辿ったパスの rb ファイルを読み込むメソッドとして定義しています。その他、以下メソッドを定義してます。
mruby | 説明 |
---|---|
require 'path/to/file' |
res://path/to/file.rb を読み込みます。 |
puts 'something' |
Godot の 出力パネルに something を出力します。 |
Object#method_missing |
method_missing シグナルを発行します。(method_name: String, args: Array)
|
Godot.emit_signal(key, payload) |
channel シグナルを発行します。(key: StringName, payload: Variant)
|
Godot::VERSION |
Godot のバージョンを返します。(例: v4.4.1.stable) |
型変換
method_missing
, channel
シグナルが発行されて gdscript 側で受け取る際は、以下のように型が変換されて渡ってきます。
mruby | GDScript | |
---|---|---|
true | ⇒ | true |
false | ⇒ | false |
nil | ⇒ | null |
Float | ⇒ | float |
Integer | ⇒ | int |
Symbol | ⇒ | StringName |
String | ⇒ | String |
Hash | ⇒ | Dictionary |
Array | ⇒ | Array |
Range | ⇒ | Array |
Time | ⇒ | Dictionary |
(others) | ⇒ | String (#inspect メソッドで変換) |
詳細はテストコードを参照ください: demo/test/gdextension/test_variant.gd
導入方法
- リリースページ から、addons.tar.gz をダウンロードします。
- 展開して
redscribe
フォルダを(Godotプロジェクト)/addons/redscribe
ディレクトリに置きます。 - プロジェクト設定から ReDScribe を有効にします。
スクリーンショット
エディタ
Godot 上で Ruby ファイルを編集できるようエディタをプラグインとして同梱しています。
(以前に「ゲームエンジンGodotでテキストエディタを作る」で作ったエディタを再利用しました。)
ファイルシステム
また、ファイルシステム上で rb ファイルの表示/作成ができます。作成するには右クリック→「新規作成」から選択します。
REPL
REPL も同梱しています。ちょっとした確認はここでできます。
利用例1 (Live coding)
きっかけで書いたやりたかったことの1つ、GUI ツールの基礎部分のイメージで作りました。
以下、作り方です。
1. ノードの作成
以下のようにシーンを作成します。
Control
└ HBoxContainer
├ ReDScribeEditor
└ RichTextLabel
ReDScribeEditor は、Ruby を編集するためにアドオン用に作成したノードですが、一応通常のシーンでも利用できるよう意識して作っています。
2. gdscript をアタッチ
Control
ノードに以下の gdscript をアタッチします。
extends Control
@onready var dsl := ReDScribe.new()
func _ready() -> void:
dsl.method_missing.connect(_method_missing)
%ReDScribeEditor.grab_focus()
perform()
func perform() -> void:
%RichTextLabel.text = ''
dsl.perform(%ReDScribeEditor.text)
func add_circle() -> void:
%RichTextLabel.text += '◯'
func add_square() -> void:
%RichTextLabel.text += '■'
func _method_missing(method_name: String, args: Array) -> void:
match method_name:
'circle': add_circle()
'square': add_square()
_: return
3. ReDScribeEdtior の text_changed シグナルを紐づける
text_changed が発火するたびに perform
を実行するようにします。
func _on_re_d_scribe_editor_text_changed() -> void:
perform()
これで完成です。
Ruby を知らなくても method_missing
でシグナルを拾えばよいだけなので、いろいろ楽しいことができると思います。
addons/redscribe/mrblib について
method_missing だけではできることが限られていますが、もっといろいろやりたい場合は Godot.emit_signal
を実行して channel シグナルを発行することで対応します。
addons/redscribe/mrblib フォルダに、Godot.emit_signal
を裏で実行するDSL用のファイルをいくつか用意しているので、そのままrequire
して使ったり、コピペして自分用に書き換えてもらえたらと思います。
resource
リソースファイルや設定ファイルなどを作成するためのDSLです。
require 'addons/redscribe/mrblib/resource'
resource :stage do
resource :image
resources :chapter => :chapters
end
stage 'First' do
number 1
music 'first_stage.mp3'
image do
path 'first_stage.png'
end
chapter do
name 'Chapter1'
image 'path/to/chapter1.png'
end
chapter do
name 'Chapter2'
image 'path/to/chapter2.png'
end
end
を実行すると、channel
シグナルの key が stage、payload が以下の Dictionary を返します。
{
&"number": 1,
&"music": "first_stage.mp3",
&"name": "First",
&"image": {
&"path": "first_stage.png",
&"name": "image_6308476176"
},
&"chapters": [
{
&"image": "path/to/chapter1.png",
&"name": "Chapter1"
},
{
&"image": "path/to/chapter2.png",
&"name": "Chapter2"
}
]
}
coroutine
コルーチン用のDSLです。Fiber を使ってます。
___?
は、単純に Fiber.yield
を実行して処理を中断します。
require 'addons/redscribe/mrblib/coroutine'
coroutine do
loop do
emit! :given, ___?
end
end
# `start` # `___?` が呼ばれると処理を中断して再開するまで待ち状態になります。
# `continue` => channel シグナル発行。key: &"given", payload: true
# `continue 123` => channel シグナル発行。key: &"given", payload: 123
coroutine 'XXX'
と書くとコルーチンに名前をつけて実行します。
前回実行したコルーチンは continue
で再開、名前を指定して再開したいときは resume
を呼びます。
require 'addons/redscribe/mrblib/coroutine'
coroutine 'Foo' do
emit! :foo, :started
while ___?
emit! :foo, :progress
end
emit! :foo, :finished
end
coroutine 'Bar' do
emit! :bar, :started
while ___?
emit! :bar, :progress
end
emit! :bar, :finished
end
# `start :all` # :all でコルーチンすべて実行。※引数なしの場合は無名のコルーチンのみ実行
# => channel シグナル発行。key: &"foo", payload: &"started"
# => channel シグナル発行。key: &"bar", payload: &"started"
# `resume 'Foo'` => channel シグナル発行。key: &"foo", payload: &"progress"
# `resume 'Bar', true` => channel シグナル発行。key: &"bar", payload: &"progress"
# `resume 'Foo', false` => channel シグナル発行。key: &"foo", payload: &"finished"
# `resume 'Bar', false` => channel シグナル発行。key: &"bar", payload: &"finished"
例えば会話ダイアログが作れます。
___?
で制御構文の中で穴埋めっぽく自然に書けるのが気に入っています。
module Helper
def says(str)
emit! :says, [name, str]
end
def asks(str, choices = { true => 'Yes', false => 'No' })
emit! :asks, [name, str, choices]
end
end
Coroutine.include Helper
coroutine 'アリス' do
asks "Ruby 好き?"
if ___?
says "私も好き!"
else
says "そっか。。。"
end
end
その他
他にもいくつか作っています。詳細は doc/addons/mrblib.md を参照ください。
利用例2 (リソースファイルの生成)
これもきっかけで書いたやりたかったことの1つ、リソースファイル(xxx.tscn)の生成を DSL で行ないます。
以下、作り方です。
1. リソースクラスの定義
Dictionary 型を渡してプロパティを設定できるよう、Resource クラスを継承した BaseResource クラスを作成します。
extends Resource
class_name BaseResource
static func build(klass, attributes: Dictionary) -> Resource:
var res = klass.new()
res.update(attributes)
return res
func assign(key: StringName, value: Variant) -> void:
set(key, value)
func update(attributes: Dictionary) -> Resource:
for prop in get_property_list():
match prop.class_name:
&'Image':
if attributes[prop.name].has('path'):
var image = Image.new()
image.load(attributes[prop.name].path)
set(prop.name, image)
_: if attributes.has(prop.name):
assign(prop.name, attributes[prop.name])
return self
BaseResource を継承したリソースクラスを定義します。
ここでは、Chapter
と Stage
を定義します。
extends BaseResource
class_name Chapter
@export var name : String
@export var number : int
@export var music : String
@export var image : Image
@export var stages : Array[Stage]
func assign(key: StringName, value: Variant) -> void:
match key:
'stages':
for v in value:
stages.push_back(build(Stage, v))
_: super(key, value)
extends BaseResource
class_name Stage
@export var name : String
@export var image : Image
2. DSLの作成
require 'addons/redscribe/mrblib/resource'
resource :chapter do
resource :image
resources :stage => :stages do
resource :image
end
end
require 'path/to/schema'
chapter 'First' do
number 1
music 'first_chapter.mp3'
image do
path 'assets/images/icon.svg'
end
(1..3).each do |i|
stage do
name "Stage#{i}"
image do
path "assets/images/stage_#{i}.svg"
end
end
end
end
3. 実行する
extends ReDScribe
class_name Generator
const DSL = "res://path/to/resource.rb"
const DIST = "res://path/to/dist/chapter.tres"
func _init() -> void:
channel.connect(_handle)
func run() -> void:
perform(FileAccess.open(DSL, FileAccess.READ).get_as_text())
func build(klass, attributes: Dictionary) -> Resource:
var res = klass.new()
res.update(attributes)
return res
func _handle(key: StringName, payload: Variant) -> void:
match key:
&'chapter':
var chapter = build(Chapter, payload)
ResourceSaver.save(chapter, DIST)
_: print_debug('[ %s ] signal emitted: %s' % [key, payload])
上記のように定義して run
メソッドを呼ぶと path/to/dist/chapter.tres
にリソースを保存します。
var generator = Generator.new()
generator.run()
その他の例について
doc/examples を参照ください。いまは、上記を含めて以下4つを作成しています。
- 1_live_coding.md
- 2_resource_generator.md
- 3_actor.md
- 4_coroutine.md
できないこと
ビルドについて
現在(2025/6/30時点)、Windows と macOS のみ対応しています。
それ以外の OS (Linux や Android)向けにはいまはビルドしていないので実行できません。今後対応予定です。
手元にあるマシンでビルドしているため、今後は github actions でビルドしたい(※やったことないのでできるようになるまで時間がかかるかもしれません)
それと、Apple の署名と公証を今回初めて行なったので、何かそのへんで問題があるかもしれません。これも github actions でスマートに行ないたい。
CRuby ではできるけど、mruby ではできないこと
Date クラスや Regexp クラスは無いです。また、sleep
や system
メソッドも無いです。
思いつく範囲はテストコードにまとめました。
ちょっと意外だったのが、File クラスは使えました。
また、system
は無かったですが、バッククォートでの実行は private メソッドになっているだけで send
で無理やり呼べたので、あるなら使いやすいようにするかと addons/redscribe/mrblib/shell.rb を作ってみました。
require 'addons/redscribe/mrblib/shell'
cd 'addons' do
sh 'ls -lA'
end
学んだことについて
以下にまとめました。
今後について
当面は、github actions でビルドして Linux や Android, iOS にも対応したいのと、
Regexp クラスを Godot の正規表現クラスを呼ぶようにして動くようにしたいかなと思っています。
あと、C++ 初心者が Copilot に聞きながら四苦八苦しながら作ったので、src/redscribe.cpp を整理したいかなと。
何か要望や不具合あればコメントで教えて下さい。では。
Discussion