Refactor codebase

This commit is contained in:
Peter Stockings
2026-01-04 15:56:18 +11:00
parent 3785885abe
commit bfe5ebae8c
18 changed files with 380 additions and 191 deletions

View 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;

View 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 [];
}

View 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;
}