@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.
- package/bin/create-game.js +213 -0
- package/package.json +18 -0
- package/template/.claude/skills/aigdtk-create-game-stories/SKILL.md +177 -0
- package/template/.claude/skills/aigdtk-create-game-stories/story-template.md +85 -0
- package/template/.claude/skills/aigdtk-implement-game-stories/SKILL.md +129 -0
- package/template/.claude/skills/aigdtk-new-game/SKILL.md +126 -0
- package/template/.claude/skills/aigdtk-shared/ascii-grammar.md +133 -0
- package/template/.claude/skills/aigdtk-shared/enemies.md +112 -0
- package/template/.claude/skills/aigdtk-shared/framework.md +93 -0
- package/template/.claude/skills/aigdtk-shared/visuals.md +125 -0
- package/template/apps/client/index.html +14 -0
- package/template/apps/client/package.json +31 -0
- package/template/apps/client/public/assets/audio/enemy_killed.wav +0 -0
- package/template/apps/client/src/components/App.svelte +290 -0
- package/template/apps/client/src/components/CraftingPanel.svelte +253 -0
- package/template/apps/client/src/components/DevPanel.svelte +180 -0
- package/template/apps/client/src/components/DungeonClearOverlay.svelte +53 -0
- package/template/apps/client/src/components/EquipmentPanel.svelte +191 -0
- package/template/apps/client/src/components/HealthBar.svelte +50 -0
- package/template/apps/client/src/components/Hub/BackToHubButton.svelte +37 -0
- package/template/apps/client/src/components/Hub/ExperienceCard.svelte +115 -0
- package/template/apps/client/src/components/Hub/Hub.svelte +88 -0
- package/template/apps/client/src/components/Inventory.svelte +174 -0
- package/template/apps/client/src/components/Runner/RunnerDeathScreen.svelte +182 -0
- package/template/apps/client/src/components/Runner/RunnerHUD.svelte +157 -0
- package/template/apps/client/src/components/Shooter/DamageNumbers.svelte +96 -0
- package/template/apps/client/src/components/Shooter/GameOverScreen.svelte +109 -0
- package/template/apps/client/src/components/Shooter/ShooterHUD.svelte +95 -0
- package/template/apps/client/src/components/SkillBar.svelte +146 -0
- package/template/apps/client/src/components/ToastSystem.svelte +158 -0
- package/template/apps/client/src/game.ts +918 -0
- package/template/apps/client/src/input.ts +92 -0
- package/template/apps/client/src/lib/audio/CombatDetector.test.ts +59 -0
- package/template/apps/client/src/lib/audio/CombatDetector.ts +53 -0
- package/template/apps/client/src/lib/audio/MusicManager.ts +137 -0
- package/template/apps/client/src/lib/audio/SoundManager.ts +59 -0
- package/template/apps/client/src/lib/audio/index.ts +9 -0
- package/template/apps/client/src/main.ts +32 -0
- package/template/apps/client/src/renderer/basecamp.ts +126 -0
- package/template/apps/client/src/renderer/dungeon.ts +250 -0
- package/template/apps/client/src/renderer/dungeonPortal.ts +73 -0
- package/template/apps/client/src/renderer/dungeonZone.ts +301 -0
- package/template/apps/client/src/renderer/entities.ts +197 -0
- package/template/apps/client/src/renderer/runnerTrack.ts +221 -0
- package/template/apps/client/src/renderer/shaders/SkyShader.ts +190 -0
- package/template/apps/client/src/renderer/shaders/TerrainShaderMaterial.ts +133 -0
- package/template/apps/client/src/renderer/shaders/floor.frag.glsl.ts +17 -0
- package/template/apps/client/src/renderer/shaders/shaderConfig.ts +18 -0
- package/template/apps/client/src/renderer/shaders/spawn.frag.glsl.ts +19 -0
- package/template/apps/client/src/renderer/shaders/terrain.frag.glsl.ts +314 -0
- package/template/apps/client/src/renderer/shaders/terrain.vert.glsl.ts +16 -0
- package/template/apps/client/src/renderer/shaders/wall.frag.glsl.ts +20 -0
- package/template/apps/client/src/renderer/shooterArena.ts +102 -0
- package/template/apps/client/src/renderer/voxelChunkStreamer.ts +79 -0
- package/template/apps/client/src/renderer/voxelMesh.ts +86 -0
- package/template/apps/client/src/renderer/voxelTerrain.ts +74 -0
- package/template/apps/client/src/socket.ts +268 -0
- package/template/apps/client/src/store.ts +74 -0
- package/template/apps/client/src/style.css +60 -0
- package/template/apps/client/tsconfig.json +11 -0
- package/template/apps/client/vite.config.ts +10 -0
- package/template/apps/client/vitest.config.ts +8 -0
- package/template/apps/experiences/diablo/index.ts +94 -0
- package/template/apps/experiences/diablo/systems/dungeonClearSystem.ts +60 -0
- package/template/apps/experiences/diablo/systems/enemyAISystem.ts +11 -0
- package/template/apps/experiences/diablo/systems/entitySyncSystem.ts +80 -0
- package/template/apps/experiences/diablo/systems/itemPickupSystem.ts +11 -0
- package/template/apps/experiences/diablo/systems/movementSystem.ts +13 -0
- package/template/apps/experiences/diablo/systems/physicsSystem.ts +92 -0
- package/template/apps/experiences/diablo/systems/transitionSystem.ts +105 -0
- package/template/apps/experiences/runner/data/runner-config.ts +54 -0
- package/template/apps/experiences/runner/index.ts +143 -0
- package/template/apps/experiences/runner/systems/collectibleSystem.ts +157 -0
- package/template/apps/experiences/runner/systems/deathSystem.ts +42 -0
- package/template/apps/experiences/runner/systems/entitySyncSystem.ts +59 -0
- package/template/apps/experiences/runner/systems/obstacleSystem.ts +91 -0
- package/template/apps/experiences/runner/systems/runnerPhysicsSystem.ts +82 -0
- package/template/apps/experiences/runner/systems/trackStreamSystem.ts +19 -0
- package/template/apps/experiences/runner/track/laneSystem.ts +53 -0
- package/template/apps/experiences/runner/track/segmentTypes.ts +141 -0
- package/template/apps/experiences/runner/track/trackGenerator.ts +292 -0
- package/template/apps/experiences/shooter/ai/aiStateMachine.ts +394 -0
- package/template/apps/experiences/shooter/ai/lineOfSight.ts +32 -0
- package/template/apps/experiences/shooter/arena/arenaGenerator.ts +101 -0
- package/template/apps/experiences/shooter/arena/arenaTypes.ts +49 -0
- package/template/apps/experiences/shooter/arena/hitscan.ts +101 -0
- package/template/apps/experiences/shooter/data/enemy-types.ts +108 -0
- package/template/apps/experiences/shooter/data/wave-definitions.ts +40 -0
- package/template/apps/experiences/shooter/data/weapon-config.ts +80 -0
- package/template/apps/experiences/shooter/index.ts +127 -0
- package/template/apps/experiences/shooter/systems/enemyAISystem.ts +113 -0
- package/template/apps/experiences/shooter/systems/entitySyncSystem.ts +68 -0
- package/template/apps/experiences/shooter/systems/shooterPhysicsSystem.ts +89 -0
- package/template/apps/experiences/shooter/systems/waveSpawnerSystem.ts +87 -0
- package/template/apps/experiences/shooter/systems/weaponSystem.ts +157 -0
- package/template/apps/game-data/src/areas/area-manifest.json +18 -0
- package/template/apps/game-data/src/assets/migration.test.ts +291 -0
- package/template/apps/game-data/src/audio/music-config.json +21 -0
- package/template/apps/game-data/src/audio/sound-config.json +11 -0
- package/template/apps/game-data/src/combat/action-types.ts +2 -0
- package/template/apps/game-data/src/combat/enemy-def.ts +12 -0
- package/template/apps/game-data/src/combat/hitboxes.ts +23 -0
- package/template/apps/game-data/src/dungeon/cell-types.ts +20 -0
- package/template/apps/game-data/src/dungeon/cell-visuals.ts +13 -0
- package/template/apps/game-data/src/dungeon/door-directions.ts +2 -0
- package/template/apps/game-data/src/enemies/enemy-defs.json +32 -0
- package/template/apps/game-data/src/equipment/slots.json +5 -0
- package/template/apps/game-data/src/events/event-defs.ts +20 -0
- package/template/apps/game-data/src/events/event-types.ts +10 -0
- package/template/apps/game-data/src/events/toast-config.json +49 -0
- package/template/apps/game-data/src/items/item-pool.json +13 -0
- package/template/apps/game-data/src/loot/item-pool.ts +14 -0
- package/template/apps/game-data/src/loot/rarities.ts +2 -0
- package/template/apps/game-data/src/physics/dungeon-physics-config.ts +12 -0
- package/template/apps/game-data/src/physics/jump-config.ts +17 -0
- package/template/apps/game-data/src/recipes/recipe-book.json +68 -0
- package/template/apps/game-data/src/rooms/room_basecamp.json +16 -0
- package/template/apps/game-data/src/rooms/room_corridor_ew.json +9 -0
- package/template/apps/game-data/src/rooms/room_corridor_ns.json +11 -0
- package/template/apps/game-data/src/rooms/room_crossroads.json +11 -0
- package/template/apps/game-data/src/rooms/room_dead_end.json +10 -0
- package/template/apps/game-data/src/rooms/room_staircase.json +12 -0
- package/template/apps/game-data/src/rooms/room_start.json +11 -0
- package/template/apps/game-data/src/skills/skill-book.json +20 -0
- package/template/apps/game-data/src/voxel/biome-terrain.ts +76 -0
- package/template/apps/game-data/src/voxel/materials.ts +45 -0
- package/template/apps/game-data/src/voxel/sandbox-terrain-config.ts +19 -0
- package/template/apps/game-data/src/world/area-config.ts +33 -0
- package/template/apps/game-data/src/world/biome-def.ts +15 -0
- package/template/apps/game-data/src/world/biomes.json +57 -0
- package/template/apps/game-data/src/world/movement.ts +2 -0
- package/template/apps/game-data/src/world/overworld-layout.test.ts +93 -0
- package/template/apps/game-data/src/world/overworld-layout.ts +127 -0
- package/template/apps/server/data/game.db +0 -0
- package/template/apps/server/package.json +30 -0
- package/template/apps/server/src/areaManager.ts +346 -0
- package/template/apps/server/src/db/client.ts +45 -0
- package/template/apps/server/src/db/schema.ts +40 -0
- package/template/apps/server/src/gameLoop.ts +267 -0
- package/template/apps/server/src/gameState.ts +3 -0
- package/template/apps/server/src/handlers/actionEvent.ts +55 -0
- package/template/apps/server/src/handlers/craftHandler.ts +59 -0
- package/template/apps/server/src/handlers/equipHandler.ts +73 -0
- package/template/apps/server/src/handlers/raycastHandler.ts +97 -0
- package/template/apps/server/src/handlers/skillHandler.ts +87 -0
- package/template/apps/server/src/handlers/terraformHandler.ts +74 -0
- package/template/apps/server/src/index.ts +597 -0
- package/template/apps/server/src/persistence.ts +135 -0
- package/template/apps/server/src/rooms.ts +20 -0
- package/template/apps/server/src/systems/dungeonPhysics.test.ts +32 -0
- package/template/apps/server/src/systems/dungeonPhysics.ts +16 -0
- package/template/apps/server/src/systems/enemyAI.ts +129 -0
- package/template/apps/server/src/systems/itemPickup.ts +31 -0
- package/template/apps/server/src/tests/areaManager.test.ts +77 -0
- package/template/apps/server/src/tests/diablo-experience.test.ts +60 -0
- package/template/apps/server/src/tests/runner-experience.test.ts +273 -0
- package/template/apps/server/src/tests/runner-powerups-scoring.test.ts +221 -0
- package/template/apps/server/src/tests/server.integration.test.ts +92 -0
- package/template/apps/server/src/tests/shooter-enemy-ai.test.ts +328 -0
- package/template/apps/server/src/tests/shooter-experience.test.ts +281 -0
- package/template/apps/server/src/tests/voxelChunkCache.test.ts +29 -0
- package/template/apps/server/src/tests/voxelSandbox.test.ts +133 -0
- package/template/apps/server/src/voxelChunkCache.ts +31 -0
- package/template/apps/server/src/voxelPlayerState.ts +23 -0
- package/template/apps/server/tsconfig.json +17 -0
- 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
|
+
}
|