📕

Godot で DSL を書きたかったので、Ruby (mruby) が動くアドオンを作った。

に公開

きっかけ

  • Sonic PiNetlogo みたいな、ユーザがスクリプトを書いて動かす GUI ツールを Godot で作りたかった。
  • Godot のリソースファイルを DSL で生成したかった。
  • Rubyist なので Ruby を触りたかった。

できたもの

ReDScribe という gdextension アドオンを作成しました。レッドスクライブと読みます。
Ruby(赤=レッド)で書く(Scribe)、よりよく記述する(re-describe)というニュアンスでつけました。
https://github.com/tkmfujise/ReDScribe

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 が発生するとシグナルを発行します。

組み込みメソッド

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

導入方法

  1. リリースページ から、addons.tar.gz をダウンロードします。
  2. 展開して redscribe フォルダを (Godotプロジェクト)/addons/redscribe ディレクトリに置きます。
  3. プロジェクト設定から ReDScribe を有効にします。

スクリーンショット

エディタ

Godot 上で Ruby ファイルを編集できるようエディタをプラグインとして同梱しています。
(以前に「ゲームエンジンGodotでテキストエディタを作る」で作ったエディタを再利用しました。)

Editor

ファイルシステム

また、ファイルシステム上で rb ファイルの表示/作成ができます。作成するには右クリック→「新規作成」から選択します。
File System

REPL

REPL も同梱しています。ちょっとした確認はここでできます。
REPL

利用例1 (Live coding)

きっかけで書いたやりたかったことの1つ、GUI ツールの基礎部分のイメージで作りました。
https://www.youtube.com/watch?v=FUZ-38F44i4

以下、作り方です。

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 で行ないます。
https://www.youtube.com/watch?v=NS4m7VBYJNk

以下、作り方です。

1. リソースクラスの定義

Dictionary 型を渡してプロパティを設定できるよう、Resource クラスを継承した BaseResource クラスを作成します。

base_resource.gd
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 を継承したリソースクラスを定義します。
ここでは、ChapterStage を定義します。

chapter.gd
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)
stage.gd
extends BaseResource
class_name Stage

@export var name : String
@export var image : Image

2. DSLの作成

schema.rb
require 'addons/redscribe/mrblib/resource'

resource :chapter do
  resource :image
  resources :stage => :stages do
    resource :image
  end
end
resource.rb
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. 実行する

generator.rb
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 クラスは無いです。また、sleepsystem メソッドも無いです。

思いつく範囲はテストコードにまとめました。

ちょっと意外だったのが、File クラスは使えました。
また、system は無かったですが、バッククォートでの実行は private メソッドになっているだけで send で無理やり呼べたので、あるなら使いやすいようにするかと addons/redscribe/mrblib/shell.rb を作ってみました。

require 'addons/redscribe/mrblib/shell'

cd 'addons' do
  sh 'ls -lA'
end

学んだことについて

以下にまとめました。
https://zenn.dev/tkmfujise/articles/0f02f927300d32

今後について

当面は、github actions でビルドして Linux や Android, iOS にも対応したいのと、
Regexp クラスを Godot の正規表現クラスを呼ぶようにして動くようにしたいかなと思っています。
あと、C++ 初心者が Copilot に聞きながら四苦八苦しながら作ったので、src/redscribe.cpp を整理したいかなと。

何か要望や不具合あればコメントで教えて下さい。では。

Discussion