@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,55 @@
1
+ import { meleeAttack, rollLoot, aggregateStats } from '@loonylabs/gamedev-core';
2
+ import type { GameSession } from '../gameState.js';
3
+ import { DEFAULT_ITEM_POOL } from '../../../game-data/src/loot/item-pool.js';
4
+
5
+ const PLAYER_MELEE_RANGE = 1.5;
6
+ const BASE_MELEE_DAMAGE = 15;
7
+
8
+ export function handleMeleeAction(session: GameSession, socketId: string): void {
9
+ const player = session.state.players.get(socketId);
10
+ if (!player) return;
11
+
12
+ const aliveEnemies = session.state.enemies.filter(g => g.alive);
13
+ const targets = aliveEnemies.map(g => ({
14
+ entityId: g.entityId,
15
+ x: g.x,
16
+ y: g.y,
17
+ health: g.hp,
18
+ }));
19
+
20
+ const stats = aggregateStats(player.equipped);
21
+ const damage = BASE_MELEE_DAMAGE + (stats.attack ?? 0);
22
+
23
+ const results = meleeAttack(
24
+ { x: player.x, y: player.y },
25
+ targets,
26
+ PLAYER_MELEE_RANGE,
27
+ damage,
28
+ );
29
+
30
+ for (const result of results) {
31
+ if (!result.hit) continue;
32
+
33
+ const enemy = session.state.enemies.find(g => g.entityId === result.targetId);
34
+ if (!enemy || !enemy.alive) continue;
35
+
36
+ enemy.hp -= result.damage;
37
+ console.log(`[combat] Player ${player.entityId} hits Enemy ${enemy.entityId} (${enemy.enemyDefId}) for ${result.damage} (hp: ${enemy.hp})`);
38
+
39
+ if (enemy.hp <= 0) {
40
+ enemy.alive = false;
41
+ console.log(`[combat] Enemy ${enemy.entityId} (${enemy.enemyDefId}) died at (${enemy.x.toFixed(1)}, ${enemy.y.toFixed(1)})`);
42
+
43
+ // Drop loot
44
+ const seed = Date.now() ^ enemy.entityId;
45
+ const item = rollLoot(1, seed, DEFAULT_ITEM_POOL);
46
+ session.state.worldItems.push({
47
+ entityId: session.nextEntityId(),
48
+ x: enemy.x,
49
+ y: enemy.y,
50
+ item,
51
+ });
52
+ console.log(`[loot] Dropped "${item.name}" at (${enemy.x.toFixed(1)}, ${enemy.y.toFixed(1)})`);
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,59 @@
1
+ import type { Socket } from 'socket.io';
2
+ import { matchRecipe } from '@loonylabs/gamedev-core';
3
+ import type { Recipe } from '@loonylabs/gamedev-core';
4
+ import type { GameSession } from '../gameState.js';
5
+ import type { InventoryItem } from '@loonylabs/gamedev-protocol';
6
+
7
+ function toInventoryItems(inventory: Array<{ id: string; name: string; rarity: string; statBonus: Record<string, number> }>): InventoryItem[] {
8
+ return inventory.map((item, slot) => ({
9
+ id: item.id,
10
+ name: item.name,
11
+ rarity: item.rarity as 'common' | 'rare' | 'unique',
12
+ gridW: 1,
13
+ gridH: 1,
14
+ slot,
15
+ statBonus: item.statBonus,
16
+ }));
17
+ }
18
+
19
+ export function handleCraftRequest(
20
+ session: GameSession,
21
+ socket: Socket,
22
+ grid: Array<string | null>,
23
+ recipes: Recipe[],
24
+ ): void {
25
+ const player = session.state.players.get(socket.id);
26
+ if (!player) return;
27
+
28
+ const recipe = matchRecipe(grid, recipes);
29
+ if (!recipe) {
30
+ socket.emit('message', { type: 'CRAFT_RESULT', success: false, error: 'No matching recipe' });
31
+ return;
32
+ }
33
+
34
+ // Verify player has all required items
35
+ const requiredIds = grid.filter((id): id is string => id !== null);
36
+ for (const id of requiredIds) {
37
+ if (!player.inventory.find(i => i.id === id)) {
38
+ socket.emit('message', { type: 'CRAFT_RESULT', success: false, error: 'Missing ingredients' });
39
+ return;
40
+ }
41
+ }
42
+
43
+ // Remove one of each required item
44
+ for (const id of requiredIds) {
45
+ const idx = player.inventory.findIndex(i => i.id === id);
46
+ if (idx !== -1) player.inventory.splice(idx, 1);
47
+ }
48
+
49
+ // Add crafted item
50
+ player.inventory.push({
51
+ id: recipe.id,
52
+ name: recipe.name,
53
+ rarity: recipe.rarity,
54
+ statBonus: recipe.statBonus,
55
+ });
56
+
57
+ socket.emit('message', { type: 'CRAFT_RESULT', success: true, item: recipe });
58
+ socket.emit('message', { type: 'INVENTORY_UPDATE', items: toInventoryItems(player.inventory) });
59
+ }
@@ -0,0 +1,73 @@
1
+ import type { Socket } from 'socket.io';
2
+ import type { GameSession, PlayerState } from '../gameState.js';
3
+ import type { InventoryItem } from '@loonylabs/gamedev-protocol';
4
+ import type { Item } from '@loonylabs/gamedev-core';
5
+
6
+ function equippedToProtocol(equipped: Record<string, Item | undefined>): Record<string, InventoryItem> {
7
+ const result: Record<string, InventoryItem> = {};
8
+ for (const [slotId, item] of Object.entries(equipped)) {
9
+ if (!item) continue;
10
+ result[slotId] = {
11
+ id: item.id,
12
+ name: item.name,
13
+ rarity: item.rarity,
14
+ gridW: 1,
15
+ gridH: 1,
16
+ slot: 0,
17
+ statBonus: item.statBonus,
18
+ };
19
+ }
20
+ return result;
21
+ }
22
+
23
+ function inventoryToProtocol(inventory: Item[]): InventoryItem[] {
24
+ return inventory.map((it, slot) => ({
25
+ id: it.id,
26
+ name: it.name,
27
+ rarity: it.rarity,
28
+ gridW: 1,
29
+ gridH: 1,
30
+ slot,
31
+ statBonus: it.statBonus,
32
+ }));
33
+ }
34
+
35
+ function sendUpdates(socket: Socket, player: PlayerState): void {
36
+ socket.emit('message', { type: 'EQUIPMENT_UPDATE', equipped: equippedToProtocol(player.equipped as Record<string, Item | undefined>) });
37
+ socket.emit('message', { type: 'INVENTORY_UPDATE', items: inventoryToProtocol(player.inventory) });
38
+ }
39
+
40
+ export function handleEquipRequest(session: GameSession, socket: Socket, slotId: string, itemId: string): void {
41
+ const player = session.state.players.get(socket.id);
42
+ if (!player) return;
43
+
44
+ const itemIdx = player.inventory.findIndex(i => i.id === itemId);
45
+ if (itemIdx === -1) return;
46
+
47
+ const item = player.inventory[itemIdx];
48
+
49
+ // If slot is already occupied, swap current item back to inventory
50
+ const existing = player.equipped[slotId];
51
+ if (existing) {
52
+ player.inventory.push(existing);
53
+ }
54
+
55
+ // Move item from inventory to equipped
56
+ player.inventory.splice(itemIdx, 1);
57
+ player.equipped[slotId] = item;
58
+
59
+ sendUpdates(socket, player);
60
+ }
61
+
62
+ export function handleUnequipRequest(session: GameSession, socket: Socket, slotId: string): void {
63
+ const player = session.state.players.get(socket.id);
64
+ if (!player) return;
65
+
66
+ const item = player.equipped[slotId];
67
+ if (!item) return;
68
+
69
+ delete player.equipped[slotId];
70
+ player.inventory.push(item);
71
+
72
+ sendUpdates(socket, player);
73
+ }
@@ -0,0 +1,97 @@
1
+ import { Server } from 'socket.io';
2
+ import { GameSession } from '../gameState.js';
3
+ import { intersectRayAABB, rollLoot } from '@loonylabs/gamedev-core';
4
+ import { HUMANOID_HITBOXES } from '../../../game-data/src/combat/hitboxes.js';
5
+ import { DEFAULT_ITEM_POOL } from '../../../game-data/src/loot/item-pool.js';
6
+ import type { Vec3 } from '@loonylabs/gamedev-core';
7
+
8
+ export function handleRaycastAction(
9
+ session: GameSession,
10
+ io: Server,
11
+ entityId: number,
12
+ origin: Vec3,
13
+ direction: Vec3,
14
+ range: number,
15
+ baseDamage = 10,
16
+ getGroundY?: (x: number, z: number) => number | null,
17
+ ) {
18
+ console.log(`[server] processing raycast from ${entityId} origin:`, origin, "dir:", direction);
19
+ const entities = session.getAllEntities();
20
+ let closestDist = range;
21
+ let closestHit: { entityId: number; multiplier: number; label: string; point: Vec3 } | null = null;
22
+
23
+ for (const entity of entities) {
24
+ if (entity.entityId === entityId) continue; // Don't hit yourself
25
+
26
+ // Check each hitbox for this entity
27
+ for (const hitbox of HUMANOID_HITBOXES) {
28
+ // Calculate absolute hitbox bounds
29
+ // entity.x is world X, entity.y is world Z
30
+ const groundY = getGroundY
31
+ ? (getGroundY(entity.x, entity.y) ?? 0)
32
+ : session.getCellHeight(Math.round(entity.x), Math.round(entity.y));
33
+ const min = {
34
+ x: entity.x + hitbox.min.x,
35
+ y: groundY + hitbox.min.y,
36
+ z: entity.y + hitbox.min.z,
37
+ };
38
+ const max = {
39
+ x: entity.x + hitbox.max.x,
40
+ y: groundY + hitbox.max.y,
41
+ z: entity.y + hitbox.max.z,
42
+ };
43
+
44
+ const dist = intersectRayAABB(origin, direction, min, max);
45
+ if (dist !== null && dist < closestDist) {
46
+ closestDist = dist;
47
+ closestHit = {
48
+ entityId: entity.entityId,
49
+ multiplier: hitbox.multiplier,
50
+ label: hitbox.label,
51
+ point: {
52
+ x: origin.x + direction.x * dist,
53
+ y: origin.y + direction.y * dist,
54
+ z: origin.z + direction.z * dist,
55
+ },
56
+ };
57
+ }
58
+ }
59
+ }
60
+
61
+ // Calculate endpoint for VFX
62
+ const endPoint = closestHit ? closestHit.point : {
63
+ x: origin.x + direction.x * range,
64
+ y: origin.y + direction.y * range,
65
+ z: origin.z + direction.z * range,
66
+ };
67
+
68
+ // Broadcast VFX to everyone
69
+ io.emit('message', {
70
+ type: 'COMBAT_VFX',
71
+ vfxType: 'tracer',
72
+ origin,
73
+ endPoint,
74
+ hit: closestHit !== null,
75
+ });
76
+
77
+ if (closestHit) {
78
+ const damage = Math.floor(baseDamage * closestHit.multiplier);
79
+ const result = session.applyDamage(closestHit.entityId, damage);
80
+ console.log(`[combat] Entity ${entityId} hit ${closestHit.entityId} in ${closestHit.label} for ${damage} dmg`);
81
+
82
+ if (result?.dead) {
83
+ const enemy = session.state.enemies.find(g => g.entityId === closestHit.entityId);
84
+ if (enemy) {
85
+ const seed = Date.now() ^ enemy.entityId;
86
+ const item = rollLoot(1, seed, DEFAULT_ITEM_POOL);
87
+ session.state.worldItems.push({
88
+ entityId: session.nextEntityId(),
89
+ x: enemy.x,
90
+ y: enemy.y,
91
+ item,
92
+ });
93
+ console.log(`[loot] Dropped "${item.name}" at (${enemy.x.toFixed(1)}, ${enemy.y.toFixed(1)})`);
94
+ }
95
+ }
96
+ }
97
+ }
@@ -0,0 +1,87 @@
1
+ import { Server } from 'socket.io';
2
+ import type { GameSession } from '../gameState.js';
3
+ import type { SkillBook } from '@loonylabs/gamedev-core';
4
+ import { canUseSkill, recordSkillUse } from '@loonylabs/gamedev-core';
5
+ import { handleRaycastAction } from './raycastHandler.js';
6
+ import type { Vec3 } from '@loonylabs/gamedev-protocol';
7
+
8
+ const PLAYER_MAX_HP = 100;
9
+
10
+ export function handleUseSkill(
11
+ session: GameSession,
12
+ io: Server,
13
+ socketId: string,
14
+ skillId: string,
15
+ skillBook: SkillBook,
16
+ origin?: Vec3,
17
+ direction?: Vec3,
18
+ getGroundY?: (x: number, z: number) => number | null,
19
+ ): void {
20
+ const player = session.state.players.get(socketId);
21
+ if (!player) return;
22
+
23
+ const def = skillBook[skillId];
24
+ if (!def) {
25
+ console.log(`[skill] Unknown skill: ${skillId}`);
26
+ return;
27
+ }
28
+
29
+ const now = Date.now();
30
+ const lastUsed = player.skillCooldowns[skillId] ?? 0;
31
+
32
+ if (!canUseSkill(def, lastUsed, now)) {
33
+ const remaining = Math.ceil((def.cooldown - (now - lastUsed)) / 100) / 10;
34
+ console.log(`[skill] ${skillId} on cooldown for ${remaining}s`);
35
+ io.to(socketId).emit('message', {
36
+ type: 'SKILL_EXECUTION',
37
+ skillId,
38
+ casterEntityId: player.entityId,
39
+ success: false,
40
+ });
41
+ return;
42
+ }
43
+
44
+ player.skillCooldowns = recordSkillUse(player.skillCooldowns, skillId, now);
45
+
46
+ const damage = def.stats.damage ?? 10;
47
+ const range = def.stats.range ?? 20;
48
+
49
+ switch (def.actionType) {
50
+ case 'raycast':
51
+ if (origin && direction) {
52
+ handleRaycastAction(session, io, player.entityId, origin, direction, range, damage, getGroundY);
53
+ }
54
+ break;
55
+
56
+ case 'melee': {
57
+ // Simple melee: damage nearest entity within range
58
+ const entities = session.getAllEntities();
59
+ for (const e of entities) {
60
+ if (e.entityId === player.entityId) continue;
61
+ const dx = e.x - player.x;
62
+ const dy = e.y - player.y;
63
+ if (Math.sqrt(dx * dx + dy * dy) <= range) {
64
+ session.applyDamage(e.entityId, damage);
65
+ console.log(`[skill] ${skillId} melee hit entity ${e.entityId} for ${damage} dmg`);
66
+ break;
67
+ }
68
+ }
69
+ break;
70
+ }
71
+
72
+ case 'self_heal': {
73
+ const heal = def.stats.value ?? 30;
74
+ player.hp = Math.min(PLAYER_MAX_HP, player.hp + heal);
75
+ console.log(`[skill] ${skillId} healed player ${player.entityId} to ${player.hp}hp`);
76
+ break;
77
+ }
78
+ }
79
+
80
+ io.emit('message', {
81
+ type: 'SKILL_EXECUTION',
82
+ skillId,
83
+ casterEntityId: player.entityId,
84
+ success: true,
85
+ });
86
+ console.log(`[skill] ${player.entityId} used ${skillId} (${def.actionType})`);
87
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @file handlers/terraformHandler.ts
3
+ * Handles TERRAFORM messages — modifies voxel terrain and broadcasts deltas.
4
+ */
5
+ import type { Server } from 'socket.io';
6
+ import type { GameSession } from '../gameState.js';
7
+ import { applyTerraform, VOXEL_CHUNK_X, VOXEL_CHUNK_Z } from '@loonylabs/gamedev-core';
8
+ import type { VoxelChunk, TerraformEdit } from '@loonylabs/gamedev-core';
9
+ import type { Terraform } from '@loonylabs/gamedev-protocol';
10
+
11
+ /** Rate limiter: max edits per second per player. */
12
+ const RATE_LIMIT_MS = 200; // 5 edits/sec
13
+ const lastEditTime = new Map<number, number>();
14
+
15
+ /**
16
+ * Handle a TERRAFORM message from a player.
17
+ *
18
+ * @param session - The current game session
19
+ * @param io - Socket.io server for broadcasting
20
+ * @param entityId - The player's entity ID
21
+ * @param msg - The validated terraform message
22
+ * @param getChunk - Function to retrieve/generate a voxel chunk
23
+ * @param persistEdits - Function to persist edits to the database
24
+ */
25
+ export function handleTerraform(
26
+ session: GameSession,
27
+ io: Server,
28
+ entityId: number,
29
+ msg: Terraform,
30
+ getChunk: (cx: number, cz: number) => VoxelChunk | null,
31
+ persistEdits?: (cx: number, cz: number, edits: TerraformEdit[]) => void,
32
+ ): void {
33
+ // Rate limit
34
+ const now = Date.now();
35
+ const lastTime = lastEditTime.get(entityId) ?? 0;
36
+ if (now - lastTime < RATE_LIMIT_MS) return;
37
+ lastEditTime.set(entityId, now);
38
+
39
+ // Convert world coords to chunk coords + local coords
40
+ const cx = Math.floor(msg.x / VOXEL_CHUNK_X);
41
+ const cz = Math.floor(msg.z / VOXEL_CHUNK_Z);
42
+ const lx = msg.x - cx * VOXEL_CHUNK_X;
43
+ const ly = msg.y;
44
+ const lz = msg.z - cz * VOXEL_CHUNK_Z;
45
+
46
+ const chunk = getChunk(cx, cz);
47
+ if (!chunk) return;
48
+
49
+ // Apply terraform edit
50
+ const edits = applyTerraform(
51
+ chunk, lx, ly, lz,
52
+ msg.radius,
53
+ msg.mode,
54
+ msg.material ?? 1,
55
+ );
56
+
57
+ if (edits.length === 0) return;
58
+
59
+ // Persist to database
60
+ persistEdits?.(cx, cz, edits);
61
+
62
+ // Broadcast delta to all clients
63
+ const flatEdits: number[] = [];
64
+ for (const edit of edits) {
65
+ flatEdits.push(edit.lx, edit.ly, edit.lz, edit.density, edit.material);
66
+ }
67
+
68
+ io.emit('VOXEL_DELTA', {
69
+ type: 'VOXEL_DELTA',
70
+ cx,
71
+ cz,
72
+ edits: flatEdits,
73
+ });
74
+ }