テストは全部通ったのに、台詞が壊れていた — 会話イベントを守る「条件・瞬間・言葉」

白物戦記の通信は、盤面の出来事に反応して流れます。発火の条件、表示直前の話者の確認、会話を閉じるクリックの遮断まで、テストで固めました。それでも「照射は下りません」のような、意味の取れない台詞が製品に載りかけました。会話を壊さないための三つの層を、実装とテストの書き方つきで紹介します。

盤面の出来事に、会話が反応する

白物戦記の戦闘中の通信。冷蔵庫のアオイが話している
門が開いた、護送車が傷ついた、指揮機が止まった。盤面で起きたことに、通信が反応します。

白物戦記は、冷蔵庫のアオイ、洗濯機のイリヤス、掃除機のリンが食材を護衛する戦術ゲームです。戦闘中は、盤面で起きたことに通信が反応します。門が開けば報告が入り、護送車が傷つけば声が上がり、敵の指揮機が止まれば誰かがそれを伝えます。

仕組みは単純です。手番が進むたびに、状態を見て条件を満たした会話を表示待ちの列へ積みます。

function stories() {
  if (state.gateOpen) dialogue('gate');                        // 門が開いた
  const truck = E.unit(state, 'rescue');
  if (truck && truck.hp.body <= truck.max.body * 0.5) dialogue('convoyDamaged');
  const boss = E.unit(state, bossId());
  if (boss && !E.active(boss)) dialogue('commanderDown');      // 指揮機が止まった
  // …ほかの出来事も同じ形で並ぶ
}

短い処理ですが、会話を壊す落とし穴はあちこちにあります。条件、瞬間、言葉の三つの層に分けて見ていきます。

一層目:条件 — ターン数ではなく状態に結ぶ

「門が開いた」という台詞は、何ターン目かではなく、門が実際に開いた状態に結び付けます。ターン数や操作の回数を条件にすると、プレイヤーが別の順番で進めたとき、まだ起きていないことを話してしまいます。

もう一つの約束は、同じ会話を二度出さないことです。条件の判定は何度も走るので、そのたびに積めば同じ台詞が終わらなくなります。白物戦記では、会話を積んだ時点で記録します。表示し終えた時点で記録すると、表示している間にも条件が成立し続け、同じ会話が重複して登録されてしまうからです。

function dialogue(key) {
  if (state.seenStories[key]) return;
  state.seenStories[key] = true;                   // 表示し終えた時点ではなく、積んだ時点で記録する
  radioQueue.push(...lines(key).filter(speakerAvailable));
}

二層目:瞬間 — 待っている間に、話者が倒れる

この層の落とし穴は、時間差です。会話を登録した時点には動いていた機体が、前の台詞を読んでいる間に大破することがあります。何もしなければ、大破したはずのイリヤスが、次の台詞で平然と話し始めてしまいます。

積むときに確かめたから安全、とは言えません。時間差のある処理では、実行する直前にもう一度確かめます。白物戦記では、次の台詞を出す直前に列をもう一度絞っています。

function speakerAvailable([portrait, , speaker]) {
  if (speaker) return true;                  // 回線越しの声は、盤上に機体を持たない
  const id = portrait === 3 ? bossId() : ['aoi', 'ilyas', 'rin'][portrait];
  return !id || E.active(E.unit(state, id));
}

function nextRadio() {
  radioQueue = radioQueue.filter(speakerAvailable);   // 表示する直前に、もう一度
  // …先頭の台詞を表示する
}

これをテストするのに、少し工夫しました。ゲームは一つのファイルの中で関数どうしが閉じているので、外から nextRadio だけを呼べません。そこでテストでは、ソースから必要な関数の範囲を切り出し、画面要素を模したテスト環境で動かしています。

const src = fs.readFileSync(require.resolve('../src/game.js'), 'utf8');
const part = src.slice(src.indexOf('  function speakerAvailable('), src.indexOf('  function dialogue('));
vm.runInContext(part, context);
// 1行目を表示 → イリヤスを大破させる → 次のクリックで、イリヤスの行が飛ばされることを確かめる

会話を閉じるクリックを、盤面へ伝えない

