Begin refactoring GameScene

This commit is contained in:
Peter Stockings
2026-01-26 15:30:14 +11:00
parent 1d7be54fd9
commit ef7d85750f
46 changed files with 2459 additions and 1291 deletions

View 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);
}
});
});

View File

@@ -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;
}
}

View File

@@ -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;
};

View File

@@ -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;
}