Use rot-js for scheduling & path finding

This commit is contained in:
Peter Stockings
2026-01-05 15:41:27 +11:00
parent 50a922ca85
commit 43d5dce2e5
9 changed files with 237 additions and 174 deletions

View File

@@ -114,7 +114,6 @@ export interface CombatantActor extends BaseActor {
isPlayer: boolean;
type: ActorType;
speed: number;
energy: number;
stats: Stats;
inventory?: Inventory;
equipment?: Equipment;

View File

@@ -14,7 +14,6 @@ describe('ProgressionManager', () => {
isPlayer: true,
pos: { x: 0, y: 0 },
speed: 100,
energy: 0,
stats: {
maxHp: 20,
hp: 20,

View File

@@ -27,10 +27,10 @@ describe('Combat Simulation', () => {
it('should deal damage when player attacks enemy', () => {
const actors = new Map<EntityId, Actor>();
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<EntityId, Actor>();
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);

View File

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

View File

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

View File

@@ -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<number>();
// 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>): 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<EntityId, Actor>, random: () => number): void {
@@ -205,7 +305,6 @@ function placeEnemies(floor: number, rooms: Room[], actors: Map<EntityId, Actor>
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),

View File

@@ -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<string, string>();
// 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<string, number>();
const fScore = new Map<string, number>();
// 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<string>([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;
}

View File

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

View File

@@ -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: [] },
};