マガジン

ゲームオーバー後も操作が効く:状態の切り替え漏れ

遊びをつくる

執筆にClaudeを使用しています。

不具合の起きた順番を絵札で並べ、相手に伝えるための手紙を整える手元。
記事のテーマをもとに制作したAI生成イラスト。

「GAME OVER」の文字が出ているのに、矢印キーを押すと自機が動き、スコアまで増えていく。投稿前の確認でこうした挙動に気づく場面を考えてみます。画面は終了を表示していますが、ゲームの中身はまだ遊びが続いている状態です。 この症状は「ゲームオーバーの処理が足りない」というより、「画面の状態」と「入力を受け付けるかどうか」が別々に管理されていることから起こります。表示を切り替えた時点で、入力側にも「もう受け付けない」と伝わる設計になっているか。この記事では、その切り分け方と直し方を扱います。

この記事の目次
  1. 症状の正体:表示と入力は別々に動いている
  2. 状態が複数あるゲームの設計から読み取れること
  3. 診断手順:ゲームオーバー直後に確かめる入力
  4. 直し方:状態を一つにして、入力の入口で見る
  5. 見落としやすい例:複数の入口と、止まる/止まらないの境目
  6. 次の行動

症状の正体:表示と入力は別々に動いている

ブラウザゲームでは、画面を描く処理と、キーやタップを受ける処理は、たいてい別の場所に書かれます。描画は毎フレーム呼ばれる関数、入力は keydown や pointerdown などのイベントリスナー(操作が起きたときに呼ばれる関数)です。

ゲームオーバーの処理で gameOverVisible = true のように表示用の値だけを変えると、入力側のリスナーはそれを知りません。リスナーは登録されたままで、押されれば自機を動かし、得点を足します。これが「切り替え漏れ」の基本形です。

漏れが生まれやすい書き方は、次の3つです。

  • 状態が複数のフラグに散らばっている。 isPaused、isOver、isCleared が別々にあり、入力側が一部しか見ていません。
  • 入力ハンドラが状態を見ていない。 動かす処理を直接書き、条件分岐がありません。
  • 遅れて動く処理が残っている。 setTimeout や setInterval、アニメーション終了後のコールバックが、ゲームオーバー後に発火します。

三つ目が厄介です。入力を止めたつもりでも、終了の直前に仕掛けた「0.5秒後に得点を加算する」処理が、終了画面の裏で動いてしまいます。

状態が複数あるゲームの設計から読み取れること

ここでは、アソバースの公式作品の確認済みの仕様から、「状態の数」を読み取ってみます。内部の実装は確認できていないため、実装そのものではなく、仕様から必要になるはずの状態の分け方を考える材料にとどめます。

こだまの森は、光る葉の順番を覚えて4つの音色で返す記憶ゲームです。間違いは30点減で、3回で終了します。ほかに聴き直し(15点減)と一時停止があり、音なしでも遊べます。順番を見る側と返す側がある遊びなので、仕様の上では「見せている間」「返している間」「一時停止」「終了」で、受け付けてよい入力が違うはずです。実際にどこまで止まるかは、作品を開いて確かめるところです。

紙ひこうき郵便は、左右キー・ドラッグ・矢印ボタンの3系統で操縦できます。風船にぶつかると紙が傷み、3回で終了します。入力経路が3つあるので、終了時に止めるべき入口も3つあります。キーボードだけ止めてドラッグが生きていると、典型的な漏れになります。この作品がどう処理しているかも、実際に終了させて確かめる題材です。

時計じかけの台所には、時間制限なしの見習いと、1皿28秒・配膳6秒・ミス3回までの本番があります。同じ「材料を入れる」操作でも、モードによって終了条件が違います。終了判定を操作ごとに書き散らすと、モードが増えるたびに抜けが出ます。

かたちのあいだには時計の一時停止があり、「一手戻す」でも手数は減りません。小さな軌道は、一時停止から同じ位置で再開できます。「止める」状態から元に戻す状態があるゲームは、止めている間の入力と、再開した瞬間の入力を区別する必要があります。

潮目のオセロでは、ヒントと一手戻すがひとり対局のみです。同じ画面のボタンでも、状況によって無効になるものがあります。ボタンを隠すだけでなく、押されても何も起きない設計が要ります。

以上から言えるのは、遊びを複数の状態に分けるなら、入力を受け付けるかどうかも状態ごとに決める必要があるということです。

診断手順:ゲームオーバー直後に確かめる入力

次の操作を、ゲームオーバー表示が出た直後に順に試します。結果は自分のゲームで確かめてください。

  1. 普段の操作キーを連打する。 移動、ジャンプ、決定など、プレイ中に使うキーをすべて押します。自機、カーソル、得点のどれかが動けば漏れです。
  2. マウスやタップで、プレイ中の場所を操作する。 キーを止めても、ポインター操作が生きていることがあります。ドラッグもクリックとは別に試します。
  3. 画面上のボタンを押す。 ヒント、戻す、一時停止などです。終了後は消す、または無効にするのが自然ですが、どう扱うかは作者の判断です。
  4. 一時停止の操作を入れてから、再開する。 終了状態から一時停止に入り、再開するとプレイ中に戻ってしまう例があります。
  5. 終了の瞬間に押しっぱなしにしていたキーを離さずにいる。 「もう一度」の決定キーが連打で誤って押されないか確認します。
  6. 終了後、数秒待ってから状態を見る。 遅れて動くタイマーによる得点や体力の変化を拾えます。

