マガジン

ゲームのポーズ機能とタブ切り替え時の挙動を正しく作る

遊びをつくる

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

週末の明るい机に置かれた、一つの仕組みで遊べる小さな完成済みゲーム。
記事のテーマをもとに制作したAI生成の写真風イメージ。

ブラウザゲームを作っていると、ポーズボタンを押したときだけでなく、プレイヤーがタブを切り替えたり画面を最小化したりしたときにも「ゲームを止める」必要があることに気づきます。この処理を正しく実装していないと、タブを離れた隙にキャラクターが死んでいたり、タイマーがずれていたり、音楽が鳴り続けたりといった体験の悪さにつながります。 この記事では、ポーズ機能そのものの設計と、Page Visibility API を使ったタブ切り替え対応の両方を扱います。どちらも独立して使えますが、同じ「一時停止・再開」のロジックに統合することで、コードがシンプルになり、バグも減ります。

この記事の目次
  1. ポーズ機能の設計で先に決めておくこと
  2. タブ切り替えを検出する Page Visibility API
  3. タイムスタンプのずれを正しく補正する
  4. サウンドと rAF を一緒に止める
  5. ポーズUIとキー操作の衝突を避ける
  6. 次の行動
  7. 参考資料

ポーズ機能の設計で先に決めておくこと

ポーズ機能を後付けで実装しようとすると、ゲームループの各所に if (paused) return を散らばせることになりがちです。これは動くものの、見落としが起きやすい構造です。最初に「ポーズ状態を一か所で管理する」方針を決めておくと、後の修正が格段に楽になります。

基本的な考え方は次のとおりです。ゲームの状態を表すオブジェクトに isPaused フラグを持たせ、ゲームループの先頭でそのフラグを確認します。フラグが true なら、更新処理(移動・当たり判定・タイマー)をスキップし、描画だけ行うか、描画も止めます。描画を止めると処理コストが下がりますが、「ポーズ画面のオーバーレイを表示したまま止める」場合は描画ループは継続する必要があります。どちらが適切かはゲームの構造によって異なります。

ゲームループに requestAnimationFrame(以下 rAF)を使っている場合、ポーズ中に rAF を止める(cancelAnimationFrame)か、rAF を動かしたまま更新処理だけスキップするかを選べます。rAF を止める方が CPU 負荷は下がりますが、再開時に「前回のタイムスタンプからの経過時間が巨大になる」問題が起きます。これについては後のセクションで詳しく扱います。

タブ切り替えを検出する Page Visibility API

Page Visibility API は、タブが非表示になったかどうかをブラウザが通知してくれる仕組みです。document.visibilityState が "hidden" になったときにポーズし、"visible" に戻ったときに再開するのが基本パターンです。

js
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden") {
    pauseGame();
  } else {
    resumeGame();
  }
});

このイベントは、タブの切り替えだけでなく、モバイルブラウザでホーム画面に戻ったときや、PCでブラウザ全体が最小化されたときにも発火します。つまり「ユーザーがゲーム画面を見ていない状況」をほぼ網羅できます。

注意点として、blur / focus イベントとの違いを意識しておくことが大切です。blur はウィンドウのフォーカスが外れたとき(たとえば別のアプリをクリックしたとき)に発火しますが、タブが実際に非表示になっているかは visibilityState の方が正確に反映します。プレイヤーがウィンドウを二つ並べて作業しているケースでは、blur が起きても visibilityState は "visible" のままです。どちらを使うかは「フォーカスを外れたらポーズ」「非表示になったらポーズ」のどちらをゲームデザインとして選ぶかで決まります。アクションゲームや対戦ゲームでは blur 対応の方が公平なことが多く、パズルや育成ゲームでは visibilitychange だけで十分なことが多いです。

タイムスタンプのずれを正しく補正する

rAF を使ったゲームループは、コールバックに渡される timestamp(ミリ秒)を使って「前のフレームからの経過時間(delta time)」を計算します。

js
let lastTime = null;

