マガジン

「一時停止」を作り込む:時計・音・入力の三つを止める

遊びをつくる

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

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

Pキーを押すとゲームが止まる機能は、数行で形になります。ところが、作ってみると意外なほど穴が出ます。たとえば、一時停止中も効果音の予約が残っていて、再開した瞬間にまとめて鳴る場面を考えてみます。画面は止まっているのに裏でタイマーが進み、再開と同時に敵が飛び出す場面もあります。止まっているあいだに押した矢印キーが、再開後に効いてしまう場合もあります。 この記事の問いは「何を止めれば一時停止と呼べるのか」です。答えは「全部止める」ではありません。ゲームの時計、音、ゲーム操作の入力は止めます。一方で、描画、再開の入力、フレームを回すループ自体は止めてはいけません。ここを取り違えると、止まったまま戻れないゲームになります。 後半は、ブラウザだけで動く最小のコードで、この線引きを手順として組み立てます。完成したら、ご自身の環境で確かめる検証手順も付けます。結果は記事側では示しません。確かめるのは読者自身です。

この記事の目次
  1. 止めるものと止めないものを先に決める
  2. 時計を二重にして、音と入力を止める
  3. 自分で確かめる検証手順
  4. ゲームの種類で分かれる判断
  5. 次の行動
  6. 参考資料

止めるものと止めないものを先に決める

コードを書く前に、一覧を作ります。一時停止の不具合の多くは、実装の難しさではなく、この線引きを決めないまま書き始めることから起きます。

  • ゲーム内の経過時間、一時停止中の扱い:止める、理由:制限時間や敵の出現が進むと不公平になります
  • キャラクターや弾の移動、一時停止中の扱い:止める、理由:時計に連動しているためです
  • 効果音・BGMの再生、一時停止中の扱い:止める、理由:再開時に続きから聞こえる必要があります
  • ゲーム操作の入力(移動・攻撃)、一時停止中の扱い:受け付けない、理由:再開後に持ち越されるのを防ぎます
  • 再開・メニューの入力、一時停止中の扱い:止めない、理由:戻れなくなるためです
  • 描画、一時停止中の扱い:止めない、理由:「一時停止」の表示を出す必要があります
  • ループ(requestAnimationFrame)、一時停止中の扱い:止めない、理由:再開の合図を拾い続けるためです

最後の2行が「止めてはいけないもの」です。ループまで止めてしまうと、再開の入力を処理する場所がなくなります。ループは回し続け、その中で「ゲームの時計を進めるかどうか」だけを切り替えます。

もう一つ、判断が分かれるものがあります。メニューを開く音や決定音のような、操作に対する応答の音です。止めるか鳴らすかは、後半で扱います。

時計を二重にして、音と入力を止める

ここからが手順です。次のコードは、1つのHTMLファイルとして保存すればそのまま動きます。外部のライブラリやサーバーは使いません。左右キーで円を動かし、1秒ごとにビープ音が鳴り、Pキーで一時停止します。

html
<canvas id="c" width="320" height="120"></canvas>
<script>
const g = document.getElementById('c').getContext('2d');
const AC = new AudioContext();
const keys = new Set();
let paused = false, last = performance.now();
let gameTime = 0, x = 160, nextBeat = 1;

function beep() {
  const o = AC.createOscillator(), v = AC.createGain();
  v.gain.value = 0.05;
  o.connect(v).connect(AC.destination);
  o.start(AC.currentTime); o.stop(AC.currentTime + 0.08);
}
function setPaused(p) {
  paused = p;
  if (p) { AC.suspend(); keys.clear(); }
  else { AC.resume(); last = performance.now(); }
}
addEventListener('keydown', e => {
  if (e.code === 'KeyP') { setPaused(!paused); return; }
  if (!paused) { AC.resume(); keys.add(e.code); }
});
addEventListener('keyup', e => keys.delete(e.code));
document.addEventListener('visibilitychange', () => {
  if (document.hidden && !paused) setPaused(true);
});

