DESIGN DOCUMENT

設計書 — 統合アーキテクチャ

DESIGN / DESIGN-V2 / DESIGN-V3 / DESIGN-V4 をエンジニア向けに統合した一次資料

本ページは v1(基盤)/ v2(月アップデート)/ v3(箱庭東京アップデート)/ v4(リアル東京 OSM)/ v5(ポリッシュ)の確定設計(docs/DESIGN.md / DESIGN-V2.md / DESIGN-V3.md / DESIGN-V4.md)を統合したものです。各バージョンの設計は ultracode ワークフロー(3視点並列提案→審査統合 / 設計→敵対的批評→改訂)で確定しています。v5 は実プレイフィードバック起点のポリッシュで、計画→敵対的チェック→3並列実装で確定(13章・14章参照)。

技術スタック: Three.js r177(固定)+ Vite / 純粋静的サイト(Cloudflare Pages)/ ランタイムアセットほぼゼロ(ジオメトリ・テクスチャ・音声をすべてコード生成)/ 約30個の小さなESモジュール + JSDoc型 + 単一イベントバス。

全バージョン共通の不変条件(binding invariants)

不変条件内容
リスケールのピクセル同一性ティア昇格の相似変換フレームは、変換なしフレームとピクセル単位で同一に描画される
シームレスネス法吸収判定・カメラ・フォグ・速度はすべて半径の連続関数。ティア番号は見た目のみを駆動
ゼロアロケーションホットパスでのフレーム毎のGCごみは0バイト(WebAudioのみ有界例外 ≦60ノード/秒)
固定60Hzアキュムレータ式固定タイムステップ、最大3サブステップ
決定論スポーンシード付きPRNGで同シード=同世界(チャンク内容はシードの純関数)
ドローコール台帳上限をコメント付き台帳で管理(v1: 55 → v2: 60 → v3: 72 → v4/v5: 72据え置き、v4台帳68)

1. 全体アーキテクチャ

何を / なぜ

