マガジン

リスタートすると前の敵が残る:初期化の抜けを探す

遊びをつくる

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

さまざまな小さなゲームの世界が並んだ棚を描いたイラスト。
記事のテーマをもとに制作したAI生成イメージ。実際のゲーム画面ではありません。

ゲームオーバーの画面で「リスタート」を押すと、スコアは0に戻り、ライフも満タンになります。ところが画面の端には、前の挑戦で倒しきれなかった敵が数体残っています。さらに、敵の出現が心なしか速くなっている気もします。こうした症状の原因は、ほぼ「リセットし忘れた状態が残っている」ことです。 ブラウザを再読み込みすれば、変数は全部初期値に戻ります。ゲーム内のリスタートは、動いているプログラムの中で変数を書き戻す操作です。書き戻す対象が20個あって、そのうち19個を戻せば、残る1個が不具合になります。バグを探すより先に、「このゲームは状態をいくつ持っているか」を一覧にするのが近道です。 この記事では、その棚卸しの作り方と、漏れを機械的に見つける手順を説明します。結果の数字は載せません。ご自身のゲームで確かめる形にします。

この記事の目次
  1. リスタートは「書き戻し」であり、漏れは4か所に出る
  2. 状態を寿命で棚卸しする
  3. コードで見つける:状態を一か所に集める
  4. 漏れを炙り出す確認手順
  5. アソバースに投稿するときの確認点
  6. 次の行動

リスタートは「書き戻し」であり、漏れは4か所に出る

初期化の漏れは、次の4種類に分けると探しやすくなります。

  1. データの残り:敵、弾、アイテムの配列に前回の要素が残ります。「前の敵が残る」はこれです。
  2. 数値・フラグの残り:スコア、ライフ、経過時間、「ゲームオーバー済み」のフラグなどが戻っていません。リスタート直後に即ゲームオーバーになる症状は、このフラグの戻し忘れによく現れます。
  3. 動き続けているものの残り:setInterval、setTimeout、requestAnimationFrameのループが止まっていません。リスタートのたびにループが重なり、動きが倍速になります。
  4. 登録したものの重複:addEventListenerをリスタート処理の中で呼ぶと、クリック1回で処理が複数回走ります。

1と2は画面を見れば気づけます。3と4は、数回リスタートしてから症状が出ることがあります。

状態を寿命で棚卸しする

漏れを防ぐ一番確実な方法は、ゲームが持つ状態を寿命ごとに分けて、表にすることです。次の表は一例です。ご自身のゲームの項目に置き換えてください。

  • 敵・弾・アイテム、リスタートで:戻す、ページを開き直すと:消える
  • スコア・ライフ・ラウンド、リスタートで:戻す、ページを開き直すと:消える
  • 各種タイマーとループ、リスタートで:止めて作り直す、ページを開き直すと:消える
  • 押されているキー、リスタートで:戻す、ページを開き直すと:消える
  • 最高記録、リスタートで:残す、ページを開き直すと:保存しなければ消える
  • 音量などの設定、リスタートで:残す、ページを開き直すと:保存しなければ消える
  • クラウドセーブの内容、リスタートで:残す、ページを開き直すと:残る

大事なのは、「戻す」と「残す」を意図して決めることです。リスタートで全部戻せばよいわけではありません。

公式作品から読み取れる「残す状態」

アソバースの公式作品にも、リスタートや再挑戦で扱いが分かれる状態があります。確認済みの仕様から、設計として読み取れる範囲を挙げます。

  • こだまの森:最高記録は「ゲームを開いている間だけ」残ります。ラウンドをやり直しても残り、画面を開き直すと消えるという二段階の寿命がある、と読み取れます。
  • 小さな軌道:前回の軌道が細い実線で表示されます。新しい挑戦を始めても前回の軌道を持ち越す状態が、意図して設けられていると考えられます。
  • かたちのあいだ:「同じ配置の再挑戦」と「一手戻す(手数は減らない)」があります。再挑戦のために開始時の配置を別に保持していること、手を戻しても手数のカウンタは戻さない扱いであることが、設計から読み取れます。
  • 月夜の配達:各夜の最少区間数はプレイ中だけ記録されます。

これらが画面上でどう見えるかは、作品を開いて確かめてください。「何をリスタートで消し、何を残しているか」を観察すると、自作の表を作る参考になります。

コードで見つける:状態を一か所に集める

棚卸しの結果をコードに反映する基本は、状態を作る関数を一つだけ用意することです。個別に書き戻す方式では、項目が増えるたびに漏れの機会が増えます。

まず、漏れやすい書き方の例です。

js
// 悪い例:個別に書き戻している
let enemies = [], score = 0, lives = 3;
function restart() {
  score = 0;
  lives = 3;
  // enemies の書き戻しを忘れている
  setInterval(spawn, 1000);   // 前のタイマーも止めていない
}

enemiesが残り、setIntervalは呼ぶたびに増えていきます。次の例は、状態を作る関数に集約し、ループも一本に管理する最小構成です。単独のHTMLとして動きます。

html
<canvas id="c" width="320" height="120"></canvas>
<button id="r">リスタート</button>
<script>
const ctx = c.getContext('2d');
const createState = () => ({ enemies: [], score: 0, spawn: 0, over: false });
let state = createState();
let best = 0;                 // 残す状態
let rafId = 0, last = 0;

