メインコンテンツまでスキップ

Example 2: 会話ダイアログを作る

以下のような登場人物による会話シーンを作成します。少しずつ DSL の構文を追加しながら作成していく過程を一緒に体験してみましょう。

シーンを作成する

Dialogue という名前で Conrol ノードのシーンを作成して、以下のようなツリーになるよう子ノードを追加します。

Content, Button は固有名でアクセスできるようにします。

ノードツリー
Dialogue <Control ノード>
├ ColorRect
├ Content <RichTextLabel ノード>
└ Button

ColorRect がダイアログの枠になるようサイズを調整して、Content を配置します。そして、枠の下に Button を置きます。

シーンを作成する

文字が小さい場合は、インスペクタから Font Size を調整してみてください。

DSL を考える

作成したシーンの Content に会話内容を表示する DSL を考えてみます。 なるべくプレーンテキストのような読み書きしやすさを備えているのが望ましいです。

読み書きしやすさを求める

声に出して読める文章を書きたいので、(登場人物) says "(発言)" のような形で書けるようにしたいと思います。

どのように定義するのがよいでしょうか。

メソッドで定義する場合

まずは、シンプルにメソッドのキーワード引数として定義する場合を考えてみます。

Alice says: "I love Ruby."

とてもシンプルで、1行で書ける簡潔さと美しさがあります。

しかし、この方法で書き続けると、登場人物の名前の長さによって says の位置が揃わず少し読みづらさがあります。 また、同じ人物が続けて発言する場合に冗長さを感じます。

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行が気になります。

今回作りたいのは、登場人物による発言を上から流れるように読み書きできる DSL なので、余計な行は省略したいです。

const_missing を使う場合

上記のコードから end を削除して動かせないでしょうか。少し考えましたが、以下の形で動かせそうです。

Alice;
says "I love Ruby."

Benjamin;
says "I love Ruby too."
says "I also love Godot."

登場人物を、メソッドではなく定数として判断させて const_missing によるメタプログラミングを使えば、余分な end の一行を省けそうです。

DSL を実行するためのヘルパーを作成する

上記の DSL を動かすためのコードを書いてみます。helper.rb ファイルとして作成したシーンと同じディレクトリに保存しましょう。

ちょっといろんなメタプログラミングを使ってしまいましたが、すべては理想の DSL を動かすためです。

helper.rb
# = Redirects method calls to Xxx.current
#
# `delegate Foo, :bar` means calling `bar` will forward to `Foo.current.bar`
#
def delegate(const, *keys)
keys.each do |key|
define_method(key) do |*args|
const.current.send(key, *args)
end
end
end


class Dialogue
class << self
attr_accessor :current
end

attr_accessor :fiber

def initialize(&block)
self.fiber = Fiber.new do
yield
Godot.emit_signal :finished, true
end
Dialogue.current = self
end

def continue(val = nil)
fiber.resume(val)
end

def listen!
Fiber.yield
end
end
delegate Dialogue, :continue #


class Speaker
class << self
attr_accessor :all, :current
end
self.all = []

attr_accessor :name

def initialize(name)
self.name = name
Speaker.all << self
end

def says(str)
emit :says, { content: str }
Dialogue.current.listen!
end

private
def emit(key, args = {})
Godot.emit_signal key, { name: name, **args }
end
end
delegate Speaker, :says #


# = 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


def speakers(names)
names.each{|name| Speaker.new(name) }
end

ファイルを保存したら、REPL で実行してみましょう。

ヒント

require 'path/to/helper' には、先ほど作成したファイルのパスを指定する必要がありますが、書くのが面倒な場合はファイルシステム上の Ruby ファイルを REPL の入力欄にドラッグ・アンド・ドロップしましょう。

require 'path/to/helper' が自動挿入されたと思います。この動作は、REPL だけではなく、エディタでも利用できます。

require 'path/to/helper'

Dialogue.new do
speakers %w(Narrator WhiteRabbit Alice)

Narrator;
says "One sunny afternoon,"
says "Alice met the White Rabbit"