function frame(now) {
  const dt = Math.min((now - last) / 1000, 0.1);
  last = now;                       // 一時停止中も更新し続ける
  if (!paused) {
    gameTime += dt;                 // ゲーム時計はここだけで進む
    if (keys.has('ArrowLeft'))  x -= 120 * dt;
    if (keys.has('ArrowRight')) x += 120 * dt;
    x = Math.max(10, Math.min(310, x));
    if (gameTime >= nextBeat) { beep(); nextBeat += 1; }
  }
  g.clearRect(0, 0, 320, 120);
  g.fillStyle = '#39c'; g.beginPath(); g.arc(x, 60, 10, 0, 7); g.fill();
  g.fillStyle = '#000'; g.fillText(gameTime.toFixed(1) + ' s', 8, 14);
  if (paused) {
    g.fillStyle = 'rgba(0,0,0,.5)'; g.fillRect(0, 0, 320, 120);
    g.fillStyle = '#fff'; g.fillText('一時停止(Pで再開)', 100, 64);
  }
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>

読み方を順に説明します。

  1. 時計を二重にします。 実時間(performance.now())から出る now と、ゲーム内の gameTime は別物として持ちます。gameTime は paused が偽のフレームでだけ dt を足します。制限時間や敵の出現は、すべて gameTime を基準に判定します。こうしておけば、一時停止で実時間がどれだけ過ぎても、ゲームの時間は進みません。
  2. last はいつも更新します。 一時停止中もフレームは回り続け、last = now が実行されます。この1行がないと、再開した最初のフレームで dt が一時停止していた時間ぶんの巨大な値になり、キャラクターが飛びます。保険として dt に上限(ここでは0.1秒)も付けています。タブを切り替えたあとなど、一時停止以外の原因で時間が空いた場合にも効きます。
  3. 音は AudioContext ごと止めます。 suspend() を呼ぶと音の内部時計も止まります。予約済みの音も、再開するまで鳴りません。再開したら resume() で続きから進みます。ビープを setTimeout で鳴らす実装にすると、一時停止しても予約が生き残り、再開前に鳴ってしまうことがあります。ゲーム内のタイマーには setTimeout を使わず、gameTime で判定するのが安全です。
  4. 入力は受け付け先を切り替えます。 一時停止中は、ゲーム操作用の keys に何も入れません。Pキーだけは常に先に判定して返します。一時停止に入る瞬間に keys.clear() を呼ぶのも大切です。押しっぱなしのキーの状態が残ると、再開後に勝手に動き続けるためです。keyup はいつでも処理して、離したキーが残らないようにしています。
  5. タブが隠れたら自動で一時停止します。 visibilitychange を使い、画面が非表示になった時点で setPaused(true) を呼びます。ブラウザは非表示のタブの描画を止めるので、放っておくとゲームの状態が中途半端になります。

なお、投稿するゲームは、アソバースではsandbox付きのiframeの中で動きます。キー入力を受けるには、画面を一度クリックしてフォーカスを入れる必要がある場合があります。Pキーが効かないときは、まずそこを疑ってください。

自分で確かめる検証手順

一時停止は「動いているように見える」だけでは不十分です。次の手順で、ご自身の環境で記録を取ってください。結果は環境で変わりうるため、ここでは示しません。

  1. 時計が止まっているか。 画面の秒数が3.0前後のときにPを押し、実時間で10秒数えてから再開します。再開直後の表示が3.0前後なら合格です。一時停止中も数字が増えていたら、時計の進め方に漏れがあります。
  2. 再開直後に飛ばないか。 右キーを押しながらPを押し、キーを離してから再開します。円が再開直後に動き出さなければ合格です。動くなら keys.clear() の位置を見直します。
  3. 一時停止中の入力が持ち越されないか。 一時停止中に左キーを数回押してから再開します。円が左へ動かなければ合格です。
  4. 音が溜まっていないか。 ビープの直前でPを押し、長めに待ってから再開します。再開後にビープが続けて何度も鳴るなら、予約が残っています。1回だけ、続きの位置で鳴るのが望ましい状態です。
  5. タブの切り替え。 別のタブへ移って戻ります。戻ったとき、画面が一時停止状態になっていることを確認します。
  6. 回数を変えて繰り返す。 1〜5を、短い一時停止(1秒程度)と長い一時停止(30秒以上)の両方で行います。長いほど dt の暴れや予約の残りが見つかりやすくなります。

起こりやすい誤りも挙げておきます。

  • 一時停止中に last を更新せず、再開時に初めて更新している。
  • setInterval や setTimeout でゲーム内の時間を測っている。
  • 一時停止中も keydown でゲーム操作のキーを記録している。
  • ループ自体を cancelAnimationFrame で止め、再開の入力を拾えなくなっている。

ゲームの種類で分かれる判断

線引きは、ゲームの性格によって変わります。次の三つの問いに答えると、自分のゲームの設計が決まります。

一時停止中に、盤面や情報を見せてよいか。 時間制限のあるゲームで、一時停止中に盤面が全部見えると、考える時間を制限なしに得られます。そのため、一時停止中は盤面を隠す、あるいは一時停止そのものに減点を付ける、といった設計も選べます。記憶を扱うゲームでは、この問題が特に重くなります。

公式作品では、「こだまの森」が一時停止を備えた記憶ゲームです。聴き直しは15点減、間違いは30点減と、行動に点数の代償があります。一時停止中の画面がどう扱われ、点数に影響するのかは、確認済みの情報にないため、作品を開いて確かめてください。「小さな軌道」は、一時停止から同じ位置で再開できることが確認されています。ゲームの時間と状態をそのまま保つ、今回の手順と同じ方向の設計です。「かたちのあいだ」には時計の一時停止があります。何が止まり、何が残るのかを、実際に触って観察してみてください。

応答の音は止めるか。 suspend() はすべての音を止めるので、メニューの決定音も出なくなります。決定音を鳴らしたい場合は、ゲームの音と応答の音を別の経路に分けます。ゲームの音はマスターのゲイン(音量)を経由させて、一時停止中はそこだけ下げます。応答の音は別の経路で鳴らします。この方式は構成が複雑になるので、まずは suspend() で全部止めるところから始め、必要になってから分ける進め方で十分です。

時間制限が短いゲームで、再開に猶予を付けるか。 たとえば「時計じかけの台所」の本番は、1皿28秒という短い制限が確認されています。紙ひこうき郵便も、36秒・40秒・44秒と、ルートごとに短い制限があります。このように秒単位で緊張が続くゲームでは、再開の瞬間にいきなり再開するより、3秒のカウントダウンを挟むほうが親切な場合があります。ただし、これらの作品が一時停止や猶予をどう扱っているかは、確認済みの情報にありません。作品を開いて確かめてください。猶予を入れる場合は、カウントダウン中も時計と入力を止めたまま進めます。

もう一点、「動きを抑える」設定が有効な利用者に配慮するなら、一時停止の画面で点滅や拡大のような動きは避けるのが無難です。止める機能の画面が、動きの多い演出になっては本末転倒です。

次の行動

まず上のコードをHTMLとして保存し、検証手順の1〜5を実行して、自分の環境でチェックを付けてください。次に、ご自身のゲームの「止めるもの・止めないもの」を表にして、setTimeout でゲーム内の時間を測っている箇所を gameTime 基準に置き換えます。仕上がったらアソバースに投稿して、プレイする人の目に触れる前に、タブ切り替えと長い一時停止で最後の確認をしてみてください。

参考資料

  • MDN Web Docs「Web Audio API」「AudioContext」
  • MDN Web Docs「requestAnimationFrame」「Page Visibility API」