ブラウザゲームの60秒は本当に60秒か
performance.now・rAF・バックグラウンド停止を実測
『60秒後の彼女』は「60秒で遊べる」と謳っています。しかしブラウザのタイマーは、setInterval の積算、フレーム落ち、バックグラウンドタブの停止によって、意図した時間からずれることがあります。この記事では本作のタイマー実装と、それがどう時間を刻むかをシミュレーションで確かめます。
performance.now() という単調増加する時計から毎フレーム「実経過時間」を引く方式で計算しています。setInterval の呼び出し回数を数える方式ではなく、実時計基準のためフレーム落ちに強く、バックグラウンドタブではゲームクロックを停止(freeze)する仕様にしています。シミュレーションでは、各難易度の制限時間(HARD 60秒 / NORMAL 64秒 / EASY 72秒)が正しく減算されることを確認しました。
1. なぜ「60秒」が問題になるのか
ブラウザゲームで「60秒」と表示しながら、実際には 58秒や 63秒で終わる、あるいは逆に長引くことがあります。原因は大きく三つあります。
- setInterval の積算依存:
setInterval(fn, 1000)を 60回数えて 60秒とする方式は、ブラウザが裏でタイマーを間引いたり、コールバックが遅延したりすると累積ずれが出ます。 - フレーム落ち:描画が 60fps から落ちると、1フレームあたりの経過が大きくなり、単純な「1フレーム=1減算」では時間が伸び縮みします。
- バックグラウンドタブ:ユーザーが別タブに移ると、多くのブラウザは rAF やタイマーを大幅に間引き(1秒に1回以下)ます。そのままにすると戻った時に一気に時間が減る、あるいは不公平な即失敗になります。
2. 本作のアプローチ:実時計基準の残り時間
実装 『60秒後の彼女』の src/core.js は、制限時間を「絶対的な締め切り(deadline)」と performance.now() の差で算出します。ゲーム開始時に startTimer() が deadline = now + timeLimit * 1000 を決め、tickTime() は毎フレーム timeLeft = (deadline - now) / 1000 を再計算します。
// ゲーム開始時(一度だけ)
export function startTimer(state, now) {
state.deadline = now + state.timeLimit * 1000;
state.timeLeft = state.timeLimit;
state.ended = false;
state.running = true;
}
// 毎フレーム呼ばれる。now は performance.now() の値。
export function tickTime(state, now) {
if (!state.running) return;
if (state.paused) { state.lastFrame = now; return; }
if (DIFFICULTY_CONFIG[state.difficulty]?.timerFrozen) {
state.lastFrame = now; // 練習モード:時間を減らさない
return;
}
// ゲーム制限時間は deadline との差。実経過時間へ正確に追従する。
state.timeLeft = Math.max(0, Math.min((state.deadline - now) / 1000, state.timeLimit));
state.lastFrame = now;
}
ポイントは次のとおりです。
nowはperformance.now()(ページ読み込みからの単調増加ミリ秒)を使い、残り時間を「deadline との差」で求めます。30fps・60fps・120fps や長いフレームでも、終了時刻は実質的に同じになります。- 描画やゲージの補間に使う
dtは別関数simulationDt()で 0.05 秒にクランプしていますが、これは「ゲーム制限時間の減算」には使いません。制限時間はあくまで deadline 方式です。 - ペナルティ(ミスなど)は
state.deadlineを早めることで減算し、次フレームの再計算とも整合します。
3. バックグラウンドタブの仕様
仕様 script.js は visibilitychange イベントを監視し、タブが隠れたらゲームループ(rAF)を止めて freezeTimer() を呼びます。再表示時に unfreezeTimer() が、隠れていた時間分だけ deadline を後ろへずらしてから再開します。つまり裏にいる間はゲームクロックは進まず(freeze)、戻った直後に「数秒分まとめて減る」不公平な挙動を回避しています。手動で一時停止(pause)している場合は freeze が上書きすることはありません。
4. シミュレーションで確かめたこと
実測 固定 60fps の fake clock で各難易度を 70秒間(wall clock)シミュレーションした結果です。これは本物のブラウザ rAF ではなくユニットレベルのシミュレーションであり、相対的な挙動を確認するためのものです。
| 難易度 | 制限時間 | 70秒経過時の残り | 消費フレーム数 |
|---|---|---|---|
| EASY | 72秒 | 約 1.98秒 | 4201 |
| NORMAL | 64秒 | 0秒(終了済み) | 3841 |
| HARD | 60秒 | 0秒(終了済み) | 3601 |
制限時間どおりに終了していることがわかります。また、バックグラウンドギャップ(10秒遊び→30秒非表示→再開)のシミュレーションでは、非表示中は残り時間が維持され、再開後に正しく減算が再開されることを確認しました。
5. リアルブラウザでの注意点
- CPU スロットル:低スペック端末や省電力モードでは rAF が間引かれますが、制限時間は deadline と実時計の差で決まるため「遊べる時間」自体は変わりません(描画がカクつくだけ)。
- 長時間タスク:メインスレッドを塞ぐ重い処理があっても、再開時に
(deadline - now)で再計算されるため、長いフレームで時間が不当に延びることはありません。 - 120Hz ディスプレイ:フレーム間隔が短くても合計減算は実経過時間に追従し、終了時刻は 60fps と実質同じです。
6. 読者が自作ゲームで使えるチェックリスト
- 残り時間は
setIntervalの回数ではなく、performance.now()の差分(deadline との差)で計算する。 - ゲーム制限時間の減算にはクランプ済み
dtを使わず、実時計との差を使う(30/60/120fps や長いフレームで終了時刻が変わらない)。 - 描画やゲージの補間に使う
dtは別関数でクランプ(例: 0.05秒)し、ゲーム時間の減算には使わない。 visibilitychangeで裏タブ時の扱い(停止/継続/警告)を一貫して実装する。- 一時停止・再開時は deadline を停止時間分だけずらし、まとめて減算されないようにする。
- リトライ時に古い rAF やリスナーが残らないようクリーンアップする。
7. 参考文献
- Performance API(performance.now)— MDN / W3C。単調増加時計の仕様。
- requestAnimationFrame — MDN / W3C。フレーム同期の描画タイミング。
- Page Visibility API — MDN / W3C。タブの表示状態検出。
仕組みがわかったら、実際に遊んでみてください。
最終更新:2026-07-18 / 運営:cig4r / この記事は本作の実装とシミュレーションに基づく検証記事です。