Alice;
says "Hi! I'm Alice. I'm curious!"

WhiteRabbit;
says "Hi, I'm late. I'm busy."
end
Output
=> "#<Dialogue:0x177babf47d0 @fiber=#<Fiber:0x177babf46e0 (created)>>"

エラーなく Fiber が作成できました。

続けて、以下を実行します。

continue
Output
[ says ] signal emitted: { &"name": "Narrator", &"content": "One sunny afternoon," }
=> <null>

says をキーにしたシグナルが発行されました。 name に対する content も正しく設定されています。うまく動いてそうです。

さらに続けて continue を4回実行します。

Output
[ says ] signal emitted: { &"name": "Narrator", &"content": "Alice met the White Rabbit" }
=> <null>

[ says ] signal emitted: { &"name": "Alice", &"content": "Hi! I\'m Alice. I\'m curious!" }
=> <null>

[ says ] signal emitted: { &"name": "WhiteRabbit", &"content": "Hi, I\'m late. I\'m busy." }
=> <null>

[ finished ] signal emitted: true
=> true

それぞれ name に対して期待した content が設定されています。

最後は、すべてが終了したことを検知するための finished をキーにしたシグナルが発行されたことを確認できました。

では、終了したのに続けて、continue を呼ぶとどうなるでしょうか? 答えはエラーになります。

Output
Error: resuming dead fiber (FiberError)

Fiber 自身は終了したかどうかを状態として持っています。

Dialogue.current.fiber

Dialogue.current.fiber.alive?
Output
=> "#<Fiber:0x177babf46e0 (terminated)>"

=> false

Fiber を使う際は、上記を使ってうまくハンドリングするか、今回の例のように Fiber.new do ... end の最後にシグナルを発行するようにして、エラーを回避しましょう。

GDScript をアタッチする

DSL の文法と、そこから発行されるシグナルの形は決まったので、Dialogue シーンの処理を書いてみましょう。

以下のような GDScript をシーンにアタッチします。

dialogue.gd
extends Control

@export var controller : ReDScribe


func _ready() -> void:
controller.channel.connect(_handle)
%Button.pressed.connect(continue_dialogue)
clear()


func speak(speaker: String, content: String) -> void:
%Content.text = "(%s)\n%s" % [speaker, content]


func continue_dialogue() -> void:
controller.perform('continue')


func clear(all: bool = false) -> void:
%Content.text = ''
if all: %Button.hide()


func _handle(key: StringName, payload: Variant) -> void:
match key:
&'says': speak(payload['name'], payload['content'])
&'finished': clear(true)
_: print_debug('[%s] %s', [key, payload])

シナリオを作成する(Ver. 1)

準備は整いました。会話内容を書いて scenario.rb として保存してみましょう。

これがプログラミング言語で書かれているとは思わないかもしれませんが、これが動くのが Ruby の柔軟性です。

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

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

WhiteRabbit;
says "I'm late, I'm late, I'm late!"

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

WhiteRabbit;
says "I'm late, I'm late, for a very important date!"

Alice;
says "Wait!"
end

インスペクタの Controller プロパティを押下して、新規 ReDScribe を選択します。

新規 ReDScribe

Boot File に作成した scenario.rb を設定して、シーンを実行します。

以下の動画のように会話ダイアログが表示されましたか?

難しかった場合は、今回作成したデモプロジェクトは下記の GitHub リポジトリに置いているので、確認してみてください。 https://github.com/tkmfujise/redscribe-docs-demo/tree/main/src/08.dialogue_basic

キャラクターの表情を制御する

会話ダイアログの基礎部分は作成できました。次は、会話ダイアログに顔グラフィックを表示しようと思います。

Speaker シーンを作成する

顔グラフィック用のシーンを新たに Speaker という名前で作成します。

Sprite2D ノードのシーンを作成して、通常の表情と、慌てた表情が描かれた 2x2 のタイル状の画像をセットします。 (サンプルと同じ画像を使いたい場合は ここからダウンロード できます。)

