From 43d5dce2e5bb4deaddfdba1b5a946604469c2ddf Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Mon, 5 Jan 2026 15:41:27 +1100 Subject: [PATCH] Use rot-js for scheduling & path finding --- src/core/types.ts | 1 - .../__tests__/ProgressionManager.test.ts | 1 - src/engine/__tests__/simulation.test.ts | 8 +- src/engine/__tests__/world.test.ts | 1 - src/engine/simulation/simulation.ts | 46 ++-- src/engine/world/generator.ts | 237 +++++++++++++----- src/engine/world/pathfinding.ts | 115 +++------ .../__tests__/DungeonRenderer.test.ts | 1 - src/scenes/__tests__/GameScene.test.ts | 1 - 9 files changed, 237 insertions(+), 174 deletions(-) diff --git a/src/core/types.ts b/src/core/types.ts index 44832ec..40aaec0 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -114,7 +114,6 @@ export interface CombatantActor extends BaseActor { isPlayer: boolean; type: ActorType; speed: number; - energy: number; stats: Stats; inventory?: Inventory; equipment?: Equipment; diff --git a/src/engine/__tests__/ProgressionManager.test.ts b/src/engine/__tests__/ProgressionManager.test.ts index 7886095..76fec74 100644 --- a/src/engine/__tests__/ProgressionManager.test.ts +++ b/src/engine/__tests__/ProgressionManager.test.ts @@ -14,7 +14,6 @@ describe('ProgressionManager', () => { isPlayer: true, pos: { x: 0, y: 0 }, speed: 100, - energy: 0, stats: { maxHp: 20, hp: 20, diff --git a/src/engine/__tests__/simulation.test.ts b/src/engine/__tests__/simulation.test.ts index 834f158..d01bb00 100644 --- a/src/engine/__tests__/simulation.test.ts +++ b/src/engine/__tests__/simulation.test.ts @@ -27,10 +27,10 @@ describe('Combat Simulation', () => { it('should deal damage when player attacks enemy', () => { const actors = new Map(); actors.set(1, { - id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, energy: 0, stats: createTestStats() + id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats() } as any); actors.set(2, { - id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, energy: 0, stats: createTestStats({ maxHp: 10, hp: 10, attack: 3, defense: 1 }) + id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, stats: createTestStats({ maxHp: 10, hp: 10, attack: 3, defense: 1 }) } as any); const world = createTestWorld(actors); @@ -45,10 +45,10 @@ describe('Combat Simulation', () => { it("should kill enemy and spawn EXP orb without ID reuse collision", () => { const actors = new Map(); actors.set(1, { - id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, energy: 0, stats: createTestStats({ attack: 50 }) + id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats({ attack: 50 }) } as any); actors.set(2, { - id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, energy: 0, stats: createTestStats({ maxHp: 10, hp: 10, attack: 3, defense: 1 }) + id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, stats: createTestStats({ maxHp: 10, hp: 10, attack: 3, defense: 1 }) } as any); const world = createTestWorld(actors); diff --git a/src/engine/__tests__/world.test.ts b/src/engine/__tests__/world.test.ts index 0c92a9d..adb4983 100644 --- a/src/engine/__tests__/world.test.ts +++ b/src/engine/__tests__/world.test.ts @@ -94,7 +94,6 @@ describe('World Utilities', () => { type: "player", pos: { x: 3, y: 3 }, speed: 100, - energy: 0, stats: { hp: 10, maxHp: 10, attack: 1, defense: 0 } as any }); diff --git a/src/engine/simulation/simulation.ts b/src/engine/simulation/simulation.ts index ae653e9..ab4be48 100644 --- a/src/engine/simulation/simulation.ts +++ b/src/engine/simulation/simulation.ts @@ -5,6 +5,7 @@ import { findPathAStar } from "../world/pathfinding"; import { GAME_CONFIG } from "../../core/config/GameConfig"; import { type EntityManager } from "../EntityManager"; import { FOV } from "rot-js"; +import * as ROT from "rot-js"; export function applyAction(w: World, actorId: EntityId, action: Action, em?: EntityManager): SimEvent[] { @@ -26,10 +27,7 @@ export function applyAction(w: World, actorId: EntityId, action: Action, em?: En break; } - // Spend energy for any action (move/wait/attack) - if (actor.category === "combatant") { - actor.energy -= GAME_CONFIG.gameplay.actionCost; - } + // Note: Energy is now managed by ROT.Scheduler, no need to deduct manually return events; } @@ -380,7 +378,7 @@ export function decideEnemyAction(w: World, enemy: CombatantActor, player: Comba } /** - * Energy/speed scheduler: runs until it's the player's turn and the game needs input. + * Speed-based scheduler using rot-js: runs until it's the player's turn and the game needs input. * Returns enemy events accumulated along the way. */ export function stepUntilPlayerTurn(w: World, playerId: EntityId, em?: EntityManager): { awaitingPlayerId: EntityId; events: SimEvent[] } { @@ -389,26 +387,36 @@ export function stepUntilPlayerTurn(w: World, playerId: EntityId, em?: EntityMan const events: SimEvent[] = []; - while (true) { - while (![...w.actors.values()].some(a => a.category === "combatant" && a.energy >= GAME_CONFIG.gameplay.energyThreshold)) { - for (const a of w.actors.values()) { - if (a.category === "combatant") { - a.energy += a.speed; - } + // Create scheduler and add all combatants + const scheduler = new ROT.Scheduler.Speed(); + + for (const actor of w.actors.values()) { + if (actor.category === "combatant") { + // ROT.Scheduler.Speed expects actors to have a getSpeed() method + // Add it dynamically if it doesn't exist + const actorWithGetSpeed = actor as any; + if (!actorWithGetSpeed.getSpeed) { + actorWithGetSpeed.getSpeed = function() { return this.speed; }; } + scheduler.add(actorWithGetSpeed, true); + } + } + + while (true) { + // Get next actor from scheduler + const actor = scheduler.next() as CombatantActor | null; + + if (!actor || !w.actors.has(actor.id)) { + // Actor was removed (died), continue to next + continue; } - const ready = [...w.actors.values()].filter(a => - a.category === "combatant" && a.energy >= GAME_CONFIG.gameplay.energyThreshold - ) as CombatantActor[]; - - ready.sort((a, b) => (b.energy - a.energy) || (a.id - b.id)); - const actor = ready[0]; - if (actor.isPlayer) { + // Player's turn - return control to the user return { awaitingPlayerId: actor.id, events }; } + // Enemy turn - decide action and apply it const decision = decideEnemyAction(w, actor, player, em); // Emit alert event if enemy just spotted player @@ -429,3 +437,5 @@ export function stepUntilPlayerTurn(w: World, playerId: EntityId, em?: EntityMan } } } + + diff --git a/src/engine/world/generator.ts b/src/engine/world/generator.ts index 979f55f..e68c0a6 100644 --- a/src/engine/world/generator.ts +++ b/src/engine/world/generator.ts @@ -27,7 +27,7 @@ export function generateWorld(floor: number, runState: RunState): { world: World // Set ROT's RNG seed for consistent dungeon generation ROT.RNG.setSeed(floor * 12345); - const rooms = generateRooms(width, height, tiles); + const rooms = generateRooms(width, height, tiles, floor); // Place player in first room const firstRoom = rooms[0]; @@ -44,7 +44,6 @@ export function generateWorld(floor: number, runState: RunState): { world: World type: "player", pos: { x: playerX, y: playerY }, speed: GAME_CONFIG.player.speed, - energy: 0, stats: { ...runState.stats }, inventory: { gold: runState.inventory.gold, items: [...runState.inventory.items] } }); @@ -66,18 +65,42 @@ export function generateWorld(floor: number, runState: RunState): { world: World } -function generateRooms(width: number, height: number, tiles: Tile[]): Room[] { +function generateRooms(width: number, height: number, tiles: Tile[], floor: number): Room[] { const rooms: Room[] = []; - // Create rot-js Uniform dungeon generator - const dungeon = new ROT.Map.Uniform(width, height, { - roomWidth: [GAME_CONFIG.map.roomMinWidth, GAME_CONFIG.map.roomMaxWidth], - roomHeight: [GAME_CONFIG.map.roomMinHeight, GAME_CONFIG.map.roomMaxHeight], - roomDugPercentage: 0.3, // 30% of the map should be rooms/corridors - }); + // Choose dungeon algorithm based on floor depth + let dungeon: any; + + if (floor <= 4) { + // Floors 1-4: Uniform (organic, irregular rooms) + dungeon = new ROT.Map.Uniform(width, height, { + roomWidth: [GAME_CONFIG.map.roomMinWidth, GAME_CONFIG.map.roomMaxWidth], + roomHeight: [GAME_CONFIG.map.roomMinHeight, GAME_CONFIG.map.roomMaxHeight], + roomDugPercentage: 0.3, + }); + } else if (floor <= 9) { + // Floors 5-9: Digger (traditional rectangular rooms + corridors) + dungeon = new ROT.Map.Digger(width, height, { + roomWidth: [GAME_CONFIG.map.roomMinWidth, GAME_CONFIG.map.roomMaxWidth], + roomHeight: [GAME_CONFIG.map.roomMinHeight, GAME_CONFIG.map.roomMaxHeight], + corridorLength: [2, 6], + }); + } else { + // Floors 10+: Cellular (natural cave systems) + dungeon = new ROT.Map.Cellular(width, height, { + born: [4, 5, 6, 7, 8], + survive: [2, 3, 4, 5], + }); + + // Cellular needs randomization and smoothing + dungeon.randomize(0.5); + for (let i = 0; i < 4; i++) { + dungeon.create(); + } + } // Generate the dungeon - dungeon.create((x, y, value) => { + dungeon.create((x: number, y: number, value: number) => { if (value === 0) { // 0 = floor, 1 = wall tiles[y * width + x] = GAME_CONFIG.terrain.empty; @@ -85,35 +108,156 @@ function generateRooms(width: number, height: number, tiles: Tile[]): Room[] { }); // Extract room information from the generated dungeon - const roomData = (dungeon as any).getRooms(); + const roomData = (dungeon as any).getRooms?.(); - for (const room of roomData) { - rooms.push({ - x: room.getLeft(), - y: room.getTop(), - width: room.getRight() - room.getLeft() + 1, - height: room.getBottom() - room.getTop() + 1 - }); + if (roomData && roomData.length > 0) { + // Traditional dungeons (Uniform/Digger) have explicit rooms + for (const room of roomData) { + rooms.push({ + x: room.getLeft(), + y: room.getTop(), + width: room.getRight() - room.getLeft() + 1, + height: room.getBottom() - room.getTop() + 1 + }); + } + } else { + // Cellular caves don't have explicit rooms, so find connected floor areas + rooms.push(...extractRoomsFromCave(width, height, tiles)); + } + + // Ensure we have at least 2 rooms for player/exit placement + if (rooms.length < 2) { + // Fallback: create two basic rooms + rooms.push( + { x: 5, y: 5, width: 5, height: 5 }, + { x: width - 10, y: height - 10, width: 5, height: 5 } + ); } return rooms; } +/** + * For cellular/cave maps, find clusters of floor tiles to use as "rooms" + */ +function extractRoomsFromCave(width: number, height: number, tiles: Tile[]): Room[] { + const rooms: Room[] = []; + const visited = new Set(); + + // Find large connected floor areas + for (let y = 1; y < height - 1; y++) { + for (let x = 1; x < width - 1; x++) { + const idx = y * width + x; + if (tiles[idx] === GAME_CONFIG.terrain.empty && !visited.has(idx)) { + const cluster = floodFill(width, height, tiles, x, y, visited); + + // Only consider clusters larger than 20 tiles + if (cluster.length > 20) { + // Create bounding box for this cluster + let minX = width, maxX = 0, minY = height, maxY = 0; + for (const pos of cluster) { + const cx = pos % width; + const cy = Math.floor(pos / width); + minX = Math.min(minX, cx); + maxX = Math.max(maxX, cx); + minY = Math.min(minY, cy); + maxY = Math.max(maxY, cy); + } + + rooms.push({ + x: minX, + y: minY, + width: maxX - minX + 1, + height: maxY - minY + 1 + }); + } + } + } + } + + return rooms; +} + +/** + * Flood fill to find connected floor tiles + */ +function floodFill(width: number, height: number, tiles: Tile[], startX: number, startY: number, visited: Set): number[] { + const cluster: number[] = []; + const queue: number[] = [startY * width + startX]; + + while (queue.length > 0) { + const idx = queue.shift()!; + if (visited.has(idx)) continue; + + visited.add(idx); + cluster.push(idx); + + const x = idx % width; + const y = Math.floor(idx / width); + + // Check 4 directions + const neighbors = [ + { nx: x + 1, ny: y }, + { nx: x - 1, ny: y }, + { nx: x, ny: y + 1 }, + { nx: x, ny: y - 1 }, + ]; + + for (const { nx, ny } of neighbors) { + if (nx >= 0 && nx < width && ny >= 0 && ny < height) { + const nIdx = ny * width + nx; + if (tiles[nIdx] === GAME_CONFIG.terrain.empty && !visited.has(nIdx)) { + queue.push(nIdx); + } + } + } + } + + return cluster; +} + function decorate(width: number, height: number, tiles: Tile[], random: () => number, exit: Vec2): void { const world = { width, height }; // Set exit tile tiles[idx(world as any, exit.x, exit.y)] = GAME_CONFIG.terrain.exit; - // Add water patches (similar to PD Sewers) - const waterMask = generatePatch(width, height, 0.45, 5, random); - for (let i = 0; i < tiles.length; i++) { - if (tiles[i] === GAME_CONFIG.terrain.empty && waterMask[i]) { - tiles[i] = GAME_CONFIG.terrain.water; + // Use Simplex noise for natural-looking water distribution + const waterNoise = new ROT.Noise.Simplex(); + const decorationNoise = new ROT.Noise.Simplex(); + + // Offset noise to get different patterns for water vs decorations + const waterOffset = random() * 1000; + const decorOffset = random() * 1000; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = idx(world as any, x, y); + + if (tiles[i] === GAME_CONFIG.terrain.empty) { + // Water lakes: use noise to create organic shapes + const waterValue = waterNoise.get((x + waterOffset) / 15, (y + waterOffset) / 15); + + // Create water patches where noise is above threshold + if (waterValue > 0.35) { + tiles[i] = GAME_CONFIG.terrain.water; + } else { + // Floor decorations (moss/grass): clustered distribution + const decoValue = decorationNoise.get((x + decorOffset) / 8, (y + decorOffset) / 8); + + // Dense clusters where noise is high + if (decoValue > 0.5) { + tiles[i] = GAME_CONFIG.terrain.emptyDeco; + } else if (decoValue > 0.3 && random() < 0.3) { + // Sparse decorations at medium noise levels + tiles[i] = GAME_CONFIG.terrain.emptyDeco; + } + } + } } } - // Wall decorations + // Wall decorations (algae near water) for (let y = 0; y < height - 1; y++) { for (let x = 0; x < width; x++) { const i = idx(world as any, x, y); @@ -126,50 +270,6 @@ function decorate(width: number, height: number, tiles: Tile[], random: () => nu } } } - - // Floor decorations (moss) - for (let y = 1; y < height - 1; y++) { - for (let x = 1; x < width - 1; x++) { - const i = idx(world as any, x, y); - if (tiles[i] === GAME_CONFIG.terrain.empty) { - let wallCount = 0; - if (tiles[idx(world as any, x + 1, y)] === GAME_CONFIG.terrain.wall) wallCount++; - if (tiles[idx(world as any, x - 1, y)] === GAME_CONFIG.terrain.wall) wallCount++; - if (tiles[idx(world as any, x, y + 1)] === GAME_CONFIG.terrain.wall) wallCount++; - if (tiles[idx(world as any, x, y - 1)] === GAME_CONFIG.terrain.wall) wallCount++; - - if (random() * 16 < wallCount * wallCount) { - tiles[i] = GAME_CONFIG.terrain.emptyDeco; - } - } - } - } -} - -/** - * Simple cellular automata for generating patches of terrain - */ -function generatePatch(width: number, height: number, fillChance: number, iterations: number, random: () => number): boolean[] { - let map = new Array(width * height).fill(false).map(() => random() < fillChance); - - for (let step = 0; step < iterations; step++) { - const nextMap = new Array(width * height).fill(false); - for (let y = 1; y < height - 1; y++) { - for (let x = 1; x < width - 1; x++) { - let neighbors = 0; - for (let dy = -1; dy <= 1; dy++) { - for (let dx = -1; dx <= 1; dx++) { - if (map[(y + dy) * width + (x + dx)]) neighbors++; - } - } - if (neighbors > 4) nextMap[y * width + x] = true; - else if (neighbors < 4) nextMap[y * width + x] = false; - else nextMap[y * width + x] = map[y * width + x]; - } - } - map = nextMap; - } - return map; } function placeEnemies(floor: number, rooms: Room[], actors: Map, random: () => number): void { @@ -205,7 +305,6 @@ function placeEnemies(floor: number, rooms: Room[], actors: Map type, pos: { x: ex, y: ey }, speed: enemyDef.minSpeed + Math.floor(random() * (enemyDef.maxSpeed - enemyDef.minSpeed)), - energy: 0, stats: { maxHp: scaledHp + Math.floor(random() * 4), hp: scaledHp + Math.floor(random() * 4), diff --git a/src/engine/world/pathfinding.ts b/src/engine/world/pathfinding.ts index 1bf10e3..bf4eae2 100644 --- a/src/engine/world/pathfinding.ts +++ b/src/engine/world/pathfinding.ts @@ -1,104 +1,63 @@ import type { World, Vec2 } from "../../core/types"; -import { key } from "../../core/utils"; -import { manhattan } from "../../core/math"; import { inBounds, isWall, isBlocked, idx } from "./world-logic"; import { type EntityManager } from "../EntityManager"; +import * as ROT from "rot-js"; /** - * Simple 4-dir A* pathfinding. + * 4-dir A* pathfinding using rot-js. * Returns an array of positions INCLUDING start and end. If no path, returns []. * * Exploration rule: * - You cannot path THROUGH unseen tiles. * - You cannot path TO an unseen target tile. */ -export function findPathAStar(w: World, seen: Uint8Array, start: Vec2, end: Vec2, options: { ignoreBlockedTarget?: boolean; ignoreSeen?: boolean; em?: EntityManager } = {}): Vec2[] { +export function findPathAStar( + w: World, + seen: Uint8Array, + start: Vec2, + end: Vec2, + options: { ignoreBlockedTarget?: boolean; ignoreSeen?: boolean; em?: EntityManager } = {} +): Vec2[] { + // Validate target if (!inBounds(w, end.x, end.y)) return []; if (isWall(w, end.x, end.y)) return []; - // If not ignoring target block, fail if blocked + // Check if target is blocked (unless ignoring) if (!options.ignoreBlockedTarget && isBlocked(w, end.x, end.y, options.em)) return []; + // Check if target is unseen (unless ignoring) if (!options.ignoreSeen && seen[idx(w, end.x, end.y)] !== 1) return []; - const open: Vec2[] = [start]; - const cameFrom = new Map(); + // Create passable callback for rot-js + const passableCallback = (x: number, y: number): boolean => { + // Out of bounds or wall = not passable + if (!inBounds(w, x, y)) return false; + if (isWall(w, x, y)) return false; - const gScore = new Map(); - const fScore = new Map(); + // Start position is always passable + if (x === start.x && y === start.y) return true; + + // Target position is passable (we already validated it above) + if (x === end.x && y === end.y) return true; - const startK = key(start.x, start.y); - gScore.set(startK, 0); - fScore.set(startK, manhattan(start, end)); + // Check seen requirement + if (!options.ignoreSeen && seen[idx(w, x, y)] !== 1) return false; - const inOpen = new Set([startK]); + // Check actor blocking + if (isBlocked(w, x, y, options.em)) return false; - const dirs = [ - { x: 1, y: 0 }, - { x: -1, y: 0 }, - { x: 0, y: 1 }, - { x: 0, y: -1 } - ]; + return true; + }; - while (open.length > 0) { - // Pick node with lowest fScore - let bestIdx = 0; - let bestF = Infinity; - for (let i = 0; i < open.length; i++) { - const k = key(open[i].x, open[i].y); - const f = fScore.get(k) ?? Infinity; - if (f < bestF) { - bestF = f; - bestIdx = i; - } - } + // Use rot-js A* pathfinding with 4-directional topology + const astar = new ROT.Path.AStar(end.x, end.y, passableCallback, { topology: 4 }); - const current = open.splice(bestIdx, 1)[0]; - const currentK = key(current.x, current.y); - inOpen.delete(currentK); + const path: Vec2[] = []; + + // Compute path from start to end + astar.compute(start.x, start.y, (x: number, y: number) => { + path.push({ x, y }); + }); - if (current.x === end.x && current.y === end.y) { - // Reconstruct path - const path: Vec2[] = [end]; - let k = currentK; - while (cameFrom.has(k)) { - const prevK = cameFrom.get(k)!; - const [px, py] = prevK.split(",").map(Number); - path.push({ x: px, y: py }); - k = prevK; - } - path.reverse(); - return path; - } - - for (const d of dirs) { - const nx = current.x + d.x; - const ny = current.y + d.y; - if (!inBounds(w, nx, ny)) continue; - if (isWall(w, nx, ny)) continue; - - // Exploration rule: cannot path through unseen (except start, or if ignoreSeen is set) - if (!options.ignoreSeen && !(nx === start.x && ny === start.y) && seen[idx(w, nx, ny)] !== 1) continue; - - // Avoid walking through other actors (except standing on start, OR if it is the target and we ignore block) - const isTarget = nx === end.x && ny === end.y; - if (!isTarget && !(nx === start.x && ny === start.y) && isBlocked(w, nx, ny, options.em)) continue; - - const nK = key(nx, ny); - const tentativeG = (gScore.get(currentK) ?? Infinity) + 1; - - if (tentativeG < (gScore.get(nK) ?? Infinity)) { - cameFrom.set(nK, currentK); - gScore.set(nK, tentativeG); - fScore.set(nK, tentativeG + manhattan({ x: nx, y: ny }, end)); - - if (!inOpen.has(nK)) { - open.push({ x: nx, y: ny }); - inOpen.add(nK); - } - } - } - } - - return []; + return path; } diff --git a/src/rendering/__tests__/DungeonRenderer.test.ts b/src/rendering/__tests__/DungeonRenderer.test.ts index fdd19c2..60df105 100644 --- a/src/rendering/__tests__/DungeonRenderer.test.ts +++ b/src/rendering/__tests__/DungeonRenderer.test.ts @@ -196,7 +196,6 @@ describe('DungeonRenderer', () => { type: "rat", pos: { x: 3, y: 1 }, speed: 10, - energy: 0, stats: { hp: 10, maxHp: 10, attack: 2, defense: 0 } as any }); diff --git a/src/scenes/__tests__/GameScene.test.ts b/src/scenes/__tests__/GameScene.test.ts index 7760f37..f74910b 100644 --- a/src/scenes/__tests__/GameScene.test.ts +++ b/src/scenes/__tests__/GameScene.test.ts @@ -130,7 +130,6 @@ describe('GameScene', () => { isPlayer: true, pos: { x: 1, y: 1 }, speed: 100, - energy: 0, stats: { hp: 10, maxHp: 10, attack: 5, defense: 2 }, inventory: { gold: 0, items: [] }, };