『霧走町 流氷に眠る』で一文を打ってから、返事が画面に出るまで。 中で何が起きているかを、一つの入力で最後までたどります。 読む側に実装する人を想定した、技術の回です。
先に言っておくと、このゲームはプレイ中に台詞を生成しません。 入力は、書いてある台詞のどれを出すかを決める検索の言葉として使われます。 その選び方の順番を、ソースの位置と一緒に追っていきます。
状態は、ゲームを始めた直後。 宿で事件の概要を聞き、港へ移り、話す相手に佐伯を選んだところです。 そこで、こう打ちます。
昨日の夜はどこにいた
返ってくるのは、佐伯のこの台詞です。
「十五日の夜なら、10時まで番屋で飲んどった。それだけだ」
この一往復のあいだに、正規化、二系統の照合、有効な意図の絞り込み、採用の判断、段の解決、台本の再生、手帳への記録が順に走ります。
担当は、三つのヘッダに分かれています。 どれも純粋な C++ で、Unreal Engine に依存していません。 CLI の検査ツールと UE の本編が、同じファイルをコンパイルしています。
AdvMatcher は、スコアを出すだけ。
AdvRules は、いまどの意図が有効かと、どの段に着地するかだけ。
AdvSession は、進行を決めるだけ。
フラグを立てられるのは、このうちセッションだけです。
照合が進行に手を出さない、というのが一番大事な線でした。 スコアを出す側が勝手にフラグを立てると、プレイヤーが言っていないことで話が進みます。 それを構造で起こらないようにしてあります。
[[SS: 1ターンの流れの図。照合・規則・進行の三段 / 画像: まだ無い:1ターンの流れの図(照合→規則→進行の三段。投稿前に描く)]]
AdvSession::say から sayInner へ入ると、照合の前にいくつか確かめます。
話し相手が、まだその場にいるか。 場面の途中で人が入れ替わると、いない相手を指したままになるので、いなければ選び直します。
直前に聞き返しをしていないか。 していれば、この入力を「はい」か「違う」として先に読みます。
台本に書いた明示の受け答えに当たらないか。 これは類似度より先に見ますが、段の条件を飛ばすことはしません。 今回の入力は、どれにも当たらずに照合へ進みます。
NormalizeText が入力を揃えます。
全角英数を半角に、英字を小文字に、カタカナをひらがなに。
空白と約物と長音「ー」は落とします。
もう一つ、敬体を常体へ畳む処理が入っています。 「ませんでした」を「なかった」に、「ました」を「た」に。 入力にも言い換えの辞書にも同じ規則をかけるので、どちらで書いても当たります。 「見ませんでしたか」が「見た」に一文字も当たらない、という穴を辞書の側で一件ずつ埋めていた時期があり、照合の側へ移しました。
今回の入力は、ひらがなと漢字だけで記号もないので、ほぼそのままです。 この正規化の定義で基準のテストを凍結しているので、変えるときは全部の焼き直しになります。
正規化した文字列から、1文字・2文字・3文字の並びを取ります。 形態素解析はしません。 辞書もライブラリも要らず、誤字や助詞落ちに強い作りです。
2文字と3文字の並びは重み1、1文字は重み0.5で混ぜています。 2・3文字だけだと、活用が違うだけの語で一致がゼロになり、短文ほど不利になったためです。
これを TF-IDF で重み付けし、長さを揃えて、意図ごとの言い換えとのコサインを取ります。 佐伯の「事件の夜の居場所」という意図には、言い換えが二十本あります。 その中に「昨日の夜はどこにいた」がそのまま入っているので、表層だけでもよく当たります。
文の埋め込みも引きます。 モデルは multilingual-e5-small の int8 量子化版で、384次元です。 言い換えの側は事前に焼いてあり、入力は焼いた表に無ければ実行時に計算します。
入力は、文全体に加えて、句読点で割った節ごとにも引きます。 前置きのついた長い文で、文全体だと前置きに引っ張られるためです。 今回は句読点が無いので、文全体の一本だけです。
生のコサインは、そのままでは混ぜません。 この系統のモデルは無関係な文でも高めの値を返すので、0.82を零点にして引き延ばします。 そのうえで、表層と合わせます。
合成値と表層単独の良いほうを採るのは、合成だけにすると表層で解けていた例まで落ちたからです。 ここまでが意図ごとの類似度で、言い換えの中の最大値を取ります。
類似度が高くても、いま答えられない意図は候補から外します。
IntentActive が見るのは、話し相手、意図の成立条件、章、時間帯、場所、その人物の在席です。
相手が決まっているときは、その人物の意図と、情景を調べる意図だけが残ります。 港の佐伯と話しているあいだ、番屋のハルの意図は候補に入りません。 ただし、外した意図は「他所でなら通るか」を見るために別に取っておきます。
在席まで見るのには理由があります。 場所だけで絞っていた頃、夜しか店にいない人物の話題が昼の店で通り、その場にいない人が答えていました。
候補ごとに、固有名詞の加点が乗ります。 意図が挙げている材料の別名が入力に含まれていれば、一つにつき0.15、最大二つまで。 今回の入力は「昨日の夜」と「どこにいた」を含み、どちらも「アリバイ」という材料の別名です。 材料は一つなので、加点は一つぶん。
雑談の意図には、本筋と競ったら本筋が勝つように小さな減点があります。 もう聞いた段には、加点ぶんを打ち消す減点が入ります。
並べ替えと、答えてよいかの判断は、別の量で見ています。 並べ替えは減点込み、採用の判断は減点を戻した値。 この値が、埋め込みを混ぜたときの採用線0.33を超えれば採用です。 届かなければ、聞き返し、他所への誘導、受け流しの順に落ちていきます。 今回は、採用されます。
意図が決まっても、どの台詞を出すかはまだ決まっていません。
一つの意図は、状況ごとに複数の段を持っています。
ResolveStage が、役割ごとの順位で段を選びます。
佐伯の「事件の夜の居場所」には、四つの段があります。 材料を挙げたときの最初の証言。 あとで話が崩れたときの、言い直した証言。 丁寧な言い方をされたときの段。 それから、何の夜かを言わずに聞かれたときの、問い返しの段です。
一番上の順位は、材料段です。 入力が「アリバイ」の別名を挙げているので、最初の証言の段が成立します。 言い直しの段は、まだ条件のフラグが立っていないので成立しません。 順位が上で決まったので、言い方による差し替えも起きません。
playStage が段を再生します。
記録上の種別は、材料を挙げて通った手を表す ACCEPT.SPECIFIC。
再生済みの段として saeki.alibi_night#0 を控えます。
二度目に同じ段へ来たら、台本ではなく短い言い直しを出すためです。
台本は scripts/saeki.json#alibi_s0 で、ここに先ほどの台詞が書いてあります。
続いて applySets が、段のフラグ f_saeki_alibi_10pm を立てます。
段に書いた一行が、手帳に載ります。
佐伯「十五日の夜は10時まで番屋で飲んでいた」
手帳の一行には、そのとき画面に映っていたものがそのまま控えられます。 話し相手、現在地、どの経路で書いたか、どの意図か、誰の口から出たか。 ゲームの見解は、ここには入れません。
say に戻ると、進まなかった手の数え直しと、ノベル場面の確認が走ります。
このフラグが立つと、港で佐伯と話しているときに読む短い場面の条件が揃います。
条件が揃った場面は、返事の後ろに続けて出ます。
最後に、この手の判定理由がログに一行で残ります。 候補の上位三つ、1位と2位の差、使った採用線、正規化後の入力。 同じ状態と同じ入力からは、必ず同じ出力になります。 乱数を使っていないからで、それが検査と保存の土台になっています。
この町が気になったら、Steamのウィッシュリストに入れておいてもらえるとうれしいです。
[[STEAM_LINK: https://store.steampowered.com/app/5287530/?utm_source=gamewith&utm_medium=devlog&utm_campaign=gamewith04&utm_content=06]]
フォローすると、開発ログや新作ゲームの公開が通知されます