HFramesVFrames には、"2" を設定します。

Frame Coords を操作することで、人物と表情を制御する仕組みにしようと思います。

Speaker シーン

speaker.gd
extends Sprite2D
class_name Speaker

enum Name { Alice, WhiteRabbit }
enum Face { DEFAULT, FLUSTERED }

@export var speaker_name : Name : set = set_speaker
@export var face : Face : set = set_face


func set_speaker(val: int) -> void:
frame_coords.y = val
speaker_name = val as Name


func set_face(val: int) -> void:
frame_coords.x = val
face = val as Face

Dialogue シーンに追加する

Speaker シーンを Dialogue シーンに追加して、枠内に配置します。

Speaker は固有名でアクセスできるようにしておきましょう。

Speaker シーンを追加する

GDScript を修正する前に、DSL をどのように変更するか考えます。

シナリオを修正する(Ver. 2)

DSL に表情を制御するための構文を追加します。

以下のように、顔グラフィックの表情を一時的に変えるメソッドと、永続的に変えるメソッドのそれぞれを定義することで対応しようと思います。

  • Someone; with :some_face では、次の人物が発言するまで、その表情にする。
  • Someone; got :some_face では、次の表情が設定されるまで、永続的に表情を変える。
scenario.rb
require 'path/to/helper'
Dialogue.new do
speakers %w(Narrator WhiteRabbit Alice)

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

WhiteRabbit; got :flustered
says "I'm late, I'm late, I'm late!"

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

WhiteRabbit;
says "I'm late, I'm late, for a very important date!"

Alice; with :flustered
says "Wait!"
end

withgot を呼べるようにして、シグナルに face を含むよう修正します。

helper.rb
class Speaker
class << self
attr_accessor :all, :current

def current=(speaker)
current.temporary_face = nil if current
@current = speaker
end
end
self.all = []

attr_accessor :name, :temporary_face, :permanent_face

def initialize(name)
self.name = name
self.permanent_face = ''
Speaker.all << self
end

def says(str)
emit :says, { content: str }
end

def with(face)
self.permanent_face = ''
self.temporary_face = face.to_s
end

def got(face)
self.permanent_face = face.to_s
end

def face
temporary_face || permanent_face
end

private
def emit(key, args = {})
Godot.emit_signal key, { name: name, face: face, **args }
end
end
delegate Speaker, :says, :with, :got #

GDScript を修正する

DSL の文法と、そこから発行されるシグナルの形は決まったので、Dialogue シーンの処理を修正しましょう。

speak メソッドで顔グラフィックを変更するようにします。

dialogue.gd
func speak(speaker: String, content: String, face: String) -> void:
%Content.text = "(%s)\n%s" % [speaker, content]
set_speaker(speaker)
set_face(face)


func set_speaker(speaker: String) -> void:
%Speaker.show()
match speaker:
'Alice': %Speaker.speaker_name = Speaker.Name.Alice
'WhiteRabbit': %Speaker.speaker_name = Speaker.Name.WhiteRabbit
_: %Speaker.hide()


func set_face(face: String) -> void:
match face:
'flustered': %Speaker.face = Speaker.Face.FLUSTERED
_: %Speaker.face = Speaker.Face.DEFAULT


func clear(all: bool = false) -> void:
%Speaker.hide()
%Content.text = ''
if all: %Buttons.hide()


func _handle(key: StringName, payload: Variant) -> void:
match key:
&'says':
speak(payload['name'], payload['content'], payload['face'])
&'finished': clear(true)
_: print_debug('[%s] %s', [key, payload])

実行してみましょう。顔グラフィックが切り替わったら成功です。

顔グラフィックを制御する

選択肢を表示する

会話の途中に選択肢を表示できるようにしてみましょう。

シナリオを修正する(Ver. 3)

DSL に選択肢を表示するための構文を追加しようと思います。

以下のように、選択肢を表示するメソッドと、選択された値を取得するメソッドを定義することで対応しようと思います。

  • asks "(発言)" では、発言を表示しつつ選択肢も表示するようにする。
  • ___? で、選択された値を取得できるようにする。
