ゲームを閉じたのに動き続ける問題を止める

Phaser のインスタンスを破棄し損ねると、画面から消えても裏で走り続けます。全画面表示との組み合わせで踏んだ落とし穴と、Canvas を使い回すための引数について。

ゲームページには「ゲームを開始」ボタンがあり、押すと全画面になってゲームが始まります。右上の「✕」で閉じると、元の解説ページに戻ります。

この閉じる処理が、思ったより厄介でした。

症状:閉じても裏で動き続ける

最初の実装では、閉じるボタンで全画面のクラスを外すだけにしていました。見た目上はゲームが消えて、解説ページに戻ります。

ところが、ゲームは裏で動き続けていました

Phaser は毎フレーム更新処理を回し続けます。画面から見えなくなっても、インスタンスが生きている限り止まりません。弾幕フィーバーのように敵や弾が数百個動くゲームだと、閉じたあともずっとその計算が続きます。

さらに悪いことに、もう一度「開始」を押すと2つ目のインスタンスが生まれます。3回開けば3つ。端末が目に見えて重くなります。

対処1:閉じるときに必ず破棄する

まず、閉じる処理で確実に破棄するようにしました。

function exitFullscreen(): void {
  container.classList.remove('game-container--fullscreen');
  document.body.style.overflow = '';

  if (activeGame) activeGame.destroy(false);
  activeGame = null;

  if (canvas) canvas.style.display = 'none';
  if (placeholder) placeholder.style.display = '';
}

activeGame に現在のインスタンスを持っておき、閉じるときに破棄して null に戻します。

対処2:開始時にも念のため破棄する

閉じるボタン以外の経路で開始が押される可能性もあるので、開始時にも前のインスタンスが残っていないか確認しています。

startBtn?.addEventListener('click', async () => {
  if (activeGame) {
    activeGame.destroy(false);
    activeGame = null;
  }
  // ...ここから新しいゲームを起動...
});

「閉じたときに必ず破棄しているのだから不要では」と思うかもしれません。しかし破棄の経路がひとつだけだと、そこを通らなかったときに詰みます。二重に守っておくコストはほぼゼロなので、入れておきました。

destroy(false)false は何か

Phaser の destroy() には引数があります。Canvas 要素も一緒に削除するかどうかを指定するものです。

ここでは false を渡しています。つまりCanvas は残す

理由は、Canvas が Astro のテンプレート側で書かれた要素だからです。

<canvas id="game-canvas" width="400" height="300"></canvas>

Phaser にこれを消させてしまうと、次に「開始」を押したときに取り付ける先がありません。毎回作り直すこともできますが、テンプレートに書いた要素をスクリプトが勝手に消すのは、あとから読む人を混乱させます。

なので Canvas は据え置いて、style.display の切り替えで見せ隠しするだけにしました。所有権を分けるという考え方です。Canvas の生存はテンプレートが持ち、その中身は Phaser が持つ。

対処3:ページを離れるときも止める

閉じるボタンを押さずに、そのままブラウザの戻るボタンでページを離れる人もいます。

この場合に備えて、pagehide イベントでも破棄しています。

window.addEventListener('pagehide', exitFullscreen, { once: true });

unload ではなく pagehide を使っているのは、モバイルで確実に発火するのは pagehide のほうだからです。unload はブラウザによっては呼ばれないことがあり、特にスマートフォンでは信頼できません。

{ once: true } を付けているのは、一度発火したら自動でリスナーを外すためです。付けないとリスナーが積み上がっていきます。

全画面の実装

全画面表示は、ブラウザの全画面API(requestFullscreen)ではなく、CSSで画面いっぱいに広げる方法にしました。

.game-container--fullscreen {
  position: fixed;
  inset: 0;
  z-index: 1000;
  max-width: none;
  aspect-ratio: auto;
  border-radius: 0;
  margin: 0;
}

ブラウザの全画面APIを使わなかったのは、モバイルでの挙動が端末ごとに違いすぎるからです。許可を求められたり、そもそも効かなかったり、抜けたときの状態が不安定だったり。

position: fixed; inset: 0; なら、どの環境でも同じように動きます。確実に動く単純な方法があるなら、そちらを選ぶという判断でした。

あわせて、全画面のあいだは背後のスクロールを止めています。

document.body.style.overflow = 'hidden';

これをやらないと、ゲーム中にスワイプした指が背後のページを動かしてしまいます。方向パニックのようなスワイプ操作のゲームでは致命的です。

そして閉じるときに、必ず元に戻します。'' を代入して、CSS側の指定に戻しているのがポイントです。'auto' などと具体的な値を入れてしまうと、元の指定を上書きしてしまいます。

ページ遷移との組み合わせ

このサイトは Astro のクライアントルーターを使っていて、ページ間の移動でページ全体を再読み込みしません。これが最後の落とし穴でした。

普通のページなら、離れた時点でスクリプトごと消えます。しかしクライアントルーティングでは、JavaScript の状態がページをまたいで残ります

そのため初期化は、ページが表示されるたびに発火するイベントで行っています。

document.addEventListener('astro:page-load', initGamePage);

このイベントは初回の読み込み時にも発火するので、これ1行で両方の場合をカバーできます。

まとめ:後始末は経路の数だけ必要

このあたりを直しながら考えていたのは、**「終わり方は1つではない」**ということでした。

ゲームが終わる経路は、閉じるボタン、ページ遷移、タブを閉じる、戻るボタン、と複数あります。そのどれを通っても、同じ後始末が走る必要があります。

だから exitFullscreen() という関数を1つ用意して、あらゆる経路からそれを呼ぶ形にしました。経路ごとに個別の処理を書くと、必ずどこかが漏れます。

始めるコードより、終わるコードのほうが難しい。10本作ってみて、いちばん実感していることかもしれません。

広告