【Godot】吉里吉里のKAGっぽいスクリプトを自作するヒント

この記事は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): テキスト送りカーソル

 

📌ノード構成の補足

前景レイヤーは "0番" のみとしています。たくさんの画像(キャラ)を表示したい場合には、"image0" を複製して連番でノードを作る必要があります。

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クラス (タグ) について

KAGTagクラスは KAG でのタグをオブジェクト化したものです。例えばKAGにおいて、画像を表示する "image" タグは以下のように記述されます。

[image storage="bg001" layer=base]

そして、このタグは以下のように要素を構造化できます。

  • タグ名: image
  • 属性リスト:
    • 属性1:
      • 名前: storage
      • : "bg001"
    • 属性2:
      • 名前: layer
      • : base

KAGTagは上記の要素のトップの部分の「タグ名」「属性リスト」を持ったクラスとなります。そしてタグの「属性」は KAGAttr となります。

KAGAttr (タグの属性) について

KAGAttrはKAGタグの属性情報で、キーとなる「名前」と「値」を持ちます。

KAGMsg (会話テキスト) について

KAGMsgはKAGで会話テキストを表示するためのコマンドです。例えば以下のような書式で表現されます。

背景を表示[l][r]

KAGではタグの始まりである "[" が行頭に存在しない場合は会話テキストとしているようなので、上記のように記述されると会話テキストとしています。

今回の実装では、会話テキストには以下のタグを指定できるようにしています。

  • [l]: クリック街
  • [r]: 改行
  • [p]: 改ページ

他にも仕様があるのかもしれないのですが、今回ではこの3つを実装しました。

KAGLabelクラス

KAGLabelはラベルの定義で、ラベルジャンプの対象となる名前を定義します。

*start

ラベルは上記のように、行頭を「*」で開始するとラベル名となります。ただ今回ラベルについて解析処理は入れたものの、ジャンプは未実装となります。

スクリプトの解析

スクリプトの解析は _parse() で行っています。

  • 1. スクリプトを1行ずつ読み取る
  • 2. _parse_tag() でその行がタグであれば KAGTag を返す
  • 3. _parse_label() でその行がラベルであれば KAGLabel を返す
  • 4. _parse_msg() でその行が会話テキストであれば KAGMsg を返す
  • 5. スクリプトをすべて読み込み終わるまで 1〜4 を繰り返す

_parse_tag(): タグの解析

タグの解析の関数は以下のコードとなっています。

## タグ(+属性)の解析.
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>.+)*\\]")
📌正規表現について

正規表現とは、文字のパターンマッチングを特殊な文字で検索できるようにした記法です。

例えば ^ は「行の始まり」を意味するメタ文字です。また [ から ] の部分は文字の集まりを意味します。

Godot Engine での正規表現の特徴的な部分としては、 Rubyというスクリプト言語で採用されている "Oniguruma" というライブラリを使用しているため、部分式呼出し ("田中哲スペシャル") という記述が使用できます。

今回のタグ名の抽出であれば、以下の記述となります。

?<tag>[a-z]+

[a-z]+ というのは、小文字のアルファベットの1文字以上繰り返しで、それに対して "tag" という名前で抽出できる記述です。

_parse_label(): ラベルの解析

ラベルの解析は以下のようにしています。

## ラベルの解析.
func _parse_label(txt:String) -> KAGLabel:
    var regex = RegEx.new()
    regex.compile("^\\*(?<label>[\\D][\\w]*)[|]?(?<comment>.+)*")
    ...

ラベルの場合は行頭に * (アスタリスク) がある前提なので、このようなマッチングをしています。

_parse_msg(): 会話テキストの解析

会話テキストはそれぞれのタグが文字列に含まれるかどうか、という単純なマッチングとしています。

## メッセージの解析.
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(): スクリプトの実行

_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が必ずしもクリック待ちになるとは限らないので、このあたり調整が必要となるかもしれません)。

ノベルゲームエンジンでは、このように会話テキストのクリック待ちといった「ユーザー入力を受け付ける」「一定時間停止する」といった何らかのインタラクションがある場合、スクリプトコマンドの実行中から別の状態への切り替えることが基本の実装方法となります。

_exec_tag(): タグの実行

このプログラムを拡張していく場合、タグの追加が必須となると思います。タグの実行は以下の記述となっています。

# タグを実行する.
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 には文字列を式として評価する機能があるため、それを使うことで楽に実装できます。

具体的には以下のように記述すると、文字列から計算結果が求められます。

var expression = Expression.new()
# 評価したい文字列表現を渡す
expression.parse("(1+2) * 5")
# 式として評価する
var result = expression.execute()

# "15" と出力される
print(result)

変数を評価するなど、より詳しい Expression の使い方は公式ドキュメントに書かれています。

参考