プレイヤーは、技術的な些細な理由でプレイが台無しになることを嫌います。足場は明確で、タイミングも完璧だった。それなのに、ミスをしたからではなく、ブラウザのタブのフォーカスが外れたという理由だけでゲームオーバーになってしまうのです。

私はこれを、自身で制作したThree.jsのアーケードゲーム『Solstice Leap』で目の当たりにしました。このゲームは、「ボタンを長押ししてジャンプのチャージを行い、離した瞬間に隙間を飛び越える」という、たった一つの爽快なメカニクスを中心に構成されていました。プレイテスト中、ある苛立たしいパターンに気づきました。メッセージに返信しようとAlt-Tabで画面を切り替えたり、別のタブをクリックしたりしてチャージ中にフォーカスが外れると、ウィンドウをクリックして戻った瞬間にキャラクターが虚空へと投げ出されてしまうのです。あるいは、フォーカスが外れた瞬間に飛び出してしまうこともありました。ゲームが、OSによる日常的な割り込みを「意図的なボタンのリリース」として解釈してしまっていたのです。プレイは不当に終了し、操作への信頼は失われていきました。

根本的な原因:一つのイベントが二つの役割を担っていた

バグは微細ながらも直接的なものでした。元の入力レイヤーでは、ジャンプのリリース(離す)ロジックを、ウィンドウの blur イベントに直接紐付けていました。

window.addEventListener("blur", releaseCharge);

一見すると、筋が通っているように見えるかもしれません。プレイヤーがキーやポインターを押し続けており、何かが止まった。しかし、blur イベントは入力イベントではありません。それはウィンドウ管理のシグナルです。ブラウザのタブがOSのフォーカスを失ったときに発生するもので、タブの切り替え、ウィンドウの最小化、外部モニターのクリック、あるいはシステム通知によるフォーカス奪取などがこれに当たります。これらの動作のどれもが「キャラクターを跳躍させたい」という意味ではありません。「ゲームの外側で何かを操作している」という意味なのです。

blurreleaseCharge にルーティングすることで、ゲームは「意図的な停止(プレイヤーがボタンを離す)」と「外部からの割り込み(ブラウザがアクティブなウィンドウではなくなる)」という、全く異なる二つの概念を混同してしまいました。releaseCharge は現在のチャージ状態に基づいてジャンプ力を計算し、即座に速度を適用するため、チャージ中のフォーカス喪失は、その時点で蓄積されていたパワーによる跳躍を引き起こしてしまったのです。プレイヤーが画面に戻ったときには、キャラクターはすでに死んでいるか、意図しない動きによって進行状況が台無しになっていました。

Three.js開発者にとってのブラウザの現実

Three.jsは強力な3Dキャンバスを提供しますが、入力は依然としてDOMを通じて流れます。この分離が重要です。ブラウザは、スペースキーを押し続けていることがジャンプのチャージであることを本質的には知りません。単に「キーが押されている」ことしか知りません。フォーカスがドキュメントから外れたとき、ブラウザは保持されているすべてのキーに対して自動的に keyup を生成することはありません。代わりに、「ウィンドウが失われた」ことを伝えてくるのです。もしゲームロジックが「フォーカスの欠如 = 入力の欠如」であると仮定してしまうと、意図しない「幽霊アクション」が発生してしまいます。

この区別は、弓を引く、車両のエンジンを吹かす、チャージ魔法を唱える、スタミナを消費して疾走するなど、至る所で見られる「チャージアップ・メカニクス」において特に重要です。時間の経過とともに状態を蓄積する持続的なアクションは、すべて同じ誤解に対して脆弱です。ネイティブアプリケーションでは、フォーカスを失った際にシミュレーション全体を一時停止することがよくあります。ブラウザゲームでも同様のことは可能ですが、たとえ実行を継続する場合でも、システムの割り込みとプレイヤーのコマンドは分離しなければなりません。

「意図」と「中断」を切り離す

修正には、チャージ状態からの脱出経路を二つの異なるレーンに分割する必要がありました。一つは意図的な入力を扱うレーン。もう一つは、現実世界が介入した際の「生命維持」を担うレーンです。

意図的なリリースpointerup および keyup)は、引き続きジャンプを実行します。これらは、プレイヤーによる直接的な「実行」のシグナルです。

フォーカス喪失イベントblurpointercancel、およびドキュメントが非表示になった際の visibilitychange)は、新たに cancelCharge と呼ばれる別の関数をトリガーするようにします。

