Use rot-js for scheduling & path finding
This commit is contained in:
@@ -114,7 +114,6 @@ export interface CombatantActor extends BaseActor {
|
|||||||
isPlayer: boolean;
|
isPlayer: boolean;
|
||||||
type: ActorType;
|
type: ActorType;
|
||||||
speed: number;
|
speed: number;
|
||||||
energy: number;
|
|
||||||
stats: Stats;
|
stats: Stats;
|
||||||
inventory?: Inventory;
|
inventory?: Inventory;
|
||||||
equipment?: Equipment;
|
equipment?: Equipment;
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ describe('ProgressionManager', () => {
|
|||||||
isPlayer: true,
|
isPlayer: true,
|
||||||
pos: { x: 0, y: 0 },
|
pos: { x: 0, y: 0 },
|
||||||
speed: 100,
|
speed: 100,
|
||||||
energy: 0,
|
|
||||||
stats: {
|
stats: {
|
||||||
maxHp: 20,
|
maxHp: 20,
|
||||||
hp: 20,
|
hp: 20,
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ describe('Combat Simulation', () => {
|
|||||||
it('should deal damage when player attacks enemy', () => {
|
it('should deal damage when player attacks enemy', () => {
|
||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
actors.set(1, {
|
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);
|
} as any);
|
||||||
actors.set(2, {
|
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);
|
} as any);
|
||||||
|
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld(actors);
|
||||||
@@ -45,10 +45,10 @@ describe('Combat Simulation', () => {
|
|||||||
it("should kill enemy and spawn EXP orb without ID reuse collision", () => {
|
it("should kill enemy and spawn EXP orb without ID reuse collision", () => {
|
||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
actors.set(1, {
|
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);
|
} as any);
|
||||||
actors.set(2, {
|
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);
|
} as any);
|
||||||
|
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld(actors);
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ describe('World Utilities', () => {
|
|||||||
type: "player",
|
type: "player",
|
||||||
pos: { x: 3, y: 3 },
|
pos: { x: 3, y: 3 },
|
||||||
speed: 100,
|
speed: 100,
|
||||||
energy: 0,
|
|
||||||
stats: { hp: 10, maxHp: 10, attack: 1, defense: 0 } as any
|
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 { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||||
import { type EntityManager } from "../EntityManager";
|
import { type EntityManager } from "../EntityManager";
|
||||||
import { FOV } from "rot-js";
|
import { FOV } from "rot-js";
|
||||||
|
import * as ROT from "rot-js";
|
||||||
|
|
||||||
|
|
||||||
export function applyAction(w: World, actorId: EntityId, action: Action, em?: EntityManager): SimEvent[] {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spend energy for any action (move/wait/attack)
|
// Note: Energy is now managed by ROT.Scheduler, no need to deduct manually
|
||||||
if (actor.category === "combatant") {
|
|
||||||
actor.energy -= GAME_CONFIG.gameplay.actionCost;
|
|
||||||
}
|
|
||||||
|
|
||||||
return events;
|
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.
|
* Returns enemy events accumulated along the way.
|
||||||
*/
|
*/
|
||||||
export function stepUntilPlayerTurn(w: World, playerId: EntityId, em?: EntityManager): { awaitingPlayerId: EntityId; events: SimEvent[] } {
|
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[] = [];
|
const events: SimEvent[] = [];
|
||||||
|
|
||||||
|
// 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) {
|
while (true) {
|
||||||
while (![...w.actors.values()].some(a => a.category === "combatant" && a.energy >= GAME_CONFIG.gameplay.energyThreshold)) {
|
// Get next actor from scheduler
|
||||||
for (const a of w.actors.values()) {
|
const actor = scheduler.next() as CombatantActor | null;
|
||||||
if (a.category === "combatant") {
|
|
||||||
a.energy += a.speed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ready = [...w.actors.values()].filter(a =>
|
if (!actor || !w.actors.has(actor.id)) {
|
||||||
a.category === "combatant" && a.energy >= GAME_CONFIG.gameplay.energyThreshold
|
// Actor was removed (died), continue to next
|
||||||
) as CombatantActor[];
|
continue;
|
||||||
|
}
|
||||||
ready.sort((a, b) => (b.energy - a.energy) || (a.id - b.id));
|
|
||||||
const actor = ready[0];
|
|
||||||
|
|
||||||
if (actor.isPlayer) {
|
if (actor.isPlayer) {
|
||||||
|
// Player's turn - return control to the user
|
||||||
return { awaitingPlayerId: actor.id, events };
|
return { awaitingPlayerId: actor.id, events };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enemy turn - decide action and apply it
|
||||||
const decision = decideEnemyAction(w, actor, player, em);
|
const decision = decideEnemyAction(w, actor, player, em);
|
||||||
|
|
||||||
// Emit alert event if enemy just spotted player
|
// 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
|
// Set ROT's RNG seed for consistent dungeon generation
|
||||||
ROT.RNG.setSeed(floor * 12345);
|
ROT.RNG.setSeed(floor * 12345);
|
||||||
|
|
||||||
const rooms = generateRooms(width, height, tiles);
|
const rooms = generateRooms(width, height, tiles, floor);
|
||||||
|
|
||||||
// Place player in first room
|
// Place player in first room
|
||||||
const firstRoom = rooms[0];
|
const firstRoom = rooms[0];
|
||||||
@@ -44,7 +44,6 @@ export function generateWorld(floor: number, runState: RunState): { world: World
|
|||||||
type: "player",
|
type: "player",
|
||||||
pos: { x: playerX, y: playerY },
|
pos: { x: playerX, y: playerY },
|
||||||
speed: GAME_CONFIG.player.speed,
|
speed: GAME_CONFIG.player.speed,
|
||||||
energy: 0,
|
|
||||||
stats: { ...runState.stats },
|
stats: { ...runState.stats },
|
||||||
inventory: { gold: runState.inventory.gold, items: [...runState.inventory.items] }
|
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[] = [];
|
const rooms: Room[] = [];
|
||||||
|
|
||||||
// Create rot-js Uniform dungeon generator
|
// Choose dungeon algorithm based on floor depth
|
||||||
const dungeon = new ROT.Map.Uniform(width, height, {
|
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],
|
roomWidth: [GAME_CONFIG.map.roomMinWidth, GAME_CONFIG.map.roomMaxWidth],
|
||||||
roomHeight: [GAME_CONFIG.map.roomMinHeight, GAME_CONFIG.map.roomMaxHeight],
|
roomHeight: [GAME_CONFIG.map.roomMinHeight, GAME_CONFIG.map.roomMaxHeight],
|
||||||
roomDugPercentage: 0.3, // 30% of the map should be rooms/corridors
|
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
|
// Generate the dungeon
|
||||||
dungeon.create((x, y, value) => {
|
dungeon.create((x: number, y: number, value: number) => {
|
||||||
if (value === 0) {
|
if (value === 0) {
|
||||||
// 0 = floor, 1 = wall
|
// 0 = floor, 1 = wall
|
||||||
tiles[y * width + x] = GAME_CONFIG.terrain.empty;
|
tiles[y * width + x] = GAME_CONFIG.terrain.empty;
|
||||||
@@ -85,8 +108,10 @@ function generateRooms(width: number, height: number, tiles: Tile[]): Room[] {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Extract room information from the generated dungeon
|
// Extract room information from the generated dungeon
|
||||||
const roomData = (dungeon as any).getRooms();
|
const roomData = (dungeon as any).getRooms?.();
|
||||||
|
|
||||||
|
if (roomData && roomData.length > 0) {
|
||||||
|
// Traditional dungeons (Uniform/Digger) have explicit rooms
|
||||||
for (const room of roomData) {
|
for (const room of roomData) {
|
||||||
rooms.push({
|
rooms.push({
|
||||||
x: room.getLeft(),
|
x: room.getLeft(),
|
||||||
@@ -95,25 +120,144 @@ function generateRooms(width: number, height: number, tiles: Tile[]): Room[] {
|
|||||||
height: room.getBottom() - room.getTop() + 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;
|
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 {
|
function decorate(width: number, height: number, tiles: Tile[], random: () => number, exit: Vec2): void {
|
||||||
const world = { width, height };
|
const world = { width, height };
|
||||||
|
|
||||||
// Set exit tile
|
// Set exit tile
|
||||||
tiles[idx(world as any, exit.x, exit.y)] = GAME_CONFIG.terrain.exit;
|
tiles[idx(world as any, exit.x, exit.y)] = GAME_CONFIG.terrain.exit;
|
||||||
|
|
||||||
// Add water patches (similar to PD Sewers)
|
// Use Simplex noise for natural-looking water distribution
|
||||||
const waterMask = generatePatch(width, height, 0.45, 5, random);
|
const waterNoise = new ROT.Noise.Simplex();
|
||||||
for (let i = 0; i < tiles.length; i++) {
|
const decorationNoise = new ROT.Noise.Simplex();
|
||||||
if (tiles[i] === GAME_CONFIG.terrain.empty && waterMask[i]) {
|
|
||||||
|
// 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;
|
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 y = 0; y < height - 1; y++) {
|
||||||
for (let x = 0; x < width; x++) {
|
for (let x = 0; x < width; x++) {
|
||||||
const i = idx(world as any, x, y);
|
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 {
|
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,
|
type,
|
||||||
pos: { x: ex, y: ey },
|
pos: { x: ex, y: ey },
|
||||||
speed: enemyDef.minSpeed + Math.floor(random() * (enemyDef.maxSpeed - enemyDef.minSpeed)),
|
speed: enemyDef.minSpeed + Math.floor(random() * (enemyDef.maxSpeed - enemyDef.minSpeed)),
|
||||||
energy: 0,
|
|
||||||
stats: {
|
stats: {
|
||||||
maxHp: scaledHp + Math.floor(random() * 4),
|
maxHp: scaledHp + Math.floor(random() * 4),
|
||||||
hp: scaledHp + Math.floor(random() * 4),
|
hp: scaledHp + Math.floor(random() * 4),
|
||||||
|
|||||||
@@ -1,104 +1,63 @@
|
|||||||
import type { World, Vec2 } from "../../core/types";
|
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 { inBounds, isWall, isBlocked, idx } from "./world-logic";
|
||||||
import { type EntityManager } from "../EntityManager";
|
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 [].
|
* Returns an array of positions INCLUDING start and end. If no path, returns [].
|
||||||
*
|
*
|
||||||
* Exploration rule:
|
* Exploration rule:
|
||||||
* - You cannot path THROUGH unseen tiles.
|
* - You cannot path THROUGH unseen tiles.
|
||||||
* - You cannot path TO an unseen target tile.
|
* - 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 (!inBounds(w, end.x, end.y)) return [];
|
||||||
if (isWall(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 [];
|
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 [];
|
if (!options.ignoreSeen && seen[idx(w, end.x, end.y)] !== 1) return [];
|
||||||
|
|
||||||
const open: Vec2[] = [start];
|
// Create passable callback for rot-js
|
||||||
const cameFrom = new Map<string, string>();
|
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>();
|
// Start position is always passable
|
||||||
const fScore = new Map<string, number>();
|
if (x === start.x && y === start.y) return true;
|
||||||
|
|
||||||
const startK = key(start.x, start.y);
|
// Target position is passable (we already validated it above)
|
||||||
gScore.set(startK, 0);
|
if (x === end.x && y === end.y) return true;
|
||||||
fScore.set(startK, manhattan(start, end));
|
|
||||||
|
|
||||||
const inOpen = new Set<string>([startK]);
|
// Check seen requirement
|
||||||
|
if (!options.ignoreSeen && seen[idx(w, x, y)] !== 1) return false;
|
||||||
|
|
||||||
const dirs = [
|
// Check actor blocking
|
||||||
{ x: 1, y: 0 },
|
if (isBlocked(w, x, y, options.em)) return false;
|
||||||
{ x: -1, y: 0 },
|
|
||||||
{ x: 0, y: 1 },
|
|
||||||
{ x: 0, y: -1 }
|
|
||||||
];
|
|
||||||
|
|
||||||
while (open.length > 0) {
|
return true;
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = open.splice(bestIdx, 1)[0];
|
// Use rot-js A* pathfinding with 4-directional topology
|
||||||
const currentK = key(current.x, current.y);
|
const astar = new ROT.Path.AStar(end.x, end.y, passableCallback, { topology: 4 });
|
||||||
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;
|
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 [];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,7 +196,6 @@ describe('DungeonRenderer', () => {
|
|||||||
type: "rat",
|
type: "rat",
|
||||||
pos: { x: 3, y: 1 },
|
pos: { x: 3, y: 1 },
|
||||||
speed: 10,
|
speed: 10,
|
||||||
energy: 0,
|
|
||||||
stats: { hp: 10, maxHp: 10, attack: 2, defense: 0 } as any
|
stats: { hp: 10, maxHp: 10, attack: 2, defense: 0 } as any
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -130,7 +130,6 @@ describe('GameScene', () => {
|
|||||||
isPlayer: true,
|
isPlayer: true,
|
||||||
pos: { x: 1, y: 1 },
|
pos: { x: 1, y: 1 },
|
||||||
speed: 100,
|
speed: 100,
|
||||||
energy: 0,
|
|
||||||
stats: { hp: 10, maxHp: 10, attack: 5, defense: 2 },
|
stats: { hp: 10, maxHp: 10, attack: 5, defense: 2 },
|
||||||
inventory: { gold: 0, items: [] },
|
inventory: { gold: 0, items: [] },
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user