@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,328 @@
1
+ import { describe, test, expect } from 'vitest';
2
+ import {
3
+ createShooterEnemy,
4
+ updateShooterEnemyAI,
5
+ damageEnemy,
6
+ type AIUpdateContext,
7
+ type ShooterEnemy,
8
+ } from '../../../experiences/shooter/ai/aiStateMachine.js';
9
+ import { hasLineOfSight, distance3D } from '../../../experiences/shooter/ai/lineOfSight.js';
10
+ import { getWaveDefinition, WAVE_DEFINITIONS } from '../../../experiences/shooter/data/wave-definitions.js';
11
+ import { ENEMY_CONFIGS } from '../../../experiences/shooter/data/enemy-types.js';
12
+ import { generateArena, getArenaObstacles } from '../../../experiences/shooter/arena/arenaGenerator.js';
13
+ import { createPhysicsBody } from '../../../../packages/core/src/physics/gravity.js';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Test Helpers
17
+ // ---------------------------------------------------------------------------
18
+
19
+ function createTestArena() {
20
+ return generateArena(42);
21
+ }
22
+
23
+ function createTestContext(
24
+ playerX: number = 10,
25
+ playerZ: number = 0,
26
+ playerHp: number = 100,
27
+ ): AIUpdateContext {
28
+ const arena = createTestArena();
29
+ const players = new Map([
30
+ ['p1', {
31
+ socketId: 'p1',
32
+ body: createPhysicsBody(playerX, 0, playerZ),
33
+ hp: playerHp,
34
+ }],
35
+ ]);
36
+ return {
37
+ players,
38
+ obstacles: getArenaObstacles(arena),
39
+ arena,
40
+ enemyShots: [],
41
+ };
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Enemy Creation
46
+ // ---------------------------------------------------------------------------
47
+
48
+ describe('createShooterEnemy', () => {
49
+ test('creates enemy with correct type and health', () => {
50
+ const enemy = createShooterEnemy(1, 'rusher', { x: 0, y: 0, z: 0 }, 42);
51
+ expect(enemy.entityId).toBe(1);
52
+ expect(enemy.enemyType).toBe('rusher');
53
+ expect(enemy.health).toBe(ENEMY_CONFIGS.rusher.health);
54
+ expect(enemy.maxHealth).toBe(ENEMY_CONFIGS.rusher.health);
55
+ expect(enemy.alive).toBe(true);
56
+ expect(enemy.aiState).toBe('idle');
57
+ });
58
+
59
+ test('creates different types with different configs', () => {
60
+ const rusher = createShooterEnemy(1, 'rusher', { x: 0, y: 0, z: 0 }, 42);
61
+ const sniper = createShooterEnemy(2, 'sniper', { x: 0, y: 0, z: 0 }, 42);
62
+ expect(rusher.health).not.toBe(sniper.health);
63
+ expect(rusher.config.attackRange).toBeLessThan(sniper.config.attackRange);
64
+ });
65
+ });
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // AI State Machine
69
+ // ---------------------------------------------------------------------------
70
+
71
+ describe('ShooterEnemyAI', () => {
72
+ test('idle enemy detects nearby player', () => {
73
+ const enemy = createShooterEnemy(1, 'rusher', { x: 0, y: 0, z: 0 }, 42);
74
+ const ctx = createTestContext(10, 0); // Player at 10 units away
75
+
76
+ // Run enough ticks to detect
77
+ for (let i = 0; i < 5; i++) {
78
+ updateShooterEnemyAI(enemy, 0.05, ctx);
79
+ }
80
+ expect(enemy.aiState).not.toBe('idle');
81
+ });
82
+
83
+ test('idle enemy does not detect distant player', () => {
84
+ const enemy = createShooterEnemy(1, 'rusher', { x: -15, y: 0, z: -15 }, 42);
85
+ // Player far away (distance ~35, beyond rusher detection of 15)
86
+ const ctx = createTestContext(15, 15);
87
+ updateShooterEnemyAI(enemy, 0.05, ctx);
88
+ expect(enemy.aiState).toBe('idle');
89
+ });
90
+
91
+ test('enemy transitions from detect to chase after reaction time', () => {
92
+ const enemy = createShooterEnemy(1, 'rusher', { x: 0, y: 0, z: 0 }, 42);
93
+ enemy.aiState = 'detect';
94
+ enemy.targetPlayerId = 'p1';
95
+ enemy.stateTimer = 0;
96
+
97
+ const ctx = createTestContext(10, 0);
98
+
99
+ // Tick past reaction time
100
+ for (let i = 0; i < 20; i++) {
101
+ updateShooterEnemyAI(enemy, 0.05, ctx);
102
+ }
103
+ expect(['chase', 'attack']).toContain(enemy.aiState);
104
+ });
105
+
106
+ test('rusher chases toward player', () => {
107
+ const enemy = createShooterEnemy(1, 'rusher', { x: 0, y: 0, z: 0 }, 42);
108
+ enemy.aiState = 'chase';
109
+ enemy.targetPlayerId = 'p1';
110
+
111
+ const ctx = createTestContext(10, 0);
112
+
113
+ // Run several ticks
114
+ for (let i = 0; i < 10; i++) {
115
+ updateShooterEnemyAI(enemy, 0.05, ctx);
116
+ }
117
+ // Should have moved toward player (positive X)
118
+ expect(enemy.body.x).toBeGreaterThan(0);
119
+ });
120
+
121
+ test('enemy switches to attack when in range with LoS', () => {
122
+ const enemy = createShooterEnemy(1, 'rusher', { x: 0, y: 0, z: 0 }, 42);
123
+ enemy.aiState = 'chase';
124
+ enemy.targetPlayerId = 'p1';
125
+
126
+ // Player very close (within attack range of 6)
127
+ const ctx = createTestContext(3, 0);
128
+
129
+ updateShooterEnemyAI(enemy, 0.05, ctx);
130
+ expect(enemy.aiState).toBe('attack');
131
+ });
132
+
133
+ test('patrol retreats at low HP', () => {
134
+ const enemy = createShooterEnemy(1, 'patrol', { x: 0, y: 0, z: 0 }, 42);
135
+ enemy.aiState = 'attack';
136
+ enemy.targetPlayerId = 'p1';
137
+ enemy.health = 5; // Very low, below 20% threshold
138
+
139
+ const ctx = createTestContext(5, 0);
140
+ updateShooterEnemyAI(enemy, 0.05, ctx);
141
+ expect(enemy.aiState).toBe('retreat');
142
+ });
143
+
144
+ test('rusher never retreats', () => {
145
+ const enemy = createShooterEnemy(1, 'rusher', { x: 0, y: 0, z: 0 }, 42);
146
+ enemy.aiState = 'attack';
147
+ enemy.targetPlayerId = 'p1';
148
+ enemy.health = 1;
149
+
150
+ const ctx = createTestContext(3, 0);
151
+ updateShooterEnemyAI(enemy, 0.05, ctx);
152
+ expect(enemy.aiState).not.toBe('retreat');
153
+ });
154
+
155
+ test('enemy shoots when attacking and generates shot intent', () => {
156
+ const enemy = createShooterEnemy(1, 'rusher', { x: 0, y: 0, z: 0 }, 42);
157
+ enemy.aiState = 'attack';
158
+ enemy.targetPlayerId = 'p1';
159
+
160
+ // Player close with clear LoS (no obstacles between them)
161
+ const arena = generateArena(42);
162
+ const players = new Map([
163
+ ['p1', { socketId: 'p1', body: createPhysicsBody(3, 0, 0), hp: 100 }],
164
+ ]);
165
+ // Use empty obstacles so LoS is guaranteed
166
+ const ctx: AIUpdateContext = { players, obstacles: [], arena, enemyShots: [] };
167
+
168
+ // Collect all shots over multiple ticks
169
+ const allShots: typeof ctx.enemyShots = [];
170
+ for (let i = 0; i < 40; i++) {
171
+ ctx.enemyShots = [];
172
+ updateShooterEnemyAI(enemy, 0.05, ctx);
173
+ allShots.push(...ctx.enemyShots);
174
+ }
175
+ // Should have produced at least one shot
176
+ expect(allShots.length).toBeGreaterThan(0);
177
+ });
178
+
179
+ test('dead enemy is not updated', () => {
180
+ const enemy = createShooterEnemy(1, 'rusher', { x: 0, y: 0, z: 0 }, 42);
181
+ enemy.alive = false;
182
+ const initialState = enemy.aiState;
183
+
184
+ const ctx = createTestContext(3, 0);
185
+ updateShooterEnemyAI(enemy, 0.05, ctx);
186
+ expect(enemy.aiState).toBe(initialState);
187
+ });
188
+ });
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Damage
192
+ // ---------------------------------------------------------------------------
193
+
194
+ describe('damageEnemy', () => {
195
+ test('reduces health', () => {
196
+ const enemy = createShooterEnemy(1, 'rusher', { x: 0, y: 0, z: 0 }, 42);
197
+ const killed = damageEnemy(enemy, 10);
198
+ expect(killed).toBe(false);
199
+ expect(enemy.health).toBe(20); // 30 - 10
200
+ expect(enemy.alive).toBe(true);
201
+ });
202
+
203
+ test('kills when health reaches 0', () => {
204
+ const enemy = createShooterEnemy(1, 'rusher', { x: 0, y: 0, z: 0 }, 42);
205
+ const killed = damageEnemy(enemy, 30);
206
+ expect(killed).toBe(true);
207
+ expect(enemy.health).toBe(0);
208
+ expect(enemy.alive).toBe(false);
209
+ });
210
+
211
+ test('overkill sets health to 0', () => {
212
+ const enemy = createShooterEnemy(1, 'rusher', { x: 0, y: 0, z: 0 }, 42);
213
+ damageEnemy(enemy, 100);
214
+ expect(enemy.health).toBe(0);
215
+ expect(enemy.alive).toBe(false);
216
+ });
217
+
218
+ test('cannot damage dead enemy', () => {
219
+ const enemy = createShooterEnemy(1, 'rusher', { x: 0, y: 0, z: 0 }, 42);
220
+ enemy.alive = false;
221
+ const killed = damageEnemy(enemy, 10);
222
+ expect(killed).toBe(false);
223
+ });
224
+ });
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Line of Sight
228
+ // ---------------------------------------------------------------------------
229
+
230
+ describe('lineOfSight', () => {
231
+ test('clear LoS between two points', () => {
232
+ const result = hasLineOfSight(
233
+ { x: 0, y: 1, z: 0 },
234
+ { x: 10, y: 1, z: 0 },
235
+ [],
236
+ );
237
+ expect(result).toBe(true);
238
+ });
239
+
240
+ test('blocked by obstacle', () => {
241
+ const wall = { min: { x: 5, y: 0, z: -1 }, max: { x: 6, y: 3, z: 1 } };
242
+ const result = hasLineOfSight(
243
+ { x: 0, y: 1, z: 0 },
244
+ { x: 10, y: 1, z: 0 },
245
+ [wall],
246
+ );
247
+ expect(result).toBe(false);
248
+ });
249
+
250
+ test('LoS around obstacle', () => {
251
+ const wall = { min: { x: 5, y: 0, z: -1 }, max: { x: 6, y: 3, z: 1 } };
252
+ // Aim around the wall (Z offset)
253
+ const result = hasLineOfSight(
254
+ { x: 0, y: 1, z: 5 },
255
+ { x: 10, y: 1, z: 5 },
256
+ [wall],
257
+ );
258
+ expect(result).toBe(true);
259
+ });
260
+ });
261
+
262
+ describe('distance3D', () => {
263
+ test('calculates correct distance', () => {
264
+ expect(distance3D({ x: 0, y: 0, z: 0 }, { x: 3, y: 4, z: 0 })).toBeCloseTo(5);
265
+ });
266
+
267
+ test('zero distance for same point', () => {
268
+ expect(distance3D({ x: 5, y: 5, z: 5 }, { x: 5, y: 5, z: 5 })).toBe(0);
269
+ });
270
+ });
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Wave Definitions
274
+ // ---------------------------------------------------------------------------
275
+
276
+ describe('WaveDefinitions', () => {
277
+ test('wave 0 has 3 rushers', () => {
278
+ const wave = getWaveDefinition(0);
279
+ expect(wave.enemies).toEqual([{ type: 'rusher', count: 3 }]);
280
+ });
281
+
282
+ test('wave 1 has patrols and rushers', () => {
283
+ const wave = getWaveDefinition(1);
284
+ const total = wave.enemies.reduce((sum, e) => sum + e.count, 0);
285
+ expect(total).toBe(3);
286
+ });
287
+
288
+ test('wave 2 has all types', () => {
289
+ const wave = getWaveDefinition(2);
290
+ const types = wave.enemies.map(e => e.type);
291
+ expect(types).toContain('sniper');
292
+ expect(types).toContain('patrol');
293
+ expect(types).toContain('rusher');
294
+ });
295
+
296
+ test('waves beyond defined ones scale up', () => {
297
+ const lastDefined = getWaveDefinition(WAVE_DEFINITIONS.length - 1);
298
+ const beyond = getWaveDefinition(WAVE_DEFINITIONS.length + 2);
299
+ const lastTotal = lastDefined.enemies.reduce((s, e) => s + e.count, 0);
300
+ const beyondTotal = beyond.enemies.reduce((s, e) => s + e.count, 0);
301
+ expect(beyondTotal).toBeGreaterThan(lastTotal);
302
+ });
303
+ });
304
+
305
+ // ---------------------------------------------------------------------------
306
+ // Enemy Type Configs
307
+ // ---------------------------------------------------------------------------
308
+
309
+ describe('EnemyTypeConfigs', () => {
310
+ test('rusher has high speed, low HP', () => {
311
+ expect(ENEMY_CONFIGS.rusher.health).toBeLessThan(ENEMY_CONFIGS.patrol.health);
312
+ expect(ENEMY_CONFIGS.rusher.characterConfig.walkSpeed).toBeGreaterThan(ENEMY_CONFIGS.patrol.characterConfig.walkSpeed);
313
+ });
314
+
315
+ test('sniper has long range, low fire rate', () => {
316
+ expect(ENEMY_CONFIGS.sniper.attackRange).toBeGreaterThan(ENEMY_CONFIGS.rusher.attackRange);
317
+ expect(ENEMY_CONFIGS.sniper.weaponConfig.fireRate).toBeLessThan(ENEMY_CONFIGS.rusher.weaponConfig.fireRate);
318
+ });
319
+
320
+ test('all configs have unique detection radii', () => {
321
+ const radii = [
322
+ ENEMY_CONFIGS.rusher.detectionRadius,
323
+ ENEMY_CONFIGS.patrol.detectionRadius,
324
+ ENEMY_CONFIGS.sniper.detectionRadius,
325
+ ];
326
+ expect(new Set(radii).size).toBe(3);
327
+ });
328
+ });
@@ -0,0 +1,281 @@
1
+ import { describe, test, expect } from 'vitest';
2
+ import { ShooterExperience, createShooterSession, addShooterPlayer, removeShooterPlayer } from '../../../experiences/shooter/index.js';
3
+ import { generateArena, getArenaGroundHeight, getArenaObstacles } from '../../../experiences/shooter/arena/arenaGenerator.js';
4
+ import { DEFAULT_ARENA_CONFIG } from '../../../experiences/shooter/arena/arenaTypes.js';
5
+ import { createWeaponState, processShoot, startReload, updateWeaponState, DEFAULT_WEAPON_CONFIG } from '../../../experiences/shooter/data/weapon-config.js';
6
+ import { hitscanRaycast } from '../../../experiences/shooter/arena/hitscan.js';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // ShooterExperience
10
+ // ---------------------------------------------------------------------------
11
+
12
+ describe('ShooterExperience', () => {
13
+ test('implements ExperienceDefinition', () => {
14
+ const exp = new ShooterExperience();
15
+ expect(exp.id).toBe('shooter');
16
+ expect(exp.name).toBeDefined();
17
+ expect(exp.description).toBeDefined();
18
+ expect(exp.defaultCameraMode).toBe('third-person');
19
+ });
20
+
21
+ test('createSystems returns non-empty list', () => {
22
+ const exp = new ShooterExperience();
23
+ const systems = exp.createSystems();
24
+ expect(systems.length).toBe(5);
25
+ expect(systems.map(s => s.id)).toEqual([
26
+ 'shooter-wave-spawner', 'shooter-enemy-ai', 'shooter-physics', 'shooter-weapon', 'shooter-entity-sync',
27
+ ]);
28
+ });
29
+
30
+ test('systems have unique ids', () => {
31
+ const exp = new ShooterExperience();
32
+ const ids = exp.createSystems().map(s => s.id);
33
+ expect(new Set(ids).size).toBe(ids.length);
34
+ });
35
+ });
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Arena Generation
39
+ // ---------------------------------------------------------------------------
40
+
41
+ describe('ArenaGenerator', () => {
42
+ test('generates arena with correct dimensions', () => {
43
+ const arena = generateArena(42, DEFAULT_ARENA_CONFIG);
44
+ expect(arena.width).toBe(DEFAULT_ARENA_CONFIG.width);
45
+ expect(arena.depth).toBe(DEFAULT_ARENA_CONFIG.depth);
46
+ });
47
+
48
+ test('generates 4 outer walls', () => {
49
+ const arena = generateArena(42);
50
+ expect(arena.walls).toHaveLength(4);
51
+ });
52
+
53
+ test('generates spawn points in corners', () => {
54
+ const arena = generateArena(42);
55
+ expect(arena.spawnPoints.length).toBeGreaterThanOrEqual(4);
56
+ });
57
+
58
+ test('generates cover blocks', () => {
59
+ const arena = generateArena(42);
60
+ expect(arena.covers.length).toBeGreaterThan(0);
61
+ expect(arena.covers.length).toBeLessThanOrEqual(DEFAULT_ARENA_CONFIG.coverCount);
62
+ });
63
+
64
+ test('same seed produces same arena', () => {
65
+ const a1 = generateArena(42);
66
+ const a2 = generateArena(42);
67
+ expect(a1.covers.length).toBe(a2.covers.length);
68
+ expect(a1.covers.map(c => c.position)).toEqual(a2.covers.map(c => c.position));
69
+ expect(a1.spawnPoints).toEqual(a2.spawnPoints);
70
+ });
71
+
72
+ test('different seeds produce different arenas', () => {
73
+ const a1 = generateArena(42);
74
+ const a2 = generateArena(999);
75
+ // Cover positions should differ (extremely unlikely to be identical)
76
+ const pos1 = a1.covers.map(c => `${c.position.x.toFixed(2)},${c.position.z.toFixed(2)}`).join('|');
77
+ const pos2 = a2.covers.map(c => `${c.position.x.toFixed(2)},${c.position.z.toFixed(2)}`).join('|');
78
+ expect(pos1).not.toBe(pos2);
79
+ });
80
+
81
+ test('ground height returns 0 inside arena', () => {
82
+ const arena = generateArena(42);
83
+ expect(getArenaGroundHeight(arena, 0, 0)).toBe(0);
84
+ expect(getArenaGroundHeight(arena, 5, -5)).toBe(0);
85
+ });
86
+
87
+ test('ground height returns null outside arena', () => {
88
+ const arena = generateArena(42);
89
+ expect(getArenaGroundHeight(arena, 100, 100)).toBeNull();
90
+ expect(getArenaGroundHeight(arena, -100, -100)).toBeNull();
91
+ });
92
+
93
+ test('getArenaObstacles includes walls and covers', () => {
94
+ const arena = generateArena(42);
95
+ const obstacles = getArenaObstacles(arena);
96
+ expect(obstacles.length).toBe(arena.walls.length + arena.covers.length);
97
+ });
98
+ });
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Shooter Session
102
+ // ---------------------------------------------------------------------------
103
+
104
+ describe('ShooterSession', () => {
105
+ test('createShooterSession creates session with arena', () => {
106
+ const session = createShooterSession(42);
107
+ expect(session.arena).toBeDefined();
108
+ expect(session.players.size).toBe(0);
109
+ expect(session.nextEntityId).toBe(1);
110
+ });
111
+
112
+ test('addShooterPlayer adds player at spawn point', () => {
113
+ const session = createShooterSession(42);
114
+ const player = addShooterPlayer(session, 'sock1', 'p1');
115
+ expect(player.entityId).toBe(1);
116
+ expect(player.hp).toBe(100);
117
+ expect(player.weapon.currentAmmo).toBe(DEFAULT_WEAPON_CONFIG.maxAmmo);
118
+ expect(session.players.size).toBe(1);
119
+ });
120
+
121
+ test('removeShooterPlayer removes player', () => {
122
+ const session = createShooterSession(42);
123
+ addShooterPlayer(session, 'sock1', 'p1');
124
+ removeShooterPlayer(session, 'sock1');
125
+ expect(session.players.size).toBe(0);
126
+ });
127
+
128
+ test('multiple players get different spawn points', () => {
129
+ const session = createShooterSession(42);
130
+ const p1 = addShooterPlayer(session, 's1', 'p1');
131
+ const p2 = addShooterPlayer(session, 's2', 'p2');
132
+ expect(p1.body.x !== p2.body.x || p1.body.z !== p2.body.z).toBe(true);
133
+ });
134
+ });
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Weapon System
138
+ // ---------------------------------------------------------------------------
139
+
140
+ describe('WeaponSystem', () => {
141
+ test('shooting decreases ammo', () => {
142
+ const state = createWeaponState(DEFAULT_WEAPON_CONFIG);
143
+ processShoot(state, DEFAULT_WEAPON_CONFIG);
144
+ expect(state.currentAmmo).toBe(DEFAULT_WEAPON_CONFIG.maxAmmo - 1);
145
+ });
146
+
147
+ test('cannot shoot during reload', () => {
148
+ const state = createWeaponState(DEFAULT_WEAPON_CONFIG);
149
+ state.isReloading = true;
150
+ const result = processShoot(state, DEFAULT_WEAPON_CONFIG);
151
+ expect(result).toBe(false);
152
+ expect(state.currentAmmo).toBe(DEFAULT_WEAPON_CONFIG.maxAmmo);
153
+ });
154
+
155
+ test('cannot shoot during cooldown', () => {
156
+ const state = createWeaponState(DEFAULT_WEAPON_CONFIG);
157
+ processShoot(state, DEFAULT_WEAPON_CONFIG); // First shot
158
+ const result = processShoot(state, DEFAULT_WEAPON_CONFIG); // Immediately after
159
+ expect(result).toBe(false);
160
+ });
161
+
162
+ test('cannot shoot with no ammo', () => {
163
+ const state = createWeaponState(DEFAULT_WEAPON_CONFIG);
164
+ state.currentAmmo = 0;
165
+ const result = processShoot(state, DEFAULT_WEAPON_CONFIG);
166
+ expect(result).toBe(false);
167
+ });
168
+
169
+ test('reload restores ammo after timer', () => {
170
+ const state = createWeaponState(DEFAULT_WEAPON_CONFIG);
171
+ state.currentAmmo = 0;
172
+ startReload(state, DEFAULT_WEAPON_CONFIG);
173
+ expect(state.isReloading).toBe(true);
174
+ // Simulate time passing
175
+ for (let i = 0; i < 100; i++) updateWeaponState(state, 0.05, DEFAULT_WEAPON_CONFIG);
176
+ expect(state.currentAmmo).toBe(DEFAULT_WEAPON_CONFIG.maxAmmo);
177
+ expect(state.isReloading).toBe(false);
178
+ });
179
+
180
+ test('fire cooldown expires over time', () => {
181
+ const state = createWeaponState(DEFAULT_WEAPON_CONFIG);
182
+ processShoot(state, DEFAULT_WEAPON_CONFIG);
183
+ expect(state.fireCooldown).toBeGreaterThan(0);
184
+ // Tick past cooldown
185
+ for (let i = 0; i < 20; i++) updateWeaponState(state, 0.05, DEFAULT_WEAPON_CONFIG);
186
+ expect(state.fireCooldown).toBe(0);
187
+ // Should be able to shoot again
188
+ const result = processShoot(state, DEFAULT_WEAPON_CONFIG);
189
+ expect(result).toBe(true);
190
+ });
191
+
192
+ test('cannot reload when already full', () => {
193
+ const state = createWeaponState(DEFAULT_WEAPON_CONFIG);
194
+ startReload(state, DEFAULT_WEAPON_CONFIG);
195
+ expect(state.isReloading).toBe(false);
196
+ });
197
+ });
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Hitscan Raycast
201
+ // ---------------------------------------------------------------------------
202
+
203
+ describe('hitscanRaycast', () => {
204
+ test('hits wall in front of player', () => {
205
+ const obstacles = [{ min: { x: 5, y: 0, z: -1 }, max: { x: 6, y: 3, z: 1 } }];
206
+ const result = hitscanRaycast(
207
+ { x: 0, y: 1, z: 0 },
208
+ { x: 1, y: 0, z: 0 },
209
+ 100,
210
+ obstacles,
211
+ [],
212
+ );
213
+ expect(result).not.toBeNull();
214
+ expect(result!.hitPoint.x).toBeCloseTo(5);
215
+ expect(result!.entityId).toBeNull();
216
+ });
217
+
218
+ test('misses when aiming away', () => {
219
+ const obstacles = [{ min: { x: 5, y: 0, z: -1 }, max: { x: 6, y: 3, z: 1 } }];
220
+ const result = hitscanRaycast(
221
+ { x: 0, y: 1, z: 0 },
222
+ { x: -1, y: 0, z: 0 },
223
+ 100,
224
+ obstacles,
225
+ [],
226
+ );
227
+ expect(result).toBeNull();
228
+ });
229
+
230
+ test('hits entity sphere', () => {
231
+ const entities = [{ entityId: 42, position: { x: 10, y: 1, z: 0 }, radius: 0.5 }];
232
+ const result = hitscanRaycast(
233
+ { x: 0, y: 1, z: 0 },
234
+ { x: 1, y: 0, z: 0 },
235
+ 100,
236
+ [],
237
+ entities,
238
+ );
239
+ expect(result).not.toBeNull();
240
+ expect(result!.entityId).toBe(42);
241
+ expect(result!.distance).toBeCloseTo(9.5, 1);
242
+ });
243
+
244
+ test('returns closest hit (wall before entity)', () => {
245
+ const obstacles = [{ min: { x: 5, y: 0, z: -1 }, max: { x: 6, y: 3, z: 1 } }];
246
+ const entities = [{ entityId: 42, position: { x: 10, y: 1, z: 0 }, radius: 0.5 }];
247
+ const result = hitscanRaycast(
248
+ { x: 0, y: 1, z: 0 },
249
+ { x: 1, y: 0, z: 0 },
250
+ 100,
251
+ obstacles,
252
+ entities,
253
+ );
254
+ expect(result).not.toBeNull();
255
+ expect(result!.entityId).toBeNull(); // Wall is closer
256
+ expect(result!.hitPoint.x).toBeCloseTo(5);
257
+ });
258
+
259
+ test('respects max distance', () => {
260
+ const obstacles = [{ min: { x: 50, y: 0, z: -1 }, max: { x: 51, y: 3, z: 1 } }];
261
+ const result = hitscanRaycast(
262
+ { x: 0, y: 1, z: 0 },
263
+ { x: 1, y: 0, z: 0 },
264
+ 10, // Max distance is 10, wall is at 50
265
+ obstacles,
266
+ [],
267
+ );
268
+ expect(result).toBeNull();
269
+ });
270
+
271
+ test('returns null when nothing in range', () => {
272
+ const result = hitscanRaycast(
273
+ { x: 0, y: 1, z: 0 },
274
+ { x: 1, y: 0, z: 0 },
275
+ 100,
276
+ [],
277
+ [],
278
+ );
279
+ expect(result).toBeNull();
280
+ });
281
+ });
@@ -0,0 +1,29 @@
1
+ import { describe, test, expect } from 'vitest';
2
+ import { VoxelChunkCache } from '../voxelChunkCache.js';
3
+ import { VOXEL_CHUNK_X, VOXEL_CHUNK_Y, VOXEL_CHUNK_Z } from '@loonylabs/gamedev-core';
4
+
5
+ describe('VoxelChunkCache', () => {
6
+ test('generates and caches chunks deterministically', () => {
7
+ const cache = new VoxelChunkCache(42, { baseHeight: 32 });
8
+ const c1 = cache.get(0, 0);
9
+ const c2 = cache.get(0, 0);
10
+ expect(c1).toBe(c2); // same reference = cached
11
+ expect(c1.density.length).toBe(VOXEL_CHUNK_X * VOXEL_CHUNK_Y * VOXEL_CHUNK_Z);
12
+ });
13
+
14
+ test('different chunk coords produce different chunks', () => {
15
+ const cache = new VoxelChunkCache(42, { baseHeight: 32 });
16
+ const c1 = cache.get(0, 0);
17
+ const c2 = cache.get(1, 0);
18
+ expect(c1).not.toBe(c2);
19
+ expect(c1.cx).toBe(0);
20
+ expect(c2.cx).toBe(1);
21
+ });
22
+
23
+ test('getChunkFn returns chunks compatible with getTerrainHeight', () => {
24
+ const cache = new VoxelChunkCache(42, { baseHeight: 32 });
25
+ const chunk = cache.getChunkFn(0, 0);
26
+ expect(chunk).not.toBeNull();
27
+ expect(chunk!.density.length).toBe(VOXEL_CHUNK_X * VOXEL_CHUNK_Y * VOXEL_CHUNK_Z);
28
+ });
29
+ });