import { Architecture, LoadedNewMap } from "./newmap.ts"; import { Grid, Point, Rect, Size } from "./engine/datatypes.ts"; import { choose, shuffle } from "./utils.ts"; import { standardVaultTemplates, VaultTemplate } from "./vaulttemplate.ts"; import { ALL_STATS } from "./datatypes.ts"; import { BreakableBlockPickup, ExperiencePickupCallbacks, LadderPickup, LockPickup, StatPickupCallbacks, ThrallItemPickup, ThrallPickup, } from "./pickups.ts"; import { getPlayerProgress } from "./playerprogress.ts"; import { ItemStage } from "./thralls.ts"; import { Microtheme } from "./colors.ts"; const WIDTH = 19; const HEIGHT = 19; const MIN_VAULTS = 1; const MAX_VAULTS = 2; const NUM_VAULT_TRIES = 90; const NUM_ROOM_TRIES = 90; const NUM_STAIRCASE_TRIES = 90; const NUM_STAIRCASES_DESIRED = 3; const NUM_ROOMS_DESIRED = 1; const EXTRA_CONNECTOR_CHANCE = 0.15; const WINDING_PERCENT = 50; // This is an implementation of Nystrom's algorithm: // https://journal.stuffwithstuff.com/2014/12/21/rooms-and-mazes/ class Knife { #map: LoadedNewMap; #region: number; #regions: Grid; #sealedWalls: Grid; constructor( map: LoadedNewMap, regions: Grid, sealedWalls: Grid, ) { this.#map = map; this.#region = -1; this.#regions = regions; this.#sealedWalls = sealedWalls; } get map(): LoadedNewMap { return this.#map; } get region(): number { return this.#region; } get regions(): Grid { return this.#regions; } get sealedWalls(): Grid { return this.#sealedWalls; } startRegion() { this.#region += 1; } carve(point: Point, theme?: Microtheme | null, label?: string) { this.#regions.set(point, this.#region); this.map.get(point).architecture = Architecture.Floor; this.map.get(point).microtheme = theme ?? null; this.map.get(point).zoneLabel = label ?? null; } carveRoom( room: Rect, protect?: boolean, theme?: Microtheme | null, label?: string, ) { for (let y = room.top.y; y < room.top.y + room.size.h; y++) { for (let x = room.top.x; x < room.top.x + room.size.w; x++) { this.carve(new Point(x, y), theme, label); } } if (protect ?? false) { for (let y = room.top.y - 1; y < room.top.y + room.size.h + 1; y++) { for (let x = room.top.x - 1; x < room.top.x + room.size.w + 1; x++) { this.#sealedWalls.set(new Point(x, y), true); } } } } showDebug(merged: Record) { if (true) { let out = ""; let errors: string[] = []; const size = this.#regions.size; for (let y = 0; y < size.h; y++) { for (let x = 0; x < size.w; x++) { const loc = new Point(x, y); out += (() => { if (this.#map.get(loc).architecture == Architecture.Wall) { return this.#sealedWalls.get(loc) ? "◘" : "█"; } let r = this.#regions.get(loc); if (typeof r === "number") { const resolved = merged[r]; if (typeof resolved === "number") { r = resolved; } else { errors.push(`${loc} is region ${r}, not found in merged`); } if (r < 0) { return "!"; } // 0...9 and lowercase if (r < 36) { return r.toString(36); } // uppercase r -= 26; if (r < 36) { return r.toString(36).toUpperCase(); } // Greek lowercase r -= 36; if (r < 25) { return String.fromCodePoint(r + 0x3b1); } // Greek uppercase (there is a hole at 0x3a2) r -= 25; if (r < 17) { return String.fromCodePoint(r + 0x391); } r -= 17; if (r < 7) { return String.fromCodePoint(r + 0x3a3); } // Hebrew r -= 7; if (r < 27) { return String.fromCodePoint(r + 0x5d0); } // give up return "?"; } return "."; // room without region })(); } out += "\n"; } console.log(out); if (errors.length > 0) { console.log(`uh-oh: \n\t${errors.join("\n\t")}`); } } } } export function generateMap(): LoadedNewMap { for (let i = 0; i < 1000; i++) { try { return tryGenerateMap(standardVaultTemplates); } catch (e) { if (e instanceof TryAgainException) { continue; } if (e instanceof BadMapError) { console.log(`Bad map generated: ${e.message}:`); showDebug(e.badMap); // continue; } throw e; } } throw new Error("couldn't generate map in 1000 attempts"); } export function tryGenerateMap(vaultTemplates: VaultTemplate[]): LoadedNewMap { let width = WIDTH; let height = HEIGHT; if (width % 2 == 0 || height % 2 == 0) { throw new Error("map bounds must be odd-sized"); } let grid = new LoadedNewMap("generated", new Size(width, height)); let regions: Grid = new Grid(grid.size, () => null); let sealedWalls: Grid = new Grid(grid.size, () => false); let knife = new Knife(grid, regions, sealedWalls); let rooms = addRooms(knife, vaultTemplates); showDebug(grid); for (let y = 1; y < grid.size.h; y += 2) { for (let x = 1; x < grid.size.w; x += 2) { let pos = new Point(x, y); if (grid.get(pos).architecture == Architecture.Wall) { growMaze(knife, pos); } } } showDebug(grid); connectRegions(knife); removeDeadEnds(knife); for (let r of rooms.values()) { decorateRoom(grid, r); } showDebug(grid); return grid; } class RoomChain { #size: Size; rooms: Rect[]; constructor(size: Size) { this.#size = size; this.rooms = []; } reserve(width: number, height: number): Rect | null { let x = randrange(0, Math.floor((this.#size.w - width) / 2)) * 2 + 1; let y = randrange(0, Math.floor((this.#size.h - height) / 2)) * 2 + 1; let room = new Rect(new Point(x, y), new Size(width, height)); for (let other of this.rooms.values()) { if (room.overlaps(other)) { return null; } } this.rooms.push(room); return room; } } function addRooms(knife: Knife, vaultTemplates: VaultTemplate[]): Rect[] { vaultTemplates = [...vaultTemplates]; // so we can mutate it shuffle(vaultTemplates); let chain = new RoomChain(knife.map.size); let nVaults = 0; let nVaultsDesired = randrange(MIN_VAULTS, MAX_VAULTS + 1); for ( let i = 0; vaultTemplates.length > 0 && nVaults < nVaultsDesired && i < NUM_VAULT_TRIES; i += 1 ) { let width = 7; let height = 7; let room = chain.reserve(width, height); if (!room) { continue; } nVaults += 1; carveVault(knife, room, vaultTemplates.pop()!); } // staircases let nStaircases = 0; let nStaircasesDesired = NUM_STAIRCASES_DESIRED; for ( let i = 0; nStaircases < nStaircasesDesired && i < NUM_STAIRCASE_TRIES; i += 1 ) { let width = 3; let height = 3; let room = chain.reserve(width, height); if (!room) { continue; } nStaircases += 1; carveStaircase(knife, room, nStaircases - 1); } if (nStaircases == 0) { throw new TryAgainException("couldn't make any staircases"); } // rooms let nRooms = 0; let nRoomsDesired = NUM_ROOMS_DESIRED; for (let i = 0; nRooms < nRoomsDesired && i < NUM_ROOM_TRIES; i += 1) { let [width, height] = choose([ [3, 5], [5, 3], ]); let room = chain.reserve(width, height); if (!room) { continue; } nRooms += 1; carveRoom(knife, room); } return chain.rooms; } function carveVault(knife: Knife, room: Rect, vaultTemplate: VaultTemplate) { if (room.size.w != 7 || room.size.h != 7) { throw new Error("room must be 7x7"); } let quad0 = new Rect(room.top, new Size(3, 3)); let quad1 = new Rect(room.top.offset(new Point(4, 0)), new Size(3, 3)); let quad2 = new Rect(room.top.offset(new Point(4, 4)), new Size(3, 3)); let quad3 = new Rect(room.top.offset(new Point(0, 4)), new Size(3, 3)); let [a, b, c, d] = choose([ [quad0, quad1, quad2, quad3], [quad1, quad2, quad3, quad0], [quad2, quad3, quad0, quad1], [quad3, quad0, quad1, quad2], [quad3, quad2, quad1, quad0], [quad2, quad1, quad0, quad3], [quad1, quad0, quad3, quad2], [quad0, quad3, quad2, quad1], ]); let ab = mergeRects(a, b); knife.startRegion(); knife.carveRoom( ab, false, vaultTemplate.microtheme(), vaultTemplate.roomLabels.hall, ); knife.carveRoom( c, true, vaultTemplate.microtheme(), vaultTemplate.roomLabels.backroom, ); knife.carveRoom( d, true, vaultTemplate.microtheme(), vaultTemplate.roomLabels.closet, ); // now place standard pickups for (let dy = 0; dy < ab.size.h; dy++) { for (let dx = 0; dx < ab.size.w; dx++) { // this is a ratio of 14 to 6 let xy = ab.top.offset(new Point(dx, dy)); let stat = vaultTemplate.stats.secondary; if (dx == 0 || dy == 0 || dx == ab.size.w - 1 || dy == ab.size.h - 1) { stat = vaultTemplate.stats.primary; } if (!(a.contains(xy) || b.contains(xy))) { stat = vaultTemplate.stats.secondary; } knife.map.get(xy).pickup = new BreakableBlockPickup( new StatPickupCallbacks(stat), ); } } for (let dy = 0; dy < c.size.h; dy++) { for (let dx = 0; dx < c.size.w; dx++) { let xy = c.top.offset(new Point(dx, dy)); knife.map.get(xy).pickup = new BreakableBlockPickup( new StatPickupCallbacks(vaultTemplate.stats.primary), ); } } for (let dy = 0; dy < d.size.h; dy++) { for (let dx = 0; dx < d.size.w; dx++) { let xy = d.top.offset(new Point(dx, dy)); knife.map.get(xy).pickup = new BreakableBlockPickup( new StatPickupCallbacks(vaultTemplate.stats.primary), ); } } // now build connectors let connectors = [ new Point(3, 1), new Point(5, 3), new Point(3, 5), new Point(1, 3), ]; for (let offset of connectors.values()) { let connector = room.top.offset(offset); if (mergeRects(b, c).contains(connector)) { // TODO: Put check 1 here let check = vaultTemplate.checks[0]; if (check != null) { knife.map.get(connector).pickup = new LockPickup(check); } knife.carve( connector, vaultTemplate.microtheme(), vaultTemplate.roomLabels.backroom, ); } if (mergeRects(c, d).contains(connector)) { // TODO: Put check 2 here let check = vaultTemplate.checks[1]; if (check != null) { knife.map.get(connector).pickup = new LockPickup(check); } knife.carve( connector, vaultTemplate.microtheme(), vaultTemplate.roomLabels.closet, ); } } // now place goodies let goodies = [ new Point(1, 1), new Point(5, 1), new Point(1, 5), new Point(5, 5), ]; for (let offset of goodies.values()) { let goodie = room.top.offset(offset); let cell = knife.map.get(goodie); if (a.contains(goodie)) { cell.pickup = new BreakableBlockPickup(new ExperiencePickupCallbacks()); let thrall = vaultTemplate.thrall(); if (!getPlayerProgress().isThrallUnlocked(thrall)) { cell.pickup = new ThrallPickup(thrall); } } if (b.contains(goodie)) { cell.pickup = new BreakableBlockPickup(new ExperiencePickupCallbacks()); } if (c.contains(goodie)) { cell.pickup = new BreakableBlockPickup(new ExperiencePickupCallbacks()); // TODO: Fill this room with the common item for this room } if (d.contains(goodie)) { cell.pickup = new BreakableBlockPickup(new ExperiencePickupCallbacks()); // replace with a fancy item if nothing is eligible let thrallItem = vaultTemplate.thrallItem(); let stage = getPlayerProgress().getThrallItemStage(thrallItem); if (stage == ItemStage.Untouched) { cell.pickup = new ThrallItemPickup(thrallItem); } } } } function carveStaircase(knife: Knife, room: Rect, ix: number) { carveRoom(knife, room, null, "Stairwell"); let x = Math.floor(room.top.x + room.size.w / 2); let y = Math.floor(room.top.y + room.size.h / 2); let center = new Point(x, y); if (ix == 0) { // first staircase is the player entrance knife.map.entrance = center; knife.map.get(center).pickup = null; } else { knife.map.get(center).pickup = new LadderPickup(); } } function carveRoom( knife: Knife, room: Rect, theme?: Microtheme | null, label?: string, ) { knife.startRegion(); knife.carveRoom(room, false, theme, label); for (let dy = 0; dy < Math.ceil(room.size.h / 2); dy++) { for (let dx = 0; dx < Math.ceil(room.size.w / 2); dx++) { let xy0 = room.top.offset(new Point(dx, dy)); let xy1 = room.top.offset(new Point(room.size.w - dx - 1, dy)); let xy2 = room.top.offset(new Point(dx, room.size.h - dy - 1)); let xy3 = room.top.offset( new Point(room.size.w - dx - 1, room.size.h - dy - 1), ); let stat = choose(ALL_STATS); let cb = choose([ () => new StatPickupCallbacks(stat), () => new StatPickupCallbacks(stat), () => new StatPickupCallbacks(stat), () => new ExperiencePickupCallbacks(), ]); knife.map.get(xy0).pickup = new BreakableBlockPickup(cb()); knife.map.get(xy1).pickup = new BreakableBlockPickup(cb()); knife.map.get(xy2).pickup = new BreakableBlockPickup(cb()); knife.map.get(xy3).pickup = new BreakableBlockPickup(cb()); } } } let mergeRects = (a: Rect, b: Rect) => { let abx0 = Math.min(a.top.x, b.top.x); let aby0 = Math.min(a.top.y, b.top.y); let abx1 = Math.max(a.top.x + a.size.w, b.top.x + b.size.w); let aby1 = Math.max(a.top.y + a.size.h, b.top.y + b.size.h); return new Rect(new Point(abx0, aby0), new Size(abx1 - abx0, aby1 - aby0)); }; export const CARDINAL_DIRECTIONS = [ new Point(-1, 0), new Point(0, -1), new Point(1, 0), new Point(0, 1), ]; function connectRegions(knife: Knife) { // this procedure is really complicated // so please read here: https://github.com/munificent/hauberk/blob/db360d9efa714efb6d937c31953ef849c7394a39/lib/src/content/dungeon.dart#L173 let connectorRegions: Grid = new Grid(knife.map.size, () => []); let connectors: Point[] = []; for (let y = 1; y < knife.map.size.h - 1; y++) { for (let x = 1; x < knife.map.size.w - 1; x++) { let pos = new Point(x, y); if (knife.sealedWalls.get(pos)) { continue; } if (knife.map.get(pos).architecture != Architecture.Wall) { continue; } let regions = []; for (let offset of CARDINAL_DIRECTIONS.values()) { let region = knife.regions.get(pos.offset(offset)); if (region != null) { regions.push(region); } } regions = dedup(regions); if (regions.length < 2) { continue; } connectorRegions.set(pos, regions); connectors.push(pos); } } // map from original index to "region it has been merged to" index let merged: Record = {}; let openRegions = []; for (let i = 0; i <= knife.region; i++) { merged[i] = i; openRegions.push(i); } let iter = 0; while (openRegions.length > 1) { if (iter > 100) { throw new TryAgainException( "algorithm was not quiescent for some reason", ); } iter++; knife.showDebug(merged); if (connectors.length == 0) { throw new TryAgainException( "couldn't figure out how to connect sections", ); } let connector = choose(connectors); // create the connections knife.map.get(connector).architecture = Architecture.Floor; let basicRegions: number[] = connectorRegions.get(connector); let sources: number[] = dedup(basicRegions.map((i) => merged[i])); let dest: number | undefined = sources.pop(); if (dest == undefined) { throw new BadMapError( `each connector should touch more than one region but ${connector} does not`, knife.map, ); } if (Math.random() > EXTRA_CONNECTOR_CHANCE) { // at random, don't regard them as merged for (let i = 0; i <= knife.region; i++) { if (sources.indexOf(merged[i]) != -1) { merged[i] = dest; } } for (let src of sources.values()) { let ix = openRegions.indexOf(src); if (ix != -1) { openRegions.splice(ix, 1); } } } let connectors2 = []; for (let other of connectors.values()) { if (other.manhattan(connector) == 1) { continue; } let connected = dedup(connectorRegions.get(other).map((m) => merged[m])); if (connected.length <= 1) { continue; } connectors2.push(other); } connectors = connectors2; } knife.showDebug(merged); // The map should now be fully connected. if (!knife.map.isConnected()) { throw new BadMapError("unconnected", knife.map); } } function growMaze(knife: Knife, start: Point) { let cells: Point[] = []; let lastDir: Point | null = null; knife.startRegion(); knife.carve(start); cells.push(start); while (cells.length > 0) { let cell = cells[cells.length - 1]; let unmadeCells: Point[] = []; let lastDirOk = false; for (let dir of CARDINAL_DIRECTIONS.values()) { if (canCarve(knife, cell, dir)) { unmadeCells.push(dir); if (lastDir != null && dir.equals(lastDir)) { lastDirOk = true; } } } if (unmadeCells.length == 0) { cells.pop(); lastDir = null; continue; } let dir: Point; if (lastDirOk && randrange(0, 100) > WINDING_PERCENT) { dir = lastDir!; } else { dir = choose(unmadeCells); // TODO: Constrain windiness as Nystrom did } let c1 = cell.offset(dir); let c2 = cell.offset(dir).offset(dir); knife.carve(c1); knife.carve(c2); cells.push(c2); lastDir = dir; } } function canCarve(knife: Knife, pos: Point, direction: Point) { let c2 = pos.offset(direction).offset(direction); let c3 = c2.offset(direction); let rect = new Rect(new Point(0, 0), knife.map.size); if (!rect.contains(c3)) { return false; } return knife.map.get(c2).architecture == Architecture.Wall; } function removeDeadEnds(knife: Knife) { let done = false; while (!done) { done = true; for (let y = 1; y < knife.map.size.h - 1; y++) { for (let x = 1; x < knife.map.size.w - 1; x++) { let xy = new Point(x, y); if (knife.map.get(xy).architecture == Architecture.Wall) { continue; } let exits = 0; for (let dir of CARDINAL_DIRECTIONS.values()) { if (knife.map.get(xy.offset(dir)).architecture != Architecture.Wall) { exits++; } } if (exits != 1) { continue; } done = false; knife.map.get(xy).architecture = Architecture.Wall; } } } } function decorateRoom(_map: LoadedNewMap, _rect: Rect) {} function randrange(lo: number, hi: number) { if (lo >= hi) { throw new Error(`randrange: hi must be >= lo, ${hi}, ${lo}`); } return lo + Math.floor(Math.random() * (hi - lo)); } function dedup(items: number[]): number[] { let deduped = []; for (let i of items.values()) { if (deduped.indexOf(i) != -1) { continue; } deduped.push(i); } return deduped; } function showDebug(grid: LoadedNewMap) { if (true) { let out = ""; for (let y = 0; y < grid.size.h; y++) { for (let x = 0; x < grid.size.w; x++) { out += grid.get(new Point(x, y)).architecture == Architecture.Wall ? "#" : "."; } out += "\n"; } console.log(out); } } class TryAgainException extends Error {} class BadMapError extends Error { badMap: LoadedNewMap; constructor(msg: string, badMap: LoadedNewMap) { super(msg); this.badMap = badMap; } }