「画鋲(2cm)からスカイツリー(634m)まで5桁のスケール変化を60fpsで」という要件に対し、物理・ワールド・レンダリング・ゲームシステム・UIを完全に分離した約30モジュール構成を採用。モジュール間の結合は (a) コンストラクタ注入と (b) 再利用ペイロードのイベントバス(src/core/events.js)の2経路のみに限定し、契約(型・イベント名・定数)を src/types.js / src/config/* に凍結することで5並列ストリームでの同時実装を可能にした。

main.js フレーム順序の唯一の所有者 core events / rng pool / mathUtils config tiers / catalog / tuning cityMap / donackLines world spawner / curated scaleManager / terrain physics ballPhysics absorb render instances / cameraRig environment / ball … game finale / runStats collection input / audio input / sfx / bgm (合成SE・合成BGM) ui hud / screens donack イベントバス events.js 再利用ペイロード・ゼロアロケーション emit(ABSORB / TIER_UP / RESCALE / GOAL_* …) main.js が直接呼ぶ バス経由(購読)
図1: モジュール構成 — 結合はコンストラクタ注入とイベントバスの2経路のみ

主要モジュールマップ

グループファイル責務
起動/ループsrc/main.jsフレーム順序の唯一の所有者。状態機械 TITLE→PLAYING→FINALE→WIN。ゲームロジックは持たない
コアsrc/core/events.js rng.js pool.js mathUtils.jsイベントバス(ゼロアロケーションemit)/ mulberry32 PRNG / フリーリスト / スプリング・イージング
設定src/config/tiers.js catalog.js tuning.js cityMap.js(v3) donackLines.js(v3)ティア表 / アーキタイプカタログ / 全フィール定数の単一ファイル / 箱庭東京データ / 実況コピー
ワールドsrc/world/objects.js spatialHash.js spawner.js scaleManager.js curated.js(v3) terrain.js(v3)SoAストア / 空間ハッシュ / チャンクスポーナー / スケール管理 / キュレーションスポーナー / 地形
物理src/physics/ballPhysics.js absorb.jsボール運動学 / 吸収・押し戻し判定
描画src/render/renderer.js geometryFactory.js instances.js extraPools.js(v3) ball.js cameraRig.js environment.js effects.js backdrop.js(v2) goalTower.js(v3)レンダラ / ジオメトリ生成 / InstancedMeshプール / BatchedMeshプール / 塊本体 / カメラ / 空・フォグ / エフェクト / 遠景 / スカイツリー
ゲームsrc/game/finale.js(v2) runStats.js(v2) collection.js(v3)フィナーレ状態機械 / タイム・スコア・ランク / コレクション図鑑
入出力src/input/input.js / src/audio/sfx.js bgm.js(v2) / src/ui/hud.js screens.js donack.js(v3)入力正規化 / 合成SE / 合成BGM / HUD / タイトル・リザルト / ドナック実況

フレームループ順序(v3最終形・BINDING)

main.js ヘッダコメントと同一。プレーンな関数呼び出しであり、イベントではない(順序が契約)。

1 入力読み取り intent = input.read() inputLocked 時は intent をゼロ化 2 固定60Hzステップ(最大3回) ballPhys.step(地形衝突を内包・v3) absorb.resolve(吸収・押し戻し) 吸収は inputLocked でスキップ 3 スポーナー更新(2系統) spawner.update() + curated.update() 4 スケール管理 maybeTierUp(リスケール)/ maybeRebase 4.5 フィナーレ更新(v2+) finale.update — 接触判定・シネマカメラ 5 塊の更新 ball.update — アタッチアニメ・埋没カリング 6 カメラ・環境・エフェクト cameraRig.update / env / backdrop / effects 6.5 runStats.addSimTime(v2+) cameraOwned 時は finale が駆動 7 フラッシュ & 描画 instances.flush() → renderer.render() 次フレームへ(requestAnimationFrame) HUDはイベント駆動で別更新
図2: フレームループ — 順序そのものが凍結された契約
1    intent = input.read()              // inputLocked時はゼロ化
2    while (accumulator >= 1/60, max3):
        ballPhys.step(dt, intent, yaw)  // terrain.collide を内包(v3)
        absorb.resolve(...)             // inputLocked時スキップ
3    spawner.update(...); curated.update(...)   // 同上ゲート
4    scaleMgr.maybeTierUp(...); scaleMgr.maybeRebase(...)
4.5  finale.update(frameDt, ballState)  // 接触判定・シネマカメラ(v2+)
5    ball.update(dt)                    // アタッチアニメ・埋没カリング
6    cameraRig.update(...)              // cameraOwned時はfinaleが駆動
     env.update(); backdrop.update(); effects.update()
6.5  runStats.addSimTime(steps * FIXED_DT)      // (v2+)
7    instances.flush(); renderer.render()        // HUDはイベント駆動
並列開発を可能にした「契約凍結」の仕組み

各バージョンの実装は「Phase 0(リード約半日〜1日)→ 5並列ストリーム → 統合」で行われた。Phase 0 が凍結するもの: types.js のJSDoc型 / events.js のイベント名+ペイロード形 / tuning.js の全定数 / tiers.js のティア・アーキタイプID / index.html のDOM id / main.js のフレーム順序スケルトン。

各ストリームはファイル単位でゼロオーバーラップに分割され、main.js は統合者しか触れない。クロスストリーム例外(v3では scaleManager の GOAL_RADIUS_M 直接import、ballPhysics の terrain 注入の2件)はすべて設計書の例外台帳に明記される。v3 ではさらに @deprecated MOON_* エイリアス層を Phase 0 が用意し、移行中もビルドが壊れない構造(退役は grep -rn 'MOON_' src == 0 のゲートで統合者が最後に実施)。

2. スケールシステム — 相似変換リスケールとシームレスネス法

何を / なぜ

塊が5桁成長してもFloat32精度と60fpsを保つため、2つの数値系を分離した:

リスケール(=ティア昇格)の仕組み

simRadius >= 2.5 に達したフレームの物理更新と描画の間に、1フレームで一様相似変換 S = 0.2 を適用する。図解はストーリーページの図4を参照。

worldScale /= S                       // 実寸の辻褄はdouble側で合わせる
ball の pos/radius/vel *= S           // 角速度はスケール不変
全生存オブジェクト(~4000)の px/py/pz/radius *= S   // SoA密ループ <0.3ms
ballGroup.scale *= S                  // くっ付き済みオブジェクトはボール子階層なので自動追従
空間ハッシュ3つを再構築
インスタンス行列を一括書き直し(meshごとに needsUpdate 1回)
フォグ/ライト/影/カメラスプリング状態 *= S

これは一様相似変換であり、すべての視覚量が半径比例なので、リスケールしたフレームはしなかったフレームとピクセル同一に描画される。dev では強制リスケールキー + スクリーンショット差分で恒常的に検証される。

シームレスネス法(構造的ルール)

「閾値で何かがポップする」ことを構造的に不可能にする法則:

関連: src/world/scaleManager.js src/config/tiers.js src/config/tuning.js

ティア表の変遷(v1: 6ティア5cm→750m / v3: 7ティア2cm→634m)

v1/v2(6ティア、×5/ティア): T0 Desk 5cm–25cm / T1 Room –1.25m / T2 Street –6m / T3 Town –30m / T4 City –150m / T5 Skyline –750m。v1 は 500m でWINバナー、v2 は 420m で「月が呼ぶ」→500m で月降臨。

v3(7ティア、開始0.02m): T0 パーツ棚 0.02m / T1 ショップ 0.10m / T2 電気街 0.50m / T3 下町 2.5m / T4 都心 12m / T5 大東京 60m / T6 スカイライン 300m。リスケール梯子(実寸 0.1/0.5/2.5/12.5/62.5/312.5m)は不変。GOAL_CALL 380m → GOAL_RADIUS 420m で接触アーム。ペーシングの単一真実: 典型初回クリア5:30–6:30、最適~3:30–4:00(Phase-3 実測リチューン済み)。

3. 物理 — 自作アーケード物理と空間ハッシュ

何を / なぜ

物理ライブラリは不採用(3提案全会一致)。rapier3d は ~1.7MB wasm でリスケール時の全ボディのテレポートスケーリングと相性最悪、cannon-es は1k超ボディでJSソルバが遅くアーケード調整と衝突する。このゲームの動体はボール1個だけ(解析平面 y=0 上のキネマティック球)で、他はすべて吸収されるまで静的バウンディング球 —— 約400行・依存ゼロ・シードPRNGと併せて決定論的。

どう実装したか

v3 追加: world/terrain.jsCityTerrain — ショップの壁/棚(円vs AABB判定、半径4.0mで一斉解除)、スカイツリー基部の恒久円コライダ(r=54m実寸、絶対に吸収されない)、マップ境界クランプ+ 4r ソフト減速帯。

関連: src/physics/ballPhysics.js src/physics/absorb.js src/world/spatialHash.js src/world/objects.js src/world/terrain.js

4. レンダリング — InstancedMeshプールとドローコール台帳

何を / なぜ

数千オブジェクトを統合GPUで60fps描画するため、ドローコールを台帳で管理し、マテリアル3種・シャドウマップなしに徹底。すべてのスポーン/フェード/消滅遷移は行列スケールのアニメーションで表現し、不透明度(=ソート発生)は一切使わない。

ドローコール台帳(正直な最悪値で管理)

バージョン内訳最悪値 / 上限
v18アーキタイプ×3ティア=24ワールド + 8スタック + ボール/地面/空/影 + エフェクト~3〜38 / 55
v24帯遷移窓 40ワールド + 8スタック + 固定6(月本体+グロー含む)+ 遠景156 / 60
v340 + 8 + 固定6 + 遠景1 + スカイツリー2 + 地形1 + 水面/岸壁2 + EXTRAプール464 / 72

どう実装したか

関連: src/render/instances.js extraPools.js ball.js cameraRig.js environment.js geometryFactory.js renderer.js

なぜシャドウマップを捨ててブロブシャドウにしたか

ライトはヘミスフィア+ディレクショナルの2灯のみで、影は canvas のラジアルグラデーションを貼った Basic マテリアルのデカール1枚。シャドウマップはレンダーパスが丸ごと1本増える上に、5桁のスケール変化に対してカスケード設定が破綻する。ブロブシャドウは半径比例でスケールするだけなのでどのスケールでも読みやすく、リスケールのピクセル同一性も自明に保たれる。

v2 の空(太陽・月・星・雲)はすべてスカイドームのフラグメントシェーダ内で完結し(方向ベースの計算なのでリスケール不変)、ドローコール増加ゼロで実装された。

5. ワールド生成 — チャンクスポーナーとキュレーションの併存

何を / なぜ

v1/v2 は無限平面の決定論的チャンク生成のみ。v3 で「実在の東京を再現した有限の箱庭」が要件になり、手続き生成(チャンク)と手作業配置(キュレーション)を同じ ObjectStore 上で共存させる必要が生じた。2つのスポーナーがスロットの所有権を取り違えると即メモリ破壊級のバグになるため、所有権プロトコルを Phase 0 で凍結した。

チャンクスポーナー 手続き生成(シード依存) 無限の埋め草オブジェクト CuratedSpawner 手作業配置411(シード非依存) ランドマーク11・コレクション12 共有 ObjectStore(8192スロット・SoA) 旗付きスロット = FLAG_CURATED(16) — チャンク側の掃除処理は1ビットテストで全スキップ dev: 300フレームごとに chunk.alive + curated.alive === store.alive をassert ABSORB イベントの購読順(凍結された契約) chunk curated ball runStats collection sfx/fx/hud この順序があるから「curated は instanceSlot を読まず、清掃を次の update() に遅延する」規約が成立する
図3: 2スポーナーの所有権プロトコル — FLAG_CURATED の旗と凍結された購読順

チャンクスポーナー(v1から継続)

キュレーションスポーナー(v3新規 world/curated.js

約370の固定配置(ショップ内装240 + 街路70 + 出口導線22 + 街区装飾~38)+ ランドマーク11 + コレクティブル12 + ショップ外殻を所有。データは src/config/cityMap.js(実寸メートル)にあり、mulberry32(0x544f4b59) で展開 — シード非依存なのでランドマーク/コレクティブルは全プレイで同一位置(チャンクフィラーだけが ?seed= で変わる)。

FLAG_CURATED 所有権プロトコル(凍結):

レアアイテム(v2導入)

_spawnPlacement で全配置に対し rareRoll を最後に無条件でドロー(決定論契約を維持)。RARE_CHANCE=0.002 で金色ティント+1.15倍スケール+FLAG_RARE。生存レアは (storeIdx, slotGen) ペアの Int32Array で追跡し、effects が金色のきらめきをポーリング。同シード=同レア配置。

関連: src/world/spawner.js curated.js objects.js src/config/cityMap.js

箱庭東京マップの構造とバリデータ

マップは 3.6×3.8km の矩形(MAP_BOUNDS x[-1800,1800] z[-1800,2000]、単一ソースは tuning.js)。原点=ボール開始点(BallPhysics.reset のハードコード (0,r,0) をそのまま正にする設計判断)。開始地点は屋根なし・全面開口のアキバパーツ館(6×8m、ドールハウス的フィクション): 壁5枚+棚/カウンター等プリズム4個だけが authored 衝突で、全世界に段差ゼロ(h=0)— 「床の意味論」系の批評クラスを構造的に表現不能にした。半径4.0mで地形一斉解除(壁0.6sフェード+棚上アイテムのy降下lerp+カメラクランプ解除の単発構造ハンドオフ、文書化例外)。

ランドマーク11基は約1:5圧縮の地理忠実配置(ハチ公1.85m→西郷像6.2m→雷門10.8m→ラジオ会館37m→109 43m→ドーム85m→東京駅135m→議事堂215m→レインボーブリッジ231m→東京タワー262mの吸収閾値ラダー。東京タワーのGROWTH_K=10ジャンプ 262→406mがフィナーレ帯への公式ランプ)。スカイツリーはストアに存在せず接触フィナーレ専用。

validateCityMap() がブートで検証するもの: 通路最低幅1.1m / ボール開始クリアランスと出口レーン / 棚アイテムの3D到達不等式 / ショップが密閉不能であること(最大到達半径 < ゲート半径の半分)/ 出口導線の成長チェーン(出口半径0.10–0.4mで150m以内に吸収可能物≧8)/ ランドマークラダーの単調増加 / SKYTREE_COLLIDER_K(0.6) < GOAL_CONTACT_PAD(0.85)(フィナーレが必ず勝つ)。

6. ゲームシステム — 成長式・ダッシュ・スコア/ランク

成長式と growthKForObjR テーパー

基本式(v1から): 吸収時 newR = cbrt(R³ + K·r³)ABSORB_RATIO = 0.65GROWTH_K = 10。見た目の半径は ≦1.5r/s でスルー(大物を吸うと「段差」でなく「膨らむ」)。

v3 の臨界ペーシング修正: K=10 と吸収比0.65の組合せは、閾値ギリギリの吸収1回で半径×1.554、捕獲レートは ~R² でスケールするため、同帯域の供給が連続している場所では成長が超指数的になる(実測で秋葉原にて 4m→117m を3秒の暴走)。修正はオブジェクト実半径の連続関数による有効K のテーパー(src/config/tuning.js:353):

export function growthKForObjR(objRealM) {
  if (objRealM <= GROWTH_NORM_REF_M /*0.1m*/) return GROWTH_K;        // 10
  const k = GROWTH_K * Math.pow(GROWTH_NORM_REF_M / objRealM, GROWTH_NORM_POW /*0.65*/);
  return k < GROWTH_K_FLOOR /*2*/ ? GROWTH_K_FLOOR : k;
}

