「一時停止」を作り込む:時計・音・入力の三つを止める
遊びをつくる執筆にClaudeを使用しています。

Pキーを押すとゲームが止まる機能は、数行で形になります。ところが、作ってみると意外なほど穴が出ます。たとえば、一時停止中も効果音の予約が残っていて、再開した瞬間にまとめて鳴る場面を考えてみます。画面は止まっているのに裏でタイマーが進み、再開と同時に敵が飛び出す場面もあります。止まっているあいだに押した矢印キーが、再開後に効いてしまう場合もあります。 この記事の問いは「何を止めれば一時停止と呼べるのか」です。答えは「全部止める」ではありません。ゲームの時計、音、ゲーム操作の入力は止めます。一方で、描画、再開の入力、フレームを回すループ自体は止めてはいけません。ここを取り違えると、止まったまま戻れないゲームになります。 後半は、ブラウザだけで動く最小のコードで、この線引きを手順として組み立てます。完成したら、ご自身の環境で確かめる検証手順も付けます。結果は記事側では示しません。確かめるのは読者自身です。
止めるものと止めないものを先に決める
コードを書く前に、一覧を作ります。一時停止の不具合の多くは、実装の難しさではなく、この線引きを決めないまま書き始めることから起きます。
- ゲーム内の経過時間、一時停止中の扱い:止める、理由:制限時間や敵の出現が進むと不公平になります
- キャラクターや弾の移動、一時停止中の扱い:止める、理由:時計に連動しているためです
- 効果音・BGMの再生、一時停止中の扱い:止める、理由:再開時に続きから聞こえる必要があります
- ゲーム操作の入力(移動・攻撃)、一時停止中の扱い:受け付けない、理由:再開後に持ち越されるのを防ぎます
- 再開・メニューの入力、一時停止中の扱い:止めない、理由:戻れなくなるためです
- 描画、一時停止中の扱い:止めない、理由:「一時停止」の表示を出す必要があります
- ループ(requestAnimationFrame)、一時停止中の扱い:止めない、理由:再開の合図を拾い続けるためです
最後の2行が「止めてはいけないもの」です。ループまで止めてしまうと、再開の入力を処理する場所がなくなります。ループは回し続け、その中で「ゲームの時計を進めるかどうか」だけを切り替えます。
もう一つ、判断が分かれるものがあります。メニューを開く音や決定音のような、操作に対する応答の音です。止めるか鳴らすかは、後半で扱います。
時計を二重にして、音と入力を止める
ここからが手順です。次のコードは、1つのHTMLファイルとして保存すればそのまま動きます。外部のライブラリやサーバーは使いません。左右キーで円を動かし、1秒ごとにビープ音が鳴り、Pキーで一時停止します。
<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>読み方を順に説明します。
- 時計を二重にします。 実時間(performance.now())から出る now と、ゲーム内の gameTime は別物として持ちます。gameTime は paused が偽のフレームでだけ dt を足します。制限時間や敵の出現は、すべて gameTime を基準に判定します。こうしておけば、一時停止で実時間がどれだけ過ぎても、ゲームの時間は進みません。
- last はいつも更新します。 一時停止中もフレームは回り続け、last = now が実行されます。この1行がないと、再開した最初のフレームで dt が一時停止していた時間ぶんの巨大な値になり、キャラクターが飛びます。保険として dt に上限(ここでは0.1秒)も付けています。タブを切り替えたあとなど、一時停止以外の原因で時間が空いた場合にも効きます。
- 音は AudioContext ごと止めます。 suspend() を呼ぶと音の内部時計も止まります。予約済みの音も、再開するまで鳴りません。再開したら resume() で続きから進みます。ビープを setTimeout で鳴らす実装にすると、一時停止しても予約が生き残り、再開前に鳴ってしまうことがあります。ゲーム内のタイマーには setTimeout を使わず、gameTime で判定するのが安全です。
- 入力は受け付け先を切り替えます。 一時停止中は、ゲーム操作用の keys に何も入れません。Pキーだけは常に先に判定して返します。一時停止に入る瞬間に keys.clear() を呼ぶのも大切です。押しっぱなしのキーの状態が残ると、再開後に勝手に動き続けるためです。keyup はいつでも処理して、離したキーが残らないようにしています。
- タブが隠れたら自動で一時停止します。 visibilitychange を使い、画面が非表示になった時点で setPaused(true) を呼びます。ブラウザは非表示のタブの描画を止めるので、放っておくとゲームの状態が中途半端になります。
なお、投稿するゲームは、アソバースではsandbox付きのiframeの中で動きます。キー入力を受けるには、画面を一度クリックしてフォーカスを入れる必要がある場合があります。Pキーが効かないときは、まずそこを疑ってください。
自分で確かめる検証手順
一時停止は「動いているように見える」だけでは不十分です。次の手順で、ご自身の環境で記録を取ってください。結果は環境で変わりうるため、ここでは示しません。
- 時計が止まっているか。 画面の秒数が3.0前後のときにPを押し、実時間で10秒数えてから再開します。再開直後の表示が3.0前後なら合格です。一時停止中も数字が増えていたら、時計の進め方に漏れがあります。
- 再開直後に飛ばないか。 右キーを押しながらPを押し、キーを離してから再開します。円が再開直後に動き出さなければ合格です。動くなら keys.clear() の位置を見直します。
- 一時停止中の入力が持ち越されないか。 一時停止中に左キーを数回押してから再開します。円が左へ動かなければ合格です。
- 音が溜まっていないか。 ビープの直前でPを押し、長めに待ってから再開します。再開後にビープが続けて何度も鳴るなら、予約が残っています。1回だけ、続きの位置で鳴るのが望ましい状態です。
- タブの切り替え。 別のタブへ移って戻ります。戻ったとき、画面が一時停止状態になっていることを確認します。
- 回数を変えて繰り返す。 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」

