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 を使う方針で作成しています。
チュートリアル
(2025/7/19 追記)
チュートリアルを作成しています。Godot と 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