1
0
Fork 0

BSP dungeon generator

This commit is contained in:
Pabloader 2026-05-05 07:19:34 +00:00
parent 925b3aabaa
commit 7cc1d5f504
4 changed files with 214 additions and 18 deletions

203
src/common/level/bsp.ts Normal file
View File

@ -0,0 +1,203 @@
import { SeededRandom } from "@common/random";
export namespace BSP {
export interface BSPOptions {
depth?: number;
minWidth?: number;
minHeight?: number;
seed?: string | number;
random?: SeededRandom;
}
interface Node {
id: string;
x: number;
y: number;
width: number;
height: number;
depth: number;
children: Node[];
}
type Carver = (x: number, y: number, node?: Node) => void;
export function generateLevel(
width: number,
height: number,
carver: Carver,
options?: BSPOptions,
) {
const minWidth = options?.minWidth ?? 4;
const minHeight = options?.minHeight ?? 4;
const depth = options?.depth ?? 4;
const random = options?.random ?? new SeededRandom(options?.seed);
const root: Node = {
id: '/',
x: 0,
y: 0,
width: width,
height: height,
depth: 0,
children: []
};
const carved = new Set<string>();
const dedupCarver = (x: number, y: number, node?: Node) => {
if (carved.has(`${x},${y}`)) return;
carved.add(`${x},${y}`);
carver(x, y, node);
}
const stack = [root];
while (stack.length > 0) {
const node = stack.pop()!;
if (node.depth >= depth) continue;
const splitHorizontally = node.width > node.height;
if (splitHorizontally && node.width > minWidth * 2) {
const splitX = random.randInt(minWidth, node.width - minWidth);
const nodeA: Node = {
id: node.id + '/A',
x: node.x,
y: node.y,
width: splitX,
height: node.height,
depth: node.depth + 1,
children: []
};
const nodeB: Node = {
id: node.id + '/B',
x: node.x + splitX,
y: node.y,
width: node.width - splitX,
height: node.height,
depth: node.depth + 1,
children: []
};
node.children = [nodeA, nodeB];
stack.push(nodeA, nodeB);
} else if (!splitHorizontally && node.height > minHeight * 2) {
const splitY = random.randInt(minHeight, node.height - minHeight);
const nodeA: Node = {
id: node.id + '/A',
x: node.x,
y: node.y,
width: node.width,
height: splitY,
depth: node.depth + 1,
children: []
};
const nodeB: Node = {
id: node.id + '/B',
x: node.x,
y: node.y + splitY,
width: node.width,
height: node.height - splitY,
depth: node.depth + 1,
children: []
};
node.children = [nodeA, nodeB];
stack.push(nodeA, nodeB);
} else {
// Leaf node
node.children = [];
}
}
// Carve rooms
stack.push(root);
while (stack.length > 0) {
const node = stack.pop()!;
if (node.children.length === 0) {
const width = random.randInt(minWidth, node.width) - 2;
const height = random.randInt(minHeight, node.height) - 2;
const x = random.randInt(node.x + 1, node.x + node.width - width - 1);
const y = random.randInt(node.y + 1, node.y + node.height - height - 1);
node.x = x;
node.y = y;
node.width = width;
node.height = height;
carveRoom(x, y, width, height, node, dedupCarver);
} else {
stack.push(...node.children);
}
}
stack.push(root);
// Carve corridors
while (stack.length > 0) {
const node = stack.pop()!;
if (node.children.length === 0) continue;
const [nodeA, nodeB] = node.children;
const leafA = findLeaf(nodeA, nodeB);
const leafB = findLeaf(nodeB, nodeA);
carveCorridor(leafA, leafB, dedupCarver, random);
stack.push(...node.children);
}
}
function carveRoom(x: number, y: number, width: number, height: number, node: Node, carver: Carver) {
for (let i = x; i < x + width; i++) {
for (let j = y; j < y + height; j++) {
carver(i, j, node);
}
}
}
function carveCorridor(nodeA: Node, nodeB: Node, carver: Carver, random: SeededRandom) {
let ax = random.randInt(nodeA.x, nodeA.x + nodeA.width);
let ay = random.randInt(nodeA.y, nodeA.y + nodeA.height);
const bx = random.randInt(nodeB.x, nodeB.x + nodeB.width);
const by = random.randInt(nodeB.y, nodeB.y + nodeB.height);
const dx = Math.sign(bx - ax);
const dy = Math.sign(by - ay);
for (; ax !== bx; ax += dx) {
carver(ax, ay);
}
for (; ay != by; ay += dy) {
carver(ax, ay);
}
}
const center = (node: Node) => ({
x: node.x + node.width / 2,
y: node.y + node.height / 2
});
function findLeaf(node: Node, closestNode: Node): Node {
if (node.children.length === 0) {
return node;
}
let minDist = Infinity;
let closest = null;
const closestCenter = center(closestNode);
for (const child of node.children.map(child => findLeaf(child, closestNode))) {
const childCenter = center(child);
const dist = Math.abs(childCenter.x - closestCenter.x) + Math.abs(childCenter.y - closestCenter.y);
if (dist < minDist) {
minDist = dist;
closest = child;
}
}
return closest ?? node;
}
}

View File

@ -125,7 +125,7 @@ export class SeededRandom {
throw new RangeError(`randInt: bounds must be integers, got (${min}, ${hi})`);
}
if (min >= hi) {
throw new RangeError(`randInt: min (${min}) must be strictly less than max (${hi})`);
return hi;
}
const range = hi - min;

View File

@ -1,6 +1,6 @@
import { createCanvas } from "@common/display/canvas";
import { gameLoop } from "@common/game";
import { circleGen } from "@common/navigation/bresenham";
import { bresenhamCircleGen } from "@common/navigation/bresenham";
import Physics from "@common/physics";
import { mapNumber } from "@common/utils";
@ -162,7 +162,7 @@ const frame = (dt: number, state: State) => {
function fillCircle(ctx: CanvasRenderingContext2D, xc: number, yc: number, r: number) {
// because default circle fill is antialiased
for (const { x, y } of circleGen(xc, yc, r, { fill: true })) {
for (const { x, y } of bresenhamCircleGen(xc, yc, r, { fill: true })) {
ctx.fillRect(x, y, 1, 1);
}
}

View File

@ -1,9 +1,8 @@
import { createCanvas } from "@common/display/canvas";
import { bresenhamCircleGen } from "@common/navigation/bresenham";
import { BSP } from "@common/level/bsp";
import { stringHash } from "@common/utils";
const S = 20;
const breaker = (x: number, y: number) => Math.hypot(x - S * 0.3, y - S * 0.3) <= S / 16;
const S = 512;
export default async function main() {
const canvas = createCanvas(S, S);
@ -11,16 +10,10 @@ export default async function main() {
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.fillStyle = 'black';
for (const fill of ['fov', 'shadow'] as const) {
let i = 0;
console.time(`fill=${fill}`);
for (const { x, y } of bresenhamCircleGen(S / 2, S / 2, S * 0.4, { fill, breaker })) {
i++;
BSP.generateLevel(S, S, (x, y, node) => {
ctx.fillStyle = node?.id ? `hsl(${stringHash(node.id) % 360}, 100%, 20%)`: 'black';
ctx.fillRect(x, y, 1, 1);
}
console.timeEnd(`fill=${fill}`);
console.log(`i=${i}`)
}
}, { depth: 16, minWidth: 8, minHeight: 8 });
console.log('done');
}