/** * The dungeon itself: generated, never stored. Five floors, each a pure * function of the seed and the depth — so every delver walks the same rooms, * every corridor is where the last one left it, and the database only has to * remember what has *happened* in the dungeon, not what it looks like. * * Nothing in this file touches the database or the clock. */ export const WIDTH = 48; export const HEIGHT = 24; export const DEPTHS = 5; /** How far a lantern throws, in tiles. */ export const LANTERN = 4; export type Tile = "#" | "." | ">" | "<"; export interface Room { x: number; y: number; w: number; h: number; } export interface Floor { depth: number; tiles: Tile[]; rooms: Room[]; /** Where a delver arrives when coming down (or starting out). */ entrance: number; /** The stair down; on the deepest floor there is none. */ descent: number | undefined; } export const at = (x: number, y: number): number => y * WIDTH + x; export const xy = (cell: number): { x: number; y: number } => ({ x: cell % WIDTH, y: Math.floor(cell / WIDTH), }); /** Deterministic noise: the same seed, depth and salt always give the same roll. */ function roll(seed: number, depth: number, salt: number): number { let h = (seed * 2654435761) ^ (depth * 40503) ^ (salt * 2246822519); h = Math.imul(h ^ (h >>> 15), 2246822519); h = Math.imul(h ^ (h >>> 13), 3266489917); return ((h ^ (h >>> 16)) >>> 0) / 4294967296; } const between = (r: number, lo: number, hi: number): number => lo + Math.floor(r * (hi - lo + 1)); function carveRoom(tiles: Tile[], room: Room): void { for (let y = room.y; y < room.y + room.h; y += 1) for (let x = room.x; x < room.x + room.w; x += 1) tiles[at(x, y)] = "."; } function carveCorridor(tiles: Tile[], a: Room, b: Room, bendFirst: boolean): void { const ax = Math.floor(a.x + a.w / 2); const ay = Math.floor(a.y + a.h / 2); const bx = Math.floor(b.x + b.w / 2); const by = Math.floor(b.y + b.h / 2); const hor = (y: number, from: number, to: number): void => { for (let x = Math.min(from, to); x <= Math.max(from, to); x += 1) tiles[at(x, y)] = "."; }; const ver = (x: number, from: number, to: number): void => { for (let y = Math.min(from, to); y <= Math.max(from, to); y += 1) tiles[at(x, y)] = "."; }; if (bendFirst) { hor(ay, ax, bx); ver(bx, ay, by); } else { ver(ax, ay, by); hor(by, ax, bx); } } const overlaps = (a: Room, b: Room): boolean => a.x < b.x + b.w + 1 && a.x + a.w + 1 > b.x && a.y < b.y + b.h + 1 && a.y + a.h + 1 > b.y; /** One floor, built the same way every time it is asked for. */ export function floorAt(depth: number, seed = 1848): Floor { const tiles: Tile[] = new Array(WIDTH * HEIGHT).fill("#"); const rooms: Room[] = []; // Deeper floors are more broken up: fewer, smaller, further apart. const attempts = 60; const maxRooms = Math.max(5, 11 - depth); for (let i = 0; i < attempts && rooms.length < maxRooms; i += 1) { const w = between(roll(seed, depth, i * 7 + 1), 5, Math.max(6, 11 - depth)); const h = between(roll(seed, depth, i * 7 + 2), 3, Math.max(4, 7 - Math.floor(depth / 2))); const room: Room = { x: between(roll(seed, depth, i * 7 + 3), 1, WIDTH - w - 2), y: between(roll(seed, depth, i * 7 + 4), 1, HEIGHT - h - 2), w, h, }; if (rooms.some((other) => overlaps(room, other))) continue; rooms.push(room); } for (const room of rooms) carveRoom(tiles, room); for (let i = 1; i < rooms.length; i += 1) carveCorridor(tiles, rooms[i - 1] as Room, rooms[i] as Room, roll(seed, depth, i * 13) < 0.5); const first = rooms[0] as Room; const last = rooms[rooms.length - 1] as Room; const entrance = at(Math.floor(first.x + first.w / 2), Math.floor(first.y + first.h / 2)); tiles[entrance] = "<"; let descent: number | undefined; if (depth < DEPTHS) { descent = at(Math.floor(last.x + last.w / 2), Math.floor(last.y + last.h / 2)); tiles[descent] = ">"; } return { depth, tiles, rooms, entrance, descent }; } export const walkable = (tile: Tile | undefined): boolean => tile === "." || tile === ">" || tile === "<"; /** Floor tiles in reading order — what anything placed in the dungeon picks from. */ export function openCells(floor: Floor): number[] { const open: number[] = []; for (const [cell, tile] of floor.tiles.entries()) if (tile === ".") open.push(cell); return open; } /** * Line of sight, Bresenham. A wall stops the light at the wall, so you see * the wall itself and nothing behind it — which is what makes a doorway read * as a doorway. */ export function seesFrom(floor: Floor, from: number, to: number): boolean { const a = xy(from); const b = xy(to); let { x, y } = a; const dx = Math.abs(b.x - x); const dy = Math.abs(b.y - y); const sx = x < b.x ? 1 : -1; const sy = y < b.y ? 1 : -1; let err = dx - dy; for (;;) { if (x === b.x && y === b.y) return true; const e2 = 2 * err; if (e2 > -dy) { err -= dy; x += sx; } if (e2 < dx) { err += dx; y += sy; } if (x === b.x && y === b.y) return true; if (!walkable(floor.tiles[at(x, y)])) return false; } } export const distance = (a: number, b: number): number => { const p = xy(a); const q = xy(b); return Math.max(Math.abs(p.x - q.x), Math.abs(p.y - q.y)); }; /** Every cell a delver standing here can currently see. */ export function visibleFrom(floor: Floor, from: number, radius = LANTERN): number[] { const seen: number[] = []; const { x, y } = xy(from); for (let dy = -radius; dy <= radius; dy += 1) for (let dx = -radius; dx <= radius; dx += 1) { const nx = x + dx; const ny = y + dy; if (nx < 0 || ny < 0 || nx >= WIDTH || ny >= HEIGHT) continue; const cell = at(nx, ny); if (distance(from, cell) > radius) continue; if (seesFrom(floor, from, cell)) seen.push(cell); } return seen; } /** One step from `from` toward `to`, around walls where it can manage it. */ export function stepToward(floor: Floor, from: number, to: number): number { const a = xy(from); const b = xy(to); const sx = Math.sign(b.x - a.x); const sy = Math.sign(b.y - a.y); const tries = [ at(a.x + sx, a.y + sy), sx !== 0 ? at(a.x + sx, a.y) : at(a.x, a.y + sy), sy !== 0 ? at(a.x, a.y + sy) : at(a.x + sx, a.y), ]; for (const cell of tries) if (cell !== from && walkable(floor.tiles[cell])) return cell; return from; }