💬

[Godot] 会話ダイアログを Ruby で作った オレオレ仕様の DSL で書く。

に公開

経緯

3ヶ月ぐらいかけて Godot で Ruby (mruby) が動くアドオンを作成 したのですが、せっかく作ったので、色んな人に届いたらいいなと思ってチュートリアルを作成しています。

https://tkmfujise.github.io/redscribe-docs/ja/docs/example2_dialogue

実装の詳しい手順は上記チュートリアルに書いたので、気になる方はそちらを参照してほしいのですが、ちょっと長い文章になってしまったので、こんなもの作ったよ! という簡単な紹介のためここに記します。

できたもの

https://youtu.be/5CELwG9aVQk

以下の Ruby コードで動いています。

scenario.rb
require 'path/to/helper'
Dialogue.new do
  speakers %w(Narrator WhiteRabbit Alice)

  Narrator;
  - "Alice is resting in the field."
  - "Suddenly, she hears a panicked voice from afar."

  ~ :riverbank

  WhiteRabbit; got :flustered
  - "I'm late, I'm late, I'm late!"
  Alice;
  - "Hi! Where are you going?"
  WhiteRabbit;
  - "I'm late, I'm late, for a very important date!"
  Alice; with :flustered
  - "Wait!"
  Narrator;
  - "Alice chased after the rabbit."
  - "But the rabbit disappeared into a burrow."

  ~ :burrow

  Alice;
  - "He went in here."
  ! "Should I go in too?"
  unless ___?
    - "It looks so narrow and grimy... I really shouldn't."
    until ___?
      - "But I just can't stop wondering."
      ! "Maybe I should go in after all?"
    end
  end
  - "Alright, here goes!"

  Narrator;
  - "As Alice entered the burrow, the ground gave way and she fell down."
end

どうでしょう?

requireDialogue.new do ... end というキーワードが無ければ Ruby だとわからない(もしくは、あっても気づかない)ぐらい、プレーンテキストのような見た目の DSL ができました。

余計な構文ノイズがなく、登場人物、発言、制御フローが書けて自分的には満足してます。

また、多言語対応しようと思ったら、scenario01.en.rbscenario01.ja.rb という形でファイルごと管理してよさそうなのもうまくできたと思います。

といっても、最初からこういうのが作りたいと思ったわけではなく、作りながら試行錯誤していった感じです。

どう動いているのか仕組みについて以下、解説します。

ReDScribe について

前提として、作成したアドオン(ReDScribe)を簡単に説明すると、以下のように Ruby を Godot 上で実行できるプラグインです。

Godot のシグナルを使って Ruby (mruby) で実行した内容を Godot にメッセージングします。

extends Node

@onready var res := ReDScribe.new()

func _ready() -> void:
    res.method_missing.connect(_method_missing)
    res.channel.connect(_subscribe)
    res.perform("""
        Alice says: "Hello Ruby! ❤️"
        puts "Welcome to the world of Ruby v#{RUBY_VERSION}, powered by #{RUBY_ENGINE} 💎"
        Godot.emit_signal :spawn, { name: 'Alice', 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] Alice: [{ &"says": "Hello Ruby! ❤️" }]
#   Welcome to the world of Ruby v3.4, powered by mruby 💎
#   [subscribe] spawn: { &"name": "Alice", &"job": "wizard", &"level": 1 }
#

Fiber について

業務で使う機会がなかったので、数ヶ月前まで存在を知らなかったのですが Ruby には Fiber というコルーチン用のクラスがあります。

Fiber を使えば、処理を途中で中断/再開できます。今回作った会話ダイアログでは登場人物の発言後に中断、「Continue」ボタンを押して再開する仕組みで動いています。

___? は、Fiber を再開した際の値を返すメソッドとして定義しています。

unless ___?
  - "It looks so narrow and grimy... I really shouldn't."
  until ___?
    - "But I just can't stop wondering."
    ! "Maybe I should go in after all?"
  end
end

穴埋めのような見た目になるので気に入っています。

const_missing について

Alice;
- "Hi! Where are you going?"

Alice は、未定義の定数として const_missing を使ったメタプログラミングで動かしています。

最初は

Alice says: "Hi! Where are you going?"

Alice do
  says "Hi! Where are you going?"
end

のような形の DSL にしようかと考えていたのですが、

前者は

Alice says: "I love Ruby."
Benjamin says: "I love Ruby too."
Benjamin says: "I also love Godot."

となったときに、says の位置が揃わないのと、同じ人物が続けて発言する際に人物名を毎回書かないといけいないのが冗長に感じて、

後者は

Alice do
  says "I love Ruby."
end

Benjamin do
  says "I love Ruby too."
  says "I also love Godot."
end

となったときに end の1行が邪魔だなと感じたので、

なんとかスッキリ書けるようにならないかなと思って調べたら const_missing というのがあることを知り、それを使って以下のように定義しました。

# = const_missing
#
#   `Alice;` sets `Speaker.current` to the Speaker instance named "Alice"
#
def Object.const_missing(name)
  speaker = Speaker.all.find{|s| s.name == name.to_s }
  if speaker
    Speaker.current = speaker
  else
    super
  end
end

method_missing は有名ですが、const_missing があって助かった。
(aliceアリス だと method_missing なんですが、チュートリアルを書きながら Alice が動かなくて定数だという当たり前に気づくのに時間かかりました。)

演算子オーバーロードについて

- 1

-1 ですね。という当たり前のことなんですが、Ruby だとメソッドで定義されているので、数字以外のクラスにも定義することができます。

以下のように定義して、

module SymbolExt
  def ~@
    scene self
  end
end
Symbol.prepend SymbolExt

module StringExt
  def -@
    says self
  end

  def !@
    asks self
  end
end
String.prepend StringExt

それぞれエイリアスを作成しました。

  • says "content" は、- "content"
  • asks "content" は、! "content"
  • scene :scene_name は、~ :scene_name

まとめ

以上がだいたいの仕組みです。

普段仕事では Rails を書いているんですが、いままで使ったことない(業務で使わない) Ruby 力を試されてる感じで、作るのはなかなか楽しかったです。

改めて Ruby の柔軟さとパワーを感じました。みんなも Ruby を書こう!

最後に、プラグインのリポジトリを貼っておきます。
https://github.com/tkmfujise/ReDScribe
この記事がおもしろかったら、どうぞいろいろ試してみてください。

Discussion