動くところと、読めるところを分ける

敵、弾、XP、衝突はCanvasで描く。menu、pause、level up、resultはReactのDOMに残す。毎frame変わるものをReactへ流し込みすぎず、ボタンや見出しはキーボードとscreen readerが扱える形にするためだ。

ゲームの計算はsrc/game/engine.tsへ置き、ブラウザAPIから離した。UIがなくてもrunを進められるので、death、clear、retry、level upの流れをテストから再現できる。

増えすぎる前に上限を置く

短いゲームでも、敵と弾とXPが溜まり続ければスマホは苦しくなる。現在の上限はenemy 90、projectile 120、XP drop 90。ゲームループへ渡すdeltaも上限を付け、非表示タブでは自動pauseする。

最適化は後から考えるとしても、増加が止まる場所だけは最初に決めた。何台かの端末で測る前に、無限に増える設計を入れない。

saveは読めたら使う、怪しければ捨てる

backendやloginは使わず、best score、meta coin、BGM、SE、muteをlocalStorageへ保存する。schema versionは1。数値が範囲外、JSONが壊れている、知らないversionだった場合は、そのsaveだけを削除して初期値へ戻す。

古いデータを無理やり解釈してゲーム全体を起動不能にするより、失うものを端末内の記録だけに限定する。書き込みに失敗した場合も、run自体は続けられる。

localStorageのkey
99-seconds-dungeon:save

version / bestScore / metaCoins
settings: bgmVolume / seVolume / muted

外部サービスはadapterの向こう側

Analyticsはevent名とpayloadの型を持つけれど、MVPはConsoleAnalyticsだけ。個人情報を外へ送らない。広告もMockMonetizationだけで、real SDKは入っていない。Audioもuser gesture後に初めてAudioContextを開始する。

本番サービスを選ぶときはadapter実装を追加する。ゲームルールの途中にSDK固有コードを散らさなければ、CrazyGames向けと自前サイト向けを分けるときも、核になる99秒のrunを触らずに済む。

まだ実機確認は残っている

mobile-firstのlayout、safe area、大きいtouch target、横scroll防止、reduced motionまでは実装した。ただし、実機のiPhone SafariとAndroid Chromeでの操作感は未確認。

自動テストが通っても、親指でDashを押しやすいか、古い端末でframeが落ちないかまでは分からない。ここは完成したことにせず、public test前の確認項目として残している。