Begin refactoring GameScene
This commit is contained in:
41
src/engine/world/__tests__/DebuggingStack.test.ts
Normal file
41
src/engine/world/__tests__/DebuggingStack.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { generateWorld } from '../generator';
|
||||
import { GAME_CONFIG } from '../../../core/config/GameConfig';
|
||||
|
||||
describe('World Generator Stacking Debug', () => {
|
||||
it('should not spawn multiple enemies on the same tile', () => {
|
||||
const runState = {
|
||||
stats: { ...GAME_CONFIG.player.initialStats },
|
||||
inventory: { gold: 0, items: [] }
|
||||
};
|
||||
|
||||
// Run multiple times to catch sporadic rng issues
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const floor = 1 + (i % 10);
|
||||
const { ecsWorld } = generateWorld(floor, runState);
|
||||
|
||||
// Get all enemies
|
||||
const aiEntities = ecsWorld.getEntitiesWith("ai");
|
||||
|
||||
const positions = new Set<string>();
|
||||
const duplicates: string[] = [];
|
||||
|
||||
for (const entityId of aiEntities) {
|
||||
const pos = ecsWorld.getComponent(entityId, "position");
|
||||
if (pos) {
|
||||
const key = `${pos.x},${pos.y}`;
|
||||
if (positions.has(key)) {
|
||||
duplicates.push(key);
|
||||
}
|
||||
positions.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (duplicates.length > 0) {
|
||||
console.error(`Found duplicates on iteration ${i} (floor ${floor}):`, duplicates);
|
||||
}
|
||||
|
||||
expect(duplicates.length).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type World, type EntityId, type RunState, type Tile, type Actor, type Vec2 } from "../../core/types";
|
||||
import { type World, type EntityId, type RunState, type Tile, type Vec2 } from "../../core/types";
|
||||
import { TileType } from "../../core/terrain";
|
||||
import { idx } from "./world-logic";
|
||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||
@@ -13,6 +13,7 @@ import { seededRandom } from "../../core/math";
|
||||
import * as ROT from "rot-js";
|
||||
import { ECSWorld } from "../ecs/World";
|
||||
import { Prefabs } from "../ecs/Prefabs";
|
||||
import { EntityBuilder } from "../ecs/EntityBuilder";
|
||||
|
||||
|
||||
interface Room {
|
||||
@@ -34,6 +35,9 @@ export function generateWorld(floor: number, runState: RunState): { world: World
|
||||
const tiles: Tile[] = new Array(width * height).fill(TileType.WALL);
|
||||
|
||||
const random = seededRandom(floor * 12345);
|
||||
|
||||
// Create ECS World first
|
||||
const ecsWorld = new ECSWorld(); // Starts at ID 1 by default
|
||||
|
||||
// Set ROT's RNG seed for consistent dungeon generation
|
||||
ROT.RNG.setSeed(floor * 12345);
|
||||
@@ -45,35 +49,34 @@ export function generateWorld(floor: number, runState: RunState): { world: World
|
||||
const playerX = firstRoom.x + Math.floor(firstRoom.width / 2);
|
||||
const playerY = firstRoom.y + Math.floor(firstRoom.height / 2);
|
||||
|
||||
const actors = new Map<EntityId, Actor>();
|
||||
const playerId = 1;
|
||||
|
||||
actors.set(playerId, {
|
||||
id: playerId,
|
||||
category: "combatant",
|
||||
isPlayer: true,
|
||||
type: "player",
|
||||
pos: { x: playerX, y: playerY },
|
||||
speed: GAME_CONFIG.player.speed,
|
||||
stats: { ...runState.stats },
|
||||
inventory: {
|
||||
// Create Player Entity in ECS
|
||||
const runInventory = {
|
||||
gold: runState.inventory.gold,
|
||||
items: [
|
||||
...runState.inventory.items,
|
||||
// Add starting items for testing if empty
|
||||
...(runState.inventory.items.length === 0 ? [
|
||||
createConsumable("health_potion", 2),
|
||||
createMeleeWeapon("iron_sword", "sharp"), // Sharp sword variant
|
||||
createMeleeWeapon("iron_sword", "sharp"),
|
||||
createConsumable("throwing_dagger", 3),
|
||||
createRangedWeapon("pistol"),
|
||||
createArmour("leather_armor", "heavy"), // Heavy armour variant
|
||||
createUpgradeScroll(2) // 2 Upgrade scrolls
|
||||
createArmour("leather_armor", "heavy"),
|
||||
createUpgradeScroll(2)
|
||||
] : [])
|
||||
]
|
||||
},
|
||||
energy: 0
|
||||
});
|
||||
};
|
||||
|
||||
const playerId = EntityBuilder.create(ecsWorld)
|
||||
.asPlayer()
|
||||
.withPosition(playerX, playerY)
|
||||
// RunState stats override default player stats
|
||||
.withStats(runState.stats)
|
||||
.withInventory(runInventory)
|
||||
.withEnergy(GAME_CONFIG.player.speed)
|
||||
.build();
|
||||
|
||||
// No more legacy Actors Map
|
||||
|
||||
// Place exit in last room
|
||||
const lastRoom = rooms[rooms.length - 1];
|
||||
const exit: Vec2 = {
|
||||
@@ -81,10 +84,10 @@ export function generateWorld(floor: number, runState: RunState): { world: World
|
||||
y: lastRoom.y + Math.floor(lastRoom.height / 2)
|
||||
};
|
||||
|
||||
placeEnemies(floor, rooms, actors, random);
|
||||
placeEnemies(floor, rooms, ecsWorld, random);
|
||||
|
||||
// Create ECS world and place traps
|
||||
const ecsWorld = new ECSWorld();
|
||||
// Place traps (using same ecsWorld)
|
||||
|
||||
const occupiedPositions = new Set<string>();
|
||||
occupiedPositions.add(`${playerX},${playerY}`); // Don't place traps on player start
|
||||
occupiedPositions.add(`${exit.x},${exit.y}`); // Don't place traps on exit
|
||||
@@ -103,7 +106,7 @@ export function generateWorld(floor: number, runState: RunState): { world: World
|
||||
tiles[playerY * width + playerX] = TileType.EMPTY;
|
||||
|
||||
return {
|
||||
world: { width, height, tiles, actors, exit },
|
||||
world: { width, height, tiles, exit },
|
||||
playerId,
|
||||
ecsWorld
|
||||
};
|
||||
@@ -368,8 +371,7 @@ function decorate(width: number, height: number, tiles: Tile[], random: () => nu
|
||||
}
|
||||
}
|
||||
|
||||
function placeEnemies(floor: number, rooms: Room[], actors: Map<EntityId, Actor>, random: () => number): void {
|
||||
let enemyId = 2;
|
||||
function placeEnemies(floor: number, rooms: Room[], ecsWorld: ECSWorld, random: () => number): void {
|
||||
const numEnemies = GAME_CONFIG.enemyScaling.baseCount + floor * GAME_CONFIG.enemyScaling.baseCountPerFloor;
|
||||
|
||||
const enemyTypes = Object.keys(GAME_CONFIG.enemies);
|
||||
@@ -394,43 +396,23 @@ function placeEnemies(floor: number, rooms: Room[], actors: Map<EntityId, Actor>
|
||||
const scaledHp = enemyDef.baseHp + floor * GAME_CONFIG.enemyScaling.hpPerFloor;
|
||||
const scaledAttack = enemyDef.baseAttack + Math.floor(floor / 2) * GAME_CONFIG.enemyScaling.attackPerTwoFloors;
|
||||
|
||||
actors.set(enemyId, {
|
||||
id: enemyId,
|
||||
category: "combatant",
|
||||
isPlayer: false,
|
||||
type,
|
||||
pos: { x: ex, y: ey },
|
||||
speed: enemyDef.minSpeed + Math.floor(random() * (enemyDef.maxSpeed - enemyDef.minSpeed)),
|
||||
stats: {
|
||||
maxHp: scaledHp + Math.floor(random() * 4),
|
||||
hp: scaledHp + Math.floor(random() * 4),
|
||||
maxMana: 0,
|
||||
mana: 0,
|
||||
attack: scaledAttack + Math.floor(random() * 2),
|
||||
defense: enemyDef.baseDefense,
|
||||
level: 0,
|
||||
exp: 0,
|
||||
expToNextLevel: 0,
|
||||
statPoints: 0,
|
||||
skillPoints: 0,
|
||||
strength: 0,
|
||||
dexterity: 0,
|
||||
intelligence: 0,
|
||||
critChance: 0,
|
||||
critMultiplier: 100,
|
||||
accuracy: 80,
|
||||
lifesteal: 0,
|
||||
evasion: 0,
|
||||
blockChance: 0,
|
||||
luck: 0,
|
||||
passiveNodes: []
|
||||
},
|
||||
energy: 0
|
||||
});
|
||||
const speed = enemyDef.minSpeed + Math.floor(random() * (enemyDef.maxSpeed - enemyDef.minSpeed));
|
||||
|
||||
// Create Enemy in ECS
|
||||
EntityBuilder.create(ecsWorld)
|
||||
.asEnemy(type)
|
||||
.withPosition(ex, ey)
|
||||
.withStats({
|
||||
maxHp: scaledHp + Math.floor(random() * 4),
|
||||
hp: scaledHp + Math.floor(random() * 4),
|
||||
attack: scaledAttack + Math.floor(random() * 2),
|
||||
defense: enemyDef.baseDefense,
|
||||
})
|
||||
.withEnergy(speed) // Configured speed
|
||||
// Note: Other stats like crit/evasion are defaults from EntityBuilder or BaseStats
|
||||
.build();
|
||||
|
||||
occupiedPositions.add(k);
|
||||
enemyId++;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { World, Vec2 } from "../../core/types";
|
||||
import { inBounds, isWall, isBlocked, idx } from "./world-logic";
|
||||
import { type EntityManager } from "../EntityManager";
|
||||
import { type EntityAccessor } from "../EntityAccessor";
|
||||
import * as ROT from "rot-js";
|
||||
|
||||
/**
|
||||
@@ -16,14 +16,14 @@ export function findPathAStar(
|
||||
seen: Uint8Array,
|
||||
start: Vec2,
|
||||
end: Vec2,
|
||||
options: { ignoreBlockedTarget?: boolean; ignoreSeen?: boolean; em?: EntityManager } = {}
|
||||
options: { ignoreBlockedTarget?: boolean; ignoreSeen?: boolean; accessor?: EntityAccessor } = {}
|
||||
): Vec2[] {
|
||||
// Validate target
|
||||
if (!inBounds(w, end.x, end.y)) return [];
|
||||
if (isWall(w, end.x, end.y)) return [];
|
||||
|
||||
// Check if target is blocked (unless ignoring)
|
||||
if (!options.ignoreBlockedTarget && isBlocked(w, end.x, end.y, options.em)) return [];
|
||||
if (!options.ignoreBlockedTarget && isBlocked(w, end.x, end.y, options.accessor)) return [];
|
||||
|
||||
// Check if target is unseen (unless ignoring)
|
||||
if (!options.ignoreSeen && seen[idx(w, end.x, end.y)] !== 1) return [];
|
||||
@@ -44,7 +44,7 @@ export function findPathAStar(
|
||||
if (!options.ignoreSeen && seen[idx(w, x, y)] !== 1) return false;
|
||||
|
||||
// Check actor blocking
|
||||
if (isBlocked(w, x, y, options.em)) return false;
|
||||
if (options.accessor && isBlocked(w, x, y, options.accessor)) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { World, EntityId } from "../../core/types";
|
||||
import type { World } from "../../core/types";
|
||||
import { isBlocking, isDestructible, getDestructionResult } from "../../core/terrain";
|
||||
import { type EntityManager } from "../EntityManager";
|
||||
import { type EntityAccessor } from "../EntityAccessor";
|
||||
|
||||
|
||||
export function inBounds(w: World, x: number, y: number): boolean {
|
||||
@@ -37,26 +37,19 @@ export function tryDestructTile(w: World, x: number, y: number): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isBlocked(w: World, x: number, y: number, em?: EntityManager): boolean {
|
||||
export function isBlocked(w: World, x: number, y: number, accessor: EntityAccessor | undefined): boolean {
|
||||
if (!inBounds(w, x, y)) return true;
|
||||
if (isBlockingTile(w, x, y)) return true;
|
||||
|
||||
if (em) {
|
||||
const actors = em.getActorsAt(x, y);
|
||||
// Only combatants block movement
|
||||
return actors.some(a => a.category === "combatant");
|
||||
}
|
||||
|
||||
for (const a of w.actors.values()) {
|
||||
if (a.pos.x === x && a.pos.y === y && a.category === "combatant") return true;
|
||||
}
|
||||
return false;
|
||||
if (!accessor) return false;
|
||||
const actors = accessor.getActorsAt(x, y);
|
||||
return actors.some(a => a.category === "combatant");
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function isPlayerOnExit(w: World, playerId: EntityId): boolean {
|
||||
const p = w.actors.get(playerId);
|
||||
export function isPlayerOnExit(w: World, accessor: EntityAccessor): boolean {
|
||||
const p = accessor.getPlayer();
|
||||
if (!p) return false;
|
||||
return p.pos.x === w.exit.x && p.pos.y === w.exit.y;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user