@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,292 @@
1
+ /**
2
+ * Procedural track generator for the Runner experience.
3
+ * Seed-based — same seed produces the same track.
4
+ * Streaming — generates ahead of the player, disposes behind.
5
+ */
6
+
7
+ import { mulberry32 } from '../../../../packages/core/src/dungeon/prng.js';
8
+ import type { SegmentDef, PlacedSegment, SegmentCollectible, SegmentPowerup, SegmentObstacle, PlacedCollectible, PlacedPowerup, PlacedObstacle } from './segmentTypes.js';
9
+ import type { Lane, CollectibleType, PowerupType, ObstacleType } from './segmentTypes.js';
10
+ import {
11
+ STRAIGHT_SEGMENT,
12
+ GAP_SEGMENT,
13
+ RAMP_SEGMENT,
14
+ NARROW_LEFT_SEGMENT,
15
+ NARROW_RIGHT_SEGMENT,
16
+ NARROW_CENTER_SEGMENT,
17
+ } from './segmentTypes.js';
18
+ import { LANE_WIDTH } from './laneSystem.js';
19
+
20
+ export interface TrackGeneratorState {
21
+ seed: number;
22
+ /** Where the next segment starts (Z position) */
23
+ nextZ: number;
24
+ /** Segment counter */
25
+ nextIndex: number;
26
+ /** Currently active segments */
27
+ segments: PlacedSegment[];
28
+ /** Auto-incrementing ID for collectibles/powerups/obstacles */
29
+ nextItemId: number;
30
+ }
31
+
32
+ export function createTrackGenerator(seed: number): TrackGeneratorState {
33
+ return { seed, nextZ: 0, nextIndex: 0, segments: [], nextItemId: 1 };
34
+ }
35
+
36
+ /**
37
+ * Generate segments until we have enough ahead of playerZ.
38
+ * Remove segments that are behind playerZ - bufferBehind.
39
+ */
40
+ export function updateTrack(
41
+ state: TrackGeneratorState,
42
+ playerZ: number,
43
+ lookAhead: number = 200,
44
+ bufferBehind: number = 50,
45
+ ): void {
46
+ // Generate ahead
47
+ while (state.nextZ < playerZ + lookAhead) {
48
+ const def = pickSegment(state.seed, state.nextIndex);
49
+ const startZ = state.nextZ;
50
+
51
+ // Place collectibles, powerups, obstacles with world positions
52
+ const collectibles: PlacedCollectible[] = [];
53
+ if (def.collectibles) {
54
+ for (const c of def.collectibles) {
55
+ collectibles.push({
56
+ id: state.nextItemId++,
57
+ type: c.type,
58
+ lane: c.lane,
59
+ x: c.lane * LANE_WIDTH,
60
+ y: 1,
61
+ z: startZ + c.offset * def.length,
62
+ collected: false,
63
+ });
64
+ }
65
+ }
66
+
67
+ let powerup: PlacedPowerup | null = null;
68
+ if (def.powerup) {
69
+ powerup = {
70
+ id: state.nextItemId++,
71
+ type: def.powerup.type,
72
+ lane: def.powerup.lane,
73
+ x: def.powerup.lane * LANE_WIDTH,
74
+ y: 1.5,
75
+ z: startZ + def.powerup.offset * def.length,
76
+ collected: false,
77
+ };
78
+ }
79
+
80
+ const obstacles: PlacedObstacle[] = [];
81
+ if (def.obstacles) {
82
+ for (const o of def.obstacles) {
83
+ obstacles.push({
84
+ id: state.nextItemId++,
85
+ type: o.type,
86
+ lane: o.lane,
87
+ x: o.lane * LANE_WIDTH,
88
+ z: startZ + o.offset * def.length,
89
+ height: o.height,
90
+ });
91
+ }
92
+ }
93
+
94
+ const segment: PlacedSegment = {
95
+ def,
96
+ startZ,
97
+ endZ: startZ + def.length,
98
+ index: state.nextIndex,
99
+ collectibles,
100
+ powerup,
101
+ obstacles,
102
+ };
103
+ state.segments.push(segment);
104
+ state.nextZ += def.length;
105
+ state.nextIndex++;
106
+ }
107
+
108
+ // Dispose behind
109
+ state.segments = state.segments.filter(s => s.endZ > playerZ - bufferBehind);
110
+ }
111
+
112
+ /**
113
+ * Pick a segment type based on difficulty (index).
114
+ * Higher index = more gaps, ramps, narrow sections.
115
+ */
116
+ function pickSegment(seed: number, index: number): SegmentDef {
117
+ const rng = mulberry32(seed ^ (index * 31337));
118
+ // Consume a few values to decorrelate sequential indices
119
+ rng(); rng();
120
+ const roll = rng();
121
+
122
+ const difficulty = Math.min(index / 50, 1); // 0-1 over 50 segments
123
+
124
+ // First 3 segments are always straight (grace period)
125
+ if (index < 3) {
126
+ const def = { ...STRAIGHT_SEGMENT };
127
+ def.collectibles = generateCollectibles(rng, def, difficulty);
128
+ return def;
129
+ }
130
+
131
+ // Probability distribution shifts with difficulty:
132
+ // Early: 70% straight, 15% gap, 10% ramp, 5% narrow
133
+ // Late: 20% straight, 25% gap, 20% ramp, 35% narrow
134
+ const straightChance = 0.70 - 0.50 * difficulty;
135
+ const gapChance = 0.15 + 0.10 * difficulty;
136
+ const rampChance = 0.10 + 0.10 * difficulty;
137
+ // narrowChance = remaining
138
+
139
+ let def: SegmentDef;
140
+ if (roll < straightChance) {
141
+ // Vary straight segment length slightly
142
+ const lengthVariation = 15 + Math.floor(rng() * 15);
143
+ def = { ...STRAIGHT_SEGMENT, length: lengthVariation };
144
+ } else if (roll < straightChance + gapChance) {
145
+ // Gap length grows slightly with difficulty
146
+ const gapLen = 4 + Math.floor(difficulty * 4);
147
+ def = { ...GAP_SEGMENT, gapLength: gapLen };
148
+ } else if (roll < straightChance + gapChance + rampChance) {
149
+ def = { ...RAMP_SEGMENT };
150
+ } else {
151
+ // Narrow — pick subtype
152
+ const narrowRoll = rng();
153
+ if (narrowRoll < 0.33) def = { ...NARROW_LEFT_SEGMENT };
154
+ else if (narrowRoll < 0.66) def = { ...NARROW_RIGHT_SEGMENT };
155
+ else def = { ...NARROW_CENTER_SEGMENT };
156
+ }
157
+
158
+ // Place collectibles on most segments
159
+ def.collectibles = generateCollectibles(rng, def, difficulty);
160
+
161
+ // Place powerup rarely (decreasing with difficulty)
162
+ const powerupChance = 0.15 - 0.05 * difficulty;
163
+ if (rng() < powerupChance && def.type !== 'gap') {
164
+ def.powerup = generatePowerup(rng, def);
165
+ }
166
+
167
+ // Place obstacles on straight/ramp segments (increasing with difficulty)
168
+ if (index >= 5 && (def.type === 'straight' || def.type === 'ramp')) {
169
+ const obstacleChance = 0.1 + 0.4 * difficulty;
170
+ if (rng() < obstacleChance) {
171
+ def.obstacles = generateObstacles(rng, def, difficulty);
172
+ }
173
+ }
174
+
175
+ return def;
176
+ }
177
+
178
+ /** Generate collectible placements for a segment */
179
+ function generateCollectibles(rng: () => number, def: SegmentDef, difficulty: number): SegmentCollectible[] {
180
+ const collectibles: SegmentCollectible[] = [];
181
+ // Skip gap segments (no collectibles in gap area)
182
+ if (def.type === 'gap') return collectibles;
183
+
184
+ // 60% chance of a coin line on the segment
185
+ if (rng() < 0.6) {
186
+ const availableLanes = ([-1, 0, 1] as Lane[]).filter((_, i) => def.lanes[i]);
187
+ if (availableLanes.length === 0) return collectibles;
188
+ const lane = availableLanes[Math.floor(rng() * availableLanes.length)];
189
+ const coinCount = 3 + Math.floor(rng() * 4); // 3-6 coins in a line
190
+ for (let i = 0; i < coinCount; i++) {
191
+ const offset = 0.2 + (i / coinCount) * 0.6; // spread over 20%-80% of segment
192
+ collectibles.push({ lane, offset, type: 'coin' });
193
+ }
194
+ }
195
+
196
+ // 5% chance of a gem
197
+ if (rng() < 0.05) {
198
+ const availableLanes = ([-1, 0, 1] as Lane[]).filter((_, i) => def.lanes[i]);
199
+ if (availableLanes.length > 0) {
200
+ const lane = availableLanes[Math.floor(rng() * availableLanes.length)];
201
+ collectibles.push({ lane, offset: 0.5, type: 'gem' });
202
+ }
203
+ }
204
+
205
+ return collectibles;
206
+ }
207
+
208
+ /** Generate a powerup for a segment */
209
+ function generatePowerup(rng: () => number, def: SegmentDef): SegmentPowerup {
210
+ const availableLanes = ([-1, 0, 1] as Lane[]).filter((_, i) => def.lanes[i]);
211
+ const lane = availableLanes[Math.floor(rng() * availableLanes.length)];
212
+ const types: PowerupType[] = ['speed-boost', 'low-gravity', 'magnet'];
213
+ const type = types[Math.floor(rng() * types.length)];
214
+ return { lane, offset: 0.5, type };
215
+ }
216
+
217
+ /** Generate obstacles for a segment */
218
+ function generateObstacles(rng: () => number, def: SegmentDef, difficulty: number): SegmentObstacle[] {
219
+ const obstacles: SegmentObstacle[] = [];
220
+ const availableLanes = ([-1, 0, 1] as Lane[]).filter((_, i) => def.lanes[i]);
221
+ if (availableLanes.length === 0) return obstacles;
222
+
223
+ // Place 1-2 obstacles, never blocking all lanes
224
+ const count = rng() < 0.3 + 0.3 * difficulty ? 2 : 1;
225
+ const maxBlocked = availableLanes.length - 1; // always leave at least one lane open
226
+ const usedLanes = new Set<Lane>();
227
+
228
+ for (let i = 0; i < Math.min(count, maxBlocked); i++) {
229
+ const freeLanes = availableLanes.filter(l => !usedLanes.has(l));
230
+ if (freeLanes.length <= 1) break; // don't block last open lane
231
+ const lane = freeLanes[Math.floor(rng() * freeLanes.length)];
232
+ usedLanes.add(lane);
233
+
234
+ const typeRoll = rng();
235
+ let type: ObstacleType;
236
+ let height: number;
237
+ if (typeRoll < 0.4) {
238
+ type = 'wall';
239
+ height = 3;
240
+ } else if (typeRoll < 0.7) {
241
+ type = 'low-barrier';
242
+ height = 1;
243
+ } else {
244
+ type = 'high-barrier';
245
+ height = 3;
246
+ }
247
+ obstacles.push({ lane, offset: 0.5, type, height });
248
+ }
249
+
250
+ return obstacles;
251
+ }
252
+
253
+ /**
254
+ * Get the ground height for a given position on the track.
255
+ * Returns the height of the track surface, or null if off-track (gap or no lane).
256
+ */
257
+ export function getTrackGroundHeight(
258
+ state: TrackGeneratorState,
259
+ x: number,
260
+ z: number,
261
+ laneWidth: number = 2,
262
+ ): number | null {
263
+ // Find the segment containing this Z position
264
+ const segment = state.segments.find(s => z >= s.startZ && z < s.endZ);
265
+ if (!segment) return null;
266
+
267
+ // Check if we're on a valid lane
268
+ const laneIndex = Math.round(x / laneWidth) + 1; // -1->0, 0->1, 1->2
269
+ if (laneIndex < 0 || laneIndex > 2) return null;
270
+ if (!segment.def.lanes[laneIndex]) return null;
271
+
272
+ // Handle gap segments
273
+ if (segment.def.type === 'gap' && segment.def.gapLength) {
274
+ const segMid = (segment.startZ + segment.endZ) / 2;
275
+ const halfGap = segment.def.gapLength / 2;
276
+ if (z >= segMid - halfGap && z < segMid + halfGap) {
277
+ return null; // In the gap
278
+ }
279
+ }
280
+
281
+ // Handle ramp segments
282
+ if (segment.def.type === 'ramp' && segment.def.rampHeight) {
283
+ const progress = (z - segment.startZ) / segment.def.length;
284
+ // Triangle ramp: up to peak at 50%, back down
285
+ const rampProgress = progress < 0.5
286
+ ? progress * 2
287
+ : (1 - progress) * 2;
288
+ return rampProgress * segment.def.rampHeight;
289
+ }
290
+
291
+ return 0; // Flat ground
292
+ }
@@ -0,0 +1,394 @@
1
+ /**
2
+ * Shooter Enemy AI — state machine with per-type behavior.
3
+ *
4
+ * States: idle → detect → chase → attack → retreat
5
+ * Pure functions operating on ShooterEnemy state.
6
+ */
7
+
8
+ import type { PhysicsBody } from '../../../../packages/core/src/physics/gravity.js';
9
+ import { applyMovementInput, applyFriction, applyHorizontalMovement } from '../../../../packages/core/src/physics/characterController.js';
10
+ import type { MovementInput } from '../../../../packages/core/src/physics/characterController.js';
11
+ import { updatePhysicsBody, snapToGround, DEFAULT_PHYSICS_CONFIG } from '../../../../packages/core/src/physics/gravity.js';
12
+ import { resolveTerrainCollision } from '../../../../packages/core/src/physics/collision.js';
13
+ import type { WeaponState } from '../data/weapon-config.js';
14
+ import { processShoot, updateWeaponState, createWeaponState } from '../data/weapon-config.js';
15
+ import type { EnemyTypeConfig, EnemyType } from '../data/enemy-types.js';
16
+ import { ENEMY_CONFIGS } from '../data/enemy-types.js';
17
+ import type { Vec3, AABB } from '../arena/arenaTypes.js';
18
+ import type { Arena } from '../arena/arenaTypes.js';
19
+ import { getArenaGroundHeight } from '../arena/arenaGenerator.js';
20
+ import { hasLineOfSight, distance3D } from './lineOfSight.js';
21
+ import { mulberry32 } from '../../../../packages/core/src/dungeon/prng.js';
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Enemy State
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export type AIState = 'idle' | 'detect' | 'chase' | 'attack' | 'retreat';
28
+
29
+ export interface ShooterEnemy {
30
+ entityId: number;
31
+ enemyType: EnemyType;
32
+ config: EnemyTypeConfig;
33
+ aiState: AIState;
34
+ body: PhysicsBody;
35
+ weapon: WeaponState;
36
+ health: number;
37
+ maxHealth: number;
38
+ targetPlayerId: string | null;
39
+ stateTimer: number;
40
+ alive: boolean;
41
+ /** Seed for per-enemy randomness (accuracy spread, patrol directions) */
42
+ rngSeed: number;
43
+ }
44
+
45
+ export function createShooterEnemy(
46
+ entityId: number,
47
+ type: EnemyType,
48
+ position: Vec3,
49
+ seed: number,
50
+ ): ShooterEnemy {
51
+ const config = ENEMY_CONFIGS[type];
52
+ return {
53
+ entityId,
54
+ enemyType: type,
55
+ config,
56
+ aiState: 'idle',
57
+ body: {
58
+ x: position.x,
59
+ y: position.y,
60
+ z: position.z,
61
+ velocityX: 0,
62
+ velocityY: 0,
63
+ velocityZ: 0,
64
+ isGrounded: true,
65
+ radius: 0.5,
66
+ },
67
+ weapon: createWeaponState(config.weaponConfig),
68
+ health: config.health,
69
+ maxHealth: config.health,
70
+ targetPlayerId: null,
71
+ stateTimer: 0,
72
+ alive: true,
73
+ rngSeed: seed,
74
+ };
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // AI Update
79
+ // ---------------------------------------------------------------------------
80
+
81
+ export interface AIUpdateContext {
82
+ players: Map<string, { socketId: string; body: PhysicsBody; hp: number }>;
83
+ obstacles: AABB[];
84
+ arena: Arena;
85
+ /** Accumulated shoot intents from enemies this tick */
86
+ enemyShots: Array<{ enemyId: number; origin: Vec3; direction: Vec3; damage: number }>;
87
+ }
88
+
89
+ /**
90
+ * Update a single enemy's AI state and physics.
91
+ */
92
+ export function updateShooterEnemyAI(
93
+ enemy: ShooterEnemy,
94
+ dt: number,
95
+ ctx: AIUpdateContext,
96
+ ): void {
97
+ if (!enemy.alive) return;
98
+
99
+ enemy.stateTimer += dt;
100
+ updateWeaponState(enemy.weapon, dt, enemy.config.weaponConfig);
101
+
102
+ // Find nearest visible player
103
+ const nearestPlayer = findNearestVisiblePlayer(enemy, ctx);
104
+
105
+ switch (enemy.aiState) {
106
+ case 'idle':
107
+ updateIdle(enemy, dt, nearestPlayer, ctx);
108
+ break;
109
+ case 'detect':
110
+ updateDetect(enemy, dt, nearestPlayer, ctx);
111
+ break;
112
+ case 'chase':
113
+ updateChase(enemy, dt, nearestPlayer, ctx);
114
+ break;
115
+ case 'attack':
116
+ updateAttack(enemy, dt, nearestPlayer, ctx);
117
+ break;
118
+ case 'retreat':
119
+ updateRetreat(enemy, dt, nearestPlayer, ctx);
120
+ break;
121
+ }
122
+
123
+ // Physics
124
+ applyFriction(enemy.body, dt, enemy.config.characterConfig);
125
+ applyHorizontalMovement(enemy.body, dt);
126
+
127
+ const groundFn = (x: number, z: number) => getArenaGroundHeight(ctx.arena, x, z);
128
+ const wallFn = (x: number, z: number) => {
129
+ for (const cover of ctx.arena.covers) {
130
+ if (x >= cover.aabb.min.x && x <= cover.aabb.max.x &&
131
+ z >= cover.aabb.min.z && z <= cover.aabb.max.z) {
132
+ return cover.height;
133
+ }
134
+ }
135
+ return getArenaGroundHeight(ctx.arena, x, z);
136
+ };
137
+
138
+ resolveTerrainCollision(enemy.body, wallFn);
139
+ updatePhysicsBody(enemy.body, dt, groundFn, DEFAULT_PHYSICS_CONFIG);
140
+ if (enemy.body.isGrounded) {
141
+ snapToGround(enemy.body, groundFn, DEFAULT_PHYSICS_CONFIG);
142
+ }
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // State handlers
147
+ // ---------------------------------------------------------------------------
148
+
149
+ function updateIdle(
150
+ enemy: ShooterEnemy,
151
+ _dt: number,
152
+ nearestPlayer: NearestPlayerResult | null,
153
+ _ctx: AIUpdateContext,
154
+ ): void {
155
+ // Wander slightly (just stand)
156
+ if (nearestPlayer && nearestPlayer.distance <= enemy.config.detectionRadius) {
157
+ enemy.aiState = 'detect';
158
+ enemy.stateTimer = 0;
159
+ enemy.targetPlayerId = nearestPlayer.socketId;
160
+ }
161
+ }
162
+
163
+ function updateDetect(
164
+ enemy: ShooterEnemy,
165
+ _dt: number,
166
+ nearestPlayer: NearestPlayerResult | null,
167
+ _ctx: AIUpdateContext,
168
+ ): void {
169
+ // Wait for reaction time
170
+ if (enemy.stateTimer >= enemy.config.reactionTime) {
171
+ if (nearestPlayer && nearestPlayer.distance <= enemy.config.attackRange && nearestPlayer.hasLos) {
172
+ enemy.aiState = 'attack';
173
+ } else if (nearestPlayer) {
174
+ enemy.aiState = 'chase';
175
+ } else {
176
+ enemy.aiState = 'idle';
177
+ }
178
+ enemy.stateTimer = 0;
179
+ }
180
+ }
181
+
182
+ function updateChase(
183
+ enemy: ShooterEnemy,
184
+ dt: number,
185
+ nearestPlayer: NearestPlayerResult | null,
186
+ ctx: AIUpdateContext,
187
+ ): void {
188
+ if (!nearestPlayer) {
189
+ enemy.aiState = 'idle';
190
+ enemy.stateTimer = 0;
191
+ enemy.targetPlayerId = null;
192
+ return;
193
+ }
194
+
195
+ // Check retreat
196
+ if (shouldRetreat(enemy)) {
197
+ enemy.aiState = 'retreat';
198
+ enemy.stateTimer = 0;
199
+ return;
200
+ }
201
+
202
+ // In attack range + LoS? Switch to attack
203
+ if (nearestPlayer.distance <= enemy.config.attackRange && nearestPlayer.hasLos) {
204
+ enemy.aiState = 'attack';
205
+ enemy.stateTimer = 0;
206
+ return;
207
+ }
208
+
209
+ // Move toward player
210
+ moveToward(enemy, nearestPlayer.position, dt);
211
+ }
212
+
213
+ function updateAttack(
214
+ enemy: ShooterEnemy,
215
+ dt: number,
216
+ nearestPlayer: NearestPlayerResult | null,
217
+ ctx: AIUpdateContext,
218
+ ): void {
219
+ if (!nearestPlayer) {
220
+ enemy.aiState = 'idle';
221
+ enemy.stateTimer = 0;
222
+ enemy.targetPlayerId = null;
223
+ return;
224
+ }
225
+
226
+ // Check retreat
227
+ if (shouldRetreat(enemy)) {
228
+ enemy.aiState = 'retreat';
229
+ enemy.stateTimer = 0;
230
+ return;
231
+ }
232
+
233
+ // Lost LoS or out of range? Chase
234
+ if (!nearestPlayer.hasLos || nearestPlayer.distance > enemy.config.attackRange * 1.2) {
235
+ enemy.aiState = 'chase';
236
+ enemy.stateTimer = 0;
237
+ return;
238
+ }
239
+
240
+ // Try to shoot
241
+ tryEnemyShoot(enemy, nearestPlayer, ctx);
242
+
243
+ // Rusher: keep moving toward player while attacking
244
+ if (enemy.enemyType === 'rusher') {
245
+ moveToward(enemy, nearestPlayer.position, dt);
246
+ }
247
+ }
248
+
249
+ function updateRetreat(
250
+ enemy: ShooterEnemy,
251
+ dt: number,
252
+ nearestPlayer: NearestPlayerResult | null,
253
+ ctx: AIUpdateContext,
254
+ ): void {
255
+ if (!nearestPlayer) {
256
+ enemy.aiState = 'idle';
257
+ enemy.stateTimer = 0;
258
+ return;
259
+ }
260
+
261
+ // Move away from player
262
+ moveAway(enemy, nearestPlayer.position, dt);
263
+
264
+ // After retreating for 3 seconds, go back to chase
265
+ if (enemy.stateTimer > 3) {
266
+ enemy.aiState = 'chase';
267
+ enemy.stateTimer = 0;
268
+ }
269
+ }
270
+
271
+ // ---------------------------------------------------------------------------
272
+ // Helpers
273
+ // ---------------------------------------------------------------------------
274
+
275
+ interface NearestPlayerResult {
276
+ socketId: string;
277
+ position: Vec3;
278
+ distance: number;
279
+ hasLos: boolean;
280
+ }
281
+
282
+ function findNearestVisiblePlayer(
283
+ enemy: ShooterEnemy,
284
+ ctx: AIUpdateContext,
285
+ ): NearestPlayerResult | null {
286
+ let nearest: NearestPlayerResult | null = null;
287
+
288
+ const eyePos = { x: enemy.body.x, y: enemy.body.y + 1.5, z: enemy.body.z };
289
+
290
+ for (const [socketId, player] of ctx.players) {
291
+ if (player.hp <= 0) continue;
292
+ const playerPos = { x: player.body.x, y: player.body.y + 1, z: player.body.z };
293
+ const dist = distance3D(eyePos, playerPos);
294
+
295
+ if (dist > enemy.config.detectionRadius) continue;
296
+
297
+ const hasLos = hasLineOfSight(eyePos, playerPos, ctx.obstacles);
298
+
299
+ if (!nearest || dist < nearest.distance) {
300
+ nearest = { socketId, position: playerPos, distance: dist, hasLos };
301
+ }
302
+ }
303
+
304
+ return nearest;
305
+ }
306
+
307
+ function moveToward(enemy: ShooterEnemy, target: Vec3, dt: number): void {
308
+ const dx = target.x - enemy.body.x;
309
+ const dz = target.z - enemy.body.z;
310
+ const len = Math.sqrt(dx * dx + dz * dz);
311
+ if (len < 0.5) return;
312
+
313
+ const input: MovementInput = {
314
+ x: dx / len,
315
+ z: dz / len,
316
+ sprint: enemy.enemyType === 'rusher',
317
+ glide: false,
318
+ };
319
+ applyMovementInput(enemy.body, input, dt, enemy.config.characterConfig);
320
+ }
321
+
322
+ function moveAway(enemy: ShooterEnemy, target: Vec3, dt: number): void {
323
+ const dx = enemy.body.x - target.x;
324
+ const dz = enemy.body.z - target.z;
325
+ const len = Math.sqrt(dx * dx + dz * dz);
326
+ if (len < 0.1) return;
327
+
328
+ const input: MovementInput = {
329
+ x: dx / len,
330
+ z: dz / len,
331
+ sprint: true,
332
+ glide: false,
333
+ };
334
+ applyMovementInput(enemy.body, input, dt, enemy.config.characterConfig);
335
+ }
336
+
337
+ function shouldRetreat(enemy: ShooterEnemy): boolean {
338
+ if (enemy.config.retreatThreshold === 0) return false;
339
+ return enemy.health / enemy.maxHealth <= enemy.config.retreatThreshold;
340
+ }
341
+
342
+ function tryEnemyShoot(
343
+ enemy: ShooterEnemy,
344
+ target: NearestPlayerResult,
345
+ ctx: AIUpdateContext,
346
+ ): void {
347
+ const fired = processShoot(enemy.weapon, enemy.config.weaponConfig);
348
+ if (!fired) return;
349
+
350
+ // Calculate aim direction with accuracy spread
351
+ const eyePos = { x: enemy.body.x, y: enemy.body.y + 1.5, z: enemy.body.z };
352
+ const dx = target.position.x - eyePos.x;
353
+ const dy = target.position.y - eyePos.y;
354
+ const dz = target.position.z - eyePos.z;
355
+ const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
356
+ if (len < 0.001) return;
357
+
358
+ // Add accuracy spread
359
+ const rng = mulberry32(enemy.rngSeed + Math.floor(enemy.stateTimer * 1000));
360
+ const spreadX = (rng() - 0.5) * 2 * enemy.config.accuracySpread;
361
+ const spreadY = (rng() - 0.5) * 2 * enemy.config.accuracySpread;
362
+
363
+ const dir = {
364
+ x: dx / len + spreadX,
365
+ y: dy / len + spreadY,
366
+ z: dz / len,
367
+ };
368
+ // Re-normalize
369
+ const dirLen = Math.sqrt(dir.x * dir.x + dir.y * dir.y + dir.z * dir.z);
370
+ dir.x /= dirLen;
371
+ dir.y /= dirLen;
372
+ dir.z /= dirLen;
373
+
374
+ ctx.enemyShots.push({
375
+ enemyId: enemy.entityId,
376
+ origin: eyePos,
377
+ direction: dir,
378
+ damage: enemy.config.weaponConfig.damage,
379
+ });
380
+ }
381
+
382
+ /**
383
+ * Apply damage to an enemy. Returns true if enemy died.
384
+ */
385
+ export function damageEnemy(enemy: ShooterEnemy, amount: number): boolean {
386
+ if (!enemy.alive) return false;
387
+ enemy.health -= amount;
388
+ if (enemy.health <= 0) {
389
+ enemy.health = 0;
390
+ enemy.alive = false;
391
+ return true;
392
+ }
393
+ return false;
394
+ }