function loop(timestamp) {
  if (lastTime !== null) {
    const delta = timestamp - lastTime;
    update(delta);
  }
  lastTime = timestamp;
  draw();
  requestAnimationFrame(loop);
}

ここでポーズを「rAF を止める」方法で実装した場合、再開時の timestamp は停止中の時間を含んだ値になります。ポーズ期間が5秒あれば、再開直後の delta が 5000ms を超え、オブジェクトが一気に5秒分動いてしまいます。

この問題を防ぐには、再開時に lastTime をリセットする方法と、ポーズ中の経過時間を除外する方法があります。もっとも簡単なのは前者です。

js
function resumeGame() {
  isPaused = false;
  lastTime = null; // 次のフレームで delta 計算をリセット
  requestAnimationFrame(loop);
}

lastTime を null にしておくと、再開後の最初のフレームで delta の計算がスキップされ、その次のフレームから正常な delta が取れます。1フレーム分の更新が飛ぶことになりますが、プレイヤーにはほぼ気づかれない範囲です。

より精密に管理したい場合は、ポーズに入ったときのタイムスタンプと再開時のタイムスタンプの差を「オフセット」として積算し、delta 計算時に差し引く方法もあります。ただし複数回のポーズ・再開があるゲームでは管理が複雑になるため、多くの場合は lastTime = null でリセットする方法が現実的です。

サウンドと rAF を一緒に止める

ポーズのよくある見落としが「映像は止まったが音楽が鳴り続ける」問題です。AudioContext を使っている場合は audioContext.suspend() でサウンド全体を一時停止でき、audioContext.resume() で再開できます。

js
function pauseGame() {
  isPaused = true;
  audioContext.suspend();
}

function resumeGame() {
  isPaused = false;
  lastTime = null;
  audioContext.resume();
  requestAnimationFrame(loop);
}

<audio> タグや HTMLAudioElement を使っている場合は、audio.pause() と audio.play() で対応します。BGM・効果音それぞれに参照を持っておき、ポーズ時に一括して止める設計にすると管理しやすくなります。ポーズ画面のUIアニメーション(「PAUSE」の文字が点滅するなど)を動かし続けたい場合は、そのアニメーション専用の rAF ループをゲームループとは分けて管理するか、CSS アニメーションで完結させる方法が手軽です。

ポーズUIとキー操作の衝突を避ける

Escape キーでポーズを切り替えるゲームは多いですが、ポーズ中もキーイベントのリスナーが生きているため、「ポーズ中に移動キーを押したら再開後すぐに動き出す」「キー連打でポーズとゲームオーバー処理が競合する」といった問題が起きることがあります。

対策として、ゲームの状態(playing / paused / gameover)を文字列や列挙値で管理し、キーイベントのハンドラ内で現在の状態を確認してから処理を振り分けるパターンが有効です。

js
document.addEventListener("keydown", (e) => {
  if (e.key === "Escape") {
    if (gameState === "playing") pauseGame();
    else if (gameState === "paused") resumeGame();
    return;
  }
  if (gameState !== "playing") return; // ポーズ中・ゲームオーバー中は移動キーを無視
  handleMovement(e.key);
});

モバイル向けにはタップでポーズを切り替えるボタンを画面上に置くのが一般的ですが、そのボタンのタップイベントがゲームキャンバスのタップイベントと干渉しないよう、イベントの伝播(stopPropagation)に注意が必要です。

次の行動

まず手元のゲームのループ関数を確認し、lastTime の扱いとサウンドの停止処理が pauseGame / resumeGame にまとまっているかをチェックしてみてください。次に visibilitychange のリスナーをひとつ追加し、タブを切り替えたときにポーズが入るかをブラウザの開発者ツールで確認するのが最初のステップです。ポーズ機能を整えたゲームはアソバースへの投稿でも安定した体験を届けられますので、完成したらぜひ公開してみてください。

参考資料

  • MDN Web Docs「Page Visibility API」
  • MDN Web Docs「requestAnimationFrame」
  • MDN Web Docs「AudioContext.suspend()」