BSP dungeon generator
This commit is contained in:
parent
925b3aabaa
commit
7cc1d5f504
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue