Refactor codebase
This commit is contained in:
136
src/engine/__tests__/generator.test.ts
Normal file
136
src/engine/__tests__/generator.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { generateWorld } from '../world/generator';
|
||||
import { isWall, inBounds } from '../world/world-logic';
|
||||
|
||||
describe('World Generator', () => {
|
||||
describe('generateWorld', () => {
|
||||
it('should generate a world with correct dimensions', () => {
|
||||
const runState = {
|
||||
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
|
||||
inventory: { gold: 0, items: [] }
|
||||
};
|
||||
|
||||
const { world } = generateWorld(1, runState);
|
||||
|
||||
expect(world.width).toBe(60);
|
||||
expect(world.height).toBe(40);
|
||||
expect(world.tiles.length).toBe(60 * 40);
|
||||
});
|
||||
|
||||
it('should place player actor', () => {
|
||||
const runState = {
|
||||
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
|
||||
inventory: { gold: 0, items: [] }
|
||||
};
|
||||
|
||||
const { world, playerId } = generateWorld(1, runState);
|
||||
|
||||
expect(playerId).toBe(1);
|
||||
const player = world.actors.get(playerId);
|
||||
expect(player).toBeDefined();
|
||||
expect(player?.isPlayer).toBe(true);
|
||||
expect(player?.stats).toEqual(runState.stats);
|
||||
});
|
||||
|
||||
it('should create walkable rooms', () => {
|
||||
const runState = {
|
||||
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
|
||||
inventory: { gold: 0, items: [] }
|
||||
};
|
||||
|
||||
const { world, playerId } = generateWorld(1, runState);
|
||||
const player = world.actors.get(playerId)!;
|
||||
|
||||
// Player should spawn in a walkable area
|
||||
expect(isWall(world, player.pos.x, player.pos.y)).toBe(false);
|
||||
});
|
||||
|
||||
it('should place exit in valid location', () => {
|
||||
const runState = {
|
||||
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
|
||||
inventory: { gold: 0, items: [] }
|
||||
};
|
||||
|
||||
const { world } = generateWorld(1, runState);
|
||||
|
||||
expect(inBounds(world, world.exit.x, world.exit.y)).toBe(true);
|
||||
// Exit should be on a floor tile
|
||||
expect(isWall(world, world.exit.x, world.exit.y)).toBe(false);
|
||||
});
|
||||
|
||||
it('should create enemies', () => {
|
||||
const runState = {
|
||||
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
|
||||
inventory: { gold: 0, items: [] }
|
||||
};
|
||||
|
||||
const { world } = generateWorld(1, runState);
|
||||
|
||||
// Should have player + enemies
|
||||
expect(world.actors.size).toBeGreaterThan(1);
|
||||
|
||||
// All non-player actors should be enemies
|
||||
const enemies = Array.from(world.actors.values()).filter(a => !a.isPlayer);
|
||||
expect(enemies.length).toBeGreaterThan(0);
|
||||
|
||||
// Enemies should have stats
|
||||
enemies.forEach(enemy => {
|
||||
expect(enemy.stats).toBeDefined();
|
||||
expect(enemy.stats!.hp).toBeGreaterThan(0);
|
||||
expect(enemy.stats!.attack).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate deterministic maps for same level', () => {
|
||||
const runState = {
|
||||
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
|
||||
inventory: { gold: 0, items: [] }
|
||||
};
|
||||
|
||||
const { world: world1, playerId: player1 } = generateWorld(1, runState);
|
||||
const { world: world2, playerId: player2 } = generateWorld(1, runState);
|
||||
|
||||
// Same level should generate identical layouts
|
||||
expect(world1.tiles).toEqual(world2.tiles);
|
||||
expect(world1.exit).toEqual(world2.exit);
|
||||
|
||||
const player1Pos = world1.actors.get(player1)!.pos;
|
||||
const player2Pos = world2.actors.get(player2)!.pos;
|
||||
expect(player1Pos).toEqual(player2Pos);
|
||||
});
|
||||
|
||||
it('should generate different maps for different levels', () => {
|
||||
const runState = {
|
||||
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
|
||||
inventory: { gold: 0, items: [] }
|
||||
};
|
||||
|
||||
const { world: world1 } = generateWorld(1, runState);
|
||||
const { world: world2 } = generateWorld(2, runState);
|
||||
|
||||
// Different levels should have different layouts
|
||||
expect(world1.tiles).not.toEqual(world2.tiles);
|
||||
});
|
||||
|
||||
it('should scale enemy difficulty with level', () => {
|
||||
const runState = {
|
||||
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
|
||||
inventory: { gold: 0, items: [] }
|
||||
};
|
||||
|
||||
const { world: world1 } = generateWorld(1, runState);
|
||||
const { world: world5 } = generateWorld(5, runState);
|
||||
|
||||
const enemies1 = Array.from(world1.actors.values()).filter(a => !a.isPlayer);
|
||||
const enemies5 = Array.from(world5.actors.values()).filter(a => !a.isPlayer);
|
||||
|
||||
// Higher level should have more enemies
|
||||
expect(enemies5.length).toBeGreaterThan(enemies1.length);
|
||||
|
||||
// Higher level enemies should have higher stats
|
||||
const avgHp1 = enemies1.reduce((sum, e) => sum + (e.stats?.hp || 0), 0) / enemies1.length;
|
||||
const avgHp5 = enemies5.reduce((sum, e) => sum + (e.stats?.hp || 0), 0) / enemies5.length;
|
||||
expect(avgHp5).toBeGreaterThan(avgHp1);
|
||||
});
|
||||
});
|
||||
});
|
||||
125
src/engine/__tests__/simulation.test.ts
Normal file
125
src/engine/__tests__/simulation.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { applyAction } from '../simulation/simulation';
|
||||
import { type World, type Actor, type EntityId } from '../../core/types';
|
||||
|
||||
describe('Combat Simulation', () => {
|
||||
const createTestWorld = (actors: Map<EntityId, Actor>): World => ({
|
||||
width: 10,
|
||||
height: 10,
|
||||
tiles: new Array(100).fill(0),
|
||||
actors,
|
||||
exit: { x: 9, y: 9 }
|
||||
});
|
||||
|
||||
describe('applyAction - attack', () => {
|
||||
it('should deal damage when player attacks enemy', () => {
|
||||
const actors = new Map<EntityId, Actor>();
|
||||
actors.set(1, {
|
||||
id: 1,
|
||||
isPlayer: true,
|
||||
pos: { x: 3, y: 3 },
|
||||
speed: 100,
|
||||
energy: 0,
|
||||
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 }
|
||||
});
|
||||
actors.set(2, {
|
||||
id: 2,
|
||||
isPlayer: false,
|
||||
pos: { x: 4, y: 3 },
|
||||
speed: 100,
|
||||
energy: 0,
|
||||
stats: { maxHp: 10, hp: 10, attack: 3, defense: 1 }
|
||||
});
|
||||
|
||||
const world = createTestWorld(actors);
|
||||
const events = applyAction(world, 1, { type: 'attack', targetId: 2 });
|
||||
|
||||
const enemy = world.actors.get(2)!;
|
||||
expect(enemy.stats!.hp).toBeLessThan(10);
|
||||
|
||||
// Should have attack event
|
||||
expect(events.some(e => e.type === 'attacked')).toBe(true);
|
||||
});
|
||||
|
||||
it('should kill enemy when damage exceeds hp', () => {
|
||||
const actors = new Map<EntityId, Actor>();
|
||||
actors.set(1, {
|
||||
id: 1,
|
||||
isPlayer: true,
|
||||
pos: { x: 3, y: 3 },
|
||||
speed: 100,
|
||||
energy: 0,
|
||||
stats: { maxHp: 20, hp: 20, attack: 50, defense: 2 }
|
||||
});
|
||||
actors.set(2, {
|
||||
id: 2,
|
||||
isPlayer: false,
|
||||
pos: { x: 4, y: 3 },
|
||||
speed: 100,
|
||||
energy: 0,
|
||||
stats: { maxHp: 10, hp: 10, attack: 3, defense: 1 }
|
||||
});
|
||||
|
||||
const world = createTestWorld(actors);
|
||||
const events = applyAction(world, 1, { type: 'attack', targetId: 2 });
|
||||
|
||||
// Enemy should be removed from world
|
||||
expect(world.actors.has(2)).toBe(false);
|
||||
|
||||
// Should have killed event
|
||||
expect(events.some(e => e.type === 'killed')).toBe(true);
|
||||
});
|
||||
|
||||
it('should apply defense to reduce damage', () => {
|
||||
const actors = new Map<EntityId, Actor>();
|
||||
actors.set(1, {
|
||||
id: 1,
|
||||
isPlayer: true,
|
||||
pos: { x: 3, y: 3 },
|
||||
speed: 100,
|
||||
energy: 0,
|
||||
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 }
|
||||
});
|
||||
actors.set(2, {
|
||||
id: 2,
|
||||
isPlayer: false,
|
||||
pos: { x: 4, y: 3 },
|
||||
speed: 100,
|
||||
energy: 0,
|
||||
stats: { maxHp: 10, hp: 10, attack: 3, defense: 3 }
|
||||
});
|
||||
|
||||
const world = createTestWorld(actors);
|
||||
applyAction(world, 1, { type: 'attack', targetId: 2 });
|
||||
|
||||
const enemy = world.actors.get(2)!;
|
||||
const damage = 10 - enemy.stats!. hp;
|
||||
|
||||
// Damage should be reduced by defense (5 attack - 3 defense = 2 damage)
|
||||
expect(damage).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyAction - move', () => {
|
||||
it('should move actor to new position', () => {
|
||||
const actors = new Map<EntityId, Actor>();
|
||||
actors.set(1, {
|
||||
id: 1,
|
||||
isPlayer: true,
|
||||
pos: { x: 3, y: 3 },
|
||||
speed: 100,
|
||||
energy: 0,
|
||||
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 }
|
||||
});
|
||||
|
||||
const world = createTestWorld(actors);
|
||||
const events = applyAction(world, 1, { type: 'move', dx: 1, dy: 0 });
|
||||
|
||||
const player = world.actors.get(1)!;
|
||||
expect(player.pos).toEqual({ x: 4, y: 3 });
|
||||
|
||||
// Should have moved event
|
||||
expect(events.some(e => e.type === 'moved')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
110
src/engine/__tests__/world.test.ts
Normal file
110
src/engine/__tests__/world.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { idx, inBounds, isWall, isBlocked } from '../world/world-logic';
|
||||
import { type World, type Tile } from '../../core/types';
|
||||
|
||||
describe('World Utilities', () => {
|
||||
const createTestWorld = (width: number, height: number, tiles: Tile[]): World => ({
|
||||
width,
|
||||
height,
|
||||
tiles,
|
||||
actors: new Map(),
|
||||
exit: { x: 0, y: 0 }
|
||||
});
|
||||
|
||||
describe('idx', () => {
|
||||
it('should calculate correct index for 2D coordinates', () => {
|
||||
const world = createTestWorld(10, 10, []);
|
||||
|
||||
expect(idx(world, 0, 0)).toBe(0);
|
||||
expect(idx(world, 5, 0)).toBe(5);
|
||||
expect(idx(world, 0, 1)).toBe(10);
|
||||
expect(idx(world, 5, 3)).toBe(35);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inBounds', () => {
|
||||
it('should return true for coordinates within bounds', () => {
|
||||
const world = createTestWorld(10, 10, []);
|
||||
|
||||
expect(inBounds(world, 0, 0)).toBe(true);
|
||||
expect(inBounds(world, 5, 5)).toBe(true);
|
||||
expect(inBounds(world, 9, 9)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for coordinates outside bounds', () => {
|
||||
const world = createTestWorld(10, 10, []);
|
||||
|
||||
expect(inBounds(world, -1, 0)).toBe(false);
|
||||
expect(inBounds(world, 0, -1)).toBe(false);
|
||||
expect(inBounds(world, 10, 0)).toBe(false);
|
||||
expect(inBounds(world, 0, 10)).toBe(false);
|
||||
expect(inBounds(world, 11, 11)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isWall', () => {
|
||||
it('should return true for wall tiles', () => {
|
||||
const tiles: Tile[] = new Array(100).fill(0);
|
||||
tiles[0] = 1; // wall at 0,0
|
||||
tiles[55] = 1; // wall at 5,5
|
||||
|
||||
const world = createTestWorld(10, 10, tiles);
|
||||
|
||||
expect(isWall(world, 0, 0)).toBe(true);
|
||||
expect(isWall(world, 5, 5)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for floor tiles', () => {
|
||||
const tiles: Tile[] = new Array(100).fill(0);
|
||||
const world = createTestWorld(10, 10, tiles);
|
||||
|
||||
expect(isWall(world, 3, 3)).toBe(false);
|
||||
expect(isWall(world, 7, 7)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for out of bounds coordinates', () => {
|
||||
const world = createTestWorld(10, 10, new Array(100).fill(0));
|
||||
|
||||
expect(isWall(world, -1, 0)).toBe(false);
|
||||
expect(isWall(world, 10, 10)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBlocked', () => {
|
||||
it('should return true for walls', () => {
|
||||
const tiles: Tile[] = new Array(100).fill(0);
|
||||
tiles[55] = 1; // wall at 5,5
|
||||
|
||||
const world = createTestWorld(10, 10, tiles);
|
||||
|
||||
expect(isBlocked(world, 5, 5)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for actor positions', () => {
|
||||
const world = createTestWorld(10, 10, new Array(100).fill(0));
|
||||
world.actors.set(1, {
|
||||
id: 1,
|
||||
isPlayer: true,
|
||||
pos: { x: 3, y: 3 },
|
||||
speed: 100,
|
||||
energy: 0
|
||||
});
|
||||
|
||||
expect(isBlocked(world, 3, 3)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for empty floor tiles', () => {
|
||||
const world = createTestWorld(10, 10, new Array(100).fill(0));
|
||||
|
||||
expect(isBlocked(world, 3, 3)).toBe(false);
|
||||
expect(isBlocked(world, 7, 7)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for out of bounds', () => {
|
||||
const world = createTestWorld(10, 10, new Array(100).fill(0));
|
||||
|
||||
expect(isBlocked(world, -1, 0)).toBe(true);
|
||||
expect(isBlocked(world, 10, 10)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
138
src/engine/simulation/simulation.ts
Normal file
138
src/engine/simulation/simulation.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { ACTION_COST, ENERGY_THRESHOLD } from "../../core/constants";
|
||||
import type { World, EntityId, Action, SimEvent, Actor } from "../../core/types";
|
||||
import { isBlocked } from "../world/world-logic";
|
||||
|
||||
export function applyAction(w: World, actorId: EntityId, action: Action): SimEvent[] {
|
||||
const actor = w.actors.get(actorId);
|
||||
if (!actor) return [];
|
||||
|
||||
const events: SimEvent[] = [];
|
||||
|
||||
switch (action.type) {
|
||||
case "move":
|
||||
events.push(...handleMove(w, actor, action));
|
||||
break;
|
||||
case "attack":
|
||||
events.push(...handleAttack(w, actor, action));
|
||||
break;
|
||||
case "wait":
|
||||
default:
|
||||
events.push({ type: "waited", actorId });
|
||||
break;
|
||||
}
|
||||
|
||||
// Spend energy for any action (move/wait/attack)
|
||||
actor.energy -= ACTION_COST;
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }): SimEvent[] {
|
||||
const from = { ...actor.pos };
|
||||
const nx = actor.pos.x + action.dx;
|
||||
const ny = actor.pos.y + action.dy;
|
||||
|
||||
if (!isBlocked(w, nx, ny)) {
|
||||
actor.pos.x = nx;
|
||||
actor.pos.y = ny;
|
||||
const to = { ...actor.pos };
|
||||
return [{ type: "moved", actorId: actor.id, from, to }];
|
||||
} else {
|
||||
return [{ type: "waited", actorId: actor.id }];
|
||||
}
|
||||
}
|
||||
|
||||
function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }): SimEvent[] {
|
||||
const target = w.actors.get(action.targetId);
|
||||
if (target && target.stats && actor.stats) {
|
||||
const events: SimEvent[] = [{ type: "attacked", attackerId: actor.id, targetId: action.targetId }];
|
||||
|
||||
const dmg = Math.max(1, actor.stats.attack - target.stats.defense);
|
||||
target.stats.hp -= dmg;
|
||||
|
||||
events.push({
|
||||
type: "damaged",
|
||||
targetId: action.targetId,
|
||||
amount: dmg,
|
||||
hp: target.stats.hp,
|
||||
x: target.pos.x,
|
||||
y: target.pos.y
|
||||
});
|
||||
|
||||
if (target.stats.hp <= 0) {
|
||||
events.push({
|
||||
type: "killed",
|
||||
targetId: target.id,
|
||||
killerId: actor.id,
|
||||
x: target.pos.x,
|
||||
y: target.pos.y,
|
||||
victimType: target.type
|
||||
});
|
||||
w.actors.delete(target.id);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
return [{ type: "waited", actorId: actor.id }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Very basic enemy AI:
|
||||
* - if adjacent to player, attack
|
||||
* - else step toward player using greedy Manhattan
|
||||
*/
|
||||
export function decideEnemyAction(w: World, enemy: Actor, player: Actor): Action {
|
||||
const dx = player.pos.x - enemy.pos.x;
|
||||
const dy = player.pos.y - enemy.pos.y;
|
||||
const dist = Math.abs(dx) + Math.abs(dy);
|
||||
|
||||
if (dist === 1) {
|
||||
return { type: "attack", targetId: player.id };
|
||||
}
|
||||
|
||||
const options: { dx: number; dy: number }[] = [];
|
||||
if (Math.abs(dx) >= Math.abs(dy)) {
|
||||
options.push({ dx: Math.sign(dx), dy: 0 });
|
||||
options.push({ dx: 0, dy: Math.sign(dy) });
|
||||
} else {
|
||||
options.push({ dx: 0, dy: Math.sign(dy) });
|
||||
options.push({ dx: Math.sign(dx), dy: 0 });
|
||||
}
|
||||
|
||||
options.push({ dx: -options[0].dx, dy: -options[0].dy });
|
||||
|
||||
for (const o of options) {
|
||||
if (o.dx === 0 && o.dy === 0) continue;
|
||||
const nx = enemy.pos.x + o.dx;
|
||||
const ny = enemy.pos.y + o.dy;
|
||||
if (!isBlocked(w, nx, ny)) return { type: "move", dx: o.dx, dy: o.dy };
|
||||
}
|
||||
return { type: "wait" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Energy/speed scheduler: 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): { awaitingPlayerId: EntityId; events: SimEvent[] } {
|
||||
const player = w.actors.get(playerId);
|
||||
if (!player) throw new Error("Player missing");
|
||||
|
||||
const events: SimEvent[] = [];
|
||||
|
||||
while (true) {
|
||||
while (![...w.actors.values()].some(a => a.energy >= ENERGY_THRESHOLD)) {
|
||||
for (const a of w.actors.values()) a.energy += a.speed;
|
||||
}
|
||||
|
||||
const ready = [...w.actors.values()].filter(a => a.energy >= ENERGY_THRESHOLD);
|
||||
ready.sort((a, b) => (b.energy - a.energy) || (a.id - b.id));
|
||||
const actor = ready[0];
|
||||
|
||||
if (actor.isPlayer) {
|
||||
return { awaitingPlayerId: actor.id, events };
|
||||
}
|
||||
|
||||
const action = decideEnemyAction(w, actor, player);
|
||||
events.push(...applyAction(w, actor.id, action));
|
||||
}
|
||||
}
|
||||
164
src/engine/world/generator.ts
Normal file
164
src/engine/world/generator.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { type World, type EntityId, type RunState, type Tile, type Actor, type Vec2 } from "../../core/types";
|
||||
import { idx } from "./world-logic";
|
||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||
import { seededRandom } from "../../core/math";
|
||||
|
||||
interface Room {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a procedural dungeon world with rooms and corridors
|
||||
* @param level The level number (affects difficulty and randomness seed)
|
||||
* @param runState Player's persistent state across levels
|
||||
* @returns Generated world and player ID
|
||||
*/
|
||||
export function generateWorld(level: number, runState: RunState): { world: World; playerId: EntityId } {
|
||||
const width = GAME_CONFIG.map.width;
|
||||
const height = GAME_CONFIG.map.height;
|
||||
const tiles: Tile[] = new Array(width * height).fill(1); // Start with all walls
|
||||
|
||||
const random = seededRandom(level * 12345);
|
||||
|
||||
const rooms = generateRooms(width, height, tiles, random);
|
||||
|
||||
// Place player in first room
|
||||
const firstRoom = rooms[0];
|
||||
const playerX = firstRoom.x + Math.floor(firstRoom.width / 2);
|
||||
const playerY = firstRoom.y + Math.floor(firstRoom.height / 2);
|
||||
|
||||
// Place exit in last room
|
||||
const lastRoom = rooms[rooms.length - 1];
|
||||
const exitX = lastRoom.x + Math.floor(lastRoom.width / 2);
|
||||
const exitY = lastRoom.y + Math.floor(lastRoom.height / 2);
|
||||
const exit: Vec2 = { x: exitX, y: exitY };
|
||||
|
||||
const actors = new Map<EntityId, Actor>();
|
||||
|
||||
const playerId = 1;
|
||||
actors.set(playerId, {
|
||||
id: playerId,
|
||||
isPlayer: true,
|
||||
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] }
|
||||
});
|
||||
|
||||
placeEnemies(level, rooms, actors, random);
|
||||
|
||||
return { world: { width, height, tiles, actors, exit }, playerId };
|
||||
}
|
||||
|
||||
function generateRooms(width: number, height: number, tiles: Tile[], random: () => number): Room[] {
|
||||
const rooms: Room[] = [];
|
||||
const numRooms = GAME_CONFIG.map.minRooms + Math.floor(random() * (GAME_CONFIG.map.maxRooms - GAME_CONFIG.map.minRooms + 1));
|
||||
|
||||
const fakeWorldForIdx = { width, height };
|
||||
|
||||
for (let i = 0; i < numRooms; i++) {
|
||||
const roomWidth = GAME_CONFIG.map.roomMinWidth + Math.floor(random() * (GAME_CONFIG.map.roomMaxWidth - GAME_CONFIG.map.roomMinWidth + 1));
|
||||
const roomHeight = GAME_CONFIG.map.roomMinHeight + Math.floor(random() * (GAME_CONFIG.map.roomMaxHeight - GAME_CONFIG.map.roomMinHeight + 1));
|
||||
const roomX = 1 + Math.floor(random() * (width - roomWidth - 2));
|
||||
const roomY = 1 + Math.floor(random() * (height - roomHeight - 2));
|
||||
|
||||
const newRoom: Room = { x: roomX, y: roomY, width: roomWidth, height: roomHeight };
|
||||
|
||||
if (!doesOverlap(newRoom, rooms)) {
|
||||
carveRoom(newRoom, tiles, fakeWorldForIdx);
|
||||
|
||||
if (rooms.length > 0) {
|
||||
carveCorridor(rooms[rooms.length - 1], newRoom, tiles, fakeWorldForIdx, random);
|
||||
}
|
||||
|
||||
rooms.push(newRoom);
|
||||
}
|
||||
}
|
||||
return rooms;
|
||||
}
|
||||
|
||||
function doesOverlap(newRoom: Room, rooms: Room[]): boolean {
|
||||
for (const room of rooms) {
|
||||
if (
|
||||
newRoom.x < room.x + room.width + 1 &&
|
||||
newRoom.x + newRoom.width + 1 > room.x &&
|
||||
newRoom.y < room.y + room.height + 1 &&
|
||||
newRoom.y + newRoom.height + 1 > room.y
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function carveRoom(room: Room, tiles: Tile[], world: any): void {
|
||||
for (let x = room.x; x < room.x + room.width; x++) {
|
||||
for (let y = room.y; y < room.y + room.height; y++) {
|
||||
tiles[idx(world, x, y)] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function carveCorridor(room1: Room, room2: Room, tiles: Tile[], world: any, random: () => number): void {
|
||||
const x1 = Math.floor(room1.x + room1.width / 2);
|
||||
const y1 = Math.floor(room1.y + room1.height / 2);
|
||||
const x2 = Math.floor(room2.x + room2.width / 2);
|
||||
const y2 = Math.floor(room2.y + room2.height / 2);
|
||||
|
||||
if (random() < 0.5) {
|
||||
// Horizontal then vertical
|
||||
for (let x = Math.min(x1, x2); x <= Math.max(x1, x2); x++) {
|
||||
tiles[idx(world, x, y1)] = 0;
|
||||
}
|
||||
for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) {
|
||||
tiles[idx(world, x2, y)] = 0;
|
||||
}
|
||||
} else {
|
||||
// Vertical then horizontal
|
||||
for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) {
|
||||
tiles[idx(world, x1, y)] = 0;
|
||||
}
|
||||
for (let x = Math.min(x1, x2); x <= Math.max(x1, x2); x++) {
|
||||
tiles[idx(world, x, y2)] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function placeEnemies(level: number, rooms: Room[], actors: Map<EntityId, Actor>, random: () => number): void {
|
||||
let enemyId = 2;
|
||||
const numEnemies = GAME_CONFIG.enemy.baseCount + level * GAME_CONFIG.enemy.baseCountPerLevel + Math.floor(random() * GAME_CONFIG.enemy.randomBonus);
|
||||
|
||||
for (let i = 0; i < numEnemies && i < rooms.length - 1; i++) {
|
||||
const roomIdx = 1 + Math.floor(random() * (rooms.length - 1));
|
||||
const room = rooms[roomIdx];
|
||||
|
||||
const enemyX = room.x + 1 + Math.floor(random() * (room.width - 2));
|
||||
const enemyY = room.y + 1 + Math.floor(random() * (room.height - 2));
|
||||
|
||||
const baseHp = GAME_CONFIG.enemy.baseHp + level * GAME_CONFIG.enemy.baseHpPerLevel;
|
||||
const baseAttack = GAME_CONFIG.enemy.baseAttack + Math.floor(level / 2) * GAME_CONFIG.enemy.attackPerTwoLevels;
|
||||
|
||||
actors.set(enemyId, {
|
||||
id: enemyId,
|
||||
isPlayer: false,
|
||||
type: random() < 0.5 ? "rat" : "bat",
|
||||
pos: { x: enemyX, y: enemyY },
|
||||
speed: GAME_CONFIG.enemy.minSpeed + Math.floor(random() * (GAME_CONFIG.enemy.maxSpeed - GAME_CONFIG.enemy.minSpeed)),
|
||||
energy: 0,
|
||||
stats: {
|
||||
maxHp: baseHp + Math.floor(random() * 4),
|
||||
hp: baseHp + Math.floor(random() * 4),
|
||||
attack: baseAttack + Math.floor(random() * 2),
|
||||
defense: Math.floor(random() * (GAME_CONFIG.enemy.maxDefense + 1))
|
||||
}
|
||||
});
|
||||
enemyId++;
|
||||
}
|
||||
}
|
||||
|
||||
export const makeTestWorld = generateWorld;
|
||||
103
src/engine/world/pathfinding.ts
Normal file
103
src/engine/world/pathfinding.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
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";
|
||||
|
||||
/**
|
||||
* Simple 4-dir A* pathfinding.
|
||||
* 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 } = {}): Vec2[] {
|
||||
if (!inBounds(w, end.x, end.y)) return [];
|
||||
if (isWall(w, end.x, end.y)) return [];
|
||||
|
||||
// If not ignoring target block, fail if blocked
|
||||
if (!options.ignoreBlockedTarget && isBlocked(w, end.x, end.y)) return [];
|
||||
|
||||
if (seen[idx(w, end.x, end.y)] !== 1) return [];
|
||||
|
||||
const open: Vec2[] = [start];
|
||||
const cameFrom = new Map<string, string>();
|
||||
|
||||
const gScore = new Map<string, number>();
|
||||
const fScore = new Map<string, number>();
|
||||
|
||||
const startK = key(start.x, start.y);
|
||||
gScore.set(startK, 0);
|
||||
fScore.set(startK, manhattan(start, end));
|
||||
|
||||
const inOpen = new Set<string>([startK]);
|
||||
|
||||
const dirs = [
|
||||
{ x: 1, y: 0 },
|
||||
{ x: -1, y: 0 },
|
||||
{ x: 0, y: 1 },
|
||||
{ x: 0, y: -1 }
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const current = open.splice(bestIdx, 1)[0];
|
||||
const currentK = key(current.x, current.y);
|
||||
inOpen.delete(currentK);
|
||||
|
||||
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)
|
||||
if (!(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)) 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 [];
|
||||
}
|
||||
29
src/engine/world/world-logic.ts
Normal file
29
src/engine/world/world-logic.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { World, EntityId } from "../../core/types";
|
||||
|
||||
export function inBounds(w: World, x: number, y: number): boolean {
|
||||
return x >= 0 && y >= 0 && x < w.width && y < w.height;
|
||||
}
|
||||
|
||||
export function idx(w: World, x: number, y: number): number {
|
||||
return y * w.width + x;
|
||||
}
|
||||
|
||||
export function isWall(w: World, x: number, y: number): boolean {
|
||||
return w.tiles[idx(w, x, y)] === 1;
|
||||
}
|
||||
|
||||
export function isBlocked(w: World, x: number, y: number): boolean {
|
||||
if (!inBounds(w, x, y)) return true;
|
||||
if (isWall(w, x, y)) return true;
|
||||
|
||||
for (const a of w.actors.values()) {
|
||||
if (a.pos.x === x && a.pos.y === y) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isPlayerOnExit(w: World, playerId: EntityId): boolean {
|
||||
const p = w.actors.get(playerId);
|
||||
if (!p) return false;
|
||||
return p.pos.x === w.exit.x && p.pos.y === w.exit.y;
|
||||
}
|
||||
Reference in New Issue
Block a user