fledgling/src/mapgen.ts
2025-02-23 19:58:00 -08:00

782 lines
20 KiB
TypeScript

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<number | null>;
#sealedWalls: Grid<boolean>;
constructor(
map: LoadedNewMap,
regions: Grid<number | null>,
sealedWalls: Grid<boolean>,
) {
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<number | null> {
return this.#regions;
}
get sealedWalls(): Grid<boolean> {
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<number, number>) {
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<number | null> = new Grid(grid.size, () => null);
let sealedWalls: Grid<boolean> = 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<number[]> = 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<number, number> = {};
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;
}
}