「連続プレイ」を作り込む:結果からすぐ次へ
遊びをつくる執筆にClaudeを使用しています。

短いゲームを作って遊ばせると、結果画面で「もう一回」を押した直後に、前の回の何かが残っていることがあります。残り時間が半端な値から始まる、得点が前回の続きから増える、1回のクリックで2点入る。こうした不具合は、1回目のプレイでは絶対に見つかりません。2回目以降に初めて現れます。 連続プレイの作り込みには、二つの仕事があります。一つは結果画面から次のプレイまでの距離を縮める「導線」です。もう一つは、前の回の状態を過不足なく片づける「リセット」です。導線だけ短くしても、リセットが雑だと「もう一回」が不具合の入口になります。リセットだけ丁寧でも、導線が長ければ遊び手は画面を閉じます。 この記事では、5秒間ボタンを連打する最小のゲームを土台に、二つを順に作ります。最後に、10回連続で遊んで不具合を洗い出す確認手順も用意しました。コードは単独のHTMLファイル1枚で動きます。
1. 何を残し何を捨てるかを先に決める
コードを書く前に、ゲームが持っている状態を書き出して三つに分けます。最初にこの仕分けをしておくと、あとで「リセットし忘れ」を探すときの基準になります。
- 毎回捨てるもの、例:得点、残り時間、ミス回数、進行中のタイマー、扱い:新しい回の開始時に必ず初期値へ戻す
- 回をまたいで残すもの、例:最高記録、選んだ難易度、音のオン/オフ、扱い:意図して持ち越す。置き場所を分ける
- 迷うもの、例:前回の盤面、直前の操作の履歴、ヒントの使用回数、扱い:作品の性格で決める。決めた理由を一行で書いておく
「迷うもの」は、公式作品の機能名から考え方を借りられます。たとえば「かたちのあいだ」には「同じ配置の再挑戦」があります。ここから、やり直しには「同じ問題をもう一度」と「新しい問題で最初から」の二種類がありうると読み取れます。前者は問題の初期配置を残し、手数だけを捨てます。後者は両方を捨てます。ボタンを一つにするか二つに分けるかは、この仕分けで決まります。
「小さな軌道」には、前回の軌道を細い実線で見せる機能があります。これは前の回の情報を、意図して次の回に持ち込む設計と読めます。持ち越しは、不具合とは限りません。ただし、持ち越すものは「持ち越す」と決めたデータとして管理します。
手順1:状態の一覧を作る
- ゲーム中に変化する値を、変数名ごとにすべて書き出します。タイマーのID、イベントの登録、アニメーションの進行状態も含めます。
- 各行に「捨てる」「残す」「迷う」のどれかを書きます。
- 「迷う」には、遊び手が困る場面と、助かる場面を一つずつ書いて判断します。
- 「残す」ものは、ページを閉じても必要かを確認します。必要なら保存が要ります(後述)。
2. 状態を一か所にまとめる
仕分けができたら、「毎回捨てるもの」を一つのオブジェクトに集め、新しい回の開始時にまるごと作り直します。個別の変数を一つずつ0に戻す書き方は、変数が増えたときに戻し忘れが出ます。
次のコードが土台です。5秒間、ボタンを押した回数が得点になります。「残す」ものは best に、「捨てる」ものは run に分けています。
<!doctype html>
<meta charset="utf-8">
<title>連続プレイの骨組み</title>
<section id="play">
<p>残り <span id="t"></span> 秒 / 得点 <span id="s"></span></p>
<button id="hit">押す</button>
</section>
<section id="result" hidden>
<p id="msg"></p>
<button id="again">もう一回</button>
</section>
<script>
const best = { score: 0 }; // 回をまたいで残す
let run = null; // 毎回作り直す
let lockUntil = 0; // 結果直後の誤入力を防ぐ
const $ = (id) => document.getElementById(id);
function newRun() { return { score: 0, timeLeft: 5, timerId: null }; }
function draw() { $("t").textContent = run.timeLeft; $("s").textContent = run.score; }
function start() {
if (run && run.timerId) clearInterval(run.timerId);
run = newRun();
run.timerId = setInterval(tick, 1000);
$("play").hidden = false;
$("result").hidden = true;
$("hit").focus();
draw();
}
function tick() {
run.timeLeft--;
if (run.timeLeft <= 0) return finish();
draw();
}
function finish() {
clearInterval(run.timerId);
run.timerId = null;
const isBest = run.score > best.score;
if (isBest) best.score = run.score;
$("msg").textContent = `得点 ${run.score}` + (isBest ? "(最高記録)" : `(最高 ${best.score})`);
$("play").hidden = true;
$("result").hidden = false;
lockUntil = performance.now() + 500;
$("again").focus();
}
// イベントは起動時に一度だけ登録する
$("hit").addEventListener("click", () => {
if (run && run.timerId) { run.score++; draw(); }
});
$("again").addEventListener("click", () => {
if (performance.now() >= lockUntil) start();
});
start();
</script>このコードには、リセットで事故が起きやすい箇所に対する備えが4つ入っています。
- 状態の作り直し:newRun() が毎回新しいオブジェクトを返すので、戻し忘れる変数が構造上できません。
- タイマーの後始末:start() の冒頭と finish() で clearInterval を呼びます。前の回のタイマーが生きたまま新しいタイマーを作ると、時間が倍速で減ります。
- イベントの登録位置:addEventListener は start() の外、起動時に一度だけ呼びます。start() の中に書くと、回を重ねるごとに登録が増え、1クリックで得点が2、3と増えます。
- 終了後の入力ガード:run.timerId が null のとき、hit は何もしません。
手順2:自作ゲームに当てはめる
- 手順1の「捨てる」行を、newRun() と同じ形のオブジェクトにまとめます。
- 開始処理を start() の一つに集約します。初回起動も「もう一回」も、同じ関数を通します。
- タイマー、requestAnimationFrame、setTimeout のIDは、すべて run の中に持たせ、終了時と開始時に止めます。
- addEventListener が関数の中に入っていないか、全文を検索して確認します。
初回起動と再開始で別の経路を使うと、初回だけ正しく動いて2回目以降が壊れる、という典型的な構造になります。
3. 結果画面から一操作で戻す
導線の目標は、結果が見えた状態から次のプレイが始まるまでを、操作1回にすることです。測り方は簡単で、結果画面で「次のプレイが始まるまでに何回クリックまたはキーを押したか」を数えます。メニューを経由する、確認ダイアログが出る、といった理由で2回以上になっていれば、削れる余地があります。
手順3:導線を整える
- 結果画面に「もう一回」のボタンを置き、finish() の最後でそのボタンにフォーカスを移します(上のコードの $("again").focus())。これでEnterキーやスペースキーだけで再開できます。
- ボタンの文言は「もう一回」のように、次に起きることが分かる言葉にします。「OK」や「閉じる」では、再開なのか終了なのか判断できません。
- 「別のコースを選ぶ」「タイトルへ戻る」は、「もう一回」より目立たない位置と大きさにします。主たる行動を一つ決めて、残りは脇に置きます。
- 最高記録の更新は結果画面で見えるようにします。更新したか否かは、次の回へ向かう動機になります。
手順4:連打による「飛ばし」を防ぐ
見落としやすいのが、プレイ中にEnterやスペースを連打していた遊び手の手が止まらず、結果画面が出た瞬間に「もう一回」が押されてしまう問題です。結果を一度も見ないまま次の回が始まります。
対策は、結果表示の直後に短い入力無効時間を置くことです。上のコードでは lockUntil で500ミリ秒としています。この値は固定の正解ではありません。長すぎると「押したのに反応しない」と感じられ、短すぎると飛ばしが起きます。自分のゲームの操作(連打型か、じっくり考える型か)に合わせて、手元で調整してください。じっくり考えるパズルなら、ほぼ不要な場合もあります。
4. 10回連続で検証する
実装したら、1回ごとの動作ではなく「連続」で確かめます。連続プレイの不具合は、回数を重ねて初めて見えるものが大半です。
手順5:記録表を作って10回遊ぶ
- 紙か表計算に、列を作ります。「回」「開始直後の得点」「開始直後の残り時間」「1クリックで増えた得点」「残り時間が1減る間隔(体感で1秒か、速いか)」「結果画面で記録表示が正しいか」。
- 「もう一回」で、同じ操作を10回続けます。途中で画面を再読み込みせず、再開ボタンだけで進めます。
- 各回の開始直後と、1回目のクリック直後の値を記録します。
- 次に、中断を混ぜた回も3回ほど作ります。プレイの途中で一時停止やブラウザの別タブへの切り替えを行い、戻って最後まで遊び、その後の回の開始値を見ます。
- 可能なら、キーボードだけで10回、マウスだけで10回、それぞれ行います。
結果の読み方
記録の見方は、症状から原因を逆引きする形で整理できます。
- 開始直後の得点が0でない:newRun() を通っていない変数があります。状態の一覧に漏れがないか見直します。
- 1クリックで得点が2以上増える:イベント登録の重複です。addEventListener が start() の中にないか確認します。
- 回を重ねるごとに残り時間の減りが速くなる:前の回のタイマーが止まっていません。clearInterval の呼び出し箇所を確認します。
- 結果画面を見ないうちに次が始まる:入力無効時間がありません(手順4)。
- 最高記録が突然0に戻る:記録を run の側に置いている可能性があります。「残す」ものの置き場所を確認します。
- 中断の後だけ値がおかしい:一時停止やタブ切り替え時の処理が、タイマーの停止と再開で食い違っています。
原因を特定したら、直した後に同じ表で再度10回遊びます。1か所を直して別の場所が壊れることがあるためです。
5. 記録を残すか、作品を開いて確かめるか
最高記録を持ち越す場合は、「いつまで残るか」を決めておく必要があります。上のコードの best は、ページを開いている間だけ有効です。再読み込みで消えます。
アソバースでは、アップロードしたゲーム内の localStorage や sessionStorage は、一時メモリーとして動く互換処理の扱いです。永続的な保存先として当てにしないでください。ずっと残したい記録は、クラウドセーブ(作品ごとにJSONで128KiBまで)の対象にします。最高記録の数個の数値なら、容量には十分な余裕があります。ただし、保存する内容を増やすほど、「どれを回ごとに捨て、どれを保存するか」の仕分けが大事になります。保存の呼び出し方は、現在の投稿ガイドで確認してください。
公式作品を開いて、連続プレイの作り方を観察する視点も挙げておきます。結果は作品ごとに異なるので、実際に開いて確かめてください。
- こだまの森:最高記録はゲームを開いている間だけ残る設計です。コースを変えて続けて遊び、最高記録がどう扱われるかを見ます。
- 時計じかけの台所:時間制限のない見習いと、制限のある本番があります。皿を終えたあと、モードが引き継がれるか確かめます。
- かたちのあいだ:同じ配置の再挑戦ができます。再挑戦と新しい配置で、何が戻り何が残るかを比べます。
- 小さな軌道:前回の軌道が細い実線で残ります。残すものを意図的に決めている例として観察します。
観察の際は、結果画面から次の操作までの回数と、次の回の開始直後に初期化されているものを、手順5の表の形式で記録すると比較しやすくなります。
次の行動
まず自分のゲームの状態を書き出し、「捨てる」「残す」「迷う」に分けてください。次に、開始処理を一つの start() にまとめ、手順5の表で10回連続の確認をします。すべての行が想定どおりになったら、投稿画面で表示を確認してから公開に進みます。