記録は、何を押したか、何が変わったか(座標、得点、残り回数、画面の文言)を1行ずつ残します。同じ操作を2〜3回繰り返すと、タイミング次第で起きる漏れも見つけやすくなります。

結果は、次のように読みます。

  • 自機・カーソルの位置、疑う場所:入力ハンドラに状態の確認がない
  • 得点・回数、疑う場所:遅れて動く処理、または加算の入口が別にある
  • 画面表示がプレイ中に戻る、疑う場所:一時停止や再開の処理が、遷移元を確認していない
  • ボタンだけが反応、疑う場所:ボタン側のイベントが別登録になっている

直し方:状態を一つにして、入力の入口で見る

直し方の骨組みは単純です。状態を一つの値にまとめ、状態ごとに受け付ける入力を表にし、遷移は一つの関数に集めます。

次のコードは、単独で動く最小構成の例です。矢印キーで動き、X で被弾、P で一時停止、3回被弾でゲームオーバーになります。ゲームオーバー中は Enter だけが効きます。

html
<canvas id="c" width="320" height="120"></canvas>
<script>
const ctx = document.getElementById("c").getContext("2d");
let state = "playing"; // playing / paused / over
let x = 150, lives = 3, timer = null;

function setState(next) {
  state = next;
  if (next !== "playing") clearTimeout(timer);
  draw();
}
const handlers = {
  playing: {
    ArrowLeft() { x -= 10; },
    ArrowRight() { x += 10; },
    KeyP() { setState("paused"); },
    KeyX() {
      lives--;
      if (lives <= 0) { setState("over"); return; }
      timer = setTimeout(() => {
        if (state === "playing") { x = 150; draw(); }
      }, 500);
    }
  },
  paused: { KeyP() { setState("playing"); } },
  over: { Enter() { x = 150; lives = 3; setState("playing"); } }
};
addEventListener("keydown", e => {
  const h = handlers[state][e.code];
  if (!h) return;
  e.preventDefault();
  h();
  draw();
});
function draw() {
  ctx.clearRect(0, 0, 320, 120);
  ctx.fillRect(x, 60, 20, 20);
  ctx.fillText(state + " / lives: " + lives, 8, 16);
  if (state === "over") ctx.fillText("GAME OVER - Enter", 110, 50);
}
draw();
</script>

ポイントは3つあります。

  • 入力は入口で振り分ける。 handlers[state][e.code] が空なら、何も起きません。ゲームオーバー中に矢印キーを押しても、対応する関数がありません。「止める処理」を書くのではなく、「受けるものだけ書く」形です。
  • 遷移は setState に集める。 状態を変える場所が一つなので、終了時に止めたいもの(タイマー、音、ループ)をここにまとめて書けます。
  • 遅れて動く処理も状態を確認する。 setTimeout のコールバックの先頭で state === "playing" を見ています。clearTimeout と二重に守るのは、片方が漏れても止まるようにするためです。

見落としやすい例:複数の入口と、止まる/止まらないの境目

状態の表を作っても、抜けやすい場所があります。

入力経路が複数ある場合。 キー、ポインター、画面ボタンを別々に登録していると、表に載せたのはキーだけ、ということが起こります。操作を「左へ」「決定」のような意味に変換する関数を一つ作り、どの入力もそこを通すと、状態の確認も一か所で済みます。

描画ループの扱い。 requestAnimationFrame のループを止めるか、動かしたまま更新だけ止めるかは、設計の選択です。終了画面でも演出が動くなら、ループは動かし、更新処理だけを state === "playing" のときに限ります。

ゲームオーバー直後の誤操作。 連打していたキーが、そのまま「もう一度」の決定として受け付けられると、結果画面を見る前にリスタートしてしまいます。終了から短い時間だけ決定を受け付けない方法がありますが、その長さは遊び心地に関わるので、自分のゲームで試して決めてください。

セーブや記録との関係。 終了後に得点が動くと、その値が記録として保存される可能性があります。アソバースでは、ログイン中のクラウドセーブや、URL投稿でSDKを読み込んだ場合の作品ごとのセーブ(JSONで128KiBまで)が使えます。保存を書き込む呼び出しがあるなら、状態が over に入る遷移の中で一度だけ呼ぶ形にすると、終了後の入力で値が変わっても保存内容が意図せず上書きされにくくなります。

投稿の流れには「入力→表示の確認→公開」の3段階と、下書き保存があります。表示の確認の段階で、先ほどの診断手順を一通り試すと、公開前に漏れを見つけられます。

次の行動

自分のゲームで、ゲームオーバー表示のあとに普段の操作をすべて押してみてください。何かが動いたら、状態を一つの値にまとめ、状態ごとに受け付ける入力の表を作るところから始めます。公式作品の「こだまの森」「紙ひこうき郵便」「時計じかけの台所」は、3回の間違いやミスで終わる作品なので、実際に終了させて、終了後に何が残り何が止まるかを確かめてみてください。