もう一つの落とし穴は、使い勝手を良くしたことで生まれました。「会話は、ボタンだけでなく画面のどこを押しても進むように」という要望です。これに応えると、会話の最後に押した場所が、そのまま盤面への操作として届く恐れが出てきます。押した場所に敵がいれば、会話を閉じた瞬間に攻撃の命令が出かねません。

対策は、盤面を包む要素でクリックをcapture段階で受けることです。イベントは外側の要素から内側へ向かって一度降りてくるので、capture段階の処理は、盤面の中のどの処理よりも先に動きます。そこで既定の動作を止め、ほかの処理への伝達も止めてから、会話を一行進めます。

field.addEventListener('click', e => {
  if (!cinematic || radio.hidden || paused) return;
  e.preventDefault();
  e.stopImmediatePropagation();   // 盤面の処理には届かせない
  nextRadio();
}, true);                         // true = capture段階で受ける

会話中は、盤面の要素に inert も付けています。キーボードの操作や読み上げでも、会話の裏の盤面へ触れないようにするためです。一方、画面上部の「戻る」や一時停止、音量のボタンは、押した意味が別にあるので会話中も働かせています。テストでは「1回のクリックで1行だけ進む」「最後のクリックでは戦闘の操作が起きない」「一時停止中や場面の見出しを出している間のクリックでは進まない」を確かめています。preventDefault と stopImmediatePropagation の役割の違いはMDNの解説が分かりやすいです。

三層目:言葉 — テストをすり抜けた台詞

ここまでで、条件も瞬間もテストで固まりました。テストは全部通っています。それでも、人が読んで止まった言葉がありました。

「当日鍵」「無主機」「発光器」「射区」、そして「照射は下りません」。どれも辞書に無い組み合わせや、名詞に付かない動詞です。意味を取ろうとすると、読む手が止まります。しかも一度出た言葉は、台詞を書き直すたびに次の版へ移っていきました。台詞の中身は機械の検査をすり抜けるので、テストが全部通ったまま製品に載ってしまうのです。「事務的で、誰が話しても同じに聞こえる」という指摘も受け、三人の口調と語尾も書き分け直しました。

そこで、物語の文章から不自然な表現の候補を探す検査を作りました。やることは三つです。

const COLLOCATION = [
  // 「下りる」は許可や指示に付く。照射そのものには付かない
  [/照射[はがも]?[^。」]{0,6}下り/, '「照射」に「下りる」は付かない'],
  // 資格は「成立する」もの。「立つ」は付かない
  [/資格[はがも]?[^。」]{0,6}立[つっち]/, '資格に「立つ」は付かない'],
];

限界もはっきりしています。文を単語に切り分ける解析はしていないので、語の切れ目を間違えることがあります。そのため、新しい語は「落とす」のではなく「挙げる」にとどめ、人が読んで判断します。機械に任せるのは、一度差し戻した言葉を二度と出さないことだけです。

もう一つ、テストで守っている言葉の約束があります。登場人物の台詞に「マス」「ターン」を入れないことです。盤面の升目や手番は遊ぶ人の言葉で、物語の中の人物は使いません。テストは第1話の通信を対象にしていて、いま全6話を調べても一つも含まれていません。こうした約束は、文章の良し悪しと違って、機械でも確かめられます。

会話を壊さないための三つの層

振り返ると、落とし穴は三つの層にありました。

層壊れ方守り方
条件まだ起きていないことを話す/同じ会話が重なる状態に結ぶ。積んだ時点で記録する
瞬間倒れた機体が話す/閉じるクリックが命令になる表示直前に確かめ直す。capture段階で入力を止める
言葉意味の取れない語が、テストを通ったまま載る差し戻した語を機械で止める。登場人物にゲームの操作用語を言わせない

条件と瞬間は、コードとテストで守れます。言葉は、最後は人が読むしかありません。ただ、人が一度下した判断を機械に覚えさせることはできます。そうすれば、人の目は新しい問題のほうへ使えます。

分岐や条件付きの文章を本格的に扱うなら、inkのWriting with inkが参考になります。白物戦記はinkを使っていませんが、文章・条件・既読の履歴を分けて考える設計の例として読めます。