scenario.rb
require 'path/to/helper'
Dialogue.new do
speakers %w(Narrator WhiteRabbit Alice)

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

WhiteRabbit; got :flustered
says "I'm late, I'm late, I'm late!"

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

WhiteRabbit;
says "I'm late, I'm late, for a very important date!"

Alice; with :flustered
says "Wait!"

Narrator;
says "Alice chased after the rabbit."
says "But the rabbit disappeared into a burrow."

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

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

どうでしょう。感じ方は人それぞれでしょうが、saysasks という短い単語を使い分ける軽快さと、___? で穴埋め構文のように書けるのが私には気持ちよいです。

asks___? を呼べるようにして、シグナルに choices を含むよう修正します。

helper.rb
class Dialogue
class << self
attr_accessor :current
end

attr_accessor :fiber, :last_value

def initialize(&block)
self.fiber = Fiber.new do
yield
Godot.emit_signal :finished, true
end
Dialogue.current = self
end

def continue(val = nil)
fiber.resume(val)
end

def listen!
self.last_value = Fiber.yield
end

def ___?
last_value
end
end
delegate Dialogue, :continue, :___? #


class Speaker
class << self
attr_accessor :all, :current

def current=(speaker)
current.temporary_face = nil if current
@current = speaker
end
end
self.all = []

attr_accessor :name, :temporary_face, :permanent_face

def initialize(name)
self.name = name
self.permanent_face = ''
Speaker.all << self
end

def says(str)
communicate :says, str
end

def asks(str, choices = { 'Yes' => true, 'No' => false })
communicate :asks, str, choices
end

def with(face)
self.permanent_face = ''
self.temporary_face = face.to_s
end

def got(face)
self.permanent_face = face.to_s
end

def face
temporary_face || permanent_face
end

private
def emit(key, args = {})
Godot.emit_signal key, { name: name, face: face, **args }
end

def communicate(key, str, choices = {})
emit key, { content: str, choices: choices }
Dialogue.current.listen!
end
end
delegate Speaker, :says, :asks, :with, :got #

Dialogue シーンを修正する

元の Button ノードは ButtonTemplate にリネームして、非表示にします。

新たに Buttons という名前で HBoxContainer ノードを作成して、見た目の調整用に子ノードに Button を1つ追加しておきます。

ノードツリー
Dialogue <Control ノード>
├ ColorRect
├ Speaker <Speaker シーン>
├ Content <RichTextLabel ノード>
├ Buttons <HBoxContainer ノード>
│ └ Button
└ ButtonTemplate

Buttonテンプレートを作成する

GDScript を修正する

saysasks のシグナルが発行された際に、選択肢を自動生成するように修正します。

そのために、set_choices メソッドで ButtonTemplate を複製して選択肢をボタンとして生成できるようにします。

また、continue_dialogue.bind(value) とすることで、ボタンが押された際に Fiber に渡す値を予め設定するようにします。

dialogue.gd
func set_choices(choices: Dictionary = { 'Continue': null }) -> void:
for child in %Buttons.get_children(): child.queue_free()
for key in choices: add_choice(key, choices[key])


func add_choice(txt: String, value: Variant) -> void:
var btn = %ButtonTemplate.duplicate()
%Buttons.add_child(btn)
btn.text = txt
btn.pressed.connect(continue_dialogue.bind(value))
btn.show()


func continue_dialogue(value) -> void:
controller.perform('continue %s' % _value_for_rb(value))
if controller.exception:
printerr("controller: %s" % controller.exception)


func clear(all: bool = false) -> void:
%Speaker.hide()
%Content.text = ''
set_choices()
if all: %Buttons.hide()


func _value_for_rb(value: Variant) -> Variant:
match typeof(value):
TYPE_STRING_NAME: return ':%s' % value
TYPE_STRING: return '"%s"' % value
TYPE_NIL: return 'nil'
_: return value


