@loonylabs/create-game 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/bin/create-game.js +213 -0
  2. package/package.json +18 -0
  3. package/template/.claude/skills/aigdtk-create-game-stories/SKILL.md +177 -0
  4. package/template/.claude/skills/aigdtk-create-game-stories/story-template.md +85 -0
  5. package/template/.claude/skills/aigdtk-implement-game-stories/SKILL.md +129 -0
  6. package/template/.claude/skills/aigdtk-new-game/SKILL.md +126 -0
  7. package/template/.claude/skills/aigdtk-shared/ascii-grammar.md +133 -0
  8. package/template/.claude/skills/aigdtk-shared/enemies.md +112 -0
  9. package/template/.claude/skills/aigdtk-shared/framework.md +93 -0
  10. package/template/.claude/skills/aigdtk-shared/visuals.md +125 -0
  11. package/template/apps/client/index.html +14 -0
  12. package/template/apps/client/package.json +31 -0
  13. package/template/apps/client/public/assets/audio/enemy_killed.wav +0 -0
  14. package/template/apps/client/src/components/App.svelte +290 -0
  15. package/template/apps/client/src/components/CraftingPanel.svelte +253 -0
  16. package/template/apps/client/src/components/DevPanel.svelte +180 -0
  17. package/template/apps/client/src/components/DungeonClearOverlay.svelte +53 -0
  18. package/template/apps/client/src/components/EquipmentPanel.svelte +191 -0
  19. package/template/apps/client/src/components/HealthBar.svelte +50 -0
  20. package/template/apps/client/src/components/Hub/BackToHubButton.svelte +37 -0
  21. package/template/apps/client/src/components/Hub/ExperienceCard.svelte +115 -0
  22. package/template/apps/client/src/components/Hub/Hub.svelte +88 -0
  23. package/template/apps/client/src/components/Inventory.svelte +174 -0
  24. package/template/apps/client/src/components/Runner/RunnerDeathScreen.svelte +182 -0
  25. package/template/apps/client/src/components/Runner/RunnerHUD.svelte +157 -0
  26. package/template/apps/client/src/components/Shooter/DamageNumbers.svelte +96 -0
  27. package/template/apps/client/src/components/Shooter/GameOverScreen.svelte +109 -0
  28. package/template/apps/client/src/components/Shooter/ShooterHUD.svelte +95 -0
  29. package/template/apps/client/src/components/SkillBar.svelte +146 -0
  30. package/template/apps/client/src/components/ToastSystem.svelte +158 -0
  31. package/template/apps/client/src/game.ts +918 -0
  32. package/template/apps/client/src/input.ts +92 -0
  33. package/template/apps/client/src/lib/audio/CombatDetector.test.ts +59 -0
  34. package/template/apps/client/src/lib/audio/CombatDetector.ts +53 -0
  35. package/template/apps/client/src/lib/audio/MusicManager.ts +137 -0
  36. package/template/apps/client/src/lib/audio/SoundManager.ts +59 -0
  37. package/template/apps/client/src/lib/audio/index.ts +9 -0
  38. package/template/apps/client/src/main.ts +32 -0
  39. package/template/apps/client/src/renderer/basecamp.ts +126 -0
  40. package/template/apps/client/src/renderer/dungeon.ts +250 -0
  41. package/template/apps/client/src/renderer/dungeonPortal.ts +73 -0
  42. package/template/apps/client/src/renderer/dungeonZone.ts +301 -0
  43. package/template/apps/client/src/renderer/entities.ts +197 -0
  44. package/template/apps/client/src/renderer/runnerTrack.ts +221 -0
  45. package/template/apps/client/src/renderer/shaders/SkyShader.ts +190 -0
  46. package/template/apps/client/src/renderer/shaders/TerrainShaderMaterial.ts +133 -0
  47. package/template/apps/client/src/renderer/shaders/floor.frag.glsl.ts +17 -0
  48. package/template/apps/client/src/renderer/shaders/shaderConfig.ts +18 -0
  49. package/template/apps/client/src/renderer/shaders/spawn.frag.glsl.ts +19 -0
  50. package/template/apps/client/src/renderer/shaders/terrain.frag.glsl.ts +314 -0
  51. package/template/apps/client/src/renderer/shaders/terrain.vert.glsl.ts +16 -0
  52. package/template/apps/client/src/renderer/shaders/wall.frag.glsl.ts +20 -0
  53. package/template/apps/client/src/renderer/shooterArena.ts +102 -0
  54. package/template/apps/client/src/renderer/voxelChunkStreamer.ts +79 -0
  55. package/template/apps/client/src/renderer/voxelMesh.ts +86 -0
  56. package/template/apps/client/src/renderer/voxelTerrain.ts +74 -0
  57. package/template/apps/client/src/socket.ts +268 -0
  58. package/template/apps/client/src/store.ts +74 -0
  59. package/template/apps/client/src/style.css +60 -0
  60. package/template/apps/client/tsconfig.json +11 -0
  61. package/template/apps/client/vite.config.ts +10 -0
  62. package/template/apps/client/vitest.config.ts +8 -0
  63. package/template/apps/experiences/diablo/index.ts +94 -0
  64. package/template/apps/experiences/diablo/systems/dungeonClearSystem.ts +60 -0
  65. package/template/apps/experiences/diablo/systems/enemyAISystem.ts +11 -0
  66. package/template/apps/experiences/diablo/systems/entitySyncSystem.ts +80 -0
  67. package/template/apps/experiences/diablo/systems/itemPickupSystem.ts +11 -0
  68. package/template/apps/experiences/diablo/systems/movementSystem.ts +13 -0
  69. package/template/apps/experiences/diablo/systems/physicsSystem.ts +92 -0
  70. package/template/apps/experiences/diablo/systems/transitionSystem.ts +105 -0
  71. package/template/apps/experiences/runner/data/runner-config.ts +54 -0
  72. package/template/apps/experiences/runner/index.ts +143 -0
  73. package/template/apps/experiences/runner/systems/collectibleSystem.ts +157 -0
  74. package/template/apps/experiences/runner/systems/deathSystem.ts +42 -0
  75. package/template/apps/experiences/runner/systems/entitySyncSystem.ts +59 -0
  76. package/template/apps/experiences/runner/systems/obstacleSystem.ts +91 -0
  77. package/template/apps/experiences/runner/systems/runnerPhysicsSystem.ts +82 -0
  78. package/template/apps/experiences/runner/systems/trackStreamSystem.ts +19 -0
  79. package/template/apps/experiences/runner/track/laneSystem.ts +53 -0
  80. package/template/apps/experiences/runner/track/segmentTypes.ts +141 -0
  81. package/template/apps/experiences/runner/track/trackGenerator.ts +292 -0
  82. package/template/apps/experiences/shooter/ai/aiStateMachine.ts +394 -0
  83. package/template/apps/experiences/shooter/ai/lineOfSight.ts +32 -0
  84. package/template/apps/experiences/shooter/arena/arenaGenerator.ts +101 -0
  85. package/template/apps/experiences/shooter/arena/arenaTypes.ts +49 -0
  86. package/template/apps/experiences/shooter/arena/hitscan.ts +101 -0
  87. package/template/apps/experiences/shooter/data/enemy-types.ts +108 -0
  88. package/template/apps/experiences/shooter/data/wave-definitions.ts +40 -0
  89. package/template/apps/experiences/shooter/data/weapon-config.ts +80 -0
  90. package/template/apps/experiences/shooter/index.ts +127 -0
  91. package/template/apps/experiences/shooter/systems/enemyAISystem.ts +113 -0
  92. package/template/apps/experiences/shooter/systems/entitySyncSystem.ts +68 -0
  93. package/template/apps/experiences/shooter/systems/shooterPhysicsSystem.ts +89 -0
  94. package/template/apps/experiences/shooter/systems/waveSpawnerSystem.ts +87 -0
  95. package/template/apps/experiences/shooter/systems/weaponSystem.ts +157 -0
  96. package/template/apps/game-data/src/areas/area-manifest.json +18 -0
  97. package/template/apps/game-data/src/assets/migration.test.ts +291 -0
  98. package/template/apps/game-data/src/audio/music-config.json +21 -0
  99. package/template/apps/game-data/src/audio/sound-config.json +11 -0
  100. package/template/apps/game-data/src/combat/action-types.ts +2 -0
  101. package/template/apps/game-data/src/combat/enemy-def.ts +12 -0
  102. package/template/apps/game-data/src/combat/hitboxes.ts +23 -0
  103. package/template/apps/game-data/src/dungeon/cell-types.ts +20 -0
  104. package/template/apps/game-data/src/dungeon/cell-visuals.ts +13 -0
  105. package/template/apps/game-data/src/dungeon/door-directions.ts +2 -0
  106. package/template/apps/game-data/src/enemies/enemy-defs.json +32 -0
  107. package/template/apps/game-data/src/equipment/slots.json +5 -0
  108. package/template/apps/game-data/src/events/event-defs.ts +20 -0
  109. package/template/apps/game-data/src/events/event-types.ts +10 -0
  110. package/template/apps/game-data/src/events/toast-config.json +49 -0
  111. package/template/apps/game-data/src/items/item-pool.json +13 -0
  112. package/template/apps/game-data/src/loot/item-pool.ts +14 -0
  113. package/template/apps/game-data/src/loot/rarities.ts +2 -0
  114. package/template/apps/game-data/src/physics/dungeon-physics-config.ts +12 -0
  115. package/template/apps/game-data/src/physics/jump-config.ts +17 -0
  116. package/template/apps/game-data/src/recipes/recipe-book.json +68 -0
  117. package/template/apps/game-data/src/rooms/room_basecamp.json +16 -0
  118. package/template/apps/game-data/src/rooms/room_corridor_ew.json +9 -0
  119. package/template/apps/game-data/src/rooms/room_corridor_ns.json +11 -0
  120. package/template/apps/game-data/src/rooms/room_crossroads.json +11 -0
  121. package/template/apps/game-data/src/rooms/room_dead_end.json +10 -0
  122. package/template/apps/game-data/src/rooms/room_staircase.json +12 -0
  123. package/template/apps/game-data/src/rooms/room_start.json +11 -0
  124. package/template/apps/game-data/src/skills/skill-book.json +20 -0
  125. package/template/apps/game-data/src/voxel/biome-terrain.ts +76 -0
  126. package/template/apps/game-data/src/voxel/materials.ts +45 -0
  127. package/template/apps/game-data/src/voxel/sandbox-terrain-config.ts +19 -0
  128. package/template/apps/game-data/src/world/area-config.ts +33 -0
  129. package/template/apps/game-data/src/world/biome-def.ts +15 -0
  130. package/template/apps/game-data/src/world/biomes.json +57 -0
  131. package/template/apps/game-data/src/world/movement.ts +2 -0
  132. package/template/apps/game-data/src/world/overworld-layout.test.ts +93 -0
  133. package/template/apps/game-data/src/world/overworld-layout.ts +127 -0
  134. package/template/apps/server/data/game.db +0 -0
  135. package/template/apps/server/package.json +30 -0
  136. package/template/apps/server/src/areaManager.ts +346 -0
  137. package/template/apps/server/src/db/client.ts +45 -0
  138. package/template/apps/server/src/db/schema.ts +40 -0
  139. package/template/apps/server/src/gameLoop.ts +267 -0
  140. package/template/apps/server/src/gameState.ts +3 -0
  141. package/template/apps/server/src/handlers/actionEvent.ts +55 -0
  142. package/template/apps/server/src/handlers/craftHandler.ts +59 -0
  143. package/template/apps/server/src/handlers/equipHandler.ts +73 -0
  144. package/template/apps/server/src/handlers/raycastHandler.ts +97 -0
  145. package/template/apps/server/src/handlers/skillHandler.ts +87 -0
  146. package/template/apps/server/src/handlers/terraformHandler.ts +74 -0
  147. package/template/apps/server/src/index.ts +597 -0
  148. package/template/apps/server/src/persistence.ts +135 -0
  149. package/template/apps/server/src/rooms.ts +20 -0
  150. package/template/apps/server/src/systems/dungeonPhysics.test.ts +32 -0
  151. package/template/apps/server/src/systems/dungeonPhysics.ts +16 -0
  152. package/template/apps/server/src/systems/enemyAI.ts +129 -0
  153. package/template/apps/server/src/systems/itemPickup.ts +31 -0
  154. package/template/apps/server/src/tests/areaManager.test.ts +77 -0
  155. package/template/apps/server/src/tests/diablo-experience.test.ts +60 -0
  156. package/template/apps/server/src/tests/runner-experience.test.ts +273 -0
  157. package/template/apps/server/src/tests/runner-powerups-scoring.test.ts +221 -0
  158. package/template/apps/server/src/tests/server.integration.test.ts +92 -0
  159. package/template/apps/server/src/tests/shooter-enemy-ai.test.ts +328 -0
  160. package/template/apps/server/src/tests/shooter-experience.test.ts +281 -0
  161. package/template/apps/server/src/tests/voxelChunkCache.test.ts +29 -0
  162. package/template/apps/server/src/tests/voxelSandbox.test.ts +133 -0
  163. package/template/apps/server/src/voxelChunkCache.ts +31 -0
  164. package/template/apps/server/src/voxelPlayerState.ts +23 -0
  165. package/template/apps/server/tsconfig.json +17 -0
  166. package/template/apps/server/vitest.config.ts +8 -0
