@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,92 @@
1
+ import * as THREE from 'three';
2
+
3
+ const FLOOR_Y = 0; // Three.js y-coordinate of the floor plane
4
+
5
+ /**
6
+ * Set up click-to-move (left-click + hold) and melee attack (Space / right-click).
7
+ * Requires a reference to the camera and renderer canvas.
8
+ */
9
+ export function initInput(
10
+ canvas: HTMLCanvasElement,
11
+ camera: THREE.Camera,
12
+ sendMessage: (msg: unknown) => void,
13
+ ): () => void {
14
+ const raycaster = new THREE.Raycaster();
15
+ const floorPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); // y=0 plane
16
+ const target = new THREE.Vector3();
17
+
18
+ let holdInterval: ReturnType<typeof setInterval> | null = null;
19
+ let lastNdc = new THREE.Vector2();
20
+
21
+ function sendMoveAtNdc(ndc: THREE.Vector2): void {
22
+ raycaster.setFromCamera(ndc, camera);
23
+ const hit = raycaster.ray.intersectPlane(floorPlane, target);
24
+ if (hit) {
25
+ // Send float coordinates — no Math.round — server handles float positions
26
+ sendMessage({ type: 'PLAYER_MOVE', targetX: target.x, targetY: target.z });
27
+ }
28
+ }
29
+
30
+ function onPointerDown(event: PointerEvent): void {
31
+ if (event.button === 0) {
32
+ // Left click — move immediately
33
+ const rect = canvas.getBoundingClientRect();
34
+ lastNdc.set(
35
+ ((event.clientX - rect.left) / rect.width) * 2 - 1,
36
+ -((event.clientY - rect.top) / rect.height) * 2 + 1,
37
+ );
38
+ sendMoveAtNdc(lastNdc);
39
+
40
+ // Hold-to-move: continue sending while button held
41
+ holdInterval = setInterval(() => {
42
+ sendMoveAtNdc(lastNdc);
43
+ }, 100);
44
+ } else if (event.button === 2) {
45
+ // Right click — melee
46
+ sendMessage({ type: 'ACTION_EVENT', entityId: 0, action: 'melee', payload: null });
47
+ }
48
+ }
49
+
50
+ function onPointerMove(event: PointerEvent): void {
51
+ const rect = canvas.getBoundingClientRect();
52
+ lastNdc.set(
53
+ ((event.clientX - rect.left) / rect.width) * 2 - 1,
54
+ -((event.clientY - rect.top) / rect.height) * 2 + 1,
55
+ );
56
+ }
57
+
58
+ function onPointerUp(event: PointerEvent): void {
59
+ if (event.button === 0 && holdInterval !== null) {
60
+ clearInterval(holdInterval);
61
+ holdInterval = null;
62
+ }
63
+ }
64
+
65
+ function onKeyDown(event: KeyboardEvent): void {
66
+ if (event.code === 'Space') {
67
+ event.preventDefault();
68
+ sendMessage({ type: 'ACTION_EVENT', entityId: 0, action: 'melee', payload: null });
69
+ }
70
+ }
71
+
72
+ function onContextMenu(event: Event): void {
73
+ event.preventDefault();
74
+ }
75
+
76
+ canvas.addEventListener('pointerdown', onPointerDown);
77
+ canvas.addEventListener('pointermove', onPointerMove);
78
+ canvas.addEventListener('pointerup', onPointerUp);
79
+ window.addEventListener('keydown', onKeyDown);
80
+ canvas.addEventListener('contextmenu', onContextMenu);
81
+
82
+ return () => {
83
+ canvas.removeEventListener('pointerdown', onPointerDown);
84
+ canvas.removeEventListener('pointermove', onPointerMove);
85
+ canvas.removeEventListener('pointerup', onPointerUp);
86
+ window.removeEventListener('keydown', onKeyDown);
87
+ canvas.removeEventListener('contextmenu', onContextMenu);
88
+ if (holdInterval !== null) {
89
+ clearInterval(holdInterval);
90
+ }
91
+ };
92
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { CombatDetector } from './CombatDetector.js';
3
+
4
+ describe('CombatDetector', () => {
5
+ beforeEach(() => { vi.useFakeTimers(); });
6
+ afterEach(() => { vi.useRealTimers(); });
7
+
8
+ it('triggers combat when grunt is within radius', () => {
9
+ const setState = vi.fn();
10
+ const detector = new CombatDetector(4000, setState);
11
+ detector.check({ x: 0, y: 0 }, [{ type: 'grunt', x: 2, y: 0 }], 5);
12
+ expect(setState).toHaveBeenCalledWith('combat');
13
+ });
14
+
15
+ it('does not trigger combat when grunt is outside radius', () => {
16
+ const setState = vi.fn();
17
+ const detector = new CombatDetector(4000, setState);
18
+ detector.check({ x: 0, y: 0 }, [{ type: 'grunt', x: 10, y: 0 }], 5);
19
+ expect(setState).not.toHaveBeenCalled();
20
+ });
21
+
22
+ it('ignores non-grunt entities', () => {
23
+ const setState = vi.fn();
24
+ const detector = new CombatDetector(4000, setState);
25
+ detector.check({ x: 0, y: 0 }, [{ type: 'item', x: 1, y: 0 }], 5);
26
+ expect(setState).not.toHaveBeenCalled();
27
+ });
28
+
29
+ it('exits combat after delay when grunt moves away', () => {
30
+ const setState = vi.fn();
31
+ const detector = new CombatDetector(4000, setState);
32
+
33
+ // Enter combat
34
+ detector.check({ x: 0, y: 0 }, [{ type: 'grunt', x: 2, y: 0 }], 5);
35
+ expect(setState).toHaveBeenCalledWith('combat');
36
+
37
+ // Grunt now out of range
38
+ detector.check({ x: 0, y: 0 }, [{ type: 'grunt', x: 10, y: 0 }], 5);
39
+ expect(setState).toHaveBeenCalledTimes(1); // still in combat, timer pending
40
+
41
+ // After delay
42
+ vi.advanceTimersByTime(4000);
43
+ expect(setState).toHaveBeenCalledWith('ambient');
44
+ });
45
+
46
+ it('cancels exit timer if grunt re-enters range during delay', () => {
47
+ const setState = vi.fn();
48
+ const detector = new CombatDetector(4000, setState);
49
+
50
+ detector.check({ x: 0, y: 0 }, [{ type: 'grunt', x: 2, y: 0 }], 5);
51
+ detector.check({ x: 0, y: 0 }, [{ type: 'grunt', x: 10, y: 0 }], 5); // starts exit timer
52
+ vi.advanceTimersByTime(2000);
53
+ detector.check({ x: 0, y: 0 }, [{ type: 'grunt', x: 2, y: 0 }], 5); // re-enters — cancel timer
54
+
55
+ vi.advanceTimersByTime(4000);
56
+ // Should not have called ambient
57
+ expect(setState).not.toHaveBeenCalledWith('ambient');
58
+ });
59
+ });
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @file lib/audio/CombatDetector.ts
3
+ * Detects combat state by checking player proximity to grunt entities.
4
+ *
5
+ * Responsibilities:
6
+ * - Check distance between player and all grunts each tick
7
+ * - Call setState('combat') / setState('ambient') on the provided callback
8
+ * - Apply a configurable exit delay before leaving combat state
9
+ *
10
+ * What does NOT belong here:
11
+ * - Music playback (→ MusicManager.ts)
12
+ * - Store reads (caller passes positions)
13
+ * - Socket or server communication
14
+ */
15
+
16
+ import type { MusicState } from './MusicManager.js';
17
+
18
+ interface Position { x: number; y: number }
19
+ interface EntityLike { type?: string; x: number; y: number }
20
+
21
+ export class CombatDetector {
22
+ private inCombat = false;
23
+ private exitTimer: ReturnType<typeof setTimeout> | null = null;
24
+
25
+ constructor(
26
+ private readonly exitDelay: number,
27
+ private readonly onStateChange: (state: MusicState) => void,
28
+ ) {}
29
+
30
+ check(playerPos: Position, entities: EntityLike[], radius: number): void {
31
+ const nearGrunt = entities.some(
32
+ e => e.type === 'grunt' && Math.hypot(e.x - playerPos.x, e.y - playerPos.y) <= radius,
33
+ );
34
+
35
+ if (nearGrunt) {
36
+ if (this.exitTimer) { clearTimeout(this.exitTimer); this.exitTimer = null; }
37
+ if (!this.inCombat) {
38
+ this.inCombat = true;
39
+ this.onStateChange('combat');
40
+ }
41
+ } else if (!nearGrunt && this.inCombat && !this.exitTimer) {
42
+ this.exitTimer = setTimeout(() => {
43
+ this.inCombat = false;
44
+ this.exitTimer = null;
45
+ this.onStateChange('ambient');
46
+ }, this.exitDelay);
47
+ }
48
+ }
49
+
50
+ dispose(): void {
51
+ if (this.exitTimer) { clearTimeout(this.exitTimer); this.exitTimer = null; }
52
+ }
53
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * @file lib/audio/MusicManager.ts
3
+ * Adaptive music manager — biome tracks + ambient/combat state machine + stingers.
4
+ *
5
+ * Responsibilities:
6
+ * - Crossfade between biome ambient tracks on setBiome()
7
+ * - Fade combat layer in/out on setState()
8
+ * - Play one-shot stingers over running music via EventBus
9
+ *
10
+ * What does NOT belong here:
11
+ * - Combat detection logic (→ CombatDetector.ts)
12
+ * - One-shot sound effects (→ SoundManager.ts)
13
+ * - Socket / store access
14
+ */
15
+
16
+ import { Howl } from 'howler';
17
+ import { eventBus } from '@loonylabs/gamedev-client';
18
+
19
+ export type MusicState = 'ambient' | 'combat';
20
+
21
+ interface TrackEntry {
22
+ src: string | null;
23
+ volume: number;
24
+ }
25
+
26
+ interface CombatLayer extends TrackEntry {
27
+ targetVolume: number;
28
+ fadeIn: number;
29
+ }
30
+
31
+ interface BiomeConfig {
32
+ ambient: TrackEntry;
33
+ layers?: { combat?: CombatLayer };
34
+ }
35
+
36
+ interface StingerEntry {
37
+ src: string | null;
38
+ volume: number;
39
+ }
40
+
41
+ interface MusicConfig {
42
+ crossfadeDuration: number;
43
+ combatDetectionRadius: number;
44
+ combatExitDelay: number;
45
+ biomes: Record<string, BiomeConfig>;
46
+ stingers: Record<string, StingerEntry>;
47
+ }
48
+
49
+ export class MusicManager {
50
+ private config: MusicConfig | null = null;
51
+ private currentTrack: Howl | null = null;
52
+ private combatLayer: Howl | null = null;
53
+ private currentBiome = '';
54
+ private state: MusicState = 'ambient';
55
+ private unsubscribers: Array<() => void> = [];
56
+
57
+ init(config: MusicConfig): void {
58
+ this.config = config;
59
+
60
+ // Subscribe stingers via EventBus
61
+ for (const id of Object.keys(config.stingers)) {
62
+ const type = id as Parameters<typeof eventBus.on>[0];
63
+ const unsub = eventBus.on(type, () => this.playStinger(id));
64
+ this.unsubscribers.push(unsub);
65
+ }
66
+ }
67
+
68
+ setBiome(biomeId: string): void {
69
+ if (!this.config || biomeId === this.currentBiome) return;
70
+ const biome = this.config.biomes[biomeId];
71
+ this.currentBiome = biomeId;
72
+
73
+ if (!biome?.ambient?.src) {
74
+ this.currentTrack?.fade(this.currentTrack.volume(), 0, this.config.crossfadeDuration * 1000);
75
+ setTimeout(() => { this.currentTrack?.stop(); this.currentTrack = null; }, this.config.crossfadeDuration * 1000);
76
+ return;
77
+ }
78
+
79
+ const fadeDuration = this.config.crossfadeDuration * 1000;
80
+ const next = new Howl({ src: [biome.ambient.src!], loop: true, volume: 0 });
81
+ next.play();
82
+ next.fade(0, biome.ambient.volume, fadeDuration);
83
+
84
+ if (this.currentTrack) {
85
+ const old = this.currentTrack;
86
+ old.fade(old.volume(), 0, fadeDuration);
87
+ setTimeout(() => old.stop(), fadeDuration);
88
+ }
89
+
90
+ this.currentTrack = next;
91
+
92
+ // Dispose combat layer on biome change — new biome may have different layers
93
+ if (this.combatLayer) {
94
+ this.combatLayer.stop();
95
+ this.combatLayer = null;
96
+ }
97
+ }
98
+
99
+ setState(state: MusicState): void {
100
+ if (!this.config || state === this.state) return;
101
+ this.state = state;
102
+
103
+ const layer = this.config.biomes[this.currentBiome]?.layers?.combat;
104
+ if (!layer?.src) return;
105
+
106
+ if (state === 'combat') {
107
+ if (!this.combatLayer) {
108
+ this.combatLayer = new Howl({ src: [layer.src!], loop: true, volume: 0 });
109
+ this.combatLayer.play();
110
+ }
111
+ this.combatLayer.fade(this.combatLayer.volume(), layer.targetVolume, layer.fadeIn * 1000);
112
+ } else {
113
+ if (this.combatLayer) {
114
+ const cl = this.combatLayer;
115
+ cl.fade(cl.volume(), 0, 1500);
116
+ setTimeout(() => { cl.stop(); }, 1500);
117
+ this.combatLayer = null;
118
+ }
119
+ }
120
+ }
121
+
122
+ playStinger(id: string): void {
123
+ if (!this.config) return;
124
+ const stinger = this.config.stingers[id];
125
+ if (!stinger?.src) return;
126
+ new Howl({ src: [stinger.src!], volume: stinger.volume }).play();
127
+ }
128
+
129
+ dispose(): void {
130
+ for (const unsub of this.unsubscribers) unsub();
131
+ this.unsubscribers = [];
132
+ this.currentTrack?.stop();
133
+ this.combatLayer?.stop();
134
+ }
135
+ }
136
+
137
+ export const musicManager = new MusicManager();
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @file lib/audio/SoundManager.ts
3
+ * Event-driven one-shot sound effects manager.
4
+ *
5
+ * Responsibilities:
6
+ * - Load Howl instances for configured event types
7
+ * - Subscribe to EventBus and play the matching clip on each event
8
+ * - Expose play() for manual/direct triggering
9
+ *
10
+ * What does NOT belong here:
11
+ * - Music / looping tracks (→ MusicManager.ts)
12
+ * - Combat state detection (→ CombatDetector.ts)
13
+ * - Toast notifications (→ components/ToastSystem.svelte)
14
+ */
15
+
16
+ import { Howl } from 'howler';
17
+ import { eventBus } from '@loonylabs/gamedev-client';
18
+ import type { GameEventType } from '@loonylabs/gamedev-core';
19
+
20
+ interface SoundEntry {
21
+ src: string | null;
22
+ volume: number;
23
+ }
24
+
25
+ interface SoundConfig {
26
+ volume: number;
27
+ events: Partial<Record<GameEventType, SoundEntry>>;
28
+ }
29
+
30
+ export class SoundManager {
31
+ private sounds = new Map<GameEventType, Howl>();
32
+ private unsubscribers: Array<() => void> = [];
33
+
34
+ init(config: SoundConfig): void {
35
+ for (const [type, entry] of Object.entries(config.events) as [GameEventType, SoundEntry][]) {
36
+ if (!entry?.src) continue;
37
+ const howl = new Howl({
38
+ src: [entry.src],
39
+ volume: entry.volume ?? config.volume,
40
+ });
41
+ this.sounds.set(type, howl);
42
+ const unsub = eventBus.on(type, () => this.play(type));
43
+ this.unsubscribers.push(unsub);
44
+ }
45
+ }
46
+
47
+ play(type: GameEventType): void {
48
+ this.sounds.get(type)?.play();
49
+ }
50
+
51
+ dispose(): void {
52
+ for (const unsub of this.unsubscribers) unsub();
53
+ this.unsubscribers = [];
54
+ this.sounds.forEach(h => h.unload());
55
+ this.sounds.clear();
56
+ }
57
+ }
58
+
59
+ export const soundManager = new SoundManager();
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @file lib/audio/index.ts
3
+ * Public re-export barrel for audio subsystem.
4
+ */
5
+
6
+ export { SoundManager, soundManager } from './SoundManager.js';
7
+ export { MusicManager, musicManager } from './MusicManager.js';
8
+ export type { MusicState } from './MusicManager.js';
9
+ export { CombatDetector } from './CombatDetector.js';
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @file main.ts
3
+ * Browser entry point — and nothing more.
4
+ *
5
+ * Responsibilities:
6
+ * - Mount the Svelte UI overlay onto #ui
7
+ * - Create the GameOrchestrator and hand it the canvas
8
+ * - Initialize the socket connection
9
+ *
10
+ * What does NOT belong here:
11
+ * - Game loop logic
12
+ * - Controller or camera setup
13
+ * - Dungeon mesh management
14
+ * - Entity rendering
15
+ *
16
+ * To add a new system (audio, events, analytics):
17
+ * → Add it to GameOrchestrator in game.ts, not here.
18
+ */
19
+
20
+ import { mount } from 'svelte';
21
+ import App from './components/App.svelte';
22
+ import { GameOrchestrator } from './game.js';
23
+ import { initSocket } from './socket.js';
24
+
25
+ const uiEl = document.getElementById('ui') as HTMLElement;
26
+ mount(App, { target: uiEl });
27
+
28
+ const canvas = document.getElementById('game-canvas') as HTMLCanvasElement;
29
+ const game = new GameOrchestrator(canvas);
30
+
31
+ initSocket(game);
32
+ game.start();
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Basecamp structure meshes — code-generated Three.js primitives.
3
+ * Zero-asset approach: huts, campfire, fence posts.
4
+ */
5
+ import * as THREE from 'three';
6
+
7
+ export interface BasecampConfig {
8
+ center: { x: number; z: number };
9
+ getTerrainHeight: (x: number, z: number) => number;
10
+ }
11
+
12
+ function createHut(w: number, h: number, d: number): THREE.Group {
13
+ const group = new THREE.Group();
14
+
15
+ // Walls
16
+ const wallGeo = new THREE.BoxGeometry(w, h, d);
17
+ const wallMat = new THREE.MeshStandardMaterial({ color: 0x8B6914, roughness: 0.9 });
18
+ const walls = new THREE.Mesh(wallGeo, wallMat);
19
+ walls.position.y = h / 2;
20
+ group.add(walls);
21
+
22
+ // Roof (cone/pyramid)
23
+ const roofGeo = new THREE.ConeGeometry(Math.max(w, d) * 0.75, h * 0.6, 4);
24
+ const roofMat = new THREE.MeshStandardMaterial({ color: 0x6B3A1A, roughness: 0.85 });
25
+ const roof = new THREE.Mesh(roofGeo, roofMat);
26
+ roof.position.y = h + h * 0.3;
27
+ roof.rotation.y = Math.PI / 4;
28
+ group.add(roof);
29
+
30
+ return group;
31
+ }
32
+
33
+ function createCampfire(): THREE.Group {
34
+ const group = new THREE.Group();
35
+
36
+ // Stone ring
37
+ const ringGeo = new THREE.TorusGeometry(0.6, 0.15, 6, 8);
38
+ const ringMat = new THREE.MeshStandardMaterial({ color: 0x666666, roughness: 1 });
39
+ const ring = new THREE.Mesh(ringGeo, ringMat);
40
+ ring.rotation.x = Math.PI / 2;
41
+ ring.position.y = 0.15;
42
+ group.add(ring);
43
+
44
+ // Log pile
45
+ const logGeo = new THREE.CylinderGeometry(0.08, 0.08, 0.7, 5);
46
+ const logMat = new THREE.MeshStandardMaterial({ color: 0x4a3010, roughness: 0.95 });
47
+ for (let i = 0; i < 3; i++) {
48
+ const log = new THREE.Mesh(logGeo, logMat);
49
+ log.position.set(Math.cos(i * 2.1) * 0.2, 0.2, Math.sin(i * 2.1) * 0.2);
50
+ log.rotation.z = Math.PI / 2 + (i - 1) * 0.3;
51
+ log.rotation.y = i * 0.8;
52
+ group.add(log);
53
+ }
54
+
55
+ // Fire glow
56
+ const light = new THREE.PointLight(0xff6622, 3, 20);
57
+ light.position.y = 1;
58
+ group.add(light);
59
+
60
+ // Ember core (small bright sphere)
61
+ const emberGeo = new THREE.SphereGeometry(0.15, 6, 6);
62
+ const emberMat = new THREE.MeshBasicMaterial({ color: 0xff4400 });
63
+ const ember = new THREE.Mesh(emberGeo, emberMat);
64
+ ember.position.y = 0.4;
65
+ group.add(ember);
66
+
67
+ return group;
68
+ }
69
+
70
+ function createFencePost(height: number = 1.2): THREE.Mesh {
71
+ const geo = new THREE.CylinderGeometry(0.06, 0.08, height, 5);
72
+ const mat = new THREE.MeshStandardMaterial({ color: 0x6B4226, roughness: 0.9 });
73
+ const post = new THREE.Mesh(geo, mat);
74
+ post.position.y = height / 2;
75
+ return post;
76
+ }
77
+
78
+ /**
79
+ * Create basecamp structure meshes.
80
+ * Returns a Group that can be added to the scene and disposed later.
81
+ */
82
+ export function createBasecampMeshes(config: BasecampConfig): THREE.Group {
83
+ const group = new THREE.Group();
84
+ const { center, getTerrainHeight } = config;
85
+ const getY = (x: number, z: number) => getTerrainHeight(x, z) ?? 32;
86
+
87
+ // Main hut
88
+ const hut1 = createHut(4, 3, 5);
89
+ const hut1X = center.x - 8;
90
+ const hut1Z = center.z - 4;
91
+ hut1.position.set(hut1X, getY(hut1X, hut1Z), hut1Z);
92
+ group.add(hut1);
93
+
94
+ // Smaller hut
95
+ const hut2 = createHut(3, 2.5, 3);
96
+ const hut2X = center.x + 6;
97
+ const hut2Z = center.z - 3;
98
+ hut2.position.set(hut2X, getY(hut2X, hut2Z), hut2Z);
99
+ group.add(hut2);
100
+
101
+ // Storage hut
102
+ const hut3 = createHut(2.5, 2, 3);
103
+ const hut3X = center.x + 4;
104
+ const hut3Z = center.z + 5;
105
+ hut3.position.set(hut3X, getY(hut3X, hut3Z), hut3Z);
106
+ group.add(hut3);
107
+
108
+ // Campfire at center
109
+ const campfire = createCampfire();
110
+ campfire.position.set(center.x, getY(center.x, center.z), center.z);
111
+ group.add(campfire);
112
+
113
+ // Fence posts in a rough semicircle on the wilderness side
114
+ const fenceRadius = 12;
115
+ const fenceCount = 10;
116
+ for (let i = 0; i < fenceCount; i++) {
117
+ const angle = (i / fenceCount) * Math.PI + Math.PI * 0.5; // semicircle facing east
118
+ const fx = center.x + Math.cos(angle) * fenceRadius;
119
+ const fz = center.z + Math.sin(angle) * fenceRadius;
120
+ const post = createFencePost();
121
+ post.position.set(fx, getY(fx, fz), fz);
122
+ group.add(post);
123
+ }
124
+
125
+ return group;
126
+ }