function update(dt) {
  if (state.over) return;
  state.spawn += dt;
  if (state.spawn > 1) { state.spawn = 0; state.enemies.push({ x: 320 }); }
  for (const e of state.enemies) e.x -= 60 * dt;
  if (state.enemies.some(e => e.x < 0)) {
    state.over = true;
    best = Math.max(best, state.score);
  }
  state.score += dt;
}
function draw() {
  ctx.clearRect(0, 0, 320, 120);
  for (const e of state.enemies) ctx.fillRect(e.x, 50, 10, 10);
  ctx.fillText(`enemies:${state.enemies.length} best:${best|0}`, 4, 12);
}
function loop(t) {
  update(Math.min((t - last) / 1000, 0.1)); last = t;
  draw();
  rafId = requestAnimationFrame(loop);
}
function restart() {
  cancelAnimationFrame(rafId);   // 前のループを止める
  state = createState();         // 戻す状態は全部ここで作り直す
  last = performance.now();
  rafId = requestAnimationFrame(loop);
}
r.onclick = restart;             // 登録は起動時に一度だけ
restart();
</script>

ポイントは次の4つです。

  • 戻す状態はcreateStateの中だけに書く:新しい状態を足すときは、この関数に1行足します。restartには何も足しません。
  • 残す状態はstateの外に置く:bestがそうです。「どちらに属するか」がコードの場所で見分けられます。
  • ループは開始前に必ず止める:cancelAnimationFrameを呼ばないと、リスタートのたびにループが増えます。
  • イベントの登録は起動時に一度だけ:restartの中でaddEventListenerを呼ばないようにします。

参照の共有に注意する

createStateの中で、外に定義した配列やオブジェクトを使い回すと、書き戻したつもりでも前の内容が残ります。たとえば、const START = { enemies: [] }を作り、state = STARTと代入すると、2回目以降のリスタートで同じ配列を触ることになります。必ず関数の中で新しい配列やオブジェクトを作ってください。

漏れを炙り出す確認手順

コードを直したあとも、棚卸しの表が正しいかは動かして確かめる必要があります。次の手順を試してください。

手順1:トップレベルの宣言を検索して一覧にする

ソースの中から次の語を検索し、見つかった行を全部、表の行にします。

  • let、var
  • 配列やオブジェクトを入れたconst(中身が変わるため)
  • setInterval、setTimeout、requestAnimationFrame
  • addEventListener
  • Date.now、performance.nowなど時刻を保持している変数

表の各行に「リスタートで戻す/残す」を書き込み、どちらにも書けない行があれば、それが調べるべき箇所です。

手順2:極端な状況でリスタートする

通常のゲームオーバー後だけでは、漏れは見つかりにくいものです。次の状況で、それぞれ3回ずつリスタートを試します。

  • 敵や弾が画面に最も多い瞬間
  • ゲームオーバー画面が出ている最中
  • 一時停止の最中(一時停止がある場合)
  • 演出やアニメーションの途中
  • リスタートボタンの連打

手順3:記録して読む

1回ごとに、リスタート直後の次の項目をメモします。

  • 画面上の敵・弾・アイテムの数
  • スコア、ライフ、ラウンドの表示
  • 敵の出現間隔と移動の速さが、初回と同じに見えるか

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

  • 敵やアイテムだけが残るなら、データの残りです。配列がcreateStateの外にないかを疑います。
  • 動きが回数に応じて速くなるなら、ループかタイマーの重複です。
  • クリックやキー1回で動作が複数回起きるなら、イベント登録の重複です。
  • リスタート直後にゲームオーバーになるなら、終了フラグか、前回の時刻の残りを疑います。

起こりやすい誤り

  • 残すべき状態まで「漏れ」と判定する:最高記録がリスタートで残るのは正しい動作です。表に「残す」と書いてあるものは、差分として数えません。
  • 1回だけ試して終える:重複は2回目、3回目から表面化します。必ず複数回試します。
  • 開発者ツールを開いたまま、ほかの動作に影響が出る:確認の前後で、ページを開き直して初回の状態を基準にします。

アソバースに投稿するときの確認点

アソバースへの投稿では、入力→表示の確認→公開の3段階があります。表示の確認の段階で、上の手順2を一通り試すことをお勧めします。リスタートの不具合は、公開後に遊ぶ人が最初に気づく種類のものです。

セーブを使う作品では、さらに次の点を確かめてください。

  • クラウドセーブとの順序:SDKを使うとクラウドセーブ(作品ごとにJSONで128KiBまで)が使えます。起動時にセーブを読み込んだあと、restartがstateと一緒にセーブ内容を上書きしていないか確認してください。
  • セーブに入れるものを選ぶ:敵の配列のような一時的な状態まで保存すると、容量を消費し、読み込み後の再開で不整合の原因になります。表で「残す」と決めたものに絞ります。
  • 保存形式にバージョンを持たせる:公式作品では、古い保存(version1)を新しい形式(version2)へ移行する扱いがあります。自作でも、保存データに番号を持たせておくと、項目を足したときに古いデータを安全に読めます。
  • localStorageを永続の前提にしない:アップロードした作品内のlocalStorage/sessionStorageは一時メモリーとしての互換処理です。長く残したい進行は、SDKのクラウドセーブを使う前提で考えます。
  • ゲストとログインの違い:ゲストの履歴と進行は端末内に保存され、ログイン後のアカウントへ自動では混ざりません。リスタートで消してよい状態と、アカウントに残す状態を分けておくと、この違いにも対応しやすくなります。

次の行動

まず自作ゲームのソースで、let・setInterval・addEventListenerなどを検索し、状態の一覧を「戻す/残す」つきの表にしてください。次に、敵が最も多い瞬間と連打の状況で、リスタートを3回ずつ試します。差分が見つかったら、その状態をcreateStateに移すところから直してみてください。