マガジン

タイマーが止まらない:終了処理に残った時計

遊びをつくる

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

緑の目印がある時計と食材を並べ、ゆっくりと練習できる温かな台所。
記事のテーマをもとに制作したAI生成イラスト。

制限時間つきのゲームを作って、時間切れの画面を出したとします。ところが画面が「終了」に変わったあとも、裏で時計が動き続ける場合があります。終了画面で効果音が鳴り続ける、スコアが勝手に増える、「もう一度」を押すたびに残り時間の減りが速くなる、といった症状です。ゲームは正しく終わって見えるので、原因が画面の外にあることに気づきにくい不具合です。 この記事の問いは、終了処理に残った時計をどう見つけ、どう止めるかです。対象は setInterval と requestAnimationFrame(画面の描画のたびに呼ばれる関数を登録する仕組み、以下 rAF)の止め忘れです。流れは三つです。まず時計が残る入口を整理し、次に単独で動く最小のコードで「止める場所を一か所にする」形を作ります。最後に、わざと止め忘れを作って症状を出し、手元で確かめる手順を示します。 アソバースの公式作品の設計も、確認済みの範囲で「設計から読み取れること」として取り上げます。ただし各作品の内部コードは確認済み事項に含まれません。実装がどうなっているかは、作品を開いて確かめる方法を示すにとどめます。

この記事の目次
  1. 時計が残る入口は「終わり方の数」だけある
  2. 止める場所を一か所に集める最小のコード
  3. 症状を自分で作って確かめる手順
  4. 自分のゲームで残り時計を探す
  5. 公式作品の設計から読み取れること
  6. 次の行動
  7. 参考資料

時計が残る入口は「終わり方の数」だけある

止め忘れは、ゲームの終わり方が複数あることから起きます。時間切れで終わる経路にだけ clearInterval を書き、別の終わり方では止め忘れる、という形です。

公式作品の仕様から、終わり方の数を数えてみます。

  • 時計じかけの台所の本番は、1皿28秒、配膳6秒、ミスは3回までです。時間が尽きる、ミスが3回に達する、料理を配膳し終える、と少なくとも三つの終わり方が読み取れます。
  • 紙ひこうき郵便は、ルートごとに36秒、40秒、44秒の制限があり、最後の6秒で郵便受けへ向かいます。風船にぶつかって紙が3回傷んでも終了します。時間切れと損傷の二つの出口があります。
  • こだまの森は、間違いが3回で終了し、一時停止もあります。最後のラウンドまで終える終わり方と合わせて、出口は複数です。

終わり方が増えるほど、「そこで時計を止めたか」を確認する場所も増えます。これは作品の内部実装の話ではなく、仕様から見える構造の話です。実際の止め方は、作品を開いて確かめます。

残りやすい時計は、次の表のとおりです。

  • 一定間隔、登録する関数:setInterval、止める関数:clearInterval、止め忘れの典型:時間切れ側にだけ書き、ミス上限側に書かない
  • 一回きりの遅延、登録する関数:setTimeout、止める関数:clearTimeout、止め忘れの典型:「あと1秒で配膳」と予約したあと、先に終了してしまう
  • 描画ループ、登録する関数:requestAnimationFrame、止める関数:cancelAnimationFrame、止め忘れの典型:関数の末尾で毎回再登録し、終了後も止まらない
  • 音の予約、登録する関数:Web Audio のスケジュール、止める関数:各ノードの停止、止め忘れの典型:先読みで予約した音が終了後も鳴る

表の最後は、音のあるゲーム特有の落とし穴です。雨音のピアノやWAVE / WAVEのように音を扱う作品では、画面のタイマーとは別に音の予約が走ります。「止める」が画面の更新を止めることだけを指していないか、自分のゲームでも確認します。

止める場所を一か所に集める最小のコード

入口が複数あるなら、止める処理は一つの関数にまとめ、どの終わり方からもその関数を呼ぶ形が安全です。次のコードは、10秒で終わる簡易ゲームの骨組みです。保存して .html として開けば、単独で動きます。

