マガジン

タブを切り替えたらゲームが進みすぎる?一時停止と復帰の実装

遊びをつくる

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

ガラスの覆いの中でボールと振り子が静止し、ゲームの一時停止を表した写真風イメージ。
記事のテーマをもとに制作したAI生成イラスト。

別のタブで調べものをして戻ってきたら、自機がとっくに画面外まで飛び去っていた——そんな経験をしたWebゲーム制作者は少なくないはずです。原因と対処は3つに分かれており、それぞれ別の箇所で実装する必要があります。この記事では、Page Visibility APIを使った非表示中の停止と、復帰時のデルタタイム補正、キー押下状態のリセット、音の停止までを順に説明します。

この記事の目次
  1. 3つの問題:なぜ復帰後に暴走するのか
  2. Page Visibility API:停止と復帰を検知する仕組み
  3. 基本のポーズと再開:最小限の骨格
  4. 復帰時のデルタタイム問題を直す
  5. キー押下状態と音のリセット
  6. 実装チェックリストと次のステップ

3つの問題:なぜ復帰後に暴走するのか

多くのWebゲームはゲームループをrequestAnimationFrame(rAF)またはsetIntervalで回しています。MDN Web Docsによれば、ほとんどのブラウザはバックグラウンドタブへのrAFコールバックを完全に停止します。そのためゲームループ自体はタブが隠れている間は動かなくなります。

ではなぜ「戻った瞬間に瞬間移動する」のかというと、performance.nowのタイムスタンプは止まらないからです。rAFが停止していても実時間は進み続けており、タブに戻った直後の最初のコールバックには「隠れていた全時間ぶんのデルタ」が渡されます。デルタタイムで物体を動かす設計のゲームは、この1フレームで数十秒ぶんの移動量が発生し、物体が瞬間移動するか当たり判定を突き抜けてしまいます。

もう一つ見落とされがちなのがキーボードの押下状態です。「キーを押している間は加速する」という設計で、keydownイベントでフラグを立てkeyupで下ろす実装はよくあります。Alt+Tabなどのショートカットでタブを切り替えると、ブラウザによってはkeyupイベントがゲームに渡されません。その結果、復帰後もフラグが立ったままとなり、ゲームが再開直後から暴走します。

整理すると問題は3つです。①rAFが停止しているにもかかわらず復帰時のデルタが巨大になる、②キー押下フラグが残る、③音が流れ続ける。それぞれに対処の場所が異なります。

Page Visibility API:停止と復帰を検知する仕組み

Page Visibility APIは、ページが現在ユーザーから見えているかどうかをスクリプトから検知するためのブラウザAPIです。WHATWG HTML Standardに定義されており、モダンブラウザで広く利用できます。

  • document.visibilityState:ページの可視状態を文字列で返します。値は "visible"(前景タブ・最大化ウィンドウ)または "hidden"(バックグラウンドタブ・最小化・画面オフなど)です。
  • document.hidden:visibilityState が "hidden" のとき true を返す真偽値の短縮プロパティです。
  • visibilitychange イベント:可視状態が変化したとき document で発火します。このイベントを購読してゲームを止めたり再開したりします。

WHATWG HTML Standardにはパフォーマンスタイムライン経由で可視状態変化を取得できるVisibilityStateEntryインターフェースも定義されています。ただしMDN Web Docsはこれを「実験的API(Experimental)」と明記しており、主要ブラウザ間でサポートが揃っていないため、ゲームのポーズ制御にはvisibilitychangeイベントリスナーで十分です。

基本のポーズと再開:最小限の骨格

まず、ゲームループが外部から停止・再開できる構造になっているかを確認してください。rAFを使っている場合、コールバック内で次のrAFを予約するパターンが一般的です。「停止フラグが立っていたらrAFを予約しない」という分岐を入れると、ループ全体を安全に止められます。

javascript
// 説明用のコード例
let rafId = null;
let isPaused = false;

function gameLoop(timestamp) {
  if (isPaused) return;
  update(timestamp);
  render();
  rafId = requestAnimationFrame(gameLoop);
}

function pause() {
  isPaused = true;
  if (rafId !== null) {
    cancelAnimationFrame(rafId);
    rafId = null;
  }
}

function resume() {
  if (isPaused) {
    isPaused = false;
    rafId = requestAnimationFrame(gameLoop);
  }
}

// Page Visibility APIで自動停止・再開
document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    pause();
  } else {
    resume();
  }
});

visibilitychangeリスナーはゲーム初期化時に一度だけ登録してください。複数回登録すると、状態変化のたびにハンドラが重複して呼ばれます。ポーズ画面を表示したい場合は、pause() の中でそのUIを出すコードを追加します。

復帰時のデルタタイム問題を直す

単純にrAFを再開するだけでは不十分です。rAFのコールバックに渡されるtimestamp はページ読み込みからの絶対的な経過時間であり、タブが隠れていた間も実時間として積み上がっています。再開した直後の最初のフレームで「停止していた全時間ぶんのデルタ」が届くのが問題の本質です。

