@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,79 @@
1
+ /**
2
+ * Streams voxel terrain chunks around the player position.
3
+ * Generates Marching Cubes meshes on demand and disposes distant ones.
4
+ *
5
+ * Supports two modes:
6
+ * 1. Single VoxelTerrainOptions — uniform terrain (legacy sandbox)
7
+ * 2. TerrainQueryFn — per-column biome-blended terrain (overworld)
8
+ */
9
+ import * as THREE from 'three';
10
+ import type { VoxelTerrainOptions, VoxelChunk, TerrainQueryFn } from '@loonylabs/gamedev-core';
11
+ import { VOXEL_CHUNK_X, VOXEL_CHUNK_Z, generateVoxelChunk } from '@loonylabs/gamedev-core';
12
+ import { buildVoxelChunkMesh } from './voxelMesh.js';
13
+ import type { VoxelMaterialVisual } from './shaders/TerrainShaderMaterial.js';
14
+
15
+ export class VoxelChunkStreamer {
16
+ private meshes = new Map<string, THREE.Mesh>();
17
+ private chunkCache = new Map<string, VoxelChunk>();
18
+ private viewRadius = 3;
19
+
20
+ constructor(
21
+ private scene: THREE.Scene,
22
+ private seed: number,
23
+ private terrainOptions: VoxelTerrainOptions,
24
+ private materialVisuals: Record<number, VoxelMaterialVisual>,
25
+ private terrainQueryFn?: TerrainQueryFn,
26
+ ) {}
27
+
28
+ /** Get or generate a VoxelChunk (cached). Used by neighbor sampler. */
29
+ private getChunk = (cx: number, cz: number): VoxelChunk => {
30
+ const key = `${cx},${cz}`;
31
+ let chunk = this.chunkCache.get(key);
32
+ if (!chunk) {
33
+ chunk = generateVoxelChunk(cx, cz, this.seed, this.terrainOptions, this.terrainQueryFn);
34
+ this.chunkCache.set(key, chunk);
35
+ }
36
+ return chunk;
37
+ };
38
+
39
+ update(playerWorldX: number, playerWorldZ: number): void {
40
+ const pcx = Math.floor(playerWorldX / VOXEL_CHUNK_X);
41
+ const pcz = Math.floor(playerWorldZ / VOXEL_CHUNK_Z);
42
+
43
+ // Add missing chunks
44
+ for (let dx = -this.viewRadius; dx <= this.viewRadius; dx++) {
45
+ for (let dz = -this.viewRadius; dz <= this.viewRadius; dz++) {
46
+ const cx = pcx + dx;
47
+ const cz = pcz + dz;
48
+ const key = `${cx},${cz}`;
49
+ if (!this.meshes.has(key)) {
50
+ const mesh = buildVoxelChunkMesh(cx, cz, this.seed, this.terrainOptions, this.materialVisuals, this.getChunk);
51
+ this.scene.add(mesh);
52
+ this.meshes.set(key, mesh);
53
+ }
54
+ }
55
+ }
56
+
57
+ // Remove distant chunks
58
+ for (const [key, mesh] of this.meshes) {
59
+ const [cx, cz] = key.split(',').map(Number);
60
+ if (Math.abs(cx - pcx) > this.viewRadius + 1 || Math.abs(cz - pcz) > this.viewRadius + 1) {
61
+ this.scene.remove(mesh);
62
+ mesh.geometry.dispose();
63
+ (mesh.material as THREE.Material).dispose();
64
+ this.meshes.delete(key);
65
+ this.chunkCache.delete(key);
66
+ }
67
+ }
68
+ }
69
+
70
+ dispose(): void {
71
+ for (const [, mesh] of this.meshes) {
72
+ this.scene.remove(mesh);
73
+ mesh.geometry.dispose();
74
+ (mesh.material as THREE.Material).dispose();
75
+ }
76
+ this.meshes.clear();
77
+ this.chunkCache.clear();
78
+ }
79
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Builds a Three.js mesh from a voxel chunk using Marching Cubes.
3
+ */
4
+ import * as THREE from 'three';
5
+ import { generateVoxelChunk, marchingCubes, VOXEL_CHUNK_X, VOXEL_CHUNK_Y, VOXEL_CHUNK_Z, getVoxel } from '@loonylabs/gamedev-core';
6
+ import type { VoxelTerrainOptions, VoxelChunk, NeighborSampler } from '@loonylabs/gamedev-core';
7
+ import { TerrainShaderMaterial } from './shaders/TerrainShaderMaterial.js';
8
+ import type { VoxelMaterialVisual } from './shaders/TerrainShaderMaterial.js';
9
+
10
+ /** Shared material instance — reused across all chunk meshes for uniform updates. */
11
+ let sharedMaterial: TerrainShaderMaterial | null = null;
12
+
13
+ /**
14
+ * Create a neighbor sampler that reads from adjacent chunks for seamless boundaries.
15
+ */
16
+ function createNeighborSampler(
17
+ chunk: VoxelChunk,
18
+ getChunk: (cx: number, cz: number) => VoxelChunk,
19
+ ): NeighborSampler {
20
+ return (lx: number, ly: number, lz: number) => {
21
+ // Determine which neighbor chunk to read from
22
+ let ncx = chunk.cx;
23
+ let ncz = chunk.cz;
24
+ let nlx = lx;
25
+ let nlz = lz;
26
+
27
+ if (lx < 0) { ncx -= 1; nlx = lx + VOXEL_CHUNK_X; }
28
+ else if (lx >= VOXEL_CHUNK_X) { ncx += 1; nlx = lx - VOXEL_CHUNK_X; }
29
+ if (lz < 0) { ncz -= 1; nlz = lz + VOXEL_CHUNK_Z; }
30
+ else if (lz >= VOXEL_CHUNK_Z) { ncz += 1; nlz = lz - VOXEL_CHUNK_Z; }
31
+
32
+ // Y out of bounds — no vertical neighbors
33
+ if (ly < 0 || ly >= VOXEL_CHUNK_Y) {
34
+ return { density: 0, material: 0 };
35
+ }
36
+
37
+ const neighborChunk = getChunk(ncx, ncz);
38
+ const v = getVoxel(neighborChunk, Math.floor(nlx), Math.floor(ly), Math.floor(nlz));
39
+ return v;
40
+ };
41
+ }
42
+
43
+ /** Update the shared terrain material each frame. */
44
+ export function tickTerrainMaterial(time: number, qualityLevel?: number): void {
45
+ sharedMaterial?.tick(time, qualityLevel);
46
+ }
47
+
48
+ /** Dispose the shared material. */
49
+ export function disposeTerrainMaterial(): void {
50
+ sharedMaterial?.dispose();
51
+ sharedMaterial = null;
52
+ }
53
+
54
+ export function buildVoxelChunkMesh(
55
+ cx: number, cz: number,
56
+ seed: number,
57
+ terrainOptions: VoxelTerrainOptions,
58
+ materialVisuals: Record<number, VoxelMaterialVisual>,
59
+ getChunk?: (cx: number, cz: number) => VoxelChunk,
60
+ ): THREE.Mesh {
61
+ // Use cached chunk from getChunk if available (ensures biome-blended terrain matches physics)
62
+ const chunk = getChunk ? getChunk(cx, cz) : generateVoxelChunk(cx, cz, seed, terrainOptions);
63
+
64
+ const neighbor = getChunk ? createNeighborSampler(chunk, getChunk) : undefined;
65
+ const result = marchingCubes(chunk, neighbor);
66
+
67
+ const geometry = new THREE.BufferGeometry();
68
+ geometry.setAttribute('position', new THREE.BufferAttribute(result.positions, 3));
69
+ geometry.setAttribute('normal', new THREE.BufferAttribute(result.normals, 3));
70
+
71
+ // Material index — shader expects `attribute float materialIndex`
72
+ const matFloat = new Float32Array(result.materials.length);
73
+ for (let i = 0; i < result.materials.length; i++) matFloat[i] = result.materials[i];
74
+ geometry.setAttribute('materialIndex', new THREE.BufferAttribute(matFloat, 1));
75
+
76
+ // Reuse shared material across all chunks
77
+ if (!sharedMaterial) {
78
+ sharedMaterial = new TerrainShaderMaterial(materialVisuals);
79
+ }
80
+ const mesh = new THREE.Mesh(geometry, sharedMaterial);
81
+
82
+ // Position mesh at chunk world offset
83
+ mesh.position.set(cx * VOXEL_CHUNK_X, 0, cz * VOXEL_CHUNK_Z);
84
+
85
+ return mesh;
86
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @file renderer/voxelTerrain.ts
3
+ * Builds a Three.js mesh from a VoxelChunk using Marching Cubes.
4
+ *
5
+ * Uses TerrainShaderMaterial for procedural noise detail, triplanar
6
+ * projection, and slope-based rock blending.
7
+ */
8
+
9
+ import * as THREE from 'three';
10
+ import { marchingCubes, VOXEL_CHUNK_X, VOXEL_CHUNK_Z } from '@loonylabs/gamedev-core';
11
+ import type { VoxelChunk } from '@loonylabs/gamedev-core';
12
+ import { TerrainShaderMaterial } from './shaders/TerrainShaderMaterial.js';
13
+ import { VOXEL_MATERIAL_VISUALS } from '../../../game-data/src/voxel/materials.js';
14
+
15
+ /** Shared terrain material — reused across all chunk meshes. */
16
+ let sharedMaterial: TerrainShaderMaterial | null = null;
17
+
18
+ function getSharedMaterial(): TerrainShaderMaterial {
19
+ if (!sharedMaterial) {
20
+ sharedMaterial = new TerrainShaderMaterial(VOXEL_MATERIAL_VISUALS);
21
+ }
22
+ return sharedMaterial;
23
+ }
24
+
25
+ /** Update the shared terrain material's time + quality uniforms (call once per frame). */
26
+ export function tickTerrainMaterial(time: number, qualityLevel?: number): void {
27
+ sharedMaterial?.tick(time, qualityLevel);
28
+ }
29
+
30
+ /** Dispose the shared material (call on cleanup). */
31
+ export function disposeTerrainMaterial(): void {
32
+ sharedMaterial?.dispose();
33
+ sharedMaterial = null;
34
+ }
35
+
36
+ /**
37
+ * Build a Three.js mesh from a voxel chunk using Marching Cubes.
38
+ */
39
+ export function buildVoxelChunkMesh(chunk: VoxelChunk): THREE.Mesh {
40
+ const result = marchingCubes(chunk);
41
+
42
+ const geometry = new THREE.BufferGeometry();
43
+ geometry.setAttribute('position', new THREE.BufferAttribute(result.positions, 3));
44
+ geometry.setAttribute('normal', new THREE.BufferAttribute(result.normals, 3));
45
+
46
+ // Material index as a custom float attribute for the shader
47
+ const matFloat = new Float32Array(result.vertexCount);
48
+ for (let i = 0; i < result.vertexCount; i++) {
49
+ matFloat[i] = result.materials[i];
50
+ }
51
+ geometry.setAttribute('materialIndex', new THREE.BufferAttribute(matFloat, 1));
52
+
53
+ const mesh = new THREE.Mesh(geometry, getSharedMaterial());
54
+
55
+ // Position mesh at chunk world offset
56
+ mesh.position.set(
57
+ chunk.cx * VOXEL_CHUNK_X,
58
+ 0,
59
+ chunk.cz * VOXEL_CHUNK_Z,
60
+ );
61
+
62
+ return mesh;
63
+ }
64
+
65
+ /**
66
+ * Build a THREE.Group containing the voxel terrain mesh.
67
+ * Compatible with ChunkStreamer's DirectMeshBuilder signature.
68
+ */
69
+ export function buildVoxelChunkGroup(chunk: VoxelChunk): THREE.Group {
70
+ const group = new THREE.Group();
71
+ const mesh = buildVoxelChunkMesh(chunk);
72
+ group.add(mesh);
73
+ return group;
74
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * @file socket.ts
3
+ * Socket.io connection management and inbound message routing.
4
+ *
5
+ * Responsibilities:
6
+ * - Establish and maintain the socket connection
7
+ * - Receive server messages and update store.ts (entities, dungeon, HP, inventory)
8
+ * - Forward input acknowledgements to GameOrchestrator
9
+ *
10
+ * What does NOT belong here:
11
+ * - Game loop logic
12
+ * - Three.js / rendering
13
+ * - Controller instantiation
14
+ */
15
+
16
+ import { io } from 'socket.io-client';
17
+ import { store } from './store.js';
18
+ import type { SnapshotEntry } from '@loonylabs/gamedev-client';
19
+ import type { GameOrchestrator } from './game.js';
20
+ import { eventBus } from '@loonylabs/gamedev-client';
21
+
22
+ const SERVER_URL = 'http://localhost:3000';
23
+
24
+ export let snapshotBuffer: SnapshotEntry[] = [];
25
+ export let sendMessage: (msg: unknown) => void = () => {};
26
+
27
+ // Tracks previous grunt entity IDs for enemy_killed detection
28
+ let prevGruntIds = new Set<number>();
29
+
30
+ function getOrCreatePlayerId(): string {
31
+ const key = 'gamedev_player_id';
32
+ let id = localStorage.getItem(key);
33
+ if (!id) {
34
+ id = `p_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
35
+ localStorage.setItem(key, id);
36
+ }
37
+ return id;
38
+ }
39
+
40
+ export function initSocket(game: GameOrchestrator): void {
41
+ const socket = io(SERVER_URL, { transports: ['websocket'] });
42
+ sendMessage = (msg: unknown) => socket.emit('message', msg);
43
+
44
+ socket.on('connect', () => {
45
+ store.connected = true;
46
+ socket.emit('message', { type: 'PLAYER_CONNECT', playerId: getOrCreatePlayerId() });
47
+ });
48
+
49
+ socket.on('disconnect', () => {
50
+ store.connected = false;
51
+ snapshotBuffer = [];
52
+ });
53
+
54
+ socket.on('message', (msg: Record<string, unknown>) => {
55
+ if (msg.type === 'WORLD_STATE') {
56
+ store.dungeon = msg.dungeon as typeof store.dungeon;
57
+ if (msg.localEntityId !== undefined) {
58
+ store.localEntityId = msg.localEntityId as number;
59
+ }
60
+ if (msg.currentArea !== undefined) {
61
+ store.currentArea = msg.currentArea as string;
62
+ }
63
+ snapshotBuffer = [];
64
+ } else if (msg.type === 'ENTITY_SYNC') {
65
+ const entities = msg.entities as Array<{ entityId: number; x: number; y: number; hp?: number; maxHp?: number; type?: 'player' | 'grunt' | 'item' }>;
66
+ const entry: SnapshotEntry = {
67
+ tick: msg.tick as number,
68
+ timestamp: performance.now(),
69
+ entities,
70
+ };
71
+ snapshotBuffer.push(entry);
72
+ if (snapshotBuffer.length > 10) snapshotBuffer.shift();
73
+ store.entities = entities;
74
+
75
+ // Detect enemy kills: grunts present in prev snapshot but gone now
76
+ const currentGruntIds = new Set(entities.filter(e => e.type === 'grunt').map(e => e.entityId));
77
+ if (prevGruntIds.size > 0) {
78
+ for (const id of prevGruntIds) {
79
+ if (!currentGruntIds.has(id)) {
80
+ eventBus.emit({ type: 'enemy_killed', enemyId: id });
81
+ }
82
+ }
83
+ }
84
+ prevGruntIds = currentGruntIds;
85
+
86
+ const localPlayer = entities.find(e => e.type === 'player');
87
+ if (localPlayer && localPlayer.hp !== undefined && localPlayer.maxHp !== undefined) {
88
+ store.playerHP.current = localPlayer.hp;
89
+ store.playerHP.max = localPlayer.maxHp;
90
+ }
91
+ } else if (msg.type === 'INPUT_ACK') {
92
+ game.acknowledgeInput({ x: msg.x as number, y: msg.y as number }, msg.lastProcessedInputId as number);
93
+ } else if (msg.type === 'INVENTORY_UPDATE') {
94
+ const newItems = (msg.items as typeof store.inventory) ?? [];
95
+ const prev = store.inventory;
96
+ store.inventory = newItems;
97
+ // Detect newly picked-up items (present in new list but not in prev by id+slot)
98
+ const added = newItems.filter(i => !prev.find(p => p.id === i.id && p.slot === i.slot));
99
+ for (const item of added) {
100
+ eventBus.emit({ type: 'item_pickup', item: { id: item.id, name: item.name, rarity: item.rarity } });
101
+ }
102
+ } else if (msg.type === 'CRAFT_RESULT') {
103
+ if (msg.success && msg.item) {
104
+ const crafted = msg.item as { id: string; name: string; rarity?: string };
105
+ eventBus.emit({ type: 'item_crafted', item: { id: crafted.id, name: crafted.name, rarity: crafted.rarity ?? 'common' } });
106
+ } else if (!msg.success) {
107
+ console.warn('[craft] failed:', msg.error);
108
+ }
109
+ } else if (msg.type === 'EQUIPMENT_UPDATE') {
110
+ store.equipped = (msg.equipped as typeof store.equipped) ?? {};
111
+ } else if (msg.type === 'AREA_CHANGE') {
112
+ store.dungeonCleared = false;
113
+ store.countdownSeconds = 0;
114
+ store.currentArea = msg.targetArea as string;
115
+ if (msg.localEntityId !== undefined) {
116
+ store.localEntityId = msg.localEntityId as number;
117
+ }
118
+ if (msg.dungeon !== undefined) {
119
+ store.dungeon = msg.dungeon as typeof store.dungeon;
120
+ }
121
+ snapshotBuffer = [];
122
+ game.onAreaChange(msg as { targetArea: string; spawnX: number; spawnY: number; dungeon?: unknown; biomeId?: string; seed?: number; voxelConfig?: { terrainOptions?: Record<string, unknown> } });
123
+ } else if (msg.type === 'DUNGEON_CLEAR') {
124
+ store.dungeonCleared = true;
125
+ store.countdownSeconds = msg.countdownSeconds as number;
126
+ } else if (msg.type === 'COUNTDOWN_TICK') {
127
+ store.countdownSeconds = msg.secondsRemaining as number;
128
+ } else if (msg.type === 'ZONE_TYPE_CHANGE') {
129
+ store.zoneType = msg.zoneType as typeof store.zoneType;
130
+ } else if (msg.type === 'EXPERIENCE_LIST') {
131
+ store.availableExperiences = (msg.experiences as typeof store.availableExperiences) ?? [];
132
+ } else if (msg.type === 'EXPERIENCE_CHANGE') {
133
+ store.currentExperience = msg.experienceId as string;
134
+ store.experienceLoading = false;
135
+
136
+ // Shooter-specific scene setup
137
+ if (msg.experienceId === 'shooter' && msg.payload) {
138
+ store.currentArea = 'shooter';
139
+ store.isVoxelArea = false;
140
+ store.hasPhysics = true;
141
+ store.localEntityId = msg.localEntityId as number;
142
+ store.shooterDead = false;
143
+ store.shooterWaveReached = 0;
144
+ store.shooterWave = 0;
145
+ store.shooterWaveClear = false;
146
+ if (!store.gameMode.startsWith('wasd')) {
147
+ store.gameMode = 'wasd+thirdperson';
148
+ }
149
+ game.onShooterJoin(msg as any);
150
+ }
151
+ // Runner-specific scene setup
152
+ if (msg.experienceId === 'runner' && msg.payload) {
153
+ store.currentArea = 'runner';
154
+ store.isVoxelArea = false;
155
+ store.hasPhysics = true;
156
+ store.localEntityId = msg.localEntityId as number;
157
+ store.runnerDead = false;
158
+ store.runnerDistance = 0;
159
+ store.runnerSpeed = 0;
160
+ store.runnerScore = 0;
161
+ store.runnerCoins = 0;
162
+ store.runnerCombo = 0;
163
+ store.runnerGems = 0;
164
+ store.runnerBestCombo = 0;
165
+ store.runnerMagnetActive = false;
166
+ store.runnerHasSpeedBoost = false;
167
+ store.runnerHasLowGravity = false;
168
+ store.runnerCollectFlash = '';
169
+ store.runnerPowerupFlash = '';
170
+ if (!store.gameMode.startsWith('wasd')) {
171
+ store.gameMode = 'wasd+follow';
172
+ }
173
+ game.onRunnerJoin(msg as any);
174
+ }
175
+ // Diablo scene setup is handled by the follow-up AREA_CHANGE message
176
+ } else if (msg.type === 'WEAPON_STATE') {
177
+ store.shooterAmmo = {
178
+ current: msg.currentAmmo as number,
179
+ max: (msg as any).maxAmmo ?? store.shooterAmmo.max,
180
+ };
181
+ store.shooterReloading = msg.isReloading as boolean;
182
+ } else if (msg.type === 'WAVE_CLEAR') {
183
+ store.shooterWave = msg.waveNumber as number;
184
+ store.shooterWaveClear = true;
185
+ setTimeout(() => { store.shooterWaveClear = false; }, 2000);
186
+ } else if (msg.type === 'PLAYER_DAMAGED') {
187
+ store.playerHP.current = msg.hp as number;
188
+ store.playerHP.max = msg.maxHp as number;
189
+ eventBus.emit({ type: 'player_damaged', damage: msg.damage as number });
190
+ } else if (msg.type === 'ENEMY_KILLED') {
191
+ eventBus.emit({ type: 'enemy_killed', enemyId: msg.entityId as number });
192
+ } else if (msg.type === 'HIT_CONFIRM') {
193
+ eventBus.emit({
194
+ type: 'hit_confirm',
195
+ targetEntityId: msg.targetEntityId as number,
196
+ damage: msg.damage as number,
197
+ hitX: msg.hitX as number,
198
+ hitY: msg.hitY as number,
199
+ hitZ: msg.hitZ as number,
200
+ });
201
+ } else if (msg.type === 'PLAYER_DEAD') {
202
+ store.shooterDead = true;
203
+ store.shooterWaveReached = msg.waveReached as number;
204
+ } else if (msg.type === 'SHOOTER_RESTARTED') {
205
+ store.shooterDead = false;
206
+ store.shooterWaveReached = 0;
207
+ store.playerHP.current = msg.hp as number;
208
+ store.playerHP.max = msg.maxHp as number;
209
+ store.shooterWave = 0;
210
+ store.shooterAmmo = { current: msg.maxAmmo as number ?? 12, max: msg.maxAmmo as number ?? 12 };
211
+ store.shooterReloading = false;
212
+ } else if (msg.type === 'RUNNER_STATE') {
213
+ store.runnerDistance = msg.distance as number;
214
+ store.runnerSpeed = msg.speed as number;
215
+ if (msg.score !== undefined) store.runnerScore = msg.score as number;
216
+ if (msg.coins !== undefined) store.runnerCoins = msg.coins as number;
217
+ if (msg.combo !== undefined) store.runnerCombo = msg.combo as number;
218
+ if (msg.magnetActive !== undefined) store.runnerMagnetActive = msg.magnetActive as boolean;
219
+ if (msg.hasSpeedBoost !== undefined) store.runnerHasSpeedBoost = msg.hasSpeedBoost as boolean;
220
+ if (msg.hasLowGravity !== undefined) store.runnerHasLowGravity = msg.hasLowGravity as boolean;
221
+ } else if (msg.type === 'RUNNER_DEAD') {
222
+ store.runnerDead = true;
223
+ store.runnerDistance = msg.distance as number;
224
+ if (msg.score !== undefined) store.runnerScore = msg.score as number;
225
+ if (msg.coins !== undefined) store.runnerCoins = msg.coins as number;
226
+ if (msg.gems !== undefined) store.runnerGems = msg.gems as number;
227
+ if (msg.bestCombo !== undefined) store.runnerBestCombo = msg.bestCombo as number;
228
+ if (msg.highScore !== undefined) store.runnerHighScore = msg.highScore as number;
229
+ } else if (msg.type === 'RUNNER_COLLECT') {
230
+ const comboVal = msg.combo as number;
231
+ store.runnerCombo = comboVal;
232
+ const label = msg.collectibleType === 'gem' ? 'GEM' : 'COIN';
233
+ const multi = (msg.multiplier as number) > 1 ? ` x${msg.multiplier}` : '';
234
+ store.runnerCollectFlash = `+${msg.value}${multi} ${label}`;
235
+ setTimeout(() => { store.runnerCollectFlash = ''; }, 600);
236
+ } else if (msg.type === 'RUNNER_POWERUP') {
237
+ const names: Record<string, string> = { 'speed-boost': 'SPEED BOOST', 'low-gravity': 'LOW GRAVITY', 'magnet': 'MAGNET' };
238
+ store.runnerPowerupFlash = names[msg.powerupType as string] || (msg.powerupType as string);
239
+ setTimeout(() => { store.runnerPowerupFlash = ''; }, 1500);
240
+ } else if (msg.type === 'RUNNER_RESTARTED') {
241
+ store.runnerDead = false;
242
+ store.runnerDistance = 0;
243
+ store.runnerSpeed = 0;
244
+ store.runnerScore = 0;
245
+ store.runnerCoins = 0;
246
+ store.runnerCombo = 0;
247
+ store.runnerGems = 0;
248
+ store.runnerBestCombo = 0;
249
+ store.runnerMagnetActive = false;
250
+ store.runnerHasSpeedBoost = false;
251
+ store.runnerHasLowGravity = false;
252
+ store.runnerCollectFlash = '';
253
+ store.runnerPowerupFlash = '';
254
+ game.onRunnerRestart();
255
+ } else if (msg.type === 'COMBAT_VFX') {
256
+ if (msg.vfxType === 'tracer') {
257
+ // Enemy tracers: orange-red, player tracers: yellow/red
258
+ const color = msg.isEnemy ? 0xff6622 : undefined;
259
+ game.addTracer(
260
+ msg.origin as { x: number; y: number; z: number },
261
+ msg.endPoint as { x: number; y: number; z: number },
262
+ msg.hit as boolean,
263
+ color,
264
+ );
265
+ }
266
+ }
267
+ });
268
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @file store.ts
3
+ * Valtio reactive store — the single source of truth for all client UI state.
4
+ *
5
+ * Responsibilities:
6
+ * - Hold all reactive state (connection, dungeon, entities, HP, inventory, game mode)
7
+ * - Expose the store proxy for Svelte components and game systems to read/write
8
+ *
9
+ * What does NOT belong here:
10
+ * - Game logic or calculations
11
+ * - Socket communication
12
+ * - Three.js / rendering
13
+ * - Controller instantiation
14
+ */
15
+
16
+ import { proxy } from 'valtio';
17
+ import type { Dungeon, WorldManager, VoxelTerrainOptions } from '@loonylabs/gamedev-core';
18
+ import type { EntitySnapshot, InventoryItem } from '@loonylabs/gamedev-protocol';
19
+ import type { ExperienceManifest } from '@loonylabs/gamedev-core';
20
+
21
+ export type GameMode = 'click+orbit' | 'wasd+thirdperson' | 'wasd+firstperson' | 'wasd+follow';
22
+
23
+ export const store = proxy({
24
+ connected: false,
25
+ /** null = in hub, string = in experience */
26
+ currentExperience: null as string | null,
27
+ availableExperiences: [] as ExperienceManifest[],
28
+ experienceLoading: false,
29
+ dungeon: null as Dungeon | null,
30
+ worldManager: null as WorldManager | null,
31
+ currentBiomeId: null as string | null,
32
+ entities: [] as EntitySnapshot[],
33
+ playerHP: { current: 100, max: 100 },
34
+ inventory: [] as InventoryItem[],
35
+ equipped: {} as Record<string, InventoryItem>,
36
+ equipInteraction: 'both' as 'drag' | 'doubleclick' | 'both',
37
+ localEntityId: 0,
38
+ gameMode: 'click+orbit' as GameMode,
39
+ playerFacingAngle: null as number | null,
40
+ skillCooldowns: {} as Record<string, number>, // skillId -> lastUsedAt (ms), client-side prediction
41
+ currentArea: 'overworld' as string,
42
+ zoneType: 'basecamp' as 'basecamp' | 'wilderness',
43
+ dungeonCleared: false,
44
+ countdownSeconds: 0,
45
+ showAimDebug: false,
46
+ terrainQuality: 2, // 0=Low, 1=Medium, 2=High, 3=Ultra
47
+ isVoxelArea: false,
48
+ hasPhysics: false, // true for any area with jump/gravity (voxel + dungeon)
49
+ voxelConfig: null as { seed: number; terrainOptions: VoxelTerrainOptions } | null,
50
+ // Runner experience state
51
+ runnerDistance: 0,
52
+ runnerSpeed: 0,
53
+ runnerDead: false,
54
+ runnerScore: 0,
55
+ runnerCoins: 0,
56
+ runnerCombo: 0,
57
+ runnerHighScore: 0,
58
+ runnerBestCombo: 0,
59
+ runnerGems: 0,
60
+ runnerMagnetActive: false,
61
+ runnerHasSpeedBoost: false,
62
+ runnerHasLowGravity: false,
63
+ /** Brief flash when collecting something */
64
+ runnerCollectFlash: '' as string,
65
+ /** Powerup notification */
66
+ runnerPowerupFlash: '' as string,
67
+ // Shooter experience state
68
+ shooterAmmo: { current: 0, max: 0 },
69
+ shooterReloading: false,
70
+ shooterWave: 0,
71
+ shooterWaveClear: false,
72
+ shooterDead: false,
73
+ shooterWaveReached: 0,
74
+ });
@@ -0,0 +1,60 @@
1
+ :root {
2
+ --color-hp-high: #44cc44;
3
+ --color-hp-mid: #cccc44;
4
+ --color-hp-low: #cc4444;
5
+ --color-rarity-common: #888888;
6
+ --color-rarity-rare: #4488cc;
7
+ --color-rarity-unique: #ccaa22;
8
+ --color-slot-empty: #1a1a1a;
9
+ --color-slot-border: #333333;
10
+ }
11
+
12
+ *, *::before, *::after {
13
+ box-sizing: border-box;
14
+ margin: 0;
15
+ padding: 0;
16
+ }
17
+
18
+ body {
19
+ background: #000;
20
+ overflow: hidden;
21
+ width: 100vw;
22
+ height: 100vh;
23
+ }
24
+
25
+ #game-canvas {
26
+ position: fixed;
27
+ inset: 0;
28
+ width: 100vw;
29
+ height: 100vh;
30
+ display: block;
31
+ z-index: 0;
32
+ }
33
+
34
+ #ui {
35
+ position: fixed;
36
+ inset: 0;
37
+ z-index: 10;
38
+ pointer-events: none;
39
+ font-family: 'Courier New', monospace;
40
+ font-size: 0.75rem;
41
+ }
42
+
43
+ .status {
44
+ padding: 4px 10px;
45
+ border-radius: 3px;
46
+ letter-spacing: 0.05em;
47
+ text-transform: uppercase;
48
+ }
49
+
50
+ .status.connected {
51
+ background: rgba(68, 204, 68, 0.2);
52
+ color: #44cc44;
53
+ border: 1px solid #44cc44;
54
+ }
55
+
56
+ .status.disconnected {
57
+ background: rgba(204, 68, 68, 0.2);
58
+ color: #cc4444;
59
+ border: 1px solid #cc4444;
60
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "skipLibCheck": true,
8
+ "esModuleInterop": true
9
+ },
10
+ "include": ["src"]
11
+ }
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from 'vite';
2
+ import { svelte } from '@sveltejs/vite-plugin-svelte';
3
+
4
+ export default defineConfig({
5
+ clearScreen: false,
6
+ plugins: [svelte()],
7
+ server: {
8
+ port: 3200,
9
+ },
10
+ });
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['src/**/*.test.ts'],
6
+ environment: 'node',
7
+ },
8
+ });