Use rot-js for scheduling & path finding
This commit is contained in:
@@ -114,7 +114,6 @@ export interface CombatantActor extends BaseActor {
|
||||
isPlayer: boolean;
|
||||
type: ActorType;
|
||||
speed: number;
|
||||
energy: number;
|
||||
stats: Stats;
|
||||
inventory?: Inventory;
|
||||
equipment?: Equipment;
|
||||
|
||||
@@ -14,7 +14,6 @@ describe('ProgressionManager', () => {
|
||||
isPlayer: true,
|
||||
pos: { x: 0, y: 0 },
|
||||
speed: 100,
|
||||
energy: 0,
|
||||
stats: {
|
||||
maxHp: 20,
|
||||
hp: 20,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
const startK = key(start.x, start.y);
|
||||
gScore.set(startK, 0);
|
||||
fScore.set(startK, manhattan(start, end));
|
||||
// Target position is passable (we already validated it above)
|
||||
if (x === end.x && y === end.y) return true;
|
||||
|
||||
const inOpen = new Set<string>([startK]);
|
||||
// Check seen requirement
|
||||
if (!options.ignoreSeen && seen[idx(w, x, y)] !== 1) return false;
|
||||
|
||||
const dirs = [
|
||||
{ x: 1, y: 0 },
|
||||
{ x: -1, y: 0 },
|
||||
{ x: 0, y: 1 },
|
||||
{ x: 0, y: -1 }
|
||||
];
|
||||
// Check actor blocking
|
||||
if (isBlocked(w, x, y, options.em)) return false;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const current = open.splice(bestIdx, 1)[0];
|
||||
const currentK = key(current.x, current.y);
|
||||
inOpen.delete(currentK);
|
||||
// Use rot-js A* pathfinding with 4-directional topology
|
||||
const astar = new ROT.Path.AStar(end.x, end.y, passableCallback, { topology: 4 });
|
||||
|
||||
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;
|
||||
}
|
||||
const path: Vec2[] = [];
|
||||
|
||||
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;
|
||||
// Compute path from start to end
|
||||
astar.compute(start.x, start.y, (x: number, y: number) => {
|
||||
path.push({ x, y });
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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: [] },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user