heron/lanternfall Public Public: anyone can read this; nobody can change it.
workbenchthis machine
Files e53b98b72d84ce5feeddbe32d2586ca5df139720
| 1 | /** |
| 2 | * The dungeon itself: generated, never stored. Five floors, each a pure |
| 3 | * function of the seed and the depth — so every delver walks the same rooms, |
| 4 | * every corridor is where the last one left it, and the database only has to |
| 5 | * remember what has *happened* in the dungeon, not what it looks like. |
| 6 | * |
| 7 | * Nothing in this file touches the database or the clock. |
| 8 | */ |
| 9 | |
| 10 | export const WIDTH = 48; |
| 11 | export const HEIGHT = 24; |
| 12 | export const DEPTHS = 5; |
| 13 | /** How far a lantern throws, in tiles. */ |
| 14 | export const LANTERN = 4; |
| 15 | |
| 16 | export type Tile = "#" | "." | ">" | "<"; |
| 17 | |
| 18 | export interface Room { |
| 19 | x: number; |
| 20 | y: number; |
| 21 | w: number; |
| 22 | h: number; |
| 23 | } |
| 24 | |
| 25 | export interface Floor { |
| 26 | depth: number; |
| 27 | tiles: Tile[]; |
| 28 | rooms: Room[]; |
| 29 | /** Where a delver arrives when coming down (or starting out). */ |
| 30 | entrance: number; |
| 31 | /** The stair down; on the deepest floor there is none. */ |
| 32 | descent: number | undefined; |
| 33 | } |
| 34 | |
| 35 | export const at = (x: number, y: number): number => y * WIDTH + x; |
| 36 | export const xy = (cell: number): { x: number; y: number } => ({ |
| 37 | x: cell % WIDTH, |
| 38 | y: Math.floor(cell / WIDTH), |
| 39 | }); |
| 40 | |
| 41 | /** Deterministic noise: the same seed, depth and salt always give the same roll. */ |
| 42 | function roll(seed: number, depth: number, salt: number): number { |
| 43 | let h = (seed * 2654435761) ^ (depth * 40503) ^ (salt * 2246822519); |
| 44 | h = Math.imul(h ^ (h >>> 15), 2246822519); |
| 45 | h = Math.imul(h ^ (h >>> 13), 3266489917); |
| 46 | return ((h ^ (h >>> 16)) >>> 0) / 4294967296; |
| 47 | } |
| 48 | |
| 49 | const between = (r: number, lo: number, hi: number): number => |
| 50 | lo + Math.floor(r * (hi - lo + 1)); |
| 51 | |
| 52 | function carveRoom(tiles: Tile[], room: Room): void { |
| 53 | for (let y = room.y; y < room.y + room.h; y += 1) |
| 54 | for (let x = room.x; x < room.x + room.w; x += 1) tiles[at(x, y)] = "."; |
| 55 | } |
| 56 | |
| 57 | function carveCorridor(tiles: Tile[], a: Room, b: Room, bendFirst: boolean): void { |
| 58 | const ax = Math.floor(a.x + a.w / 2); |
| 59 | const ay = Math.floor(a.y + a.h / 2); |
| 60 | const bx = Math.floor(b.x + b.w / 2); |
| 61 | const by = Math.floor(b.y + b.h / 2); |
| 62 | const hor = (y: number, from: number, to: number): void => { |
| 63 | for (let x = Math.min(from, to); x <= Math.max(from, to); x += 1) |
| 64 | tiles[at(x, y)] = "."; |
| 65 | }; |
| 66 | const ver = (x: number, from: number, to: number): void => { |
| 67 | for (let y = Math.min(from, to); y <= Math.max(from, to); y += 1) |
| 68 | tiles[at(x, y)] = "."; |
| 69 | }; |
| 70 | if (bendFirst) { |
| 71 | hor(ay, ax, bx); |
| 72 | ver(bx, ay, by); |
| 73 | } else { |
| 74 | ver(ax, ay, by); |
| 75 | hor(by, ax, bx); |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | const overlaps = (a: Room, b: Room): boolean => |
| 80 | a.x < b.x + b.w + 1 && |
| 81 | a.x + a.w + 1 > b.x && |
| 82 | a.y < b.y + b.h + 1 && |
| 83 | a.y + a.h + 1 > b.y; |
| 84 | |
| 85 | /** One floor, built the same way every time it is asked for. */ |
| 86 | export function floorAt(depth: number, seed = 1848): Floor { |
| 87 | const tiles: Tile[] = new Array(WIDTH * HEIGHT).fill("#"); |
| 88 | const rooms: Room[] = []; |
| 89 | // Deeper floors are more broken up: fewer, smaller, further apart. |
| 90 | const attempts = 60; |
| 91 | const maxRooms = Math.max(5, 11 - depth); |
| 92 | for (let i = 0; i < attempts && rooms.length < maxRooms; i += 1) { |
| 93 | const w = between(roll(seed, depth, i * 7 + 1), 5, Math.max(6, 11 - depth)); |
| 94 | const h = between(roll(seed, depth, i * 7 + 2), 3, Math.max(4, 7 - Math.floor(depth / 2))); |
| 95 | const room: Room = { |
| 96 | x: between(roll(seed, depth, i * 7 + 3), 1, WIDTH - w - 2), |
| 97 | y: between(roll(seed, depth, i * 7 + 4), 1, HEIGHT - h - 2), |
| 98 | w, |
| 99 | h, |
| 100 | }; |
| 101 | if (rooms.some((other) => overlaps(room, other))) continue; |
| 102 | rooms.push(room); |
| 103 | } |
| 104 | for (const room of rooms) carveRoom(tiles, room); |
| 105 | for (let i = 1; i < rooms.length; i += 1) |
| 106 | carveCorridor(tiles, rooms[i - 1] as Room, rooms[i] as Room, roll(seed, depth, i * 13) < 0.5); |
| 107 | |
| 108 | const first = rooms[0] as Room; |
| 109 | const last = rooms[rooms.length - 1] as Room; |
| 110 | const entrance = at(Math.floor(first.x + first.w / 2), Math.floor(first.y + first.h / 2)); |
| 111 | tiles[entrance] = "<"; |
| 112 | let descent: number | undefined; |
| 113 | if (depth < DEPTHS) { |
| 114 | descent = at(Math.floor(last.x + last.w / 2), Math.floor(last.y + last.h / 2)); |
| 115 | tiles[descent] = ">"; |
| 116 | } |
| 117 | return { depth, tiles, rooms, entrance, descent }; |
| 118 | } |
| 119 | |
| 120 | export const walkable = (tile: Tile | undefined): boolean => |
| 121 | tile === "." || tile === ">" || tile === "<"; |
| 122 | |
| 123 | /** Floor tiles in reading order — what anything placed in the dungeon picks from. */ |
| 124 | export function openCells(floor: Floor): number[] { |
| 125 | const open: number[] = []; |
| 126 | for (const [cell, tile] of floor.tiles.entries()) |
| 127 | if (tile === ".") open.push(cell); |
| 128 | return open; |
| 129 | } |
| 130 | |
| 131 | /** |
| 132 | * Line of sight, Bresenham. A wall stops the light at the wall, so you see |
| 133 | * the wall itself and nothing behind it — which is what makes a doorway read |
| 134 | * as a doorway. |
| 135 | */ |
| 136 | export function seesFrom(floor: Floor, from: number, to: number): boolean { |
| 137 | const a = xy(from); |
| 138 | const b = xy(to); |
| 139 | let { x, y } = a; |
| 140 | const dx = Math.abs(b.x - x); |
| 141 | const dy = Math.abs(b.y - y); |
| 142 | const sx = x < b.x ? 1 : -1; |
| 143 | const sy = y < b.y ? 1 : -1; |
| 144 | let err = dx - dy; |
| 145 | for (;;) { |
| 146 | if (x === b.x && y === b.y) return true; |
| 147 | const e2 = 2 * err; |
| 148 | if (e2 > -dy) { |
| 149 | err -= dy; |
| 150 | x += sx; |
| 151 | } |
| 152 | if (e2 < dx) { |
| 153 | err += dx; |
| 154 | y += sy; |
| 155 | } |
| 156 | if (x === b.x && y === b.y) return true; |
| 157 | if (!walkable(floor.tiles[at(x, y)])) return false; |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | export const distance = (a: number, b: number): number => { |
| 162 | const p = xy(a); |
| 163 | const q = xy(b); |
| 164 | return Math.max(Math.abs(p.x - q.x), Math.abs(p.y - q.y)); |
| 165 | }; |
| 166 | |
| 167 | /** Every cell a delver standing here can currently see. */ |
| 168 | export function visibleFrom(floor: Floor, from: number, radius = LANTERN): number[] { |
| 169 | const seen: number[] = []; |
| 170 | const { x, y } = xy(from); |
| 171 | for (let dy = -radius; dy <= radius; dy += 1) |
| 172 | for (let dx = -radius; dx <= radius; dx += 1) { |
| 173 | const nx = x + dx; |
| 174 | const ny = y + dy; |
| 175 | if (nx < 0 || ny < 0 || nx >= WIDTH || ny >= HEIGHT) continue; |
| 176 | const cell = at(nx, ny); |
| 177 | if (distance(from, cell) > radius) continue; |
| 178 | if (seesFrom(floor, from, cell)) seen.push(cell); |
| 179 | } |
| 180 | return seen; |
| 181 | } |
| 182 | |
| 183 | /** One step from `from` toward `to`, around walls where it can manage it. */ |
| 184 | export function stepToward(floor: Floor, from: number, to: number): number { |
| 185 | const a = xy(from); |
| 186 | const b = xy(to); |
| 187 | const sx = Math.sign(b.x - a.x); |
| 188 | const sy = Math.sign(b.y - a.y); |
| 189 | const tries = [ |
| 190 | at(a.x + sx, a.y + sy), |
| 191 | sx !== 0 ? at(a.x + sx, a.y) : at(a.x, a.y + sy), |
| 192 | sy !== 0 ? at(a.x, a.y + sy) : at(a.x + sx, a.y), |
| 193 | ]; |
| 194 | for (const cell of tries) |
| 195 | if (cell !== from && walkable(floor.tiles[cell])) return cell; |
| 196 | return from; |
| 197 | } |