対処は「再開直後の最初のフレームでは前フレームのタイムスタンプをリセットする」ことです。pause() を呼んだ時点で prevTimestamp を null に上書きし、update() の先頭で prevTimestamp がない場合は今回の timestamp をそのまま前回値として扱いデルタをゼロにします。

javascript
// 説明用のコード例
let prevTimestamp = null;

function update(timestamp) {
  if (prevTimestamp === null) {
    // 再開直後:デルタを0扱いにしてスキップ
    prevTimestamp = timestamp;
    return;
  }
  const delta = (timestamp - prevTimestamp) / 1000; // 秒単位
  prevTimestamp = timestamp;

  // delta を使った物体移動など
  player.x += player.vx * delta;
}

function pause() {
  isPaused = true;
  prevTimestamp = null; // 次回再開時にデルタをリセット
  if (rafId !== null) {
    cancelAnimationFrame(rafId);
    rafId = null;
  }
}

もう一つの方法は「デルタに上限(cap)を設ける」ことです。たとえば delta = Math.min(delta, 0.1) とすれば1フレームあたり最大0.1秒ぶんしか進まなくなります。ただしこれは問題を緩和するだけで、0.1秒の瞬間移動自体は起きます。ゲームの性質によって「リセット方式」と「キャップ方式」を選ぶか、両方を組み合わせてください。

キー押下状態と音のリセット

keydownでフラグを立てkeyupで下ろす実装では、Alt+Tabなどのショートカットでタブを切り替えたときにkeyupがゲームに届かないケースがあります。その結果、復帰後もフラグが立ったままになります。解決策はシンプルです。visibilitychange で非表示になった瞬間にすべてのキーフラグをリセットすることです。

javascript
// 説明用のコード例
const keys = {};

document.addEventListener('keydown', (e) => { keys[e.code] = true; });
document.addEventListener('keyup',   (e) => { keys[e.code] = false; });

document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    // タブを離れるときにすべての入力フラグをリセット
    Object.keys(keys).forEach(k => { keys[k] = false; });
    pause();
  } else {
    resume();
  }
});

同様の問題はマウスボタンにも起こります。mousedownでフラグを立てているなら、非表示時に同じようにリセットしてください。タッチ操作はtouchendが発生するシナリオがほとんどですが、複数タッチの状態管理をしている場合も非表示時にクリアしておくと安全です。

BGMやSEにWeb Audio APIを使っている場合、AudioContextのsuspend()とresume()を利用すると、処理中のオーディオグラフをそのまま停止・再開できます。HTMLの<audio>要素を使っている場合はpause()とplay()で対応します。MDNのPage Visibility APIページにも<audio>の一時停止例が掲載されています。バックグラウンドで音が流れ続けることはユーザー体験を大きく損なうため、ゲームループの停止と同時に音も止めることをお勧めします。

実装チェックリストと次のステップ

タブを離れたときにポーズ画面を出す場合は、復帰時に「ポーズ中」のUIを表示したまま一呼吸置き、ユーザーが自分で再開ボタンを押せるようにする設計も検討に値します。急に再開するより、ユーザーが状況を把握してから操作できる方が親切です。

実装前の確認チェックリスト

  1. ゲームループ(rAF / setInterval)を外から停止・再開できる関数に切り出しているか
  2. visibilitychange イベントリスナーをゲーム初期化時に一度だけ登録しているか(複数回登録すると重複して発火する)
  3. 停止時に prevTimestamp(または前フレーム時刻)をリセットしているか
  4. 停止時にキーボード・マウスの押下フラグをすべてクリアしているか
  5. BGM・SEの一時停止と再開を実装しているか
  6. ユーザーが自分で再開を選べるUI(ポーズ画面など)を用意しているか、または即時再開でも問題ないゲーム設計かを判断しているか

実際に自分のブラウザでタブを切り替えて戻る操作を試し、移動量・音・入力状態が意図どおりかを確認してください。自動テストでカバーしにくい挙動なので、手動での動作確認が欠かせません。

ブラウザによってバックグラウンドタブへのrAF停止タイミングや、setIntervalの最低間隔の扱いは異なる場合があります。挙動の詳細は各ブラウザの公式開発者ドキュメントで随時確認することをお勧めします。このコード例はPage Visibility APIの公式仕様に基づいた説明用のものです。

Page Visibility APIを使った停止・復帰の実装はコードの量が多くありません。それでも、タブを離れるたびにゲームが暴走するストレスがなくなるだけで、プレイヤーが安心してゲームを中断・再開できるようになります。特にスマートフォンではアプリ切り替えが頻繁に起こるため、この対応の有無がゲームの完成度の印象に直結します。実装の流れを整理すると、「visibilitychangeイベントで停止・再開を制御する」「復帰時のデルタタイムをリセットまたはキャップする」「入力フラグと音をクリアする」の3点です。順番に実装して、それぞれタブ切り替えで動作を確認しながら進めると問題の切り分けがしやすくなります。

出典・参考資料