From 7cc1d5f504f63fa671c6c73559877407c4a49b0b Mon Sep 17 00:00:00 2001 From: Pabloader Date: Tue, 5 May 2026 07:19:34 +0000 Subject: [PATCH] BSP dungeon generator --- src/common/level/bsp.ts | 203 +++++++++++++++++++++++++++++++++ src/common/random.ts | 2 +- src/games/dropballs/index.ts | 4 +- src/games/playground/index.tsx | 23 ++-- 4 files changed, 214 insertions(+), 18 deletions(-) create mode 100644 src/common/level/bsp.ts diff --git a/src/common/level/bsp.ts b/src/common/level/bsp.ts new file mode 100644 index 0000000..fac76de --- /dev/null +++ b/src/common/level/bsp.ts @@ -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(); + + 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; + } +} \ No newline at end of file diff --git a/src/common/random.ts b/src/common/random.ts index 11d57f7..e5f3a67 100644 --- a/src/common/random.ts +++ b/src/common/random.ts @@ -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; diff --git a/src/games/dropballs/index.ts b/src/games/dropballs/index.ts index 79594d1..2a907a3 100644 --- a/src/games/dropballs/index.ts +++ b/src/games/dropballs/index.ts @@ -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); } } diff --git a/src/games/playground/index.tsx b/src/games/playground/index.tsx index 6eb35ae..701f595 100644 --- a/src/games/playground/index.tsx +++ b/src/games/playground/index.tsx @@ -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; + 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); + }, { depth: 16, minWidth: 8, minHeight: 8 }); - console.time(`fill=${fill}`); - for (const { x, y } of bresenhamCircleGen(S / 2, S / 2, S * 0.4, { fill, breaker })) { - i++; - ctx.fillRect(x, y, 1, 1); - } - console.timeEnd(`fill=${fill}`); - console.log(`i=${i}`) - } + console.log('done'); }