@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,113 @@
1
+ /**
2
+ * Enemy AI System — runs AI state machine for all enemies each tick.
3
+ * Processes enemy shots via hitscan against players.
4
+ */
5
+
6
+ import type { ExperienceSystem, SystemContext } from '../../../../packages/core/src/index.js';
7
+ import { updateShooterEnemyAI, type AIUpdateContext } from '../ai/aiStateMachine.js';
8
+ import { getArenaObstacles } from '../arena/arenaGenerator.js';
9
+ import { hitscanRaycast, type HitscanTarget } from '../arena/hitscan.js';
10
+ import type { AABB } from '../arena/arenaTypes.js';
11
+ import type { ShooterSessionWithEnemies } from '../index.js';
12
+
13
+ export class EnemyAISystem implements ExperienceSystem {
14
+ readonly id = 'shooter-enemy-ai';
15
+
16
+ private obstacles: AABB[] = [];
17
+
18
+ init(session: unknown): void {
19
+ const s = session as ShooterSessionWithEnemies;
20
+ this.obstacles = getArenaObstacles(s.arena);
21
+ }
22
+
23
+ update(session: unknown, dt: number, context: SystemContext): void {
24
+ const s = session as ShooterSessionWithEnemies;
25
+ const io = context.io as { sockets: { sockets: Map<string, { emit: (ev: string, data: unknown) => void }> } };
26
+
27
+ const aiCtx: AIUpdateContext = {
28
+ players: s.players,
29
+ obstacles: this.obstacles,
30
+ arena: s.arena,
31
+ enemyShots: [],
32
+ };
33
+
34
+ // Update all enemies
35
+ for (const enemy of s.enemies) {
36
+ updateShooterEnemyAI(enemy, dt, aiCtx);
37
+ }
38
+
39
+ // Process enemy shots — raycast against players
40
+ for (const shot of aiCtx.enemyShots) {
41
+ const playerTargets: HitscanTarget[] = [];
42
+ for (const player of s.players.values()) {
43
+ if (player.hp <= 0) continue;
44
+ playerTargets.push({
45
+ entityId: player.entityId,
46
+ position: { x: player.body.x, y: player.body.y + 1, z: player.body.z },
47
+ radius: 0.5,
48
+ });
49
+ }
50
+
51
+ const result = hitscanRaycast(
52
+ shot.origin,
53
+ shot.direction,
54
+ 100,
55
+ this.obstacles,
56
+ playerTargets,
57
+ );
58
+
59
+ if (result && result.entityId !== null) {
60
+ // Find and damage the player
61
+ for (const player of s.players.values()) {
62
+ if (player.entityId === result.entityId) {
63
+ player.hp = Math.max(0, player.hp - shot.damage);
64
+
65
+ // Notify the hit player
66
+ const sock = io.sockets?.sockets?.get(player.socketId);
67
+ if (sock) {
68
+ sock.emit('message', {
69
+ type: 'PLAYER_DAMAGED',
70
+ damage: shot.damage,
71
+ hp: player.hp,
72
+ maxHp: player.maxHp,
73
+ fromEntityId: shot.enemyId,
74
+ });
75
+
76
+ // Player death
77
+ if (player.hp <= 0) {
78
+ sock.emit('message', {
79
+ type: 'PLAYER_DEAD',
80
+ waveReached: s.waveState.currentWave + 1,
81
+ });
82
+ }
83
+ }
84
+ break;
85
+ }
86
+ }
87
+ }
88
+
89
+ // Broadcast tracer VFX for enemy shot to all players
90
+ const endPoint = result
91
+ ? result.hitPoint
92
+ : {
93
+ x: shot.origin.x + shot.direction.x * 100,
94
+ y: shot.origin.y + shot.direction.y * 100,
95
+ z: shot.origin.z + shot.direction.z * 100,
96
+ };
97
+
98
+ for (const player of s.players.values()) {
99
+ const sock = io.sockets?.sockets?.get(player.socketId);
100
+ if (sock) {
101
+ sock.emit('message', {
102
+ type: 'COMBAT_VFX',
103
+ vfxType: 'tracer',
104
+ origin: shot.origin,
105
+ endPoint,
106
+ hit: result !== null && result.entityId !== null,
107
+ isEnemy: true,
108
+ });
109
+ }
110
+ }
111
+ }
112
+ }
113
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Entity Sync System — broadcasts player positions to all connected clients.
3
+ */
4
+
5
+ import type { ExperienceSystem, SystemContext } from '../../../../packages/core/src/index.js';
6
+ import type { ShooterSessionWithEnemies } from '../index.js';
7
+
8
+ export class ShooterEntitySyncSystem implements ExperienceSystem {
9
+ readonly id = 'shooter-entity-sync';
10
+
11
+ update(session: unknown, _dt: number, context: SystemContext): void {
12
+ const s = session as ShooterSessionWithEnemies;
13
+ const io = context.io as { sockets: { sockets: Map<string, { emit: (ev: string, data: unknown) => void }> } };
14
+
15
+ const entities: Array<{
16
+ entityId: number;
17
+ x: number;
18
+ y: number;
19
+ hp?: number;
20
+ maxHp?: number;
21
+ type: string;
22
+ z?: number;
23
+ worldY?: number;
24
+ facingAngle?: number;
25
+ }> = [];
26
+
27
+ for (const player of s.players.values()) {
28
+ entities.push({
29
+ entityId: player.entityId,
30
+ x: player.body.x,
31
+ y: player.body.z, // Map 3D Z to 2D Y for entity sync (convention)
32
+ z: player.body.y, // Height
33
+ worldY: player.body.y, // Three.js Y for renderer
34
+ hp: player.hp,
35
+ maxHp: player.maxHp,
36
+ type: 'player',
37
+ facingAngle: player.facingAngle,
38
+ });
39
+ }
40
+
41
+ // Add enemies
42
+ for (const enemy of (s.enemies ?? [])) {
43
+ if (!enemy.alive) continue;
44
+ entities.push({
45
+ entityId: enemy.entityId,
46
+ x: enemy.body.x,
47
+ y: enemy.body.z,
48
+ z: enemy.body.y,
49
+ worldY: enemy.body.y,
50
+ hp: enemy.health,
51
+ maxHp: enemy.maxHealth,
52
+ type: enemy.enemyType,
53
+ });
54
+ }
55
+
56
+ // Broadcast to all players in this session
57
+ for (const player of s.players.values()) {
58
+ const sock = io.sockets?.sockets?.get(player.socketId);
59
+ if (sock) {
60
+ sock.emit('message', {
61
+ type: 'ENTITY_SYNC',
62
+ tick: context.tick,
63
+ entities,
64
+ });
65
+ }
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Shooter Physics System — integrates Character Controller for player movement.
3
+ */
4
+
5
+ import type { ExperienceSystem, SystemContext, CharacterConfig, PhysicsBody } from '../../../../packages/core/src/index.js';
6
+ import { applyMovementInput, applyFriction, applyHorizontalMovement, updatePhysicsBody, snapToGround, DEFAULT_CHARACTER_CONFIG, DEFAULT_PHYSICS_CONFIG } from '../../../../packages/core/src/index.js';
7
+ import { resolveTerrainCollision } from '../../../../packages/core/src/physics/collision.js';
8
+ import type { Arena } from '../arena/arenaTypes.js';
9
+ import { getArenaGroundHeight } from '../arena/arenaGenerator.js';
10
+ import type { ShooterSessionWithEnemies } from '../index.js';
11
+
12
+ /** Shooter-tuned character config — responsive, fast. */
13
+ export const SHOOTER_CHARACTER_CONFIG: CharacterConfig = {
14
+ ...DEFAULT_CHARACTER_CONFIG,
15
+ walkSpeed: 8,
16
+ sprintMultiplier: 1.5,
17
+ maxSpeed: 15,
18
+ friction: 30,
19
+ airFriction: 5,
20
+ airControl: 0.3,
21
+ acceleration: 50,
22
+ };
23
+
24
+ export class ShooterPhysicsSystem implements ExperienceSystem {
25
+ readonly id = 'shooter-physics';
26
+
27
+ private arena: Arena | null = null;
28
+
29
+ init(session: unknown): void {
30
+ this.arena = (session as ShooterSessionWithEnemies).arena;
31
+ }
32
+
33
+ update(session: unknown, dt: number): void {
34
+ const s = session as ShooterSessionWithEnemies;
35
+ if (!this.arena) return;
36
+
37
+ const arena = this.arena;
38
+ const groundHeight = (x: number, z: number) => {
39
+ // Check if position is inside a cover block (acts as wall)
40
+ for (const cover of arena.covers) {
41
+ if (x >= cover.aabb.min.x && x <= cover.aabb.max.x &&
42
+ z >= cover.aabb.min.z && z <= cover.aabb.max.z) {
43
+ return cover.height; // Top of cover block
44
+ }
45
+ }
46
+ return getArenaGroundHeight(arena, x, z);
47
+ };
48
+
49
+ for (const player of s.players.values()) {
50
+ const input = player.input;
51
+
52
+ // Apply character controller
53
+ applyMovementInput(player.body, input, dt, SHOOTER_CHARACTER_CONFIG);
54
+ applyFriction(player.body, dt, SHOOTER_CHARACTER_CONFIG);
55
+ applyHorizontalMovement(player.body, dt);
56
+
57
+ // Terrain collision (walls + cover)
58
+ resolveTerrainCollision(player.body, groundHeight);
59
+
60
+ // Clamp to arena bounds (prevent falling through outer walls)
61
+ const hw = arena.width / 2;
62
+ const hd = arena.depth / 2;
63
+ const margin = player.body.radius + 0.1;
64
+ if (player.body.x < -hw + margin) { player.body.x = -hw + margin; player.body.velocityX = 0; }
65
+ if (player.body.x > hw - margin) { player.body.x = hw - margin; player.body.velocityX = 0; }
66
+ if (player.body.z < -hd + margin) { player.body.z = -hd + margin; player.body.velocityZ = 0; }
67
+ if (player.body.z > hd - margin) { player.body.z = hd - margin; player.body.velocityZ = 0; }
68
+
69
+ // Gravity + ground
70
+ const flatGround = (x: number, z: number) => getArenaGroundHeight(arena, x, z);
71
+ updatePhysicsBody(player.body, dt, flatGround, DEFAULT_PHYSICS_CONFIG);
72
+ if (player.body.isGrounded) {
73
+ snapToGround(player.body, flatGround, DEFAULT_PHYSICS_CONFIG);
74
+ }
75
+ }
76
+
77
+ // Also clamp enemies to arena bounds
78
+ for (const enemy of (s.enemies ?? [])) {
79
+ if (!enemy.alive) continue;
80
+ const hw = arena.width / 2;
81
+ const hd = arena.depth / 2;
82
+ const margin = enemy.body.radius + 0.1;
83
+ if (enemy.body.x < -hw + margin) { enemy.body.x = -hw + margin; enemy.body.velocityX = 0; }
84
+ if (enemy.body.x > hw - margin) { enemy.body.x = hw - margin; enemy.body.velocityX = 0; }
85
+ if (enemy.body.z < -hd + margin) { enemy.body.z = -hd + margin; enemy.body.velocityZ = 0; }
86
+ if (enemy.body.z > hd - margin) { enemy.body.z = hd - margin; enemy.body.velocityZ = 0; }
87
+ }
88
+ }
89
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Wave Spawner System — spawns enemies in escalating waves.
3
+ */
4
+
5
+ import type { ExperienceSystem, SystemContext } from '../../../../packages/core/src/index.js';
6
+ import { createShooterEnemy, type ShooterEnemy } from '../ai/aiStateMachine.js';
7
+ import { getWaveDefinition, type WaveDefinition } from '../data/wave-definitions.js';
8
+ import type { ShooterSessionWithEnemies } from '../index.js';
9
+
10
+ export class WaveSpawnerSystem implements ExperienceSystem {
11
+ readonly id = 'shooter-wave-spawner';
12
+
13
+ update(session: unknown, dt: number, context: SystemContext): void {
14
+ const s = session as ShooterSessionWithEnemies;
15
+
16
+ // Don't run if no players
17
+ if (s.players.size === 0) return;
18
+
19
+ const ws = s.waveState;
20
+
21
+ if (!ws.waveActive) {
22
+ // Between waves — count down delay
23
+ ws.delayTimer -= dt;
24
+ if (ws.delayTimer <= 0) {
25
+ spawnWave(s);
26
+ }
27
+ return;
28
+ }
29
+
30
+ // Wave active — check if all enemies are dead
31
+ const aliveCount = s.enemies.filter(e => e.alive).length;
32
+ if (aliveCount === 0) {
33
+ ws.waveActive = false;
34
+ const waveDef = getWaveDefinition(ws.currentWave);
35
+ ws.delayTimer = waveDef.delayAfterClear;
36
+ ws.currentWave++;
37
+
38
+ // Notify players
39
+ const io = context.io as { sockets: { sockets: Map<string, { emit: (ev: string, data: unknown) => void }> } };
40
+ for (const player of s.players.values()) {
41
+ const sock = io.sockets?.sockets?.get(player.socketId);
42
+ if (sock) {
43
+ sock.emit('message', {
44
+ type: 'WAVE_CLEAR',
45
+ waveNumber: ws.currentWave,
46
+ nextWaveIn: ws.delayTimer,
47
+ });
48
+ }
49
+ }
50
+ }
51
+ }
52
+ }
53
+
54
+ function spawnWave(s: ShooterSessionWithEnemies): void {
55
+ const waveDef = getWaveDefinition(s.waveState.currentWave);
56
+
57
+ // Clear dead enemies from previous wave
58
+ s.enemies = s.enemies.filter(e => e.alive);
59
+
60
+ // Collect spawn points — use arena spawns that are far from players
61
+ const spawnPoints = [...s.arena.spawnPoints];
62
+
63
+ let spawnIdx = 0;
64
+ for (const entry of waveDef.enemies) {
65
+ for (let i = 0; i < entry.count; i++) {
66
+ const spawn = spawnPoints[spawnIdx % spawnPoints.length];
67
+ // Offset slightly so enemies don't stack
68
+ const offset = (spawnIdx * 1.5) % 4 - 2;
69
+ const position = {
70
+ x: spawn.x + offset,
71
+ y: spawn.y,
72
+ z: spawn.z + offset,
73
+ };
74
+
75
+ const enemy = createShooterEnemy(
76
+ s.nextEntityId++,
77
+ entry.type,
78
+ position,
79
+ s.nextEntityId * 31337 + s.waveState.currentWave,
80
+ );
81
+ s.enemies.push(enemy);
82
+ spawnIdx++;
83
+ }
84
+ }
85
+
86
+ s.waveState.waveActive = true;
87
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Weapon System — hitscan shooting, cooldown, ammo, reload.
3
+ */
4
+
5
+ import type { ExperienceSystem, SystemContext } from '../../../../packages/core/src/index.js';
6
+ import { processShoot, updateWeaponState, startReload, DEFAULT_WEAPON_CONFIG } from '../data/weapon-config.js';
7
+ import type { WeaponConfig } from '../data/weapon-config.js';
8
+ import { hitscanRaycast } from '../arena/hitscan.js';
9
+ import { getArenaObstacles } from '../arena/arenaGenerator.js';
10
+ import type { AABB } from '../arena/arenaTypes.js';
11
+ import type { ShooterSessionWithEnemies, ShooterPlayer } from '../index.js';
12
+ import { damageEnemy } from '../ai/aiStateMachine.js';
13
+ import type { HitscanTarget } from '../arena/hitscan.js';
14
+
15
+ export class WeaponSystem implements ExperienceSystem {
16
+ readonly id = 'shooter-weapon';
17
+
18
+ private obstacles: AABB[] = [];
19
+
20
+ init(session: unknown): void {
21
+ const s = session as ShooterSessionWithEnemies;
22
+ this.obstacles = getArenaObstacles(s.arena);
23
+ }
24
+
25
+ update(session: unknown, dt: number, context: SystemContext): void {
26
+ const s = session as ShooterSessionWithEnemies;
27
+ const io = context.io as { sockets: { sockets: Map<string, { emit: (ev: string, data: unknown) => void }> } };
28
+
29
+ for (const player of s.players.values()) {
30
+ const wasReloading = player.weapon.isReloading;
31
+ const prevAmmo = player.weapon.currentAmmo;
32
+
33
+ // Tick weapon timers
34
+ updateWeaponState(player.weapon, dt, DEFAULT_WEAPON_CONFIG);
35
+
36
+ // Process reload request
37
+ if (player.wantsReload) {
38
+ startReload(player.weapon, DEFAULT_WEAPON_CONFIG);
39
+ player.wantsReload = false;
40
+ }
41
+
42
+ // Send WEAPON_STATE on reload start or reload completion
43
+ const reloadChanged = player.weapon.isReloading !== wasReloading || player.weapon.currentAmmo !== prevAmmo;
44
+ if (reloadChanged) {
45
+ const sock = io.sockets?.sockets?.get(player.socketId);
46
+ if (sock) {
47
+ sock.emit('message', {
48
+ type: 'WEAPON_STATE',
49
+ currentAmmo: player.weapon.currentAmmo,
50
+ maxAmmo: DEFAULT_WEAPON_CONFIG.maxAmmo,
51
+ isReloading: player.weapon.isReloading,
52
+ });
53
+ }
54
+ }
55
+
56
+ // Process shoot intents
57
+ while (player.shootQueue.length > 0) {
58
+ const intent = player.shootQueue.shift()!;
59
+ const fired = processShoot(player.weapon, DEFAULT_WEAPON_CONFIG);
60
+ if (!fired) {
61
+ // Auto-reload when trying to shoot with empty magazine
62
+ if (player.weapon.currentAmmo <= 0 && !player.weapon.isReloading) {
63
+ startReload(player.weapon, DEFAULT_WEAPON_CONFIG);
64
+ }
65
+ break;
66
+ }
67
+
68
+ // Build enemy targets for hitscan — two spheres per enemy (body + head)
69
+ const enemyTargets: HitscanTarget[] = [];
70
+ for (const e of (s.enemies ?? [])) {
71
+ if (!e.alive) continue;
72
+ // Body sphere (torso height, large radius)
73
+ enemyTargets.push({
74
+ entityId: e.entityId,
75
+ position: { x: e.body.x, y: e.body.y + 0.6, z: e.body.z },
76
+ radius: 0.8,
77
+ });
78
+ // Head sphere (higher, smaller)
79
+ enemyTargets.push({
80
+ entityId: e.entityId,
81
+ position: { x: e.body.x, y: e.body.y + 1.3, z: e.body.z },
82
+ radius: 0.5,
83
+ });
84
+ }
85
+
86
+ // Raycast
87
+ const result = hitscanRaycast(
88
+ intent.origin,
89
+ intent.direction,
90
+ DEFAULT_WEAPON_CONFIG.range,
91
+ this.obstacles,
92
+ enemyTargets,
93
+ );
94
+
95
+ // Send weapon state update
96
+ const sock = io.sockets?.sockets?.get(player.socketId);
97
+ if (sock) {
98
+ sock.emit('message', {
99
+ type: 'WEAPON_STATE',
100
+ currentAmmo: player.weapon.currentAmmo,
101
+ maxAmmo: DEFAULT_WEAPON_CONFIG.maxAmmo,
102
+ isReloading: player.weapon.isReloading,
103
+ });
104
+
105
+ // Send hit confirm / tracer VFX
106
+ const endPoint = result
107
+ ? result.hitPoint
108
+ : {
109
+ x: intent.origin.x + intent.direction.x * DEFAULT_WEAPON_CONFIG.range,
110
+ y: intent.origin.y + intent.direction.y * DEFAULT_WEAPON_CONFIG.range,
111
+ z: intent.origin.z + intent.direction.z * DEFAULT_WEAPON_CONFIG.range,
112
+ };
113
+
114
+ sock.emit('message', {
115
+ type: 'COMBAT_VFX',
116
+ vfxType: 'tracer',
117
+ origin: intent.origin,
118
+ endPoint,
119
+ hit: result !== null && result.entityId !== null,
120
+ });
121
+
122
+
123
+ if (result?.entityId !== null && result?.entityId !== undefined) {
124
+ // Apply damage to enemy
125
+ const hitEnemy = (s.enemies ?? []).find(e => e.entityId === result.entityId);
126
+ let killed = false;
127
+ if (hitEnemy) {
128
+ killed = damageEnemy(hitEnemy, DEFAULT_WEAPON_CONFIG.damage);
129
+ }
130
+
131
+ sock.emit('message', {
132
+ type: 'HIT_CONFIRM',
133
+ targetEntityId: result.entityId,
134
+ damage: DEFAULT_WEAPON_CONFIG.damage,
135
+ hitX: result.hitPoint.x,
136
+ hitY: result.hitPoint.y,
137
+ hitZ: result.hitPoint.z,
138
+ });
139
+
140
+ if (killed) {
141
+ // Notify all players of enemy death
142
+ for (const p of s.players.values()) {
143
+ const pSock = io.sockets?.sockets?.get(p.socketId);
144
+ if (pSock) {
145
+ pSock.emit('message', {
146
+ type: 'ENEMY_KILLED',
147
+ entityId: result.entityId,
148
+ });
149
+ }
150
+ }
151
+ }
152
+ }
153
+ }
154
+ }
155
+ }
156
+ }
157
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "areas": [
3
+ {
4
+ "id": "overworld",
5
+ "type": "voxel",
6
+ "transitions": [
7
+ { "tileType": "D", "targetArea": "dungeon", "spawnX": -1, "spawnY": -1 }
8
+ ]
9
+ },
10
+ {
11
+ "id": "dungeon",
12
+ "type": "dungeon",
13
+ "transitions": [
14
+ { "cellType": "transition", "targetArea": "overworld", "spawnX": -1, "spawnY": -1 }
15
+ ]
16
+ }
17
+ ]
18
+ }