func _handle(key: StringName, payload: Variant) -> void:
match key:
&'says':
speak(payload['name'], payload['content'], payload['face'])
set_choices()
&'asks':
speak(payload['name'], payload['content'], payload['face'])
set_choices(payload['choices'])
&'finished': clear(true)
_: print_debug('[%s] %s', [key, payload])

実行すると、選択肢が表示されるようになったと思います。

選択肢を表示する

背景画像やBGMを変更する

これまで書いてきた内容で、会話ダイアログで以下ができるようになりました。

  • 文字列を表示する
  • 顔グラフィックを変更する
  • 選択肢を表示する

上記に加えて、さらに背景画像やBGMを変更するなどの演出を付け足したい場合はどうしたらよいでしょうか。

シナリオを修正する(Ver. 4)

ここでは背景画像を変更するイメージで、場面転換するタイミングで scene :scene_name というメソッドで書けるようにしてみます。

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

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

scene :riverbank

WhiteRabbit; got :flustered
says "I'm late, I'm late, I'm late!"

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

WhiteRabbit;
says "I'm late, I'm late, for a very important date!"

Alice; with :flustered
says "Wait!"

Narrator;
says "Alice chased after the rabbit."
says "But the rabbit disappeared into a burrow."

scene :burrow

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

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

scene メソッドを実行すると、background シグナルを発行するように変更します。

helper.rb
class Dialogue
class << self
attr_accessor :current
end

attr_accessor :fiber, :last_value

def initialize(&block)
self.fiber = Fiber.new do
yield
Godot.emit_signal :finished, true
end
Dialogue.current = self
end

def continue(val = nil)
fiber.resume(val)
end

def listen!
self.last_value = Fiber.yield
end

def ___?
last_value
end

def scene(sym)
Godot.emit_signal :background, sym
end
end
delegate Dialogue, :continue, :___?, :scene #

GDScript を修正する

実装は省略しますが、_handle メソッドを修正すれば背景画像を変更できるでしょう。

dialogue.gd
func _handle(key: StringName, payload: Variant) -> void:
match key:
&'says':
speak(payload['name'], payload['content'], payload['face'])
set_choices()
&'asks':
speak(payload['name'], payload['content'], payload['face'])
set_choices(payload['choices'])
&'background': print_debug("TODO [background] %s" % payload)
&'finished': clear(true)
_: print_debug('[%s] %s', [key, payload])

DSL を洗練する

会話ダイアログに表示する内容が長くなるにつれて、 says, asks, scene という今回作成した DSL 特有のキーワードがだんだんとノイズのように思えてきた人も多いと思います。

最初は、 (登場人物) says "(発言)" という形で、声に出して読める文章を書けるのがよいかと思いましたが、大事なのは登場人物と発言を書きやすいことです。

どうやったらノイズを削減できるでしょうか。

シナリオを修正する(Ver. 5)

演算子オーバーロードを使うことで、says, asks, scene を書かなくても済むようにしようと思います。

helper.rb に以下を追加してみましょう。

helper.rb
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

これを使って書き直すと以下のようになります。

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

どうでしょう。記号を使うことで、ぎゅっと詰めて書いても読めるようになりました。 もう、Ruby の面影はほとんど残っていません。まるで、会話ダイアログシステムに特化したプレーンテキストのような見た目をしています。

人によっては、初めてこれを見ても記号それぞれが何を意味するか、どのような動きをするのかわからないかもしれません。

ですが、世にある第3者が作成した会話ダイアログのライブラリと比較すると、自分で文法を定義できるという極めて強いメリットがあります。覚えにくい記法を覚える必要はありません。制御構文や変数を使うための特殊な埋め込み用の記法もありません。気に入らなければ自分の手に馴染むように変えればよいのです。

パーサを書く必要もなく、自分の好きな文法を定義できます。 きっと、あなたの作りたいゲームに合わせて柔軟に Ruby は応えてくれるでしょう。

今回作成したデモは https://github.com/tkmfujise/redscribe-docs-demo/tree/main/src/08.dialogue_refined から確認できます。

うまく動かせなかった方は、上記を参考にしてみてください。