cancelCharge は、修正されたリリースではありません。それは「強制リセット」です。蓄積されたチャージ力をゼロに戻し、プレイヤーの視覚的なスケールをデフォルトの待機状態に復元し、画面上のチャージメーターをゼロにし、ゲームをエイミング(照準)モードに戻します。最も重要なのは、これが跳躍の軌道計算コードには一切触れないことです。速度計算も、物理的なインパルスも、跳躍も発生しません。チャージは安全に霧散します。

更新された仕組みの概念的なイメージは以下の通りです。

window.addEventListener("blur", cancelCharge);

しかし、真のアーキテクチャ上の変更は、「チャージ」が「二つの可能な脱出経路を持つ状態」になったという認識にあります。適切なリリースが行われた場合、ステートマシンはチャージ率を評価し、ジャンプ速度を計算し、跳躍アニメーションへと遷移します。割り込みが発生した場合、ステートマシンは中断してアイドル状態へと戻ります。これらの経路を分離しておくことで、副作用を防ぐことができるのです。

pointercancel も監視すべきです。ブラウザは、ポインティングデバイスにシステムレベルの中断(タッチスクリーンでのパームリジェクション、システムメニューの呼び出し、または異常な条件下でのペンの接触喪失など)を検知したときにこれをディスパッチします。blurpointercancel と組み合わせることで、デスクトップのマルチタスクとモバイルの中断の両方をカバーできます。さらに visibilitychange を追加することで、一部のブラウザやOSの組み合わせで発生しうる、window オブジェクト自体に必ずしも blur が発生せずにタブを切り替えるシナリオも捉えることができます。

境界条件のテスト

入力のバグを修正するには、ハッピーパス以外のテストが必要です。単一のタブで穏やかにゲームをプレイしているだけでは、こうした問題は見つかりません。新しい挙動を検証するために、私は2つの特定のシナリオを実行しました。

まず、ジャンプのチャージを開始した状態で、キーボードを使用してブラウザのタブを切り替え、強制的に blur イベントを発生させました。ゲームは即座にチャージモードを終了し、エイム状態に戻りました。ジャンプは実行されず、速度も適用されませんでした。チャージメーターもクリアされました。次に、通常のチャージを行い、意図的にボタンを離しました。ジャンプは以前と全く同じように、同じ放物線と力のスケーリングで実行されました。ゲームの感触は損なわれず、エッジケースのみが修正されました。

両方のパスは独立していなければなりませんでした。誤ったジャンプを防ぎつつ、正当なジャンプの感覚を鈍らせるような修正は、修正ではなく、別のバグを生んでいることになります。元のメカニクスのキレを維持しながら、ブラウザの混乱に対して堅牢にすることが目標でした。

持続的な入力のためのパターン

この問題は、プラットフォーマーをはるかに超えて広がっています。連続した押し込みに依存するあらゆる Three.js ゲームが、このリスクにさらされています。マウスを押し続けることでテンションが溜まる一人称視点のグラップリングフックや、キーを押し続けることでブーストをチャージするレースゲームを考えてみてください。もし、あなたのクリーンアップロジックがボタンのリリースハンドラーにしか存在せず、タブの切り替え、OSの通知、または画面ロックを考慮していないのであれば、あなたはオペレーティングシステムに自分のゲームをプレイさせていることになります。

より広範なパターンとしては、入力レイヤーを「active input(入力中)」、「released input(リリース)」、「cancelled input(キャンセル)」という3つの明示的な状態で構築することです。active input はチャージを蓄積したりアクションを開始したりします。released input はそれを確定させます。cancelled input はそれをクリーンに終了させます。window の blur をリリースとして扱ってはいけません。ブラウザはホストであり、プレイヤーではないのです。

人間の行動を念頭に置く

人々はタブを切り替えます。ダイレクトメッセージに返信します。セカンドモニターでガイドを調べます。仕事の Slack 通知を受け取ります。これらはエッジケースではなく、ブラウザ内における標準的な行動です。通常のマルチタスクを行う人間を罰するようなブラウザゲームは、脆弱に感じられます。フォーカスの喪失を「コマンド」ではなく「キャンセル」として扱うことで、Solstice Leap は、プレイヤーが慎重にセットアップしたジャンプを犠牲にすることなく、一瞬席を外せるようになりました。

blur イベントはリリースイベントではありません。それは単に、ブラウザが「部屋から出ます」と言っているだけなのです。それに合わせてコードを書けば、プレイヤーはコントロールを信頼し、本当に飛びたいと思ったときに、迷わずジャンプできるようになるでしょう。