@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,135 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm';
|
|
2
|
+
import type { Db } from './db/client.js';
|
|
3
|
+
import { players as playersTable, items as itemsTable, inventories as inventoriesTable, equipped as equippedTable } from './db/schema.js';
|
|
4
|
+
import type { PlayerState } from './gameState.js';
|
|
5
|
+
import type { GameSession } from './gameState.js';
|
|
6
|
+
import type { InventoryItem } from '@loonylabs/gamedev-protocol';
|
|
7
|
+
|
|
8
|
+
export function toInventoryItems(inventory: Array<{ id: string; name: string; rarity: string; statBonus: Record<string, number> }>): InventoryItem[] {
|
|
9
|
+
return inventory.map((item, slot) => ({
|
|
10
|
+
id: item.id,
|
|
11
|
+
name: item.name,
|
|
12
|
+
rarity: item.rarity as 'common' | 'rare' | 'unique',
|
|
13
|
+
gridW: 1,
|
|
14
|
+
gridH: 1,
|
|
15
|
+
slot,
|
|
16
|
+
statBonus: item.statBonus,
|
|
17
|
+
}));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Load persisted player state from DB into the given PlayerState.
|
|
22
|
+
* Returns true if a saved state was found.
|
|
23
|
+
*/
|
|
24
|
+
export function loadPlayerState(db: Db, session: GameSession, player: PlayerState, playerId: string): boolean {
|
|
25
|
+
player.playerId = playerId;
|
|
26
|
+
|
|
27
|
+
const saved = db.select().from(playersTable).where(eq(playersTable.id, playerId)).get();
|
|
28
|
+
if (!saved) return false;
|
|
29
|
+
|
|
30
|
+
// Only restore position if it's walkable in the current area
|
|
31
|
+
if (session.isWalkable(saved.x, saved.y)) {
|
|
32
|
+
player.x = saved.x;
|
|
33
|
+
player.y = saved.y;
|
|
34
|
+
} else {
|
|
35
|
+
console.log(`[db] Saved position (${saved.x.toFixed(1)}, ${saved.y.toFixed(1)}) is not walkable, resetting to spawn`);
|
|
36
|
+
}
|
|
37
|
+
player.hp = saved.hp;
|
|
38
|
+
|
|
39
|
+
// Load inventory
|
|
40
|
+
const savedInventory = db
|
|
41
|
+
.select()
|
|
42
|
+
.from(inventoriesTable)
|
|
43
|
+
.where(eq(inventoriesTable.playerId, playerId))
|
|
44
|
+
.all();
|
|
45
|
+
|
|
46
|
+
player.inventory = [];
|
|
47
|
+
for (const row of savedInventory) {
|
|
48
|
+
const itemRow = db.select().from(itemsTable).where(eq(itemsTable.id, row.itemId)).get();
|
|
49
|
+
if (itemRow) {
|
|
50
|
+
player.inventory.push({
|
|
51
|
+
id: itemRow.id,
|
|
52
|
+
name: itemRow.name,
|
|
53
|
+
rarity: itemRow.rarity as 'common' | 'rare' | 'unique',
|
|
54
|
+
statBonus: JSON.parse(itemRow.statBonusJson),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Load equipped items
|
|
60
|
+
const savedEquipped = db
|
|
61
|
+
.select()
|
|
62
|
+
.from(equippedTable)
|
|
63
|
+
.where(eq(equippedTable.playerId, playerId))
|
|
64
|
+
.all();
|
|
65
|
+
|
|
66
|
+
player.equipped = {};
|
|
67
|
+
for (const row of savedEquipped) {
|
|
68
|
+
const itemRow = db.select().from(itemsTable).where(eq(itemsTable.id, row.itemId)).get();
|
|
69
|
+
if (itemRow) {
|
|
70
|
+
player.equipped[row.slotId] = {
|
|
71
|
+
id: itemRow.id,
|
|
72
|
+
name: itemRow.name,
|
|
73
|
+
rarity: itemRow.rarity as 'common' | 'rare' | 'unique',
|
|
74
|
+
statBonus: JSON.parse(itemRow.statBonusJson),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
console.log(`[db] Loaded player ${playerId} (hp:${player.hp}, items:${player.inventory.length}, equipped:${Object.keys(player.equipped).length})`);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Persist player state to DB on disconnect.
|
|
85
|
+
*/
|
|
86
|
+
export function savePlayerState(db: Db, player: PlayerState): void {
|
|
87
|
+
const pid = player.playerId;
|
|
88
|
+
|
|
89
|
+
db.insert(playersTable).values({
|
|
90
|
+
id: pid,
|
|
91
|
+
name: `Player_${player.entityId}`,
|
|
92
|
+
hp: player.hp,
|
|
93
|
+
x: player.x,
|
|
94
|
+
y: player.y,
|
|
95
|
+
}).onConflictDoUpdate({
|
|
96
|
+
target: playersTable.id,
|
|
97
|
+
set: { hp: player.hp, x: player.x, y: player.y },
|
|
98
|
+
}).run();
|
|
99
|
+
|
|
100
|
+
db.delete(inventoriesTable).where(eq(inventoriesTable.playerId, pid)).run();
|
|
101
|
+
player.inventory.forEach((item, slot) => {
|
|
102
|
+
db.insert(itemsTable).values({
|
|
103
|
+
id: item.id,
|
|
104
|
+
name: item.name,
|
|
105
|
+
rarity: item.rarity,
|
|
106
|
+
statBonusJson: JSON.stringify(item.statBonus),
|
|
107
|
+
}).onConflictDoNothing().run();
|
|
108
|
+
|
|
109
|
+
db.insert(inventoriesTable).values({
|
|
110
|
+
playerId: pid,
|
|
111
|
+
itemId: item.id,
|
|
112
|
+
slot,
|
|
113
|
+
}).run();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Persist equipped items
|
|
117
|
+
db.delete(equippedTable).where(eq(equippedTable.playerId, pid)).run();
|
|
118
|
+
for (const [slotId, item] of Object.entries(player.equipped)) {
|
|
119
|
+
if (!item) continue;
|
|
120
|
+
db.insert(itemsTable).values({
|
|
121
|
+
id: item.id,
|
|
122
|
+
name: item.name,
|
|
123
|
+
rarity: item.rarity,
|
|
124
|
+
statBonusJson: JSON.stringify(item.statBonus),
|
|
125
|
+
}).onConflictDoNothing().run();
|
|
126
|
+
|
|
127
|
+
db.insert(equippedTable).values({
|
|
128
|
+
playerId: pid,
|
|
129
|
+
slotId,
|
|
130
|
+
itemId: item.id,
|
|
131
|
+
}).run();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
console.log(`[db] Saved player ${pid} (hp:${player.hp}, items:${player.inventory.length}, equipped:${Object.keys(player.equipped).length})`);
|
|
135
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { loadRooms as genericLoadRooms } from '@loonylabs/gamedev-server';
|
|
2
|
+
import { resolve, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import type { RoomDef } from '@loonylabs/gamedev-core';
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const ROOMS_DIR = resolve(__dirname, '../../game-data/src/rooms');
|
|
8
|
+
|
|
9
|
+
const ROOM_FILES = [
|
|
10
|
+
'room_start.json',
|
|
11
|
+
'room_corridor_ew.json',
|
|
12
|
+
'room_corridor_ns.json',
|
|
13
|
+
'room_crossroads.json',
|
|
14
|
+
'room_dead_end.json',
|
|
15
|
+
'room_staircase.json',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export function loadRooms(): RoomDef[] {
|
|
19
|
+
return genericLoadRooms(ROOMS_DIR, ROOM_FILES);
|
|
20
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest';
|
|
2
|
+
import { getDungeonGroundY } from './dungeonPhysics.js';
|
|
3
|
+
import { DUNGEON_WALL_Y } from '../../../game-data/src/physics/dungeon-physics-config.js';
|
|
4
|
+
import { GameSession } from '@loonylabs/gamedev-server';
|
|
5
|
+
import { loadRooms } from '../rooms.js';
|
|
6
|
+
|
|
7
|
+
describe('getDungeonGroundY', () => {
|
|
8
|
+
const rooms = loadRooms();
|
|
9
|
+
const session = new GameSession('test-dungeon', rooms, 42);
|
|
10
|
+
|
|
11
|
+
test('returns cell height for floor/spawn cells', () => {
|
|
12
|
+
const spawn = session.state.dungeon.spawnPosition;
|
|
13
|
+
const y = getDungeonGroundY(session, spawn.x, spawn.y);
|
|
14
|
+
// Spawn cell height matches grid cell.height (typically 0)
|
|
15
|
+
const cell = session.getCell(Math.round(spawn.x), Math.round(spawn.y));
|
|
16
|
+
expect(y).toBe(cell?.height ?? 0);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('returns wall height for wall cells', () => {
|
|
20
|
+
const y = getDungeonGroundY(session, 0, 0);
|
|
21
|
+
expect(y).toBe(DUNGEON_WALL_Y);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('returns wall height for out of bounds', () => {
|
|
25
|
+
const y = getDungeonGroundY(session, -100, -100);
|
|
26
|
+
expect(y).toBe(DUNGEON_WALL_Y);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('wall height is significantly higher than floor', () => {
|
|
30
|
+
expect(DUNGEON_WALL_Y).toBeGreaterThanOrEqual(5);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { GameSession } from '@loonylabs/gamedev-server';
|
|
2
|
+
import { DUNGEON_WALL_Y } from '../../../game-data/src/physics/dungeon-physics-config.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Ground height lookup for dungeon grid.
|
|
6
|
+
* Floor/spawn cells -> cell.height (matches mesh geometry), walls -> tall barrier, OOB -> wall.
|
|
7
|
+
*/
|
|
8
|
+
export function getDungeonGroundY(session: GameSession, worldX: number, worldZ: number): number {
|
|
9
|
+
const cx = Math.round(worldX);
|
|
10
|
+
const cz = Math.round(worldZ);
|
|
11
|
+
const cell = session.getCell(cx, cz);
|
|
12
|
+
|
|
13
|
+
if (!cell) return DUNGEON_WALL_Y;
|
|
14
|
+
if (cell.type === 'wall' || cell.type === 'obstacle') return DUNGEON_WALL_Y;
|
|
15
|
+
return cell.height ?? 0;
|
|
16
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { findPath, meleeAttack, aggregateStats } from '@loonylabs/gamedev-core';
|
|
2
|
+
import type { GameSession, EnemyState, PlayerState } from '../gameState.js';
|
|
3
|
+
|
|
4
|
+
const GRUNT_SPEED = 3; // cells/second
|
|
5
|
+
const ATTACK_RANGE = 2; // cells
|
|
6
|
+
const ATTACK_RETURN_RANGE = 4; // cells — return to patrol beyond this
|
|
7
|
+
const ATTACK_INTERVAL = 1.5; // seconds between attacks
|
|
8
|
+
const GRUNT_DAMAGE = 10;
|
|
9
|
+
|
|
10
|
+
function dist(ax: number, ay: number, bx: number, by: number): number {
|
|
11
|
+
const dx = ax - bx;
|
|
12
|
+
const dy = ay - by;
|
|
13
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function updateEnemyAI(session: GameSession, dt: number): void {
|
|
17
|
+
// In voxel overworld (skipWalkCheck), use direct movement instead of grid pathfinding
|
|
18
|
+
const usePathfinding = !session.skipWalkCheck;
|
|
19
|
+
|
|
20
|
+
for (const enemy of session.state.enemies) {
|
|
21
|
+
if (!enemy.alive) continue;
|
|
22
|
+
|
|
23
|
+
// Find nearest player
|
|
24
|
+
let nearestPlayer: PlayerState | null = null;
|
|
25
|
+
let nearestDist = Infinity;
|
|
26
|
+
for (const player of session.state.players.values()) {
|
|
27
|
+
const d = dist(enemy.x, enemy.y, player.x, player.y);
|
|
28
|
+
if (d < nearestDist) {
|
|
29
|
+
nearestDist = d;
|
|
30
|
+
nearestPlayer = player;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (enemy.aiState === 'idle') continue;
|
|
35
|
+
|
|
36
|
+
// In grid-based areas, enemies use pathfinding. In voxel areas (skipWalkCheck),
|
|
37
|
+
// enemies use direct waypoint movement — no basecamp safe zone check needed
|
|
38
|
+
// since enemies are only spawned on wilderness tiles.
|
|
39
|
+
|
|
40
|
+
if (nearestPlayer && nearestDist <= ATTACK_RANGE) {
|
|
41
|
+
// Attack state
|
|
42
|
+
enemy.aiState = 'attack';
|
|
43
|
+
enemy.path = [];
|
|
44
|
+
|
|
45
|
+
enemy.attackCooldown -= dt;
|
|
46
|
+
if (enemy.attackCooldown <= 0) {
|
|
47
|
+
enemy.attackCooldown = ATTACK_INTERVAL;
|
|
48
|
+
const results = meleeAttack(
|
|
49
|
+
{ x: enemy.x, y: enemy.y },
|
|
50
|
+
[{ entityId: nearestPlayer.entityId, x: nearestPlayer.x, y: nearestPlayer.y, health: nearestPlayer.hp }],
|
|
51
|
+
ATTACK_RANGE,
|
|
52
|
+
GRUNT_DAMAGE,
|
|
53
|
+
);
|
|
54
|
+
const playerStats = aggregateStats(nearestPlayer.equipped);
|
|
55
|
+
for (const result of results) {
|
|
56
|
+
if (result.hit) {
|
|
57
|
+
const actualDamage = Math.max(1, result.damage - (playerStats.defense ?? 0));
|
|
58
|
+
nearestPlayer.hp -= actualDamage;
|
|
59
|
+
console.log(`[AI] Enemy ${enemy.entityId} (${enemy.enemyDefId}) attacks player ${result.targetId} for ${actualDamage} dmg (hp: ${nearestPlayer.hp})`);
|
|
60
|
+
if (nearestPlayer.hp <= 0) {
|
|
61
|
+
// Respawn player at spawn with 50% HP
|
|
62
|
+
nearestPlayer.hp = Math.ceil(nearestPlayer.maxHp / 2);
|
|
63
|
+
nearestPlayer.x = session.state.dungeon.spawnPosition.x;
|
|
64
|
+
nearestPlayer.y = session.state.dungeon.spawnPosition.y;
|
|
65
|
+
nearestPlayer.path = [];
|
|
66
|
+
console.log(`[AI] Player ${result.targetId} died — respawning at spawn`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} else if (nearestPlayer && nearestDist <= ATTACK_RETURN_RANGE && enemy.aiState === 'attack') {
|
|
72
|
+
// Stay in attack state, move toward player
|
|
73
|
+
if (enemy.path.length === 0) {
|
|
74
|
+
if (usePathfinding) {
|
|
75
|
+
const path = findPath(session.state.dungeon, { x: enemy.x, y: enemy.y }, { x: nearestPlayer.x, y: nearestPlayer.y });
|
|
76
|
+
if (path && path.length > 0) enemy.path = path;
|
|
77
|
+
} else {
|
|
78
|
+
// Direct movement toward player
|
|
79
|
+
enemy.path = [{ x: nearestPlayer.x, y: nearestPlayer.y }];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
moveAlongPath(enemy, dt, GRUNT_SPEED);
|
|
83
|
+
} else {
|
|
84
|
+
// Patrol state
|
|
85
|
+
if (enemy.aiState === 'attack' && nearestDist > ATTACK_RETURN_RANGE) {
|
|
86
|
+
enemy.aiState = 'patrol';
|
|
87
|
+
enemy.path = [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (enemy.path.length === 0) {
|
|
91
|
+
const target = enemy.aiState === 'patrol'
|
|
92
|
+
? enemy.waypointIndex === 0 ? enemy.waypoints[1] : enemy.waypoints[0]
|
|
93
|
+
: enemy.waypoints[enemy.waypointIndex];
|
|
94
|
+
enemy.waypointIndex = enemy.waypointIndex === 0 ? 1 : 0;
|
|
95
|
+
if (usePathfinding) {
|
|
96
|
+
const path = findPath(session.state.dungeon, { x: enemy.x, y: enemy.y }, target);
|
|
97
|
+
if (path && path.length > 0) enemy.path = path;
|
|
98
|
+
} else {
|
|
99
|
+
enemy.path = [target];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
moveAlongPath(enemy, dt, GRUNT_SPEED);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function moveAlongPath(entity: { x: number; y: number; path: Array<{ x: number; y: number }> }, dt: number, speed: number): void {
|
|
109
|
+
let remaining = speed * dt;
|
|
110
|
+
|
|
111
|
+
while (remaining > 0 && entity.path.length > 0) {
|
|
112
|
+
const next = entity.path[0];
|
|
113
|
+
const dx = next.x - entity.x;
|
|
114
|
+
const dy = next.y - entity.y;
|
|
115
|
+
const d = Math.sqrt(dx * dx + dy * dy);
|
|
116
|
+
|
|
117
|
+
if (d <= remaining) {
|
|
118
|
+
entity.x = next.x;
|
|
119
|
+
entity.y = next.y;
|
|
120
|
+
entity.path.shift();
|
|
121
|
+
remaining -= d;
|
|
122
|
+
} else {
|
|
123
|
+
const ratio = remaining / d;
|
|
124
|
+
entity.x += dx * ratio;
|
|
125
|
+
entity.y += dy * ratio;
|
|
126
|
+
remaining = 0;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { GameSession, WorldItem } from '../gameState.js';
|
|
2
|
+
|
|
3
|
+
const PICKUP_RANGE = 0.5; // cells
|
|
4
|
+
|
|
5
|
+
function dist(ax: number, ay: number, bx: number, by: number): number {
|
|
6
|
+
const dx = ax - bx;
|
|
7
|
+
const dy = ay - by;
|
|
8
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function updateItemPickup(session: GameSession): void {
|
|
12
|
+
for (const player of session.state.players.values()) {
|
|
13
|
+
const toRemove: WorldItem[] = [];
|
|
14
|
+
|
|
15
|
+
for (const worldItem of session.state.worldItems) {
|
|
16
|
+
if (dist(player.x, player.y, worldItem.x, worldItem.y) <= PICKUP_RANGE) {
|
|
17
|
+
toRemove.push(worldItem);
|
|
18
|
+
player.inventory.push(worldItem.item);
|
|
19
|
+
console.log(`[pickup] Player ${player.entityId} picked up "${worldItem.item.name}"`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (toRemove.length > 0) {
|
|
24
|
+
for (const item of toRemove) {
|
|
25
|
+
const idx = session.state.worldItems.indexOf(item);
|
|
26
|
+
if (idx >= 0) session.state.worldItems.splice(idx, 1);
|
|
27
|
+
}
|
|
28
|
+
session.onItemPickup?.(player.socketId, player.inventory);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { AreaManager } from '../areaManager.js';
|
|
3
|
+
import { loadRooms } from '../rooms.js';
|
|
4
|
+
import { TILE_SIZE } from '../../../game-data/src/world/overworld-layout.js';
|
|
5
|
+
|
|
6
|
+
describe('AreaManager', () => {
|
|
7
|
+
const rooms = loadRooms();
|
|
8
|
+
const manager = new AreaManager(rooms, 42);
|
|
9
|
+
|
|
10
|
+
it('creates overworld area with voxel type', () => {
|
|
11
|
+
const ow = manager.getAreaSession('overworld');
|
|
12
|
+
expect(ow).toBeDefined();
|
|
13
|
+
expect(ow!.areaType).toBe('voxel');
|
|
14
|
+
expect(ow!.chunkCache).toBeDefined();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('does not create old ow1/ow2/sandbox areas', () => {
|
|
18
|
+
expect(manager.getAreaSession('ow1')).toBeUndefined();
|
|
19
|
+
expect(manager.getAreaSession('ow2')).toBeUndefined();
|
|
20
|
+
expect(manager.getAreaSession('voxel-sandbox')).toBeUndefined();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('creates dungeon area', () => {
|
|
24
|
+
const dungeon = manager.getAreaSession('dungeon');
|
|
25
|
+
expect(dungeon).toBeDefined();
|
|
26
|
+
expect(dungeon!.areaType).toBe('dungeon');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('overworld has spawn position in basecamp area', () => {
|
|
30
|
+
const session = manager.getSession('overworld')!;
|
|
31
|
+
const spawn = session.state.dungeon.spawnPosition;
|
|
32
|
+
expect(spawn).toBeDefined();
|
|
33
|
+
// Basecamp tiles are at cols 1-2, row 1 in the layout
|
|
34
|
+
// Center should be around (1.5+0.5)*32 = 64, (1+0.5)*32 = 48
|
|
35
|
+
expect(spawn.x).toBeGreaterThan(0);
|
|
36
|
+
expect(spawn.y).toBeGreaterThan(0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('addPlayerToArea registers player in overworld', () => {
|
|
40
|
+
const player = manager.addPlayerToArea('socket-1', 'player-1', 'overworld');
|
|
41
|
+
expect(player).toBeDefined();
|
|
42
|
+
expect(player.socketId).toBe('socket-1');
|
|
43
|
+
expect(manager.getPlayerArea('socket-1')).toBe('overworld');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('removePlayerFromArea cleans up', () => {
|
|
47
|
+
manager.addPlayerToArea('socket-2', 'player-2', 'dungeon');
|
|
48
|
+
expect(manager.getPlayerArea('socket-2')).toBe('dungeon');
|
|
49
|
+
manager.removePlayerFromArea('socket-2');
|
|
50
|
+
expect(manager.getPlayerArea('socket-2')).toBe('overworld'); // default fallback
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('spawns enemies only on wilderness tiles', () => {
|
|
54
|
+
const session = manager.getSession('overworld')!;
|
|
55
|
+
const layout = manager.layout;
|
|
56
|
+
expect(session.state.enemies.length).toBeGreaterThan(0);
|
|
57
|
+
|
|
58
|
+
for (const enemy of session.state.enemies) {
|
|
59
|
+
const col = Math.floor(enemy.x / TILE_SIZE);
|
|
60
|
+
const row = Math.floor(enemy.y / TILE_SIZE);
|
|
61
|
+
const tile = layout.tiles[row]?.[col];
|
|
62
|
+
expect(tile).toBeDefined();
|
|
63
|
+
expect(tile.def.spawnsMobs).toBe(true);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('dungeon session spawns enemies', () => {
|
|
68
|
+
const session = manager.getSession('dungeon')!;
|
|
69
|
+
expect(Array.isArray(session.state.enemies)).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('getDungeonEntrancePos returns valid position', () => {
|
|
73
|
+
const pos = manager.getDungeonEntrancePos();
|
|
74
|
+
expect(pos.x).toBeGreaterThan(0);
|
|
75
|
+
expect(pos.z).toBeGreaterThan(0);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest';
|
|
2
|
+
import { DiabloExperience, createDiabloAreaSystems } from '../../../experiences/diablo/index.js';
|
|
3
|
+
|
|
4
|
+
describe('DiabloExperience', () => {
|
|
5
|
+
test('implements ExperienceDefinition with correct metadata', () => {
|
|
6
|
+
const exp = new DiabloExperience();
|
|
7
|
+
expect(exp.id).toBe('diablo');
|
|
8
|
+
expect(exp.name).toContain('Diablo');
|
|
9
|
+
expect(exp.description).toBeTruthy();
|
|
10
|
+
expect(exp.defaultCameraMode).toBe('orbit');
|
|
11
|
+
expect(exp.physicsConfig).toBeDefined();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('createSystems returns empty (systems created per-area)', () => {
|
|
15
|
+
const exp = new DiabloExperience();
|
|
16
|
+
expect(exp.createSystems()).toHaveLength(0);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('createDiabloAreaSystems', () => {
|
|
21
|
+
test('creates 7 systems for a dungeon area', () => {
|
|
22
|
+
// Minimal mock area session
|
|
23
|
+
const mockAreaSession = { session: {}, areaType: 'dungeon' as const };
|
|
24
|
+
const mockAreaManager = { layout: { tiles: [] } } as any;
|
|
25
|
+
const systems = createDiabloAreaSystems({
|
|
26
|
+
areaId: 'dungeon',
|
|
27
|
+
areaSession: mockAreaSession as any,
|
|
28
|
+
areaManager: mockAreaManager,
|
|
29
|
+
physicsConfig: { gravity: -20, terminalVelocity: -50, groundSnapThreshold: 0.3 },
|
|
30
|
+
jumpConfig: { jumpVelocity: 10, coyoteTime: 0.1, jumpBuffer: 0.1, allowDoubleJump: false },
|
|
31
|
+
transitions: [],
|
|
32
|
+
});
|
|
33
|
+
expect(systems).toHaveLength(7);
|
|
34
|
+
const ids = systems.map(s => s.id);
|
|
35
|
+
expect(new Set(ids).size).toBe(ids.length); // all unique
|
|
36
|
+
expect(ids).toContain('diablo-movement');
|
|
37
|
+
expect(ids).toContain('diablo-enemy-ai');
|
|
38
|
+
expect(ids).toContain('diablo-item-pickup');
|
|
39
|
+
expect(ids).toContain('diablo-physics');
|
|
40
|
+
expect(ids).toContain('diablo-transition');
|
|
41
|
+
expect(ids).toContain('diablo-dungeon-clear');
|
|
42
|
+
expect(ids).toContain('diablo-entity-sync');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('systems have unique ids for voxel area too', () => {
|
|
46
|
+
const mockAreaSession = { session: {}, areaType: 'voxel' as const };
|
|
47
|
+
const mockAreaManager = { layout: { tiles: [] } } as any;
|
|
48
|
+
const systems = createDiabloAreaSystems({
|
|
49
|
+
areaId: 'overworld',
|
|
50
|
+
areaSession: mockAreaSession as any,
|
|
51
|
+
areaManager: mockAreaManager,
|
|
52
|
+
physicsConfig: { gravity: -20, terminalVelocity: -50, groundSnapThreshold: 0.5 },
|
|
53
|
+
jumpConfig: { jumpVelocity: 10, coyoteTime: 0.1, jumpBuffer: 0.1, allowDoubleJump: false },
|
|
54
|
+
transitions: [{ tileType: 'D', targetArea: 'dungeon', spawnX: -1, spawnY: -1 }],
|
|
55
|
+
});
|
|
56
|
+
expect(systems).toHaveLength(7);
|
|
57
|
+
const ids = systems.map(s => s.id);
|
|
58
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
59
|
+
});
|
|
60
|
+
});
|