関連: src/config/tuning.js src/physics/absorb.js:239-246 src/world/spawner.js:801

ダッシュ(v2導入)

ゲージ式(dashGauge01 開始1.0、dt/4.0s で回復+吸収ごとに+0.03)。発動で速度上限×2.2・加速×1.8 が0.8秒、インパルス 7.0×r を速度方向(低速時はカメラ前方)へ。ゲージ/タイマーは無次元/秒なのでリスケール不変、フックは不要。演出はイベント駆動: FOV+8°キック、スピードライン10本、合成 whoosh、HUDゲージリング即ゼロ。

タイム・スコア・ランク(v2導入 game/runStats.js

関連: src/game/runStats.js src/physics/ballPhysics.js src/ui/screens.js

7. オーディオ — 完全合成BGMとSE

何を / なぜ

音声アセットはゼロ。SE(v1)も BGM(v2)も WebAudio でリアルタイム合成する。これは「ホットパスのゼロアロケーション法」に対する唯一の有界例外(≦60短命ノード/秒、高コスト確保は初期化時にホイスト: ハット/シェイカー/whoosh 用ノイズ AudioBuffer は1個を永続共有、PeriodicWave は init 時生成)。

BGMスケジューラ(src/audio/bgm.js

SE(src/audio/sfx.js

すべてオシレータ+ノイズの合成: ピッチ上昇する吸収コンボブリップ / bonk クロンク / ロールループ / ティアアップ・アルペジオ / ダッシュ whoosh(ノイズのバンドパススイープ300→2400Hz)/ レア5音グリス / ランドマークファンファーレ(v3)/ 接触グランドファンファーレ8音+AM9パッド / ランクスタンプの70Hzサイン thud(GAME_WIN の +1.6s を ctx 時間で予約し、CSSのスタンプ演出 1600ms と正確に同期)。デュアルタグ(ハチ公=ランドマーク+コレクティブル)が同フレームで発火した場合はコレクトグリスを抑制してファンファーレのみ鳴らす。

関連: src/audio/bgm.js src/audio/sfx.js

8. フィナーレ — スカイツリー状態機械

何を / なぜ

v2 で「ゴール=月との接触シネマ」を導入する際、ScaleManager の WIN ラッチを撤去し、ゲーム終了の唯一の経路を game/finale.js の小さな状態機械に集約した。v3 はこれを機械ごと再テーマ(月→固定位置のスカイツリー、降下フェーズ削除)。

idle 通常プレイ(380m未満) called 380mで「ゴールが呼ぶ」 approach 画面端🗼矢印で誘導 contact 接触=タイム確定 merge 合体 1.2s ascension 昇天 5.0s・夜フェード afterglow 余韻 2.5s done GAME_WIN → リザルト 点線の帯 = inputLocked / cameraOwned が true(contact 以降、操作とカメラを finale が所有) v2では called と approach の間に descent / landed(月の降下・着地)が存在した
図4: フィナーレ状態機械(v3) — ゲーム終了の唯一の経路

状態と各フェーズの実装(v3)

状態トリガ / 内容
idletrueRadius < 380m。devウォッチドッグ: ゴール×1.2を超えても idle なら console.error + 強制遷移(終了経路が死ぬバグへの保険)
called≧380m で1回。EVT.GOAL_CALL → HUDトースト・スカイツリービームパルス・BGMシマー。純コスメ
approachゲームプレイ完全継続。finale が10HzでスカイツリーをNDC投影し EVT.GOAL_GUIDE → 画面端🗼矢印
contactdist ≤ ballR + towerR×0.85 の描画フレーム判定 = クリアタイム確定の瞬間。EVT.GOAL_CONTACT → runStats凍結+リザルト計算、BGMダック→停止、ファンファーレ、HUD全隠し、白フラッシュ。ここから inputLocked / cameraOwned が true
merge1.2s。finale が ball.pos を直接 lerp(intentはゼロ化済みで物理と喧嘩しない)、0.6s でボール非表示
ascension5.0s。タワーが +40r までイーズ上昇、env.beginNightFade(5.0) で夜パレットへ、金色スパークル噴水。カメラは毎フレーム導出のターゲットで cameraRig.cinematicUpdateキャッシュゼロ=リスケール安全
afterglow2.5s 余韻 → done。main.js(GAME_WIN の唯一のemitter)がリザルトへ

リスケール/リベース安全性の設計

フィナーレ中も rescale/rebase は起こり得る(contact 以降は成長凍結でrescale不可、rebaseはスキップ)。sim空間の数値をキャッシュしてよい場所は _simCache 構造体ただ1つ(v3: towerX/Z, towerR, mergeFrom*, ascendBaseY)で、finale 自身が EVT.RESCALE(全フィールド×S)/ EVT.REBASE(X/Z -= shift)を購読する。それ以外のカメラターゲット等は毎フレーム現在ポーズから導出。新しい状態フィールドは「_simCache に入れるか毎フレーム導出するか」の二択が規約。goalTower.js / terrain.js も同型の自前購読を持つ。

スカイツリーの2表現ハンドオフ

worldScale < 0.2 の間、スカイツリーは environment.js のスカイドームシェーダ内のシルエット(uGoalSil* ユニフォーム、方位を SKYTREE_POS から毎フレーム再計算)として描画され、ゲーム開始の1フレーム目から航法アンカーになる。simDist < 0.8×CAMERA_FAR に入った最初のフレームで、v2 の月で実証済みの角サイズ・方向一致クロスフェードにより render/goalTower.js の実メッシュ(~1400tris、fog:false の空要素例外、2ドロー)へ引き継ぐ — ポップが原理的に起きない。

関連: src/game/finale.js src/render/goalTower.js src/render/environment.js src/render/cameraRig.js

v2 月フィナーレとの差分(descent/landed の削除)

v2 の状態列は idle→called(420m)→descent(500m)→landed→contact→merge→ascension→afterglow→done。月は空のシェーダディスクとして序盤から見えており、降下開始時に「カメラ位置 + 月方向 × (月半径/tan(角サイズ))」でメッシュの角サイズと画面方向をシェーダディスクに正確に一致させてから 2.0s クロスフェード — 月が6秒かけてボール前方 45r へ降りてきて着地し、ソフトマグネット(入力を上書きしない速度バイアス)で誘導していた。

v3 はゴールが世界に固定されたので降下の数学を丸ごと削除し、called/approach/contact 以降の演出機械(夜フェード・シネマカメラ・フラッシュ・リザルト段階表示)を逐語的に再利用した。状態機械・_simCache・ハンドオフという構造が再テーマのコストを1ストリームに閉じ込めた好例。

9. モバイルUI — バンドレイアウト

何を / なぜ

v2 までの「四隅パネル」HUDはスマホ縦持ちで重なりが発生していた(オーナーの主用端末はスマホ)。v3 でモバイルファーストに全面書き直し: ベーススタイルが≦480px縦持ちで、768px以上と横持ちは@mediaの「追加装飾」。

バンド重なり解決マトリクス(binding)

画面を横帯に分割し、各要素は自分の帯の外に置いてはならないというルールで重なりを構造的に排除:

占有者
上端ストリップ#top-bar(サイズピル+タイマー+スコアピル+ミュート)→ その下に進捗バー
中段左ドナック(アバター+吹き出し)
中段中央トースト(74px) / ティアバナー(30vh) / スコアフロート(44vh〜上昇)
中段右コレクションポップアップ(トーストの反対側 — 構造的に非重複)
下段左 65%×55%ジョイスティック専有(動的アンカー出現域)
下段右ダッシュボタン専有(76px、サムターゲット)

実装の要点

関連: index.html(CSS本体)src/ui/hud.js src/input/input.js

10. ドナック実況 — トリガー優先度系

何を / なぜ

公式ピクセルアートキャラ「ドナック」(緑帽子のアヒル)が吹き出しでランドマーク豆知識・コツ・お祝いを実況する。課題はスパム化の防止: イベントは秒間数十発生するが、吹き出しは平均20秒に1個以下に抑えたい。解決は優先度+クールダウン+queue-of-1 の小さなスケジューラ。

トリガー優先度系(src/ui/donack.js

優先度トリガ最小間隔
P3ランドマーク吸収・フィナーレ0(現在の吹き出しに割り込む)
P2コレクティブル・ティアアップ4s
P1カテゴリ初回吸収・コンボ≧15・ノックオフ・マップ端8s
P0行き詰まりヒント(10秒無吸収・ダッシュ12秒未使用)8s

アセット(ゼロアセット法の文書化例外 #2)

public/assets/donack/webp 8枚(~20KB)だけがデプロイ内の唯一のバイナリアセット({idle,happy,thinking,speaking}-{0,3}、120×90、自社ファーストパーティ資産)。スプライトシート化もビルドパイプラインもなし — 表情=CSSクラス交換、まばたき=吹き出し表示中のみ動く4fpsのフレーム0/3トグル。scripts/verify-donack-assets.sh が「ちょうど8ファイル・合計40KB以下・余剰なし」をCI/predeployで強制する。

関連: src/ui/donack.js src/config/donackLines.js scripts/verify-donack-assets.sh public/assets/donack/

11. 永続化 — localStorage スキーマ

すべて try/catch + 形状バリデーション付き(プライベートモードや破損JSONで null を返し、絶対に throw しない)。

キースキーマ規約
fableKatamari.v3.best{v:1, bestTime:{timeS,score,rank,seed}, bestScore:{...}}サブレコードは各指標が更新されたときアトミックに丸ごと差し替え(行内整合性、フィールド混在なし)。v2キーは退役
fableKatamari.v3.collection{v:1, mask:int}凍結整数ID(0..11)のビットマスクをORで蓄積。IDは append-only(v3.1以降は12+を追記、再利用・並べ替え禁止、boot assertでunique且つ<31)。未知の上位ビットは保存(前方互換)
fableKatamari.v3.mutedboolean相当main.js が Bgm/Sfx 構築に読み、initialMuted として注入(構築後のsetMutedは遅延コンテキストでno-opになる既知の穴を回避)
fableKatamari.v3.donackOffboolean相当タイトル画面のトグルピルが永続化。OFF時は donack.js が全イベントを破棄

リセット所有権(v3凍結): main.resetWorld() がスポーナー→ストア→プール→物理→finale→runStats→terrain→curated→collection.resetRun の直接呼び出しチェーンを所有し、バス購読側(cameraRig / env / backdrop / donack / hud)は GAME_RESET / GAME_START で自己リセットする。localStorage の4キーはリセットの対象外(周回を跨ぐのが目的)。

関連: src/game/runStats.js src/game/collection.js src/main.js

12. バージョン差分早見表

領域v1(基盤)v2(月アップデート)v3(箱庭東京)v4(リアル東京 OSM)v5(ポリッシュ)
ワールド無限平面・決定論チャンクのみ同左 + 巻き込めないランドマーク12種(アーキタイプ8→10/ティア)有限3.6×3.8km東京・ゾーンマスク + CuratedSpawner(FLAG_CURATED・動的リバンディング)カバレッジ内はOSM実建物14,563棟(OsmSpawner・FLAG_OSM=32・admission制御)+ 実道路/河川/公園の地表レイヤ同v4 + アキバ建物6棟・除外矩形をリボンにも適用
スケール6ティア 5cm→750m・500mでWINバナー同ティア・420m月コール→500m降臨7ティア 2cm→・380mコール→420mアーム→スカイツリー634m接触同v3(座標は実地理 水平1:5/高さ1:2.5)同v4
ゴールScaleManager の WIN ラッチフィナーレ状態機械(月降下→着地→接触→合体→昇天)同機械を再テーマ(降下削除・固定ゴール・シルエット⇔メッシュハンドオフ・基部恒久コライダ)同v3昇天が宇宙の地球エンディングへ(EarthView・GOAL_ASCEND_S 7.0)
成長cbrt(R³+K·r³) 一律同左(K=10に調整)growthKForObjR テーパー(K=10→床2、ランドマーク/コレクティブル除外)+ 帯別密度同v3 + OSM帯のKEEP_K間引き(データ側ペーシングレバー)同v4
操作WASD+タッチジョイスティック+ダッシュ(ゲージ式、Space/ボタン)同左(ジョイスティック出現域・透明度を調整)同v3同v3 + 開幕オンボーディング🔩誘導
スコアなしタイム/スコア/コンボ/レア/ランクS–D/X共有/自己ベストランク実測リチューン(S290)+ ランドマークボーナス + コレクション数同v3(実測±15%内、再アンカーなし)同v4
収集なしランダムレア(金ティント、スコアのみ)コレクション図鑑12種(凍結ID・サムネイル・周回永続)+ ランダムレア併存同v313種(スタックチャン追記、append-only・旧セーブ互換)
合成SEのみ+合成BGM(ボサポップ・ティアレイヤ解放・2クロックスケジューラ)+ ミュート7ティア再キー + ランドマーク/コレクトSE同v3同v3
空・遠景グラデーションドーム+フォグ太陽/月/星/雲のスカイシェーダ + 遠景シルエットリング + 夜フェードスカイツリーシルエットスロット + 東京湾の水面/岸壁 + 富士山遠景同v3 + OSM河川を共有水面マテリアルへ+ 宇宙フェード(setSpaceFade01)・夜の地球・星シェル
UIHUD+タイトル/WINタイマー/スコア/ゲージ/トースト/月矢印/リザルト段階表示モバイルファースト全面書き直し(バンドマトリクス・ダッシュリング・セーフエリア)+ 巻き込み名フロート + 図鑑グリッド+ OSMクレジット(タイトル/リザルト)+ データ取得進捗行+ 🔩パーツ矢印(kindフィールド)+ 13セル図鑑グリッド
キャラなしなしドナック実況(webp8枚・44行・優先度+クールダウン)同v345行(スタックチャン+地球の台詞、'start'/'ascension'リテキスト)
描画上限55(典型~38)60(最悪56)72(最悪64、BatchedMesh EXTRAプール+4を含む)72(台帳68: +OSMプール2+地表1+河川1、実測60)72(フィナーレ時のみ+2で最悪70)
物理自作400行・空間ハッシュ3面不変+ CityTerrain(ショップ壁/棚・スカイツリー基部・境界ソフト減速)同v3(OSM建物は静的球として既存経路に乗る)同v4
永続化なし(?seed= URLのみ)best / mute(v2キー)best / mute / collection / donackOff(v3キー、図鑑はappend-only契約)同v3collection 13ビット(0xFFF→0x1FFF、未知上位ビット保存)
例外台帳WebAudio有界アロケーション・月の fog:false・月メッシュ1280tris+ ドナックwebp・スカイツリー fog:false~1400tris・ブートサムネイル描画・ショップ地形リリース+ 東京データ285KB(ODbL帰属必須)・unitBox軸整列法線制約+ EarthView透明パス(depthTest:false・fog:false)

シード互換性: ストライドや密度・ドロー列が変わるため、v1/v2のシードURLは v3 では別の世界を生む。同一ビルド内では従来どおり同シード=同世界(レア・ランドマーク配置含む)。

13. v4 リアル東京(OSM)

何を / なぜ

v3の手作業の箱庭を、OpenStreetMap由来の実在の東京で上書きするピュアデルタ。Google Maps Platform は規約上の派生データセット禁止により不採用とし、OSM(ODbL)を採用。全bindingな不変条件(リスケールのピクセル同一性・シームレスネス法・ゼロアロケーション・DRAW_CALL_CAP 72)は据え置きのまま、明示的に定義されたカバレッジ幾何(秋葉原アンカーの詳細ディスク r=500 game m + 渋谷・浅草パッチ矩形)の内側で、手続き生成の帯域3/4フィルを吸収可能な実建物 14,563棟(FLAG_OSM=32、コード94..109、16ボクセルアーキタイプ)に置き換える。設計は設計→敵対的批評→改訂の3段で確定し、批評エージェントは設計の数値を信用せず自らOverpassへカウントクエリを再実行して全予算を実測値(詳細エリア建物58,155棟=設計の1.5倍)から再導出させた。

データパイプライン(ビルド時・scripts/osm/

スクリプト内容
取得fetch-osm.mjs再開可能なOverpass GETプロトコル(UAヘッダ必須・/api/statusスロットポーリング・429はretry-afterでリトライ回数を消費しない・セルごとアトミック書き込み)。1km四方42セル・約115リクエスト。タグ削減済み生データ 7.3MB/124ファイルをコミット
変換build-tokyo-bin.mjs束縛された変換順: グローバル(type,id)重複除去(tower優先)→ マルチポリゴン外輪組み立て → 投影(水平1:5・高さ1:2.5)→ カバレッジ重心クリップ → 除外ゾーン → 凸包+回転キャリパOBB → 接尾辞安全な高さパース → ボクセル量子化(0.05/0.25m刻み)→ 長屋マージ(14,546件)→ クリアランス焼き込み(未出荷のresidential道路も使い、道路回廊へ食い込むOBBを最大30%インセット/ドロップ: 13,367インセット・14,319ドロップ)→ 帯域+KEEP_K間引き → 最終再クリップ → FKT4 v1シャード+マニフェスト。決定論的(再実行でバイト同一)
検証verify-tokyo-data.mjspredeployゲート: 容量予算 / EXPECTED_COUNTS(フェッチ時のout count;再実測)±20% / 重複・カバレッジ外・除外違反ゼロ / POLY u16証明 / 二重実行バイト同一 / ランドマーク実距離グラウンドトゥルース / ナビゲビリティ(0.5mラスタ・ボール半径侵食フラッドフィル、連結率≧95%ゲート: r=1で100.0%、r=3で96.7% PASS)

出荷データ: 建物14,563棟(帯域ヒストグラム [—,—,102→288,13020,1437,4])+ 道路12,980レコード/387タイル + 水面・公園ポリゴン1,478レコード/353タイル = core 105KB + outer 181KB ≈ 285KB gz(ハードキャップ1,536KBの2割以下)。建物レコードは詳細10B/タワー12Bの自前リトルエンディアンバイナリで、実行時は型付き配列へ一発デコード(レコード単位のJSオブジェクトなし=ゼロアロケーション法の継続)。

ランタイム統合

地理マッピング

ODbLコンプライアンス(リリースゲート・全項目必須)

関連: scripts/osm/geo.mjs fetch-osm.mjs build-tokyo-bin.mjs verify-tokyo-data.mjs / src/world/osmWorld.js osmSpawner.js / src/render/osmPools.js osmGround.js / docs/DESIGN-V4.md

14. v5 ポリッシュ

何を / なぜ

オーナーのスマホ実プレイ起点のフィードバック対応。①開幕不可視バグ(v4の地表レイヤが2cmボールを覆っていた——浮かせ量がフォグ下限で半径の90%、道路Yオフセットが半径の3倍、そして実在のJR総武線高架リボンがスポーン直上を通過。除外ゾーンが建物のみ適用でリボン素通し)のホットフィックス、②要望5点(誘導・スタックチャン・アキバ建物・センゴク電子・地球エンディング)の実装。6エージェント・約97.7万トークン・約100分、計画フェーズの敵対的チェックが実装前に4件のショーストッパーを検出した。

オンボーディングシステム(game/onboarding.js 新規)

地表レイヤの半径連続フェード

コレクション拡張(append-onlyの実践)

宇宙-地球エンディング(render/earthView.js 新規)

関連: src/game/onboarding.js src/render/earthView.js src/render/osmGround.js src/game/finale.js src/world/objects.js src/config/cityMap.js / 一次記録: docs/worklog/14-v5-polish.md

参考資料