html
<!doctype html>
<meta charset="utf-8">
<button id="start">スタート</button>
<button id="end">終了</button>
<p id="out"></p>
<script>
const out = document.getElementById("out");
const timers = { interval: null, raf: null };
let running = false, left = 0, frames = 0, ticks = 0;

function start() {
  stopAll();                       // 二重起動を防ぐ
  running = true; left = 10; frames = 0; ticks = 0;
  timers.interval = setInterval(onSecond, 1000);
  timers.raf = requestAnimationFrame(onFrame);
}
function onSecond() {
  ticks++; left--;
  if (left <= 0) finish();
}
function onFrame() {
  if (!running) return;            // 終了後は再登録しない
  frames++;
  out.textContent = "残り " + left + " 秒 / 描画 " + frames +
    " 回 / 秒カウント " + ticks + " 回";
  timers.raf = requestAnimationFrame(onFrame);
}
function stopAll() {
  clearInterval(timers.interval);
  cancelAnimationFrame(timers.raf);
  timers.interval = timers.raf = null;
  running = false;
}
function finish() {
  stopAll();
  out.textContent = "終了。描画 " + frames + " 回 / 秒カウント " + ticks + " 回";
}
document.getElementById("start").onclick = start;
document.getElementById("end").onclick = finish;
</script>

ポイントは三つです。

  1. 時計のIDを timers に集めています。setInterval や requestAnimationFrame の戻り値(ID)を変数に取っておかないと、あとから止められません。
  2. start() の先頭で stopAll() を呼んでいます。「スタート」の連打や「もう一度」で、前の時計が残ったまま新しい時計が増えるのを防ぎます。
  3. onFrame の先頭に running の確認を置いています。cancelAnimationFrame の呼び出しがどこかで漏れても、終了後に再登録されないための二重の備えです。

なお、このコードは説明を短くするために、left-- で1秒ごとに残り時間を減らしています。実際のゲームでは、performance.now() で開始時刻との差を取って残り時間を計算するほうが正確です。タブが裏に回ると、ブラウザは setInterval の呼び出し頻度を下げることがあり(挙動はブラウザや設定で異なります)、回数で数える方式は実時間とずれやすいためです。

症状を自分で作って確かめる手順

「止め忘れを直した」と言えるのは、止め忘れた状態の症状を見たあとです。上のコードを少し壊して、症状がどう出るかを確かめます。結果はここには書きません。手元で観察してください。

準備するもの

  • 上のコードを保存したHTMLファイル(以下「正常版」)。
  • 正常版を複製して、次の二か所を変えたファイル(以下「壊した版」)。
  • start() の先頭の stopAll(); を削除する。
  • onFrame の先頭の if (!running) return; を削除する。

手順

  1. 正常版でスタートを押し、10秒たって終了するまで待ちます。
  2. 終了表示のあとも「描画」と「秒カウント」の数字が変わるか、10秒ほど眺めます。
  3. 正常版で、スタートを1回だけ押した場合と、続けて3回押した場合を比べます。
  4. 壊した版でも、1〜3を同じ手順で繰り返します。
  5. 各条件を3回ずつ行い、「1秒あたりの秒カウントの増え方」と「終了後に数字が動いたか」をメモします。

結果の読み方

コードの構造から、次のように読めるはずです。実際にそうなるかは手元で確かめます。

  • 壊した版でスタートを3回押すと、timers.interval に入るIDが上書きされ、最初の2本の時計のIDは失われます。clearInterval で止められる時計は最後の1本だけです。残り時間の減りが速くなる、またはゲームが終わらなくなる症状は、この「IDを失った時計」が原因です。
  • 終了後も「描画」の数字が動くなら、rAFの再登録が残っています。onFrame が running を見ずに毎回自分を登録し直すためです。
  • 正常版で3回押しても1秒あたりの増え方が変わらないなら、スタート時の stopAll() が効いています。

起こりやすい誤り

  • clearInterval の引数に、止めたい時計のIDではなく別の変数を渡してしまう。
  • IDを関数内の const に入れてしまい、終了処理から参照できない。
  • setTimeout の予約を忘れる。「配膳の6秒」のような遅延は、setInterval と別に止める必要があります。

