@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,346 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { parseAsciiGrid, createBiomeBlendFn, getTerrainHeight, getZoneCoords, ZONE_SIZE, mulberry32 } from '@loonylabs/gamedev-core';
|
|
5
|
+
import type { RoomDef, Dungeon, PlacedRoom, GridData, TileInfo, BiomeDef } from '@loonylabs/gamedev-core';
|
|
6
|
+
import { PLAYER_SPEED } from '../../game-data/src/world/movement.js';
|
|
7
|
+
import type { GameEnemyDef as EnemyDef } from '../../game-data/src/combat/enemy-def.js';
|
|
8
|
+
import { GameSession, type PlayerState } from '@loonylabs/gamedev-server';
|
|
9
|
+
import type { Server } from 'socket.io';
|
|
10
|
+
import { VoxelChunkCache } from './voxelChunkCache.js';
|
|
11
|
+
import {
|
|
12
|
+
parseOverworldLayout,
|
|
13
|
+
getTileAt,
|
|
14
|
+
getBiomeTerrainOptions,
|
|
15
|
+
getStructureFlatZones,
|
|
16
|
+
TILE_SIZE,
|
|
17
|
+
BIOME_MOB_MAPPING,
|
|
18
|
+
type OverworldLayout,
|
|
19
|
+
type ParsedTile,
|
|
20
|
+
} from '../../game-data/src/world/overworld-layout.js';
|
|
21
|
+
import { BIOME_TERRAIN_OPTIONS } from '../../game-data/src/voxel/biome-terrain.js';
|
|
22
|
+
|
|
23
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const CONFIG_DIR = resolve(__dirname, '../../game-data/src');
|
|
25
|
+
|
|
26
|
+
/** Build a Dungeon from a single room definition — no generator randomness. */
|
|
27
|
+
function buildSingleRoomDungeon(roomDef: RoomDef, seed = 0): Dungeon {
|
|
28
|
+
const grid = parseAsciiGrid(roomDef.ascii);
|
|
29
|
+
|
|
30
|
+
// Find spawn position
|
|
31
|
+
let spawnPosition = { x: 2, y: 5 }; // fallback
|
|
32
|
+
outer:
|
|
33
|
+
for (const row of grid.cells) {
|
|
34
|
+
for (const cell of row) {
|
|
35
|
+
if (cell.type === 'spawn') {
|
|
36
|
+
spawnPosition = { x: cell.x + 0.5, y: cell.y + 0.5 };
|
|
37
|
+
break outer;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const placedRoom: PlacedRoom = {
|
|
43
|
+
def: roomDef,
|
|
44
|
+
grid,
|
|
45
|
+
worldOffset: { x: 0, y: 0 },
|
|
46
|
+
doors: [],
|
|
47
|
+
isReachable: true,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return { seed, rooms: [placedRoom], spawnPosition };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface AreaSession {
|
|
54
|
+
session: GameSession;
|
|
55
|
+
areaType: 'room' | 'overworld' | 'dungeon' | 'voxel';
|
|
56
|
+
biomeId?: string;
|
|
57
|
+
/** Voxel chunk cache for voxel areas. */
|
|
58
|
+
chunkCache?: VoxelChunkCache;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Returns the tile type for a world position in the overworld layout. */
|
|
62
|
+
export function getOverworldTileType(
|
|
63
|
+
layout: OverworldLayout,
|
|
64
|
+
worldX: number,
|
|
65
|
+
worldZ: number,
|
|
66
|
+
): ParsedTile | undefined {
|
|
67
|
+
const col = Math.floor(worldX / TILE_SIZE);
|
|
68
|
+
const row = Math.floor(worldZ / TILE_SIZE);
|
|
69
|
+
return getTileAt(layout, col, row);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class AreaManager {
|
|
73
|
+
private areas = new Map<string, AreaSession>();
|
|
74
|
+
private playerArea = new Map<string, string>(); // socketId -> areaId
|
|
75
|
+
private enemyDefs: EnemyDef[];
|
|
76
|
+
private biomes: Record<string, BiomeDef>;
|
|
77
|
+
private rooms: RoomDef[];
|
|
78
|
+
readonly layout: OverworldLayout;
|
|
79
|
+
|
|
80
|
+
/** World position of the dungeon entrance tile center (for return transitions). */
|
|
81
|
+
private dungeonEntrancePos: { x: number; z: number };
|
|
82
|
+
|
|
83
|
+
constructor(rooms: RoomDef[], seed: number) {
|
|
84
|
+
this.rooms = rooms;
|
|
85
|
+
|
|
86
|
+
// Load config
|
|
87
|
+
this.enemyDefs = JSON.parse(
|
|
88
|
+
readFileSync(resolve(CONFIG_DIR, 'enemies/enemy-defs.json'), 'utf-8')
|
|
89
|
+
);
|
|
90
|
+
this.biomes = JSON.parse(
|
|
91
|
+
readFileSync(resolve(CONFIG_DIR, 'world/biomes.json'), 'utf-8')
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// --- Voxel Overworld ---
|
|
95
|
+
this.layout = parseOverworldLayout();
|
|
96
|
+
|
|
97
|
+
// Build TileInfo grid for biome blending
|
|
98
|
+
const tileInfoGrid: TileInfo[][] = this.layout.tiles.map(row =>
|
|
99
|
+
row.map(tile => ({
|
|
100
|
+
terrainOptions: getBiomeTerrainOptions(tile.def.biomeId),
|
|
101
|
+
})),
|
|
102
|
+
);
|
|
103
|
+
const flatZones = getStructureFlatZones(this.layout);
|
|
104
|
+
const blendFn = createBiomeBlendFn(tileInfoGrid, TILE_SIZE, TILE_SIZE, flatZones);
|
|
105
|
+
const overworldCache = new VoxelChunkCache(seed, undefined, blendFn);
|
|
106
|
+
|
|
107
|
+
const overworldSession = new GameSession('overworld', [], seed, undefined, undefined, {
|
|
108
|
+
playerSpeed: PLAYER_SPEED,
|
|
109
|
+
skipWalkCheck: true,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Find basecamp center for spawn position
|
|
113
|
+
const basecampTiles = this._findTilesOfType('B');
|
|
114
|
+
const bcCenterCol = basecampTiles.length > 0
|
|
115
|
+
? basecampTiles.reduce((s, t) => s + t.col, 0) / basecampTiles.length
|
|
116
|
+
: 1;
|
|
117
|
+
const bcCenterRow = basecampTiles.length > 0
|
|
118
|
+
? basecampTiles.reduce((s, t) => s + t.row, 0) / basecampTiles.length
|
|
119
|
+
: 1;
|
|
120
|
+
const spawnX = (bcCenterCol + 0.5) * TILE_SIZE;
|
|
121
|
+
const spawnZ = (bcCenterRow + 0.5) * TILE_SIZE;
|
|
122
|
+
overworldSession.state.dungeon.spawnPosition = { x: spawnX, y: spawnZ };
|
|
123
|
+
|
|
124
|
+
this.areas.set('overworld', {
|
|
125
|
+
session: overworldSession,
|
|
126
|
+
areaType: 'voxel',
|
|
127
|
+
chunkCache: overworldCache,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
this._spawnOverworldEnemies(overworldSession, seed);
|
|
131
|
+
|
|
132
|
+
// Find dungeon entrance position
|
|
133
|
+
const dungeonTiles = this._findTilesOfType('D');
|
|
134
|
+
if (dungeonTiles.length > 0) {
|
|
135
|
+
this.dungeonEntrancePos = {
|
|
136
|
+
x: (dungeonTiles[0].col + 0.5) * TILE_SIZE,
|
|
137
|
+
z: (dungeonTiles[0].row + 0.5) * TILE_SIZE,
|
|
138
|
+
};
|
|
139
|
+
} else {
|
|
140
|
+
this.dungeonEntrancePos = { x: spawnX, z: spawnZ };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// --- Dungeon ---
|
|
144
|
+
const dungeonSession = new GameSession('dungeon', rooms, seed, undefined, undefined, { playerSpeed: PLAYER_SPEED });
|
|
145
|
+
this.spawnInitialDungeonEnemies(dungeonSession);
|
|
146
|
+
this.areas.set('dungeon', { session: dungeonSession, areaType: 'dungeon' });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private _findTilesOfType(type: string): ParsedTile[] {
|
|
150
|
+
const result: ParsedTile[] = [];
|
|
151
|
+
for (const row of this.layout.tiles) {
|
|
152
|
+
for (const tile of row) {
|
|
153
|
+
if (tile.def.type === type) result.push(tile);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private _spawnOverworldEnemies(session: GameSession, seed: number): void {
|
|
160
|
+
const rng = mulberry32(seed);
|
|
161
|
+
|
|
162
|
+
for (const row of this.layout.tiles) {
|
|
163
|
+
for (const tile of row) {
|
|
164
|
+
if (!tile.def.spawnsMobs) continue;
|
|
165
|
+
|
|
166
|
+
const mobBiomeId = BIOME_MOB_MAPPING[tile.def.biomeId];
|
|
167
|
+
if (!mobBiomeId) continue;
|
|
168
|
+
|
|
169
|
+
const biome = this.biomes[mobBiomeId];
|
|
170
|
+
if (!biome?.mobs || biome.mobs.length === 0) continue;
|
|
171
|
+
|
|
172
|
+
// Spawn 3 enemies per wilderness tile
|
|
173
|
+
const enemiesPerTile = 3;
|
|
174
|
+
for (let i = 0; i < enemiesPerTile; i++) {
|
|
175
|
+
const mobId = biome.mobs[Math.floor(rng() * biome.mobs.length)];
|
|
176
|
+
const enemyDef = this.enemyDefs.find(d => d.id === mobId);
|
|
177
|
+
if (!enemyDef) continue;
|
|
178
|
+
|
|
179
|
+
// Random position within tile
|
|
180
|
+
const wx = (tile.col + 0.1 + rng() * 0.8) * TILE_SIZE;
|
|
181
|
+
const wz = (tile.row + 0.1 + rng() * 0.8) * TILE_SIZE;
|
|
182
|
+
|
|
183
|
+
// Second waypoint nearby
|
|
184
|
+
const wp2x = wx + (rng() * 6 - 3);
|
|
185
|
+
const wp2z = wz + (rng() * 6 - 3);
|
|
186
|
+
|
|
187
|
+
session.spawnEnemy({
|
|
188
|
+
enemyDefId: enemyDef.id,
|
|
189
|
+
x: wx,
|
|
190
|
+
y: wz, // Note: y = world Z in 2D convention
|
|
191
|
+
hp: enemyDef.hp,
|
|
192
|
+
maxHp: enemyDef.maxHp,
|
|
193
|
+
alive: true,
|
|
194
|
+
aiState: 'patrol',
|
|
195
|
+
waypoints: [{ x: wx, y: wz }, { x: wp2x, y: wp2z }],
|
|
196
|
+
waypointIndex: 0,
|
|
197
|
+
path: [],
|
|
198
|
+
attackCooldown: 0,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
spawnInitialDungeonEnemies(session: GameSession): void {
|
|
206
|
+
const spawn = session.state.dungeon.spawnPosition;
|
|
207
|
+
const wp1 = { x: spawn.x + 1, y: spawn.y };
|
|
208
|
+
const wp2 = { x: spawn.x + 1, y: spawn.y + 1 };
|
|
209
|
+
|
|
210
|
+
if (session.isWalkable(Math.round(wp1.x), Math.round(wp1.y))) {
|
|
211
|
+
session.spawnEnemy({
|
|
212
|
+
enemyDefId: 'grunt',
|
|
213
|
+
x: wp1.x,
|
|
214
|
+
y: wp1.y,
|
|
215
|
+
hp: 50,
|
|
216
|
+
maxHp: 50,
|
|
217
|
+
alive: true,
|
|
218
|
+
aiState: 'patrol',
|
|
219
|
+
waypoints: [wp1, wp2],
|
|
220
|
+
waypointIndex: 0,
|
|
221
|
+
path: [],
|
|
222
|
+
attackCooldown: 0,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
getAreaSession(areaId: string): AreaSession | undefined {
|
|
228
|
+
return this.areas.get(areaId);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
getSession(areaId: string): GameSession | undefined {
|
|
232
|
+
return this.areas.get(areaId)?.session;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
getPlayerArea(socketId: string): string {
|
|
236
|
+
return this.playerArea.get(socketId) ?? 'overworld';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
addPlayerToArea(socketId: string, playerId: string, areaId: string): PlayerState {
|
|
240
|
+
const session = this.getSession(areaId);
|
|
241
|
+
if (!session) throw new Error(`Unknown area: ${areaId}`);
|
|
242
|
+
this.playerArea.set(socketId, areaId);
|
|
243
|
+
return session.addPlayer(socketId, playerId);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
removePlayerFromArea(socketId: string): void {
|
|
247
|
+
const areaId = this.playerArea.get(socketId);
|
|
248
|
+
if (areaId) {
|
|
249
|
+
this.getSession(areaId)?.removePlayer(socketId);
|
|
250
|
+
}
|
|
251
|
+
this.playerArea.delete(socketId);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Get the dungeon entrance world position (for return transitions). */
|
|
255
|
+
getDungeonEntrancePos(): { x: number; z: number } {
|
|
256
|
+
return this.dungeonEntrancePos;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Move a player from their current area to a new area.
|
|
261
|
+
* Emits AREA_CHANGE to the player's socket.
|
|
262
|
+
*/
|
|
263
|
+
movePlayer(
|
|
264
|
+
io: Server,
|
|
265
|
+
socketId: string,
|
|
266
|
+
toAreaId: string,
|
|
267
|
+
spawnX: number,
|
|
268
|
+
spawnY: number,
|
|
269
|
+
): void {
|
|
270
|
+
const fromAreaId = this.playerArea.get(socketId);
|
|
271
|
+
if (!fromAreaId) return;
|
|
272
|
+
|
|
273
|
+
const fromSession = this.getSession(fromAreaId);
|
|
274
|
+
const player = fromSession?.state.players.get(socketId);
|
|
275
|
+
const playerId = player?.playerId ?? socketId;
|
|
276
|
+
const inventory = player?.inventory ?? [];
|
|
277
|
+
const equipped = player?.equipped ?? {};
|
|
278
|
+
const hp = player?.hp ?? 100;
|
|
279
|
+
|
|
280
|
+
// Remove from old area
|
|
281
|
+
fromSession?.removePlayer(socketId);
|
|
282
|
+
this.playerArea.delete(socketId);
|
|
283
|
+
|
|
284
|
+
// Add to new area
|
|
285
|
+
const toAreaSession = this.getAreaSession(toAreaId);
|
|
286
|
+
if (!toAreaSession) {
|
|
287
|
+
console.error(`[AreaManager] Unknown target area: ${toAreaId}`);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Reset dungeon if it was cleared (fresh run on re-entry)
|
|
292
|
+
if (toAreaId === 'dungeon' && toAreaSession.session.clearedAt) {
|
|
293
|
+
toAreaSession.session.regenerateDungeon(this.rooms);
|
|
294
|
+
this.spawnInitialDungeonEnemies(toAreaSession.session);
|
|
295
|
+
console.log(`[area] Dungeon reset for re-entry`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// spawnX/Y of -1 means "use the area's natural spawn position"
|
|
299
|
+
const effectiveSpawnX = spawnX < 0 ? toAreaSession.session.state.dungeon.spawnPosition.x : spawnX;
|
|
300
|
+
const effectiveSpawnY = spawnY < 0 ? toAreaSession.session.state.dungeon.spawnPosition.y : spawnY;
|
|
301
|
+
|
|
302
|
+
const newPlayer = toAreaSession.session.addPlayer(socketId, playerId);
|
|
303
|
+
newPlayer.x = effectiveSpawnX;
|
|
304
|
+
newPlayer.y = effectiveSpawnY;
|
|
305
|
+
newPlayer.inventory = inventory;
|
|
306
|
+
newPlayer.equipped = equipped;
|
|
307
|
+
newPlayer.hp = hp;
|
|
308
|
+
|
|
309
|
+
// Explicitly update zone after setting new coordinates
|
|
310
|
+
const newZone = getZoneCoords(effectiveSpawnX, effectiveSpawnY, ZONE_SIZE);
|
|
311
|
+
|
|
312
|
+
// Move in spatial hash
|
|
313
|
+
toAreaSession.session.spatialHash.move(newPlayer.entityId, newPlayer.currentZone.tx, newPlayer.currentZone.ty, newZone.tx, newZone.ty);
|
|
314
|
+
newPlayer.currentZone = newZone;
|
|
315
|
+
|
|
316
|
+
this.playerArea.set(socketId, toAreaId);
|
|
317
|
+
|
|
318
|
+
// Build AREA_CHANGE payload
|
|
319
|
+
const payload: Record<string, unknown> = {
|
|
320
|
+
type: 'AREA_CHANGE',
|
|
321
|
+
targetArea: toAreaId,
|
|
322
|
+
spawnX: effectiveSpawnX,
|
|
323
|
+
spawnY: effectiveSpawnY,
|
|
324
|
+
localEntityId: newPlayer.entityId,
|
|
325
|
+
seed: toAreaSession.session.state.dungeon.seed,
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
if (toAreaSession.areaType === 'room' || toAreaSession.areaType === 'dungeon') {
|
|
329
|
+
payload.dungeon = toAreaSession.session.state.dungeon;
|
|
330
|
+
}
|
|
331
|
+
if (toAreaSession.biomeId) {
|
|
332
|
+
payload.biomeId = toAreaSession.biomeId;
|
|
333
|
+
}
|
|
334
|
+
if (toAreaSession.areaType === 'voxel') {
|
|
335
|
+
payload.voxelConfig = {
|
|
336
|
+
terrainOptions: null, // client will use its own biome blend
|
|
337
|
+
tileLayout: true, // signal to client that this is a tile-based voxel area
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const sock = io.sockets.sockets.get(socketId);
|
|
342
|
+
sock?.emit('message', payload);
|
|
343
|
+
|
|
344
|
+
console.log(`[area] ${playerId} moved from ${fromAreaId} to ${toAreaId} at (${effectiveSpawnX}, ${effectiveSpawnY})`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createDb } from '@loonylabs/gamedev-server';
|
|
2
|
+
import { resolve, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import * as schema from './schema.js';
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const DATA_DIR = resolve(__dirname, '../../data');
|
|
8
|
+
|
|
9
|
+
const CREATE_TABLES_SQL = `
|
|
10
|
+
CREATE TABLE IF NOT EXISTS players (
|
|
11
|
+
id TEXT PRIMARY KEY,
|
|
12
|
+
name TEXT NOT NULL,
|
|
13
|
+
hp INTEGER NOT NULL DEFAULT 100,
|
|
14
|
+
x REAL NOT NULL DEFAULT 0,
|
|
15
|
+
y REAL NOT NULL DEFAULT 0
|
|
16
|
+
);
|
|
17
|
+
CREATE TABLE IF NOT EXISTS items (
|
|
18
|
+
id TEXT PRIMARY KEY,
|
|
19
|
+
name TEXT NOT NULL,
|
|
20
|
+
rarity TEXT NOT NULL,
|
|
21
|
+
stat_bonus_json TEXT NOT NULL
|
|
22
|
+
);
|
|
23
|
+
CREATE TABLE IF NOT EXISTS inventories (
|
|
24
|
+
player_id TEXT NOT NULL,
|
|
25
|
+
item_id TEXT NOT NULL,
|
|
26
|
+
slot INTEGER NOT NULL,
|
|
27
|
+
PRIMARY KEY (player_id, slot)
|
|
28
|
+
);
|
|
29
|
+
CREATE TABLE IF NOT EXISTS equipped (
|
|
30
|
+
player_id TEXT NOT NULL,
|
|
31
|
+
slot_id TEXT NOT NULL,
|
|
32
|
+
item_id TEXT NOT NULL,
|
|
33
|
+
PRIMARY KEY (player_id, slot_id)
|
|
34
|
+
);
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
export const db = createDb(resolve(DATA_DIR, 'game.db'), schema, CREATE_TABLES_SQL);
|
|
38
|
+
|
|
39
|
+
export type Db = typeof db;
|
|
40
|
+
|
|
41
|
+
export type PlayerRow = typeof schema.players.$inferSelect;
|
|
42
|
+
export type ItemRow = typeof schema.items.$inferSelect;
|
|
43
|
+
export type InventoryRow = typeof schema.inventories.$inferSelect;
|
|
44
|
+
|
|
45
|
+
export { schema };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
|
|
2
|
+
|
|
3
|
+
export const players = sqliteTable('players', {
|
|
4
|
+
id: text('id').primaryKey(),
|
|
5
|
+
name: text('name').notNull(),
|
|
6
|
+
hp: integer('hp').notNull().default(100),
|
|
7
|
+
x: real('x').notNull().default(0),
|
|
8
|
+
y: real('y').notNull().default(0),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const items = sqliteTable('items', {
|
|
12
|
+
id: text('id').primaryKey(),
|
|
13
|
+
name: text('name').notNull(),
|
|
14
|
+
rarity: text('rarity').notNull(),
|
|
15
|
+
statBonusJson: text('stat_bonus_json').notNull(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const inventories = sqliteTable('inventories', {
|
|
19
|
+
playerId: text('player_id').notNull(),
|
|
20
|
+
itemId: text('item_id').notNull(),
|
|
21
|
+
slot: integer('slot').notNull(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const equipped = sqliteTable('equipped', {
|
|
25
|
+
playerId: text('player_id').notNull(),
|
|
26
|
+
slotId: text('slot_id').notNull(),
|
|
27
|
+
itemId: text('item_id').notNull(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const voxelDeltas = sqliteTable('voxel_deltas', {
|
|
31
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
32
|
+
cx: integer('cx').notNull(),
|
|
33
|
+
cz: integer('cz').notNull(),
|
|
34
|
+
lx: integer('lx').notNull(),
|
|
35
|
+
ly: integer('ly').notNull(),
|
|
36
|
+
lz: integer('lz').notNull(),
|
|
37
|
+
density: real('density').notNull(),
|
|
38
|
+
material: integer('material').notNull(),
|
|
39
|
+
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
|
40
|
+
});
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import type { Server } from 'socket.io';
|
|
5
|
+
import type { GameSession } from '@loonylabs/gamedev-server';
|
|
6
|
+
import { createTickLoop } from '@loonylabs/gamedev-server';
|
|
7
|
+
import type { AreaManager, AreaSession } from './areaManager.js';
|
|
8
|
+
import { getOverworldTileType } from './areaManager.js';
|
|
9
|
+
import { updateEnemyAI } from './systems/enemyAI.js';
|
|
10
|
+
import { updateItemPickup } from './systems/itemPickup.js';
|
|
11
|
+
import { isDungeonCleared, getTerrainHeight, updatePhysicsBody, updateJumpState, snapToGround, consumeJumpBuffer } from '@loonylabs/gamedev-core';
|
|
12
|
+
import { createVoxelPlayerPhysics, type VoxelPhysicsMap, voxelPhysicsRegistry } from './voxelPlayerState.js';
|
|
13
|
+
import type { VoxelChunkCache } from './voxelChunkCache.js';
|
|
14
|
+
import { SANDBOX_PHYSICS_CONFIG } from '../../game-data/src/voxel/sandbox-terrain-config.js';
|
|
15
|
+
import { DUNGEON_PHYSICS_CONFIG } from '../../game-data/src/physics/dungeon-physics-config.js';
|
|
16
|
+
import { GAME_JUMP_CONFIG } from '../../game-data/src/physics/jump-config.js';
|
|
17
|
+
import { TILE_SIZE } from '../../game-data/src/world/overworld-layout.js';
|
|
18
|
+
import { getDungeonGroundY } from './systems/dungeonPhysics.js';
|
|
19
|
+
|
|
20
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
|
|
22
|
+
const TICK_RATE = 20; // Hz
|
|
23
|
+
|
|
24
|
+
// Area manifest — defines transition targets per area
|
|
25
|
+
interface TransitionDef {
|
|
26
|
+
cellType?: string;
|
|
27
|
+
tileType?: string;
|
|
28
|
+
targetArea: string;
|
|
29
|
+
spawnX: number;
|
|
30
|
+
spawnY: number;
|
|
31
|
+
minX?: number;
|
|
32
|
+
maxX?: number;
|
|
33
|
+
minY?: number;
|
|
34
|
+
maxY?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface AreaDef {
|
|
38
|
+
id: string;
|
|
39
|
+
transitions: TransitionDef[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface AreaManifest {
|
|
43
|
+
areas: AreaDef[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const manifest: AreaManifest = JSON.parse(
|
|
47
|
+
readFileSync(resolve(__dirname, '../../game-data/src/areas/area-manifest.json'), 'utf-8')
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const transitionsByArea = new Map<string, TransitionDef[]>(
|
|
51
|
+
manifest.areas.map(a => [a.id, a.transitions])
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
export function startGameLoop(
|
|
55
|
+
io: Server,
|
|
56
|
+
session: GameSession,
|
|
57
|
+
areaManager: AreaManager,
|
|
58
|
+
areaId: string,
|
|
59
|
+
areaSession?: AreaSession,
|
|
60
|
+
): () => void {
|
|
61
|
+
let tick = 0;
|
|
62
|
+
|
|
63
|
+
// Per-player transition cooldown (socketId -> timestamp) — prevents double-fire
|
|
64
|
+
const transitionCooldown = new Map<string, number>();
|
|
65
|
+
|
|
66
|
+
// Physics state — runs for voxel AND dungeon areas
|
|
67
|
+
const isVoxelArea = areaSession?.areaType === 'voxel';
|
|
68
|
+
const isDungeonArea = areaSession?.areaType === 'dungeon';
|
|
69
|
+
const runPhysics = isVoxelArea || isDungeonArea;
|
|
70
|
+
const chunkCache = areaSession?.chunkCache;
|
|
71
|
+
const physicsMap: VoxelPhysicsMap = new Map();
|
|
72
|
+
if (runPhysics) {
|
|
73
|
+
voxelPhysicsRegistry.set(areaId, physicsMap);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return createTickLoop(TICK_RATE, (dt) => {
|
|
77
|
+
tick++;
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
|
|
80
|
+
session.updateMovement(dt);
|
|
81
|
+
updateEnemyAI(session, dt);
|
|
82
|
+
updateItemPickup(session);
|
|
83
|
+
|
|
84
|
+
// Physics tick — runs for voxel and dungeon areas
|
|
85
|
+
if (runPhysics) {
|
|
86
|
+
const getGroundY = isVoxelArea && chunkCache
|
|
87
|
+
? (x: number, z: number) => getTerrainHeight(chunkCache.getChunkFn, x, z)
|
|
88
|
+
: (x: number, z: number) => getDungeonGroundY(session, x, z);
|
|
89
|
+
|
|
90
|
+
const physicsConfig = isVoxelArea ? SANDBOX_PHYSICS_CONFIG : DUNGEON_PHYSICS_CONFIG;
|
|
91
|
+
|
|
92
|
+
for (const player of session.state.players.values()) {
|
|
93
|
+
// Ensure physics state exists
|
|
94
|
+
if (!physicsMap.has(player.socketId)) {
|
|
95
|
+
const spawnHeight = getGroundY(player.x, player.y) ?? 32;
|
|
96
|
+
physicsMap.set(player.socketId, createVoxelPlayerPhysics(player.x, spawnHeight, player.y));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const physics = physicsMap.get(player.socketId)!;
|
|
100
|
+
|
|
101
|
+
// Sync horizontal position from PLAYER_MOVE_DIRECT
|
|
102
|
+
// player.x = world X, player.y = world Z (2D legacy mapping)
|
|
103
|
+
physics.body.x = player.x;
|
|
104
|
+
physics.body.z = player.y;
|
|
105
|
+
|
|
106
|
+
// Update jump timers
|
|
107
|
+
updateJumpState(physics.jumpState, physics.body, dt);
|
|
108
|
+
|
|
109
|
+
// Apply gravity + ground check
|
|
110
|
+
updatePhysicsBody(physics.body, dt, getGroundY, physicsConfig);
|
|
111
|
+
|
|
112
|
+
// Auto-jump if buffered and just landed
|
|
113
|
+
consumeJumpBuffer(physics.body, physics.jumpState, GAME_JUMP_CONFIG);
|
|
114
|
+
|
|
115
|
+
// Snap grounded entities to terrain after horizontal movement
|
|
116
|
+
if (physics.body.isGrounded) {
|
|
117
|
+
snapToGround(physics.body, getGroundY, physicsConfig);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Clean up physics for disconnected players
|
|
122
|
+
for (const socketId of physicsMap.keys()) {
|
|
123
|
+
if (!session.state.players.has(socketId)) {
|
|
124
|
+
physicsMap.delete(socketId);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Detect transitions
|
|
130
|
+
const transitions = transitionsByArea.get(areaId) ?? [];
|
|
131
|
+
if (transitions.length > 0) {
|
|
132
|
+
for (const player of session.state.players.values()) {
|
|
133
|
+
const lastTransition = transitionCooldown.get(player.socketId) ?? 0;
|
|
134
|
+
if (now - lastTransition < 1000) continue; // 1s cooldown
|
|
135
|
+
|
|
136
|
+
let matched = false;
|
|
137
|
+
|
|
138
|
+
// Tile-based transition (for voxel overworld)
|
|
139
|
+
// Only trigger near tile center (within 4 units) — not the whole 32×32 tile
|
|
140
|
+
if (isVoxelArea) {
|
|
141
|
+
const tile = getOverworldTileType(areaManager.layout, player.x, player.y);
|
|
142
|
+
if (tile) {
|
|
143
|
+
const tr = transitions.find(t => t.tileType && t.tileType === tile.def.type);
|
|
144
|
+
if (tr) {
|
|
145
|
+
const tileCenterX = (tile.col + 0.5) * TILE_SIZE;
|
|
146
|
+
const tileCenterZ = (tile.row + 0.5) * TILE_SIZE;
|
|
147
|
+
const dx = player.x - tileCenterX;
|
|
148
|
+
const dz = player.y - tileCenterZ;
|
|
149
|
+
const distToCenter = Math.sqrt(dx * dx + dz * dz);
|
|
150
|
+
if (distToCenter <= 4) {
|
|
151
|
+
transitionCooldown.set(player.socketId, now);
|
|
152
|
+
areaManager.movePlayer(io, player.socketId, tr.targetArea, tr.spawnX, tr.spawnY);
|
|
153
|
+
matched = true;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (matched) break;
|
|
160
|
+
|
|
161
|
+
// Cell-based transition (for dungeons with stamped cells)
|
|
162
|
+
const cx = Math.round(player.x);
|
|
163
|
+
const cy = Math.round(player.y);
|
|
164
|
+
const cell = session.getCell(cx, cy);
|
|
165
|
+
if (cell?.type === 'transition') {
|
|
166
|
+
const tr = transitions.find(t => {
|
|
167
|
+
if (t.cellType !== 'transition' && t.cellType !== undefined) return false;
|
|
168
|
+
if (t.minX !== undefined && player.x < t.minX) return false;
|
|
169
|
+
if (t.maxX !== undefined && player.x > t.maxX) return false;
|
|
170
|
+
if (t.minY !== undefined && player.y < t.minY) return false;
|
|
171
|
+
if (t.maxY !== undefined && player.y > t.maxY) return false;
|
|
172
|
+
return true;
|
|
173
|
+
});
|
|
174
|
+
if (tr) {
|
|
175
|
+
transitionCooldown.set(player.socketId, now);
|
|
176
|
+
// When returning to overworld from dungeon, spawn near dungeon entrance
|
|
177
|
+
let sx = tr.spawnX;
|
|
178
|
+
let sy = tr.spawnY;
|
|
179
|
+
if (tr.targetArea === 'overworld' && sx < 0) {
|
|
180
|
+
const entrance = areaManager.getDungeonEntrancePos();
|
|
181
|
+
// Offset slightly so player doesn't immediately re-trigger D tile
|
|
182
|
+
sx = entrance.x - TILE_SIZE * 0.5;
|
|
183
|
+
sy = entrance.z;
|
|
184
|
+
}
|
|
185
|
+
areaManager.movePlayer(io, player.socketId, tr.targetArea, sx, sy);
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Dungeon clearance check
|
|
193
|
+
if (areaId === 'dungeon' && session.state.enemies.length > 0 && !session.clearedAt) {
|
|
194
|
+
if (isDungeonCleared(session.state)) {
|
|
195
|
+
session.clearedAt = Date.now();
|
|
196
|
+
for (const player of session.state.players.values()) {
|
|
197
|
+
io.sockets.sockets.get(player.socketId)?.emit('message', {
|
|
198
|
+
type: 'DUNGEON_CLEAR',
|
|
199
|
+
countdownSeconds: 10,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let remaining = 10;
|
|
204
|
+
const countdown = setInterval(() => {
|
|
205
|
+
remaining--;
|
|
206
|
+
for (const player of session.state.players.values()) {
|
|
207
|
+
io.sockets.sockets.get(player.socketId)?.emit('message', {
|
|
208
|
+
type: 'COUNTDOWN_TICK',
|
|
209
|
+
secondsRemaining: remaining,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
if (remaining <= 0) {
|
|
213
|
+
clearInterval(countdown);
|
|
214
|
+
const playerIds = [...session.state.players.keys()];
|
|
215
|
+
// Return to overworld basecamp
|
|
216
|
+
for (const socketId of playerIds) {
|
|
217
|
+
areaManager.movePlayer(io, socketId, 'overworld', -1, -1);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}, 1000);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let entities = session.getEntitySnapshots();
|
|
225
|
+
|
|
226
|
+
// For areas with physics, override positions with PhysicsBody data
|
|
227
|
+
if (runPhysics) {
|
|
228
|
+
const getGroundYForSync = isVoxelArea && chunkCache
|
|
229
|
+
? (x: number, z: number) => getTerrainHeight(chunkCache.getChunkFn, x, z) ?? 0
|
|
230
|
+
: null;
|
|
231
|
+
|
|
232
|
+
entities = entities.map(e => {
|
|
233
|
+
// Players: use physics body Y
|
|
234
|
+
for (const [socketId, physics] of physicsMap) {
|
|
235
|
+
const p = session.state.players.get(socketId);
|
|
236
|
+
if (p && p.entityId === e.entityId) {
|
|
237
|
+
return { ...e, x: physics.body.x, y: physics.body.z, z: physics.body.z, worldY: physics.body.y };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// Non-player entities in voxel areas: compute terrain height
|
|
241
|
+
if (getGroundYForSync && e.type !== 'player') {
|
|
242
|
+
return { ...e, worldY: getGroundYForSync(e.x, e.y) };
|
|
243
|
+
}
|
|
244
|
+
return e;
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Emit to players in this session
|
|
249
|
+
for (const player of session.state.players.values()) {
|
|
250
|
+
const sock = io.sockets.sockets.get(player.socketId);
|
|
251
|
+
sock?.emit('message', { type: 'ENTITY_SYNC', entities, tick });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Per-socket INPUT_ACK so each WASD client can reconcile predictions
|
|
255
|
+
for (const player of session.state.players.values()) {
|
|
256
|
+
if (player.lastProcessedInputId >= 0) {
|
|
257
|
+
const sock = io.sockets.sockets.get(player.socketId);
|
|
258
|
+
sock?.emit('message', {
|
|
259
|
+
type: 'INPUT_ACK',
|
|
260
|
+
lastProcessedInputId: player.lastProcessedInputId,
|
|
261
|
+
x: player.x,
|
|
262
|
+
y: player.y,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// Re-export from framework package for backwards compatibility
|
|
2
|
+
export { GameSession, SpatialHash, moveAlongPath } from '@loonylabs/gamedev-server';
|
|
3
|
+
export type { PlayerState, EnemyState, WorldItem, SpawnableEnemyDef, GameSessionOptions } from '@loonylabs/gamedev-server';
|