@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,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
|
+
}
|