自分のゲームで残り時計を探す

自作ゲームに止め忘れがありそうなときは、検索と計測を組み合わせます。

まずコードを setInterval、setTimeout、requestAnimationFrame、addEventListener で検索し、登録している場所の一覧を作ります。次に、それぞれについて「止める場所はどこか」を横に書き出します。止める場所が書けない行が、疑わしい時計です。

計測には、次の数行をゲームの <script> より前に貼る方法があります。登録された時計の数を数えます。

js
const ids = new Set();
const _si = window.setInterval, _ci = window.clearInterval;
window.setInterval = (...a) => { const id = _si(...a); ids.add(id); return id; };
window.clearInterval = id => { ids.delete(id); return _ci(id); };
let rafCalls = 0;
const _raf = window.requestAnimationFrame.bind(window);
window.requestAnimationFrame = f => { rafCalls++; return _raf(f); };

ゲームが終了画面になったあと、ブラウザの開発者ツールのコンソールで ids.size を見ます。1以上なら、止められていない setInterval が残っています。rafCalls は数秒おきに2回読み、値が増え続けていれば、描画ループが終了後も再登録されています。

判断の目安は次のとおりです。

  • 終了画面で ids.size が0、rafCalls が増えない。時計は止まっています。
  • 終了画面で rafCalls が増え続ける。描画ループの終了条件を見直します。画面が静止していても、再描画のたびに処理が走っている場合があります。
  • 「もう一度」を何回か押すと ids.size が増える。スタート時の二重起動です。

このスニペットは、あくまで調査用です。確認が終わったら削除します。

公式作品の設計から読み取れること

ここからは、確認済みの仕様だけを手がかりに、時計の扱いが問われそうな場面を挙げます。内部の実装は確認できていないので、いずれも作品を開いて確かめる観察ポイントとして読んでください。

一時停止と再開。 こだまの森、小さな軌道、かたちのあいだには一時停止があります。小さな軌道は「同じ位置で再開」できると案内されています。時計を止めるだけでなく、再開時に基準時刻をずらす設計になっているはずですが、実装は不明です。停止中に時間が進んでしまう作りだと、再開した瞬間に残り時間が急に減ります。一時停止を長めに取ってから再開し、残りが飛んでいないかを見ます。

言語変更とプレイ画面の拡大。 13作品はいずれも、言語を切り替えたりプレイ画面を拡大したりしても進行を保つとされています。表示を組み直すときに時計まで作り直すと、二重起動の原因になります。時計じかけの台所や紙ひこうき郵便のように時間制限のある作品で、プレイ中に言語を切り替えたり画面を拡大したりして、残り時間の減り方が変わらないかを観察できます。

終了後の放置。 終了画面で数十秒放置し、ブラウザのタスクマネージャ(ブラウザによって名称や場所は異なります)でそのタブのCPU使用が下がるかを見ます。描画ループが残っていれば、静止画面でも処理が続くことがあります。

動きを抑える設定。 13作品は「動きを抑える」設定に対応しています。この設定は動きの演出を減らすもので、時計の有無とは別の話です。設定をオンにしたまま終了画面で放置し、先ほどの観察を繰り返して差が出ないかを見ると、動きの描画と時間の管理が分かれて作られているかのヒントになります。

また、WAVE / WAVE は4つの音を16ステップに並べてループ再生するリズムスタジオです。ループの先読み再生がある作品では、画面の更新と音の予約が別々に動きます。止める操作で音も画面も止まるかは、作品を開いて確かめます。

次の行動

自作ゲームの setInterval、setTimeout、requestAnimationFrame をすべて検索し、それぞれの止める場所を書き出してみます。止める場所が書けない時計があれば、終了処理の関数にまとめます。そのうえで、「もう一度」を3回押しても時計が増えないことを確認します。アソバースに投稿するときは、公開前の「表示の確認」で、終了後に画面や音が動き続けていないかも見ておきます。

参考資料

  • MDN Web Docs「setInterval」「clearInterval」「requestAnimationFrame」「cancelAnimationFrame」の各解説
  • MDN Web Docs「Page Visibility API」の解説