@@ -0,0 +1,918 @@
1
+ /**
2
+ * @file game.ts
3
+ * Game orchestrator — the single owner of the game loop and all runtime systems.
4
+ *
5
+ * Responsibilities:
6
+ * - Own and drive the requestAnimationFrame loop
7
+ * - Create and hot-swap InputController + CameraController on mode changes
8
+ * - Manage dungeon mesh lifecycle (build on load, dispose on swap)
9
+ * - Own EntityRenderer and feed it interpolated snapshots each frame
10
+ *
11
+ * What does NOT belong here:
12
+ * - Svelte mounting (→ main.ts)
13
+ * - Socket.io connection management (→ socket.ts)
14
+ * - Pure game logic / ECS (→ packages/core)
15
+ * - UI state (→ store.ts)
16
+ *
17
+ * Extension points for future stories:
18
+ * - Story 15 (VFX): instantiate particle system here, subscribe to eventBus
19
+ */
20
+
21
+ import { subscribe } from 'valtio';
22
+ import * as THREE from 'three';
23
+ import { store } from './store.js';
24
+ import type { GameMode } from './store.js';
25
+ import { snapshotBuffer, sendMessage } from './socket.js';
26
+ import { buildDungeonMesh, disposeMesh } from './renderer/dungeon.js';
27
+ import { EntityRenderer } from './renderer/entities.js';
28
+ import {
29
+ ShaderManager,
30
+ getInterpolatedEntities,
31
+ initScene,
32
+ VFXRenderer,
33
+ ClickToMoveController,
34
+ WASDController,
35
+ ThirdPersonCameraController,
36
+ FirstPersonCameraController,
37
+ FollowCameraController,
38
+ updateAim,
39
+ AmbientEmitter,
40
+ } from '@loonylabs/gamedev-client';
41
+ import type { InputController, CameraController, SceneContext, EntitySnapshot } from '@loonylabs/gamedev-client';
42
+ import { soundManager, musicManager, CombatDetector } from './lib/audio/index.js';
43
+ import soundConfig from '../../game-data/src/audio/sound-config.json';
44
+ import musicConfig from '../../game-data/src/audio/music-config.json';
45
+ import biomes from '../../game-data/src/world/biomes.json';
46
+ import { createBiomeBlendFn, getTerrainHeight, generateVoxelChunk, VOXEL_CHUNK_X, VOXEL_CHUNK_Z } from '@loonylabs/gamedev-core';
47
+ import type { BiomeDef, TileInfo, VoxelTerrainOptions } from '@loonylabs/gamedev-core';
48
+ import { SkyMesh } from './renderer/shaders/SkyShader.js';
49
+ import { VoxelChunkStreamer } from './renderer/voxelChunkStreamer.js';
50
+ import { tickTerrainMaterial } from './renderer/voxelMesh.js';
51
+ import { VOXEL_MATERIAL_VISUALS } from '../../game-data/src/voxel/materials.js';
52
+ import { parseOverworldLayout, getTileAt, getBiomeTerrainOptions, getStructureFlatZones, TILE_SIZE } from '../../game-data/src/world/overworld-layout.js';
53
+ import { createBasecampMeshes } from './renderer/basecamp.js';
54
+ import { createDungeonPortal } from './renderer/dungeonPortal.js';
55
+ import { buildArenaScene, disposeArenaScene } from './renderer/shooterArena.js';
56
+ import type { ArenaPayload } from './renderer/shooterArena.js';
57
+ import { buildRunnerScene, buildSegmentMesh, disposeSegmentMesh, disposeRunnerScene, createTrackGenerator, updateTrack } from './renderer/runnerTrack.js';
58
+ import type { TrackGeneratorState } from './renderer/runnerTrack.js';
59
+
60
+ export class GameOrchestrator {
61
+ private sceneContext: SceneContext;
62
+ private scene: THREE.Scene;
63
+ private camera: THREE.PerspectiveCamera;
64
+ private renderer: THREE.WebGLRenderer;
65
+ private entityRenderer: EntityRenderer;
66
+ private shaderManager: ShaderManager;
67
+ private vfxRenderer: VFXRenderer;
68
+ private cameraController: CameraController;
69
+ private inputController: InputController;
70
+ private dungeonGroup: THREE.Group | null = null;
71
+ private voxelChunkStreamer: VoxelChunkStreamer | null = null;
72
+ private structureMeshes: THREE.Group[] = [];
73
+ private skyMesh: SkyMesh | null = null;
74
+ private ambientEmitter: AmbientEmitter | null = null;
75
+ private latestInterpolated: EntitySnapshot[] = [];
76
+ private lastTime = performance.now();
77
+ private lastMode: GameMode;
78
+ private lastDungeon = store.dungeon;
79
+ private combatDetector: CombatDetector;
80
+ private combatCheckTimer = 0;
81
+ private audioInitialized = false;
82
+ private aimDebugLine: THREE.Line | null = null;
83
+ private jumpInputId = 0;
84
+ private shooterArenaGroup: THREE.Group | null = null;
85
+ private shooterKeys: Record<string, boolean> = {};
86
+ private shooterInputCleanup: (() => void) | null = null;
87
+ private runnerSceneGroup: THREE.Group | null = null;
88
+ private runnerSegmentMeshes = new Map<number, THREE.Group>(); // index -> mesh group
89
+ private runnerKeys: Record<string, boolean> = {};
90
+ private runnerInputCleanup: (() => void) | null = null;
91
+ private runnerSeed = 0;
92
+ private runnerTrackState: TrackGeneratorState | null = null;
93
+ private _onKeyDown: ((e: KeyboardEvent) => void) | null = null;
94
+
95
+ constructor(canvas: HTMLCanvasElement) {
96
+ const ctx = initScene(canvas);
97
+ this.sceneContext = ctx;
98
+ this.scene = ctx.scene;
99
+ this.camera = ctx.camera;
100
+ this.renderer = ctx.renderer;
101
+ this.entityRenderer = new EntityRenderer(this.scene);
102
+ this.shaderManager = new ShaderManager();
103
+ this.vfxRenderer = new VFXRenderer(this.scene);
104
+
105
+ // Initial controllers — Diablo-style auto-follow + click-to-move
106
+ this.cameraController = new FollowCameraController(
107
+ this.camera, canvas,
108
+ () => this.getInterpolatedPlayerPosition(),
109
+ { defaultElevation: 1.05, defaultDistance: 10, minDistance: 3, maxDistance: 20 },
110
+ );
111
+ this.inputController = new ClickToMoveController(canvas, this.camera, (msg) => sendMessage(msg));
112
+ this.lastMode = store.gameMode;
113
+
114
+ // Subscribe to mode changes
115
+ subscribe(store, () => {
116
+ if (store.gameMode !== this.lastMode) {
117
+ this.lastMode = store.gameMode;
118
+ this.setMode(store.gameMode);
119
+ }
120
+ });
121
+
122
+ // Subscribe to dungeon changes
123
+ subscribe(store, () => {
124
+ if (store.dungeon !== this.lastDungeon) {
125
+ this.lastDungeon = store.dungeon;
126
+ this.onDungeonLoaded();
127
+ }
128
+ });
129
+
130
+ // Sky dome — procedural gradient, no texture assets needed
131
+ this.skyMesh = new SkyMesh(this.scene);
132
+ this.scene.background = null; // sky shader handles background
133
+
134
+ this.combatDetector = new CombatDetector(
135
+ (musicConfig as { combatExitDelay: number }).combatExitDelay,
136
+ (state) => musicManager.setState(state),
137
+ );
138
+ }
139
+
140
+ /** Start the animation loop. Called once from main.ts after socket is ready. */
141
+ start(): void {
142
+ // Defer audio init until first user interaction (browser autoplay policy)
143
+ const initOnInteraction = () => {
144
+ if (this.audioInitialized) return;
145
+ this.audioInitialized = true;
146
+ this.initAudio();
147
+ window.removeEventListener('keydown', initOnInteraction);
148
+ window.removeEventListener('click', initOnInteraction);
149
+ };
150
+ window.addEventListener('keydown', initOnInteraction);
151
+ window.addEventListener('click', initOnInteraction);
152
+
153
+ // Global key handlers for voxel sandbox
154
+ this._onKeyDown = (e: KeyboardEvent) => {
155
+ // F8 — debug teleport to overworld
156
+ if (e.code === 'F8') {
157
+ e.preventDefault();
158
+ sendMessage({ type: 'DEBUG_TELEPORT', targetArea: 'overworld' });
159
+ return;
160
+ }
161
+ // Space — jump (only in voxel/dungeon areas, not shooter — shooter handles its own jump)
162
+ if (e.code === 'Space' && !e.repeat && store.hasPhysics && store.currentArea !== 'shooter') {
163
+ e.preventDefault();
164
+ sendMessage({ type: 'PLAYER_JUMP', inputId: ++this.jumpInputId });
165
+ }
166
+ };
167
+ window.addEventListener('keydown', this._onKeyDown);
168
+
169
+ this.animate();
170
+ }
171
+
172
+ /** Initialize audio managers. Called after first user interaction. */
173
+ private initAudio(): void {
174
+ soundManager.init(soundConfig as Parameters<typeof soundManager.init>[0]);
175
+ musicManager.init(musicConfig as Parameters<typeof musicManager.init>[0]);
176
+ musicManager.setBiome('dungeon');
177
+ }
178
+
179
+ /** Hot-swap input + camera controllers on mode change. */
180
+ setMode(mode: GameMode): void {
181
+ this.inputController.dispose();
182
+ this.cameraController.dispose();
183
+
184
+ const getPos = () => {
185
+ const predicted = this.inputController instanceof WASDController
186
+ ? this.inputController.getPredictedPosition()
187
+ : null;
188
+ const interpolated = this.getInterpolatedPlayerPosition();
189
+ if (predicted) return { ...predicted, worldY: interpolated?.worldY };
190
+ return interpolated;
191
+ };
192
+
193
+ if (mode === 'click+orbit') {
194
+ // Diablo-style: auto-follow camera with high elevation + click-to-move
195
+ this.cameraController = new FollowCameraController(
196
+ this.camera, this.renderer.domElement, getPos,
197
+ { defaultElevation: 1.05, defaultDistance: 10, minDistance: 3, maxDistance: 20 },
198
+ );
199
+ this.inputController = new ClickToMoveController(this.renderer.domElement, this.camera, (msg) => sendMessage(msg));
200
+ } else {
201
+ if (mode === 'wasd+thirdperson') {
202
+ this.cameraController = new ThirdPersonCameraController(this.camera, getPos);
203
+ } else if (mode === 'wasd+firstperson') {
204
+ this.cameraController = new FirstPersonCameraController(this.camera, this.renderer.domElement, getPos);
205
+ } else {
206
+ // wasd+follow
207
+ this.cameraController = new FollowCameraController(this.camera, this.renderer.domElement, getPos);
208
+ }
209
+
210
+ this.inputController = new WASDController(
211
+ this.renderer.domElement,
212
+ this.cameraController,
213
+ (msg) => sendMessage(msg),
214
+ () => this.getRawPlayerPosition(),
215
+ () => store.dungeon,
216
+ {},
217
+ {
218
+ getWorldManager: () => store.worldManager,
219
+ setPlayerFacingAngle: (angle) => { store.playerFacingAngle = angle; },
220
+ getLocalEntityId: () => store.localEntityId,
221
+ getPlayerWorldY: () => this.getInterpolatedPlayerPosition()?.worldY ?? 0,
222
+ isVoxelArea: () => store.isVoxelArea,
223
+ suppressRaycast: () => store.currentArea === 'shooter',
224
+ },
225
+ );
226
+ }
227
+ }
228
+
229
+ /** Called when a new dungeon arrives (store.dungeon changed). */
230
+ onDungeonLoaded(): void {
231
+ if (this.dungeonGroup) {
232
+ this.scene.remove(this.dungeonGroup);
233
+ disposeMesh(this.dungeonGroup, this.shaderManager);
234
+ this.shaderManager.clear();
235
+ }
236
+ if (store.dungeon) {
237
+ this.dungeonGroup = buildDungeonMesh(store.dungeon, this.shaderManager);
238
+ this.scene.add(this.dungeonGroup);
239
+ this.centerOrbit();
240
+ }
241
+ }
242
+
243
+ /** Called by socket.ts when the server sends AREA_CHANGE. */
244
+ onAreaChange(msg: { targetArea: string; spawnX: number; spawnY: number; dungeon?: any; biomeId?: string; seed?: number; voxelConfig?: { terrainOptions?: Record<string, unknown>; tileLayout?: boolean } }): void {
245
+ const isVoxel = msg.voxelConfig !== undefined;
246
+ const isDungeon = msg.targetArea === 'dungeon';
247
+
248
+ // --- Dispose previous area state ---
249
+ this.disposeShooterArena();
250
+ if (this.dungeonGroup) {
251
+ this.scene.remove(this.dungeonGroup);
252
+ disposeMesh(this.dungeonGroup, this.shaderManager);
253
+ this.dungeonGroup = null;
254
+ this.shaderManager.clear();
255
+ }
256
+ if (this.voxelChunkStreamer) {
257
+ this.voxelChunkStreamer.dispose();
258
+ this.voxelChunkStreamer = null;
259
+ }
260
+ for (const group of this.structureMeshes) {
261
+ this.scene.remove(group);
262
+ group.traverse((child) => {
263
+ if (child instanceof THREE.Mesh) {
264
+ child.geometry.dispose();
265
+ if (Array.isArray(child.material)) child.material.forEach(m => m.dispose());
266
+ else child.material.dispose();
267
+ }
268
+ });
269
+ }
270
+ this.structureMeshes = [];
271
+
272
+ // Update area flags
273
+ store.isVoxelArea = isVoxel;
274
+ store.hasPhysics = isVoxel || isDungeon;
275
+
276
+ if (isVoxel) {
277
+ // --- Voxel Overworld ---
278
+ const seed = msg.seed ?? 0;
279
+
280
+ // Build biome blend function from tile layout (client-side reconstruction)
281
+ const layout = parseOverworldLayout();
282
+ const tileInfoGrid: TileInfo[][] = layout.tiles.map(row =>
283
+ row.map(tile => ({
284
+ terrainOptions: getBiomeTerrainOptions(tile.def.biomeId),
285
+ })),
286
+ );
287
+ const flatZones = getStructureFlatZones(layout);
288
+ const blendFn = createBiomeBlendFn(tileInfoGrid, TILE_SIZE, TILE_SIZE, flatZones);
289
+
290
+ this.voxelChunkStreamer = new VoxelChunkStreamer(
291
+ this.scene,
292
+ seed,
293
+ {},
294
+ VOXEL_MATERIAL_VISUALS,
295
+ blendFn,
296
+ );
297
+ store.voxelConfig = { seed, terrainOptions: {} as VoxelTerrainOptions };
298
+ store.worldManager = null;
299
+ store.currentBiomeId = null;
300
+
301
+ // Force WASD mode for voxel areas
302
+ if (!store.gameMode.startsWith('wasd')) {
303
+ store.gameMode = 'wasd+follow';
304
+ }
305
+
306
+ // Place overworld structures
307
+ this.setupOverworldStructures(seed, layout);
308
+
309
+ } else if (isDungeon && msg.dungeon !== undefined) {
310
+ // --- Dungeon (grid-based) ---
311
+ this.dungeonGroup = buildDungeonMesh(msg.dungeon, this.shaderManager);
312
+ this.scene.add(this.dungeonGroup);
313
+
314
+ store.worldManager = null;
315
+ store.currentBiomeId = null;
316
+
317
+ // Force WASD mode for dungeons (3D movement with jump)
318
+ if (!store.gameMode.startsWith('wasd')) {
319
+ store.gameMode = 'wasd+thirdperson';
320
+ }
321
+ }
322
+
323
+ // Update fog
324
+ if (isDungeon) {
325
+ const dungeonBiome = (biomes as Record<string, { fogColor: string; fogDensity: number }>)['dungeon'];
326
+ if (dungeonBiome) this.sceneContext.setFog(dungeonBiome.fogColor, dungeonBiome.fogDensity);
327
+ } else if (isVoxel) {
328
+ // Light fog for overworld
329
+ this.sceneContext.setFog('#a0b8d0', 0.008);
330
+ }
331
+
332
+ // Update sky dome
333
+ this.skyMesh?.setSkyForArea(msg.targetArea);
334
+
335
+ // Swap ambient particle emitter
336
+ this.ambientEmitter?.dispose();
337
+ this.ambientEmitter = null;
338
+ const particlePreset = isDungeon ? 'embers' : 'leaves';
339
+ const particleCentre = new THREE.Vector3(msg.spawnX ?? 16, 0, msg.spawnY ?? 16);
340
+ this.ambientEmitter = new AmbientEmitter(this.scene, particlePreset, particleCentre);
341
+
342
+ // Reset entity renderer
343
+ this.entityRenderer.dispose();
344
+ this.entityRenderer = new EntityRenderer(this.scene);
345
+ }
346
+
347
+ /** Called by socket.ts when joining the Shooter experience. */
348
+ onShooterJoin(msg: { spawnX: number; spawnY: number; spawnZ: number; localEntityId: number; payload: ArenaPayload }): void {
349
+ // Dispose previous scene state
350
+ this.disposeShooterArena();
351
+ if (this.dungeonGroup) {
352
+ this.scene.remove(this.dungeonGroup);
353
+ disposeMesh(this.dungeonGroup, this.shaderManager);
354
+ this.dungeonGroup = null;
355
+ this.shaderManager.clear();
356
+ }
357
+ if (this.voxelChunkStreamer) {
358
+ this.voxelChunkStreamer.dispose();
359
+ this.voxelChunkStreamer = null;
360
+ }
361
+ for (const group of this.structureMeshes) {
362
+ this.scene.remove(group);
363
+ group.traverse((child) => {
364
+ if (child instanceof THREE.Mesh) {
365
+ child.geometry.dispose();
366
+ if (Array.isArray(child.material)) child.material.forEach(m => m.dispose());
367
+ else child.material.dispose();
368
+ }
369
+ });
370
+ }
371
+ this.structureMeshes = [];
372
+ this.ambientEmitter?.dispose();
373
+ this.ambientEmitter = null;
374
+
375
+ // Build arena
376
+ this.shooterArenaGroup = buildArenaScene(msg.payload);
377
+ this.scene.add(this.shooterArenaGroup);
378
+
379
+ // Light fog for arena
380
+ this.sceneContext.setFog('#1a1a2e', 0.015);
381
+ this.skyMesh?.setSkyForArea('shooter');
382
+
383
+ // Reset entity renderer
384
+ this.entityRenderer.dispose();
385
+ this.entityRenderer = new EntityRenderer(this.scene);
386
+
387
+ // Position camera at spawn
388
+ this.camera.position.set(msg.spawnX, 8, (msg.spawnZ ?? msg.spawnY) + 8);
389
+ this.camera.lookAt(msg.spawnX, 0, msg.spawnZ ?? msg.spawnY);
390
+
391
+ // Force WASD third-person mode
392
+ this.setMode('wasd+thirdperson');
393
+
394
+ // Set up shooter-specific input
395
+ this.setupShooterInput();
396
+ }
397
+
398
+ /** Set up shooter-specific input handlers (click to shoot, R to reload, Space to jump). */
399
+ private setupShooterInput(): void {
400
+ this.disposeShooterInput();
401
+ this.shooterKeys = {};
402
+
403
+ const onKeyDown = (e: KeyboardEvent) => {
404
+ if (store.currentArea !== 'shooter') return;
405
+ this.shooterKeys[e.code] = true;
406
+ if (e.code === 'KeyR') {
407
+ sendMessage({ type: 'RELOAD' });
408
+ }
409
+ if (e.code === 'Space' && !e.repeat) {
410
+ e.preventDefault();
411
+ sendMessage({ type: 'SHOOTER_JUMP' });
412
+ }
413
+ };
414
+ const onKeyUp = (e: KeyboardEvent) => {
415
+ this.shooterKeys[e.code] = false;
416
+ };
417
+ const onClick = (e: MouseEvent) => {
418
+ if (store.currentArea !== 'shooter' || store.shooterDead) return;
419
+ if (e.button !== 0) return;
420
+
421
+ const playerPos = this.getInterpolatedPlayerPosition();
422
+ const EYE_HEIGHT = 1.7;
423
+ const eyePos = playerPos
424
+ ? new THREE.Vector3(playerPos.x, (playerPos.worldY ?? 0) + EYE_HEIGHT, playerPos.y)
425
+ : this.camera.position.clone();
426
+
427
+ // Third-person parallax fix: cast ray from camera through crosshair
428
+ // to find the world-space aim point, then shoot from player eye toward it
429
+ const cameraDir = new THREE.Vector3(0, 0, -1).applyQuaternion(this.camera.quaternion);
430
+ const aimPoint = this.camera.position.clone().addScaledVector(cameraDir, 200);
431
+ const shootDir = new THREE.Vector3().subVectors(aimPoint, eyePos).normalize();
432
+
433
+ sendMessage({
434
+ type: 'SHOOT',
435
+ originX: eyePos.x,
436
+ originY: eyePos.y,
437
+ originZ: eyePos.z,
438
+ dirX: shootDir.x,
439
+ dirY: shootDir.y,
440
+ dirZ: shootDir.z,
441
+ });
442
+ };
443
+
444
+ window.addEventListener('keydown', onKeyDown);
445
+ window.addEventListener('keyup', onKeyUp);
446
+ this.renderer.domElement.addEventListener('click', onClick);
447
+
448
+ this.shooterInputCleanup = () => {
449
+ window.removeEventListener('keydown', onKeyDown);
450
+ window.removeEventListener('keyup', onKeyUp);
451
+ this.renderer.domElement.removeEventListener('click', onClick);
452
+ };
453
+ }
454
+
455
+ private disposeShooterInput(): void {
456
+ this.shooterInputCleanup?.();
457
+ this.shooterInputCleanup = null;
458
+ this.shooterKeys = {};
459
+ }
460
+
461
+ /** Send SHOOTER_MOVE based on current key state. Called each frame when in shooter. */
462
+ private tickShooterInput(): void {
463
+ if (store.currentArea !== 'shooter' || store.shooterDead) return;
464
+
465
+ const forward = this.cameraController.getForward();
466
+ const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize();
467
+
468
+ const dir = new THREE.Vector3();
469
+ if (this.shooterKeys['KeyW'] || this.shooterKeys['ArrowUp']) dir.addScaledVector(forward, 1);
470
+ if (this.shooterKeys['KeyS'] || this.shooterKeys['ArrowDown']) dir.addScaledVector(forward, -1);
471
+ if (this.shooterKeys['KeyA'] || this.shooterKeys['ArrowLeft']) dir.addScaledVector(right, -1);
472
+ if (this.shooterKeys['KeyD'] || this.shooterKeys['ArrowRight']) dir.addScaledVector(right, 1);
473
+
474
+ const sprint = this.shooterKeys['ShiftLeft'] || this.shooterKeys['ShiftRight'] || false;
475
+ const facingAngle = Math.atan2(forward.x, forward.z);
476
+ store.playerFacingAngle = facingAngle;
477
+
478
+ if (dir.lengthSq() > 0) {
479
+ dir.normalize();
480
+ }
481
+
482
+ sendMessage({
483
+ type: 'SHOOTER_MOVE',
484
+ x: dir.x,
485
+ z: dir.z,
486
+ sprint,
487
+ facingAngle,
488
+ });
489
+ }
490
+
491
+ /** Dispose shooter arena meshes and input. */
492
+ private disposeShooterArena(): void {
493
+ if (this.shooterArenaGroup) {
494
+ this.scene.remove(this.shooterArenaGroup);
495
+ disposeArenaScene(this.shooterArenaGroup);
496
+ this.shooterArenaGroup = null;
497
+ }
498
+ this.disposeShooterInput();
499
+ }
500
+
501
+ /** Called by socket.ts when joining the Runner experience. */
502
+ onRunnerJoin(msg: { spawnX: number; spawnY: number; spawnZ: number; localEntityId: number; seed: number; payload: { laneWidth: number; trackWidth: number } }): void {
503
+ // Dispose previous scene state
504
+ this.disposeRunnerScene();
505
+ this.disposeShooterArena();
506
+ if (this.dungeonGroup) {
507
+ this.scene.remove(this.dungeonGroup);
508
+ disposeMesh(this.dungeonGroup, this.shaderManager);
509
+ this.dungeonGroup = null;
510
+ this.shaderManager.clear();
511
+ }
512
+ if (this.voxelChunkStreamer) {
513
+ this.voxelChunkStreamer.dispose();
514
+ this.voxelChunkStreamer = null;
515
+ }
516
+ for (const group of this.structureMeshes) {
517
+ this.scene.remove(group);
518
+ group.traverse((child) => {
519
+ if (child instanceof THREE.Mesh) {
520
+ child.geometry.dispose();
521
+ if (Array.isArray(child.material)) child.material.forEach(m => m.dispose());
522
+ else child.material.dispose();
523
+ }
524
+ });
525
+ }
526
+ this.structureMeshes = [];
527
+ this.ambientEmitter?.dispose();
528
+ this.ambientEmitter = null;
529
+
530
+ // Build runner scene (lighting)
531
+ this.runnerSceneGroup = buildRunnerScene();
532
+ this.scene.add(this.runnerSceneGroup);
533
+ this.runnerSeed = msg.seed;
534
+
535
+ // Init client-side track generator (same seed as server = identical track)
536
+ this.runnerTrackState = createTrackGenerator(msg.seed);
537
+ updateTrack(this.runnerTrackState, 0, 200, 50);
538
+ // Build initial segment meshes
539
+ for (const seg of this.runnerTrackState.segments) {
540
+ const mesh = buildSegmentMesh({
541
+ type: seg.def.type,
542
+ startZ: seg.startZ,
543
+ endZ: seg.endZ,
544
+ length: seg.def.length,
545
+ lanes: seg.def.lanes,
546
+ gapLength: seg.def.gapLength,
547
+ rampHeight: seg.def.rampHeight,
548
+ index: seg.index,
549
+ });
550
+ this.scene.add(mesh);
551
+ this.runnerSegmentMeshes.set(seg.index, mesh);
552
+ }
553
+
554
+ // Set fog and sky
555
+ this.sceneContext.setFog('#0a0a1e', 0.008);
556
+ this.skyMesh?.setSkyForArea('runner');
557
+
558
+ // Reset entity renderer
559
+ this.entityRenderer.dispose();
560
+ this.entityRenderer = new EntityRenderer(this.scene);
561
+
562
+ // Position camera behind the start
563
+ this.camera.position.set(0, 8, -10);
564
+ this.camera.lookAt(0, 0, 10);
565
+
566
+ // Force follow mode
567
+ this.setMode('wasd+follow');
568
+
569
+ // Set up runner-specific input
570
+ this.setupRunnerInput();
571
+ }
572
+
573
+ /** Called when runner is restarted after death. */
574
+ onRunnerRestart(): void {
575
+ // Clear segment meshes
576
+ for (const [, mesh] of this.runnerSegmentMeshes) {
577
+ this.scene.remove(mesh);
578
+ disposeSegmentMesh(mesh);
579
+ }
580
+ this.runnerSegmentMeshes.clear();
581
+
582
+ // Reset track generator and rebuild initial segments
583
+ this.runnerTrackState = createTrackGenerator(this.runnerSeed);
584
+ updateTrack(this.runnerTrackState, 0, 200, 50);
585
+ for (const seg of this.runnerTrackState.segments) {
586
+ const mesh = buildSegmentMesh({
587
+ type: seg.def.type,
588
+ startZ: seg.startZ,
589
+ endZ: seg.endZ,
590
+ length: seg.def.length,
591
+ lanes: seg.def.lanes,
592
+ gapLength: seg.def.gapLength,
593
+ rampHeight: seg.def.rampHeight,
594
+ index: seg.index,
595
+ });
596
+ this.scene.add(mesh);
597
+ this.runnerSegmentMeshes.set(seg.index, mesh);
598
+ }
599
+
600
+ // Reset camera
601
+ this.camera.position.set(0, 8, -10);
602
+ this.camera.lookAt(0, 0, 10);
603
+ }
604
+
605
+ /** Set up runner-specific input handlers. */
606
+ private setupRunnerInput(): void {
607
+ this.disposeRunnerInput();
608
+ this.runnerKeys = {};
609
+
610
+ const onKeyDown = (e: KeyboardEvent) => {
611
+ if (store.currentArea !== 'runner') return;
612
+ this.runnerKeys[e.code] = true;
613
+
614
+ if (e.code === 'Space' && !e.repeat) {
615
+ e.preventDefault();
616
+ sendMessage({ type: 'RUNNER_INPUT', left: false, right: false, jump: true, glide: false });
617
+ }
618
+ if ((e.code === 'KeyA' || e.code === 'ArrowLeft') && !e.repeat) {
619
+ sendMessage({ type: 'RUNNER_INPUT', left: true, right: false, jump: false, glide: false });
620
+ }
621
+ if ((e.code === 'KeyD' || e.code === 'ArrowRight') && !e.repeat) {
622
+ sendMessage({ type: 'RUNNER_INPUT', left: false, right: true, jump: false, glide: false });
623
+ }
624
+ };
625
+ const onKeyUp = (e: KeyboardEvent) => {
626
+ this.runnerKeys[e.code] = false;
627
+ };
628
+
629
+ window.addEventListener('keydown', onKeyDown);
630
+ window.addEventListener('keyup', onKeyUp);
631
+
632
+ this.runnerInputCleanup = () => {
633
+ window.removeEventListener('keydown', onKeyDown);
634
+ window.removeEventListener('keyup', onKeyUp);
635
+ };
636
+ }
637
+
638
+ private disposeRunnerInput(): void {
639
+ this.runnerInputCleanup?.();
640
+ this.runnerInputCleanup = null;
641
+ this.runnerKeys = {};
642
+ }
643
+
644
+ /** Send glide state each frame when in runner. */
645
+ private tickRunnerInput(): void {
646
+ if (store.currentArea !== 'runner' || store.runnerDead) return;
647
+
648
+ // Glide state: hold space while airborne
649
+ const isGliding = this.runnerKeys['Space'] || false;
650
+ sendMessage({ type: 'RUNNER_INPUT', left: false, right: false, jump: false, glide: isGliding });
651
+ }
652
+
653
+ /** Update track segment meshes based on player position. */
654
+ private updateRunnerTrack(): void {
655
+ if (store.currentArea !== 'runner' || !this.runnerTrackState) return;
656
+
657
+ const playerPos = this.getInterpolatedPlayerPosition();
658
+ if (!playerPos) return;
659
+
660
+ // playerPos.y is the forward Z in 2D mapping
661
+ const playerZ = playerPos.y;
662
+
663
+ // Remember which segments existed before update
664
+ const prevIndices = new Set(this.runnerTrackState.segments.map(s => s.index));
665
+
666
+ // Stream track — generate ahead, dispose behind
667
+ updateTrack(this.runnerTrackState, playerZ, 200, 50);
668
+
669
+ // Build meshes for new segments
670
+ for (const seg of this.runnerTrackState.segments) {
671
+ if (!this.runnerSegmentMeshes.has(seg.index)) {
672
+ const mesh = buildSegmentMesh({
673
+ type: seg.def.type,
674
+ startZ: seg.startZ,
675
+ endZ: seg.endZ,
676
+ length: seg.def.length,
677
+ lanes: seg.def.lanes,
678
+ gapLength: seg.def.gapLength,
679
+ rampHeight: seg.def.rampHeight,
680
+ index: seg.index,
681
+ });
682
+ this.scene.add(mesh);
683
+ this.runnerSegmentMeshes.set(seg.index, mesh);
684
+ }
685
+ }
686
+
687
+ // Dispose meshes for removed segments
688
+ const currentIndices = new Set(this.runnerTrackState.segments.map(s => s.index));
689
+ for (const [idx, mesh] of this.runnerSegmentMeshes) {
690
+ if (!currentIndices.has(idx)) {
691
+ this.scene.remove(mesh);
692
+ disposeSegmentMesh(mesh);
693
+ this.runnerSegmentMeshes.delete(idx);
694
+ }
695
+ }
696
+
697
+ this.updateRunnerCamera(playerPos);
698
+ }
699
+
700
+ /** Update camera to follow runner player. */
701
+ private updateRunnerCamera(playerPos: { x: number; y: number; worldY?: number }): void {
702
+ const px = playerPos.x;
703
+ const pz = playerPos.y; // forward
704
+ const py = (playerPos.worldY ?? 0);
705
+
706
+ // Camera: behind and above the player, looking forward
707
+ const camOffsetY = 6;
708
+ const camOffsetZ = -10;
709
+ this.camera.position.set(px * 0.3, py + camOffsetY, pz + camOffsetZ);
710
+ this.camera.lookAt(px * 0.3, py + 1, pz + 15);
711
+ }
712
+
713
+ private disposeRunnerScene(): void {
714
+ if (this.runnerSceneGroup) {
715
+ this.scene.remove(this.runnerSceneGroup);
716
+ disposeRunnerScene(this.runnerSceneGroup);
717
+ this.runnerSceneGroup = null;
718
+ }
719
+ for (const [, mesh] of this.runnerSegmentMeshes) {
720
+ this.scene.remove(mesh);
721
+ disposeSegmentMesh(mesh);
722
+ }
723
+ this.runnerSegmentMeshes.clear();
724
+ this.runnerTrackState = null;
725
+ this.disposeRunnerInput();
726
+ }
727
+
728
+ /** Place basecamp structures and dungeon portal in the overworld. */
729
+ private setupOverworldStructures(
730
+ seed: number,
731
+ layout: ReturnType<typeof parseOverworldLayout>,
732
+ ): void {
733
+ // Terrain height lookup using client-side chunk generation
734
+ const tileInfoGrid: TileInfo[][] = layout.tiles.map(row =>
735
+ row.map(tile => ({ terrainOptions: getBiomeTerrainOptions(tile.def.biomeId) })),
736
+ );
737
+ const flatZones = getStructureFlatZones(layout);
738
+ const blendFn = createBiomeBlendFn(tileInfoGrid, TILE_SIZE, TILE_SIZE, flatZones);
739
+
740
+ // Simple chunk cache for height queries
741
+ const chunkCache = new Map<string, import('@loonylabs/gamedev-core').VoxelChunk>();
742
+ const getChunkFn = (cx: number, cz: number) => {
743
+ const key = `${cx},${cz}`;
744
+ let chunk = chunkCache.get(key);
745
+ if (!chunk) {
746
+ chunk = generateVoxelChunk(cx, cz, seed, {}, blendFn);
747
+ chunkCache.set(key, chunk);
748
+ }
749
+ return chunk;
750
+ };
751
+ const getHeight = (x: number, z: number) =>
752
+ getTerrainHeight(getChunkFn, x, z) ?? 32;
753
+
754
+ // Find basecamp center
755
+ const basecampTiles = layout.tiles.flat().filter(t => t.def.type === 'B');
756
+ if (basecampTiles.length > 0) {
757
+ const cx = basecampTiles.reduce((s, t) => s + (t.col + 0.5) * TILE_SIZE, 0) / basecampTiles.length;
758
+ const cz = basecampTiles.reduce((s, t) => s + (t.row + 0.5) * TILE_SIZE, 0) / basecampTiles.length;
759
+ const basecampGroup = createBasecampMeshes({
760
+ center: { x: cx, z: cz },
761
+ getTerrainHeight: getHeight,
762
+ });
763
+ this.scene.add(basecampGroup);
764
+ this.structureMeshes.push(basecampGroup);
765
+ }
766
+
767
+ // Find dungeon entrance
768
+ const dungeonTile = layout.tiles.flat().find(t => t.def.type === 'D');
769
+ if (dungeonTile) {
770
+ const portalX = (dungeonTile.col + 0.5) * TILE_SIZE;
771
+ const portalZ = (dungeonTile.row + 0.5) * TILE_SIZE;
772
+ const portal = createDungeonPortal(portalX, portalZ, getHeight);
773
+ this.scene.add(portal);
774
+ this.structureMeshes.push(portal);
775
+ }
776
+ }
777
+
778
+ /** Called by socket.ts with server reconciliation data. */
779
+ acknowledgeInput(pos: { x: number; y: number }, inputId: number): void {
780
+ if (this.inputController instanceof WASDController) {
781
+ this.inputController.acknowledgeInput(pos, inputId);
782
+ }
783
+ }
784
+
785
+ addTracer(origin: { x: number; y: number; z: number }, endPoint: { x: number; y: number; z: number }, hit: boolean, color?: number): void {
786
+ this.vfxRenderer.addTracer(origin, endPoint, hit, color);
787
+ }
788
+
789
+ dispose(): void {
790
+ this.inputController.dispose();
791
+ this.cameraController.dispose();
792
+ this.sceneContext.dispose();
793
+ this.combatDetector.dispose();
794
+ soundManager.dispose();
795
+ musicManager.dispose();
796
+ this.skyMesh?.dispose();
797
+ this.ambientEmitter?.dispose();
798
+ this.voxelChunkStreamer?.dispose();
799
+ this.disposeShooterArena();
800
+ this.disposeRunnerScene();
801
+ if (this._onKeyDown) {
802
+ window.removeEventListener('keydown', this._onKeyDown);
803
+ }
804
+ }
805
+
806
+ private animate(): void {
807
+ requestAnimationFrame(() => this.animate());
808
+ const now = performance.now();
809
+ const dt = Math.min((now - this.lastTime) / 1000, 0.1);
810
+ this.lastTime = now;
811
+
812
+ this.shaderManager.tick(now / 1000);
813
+ this.skyMesh?.tick(now / 1000);
814
+ tickTerrainMaterial(now / 1000, store.terrainQuality);
815
+ this.latestInterpolated = getInterpolatedEntities(snapshotBuffer);
816
+ this.cameraController.update(dt);
817
+ if (store.currentArea === 'shooter') {
818
+ this.tickShooterInput();
819
+ }
820
+ if (store.currentArea === 'runner') {
821
+ this.tickRunnerInput();
822
+ this.updateRunnerTrack();
823
+ }
824
+ this.inputController.update(dt);
825
+ this.entityRenderer.update(this.latestInterpolated, dt);
826
+ this.ambientEmitter?.update(dt);
827
+
828
+ // Update voxel chunk streamer if active
829
+ if (this.voxelChunkStreamer) {
830
+ const playerPos = this.getInterpolatedPlayerPosition();
831
+ if (playerPos) {
832
+ this.voxelChunkStreamer.update(playerPos.x, playerPos.y);
833
+ }
834
+ }
835
+
836
+ this.renderer.render(this.scene, this.camera);
837
+
838
+ // Update shared aim state for skill bar / dev tools
839
+ // Use player eye position as origin so skills fire from the player, not the camera
840
+ const dir = new THREE.Vector3();
841
+ this.camera.getWorldDirection(dir);
842
+ const aimPlayerPos = this.getInterpolatedPlayerPosition();
843
+ const aimOrigin = aimPlayerPos
844
+ ? { x: aimPlayerPos.x, y: (aimPlayerPos.worldY ?? 0) + 1.7, z: aimPlayerPos.y }
845
+ : { x: this.camera.position.x, y: this.camera.position.y, z: this.camera.position.z };
846
+ updateAim(aimOrigin, { x: dir.x, y: dir.y, z: dir.z });
847
+
848
+ // Aim debug line — starts at player position, points in facing direction
849
+ if (store.showAimDebug) {
850
+ if (!this.aimDebugLine) {
851
+ const mat = new THREE.LineBasicMaterial({ color: 0xff0000 });
852
+ const geo = new THREE.BufferGeometry();
853
+ this.aimDebugLine = new THREE.Line(geo, mat);
854
+ this.scene.add(this.aimDebugLine);
855
+ }
856
+ const playerPos = this.getInterpolatedPlayerPosition();
857
+ if (playerPos) {
858
+ const angle = store.playerFacingAngle ?? 0;
859
+ const aimDir = new THREE.Vector3(Math.sin(angle), 0, Math.cos(angle));
860
+ const origin = new THREE.Vector3(playerPos.x, (playerPos.worldY ?? 0) + 1.0, playerPos.y);
861
+ const end = origin.clone().addScaledVector(aimDir, 30);
862
+ (this.aimDebugLine.geometry as THREE.BufferGeometry).setFromPoints([origin, end]);
863
+ this.aimDebugLine.visible = true;
864
+ }
865
+ } else if (this.aimDebugLine) {
866
+ this.aimDebugLine.visible = false;
867
+ }
868
+
869
+ // Combat detection — run every 500ms
870
+ this.combatCheckTimer += dt;
871
+ if (this.combatCheckTimer >= 0.5) {
872
+ this.combatCheckTimer = 0;
873
+ const playerPos = this.getInterpolatedPlayerPosition();
874
+ if (playerPos && this.audioInitialized) {
875
+ this.combatDetector.check(
876
+ playerPos,
877
+ this.latestInterpolated as Array<{ type?: string; x: number; y: number }>,
878
+ (musicConfig as { combatDetectionRadius: number }).combatDetectionRadius,
879
+ );
880
+ }
881
+ }
882
+ }
883
+
884
+ private getInterpolatedPlayerPosition(): { x: number; y: number; worldY?: number } | null {
885
+ const id = store.localEntityId;
886
+ if (!id) return null;
887
+ const entity = this.latestInterpolated.find(e => e.entityId === id);
888
+ if (!entity) return null;
889
+ return { x: entity.x, y: entity.y, worldY: entity.worldY };
890
+ }
891
+
892
+ private getRawPlayerPosition(): { x: number; y: number } | null {
893
+ const id = store.localEntityId;
894
+ if (!id) return null;
895
+ const entity = store.entities.find(e => e.entityId === id);
896
+ if (!entity) return null;
897
+ return { x: entity.x, y: entity.y };
898
+ }
899
+
900
+ private centerOrbit(): void {
901
+ if (!store.dungeon || !('centerOn' in this.cameraController)) return;
902
+ let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity;
903
+ for (const room of store.dungeon.rooms) {
904
+ const x1 = room.worldOffset.x;
905
+ const z1 = room.worldOffset.y;
906
+ const x2 = x1 + room.grid.width;
907
+ const z2 = z1 + room.grid.height;
908
+ if (x1 < minX) minX = x1;
909
+ if (z1 < minZ) minZ = z1;
910
+ if (x2 > maxX) maxX = x2;
911
+ if (z2 > maxZ) maxZ = z2;
912
+ }
913
+ const cx = (minX + maxX) / 2;
914
+ const cz = (minZ + maxZ) / 2;
915
+ const span = Math.max(maxX - minX, maxZ - minZ);
916
+ (this.cameraController as any).centerOn(cx, 0, cz, span);
917
+ }
918
+ }