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