@loonylabs/create-game 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/bin/create-game.js +213 -0
  2. package/package.json +18 -0
  3. package/template/.claude/skills/aigdtk-create-game-stories/SKILL.md +177 -0
  4. package/template/.claude/skills/aigdtk-create-game-stories/story-template.md +85 -0
  5. package/template/.claude/skills/aigdtk-implement-game-stories/SKILL.md +129 -0
  6. package/template/.claude/skills/aigdtk-new-game/SKILL.md +126 -0
  7. package/template/.claude/skills/aigdtk-shared/ascii-grammar.md +133 -0
  8. package/template/.claude/skills/aigdtk-shared/enemies.md +112 -0
  9. package/template/.claude/skills/aigdtk-shared/framework.md +93 -0
  10. package/template/.claude/skills/aigdtk-shared/visuals.md +125 -0
  11. package/template/apps/client/index.html +14 -0
  12. package/template/apps/client/package.json +31 -0
  13. package/template/apps/client/public/assets/audio/enemy_killed.wav +0 -0
  14. package/template/apps/client/src/components/App.svelte +290 -0
  15. package/template/apps/client/src/components/CraftingPanel.svelte +253 -0
  16. package/template/apps/client/src/components/DevPanel.svelte +180 -0
  17. package/template/apps/client/src/components/DungeonClearOverlay.svelte +53 -0
  18. package/template/apps/client/src/components/EquipmentPanel.svelte +191 -0
  19. package/template/apps/client/src/components/HealthBar.svelte +50 -0
  20. package/template/apps/client/src/components/Hub/BackToHubButton.svelte +37 -0
  21. package/template/apps/client/src/components/Hub/ExperienceCard.svelte +115 -0
  22. package/template/apps/client/src/components/Hub/Hub.svelte +88 -0
  23. package/template/apps/client/src/components/Inventory.svelte +174 -0
  24. package/template/apps/client/src/components/Runner/RunnerDeathScreen.svelte +182 -0
  25. package/template/apps/client/src/components/Runner/RunnerHUD.svelte +157 -0
  26. package/template/apps/client/src/components/Shooter/DamageNumbers.svelte +96 -0
  27. package/template/apps/client/src/components/Shooter/GameOverScreen.svelte +109 -0
  28. package/template/apps/client/src/components/Shooter/ShooterHUD.svelte +95 -0
  29. package/template/apps/client/src/components/SkillBar.svelte +146 -0
  30. package/template/apps/client/src/components/ToastSystem.svelte +158 -0
  31. package/template/apps/client/src/game.ts +918 -0
  32. package/template/apps/client/src/input.ts +92 -0
  33. package/template/apps/client/src/lib/audio/CombatDetector.test.ts +59 -0
  34. package/template/apps/client/src/lib/audio/CombatDetector.ts +53 -0
  35. package/template/apps/client/src/lib/audio/MusicManager.ts +137 -0
  36. package/template/apps/client/src/lib/audio/SoundManager.ts +59 -0
  37. package/template/apps/client/src/lib/audio/index.ts +9 -0
  38. package/template/apps/client/src/main.ts +32 -0
  39. package/template/apps/client/src/renderer/basecamp.ts +126 -0
  40. package/template/apps/client/src/renderer/dungeon.ts +250 -0
  41. package/template/apps/client/src/renderer/dungeonPortal.ts +73 -0
  42. package/template/apps/client/src/renderer/dungeonZone.ts +301 -0
  43. package/template/apps/client/src/renderer/entities.ts +197 -0
  44. package/template/apps/client/src/renderer/runnerTrack.ts +221 -0
  45. package/template/apps/client/src/renderer/shaders/SkyShader.ts +190 -0
  46. package/template/apps/client/src/renderer/shaders/TerrainShaderMaterial.ts +133 -0
  47. package/template/apps/client/src/renderer/shaders/floor.frag.glsl.ts +17 -0
  48. package/template/apps/client/src/renderer/shaders/shaderConfig.ts +18 -0
  49. package/template/apps/client/src/renderer/shaders/spawn.frag.glsl.ts +19 -0
  50. package/template/apps/client/src/renderer/shaders/terrain.frag.glsl.ts +314 -0
  51. package/template/apps/client/src/renderer/shaders/terrain.vert.glsl.ts +16 -0
  52. package/template/apps/client/src/renderer/shaders/wall.frag.glsl.ts +20 -0
  53. package/template/apps/client/src/renderer/shooterArena.ts +102 -0
  54. package/template/apps/client/src/renderer/voxelChunkStreamer.ts +79 -0
  55. package/template/apps/client/src/renderer/voxelMesh.ts +86 -0
  56. package/template/apps/client/src/renderer/voxelTerrain.ts +74 -0
  57. package/template/apps/client/src/socket.ts +268 -0
  58. package/template/apps/client/src/store.ts +74 -0
  59. package/template/apps/client/src/style.css +60 -0
  60. package/template/apps/client/tsconfig.json +11 -0
  61. package/template/apps/client/vite.config.ts +10 -0
  62. package/template/apps/client/vitest.config.ts +8 -0
  63. package/template/apps/experiences/diablo/index.ts +94 -0
  64. package/template/apps/experiences/diablo/systems/dungeonClearSystem.ts +60 -0
  65. package/template/apps/experiences/diablo/systems/enemyAISystem.ts +11 -0
  66. package/template/apps/experiences/diablo/systems/entitySyncSystem.ts +80 -0
  67. package/template/apps/experiences/diablo/systems/itemPickupSystem.ts +11 -0
  68. package/template/apps/experiences/diablo/systems/movementSystem.ts +13 -0
  69. package/template/apps/experiences/diablo/systems/physicsSystem.ts +92 -0
  70. package/template/apps/experiences/diablo/systems/transitionSystem.ts +105 -0
  71. package/template/apps/experiences/runner/data/runner-config.ts +54 -0
  72. package/template/apps/experiences/runner/index.ts +143 -0
  73. package/template/apps/experiences/runner/systems/collectibleSystem.ts +157 -0
  74. package/template/apps/experiences/runner/systems/deathSystem.ts +42 -0
  75. package/template/apps/experiences/runner/systems/entitySyncSystem.ts +59 -0
  76. package/template/apps/experiences/runner/systems/obstacleSystem.ts +91 -0
  77. package/template/apps/experiences/runner/systems/runnerPhysicsSystem.ts +82 -0
  78. package/template/apps/experiences/runner/systems/trackStreamSystem.ts +19 -0
  79. package/template/apps/experiences/runner/track/laneSystem.ts +53 -0
  80. package/template/apps/experiences/runner/track/segmentTypes.ts +141 -0
  81. package/template/apps/experiences/runner/track/trackGenerator.ts +292 -0
  82. package/template/apps/experiences/shooter/ai/aiStateMachine.ts +394 -0
  83. package/template/apps/experiences/shooter/ai/lineOfSight.ts +32 -0
  84. package/template/apps/experiences/shooter/arena/arenaGenerator.ts +101 -0
  85. package/template/apps/experiences/shooter/arena/arenaTypes.ts +49 -0
  86. package/template/apps/experiences/shooter/arena/hitscan.ts +101 -0
  87. package/template/apps/experiences/shooter/data/enemy-types.ts +108 -0
  88. package/template/apps/experiences/shooter/data/wave-definitions.ts +40 -0
  89. package/template/apps/experiences/shooter/data/weapon-config.ts +80 -0
  90. package/template/apps/experiences/shooter/index.ts +127 -0
  91. package/template/apps/experiences/shooter/systems/enemyAISystem.ts +113 -0
  92. package/template/apps/experiences/shooter/systems/entitySyncSystem.ts +68 -0
  93. package/template/apps/experiences/shooter/systems/shooterPhysicsSystem.ts +89 -0
  94. package/template/apps/experiences/shooter/systems/waveSpawnerSystem.ts +87 -0
  95. package/template/apps/experiences/shooter/systems/weaponSystem.ts +157 -0
  96. package/template/apps/game-data/src/areas/area-manifest.json +18 -0
  97. package/template/apps/game-data/src/assets/migration.test.ts +291 -0
  98. package/template/apps/game-data/src/audio/music-config.json +21 -0
  99. package/template/apps/game-data/src/audio/sound-config.json +11 -0
  100. package/template/apps/game-data/src/combat/action-types.ts +2 -0
  101. package/template/apps/game-data/src/combat/enemy-def.ts +12 -0
  102. package/template/apps/game-data/src/combat/hitboxes.ts +23 -0
  103. package/template/apps/game-data/src/dungeon/cell-types.ts +20 -0
  104. package/template/apps/game-data/src/dungeon/cell-visuals.ts +13 -0
  105. package/template/apps/game-data/src/dungeon/door-directions.ts +2 -0
  106. package/template/apps/game-data/src/enemies/enemy-defs.json +32 -0
  107. package/template/apps/game-data/src/equipment/slots.json +5 -0
  108. package/template/apps/game-data/src/events/event-defs.ts +20 -0
  109. package/template/apps/game-data/src/events/event-types.ts +10 -0
  110. package/template/apps/game-data/src/events/toast-config.json +49 -0
  111. package/template/apps/game-data/src/items/item-pool.json +13 -0
  112. package/template/apps/game-data/src/loot/item-pool.ts +14 -0
  113. package/template/apps/game-data/src/loot/rarities.ts +2 -0
  114. package/template/apps/game-data/src/physics/dungeon-physics-config.ts +12 -0
  115. package/template/apps/game-data/src/physics/jump-config.ts +17 -0
  116. package/template/apps/game-data/src/recipes/recipe-book.json +68 -0
  117. package/template/apps/game-data/src/rooms/room_basecamp.json +16 -0
  118. package/template/apps/game-data/src/rooms/room_corridor_ew.json +9 -0
  119. package/template/apps/game-data/src/rooms/room_corridor_ns.json +11 -0
  120. package/template/apps/game-data/src/rooms/room_crossroads.json +11 -0
  121. package/template/apps/game-data/src/rooms/room_dead_end.json +10 -0
  122. package/template/apps/game-data/src/rooms/room_staircase.json +12 -0
  123. package/template/apps/game-data/src/rooms/room_start.json +11 -0
  124. package/template/apps/game-data/src/skills/skill-book.json +20 -0
  125. package/template/apps/game-data/src/voxel/biome-terrain.ts +76 -0
  126. package/template/apps/game-data/src/voxel/materials.ts +45 -0
  127. package/template/apps/game-data/src/voxel/sandbox-terrain-config.ts +19 -0
  128. package/template/apps/game-data/src/world/area-config.ts +33 -0
  129. package/template/apps/game-data/src/world/biome-def.ts +15 -0
  130. package/template/apps/game-data/src/world/biomes.json +57 -0
  131. package/template/apps/game-data/src/world/movement.ts +2 -0
  132. package/template/apps/game-data/src/world/overworld-layout.test.ts +93 -0
  133. package/template/apps/game-data/src/world/overworld-layout.ts +127 -0
  134. package/template/apps/server/data/game.db +0 -0
  135. package/template/apps/server/package.json +30 -0
  136. package/template/apps/server/src/areaManager.ts +346 -0
  137. package/template/apps/server/src/db/client.ts +45 -0
  138. package/template/apps/server/src/db/schema.ts +40 -0
  139. package/template/apps/server/src/gameLoop.ts +267 -0
  140. package/template/apps/server/src/gameState.ts +3 -0
  141. package/template/apps/server/src/handlers/actionEvent.ts +55 -0
  142. package/template/apps/server/src/handlers/craftHandler.ts +59 -0
  143. package/template/apps/server/src/handlers/equipHandler.ts +73 -0
  144. package/template/apps/server/src/handlers/raycastHandler.ts +97 -0
  145. package/template/apps/server/src/handlers/skillHandler.ts +87 -0
  146. package/template/apps/server/src/handlers/terraformHandler.ts +74 -0
  147. package/template/apps/server/src/index.ts +597 -0
  148. package/template/apps/server/src/persistence.ts +135 -0
  149. package/template/apps/server/src/rooms.ts +20 -0
  150. package/template/apps/server/src/systems/dungeonPhysics.test.ts +32 -0
  151. package/template/apps/server/src/systems/dungeonPhysics.ts +16 -0
  152. package/template/apps/server/src/systems/enemyAI.ts +129 -0
  153. package/template/apps/server/src/systems/itemPickup.ts +31 -0
  154. package/template/apps/server/src/tests/areaManager.test.ts +77 -0
  155. package/template/apps/server/src/tests/diablo-experience.test.ts +60 -0
  156. package/template/apps/server/src/tests/runner-experience.test.ts +273 -0
  157. package/template/apps/server/src/tests/runner-powerups-scoring.test.ts +221 -0
  158. package/template/apps/server/src/tests/server.integration.test.ts +92 -0
  159. package/template/apps/server/src/tests/shooter-enemy-ai.test.ts +328 -0
  160. package/template/apps/server/src/tests/shooter-experience.test.ts +281 -0
  161. package/template/apps/server/src/tests/voxelChunkCache.test.ts +29 -0
  162. package/template/apps/server/src/tests/voxelSandbox.test.ts +133 -0
  163. package/template/apps/server/src/voxelChunkCache.ts +31 -0
  164. package/template/apps/server/src/voxelPlayerState.ts +23 -0
  165. package/template/apps/server/tsconfig.json +17 -0
  166. package/template/apps/server/vitest.config.ts +8 -0
@@ -0,0 +1,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';