Godot Engine Advent Celendar 2022 9日目
この記事はGodot Engine Advent Celendar 2022 9日目の記事となります。
今回はノベルゲームエンジンである「吉里吉里」のKAGスクリプトを自作するヒントについて書いていきます。
Godot Engineには「Dialogic」という強力なプラグインがあるので自作する必要はあまりないのですが、自分好みのスクリプトエンジン(ノベルゲームエンジン)を作って、会話イベントを作り込みたい、という方にオススメの記事です。
目次
今回実装する内容
今回実装する内容は、以下の機能となります。
- 会話テキストの表示
- 背景画像の表示
- キャラ画像の表示
プロジェクトファイルのダウンロード
今回は作成済みのプロジェクトを解説する記事となります。プロジェクトファイルは以下からダウンロードできます。
ノード構成
プロジェクトのノード構成は以下の通りとなります。
Main (Node2D)
+-- LayerImage (CanvasLayer)
| +-- base (TextureRect): 背景
| +-- image0 (TextureRect): 前景レイヤー0
|
+-- LayerTalkWindow (CanvasLayer)
+-- Window (ColorRect): 会話ウィンドウの背景
+-- Text (RichTextLabel): 会話テキスト
+-- Cursor (Sprite): テキスト送りカーソル
Main.gd の説明
今回はわかりやすさを考えて、スクリプトは Main.gd のみのシンプルな構成としました。その Main.gd ですが、大きく分けて以下の3つの処理をしています。
- 1. KAGTag / KAGAttr / KAGMsg / KAGLabel という KAGスクリプトの命令のクラスを定義している
- 2. _ready()で “script.txt” を読み込み、解析結果をKAGオブジェクトを “_cmd_list” にコマンドとして格納している
- 3. _process()で “_cmd_list” に格納したコマンドを順次実行している
これらについて1つずつ説明していきます。
KAGクラスの説明
KAGTagクラスは KAG でのタグをオブジェクト化したものです。例えばKAGにおいて、画像を表示する “image” タグは以下のように記述されます。
[image storage="bg001" layer=base]
そして、このタグは以下のように要素を構造化できます。
- タグ名: image
- 属性リスト:
- 属性1:
- 名前: storage
- 値: “bg001”
- 属性2:
- 名前: layer
- 値: base
- 属性1:
KAGTagは上記の要素のトップの部分の「タグ名」「属性リスト」を持ったクラスとなります。そしてタグの「属性」は KAGAttr となります。
KAGAttrはKAGタグの属性情報で、キーとなる「名前」と「値」を持ちます。
KAGMsgはKAGで会話テキストを表示するためのコマンドです。例えば以下のような書式で表現されます。
背景を表示[l][r]
KAGではタグの始まりである “[” が行頭に存在しない場合は会話テキストとしているようなので、上記のように記述されると会話テキストとしています。
今回の実装では、会話テキストには以下のタグを指定できるようにしています。
- [l]: クリック街
- [r]: 改行
- [p]: 改ページ
他にも仕様があるのかもしれないのですが、今回ではこの3つを実装しました。
KAGLabelはラベルの定義で、ラベルジャンプの対象となる名前を定義します。
*start
ラベルは上記のように、行頭を「*」で開始するとラベル名となります。ただ今回ラベルについて解析処理は入れたものの、ジャンプは未実装となります。
スクリプトの解析
スクリプトの解析は _parse() で行っています。
- 1. スクリプトを1行ずつ読み取る
- 2. _parse_tag() でその行がタグであれば KAGTag を返す
- 3. _parse_label() でその行がラベルであれば KAGLabel を返す
- 4. _parse_msg() でその行が会話テキストであれば KAGMsg を返す
- 5. スクリプトをすべて読み込み終わるまで 1〜4 を繰り返す
タグの解析の関数は以下のコードとなっています。
## タグ(+属性)の解析.
func _parse_tag(txt:String) -> KAGTag:
var regex = RegEx.new()
regex.compile("^\\[(?<tag>[a-z]+)[ ]?(?<attrs>.+)*\\]")
var result = regex.search(txt)
if result == null:
# コマンド行かどうか調べる.
regex.compile("^@(?<tag>[a-z]+)[ ]?(?<attrs>.+)*")
result = regex.search(txt)
if result == null:
# コマンド行でもない.
return null
# タグ名を取得.
var name = result.get_string("tag")
# 属性を取得する.
var attrs = {}
var attrs_result = result.get_string("attrs")
タグは [タグ名 属性1=値 属性2=値 ...]
という書式で記述されます。
そのため、以下の正規表現で対応する文字にマッチングしています。
regex.compile("^\\[(?<tag>[a-z]+)[ ]?(?<attrs>.+)*\\]")
ラベルの解析は以下のようにしています。
## ラベルの解析.
func _parse_label(txt:String) -> KAGLabel:
var regex = RegEx.new()
regex.compile("^\\*(?<label>[\\D][\\w]*)[|]?(?<comment>.+)*")
...
ラベルの場合は行頭に *
(アスタリスク) がある前提なので、このようなマッチングをしています。
会話テキストはそれぞれのタグが文字列に含まれるかどうか、という単純なマッチングとしています。
## メッセージの解析.
func _parse_msg(txt:String) -> KAGMsg:
var msg = txt
var is_click = txt.find("[l]") >= 0 # クリック待ちするかどうか.
var is_ctrl = txt.find("[r]") >= 0 # 改行するかどうか.
var is_pf = txt.find("[p]") >= 0 # 改ページするかどうか.
KAGの文法を完全に把握していないので、ひょっとしたらこの判定には抜けがあるのかもしれませんが、ひとまずこのようにしました。
コマンドの実行
_process() で _state(状態) に対応する処理を行っています。_state が保持する状態は現状、以下の4つです。
# 状態.
enum eState {
EXEC_SCRIPT, # スクリプト実行中.
MESSAGE_WAIT, # メッセージ待ち.
TIME_WAIT, # 一定時間待つ.
END, # 終了.
}
_exec_script()では _cmd_list の値を先頭から順番に実行していきます。
# スクリプト実行.
func _exec_script() -> void:
var is_loop = true
while is_loop:
if _cmd_idx >= _cmd_list.size():
# スクリプト終了.
_state = eState.END
break # 処理を中断する.
# コマンド読み取り.
_cmd = _cmd_list[_cmd_idx]
_cmd_idx += 1 # コマンドを次に進める.
# コマンドの種類ごとに処理をする.
if _cmd is KAGTag:
var tag:KAGTag = _cmd
print("#TAG # ", tag)
var ret = _exec_tag(tag) # タグを実行する.
match ret:
eCmdRet.YIELD:
is_loop = false # 処理を中断する.
eCmdRet.CONTINUE:
pass # 処理を継続する.
elif _cmd is KAGMsg:
var msg:KAGMsg = _cmd
print("#MSG # ", msg)
# メッセージを表示.
_text.text += msg.msg
_state = eState.MESSAGE_WAIT
is_loop = false # 処理を中断する.
elif _cmd is KAGLabel:
var label:KAGLabel = _cmd
print("#LABEL# ", label)
# TODO: 未実装.
else:
push_error("不明なコマンド: " + str(_cmd))
コマンドの種類は “is” 演算子で比較し対応する処理を行います。例えば、コマンドが “KAGMsg” であればテキスト表示となるので、_state を eState.MESSAGE_WAIT に遷移しています(※ただKAGMsgが必ずしもクリック待ちになるとは限らないので、このあたり調整が必要となるかもしれません)。
ノベルゲームエンジンでは、このように会話テキストのクリック待ちといった「ユーザー入力を受け付ける」「一定時間停止する」といった何らかのインタラクションがある場合、スクリプトコマンドの実行中から別の状態への切り替えることが基本の実装方法となります。
このプログラムを拡張していく場合、タグの追加が必須となると思います。タグの実行は以下の記述となっています。
# タグを実行する.
func _exec_tag(tag:KAGTag) -> int:
# 関数名は "_" + タグ名.
# @note タグを追加する場合は "_[タグ名]" の関数を追加します.
var ret = eCmdRet.CONTINUE
var func_name = "_" + tag.name
if has_method(func_name):
# タグ関数呼び出し.
ret = call(func_name, tag)
else:
push_warning("未実装の関数: %s"%func_name)
# コマンド実行時の戻り値を返す.
return ret
GDScriptはクラス内の関数を「文字列指定」で呼び出すことができる call() が用意されています。
それを使って、タグ名に対して先頭に「_」をつけた関数を呼び出すようにして、各タグの処理を実行しています。例えば “image” タグは、”_image” を呼び出しています。
以下、image()の実装コードです。
# 画像タグの実行.
func _image(tag:KAGTag) -> int:
var layer = tag.get_attr_value("layer") # レイヤー名.
if layer != "base":
# baseでない場合は 前景レイヤー番号なので "image" を先頭につける.
layer = "image" + layer
# TextureRectを取得する.
var tex:TextureRect = get_node("LayerImage/" + layer)
# 表示モード.
var display = tag.get_attr_value("visible")
if display == "false":
# 非表示の場合はここで終了
tex.visible = false
return eCmdRet.CONTINUE # 続行する.
# それ以外は表示するとします
tex.visible = true
...(中略)
return eCmdRet.CONTINUE # 続行する.
細かい属性の処理は省略していますが、基本的にはタグに含まれる属性の値で TextureRectノードを取得して各パラメータを設定しています。例えば、imageタグには “layer” という属性があるので、それに対応する TextureRect を取得しています。
関数の戻り値は、スクリプトの実行を続行するかどうかです。imageタグは画像を表示・非表示するだけでインタラクションを必要としないので、”eCmdRet.CONTINUE” を返しています。
それに対して、waitタグの関数 “_wait()” は、待ち時間のインタラクションが発生するので “eCmdRet.YIELD” を返しています。
# 一定時間待つタグ.
func _wait(tag:KAGTag) -> int:
# 待ち時間.
var time = int(tag.get_attr_value("time"))
# msを秒に変換する.
time *= 0.001
# 一定時間待つ状態に遷移.
_state = eState.TIME_WAIT
_timer = time
return eCmdRet.YIELD # 待ち状態に入るので処理を中断する..
拡張の手引
今回は、背景と立ち絵、会話テキストといったシンプルな実装としましたが、より吉里吉里のKAGに近づけたい…という方のために簡単なヒントを提示していきます。
ラベルジャンプについて
今回は実装しませんでしたが、ラベルジャンプはあまり難しくないと思っています。ラベルの解析処理は実装しましたので、”_cmd_list” の先頭から対象となるラベルを探して “_cmd_idx” の値を書き換えれば実現できると思います。
変数の判定
問題になるのは条件式や変数の処理になると思います。ただ、Godot には文字列を式として評価する機能があるため、それを使うことで楽に実装できます。
【Godot】文字列を式として評価する方法具体的には以下のように記述すると、文字列から計算結果が求められます。
var expression = Expression.new()
# 評価したい文字列表現を渡す
expression.parse("(1+2) * 5")
# 式として評価する
var result = expression.execute()
# "15" と出力される
print(result)
変数を評価するなど、より詳しい Expression の使い方は公式ドキュメントに書かれています。