ゲームを閉じたのに動き続ける問題を止める
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本作ってみて、いちばん実感していることかもしれません。