From 6bdf688bd74e4972de1e7461be60cae26d75ba60 Mon Sep 17 00:00:00 2001 From: Bob Carmichael Date: Mon, 15 Dec 2025 05:31:57 -0500 Subject: [PATCH 01/22] maze shape --- src/features/shapes/maze/LICENSE | 2 + src/features/shapes/maze/Maze.js | 216 ++++++++++++++++++ .../shapes/maze/algorithms/backtracker.js | 58 +++++ .../shapes/maze/algorithms/console.js | 38 +++ .../shapes/maze/algorithms/division.js | 78 +++++++ .../shapes/maze/algorithms/kruskal.js | 95 ++++++++ src/features/shapes/maze/algorithms/prim.js | 65 ++++++ .../shapes/maze/algorithms/sidewinder.js | 50 ++++ src/features/shapes/maze/algorithms/wilson.js | 96 ++++++++ src/features/shapes/shapeFactory.js | 2 + 10 files changed, 700 insertions(+) create mode 100644 src/features/shapes/maze/LICENSE create mode 100644 src/features/shapes/maze/Maze.js create mode 100644 src/features/shapes/maze/algorithms/backtracker.js create mode 100644 src/features/shapes/maze/algorithms/console.js create mode 100644 src/features/shapes/maze/algorithms/division.js create mode 100644 src/features/shapes/maze/algorithms/kruskal.js create mode 100644 src/features/shapes/maze/algorithms/prim.js create mode 100644 src/features/shapes/maze/algorithms/sidewinder.js create mode 100644 src/features/shapes/maze/algorithms/wilson.js diff --git a/src/features/shapes/maze/LICENSE b/src/features/shapes/maze/LICENSE new file mode 100644 index 00000000..050823b3 --- /dev/null +++ b/src/features/shapes/maze/LICENSE @@ -0,0 +1,2 @@ +"Maze Generation: Wilson's Algorithm" by Jamis Buck, licensed under CC BY-NC-SA 4.0 +(https://creativecommons.org/licenses/by-nc-sa/4.0/deed.en). Modified from the original. diff --git a/src/features/shapes/maze/Maze.js b/src/features/shapes/maze/Maze.js new file mode 100644 index 00000000..79e4e482 --- /dev/null +++ b/src/features/shapes/maze/Maze.js @@ -0,0 +1,216 @@ +import Victor from "victor" +import Shape from "../Shape" +import seedrandom from "seedrandom" +import Graph from "@/common/Graph" +import { eulerianTrail } from "@/common/eulerian_trail/eulerianTrail" +import { difference } from "@/common/util" +import { cloneVertices, centerOnOrigin } from "@/common/geometry" +import { wilson } from "./algorithms/wilson" +import { backtracker } from "./algorithms/backtracker" +import { division } from "./algorithms/division" +import { prim } from "./algorithms/prim" +import { kruskal } from "./algorithms/kruskal" +import { sidewinder } from "./algorithms/sidewinder" +import { consoleDisplay } from "./algorithms/console" + +const algorithms = { + wilson, + backtracker, + division, + prim, + kruskal, + sidewinder, + consoleDisplay, +} +const N = 1 +const S = 2 +const E = 4 +const W = 8 +const IN = 0x10 // good for tracking visited cells +const DX = { [E]: 1, [W]: -1, [N]: 0, [S]: 0 } +const DY = { [E]: 0, [W]: 0, [N]: -1, [S]: 1 } +const OPPOSITE = { [E]: W, [W]: E, [N]: S, [S]: N } +const options = { + mazeType: { + title: "Algorithm", + type: "dropdown", + choices: ["Wilson", "Backtracker", "Division", "Prim", "Kruskal", "Sidewinder"], + }, + mazeWidth: { + title: "Maze width", + min: 1, + max: 20, + }, + mazeHeight: { + title: "Maze height", + min: 1, + max: 20, + }, + seed: { + title: "Random seed", + min: 1, + randomMax: 1000, + }, +} + +export default class Maze extends Shape { + constructor() { + super("maze") + this.label = "Maze" + } + + getInitialState() { + return { + ...super.getInitialState(), + ...{ + mazeType: "Wilson", + mazeWidth: 8, + mazeHeight: 8, + seed: 1, + }, + } + } + + getVertices(state) { + const { mazeType, mazeWidth, mazeHeight, seed } = state.shape + const width = Math.max(2, mazeWidth) + const height = Math.max(2, mazeHeight) + + this.setup(width, height, seed) + this.generateMaze(mazeType, width, height) + + return this.drawMaze(width, height) + } + + drawMaze(mazeWidth, mazeHeight) { + const wallSegments = this.extractWallSegments(mazeWidth, mazeHeight) + const graph = new Graph() + + wallSegments.forEach(([v1, v2]) => { + graph.addNode(v1) + graph.addNode(v2) + graph.addEdge(v1, v2) + }) + + const trail = eulerianTrail({ edges: Object.values(graph.edgeMap) }) + let prevKey + const walkedVertices = [] + const walkedEdges = new Set( + trail.slice(0, -1).map((key, i) => [key, trail[i + 1]].sort().toString()) + ) + + // find edges that weren't walked + const missingEdges = Array.from( + difference(walkedEdges, graph.edgeKeys), + ).reduce((hash, d) => { + d = d.split(",") + hash[d[0] + "," + d[1]] = d[2] + "," + d[3] + + return hash + }, {}) + + // walk the trail, filling gaps with Dijkstra shortest paths + trail.forEach((key) => { + const vertex = graph.nodeMap[key] + + if (prevKey) { + if (!graph.hasEdge(key, prevKey)) { + const path = graph.dijkstraShortestPath(prevKey, key) + + path.shift() + walkedVertices.push(...path, vertex) + } else { + walkedVertices.push(vertex) + } + } else { + walkedVertices.push(vertex) + } + + // add back any missing edges + if (missingEdges[key]) { + const missingVertex = graph.nodeMap[missingEdges[key]] + const edgeKey = [key, missingEdges[key]].sort().toString() + + if (graph.edgeMap[edgeKey]) { + walkedVertices.push(missingVertex) + walkedVertices.push(vertex) + } + + delete missingEdges[key] + } + + prevKey = key + }) + + const clonedVertices = cloneVertices(walkedVertices) + + centerOnOrigin(clonedVertices) + + return clonedVertices + } + + extractWallSegments(width, height) { + const walls = [] + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const cell = this.grid[y][x] + + // North wall (top of cell) + if (y === 0 || !(cell & N)) { + walls.push([ + new Victor(x, y), + new Victor(x + 1, y), + ]) + } + + // South wall (bottom of cell) + if (y === height - 1 || !(cell & S)) { + walls.push([ + new Victor(x, y + 1), + new Victor(x + 1, y + 1), + ]) + } + + // West wall (left of cell) + if (x === 0 || !(cell & W)) { + walls.push([ + new Victor(x, y), + new Victor(x, y + 1), + ]) + } + + // East wall (right of cell) + if (x === width - 1 || !(cell & E)) { + walls.push([ + new Victor(x + 1, y), + new Victor(x + 1, y + 1), + ]) + } + } + } + + return walls + } + + setup(width, height, seed) { + this.rng = seedrandom(seed) + this.grid = Array(height) + .fill(0) + .map(() => Array(width).fill(0)) + + // initialize a random starting cell + this.grid[Math.floor(this.rng() * height)][Math.floor(this.rng() * width)] = + IN + } + + generateMaze(mazeType, width, height) { + const algorithm = algorithms[mazeType.toLowerCase()] || wilson + + algorithm(this.grid, width, height, this.rng) + } + + getOptions() { + return options + } +} diff --git a/src/features/shapes/maze/algorithms/backtracker.js b/src/features/shapes/maze/algorithms/backtracker.js new file mode 100644 index 00000000..c6f5c7c7 --- /dev/null +++ b/src/features/shapes/maze/algorithms/backtracker.js @@ -0,0 +1,58 @@ +// Recursive Backtracker algorithm for maze generation +// Creates long, winding passages using depth-first search + +const N = 1 +const S = 2 +const E = 4 +const W = 8 +const IN = 0x10 +const DX = { [E]: 1, [W]: -1, [N]: 0, [S]: 0 } +const DY = { [E]: 0, [W]: 0, [N]: -1, [S]: 1 } +const OPPOSITE = { [E]: W, [W]: E, [N]: S, [S]: N } + +export const backtracker = (grid, width, height, rng) => { + const stack = [] + const startX = Math.floor(rng() * width) + const startY = Math.floor(rng() * height) + + grid[startY][startX] = IN + stack.push([startX, startY]) + + while (stack.length > 0) { + const [cx, cy] = stack[stack.length - 1] + const unvisitedNeighbors = [] + + // Check all four directions for unvisited neighbors + const directions = [N, S, E, W] + for (const dir of directions) { + const nx = cx + DX[dir] + const ny = cy + DY[dir] + + if ( + nx >= 0 && + ny >= 0 && + ny < height && + nx < width && + grid[ny][nx] === 0 + ) { + unvisitedNeighbors.push({ dir, nx, ny }) + } + } + + if (unvisitedNeighbors.length > 0) { + // Choose a random unvisited neighbor + const idx = Math.floor(rng() * unvisitedNeighbors.length) + const { dir, nx, ny } = unvisitedNeighbors[idx] + + // Remove wall between current cell and chosen neighbor + grid[cy][cx] |= dir + grid[ny][nx] |= OPPOSITE[dir] | IN + + // Push neighbor onto stack + stack.push([nx, ny]) + } else { + // Backtrack + stack.pop() + } + } +} diff --git a/src/features/shapes/maze/algorithms/console.js b/src/features/shapes/maze/algorithms/console.js new file mode 100644 index 00000000..88d406b8 --- /dev/null +++ b/src/features/shapes/maze/algorithms/console.js @@ -0,0 +1,38 @@ +// Utility for debugging maze generation - displays ASCII art in console + +const S = 2 +const E = 4 + +export const consoleDisplay = (grid, width, height) => { + let mazeOutput = " " + "_".repeat(width * 2 - 1) + "\n" + + grid.forEach((row, y) => { + mazeOutput += "|" + + row.forEach((cell, x) => { + // determine if a south wall exists + if (cell === 0 && y + 1 < height && grid[y + 1][x] === 0) { + mazeOutput += " " + } else { + mazeOutput += (cell & S) !== 0 ? " " : "_" + } + + // determine if an east wall exists + if (cell === 0 && x + 1 < width && row[x + 1] === 0) { + mazeOutput += + y + 1 < height && (grid[y + 1][x] === 0 || grid[y + 1][x + 1] === 0) + ? " " + : "_" + } else if ((cell & E) !== 0) { + mazeOutput += ((cell | row[x + 1]) & S) !== 0 ? " " : "_" + } else { + mazeOutput += "|" + } + }) + + mazeOutput += "\n" + }) + + // eslint-disable-next-line no-console + console.log(mazeOutput) +} diff --git a/src/features/shapes/maze/algorithms/division.js b/src/features/shapes/maze/algorithms/division.js new file mode 100644 index 00000000..a1e72336 --- /dev/null +++ b/src/features/shapes/maze/algorithms/division.js @@ -0,0 +1,78 @@ +// Recursive Division algorithm for maze generation +// Divides space with walls, leaving passages - creates long straight corridors + +const N = 1 +const S = 2 +const E = 4 +const W = 8 +const IN = 0x10 +const DX = { [E]: 1, [W]: -1, [N]: 0, [S]: 0 } +const DY = { [E]: 0, [W]: 0, [N]: -1, [S]: 1 } +const OPPOSITE = { [E]: W, [W]: E, [N]: S, [S]: N } + +const divide = (grid, x, y, width, height, rng) => { + if (width < 2 || height < 2) return + + const horizontal = height > width || (height === width && rng() < 0.5) + + if (horizontal) { + // Divide horizontally + const wallY = y + Math.floor(rng() * (height - 1)) + const passageX = x + Math.floor(rng() * width) + + // Add horizontal wall with passage + for (let wx = x; wx < x + width; wx++) { + if (wx !== passageX) { + // Remove south passage from cells above the wall + grid[wallY][wx] &= ~S + // Remove north passage from cells below the wall + if (wallY + 1 < grid.length) { + grid[wallY + 1][wx] &= ~N + } + } + } + + // Recursively divide the two sections + divide(grid, x, y, width, wallY - y + 1, rng) + divide(grid, x, wallY + 1, width, y + height - wallY - 1, rng) + } else { + // Divide vertically + const wallX = x + Math.floor(rng() * (width - 1)) + const passageY = y + Math.floor(rng() * height) + + // Add vertical wall with passage + for (let wy = y; wy < y + height; wy++) { + if (wy !== passageY) { + // Remove east passage from cells left of the wall + grid[wy][wallX] &= ~E + // Remove west passage from cells right of the wall + if (wallX + 1 < grid[wy].length) { + grid[wy][wallX + 1] &= ~W + } + } + } + + // Recursively divide the two sections + divide(grid, x, y, wallX - x + 1, height, rng) + divide(grid, wallX + 1, y, x + width - wallX - 1, height, rng) + } +} + +export const division = (grid, width, height, rng) => { + // Start with all passages open (no walls) + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let cell = IN + + if (y > 0) cell |= N + if (y < height - 1) cell |= S + if (x > 0) cell |= W + if (x < width - 1) cell |= E + + grid[y][x] = cell + } + } + + // Recursively divide the space + divide(grid, 0, 0, width, height, rng) +} diff --git a/src/features/shapes/maze/algorithms/kruskal.js b/src/features/shapes/maze/algorithms/kruskal.js new file mode 100644 index 00000000..380f49c8 --- /dev/null +++ b/src/features/shapes/maze/algorithms/kruskal.js @@ -0,0 +1,95 @@ +// Kruskal's algorithm for maze generation +// Works on edges, creates a "random forest" that merges together + +const N = 1 +const S = 2 +const E = 4 +const W = 8 +const IN = 0x10 +const DX = { [E]: 1, [W]: -1, [N]: 0, [S]: 0 } +const DY = { [E]: 0, [W]: 0, [N]: -1, [S]: 1 } +const OPPOSITE = { [E]: W, [W]: E, [N]: S, [S]: N } + +// Simple union-find data structure +class UnionFind { + constructor(size) { + this.parent = Array(size) + .fill(0) + .map((_, i) => i) + this.rank = Array(size).fill(0) + } + + find(x) { + if (this.parent[x] !== x) { + this.parent[x] = this.find(this.parent[x]) // path compression + } + return this.parent[x] + } + + union(x, y) { + const rootX = this.find(x) + const rootY = this.find(y) + + if (rootX === rootY) return false + + // Union by rank + if (this.rank[rootX] < this.rank[rootY]) { + this.parent[rootX] = rootY + } else if (this.rank[rootX] > this.rank[rootY]) { + this.parent[rootY] = rootX + } else { + this.parent[rootY] = rootX + this.rank[rootX]++ + } + + return true + } +} + +export const kruskal = (grid, width, height, rng) => { + // Initialize all cells as separate sets + const uf = new UnionFind(width * height) + + // Mark all cells as IN + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + grid[y][x] = IN + } + } + + // Create list of all possible edges (walls) + const edges = [] + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + // Add south edge + if (y < height - 1) { + edges.push({ x, y, dir: S }) + } + // Add east edge + if (x < width - 1) { + edges.push({ x, y, dir: E }) + } + } + } + + // Shuffle edges randomly + for (let i = edges.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)) + ;[edges[i], edges[j]] = [edges[j], edges[i]] + } + + // Process edges + for (const edge of edges) { + const { x, y, dir } = edge + const cell1 = y * width + x + const nx = x + DX[dir] + const ny = y + DY[dir] + const cell2 = ny * width + nx + + // If cells are in different sets, connect them + if (uf.union(cell1, cell2)) { + grid[y][x] |= dir + grid[ny][nx] |= OPPOSITE[dir] + } + } +} diff --git a/src/features/shapes/maze/algorithms/prim.js b/src/features/shapes/maze/algorithms/prim.js new file mode 100644 index 00000000..af83a14a --- /dev/null +++ b/src/features/shapes/maze/algorithms/prim.js @@ -0,0 +1,65 @@ +// Prim's algorithm for maze generation +// Creates lots of short branches and dead ends - "bushy" appearance + +const N = 1 +const S = 2 +const E = 4 +const W = 8 +const IN = 0x10 +const DX = { [E]: 1, [W]: -1, [N]: 0, [S]: 0 } +const DY = { [E]: 0, [W]: 0, [N]: -1, [S]: 1 } +const OPPOSITE = { [E]: W, [W]: E, [N]: S, [S]: N } + +export const prim = (grid, width, height, rng) => { + const frontier = [] + + // Start with a random cell + const startX = Math.floor(rng() * width) + const startY = Math.floor(rng() * height) + + grid[startY][startX] = IN + + // Add neighbors to frontier + const addFrontier = (x, y) => { + const directions = [N, S, E, W] + for (const dir of directions) { + const nx = x + DX[dir] + const ny = y + DY[dir] + + if ( + nx >= 0 && + ny >= 0 && + ny < height && + nx < width && + grid[ny][nx] === 0 + ) { + grid[ny][nx] = OPPOSITE[dir] // mark with direction back to parent + frontier.push([nx, ny]) + } + } + } + + addFrontier(startX, startY) + + // Process frontier + while (frontier.length > 0) { + // Pick random cell from frontier + const idx = Math.floor(rng() * frontier.length) + const [fx, fy] = frontier[idx] + frontier.splice(idx, 1) + + // Get direction back to parent (stored in grid) + const dir = grid[fy][fx] + + // Find the parent cell (go in the stored direction) + const px = fx + DX[dir] + const py = fy + DY[dir] + + // Connect frontier cell to parent + grid[fy][fx] = IN | dir + grid[py][px] |= OPPOSITE[dir] + + // Add new neighbors to frontier + addFrontier(fx, fy) + } +} diff --git a/src/features/shapes/maze/algorithms/sidewinder.js b/src/features/shapes/maze/algorithms/sidewinder.js new file mode 100644 index 00000000..00e354a4 --- /dev/null +++ b/src/features/shapes/maze/algorithms/sidewinder.js @@ -0,0 +1,50 @@ +// Sidewinder algorithm for maze generation +// Works row by row, creates horizontal bias with long east-west corridors + +const N = 1 +const S = 2 +const E = 4 +const W = 8 +const IN = 0x10 +const OPPOSITE = { [E]: W, [W]: E, [N]: S, [S]: N } + +export const sidewinder = (grid, width, height, rng) => { + // Mark all cells as IN + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + grid[y][x] = IN + } + } + + // Process each row + for (let y = 0; y < height; y++) { + let run = [] + + for (let x = 0; x < width; x++) { + run.push(x) + + // At east boundary or randomly decide to close out the run + const atEastBoundary = x === width - 1 + const atNorthBoundary = y === 0 + const shouldCloseRun = atEastBoundary || (!atNorthBoundary && rng() < 0.5) + + if (shouldCloseRun) { + // Pick random cell from run and carve north (unless at north boundary) + if (!atNorthBoundary) { + const randomIdx = Math.floor(rng() * run.length) + const cellX = run[randomIdx] + + grid[y][cellX] |= N + grid[y - 1][cellX] |= S + } + + // Clear the run + run = [] + } else { + // Carve east + grid[y][x] |= E + grid[y][x + 1] |= W + } + } + } +} diff --git a/src/features/shapes/maze/algorithms/wilson.js b/src/features/shapes/maze/algorithms/wilson.js new file mode 100644 index 00000000..c790a4ab --- /dev/null +++ b/src/features/shapes/maze/algorithms/wilson.js @@ -0,0 +1,96 @@ +// Wilson's algorithm for maze generation +// adapted from https://weblog.jamisbuck.org/2011/1/20/maze-generation-wilson-s-algorithm + +const N = 1 +const S = 2 +const E = 4 +const W = 8 +const DX = { [E]: 1, [W]: -1, [N]: 0, [S]: 0 } +const DY = { [E]: 0, [W]: 0, [N]: -1, [S]: 1 } +const OPPOSITE = { [E]: W, [W]: E, [N]: S, [S]: N } + +const walk = (grid, rng) => { + while (true) { + let cx, cy + + do { + cx = Math.floor(rng() * grid[0].length) + cy = Math.floor(rng() * grid.length) + } while (grid[cy][cx] !== 0) // find an unvisited cell + + const visits = new Map() + + visits.set(`${cx},${cy}`, 0) // store direction as 0 initially, meaning no direction yet + + const startX = cx + const startY = cy + let walking = true + + while (walking) { + walking = false + + // shuffle directions using the seeded random + const directions = [N, S, E, W].sort(() => rng() - 0.5) + + for (const dir of directions) { + const nx = cx + DX[dir] + const ny = cy + DY[dir] + + if ( + nx >= 0 && + ny >= 0 && + ny < grid.length && + nx < grid[ny].length + ) { + visits.set(`${cx},${cy}`, dir) + + if (grid[ny][nx] !== 0) { + // found a visited cell, break the loop and record the path + walking = false + break + } else { + // move to the next cell + cx = nx + cy = ny + walking = true + break + } + } + } + } + + const path = [] + let x = startX + let y = startY + + while (true) { + const dir = visits.get(`${x},${y}`) + + if (dir === undefined || dir === 0) break + + path.push([x, y, dir]) + x = x + DX[dir] + y = y + DY[dir] + } + + return path + } +} + +export const wilson = (grid, width, height, rng) => { + let remaining = width * height - 1 + + while (remaining > 0) { + const currentPath = walk(grid, rng) + + currentPath.forEach(([x, y, dir]) => { + const nx = x + DX[dir] + const ny = y + DY[dir] + + grid[y][x] |= dir + grid[ny][nx] |= OPPOSITE[dir] + + remaining -= 1 + }) + } +} diff --git a/src/features/shapes/shapeFactory.js b/src/features/shapes/shapeFactory.js index 1e40df15..9cb11409 100644 --- a/src/features/shapes/shapeFactory.js +++ b/src/features/shapes/shapeFactory.js @@ -10,6 +10,7 @@ import Hypocycloid from "./Hypocycloid" import ImageImport from "./image_import/ImageImport" import InputText from "./input_text/InputText" import LSystem from "./lsystem/LSystem" +import Maze from "./maze/Maze" import Point from "./Point" import Polygon from "./Polygon" import Reuleaux from "./Reuleaux" @@ -38,6 +39,7 @@ export const shapeFactory = { lsystem: LSystem, fractalSpirograph: FractalSpirograph, tessellationTwist: TessellationTwist, + maze: Maze, voronoi: Voronoi, point: Point, circlePacker: CirclePacker, From 85cda6cc724137690176af7be4018b841c723915 Mon Sep 17 00:00:00 2001 From: Bob Carmichael Date: Tue, 16 Dec 2025 07:37:51 -0500 Subject: [PATCH 02/22] maze-specific options to vary shape; some refactoring --- src/features/shapes/maze/Maze.js | 54 +++++++++++++++++-- .../shapes/maze/algorithms/backtracker.js | 37 ++++++++++--- .../shapes/maze/algorithms/console.js | 2 +- .../shapes/maze/algorithms/division.js | 19 ++++--- .../shapes/maze/algorithms/kruskal.js | 46 ++++++++++++---- src/features/shapes/maze/algorithms/prim.js | 12 +++-- .../shapes/maze/algorithms/sidewinder.js | 8 ++- src/features/shapes/maze/algorithms/wilson.js | 2 +- 8 files changed, 142 insertions(+), 38 deletions(-) diff --git a/src/features/shapes/maze/Maze.js b/src/features/shapes/maze/Maze.js index 79e4e482..efc8cefd 100644 --- a/src/features/shapes/maze/Maze.js +++ b/src/features/shapes/maze/Maze.js @@ -35,6 +35,12 @@ const options = { title: "Algorithm", type: "dropdown", choices: ["Wilson", "Backtracker", "Division", "Prim", "Kruskal", "Sidewinder"], + onChange: (model, changes, state) => { + if (changes.mazeType === "Kruskal") { + changes.mazeHorizontalBias = 5 + } + return changes + }, }, mazeWidth: { title: "Maze width", @@ -46,6 +52,36 @@ const options = { min: 1, max: 20, }, + mazeStraightness: { + title: "Straightness", + type: "slider", + min: 0, + max: 10, + step: 1, + isVisible: (layer, state) => { + return state.mazeType === "Backtracker" || state.mazeType === "Sidewinder" + }, + }, + mazeHorizontalBias: { + title: "Horizontal bias", + type: "slider", + min: 0, + max: 10, + step: 1, + isVisible: (layer, state) => { + return state.mazeType === "Division" || state.mazeType === "Kruskal" + }, + }, + mazeBranchLevel: { + title: "Branch level", + type: "slider", + min: 0, + max: 10, + step: 1, + isVisible: (layer, state) => { + return state.mazeType === "Prim" + }, + }, seed: { title: "Random seed", min: 1, @@ -66,18 +102,21 @@ export default class Maze extends Shape { mazeType: "Wilson", mazeWidth: 8, mazeHeight: 8, + mazeStraightness: 0, + mazeHorizontalBias: 0, + mazeBranchLevel: 5, seed: 1, }, } } getVertices(state) { - const { mazeType, mazeWidth, mazeHeight, seed } = state.shape + const { mazeType, mazeWidth, mazeHeight, mazeStraightness, mazeHorizontalBias, mazeBranchLevel, seed } = state.shape const width = Math.max(2, mazeWidth) const height = Math.max(2, mazeHeight) this.setup(width, height, seed) - this.generateMaze(mazeType, width, height) + this.generateMaze(mazeType, width, height, mazeStraightness, mazeHorizontalBias, mazeBranchLevel) return this.drawMaze(width, height) } @@ -204,10 +243,17 @@ export default class Maze extends Shape { IN } - generateMaze(mazeType, width, height) { + generateMaze(mazeType, width, height, straightness, horizontalBias, branchLevel) { const algorithm = algorithms[mazeType.toLowerCase()] || wilson - algorithm(this.grid, width, height, this.rng) + algorithm(this.grid, { + width, + height, + rng: this.rng, + straightness, + horizontalBias, + branchLevel, + }) } getOptions() { diff --git a/src/features/shapes/maze/algorithms/backtracker.js b/src/features/shapes/maze/algorithms/backtracker.js index c6f5c7c7..24344eaf 100644 --- a/src/features/shapes/maze/algorithms/backtracker.js +++ b/src/features/shapes/maze/algorithms/backtracker.js @@ -10,16 +10,16 @@ const DX = { [E]: 1, [W]: -1, [N]: 0, [S]: 0 } const DY = { [E]: 0, [W]: 0, [N]: -1, [S]: 1 } const OPPOSITE = { [E]: W, [W]: E, [N]: S, [S]: N } -export const backtracker = (grid, width, height, rng) => { +export const backtracker = (grid, { width, height, rng, straightness = 0 }) => { const stack = [] const startX = Math.floor(rng() * width) const startY = Math.floor(rng() * height) grid[startY][startX] = IN - stack.push([startX, startY]) + stack.push([startX, startY, null]) // Include last direction while (stack.length > 0) { - const [cx, cy] = stack[stack.length - 1] + const [cx, cy, lastDir] = stack[stack.length - 1] const unvisitedNeighbors = [] // Check all four directions for unvisited neighbors @@ -40,16 +40,37 @@ export const backtracker = (grid, width, height, rng) => { } if (unvisitedNeighbors.length > 0) { - // Choose a random unvisited neighbor - const idx = Math.floor(rng() * unvisitedNeighbors.length) - const { dir, nx, ny } = unvisitedNeighbors[idx] + let chosenNeighbor + + // Apply straightness bias if we have a previous direction + if (lastDir !== null && straightness > 0) { + const sameDirectionNeighbor = unvisitedNeighbors.find(n => n.dir === lastDir) + + if (sameDirectionNeighbor) { + // Calculate probability to continue straight based on straightness + // straightness 0 = 0% bias, straightness 10 = 90% bias + const continueProb = straightness * 0.09 + + if (rng() < continueProb) { + chosenNeighbor = sameDirectionNeighbor + } + } + } + + // If no bias applied or bias didn't trigger, choose randomly + if (!chosenNeighbor) { + const idx = Math.floor(rng() * unvisitedNeighbors.length) + chosenNeighbor = unvisitedNeighbors[idx] + } + + const { dir, nx, ny } = chosenNeighbor // Remove wall between current cell and chosen neighbor grid[cy][cx] |= dir grid[ny][nx] |= OPPOSITE[dir] | IN - // Push neighbor onto stack - stack.push([nx, ny]) + // Push neighbor onto stack with current direction + stack.push([nx, ny, dir]) } else { // Backtrack stack.pop() diff --git a/src/features/shapes/maze/algorithms/console.js b/src/features/shapes/maze/algorithms/console.js index 88d406b8..b0fc2776 100644 --- a/src/features/shapes/maze/algorithms/console.js +++ b/src/features/shapes/maze/algorithms/console.js @@ -3,7 +3,7 @@ const S = 2 const E = 4 -export const consoleDisplay = (grid, width, height) => { +export const consoleDisplay = (grid, { width, height }) => { let mazeOutput = " " + "_".repeat(width * 2 - 1) + "\n" grid.forEach((row, y) => { diff --git a/src/features/shapes/maze/algorithms/division.js b/src/features/shapes/maze/algorithms/division.js index a1e72336..e5291645 100644 --- a/src/features/shapes/maze/algorithms/division.js +++ b/src/features/shapes/maze/algorithms/division.js @@ -10,10 +10,13 @@ const DX = { [E]: 1, [W]: -1, [N]: 0, [S]: 0 } const DY = { [E]: 0, [W]: 0, [N]: -1, [S]: 1 } const OPPOSITE = { [E]: W, [W]: E, [N]: S, [S]: N } -const divide = (grid, x, y, width, height, rng) => { +const divide = (grid, x, y, width, height, rng, horizontalBias = 0) => { if (width < 2 || height < 2) return - const horizontal = height > width || (height === width && rng() < 0.5) + // Calculate horizontal probability based on horizontalBias + // horizontalBias 0 = 0.1 (prefer vertical walls/horizontal corridors), 10 = 0.9 (prefer horizontal walls/vertical corridors) + const horizontalProb = 0.1 + (horizontalBias * 0.08) + const horizontal = height > width || (height === width && rng() < horizontalProb) if (horizontal) { // Divide horizontally @@ -33,8 +36,8 @@ const divide = (grid, x, y, width, height, rng) => { } // Recursively divide the two sections - divide(grid, x, y, width, wallY - y + 1, rng) - divide(grid, x, wallY + 1, width, y + height - wallY - 1, rng) + divide(grid, x, y, width, wallY - y + 1, rng, horizontalBias) + divide(grid, x, wallY + 1, width, y + height - wallY - 1, rng, horizontalBias) } else { // Divide vertically const wallX = x + Math.floor(rng() * (width - 1)) @@ -53,12 +56,12 @@ const divide = (grid, x, y, width, height, rng) => { } // Recursively divide the two sections - divide(grid, x, y, wallX - x + 1, height, rng) - divide(grid, wallX + 1, y, x + width - wallX - 1, height, rng) + divide(grid, x, y, wallX - x + 1, height, rng, horizontalBias) + divide(grid, wallX + 1, y, x + width - wallX - 1, height, rng, horizontalBias) } } -export const division = (grid, width, height, rng) => { +export const division = (grid, { width, height, rng, horizontalBias = 0 }) => { // Start with all passages open (no walls) for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { @@ -74,5 +77,5 @@ export const division = (grid, width, height, rng) => { } // Recursively divide the space - divide(grid, 0, 0, width, height, rng) + divide(grid, 0, 0, width, height, rng, horizontalBias) } diff --git a/src/features/shapes/maze/algorithms/kruskal.js b/src/features/shapes/maze/algorithms/kruskal.js index 380f49c8..f620d3ad 100644 --- a/src/features/shapes/maze/algorithms/kruskal.js +++ b/src/features/shapes/maze/algorithms/kruskal.js @@ -46,7 +46,7 @@ class UnionFind { } } -export const kruskal = (grid, width, height, rng) => { +export const kruskal = (grid, { width, height, rng, horizontalBias = 0 }) => { // Initialize all cells as separate sets const uf = new UnionFind(width * height) @@ -57,25 +57,49 @@ export const kruskal = (grid, width, height, rng) => { } } - // Create list of all possible edges (walls) - const edges = [] + // Create separate lists for horizontal and vertical edges + const horizontalEdges = [] + const verticalEdges = [] for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { - // Add south edge + // Add south edge (vertical) if (y < height - 1) { - edges.push({ x, y, dir: S }) + verticalEdges.push({ x, y, dir: S }) } - // Add east edge + // Add east edge (horizontal) if (x < width - 1) { - edges.push({ x, y, dir: E }) + horizontalEdges.push({ x, y, dir: E }) } } } - // Shuffle edges randomly - for (let i = edges.length - 1; i > 0; i--) { - const j = Math.floor(rng() * (i + 1)) - ;[edges[i], edges[j]] = [edges[j], edges[i]] + // Shuffle each list independently + const shuffle = (arr) => { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)) + ;[arr[i], arr[j]] = [arr[j], arr[i]] + } + } + shuffle(horizontalEdges) + shuffle(verticalEdges) + + // Interleave edges based on horizontalBias + // horizontalBias 0 = prefer horizontal passages, 10 = prefer vertical passages + const horizontalProb = 0.9 - (horizontalBias * 0.08) + const edges = [] + let hIdx = 0 + let vIdx = 0 + + while (hIdx < horizontalEdges.length || vIdx < verticalEdges.length) { + if (hIdx >= horizontalEdges.length) { + edges.push(verticalEdges[vIdx++]) + } else if (vIdx >= verticalEdges.length) { + edges.push(horizontalEdges[hIdx++]) + } else if (rng() < horizontalProb) { + edges.push(horizontalEdges[hIdx++]) + } else { + edges.push(verticalEdges[vIdx++]) + } } // Process edges diff --git a/src/features/shapes/maze/algorithms/prim.js b/src/features/shapes/maze/algorithms/prim.js index af83a14a..c84a1b25 100644 --- a/src/features/shapes/maze/algorithms/prim.js +++ b/src/features/shapes/maze/algorithms/prim.js @@ -10,7 +10,7 @@ const DX = { [E]: 1, [W]: -1, [N]: 0, [S]: 0 } const DY = { [E]: 0, [W]: 0, [N]: -1, [S]: 1 } const OPPOSITE = { [E]: W, [W]: E, [N]: S, [S]: N } -export const prim = (grid, width, height, rng) => { +export const prim = (grid, { width, height, rng, branchLevel = 0 }) => { const frontier = [] // Start with a random cell @@ -43,8 +43,14 @@ export const prim = (grid, width, height, rng) => { // Process frontier while (frontier.length > 0) { - // Pick random cell from frontier - const idx = Math.floor(rng() * frontier.length) + // Pick cell from frontier based on branchLevel + // branchLevel 0 = bushy (pick from start/FIFO), 10 = winding (pick from end/LIFO) + let idx + // Use power distribution to bias selection + // power > 1 → picks from start (bushy), power < 1 → picks from end (winding) + const t = branchLevel / 10 // 0 (bushy) to 1 (winding) + const power = 2 - 1.5 * t // 2 (bushy) to 0.5 (winding) + idx = Math.floor(Math.pow(rng(), power) * frontier.length) const [fx, fy] = frontier[idx] frontier.splice(idx, 1) diff --git a/src/features/shapes/maze/algorithms/sidewinder.js b/src/features/shapes/maze/algorithms/sidewinder.js index 00e354a4..6b3e33e3 100644 --- a/src/features/shapes/maze/algorithms/sidewinder.js +++ b/src/features/shapes/maze/algorithms/sidewinder.js @@ -8,7 +8,7 @@ const W = 8 const IN = 0x10 const OPPOSITE = { [E]: W, [W]: E, [N]: S, [S]: N } -export const sidewinder = (grid, width, height, rng) => { +export const sidewinder = (grid, { width, height, rng, straightness = 0 }) => { // Mark all cells as IN for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { @@ -16,6 +16,10 @@ export const sidewinder = (grid, width, height, rng) => { } } + // Calculate close probability based on straightness + // straightness 0 = 0.5 (default), straightness 10 = 0.1 (long corridors) + const closeProbability = 0.5 - (straightness * 0.04) + // Process each row for (let y = 0; y < height; y++) { let run = [] @@ -26,7 +30,7 @@ export const sidewinder = (grid, width, height, rng) => { // At east boundary or randomly decide to close out the run const atEastBoundary = x === width - 1 const atNorthBoundary = y === 0 - const shouldCloseRun = atEastBoundary || (!atNorthBoundary && rng() < 0.5) + const shouldCloseRun = atEastBoundary || (!atNorthBoundary && rng() < closeProbability) if (shouldCloseRun) { // Pick random cell from run and carve north (unless at north boundary) diff --git a/src/features/shapes/maze/algorithms/wilson.js b/src/features/shapes/maze/algorithms/wilson.js index c790a4ab..47e21b72 100644 --- a/src/features/shapes/maze/algorithms/wilson.js +++ b/src/features/shapes/maze/algorithms/wilson.js @@ -77,7 +77,7 @@ const walk = (grid, rng) => { } } -export const wilson = (grid, width, height, rng) => { +export const wilson = (grid, { width, height, rng }) => { let remaining = width * height - 1 while (remaining > 0) { From 712a13148c99bf019013aba8682dfc54990966fa Mon Sep 17 00:00:00 2001 From: Bob Carmichael Date: Thu, 18 Dec 2025 06:46:54 -0500 Subject: [PATCH 03/22] refactor to add circle maze shape; add eller algorithm; other tweaks --- src/features/shapes/maze/Maze.js | 267 +++++++++------- src/features/shapes/maze/PolarGrid.js | 297 ++++++++++++++++++ src/features/shapes/maze/RectangularGrid.js | 144 +++++++++ .../shapes/maze/algorithms/backtracker.js | 85 +++-- .../shapes/maze/algorithms/console.js | 3 +- .../shapes/maze/algorithms/division.js | 135 ++++---- src/features/shapes/maze/algorithms/eller.js | 168 ++++++++++ .../shapes/maze/algorithms/kruskal.js | 147 ++++----- src/features/shapes/maze/algorithms/prim.js | 64 ++-- .../shapes/maze/algorithms/sidewinder.js | 54 ++-- src/features/shapes/maze/algorithms/wilson.js | 131 +++----- 11 files changed, 1031 insertions(+), 464 deletions(-) create mode 100644 src/features/shapes/maze/PolarGrid.js create mode 100644 src/features/shapes/maze/RectangularGrid.js create mode 100644 src/features/shapes/maze/algorithms/eller.js diff --git a/src/features/shapes/maze/Maze.js b/src/features/shapes/maze/Maze.js index efc8cefd..5bae4458 100644 --- a/src/features/shapes/maze/Maze.js +++ b/src/features/shapes/maze/Maze.js @@ -1,17 +1,18 @@ -import Victor from "victor" import Shape from "../Shape" import seedrandom from "seedrandom" import Graph from "@/common/Graph" import { eulerianTrail } from "@/common/eulerian_trail/eulerianTrail" import { difference } from "@/common/util" import { cloneVertices, centerOnOrigin } from "@/common/geometry" +import RectangularGrid from "./RectangularGrid" +import PolarGrid from "./PolarGrid" import { wilson } from "./algorithms/wilson" import { backtracker } from "./algorithms/backtracker" import { division } from "./algorithms/division" import { prim } from "./algorithms/prim" import { kruskal } from "./algorithms/kruskal" import { sidewinder } from "./algorithms/sidewinder" -import { consoleDisplay } from "./algorithms/console" +import { eller } from "./algorithms/eller" const algorithms = { wilson, @@ -20,37 +21,86 @@ const algorithms = { prim, kruskal, sidewinder, - consoleDisplay, + eller, } -const N = 1 -const S = 2 -const E = 4 -const W = 8 -const IN = 0x10 // good for tracking visited cells -const DX = { [E]: 1, [W]: -1, [N]: 0, [S]: 0 } -const DY = { [E]: 0, [W]: 0, [N]: -1, [S]: 1 } -const OPPOSITE = { [E]: W, [W]: E, [N]: S, [S]: N } + const options = { + mazeShape: { + title: "Shape", + type: "togglebutton", + choices: ["Rectangle", "Circle"], + }, mazeType: { title: "Algorithm", type: "dropdown", - choices: ["Wilson", "Backtracker", "Division", "Prim", "Kruskal", "Sidewinder"], - onChange: (model, changes, state) => { - if (changes.mazeType === "Kruskal") { - changes.mazeHorizontalBias = 5 - } - return changes + choices: [ + "Wilson", + "Backtracker", + "Division", + "Prim", + "Kruskal", + "Sidewinder", + "Eller", + ], + isVisible: (layer, state) => { + return state.mazeShape !== "Circle" + }, + }, + mazeTypeCircle: { + title: "Algorithm", + type: "dropdown", + choices: ["Wilson", "Backtracker", "Prim", "Kruskal"], + isVisible: (layer, state) => { + return state.mazeShape === "Circle" }, }, mazeWidth: { title: "Maze width", min: 1, max: 20, + isVisible: (layer, state) => { + return state.mazeShape !== "Circle" + }, }, mazeHeight: { title: "Maze height", min: 1, max: 20, + isVisible: (layer, state) => { + return state.mazeShape !== "Circle" + }, + }, + mazeRingCount: { + title: "Rings", + min: 2, + max: 15, + isVisible: (layer, state) => { + return state.mazeShape === "Circle" + }, + }, + mazeWedgeCount: { + title: "Wedges", + min: 4, + max: 16, + isVisible: (layer, state) => { + return state.mazeShape === "Circle" + }, + }, + mazeWedgeDoubling: { + title: "Doubling interval", + min: 1, + max: 10, + isVisible: (layer, state) => { + return state.mazeShape === "Circle" + }, + }, + mazeWallType: { + title: "Wall type", + type: "togglebutton", + choices: ["Segment", "Arc"], + isVisible: (layer, state) => { + return state.mazeShape === "Circle" + }, }, mazeStraightness: { title: "Straightness", @@ -59,7 +109,10 @@ const options = { max: 10, step: 1, isVisible: (layer, state) => { - return state.mazeType === "Backtracker" || state.mazeType === "Sidewinder" + return ( + state.mazeShape !== "Circle" && + (state.mazeType === "Backtracker" || state.mazeType === "Sidewinder") + ) }, }, mazeHorizontalBias: { @@ -69,7 +122,12 @@ const options = { max: 10, step: 1, isVisible: (layer, state) => { - return state.mazeType === "Division" || state.mazeType === "Kruskal" + return ( + state.mazeShape !== "Circle" && + (state.mazeType === "Division" || + state.mazeType === "Kruskal" || + state.mazeType === "Eller") + ) }, }, mazeBranchLevel: { @@ -79,7 +137,11 @@ const options = { max: 10, step: 1, isVisible: (layer, state) => { - return state.mazeType === "Prim" + // Works for both rectangular and circular Prim + const algo = + state.mazeShape === "Circle" ? state.mazeTypeCircle : state.mazeType + + return algo === "Prim" }, }, seed: { @@ -99,11 +161,17 @@ export default class Maze extends Shape { return { ...super.getInitialState(), ...{ + mazeShape: "Rectangle", mazeType: "Wilson", + mazeTypeCircle: "Wilson", mazeWidth: 8, mazeHeight: 8, + mazeRingCount: 6, + mazeWedgeCount: 8, + mazeWedgeDoubling: 3, + mazeWallType: "Arc", mazeStraightness: 0, - mazeHorizontalBias: 0, + mazeHorizontalBias: 5, mazeBranchLevel: 5, seed: 1, }, @@ -111,18 +179,60 @@ export default class Maze extends Shape { } getVertices(state) { - const { mazeType, mazeWidth, mazeHeight, mazeStraightness, mazeHorizontalBias, mazeBranchLevel, seed } = state.shape + const { + mazeShape, + mazeType, + mazeTypeCircle, + mazeStraightness, + mazeHorizontalBias, + mazeBranchLevel, + seed, + } = state.shape + + const rng = seedrandom(seed) + const grid = this.createGrid(state.shape, rng) + const algorithmName = mazeShape === "Circle" ? mazeTypeCircle : mazeType + const algorithm = algorithms[algorithmName.toLowerCase()] + + algorithm(grid, { + rng, + straightness: mazeStraightness, + horizontalBias: mazeHorizontalBias, + branchLevel: mazeBranchLevel, + }) + + return this.drawMaze(grid) + } + + createGrid(shape, rng) { + const { + mazeShape, + mazeWidth, + mazeHeight, + mazeRingCount, + mazeWedgeCount, + mazeWedgeDoubling, + mazeWallType, + } = shape + + if (mazeShape === "Circle") { + return new PolarGrid( + Math.max(2, mazeRingCount), + Math.max(4, mazeWedgeCount), + Math.max(1, mazeWedgeDoubling), + rng, + mazeWallType === "Arc", + ) + } + const width = Math.max(2, mazeWidth) const height = Math.max(2, mazeHeight) - this.setup(width, height, seed) - this.generateMaze(mazeType, width, height, mazeStraightness, mazeHorizontalBias, mazeBranchLevel) - - return this.drawMaze(width, height) + return new RectangularGrid(width, height, rng) } - drawMaze(mazeWidth, mazeHeight) { - const wallSegments = this.extractWallSegments(mazeWidth, mazeHeight) + drawMaze(grid) { + const wallSegments = grid.extractWalls() const graph = new Graph() wallSegments.forEach(([v1, v2]) => { @@ -131,24 +241,22 @@ export default class Maze extends Shape { graph.addEdge(v1, v2) }) + // Calculate Eulerian trail and track which edges we walk const trail = eulerianTrail({ edges: Object.values(graph.edgeMap) }) - let prevKey - const walkedVertices = [] const walkedEdges = new Set( - trail.slice(0, -1).map((key, i) => [key, trail[i + 1]].sort().toString()) + trail.slice(0, -1).map((key, i) => [key, trail[i + 1]].sort().toString()), ) + const missingEdges = {} - // find edges that weren't walked - const missingEdges = Array.from( - difference(walkedEdges, graph.edgeKeys), - ).reduce((hash, d) => { - d = d.split(",") - hash[d[0] + "," + d[1]] = d[2] + "," + d[3] + for (const edgeStr of difference(walkedEdges, graph.edgeKeys)) { + const [x1, y1, x2, y2] = edgeStr.split(",") + missingEdges[`${x1},${y1}`] = `${x2},${y2}` + } - return hash - }, {}) + // Walk the trail, filling gaps with Dijkstra shortest paths + const walkedVertices = [] + let prevKey - // walk the trail, filling gaps with Dijkstra shortest paths trail.forEach((key) => { const vertex = graph.nodeMap[key] @@ -165,14 +273,13 @@ export default class Maze extends Shape { walkedVertices.push(vertex) } - // add back any missing edges + // Add back any missing edges if (missingEdges[key]) { const missingVertex = graph.nodeMap[missingEdges[key]] const edgeKey = [key, missingEdges[key]].sort().toString() if (graph.edgeMap[edgeKey]) { - walkedVertices.push(missingVertex) - walkedVertices.push(vertex) + walkedVertices.push(missingVertex, vertex) } delete missingEdges[key] @@ -181,79 +288,11 @@ export default class Maze extends Shape { prevKey = key }) - const clonedVertices = cloneVertices(walkedVertices) - - centerOnOrigin(clonedVertices) - - return clonedVertices - } - - extractWallSegments(width, height) { - const walls = [] + const vertices = cloneVertices(walkedVertices) - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const cell = this.grid[y][x] - - // North wall (top of cell) - if (y === 0 || !(cell & N)) { - walls.push([ - new Victor(x, y), - new Victor(x + 1, y), - ]) - } + centerOnOrigin(vertices) - // South wall (bottom of cell) - if (y === height - 1 || !(cell & S)) { - walls.push([ - new Victor(x, y + 1), - new Victor(x + 1, y + 1), - ]) - } - - // West wall (left of cell) - if (x === 0 || !(cell & W)) { - walls.push([ - new Victor(x, y), - new Victor(x, y + 1), - ]) - } - - // East wall (right of cell) - if (x === width - 1 || !(cell & E)) { - walls.push([ - new Victor(x + 1, y), - new Victor(x + 1, y + 1), - ]) - } - } - } - - return walls - } - - setup(width, height, seed) { - this.rng = seedrandom(seed) - this.grid = Array(height) - .fill(0) - .map(() => Array(width).fill(0)) - - // initialize a random starting cell - this.grid[Math.floor(this.rng() * height)][Math.floor(this.rng() * width)] = - IN - } - - generateMaze(mazeType, width, height, straightness, horizontalBias, branchLevel) { - const algorithm = algorithms[mazeType.toLowerCase()] || wilson - - algorithm(this.grid, { - width, - height, - rng: this.rng, - straightness, - horizontalBias, - branchLevel, - }) + return vertices } getOptions() { diff --git a/src/features/shapes/maze/PolarGrid.js b/src/features/shapes/maze/PolarGrid.js new file mode 100644 index 00000000..51c8d625 --- /dev/null +++ b/src/features/shapes/maze/PolarGrid.js @@ -0,0 +1,297 @@ +import Victor from "victor" + +// Polar grid for circular mazes +// Cells are arranged in concentric rings, with ring 0 being a single center cell +// Outer rings can have more wedges (doubling) to maintain proportional cell sizes + +export default class PolarGrid { + constructor( + ringCount, + baseWedgeCount, + doublingInterval, + rng, + useArcs = false, + ) { + this.gridType = "polar" + this.ringCount = ringCount + this.baseWedgeCount = baseWedgeCount + this.doublingInterval = doublingInterval + this.rng = rng + this.useArcs = useArcs + + // Build the grid structure + // rings[r] = array of cells at ring r + // Each cell: { ring, wedge, links: Set of "ring,wedge" keys } + this.rings = [] + + // Ring 0: single center cell + this.rings[0] = [this.createCell(0, 0)] + + // Outer rings with wedge doubling + for (let r = 1; r <= ringCount; r++) { + const wedgeCount = this.getWedgeCount(r) + + this.rings[r] = [] + for (let w = 0; w < wedgeCount; w++) { + this.rings[r][w] = this.createCell(r, w) + } + } + + // Mark a random cell as visited (for algorithm initialization) + const startRing = Math.floor(rng() * (ringCount + 1)) + const startWedge = Math.floor(rng() * this.rings[startRing].length) + + this.rings[startRing][startWedge].visited = true + } + + createCell(ring, wedge) { + return { + ring, + wedge, + links: new Set(), + visited: false, + } + } + + // Ring 0 always has 1 cell + // Wedge count doubles every `doublingInterval` rings + getWedgeCount(ring) { + if (ring === 0) return 1 + + const doublings = Math.floor((ring - 1) / this.doublingInterval) + + return this.baseWedgeCount * Math.pow(2, doublings) + } + + getCell(ring, wedge) { + if (ring < 0 || ring > this.ringCount) return null + + const cells = this.rings[ring] + + if (!cells || wedge < 0 || wedge >= cells.length) return null + + return cells[wedge] + } + + getAllCells() { + const cells = [] + + for (let r = 0; r <= this.ringCount; r++) { + for (const cell of this.rings[r]) { + cells.push(cell) + } + } + + return cells + } + + getRandomCell() { + const allCells = this.getAllCells() + + return allCells[Math.floor(this.rng() * allCells.length)] + } + + getNeighbors(cell) { + const { ring, wedge } = cell + const neighbors = [] + const wedgesInThisRing = this.rings[ring].length + + // Center cell (ring 0) only has outward neighbors + if (ring === 0) { + // All cells in ring 1 are neighbors of center + for (const outerCell of this.rings[1]) { + neighbors.push(outerCell) + } + + return neighbors + } + + // Clockwise neighbor (same ring, next wedge with wraparound) + const cwWedge = (wedge + 1) % wedgesInThisRing + + neighbors.push(this.rings[ring][cwWedge]) + + // Counter-clockwise neighbor (same ring, previous wedge with wraparound) + const ccwWedge = (wedge - 1 + wedgesInThisRing) % wedgesInThisRing + + neighbors.push(this.rings[ring][ccwWedge]) + + // Inward neighbor(s) + if (ring > 0) { + const innerWedgeCount = this.rings[ring - 1].length + + if (ring === 1) { + // Ring 1 cells all connect to center + neighbors.push(this.rings[0][0]) + } else { + // Map this wedge to inner ring wedge + const ratio = wedgesInThisRing / innerWedgeCount + const innerWedge = Math.floor(wedge / ratio) + + neighbors.push(this.rings[ring - 1][innerWedge]) + } + } + + // Outward neighbor(s) + if (ring < this.ringCount) { + const outerWedgeCount = this.rings[ring + 1].length + const ratio = outerWedgeCount / wedgesInThisRing + + if (ratio === 1) { + // 1-to-1 mapping + neighbors.push(this.rings[ring + 1][wedge]) + } else { + // 1-to-many mapping (this cell connects to multiple outer cells) + const firstOuterWedge = wedge * ratio + + for (let i = 0; i < ratio; i++) { + neighbors.push(this.rings[ring + 1][firstOuterWedge + i]) + } + } + } + + return neighbors + } + + cellKey(cell) { + return `${cell.ring},${cell.wedge}` + } + + link(cell1, cell2) { + cell1.links.add(this.cellKey(cell2)) + cell2.links.add(this.cellKey(cell1)) + } + + unlink(cell1, cell2) { + cell1.links.delete(this.cellKey(cell2)) + cell2.links.delete(this.cellKey(cell1)) + } + + isLinked(cell1, cell2) { + return cell1.links.has(this.cellKey(cell2)) + } + + markVisited(cell) { + cell.visited = true + } + + isVisited(cell) { + return cell.visited + } + + cellEquals(cell1, cell2) { + return cell1.ring === cell2.ring && cell1.wedge === cell2.wedge + } + + extractWalls() { + const walls = [] + const vertexCache = new Map() + + // Helper to create/reuse vertices (ensures exact same object for same coords) + const makeVertex = (r, angle) => { + const x = Math.round(r * Math.cos(angle) * 1000000) / 1000000 + const y = Math.round(r * Math.sin(angle) * 1000000) / 1000000 + const key = `${x},${y}` + + if (!vertexCache.has(key)) { + vertexCache.set(key, new Victor(x, y)) + } + + return vertexCache.get(key) + } + + // 1. RADIAL WALLS (between adjacent wedges in the same ring) + for (let r = 1; r <= this.ringCount; r++) { + const wedgeCount = this.rings[r].length + const anglePerWedge = (Math.PI * 2) / wedgeCount + const innerRadius = r + const outerRadius = r + 1 + + for (let w = 0; w < wedgeCount; w++) { + const cell = this.rings[r][w] + const cwWedge = (w + 1) % wedgeCount + const cwNeighbor = this.rings[r][cwWedge] + + // Wall between this cell and clockwise neighbor + if (!this.isLinked(cell, cwNeighbor)) { + const angle = (w + 1) * anglePerWedge + walls.push([ + makeVertex(innerRadius, angle), + makeVertex(outerRadius, angle), + ]) + } + } + } + + // Add arc or segment wall based on useArcs setting + const addArcWall = (radius, startAngle, endAngle) => { + if (this.useArcs) { + const resolution = (Math.PI * 2.0) / 128.0 + const deltaAngle = endAngle - startAngle + const numSteps = Math.ceil(deltaAngle / resolution) + const points = [] + + for (let step = 0; step <= numSteps; step++) { + const t = step / numSteps + const angle = startAngle + t * deltaAngle + points.push(makeVertex(radius, angle)) + } + + for (let i = 0; i < points.length - 1; i++) { + walls.push([points[i], points[i + 1]]) + } + } else { + walls.push([ + makeVertex(radius, startAngle), + makeVertex(radius, endAngle), + ]) + } + } + + // 2. ARC WALLS (between rings) + // Inner arc of each cell (boundary with inward neighbor) + for (let r = 1; r <= this.ringCount; r++) { + const wedgeCount = this.rings[r].length + const anglePerWedge = (Math.PI * 2) / wedgeCount + const radius = r // Inner edge of ring r is at radius r + + for (let w = 0; w < wedgeCount; w++) { + const cell = this.rings[r][w] + const startAngle = w * anglePerWedge + const endAngle = (w + 1) * anglePerWedge + + let inwardNeighbor + + if (r === 1) { + inwardNeighbor = this.rings[0][0] + } else { + const innerWedgeCount = this.rings[r - 1].length + const ratio = wedgeCount / innerWedgeCount + const innerWedge = Math.floor(w / ratio) + + inwardNeighbor = this.rings[r - 1][innerWedge] + } + + // Wall if not linked to inward neighbor + if (!this.isLinked(cell, inwardNeighbor)) { + addArcWall(radius, startAngle, endAngle) + } + } + } + + // 3. OUTER PERIMETER (always walls) + const outerRing = this.ringCount + const outerWedgeCount = this.rings[outerRing].length + const outerAnglePerWedge = (Math.PI * 2) / outerWedgeCount + const outerRadius = this.ringCount + 1 + + for (let w = 0; w < outerWedgeCount; w++) { + const startAngle = w * outerAnglePerWedge + const endAngle = (w + 1) * outerAnglePerWedge + + addArcWall(outerRadius, startAngle, endAngle) + } + + return walls + } +} diff --git a/src/features/shapes/maze/RectangularGrid.js b/src/features/shapes/maze/RectangularGrid.js new file mode 100644 index 00000000..60d8cdd7 --- /dev/null +++ b/src/features/shapes/maze/RectangularGrid.js @@ -0,0 +1,144 @@ +import Victor from "victor" + +// Rectangular grid for standard mazes +// Implements the same interface as PolarGrid for algorithm compatibility + +export default class RectangularGrid { + constructor(width, height, rng) { + this.gridType = "rectangular" + this.width = width + this.height = height + this.rng = rng + + this.cells = [] + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + this.cells.push(this.createCell(x, y)) + } + } + + // Mark a random cell as visited (for algorithm initialization) + const startIndex = Math.floor(rng() * this.cells.length) + + this.cells[startIndex].visited = true + } + + createCell(x, y) { + return { + x, + y, + links: new Set(), + visited: false, + } + } + + // Get cell at position + getCell(x, y) { + if (x < 0 || x >= this.width || y < 0 || y >= this.height) return null + + return this.cells[y * this.width + x] + } + + // Get all cells as flat array + getAllCells() { + return this.cells + } + + // Get a random cell + getRandomCell() { + return this.cells[Math.floor(this.rng() * this.cells.length)] + } + + // Get neighbors of a cell (N, S, E, W) + getNeighbors(cell) { + const { x, y } = cell + const neighbors = [] + + // North + if (y > 0) neighbors.push(this.getCell(x, y - 1)) + // South + if (y < this.height - 1) neighbors.push(this.getCell(x, y + 1)) + // East + if (x < this.width - 1) neighbors.push(this.getCell(x + 1, y)) + // West + if (x > 0) neighbors.push(this.getCell(x - 1, y)) + + return neighbors + } + + cellKey(cell) { + return `${cell.x},${cell.y}` + } + + link(cell1, cell2) { + cell1.links.add(this.cellKey(cell2)) + cell2.links.add(this.cellKey(cell1)) + } + + unlink(cell1, cell2) { + cell1.links.delete(this.cellKey(cell2)) + cell2.links.delete(this.cellKey(cell1)) + } + + isLinked(cell1, cell2) { + return cell1.links.has(this.cellKey(cell2)) + } + + markVisited(cell) { + cell.visited = true + } + + isVisited(cell) { + return cell.visited + } + + cellEquals(cell1, cell2) { + return cell1.x === cell2.x && cell1.y === cell2.y + } + + extractWalls() { + const walls = [] + + for (let y = 0; y < this.height; y++) { + for (let x = 0; x < this.width; x++) { + const cell = this.getCell(x, y) + + // North wall (top of cell) + if (y === 0) { + // Boundary - always a wall + walls.push([new Victor(x, y), new Victor(x + 1, y)]) + } else { + const northNeighbor = this.getCell(x, y - 1) + + if (!this.isLinked(cell, northNeighbor)) { + walls.push([new Victor(x, y), new Victor(x + 1, y)]) + } + } + + // West wall (left of cell) + if (x === 0) { + // Boundary - always a wall + walls.push([new Victor(x, y), new Victor(x, y + 1)]) + } else { + const westNeighbor = this.getCell(x - 1, y) + + if (!this.isLinked(cell, westNeighbor)) { + walls.push([new Victor(x, y), new Victor(x, y + 1)]) + } + } + + // South wall (bottom edge only for last row) + if (y === this.height - 1) { + walls.push([new Victor(x, y + 1), new Victor(x + 1, y + 1)]) + } + + // East wall (right edge only for last column) + if (x === this.width - 1) { + walls.push([new Victor(x + 1, y), new Victor(x + 1, y + 1)]) + } + } + } + + return walls + } +} diff --git a/src/features/shapes/maze/algorithms/backtracker.js b/src/features/shapes/maze/algorithms/backtracker.js index 24344eaf..479c4b29 100644 --- a/src/features/shapes/maze/algorithms/backtracker.js +++ b/src/features/shapes/maze/algorithms/backtracker.js @@ -1,58 +1,49 @@ // Recursive Backtracker algorithm for maze generation // Creates long, winding passages using depth-first search +// Works with any grid type (RectangularGrid, PolarGrid, etc.) -const N = 1 -const S = 2 -const E = 4 -const W = 8 -const IN = 0x10 -const DX = { [E]: 1, [W]: -1, [N]: 0, [S]: 0 } -const DY = { [E]: 0, [W]: 0, [N]: -1, [S]: 1 } -const OPPOSITE = { [E]: W, [W]: E, [N]: S, [S]: N } - -export const backtracker = (grid, { width, height, rng, straightness = 0 }) => { +export const backtracker = (grid, { rng, straightness = 0 }) => { const stack = [] - const startX = Math.floor(rng() * width) - const startY = Math.floor(rng() * height) + const startCell = grid.getRandomCell() - grid[startY][startX] = IN - stack.push([startX, startY, null]) // Include last direction + grid.markVisited(startCell) + stack.push({ cell: startCell, lastNeighbor: null }) while (stack.length > 0) { - const [cx, cy, lastDir] = stack[stack.length - 1] - const unvisitedNeighbors = [] + const { cell, lastNeighbor } = stack[stack.length - 1] - // Check all four directions for unvisited neighbors - const directions = [N, S, E, W] - for (const dir of directions) { - const nx = cx + DX[dir] - const ny = cy + DY[dir] - - if ( - nx >= 0 && - ny >= 0 && - ny < height && - nx < width && - grid[ny][nx] === 0 - ) { - unvisitedNeighbors.push({ dir, nx, ny }) - } - } + // Get unvisited neighbors + const neighbors = grid.getNeighbors(cell) + const unvisitedNeighbors = neighbors.filter((n) => !grid.isVisited(n)) if (unvisitedNeighbors.length > 0) { let chosenNeighbor - // Apply straightness bias if we have a previous direction - if (lastDir !== null && straightness > 0) { - const sameDirectionNeighbor = unvisitedNeighbors.find(n => n.dir === lastDir) + // Apply straightness bias if we have a previous neighbor and straightness > 0 + // For straightness, we try to continue in a "similar direction" + // by preferring the neighbor that is furthest from the previous cell + if ( + lastNeighbor !== null && + straightness > 0 && + unvisitedNeighbors.length > 1 + ) { + const continueProb = straightness * 0.09 - if (sameDirectionNeighbor) { - // Calculate probability to continue straight based on straightness - // straightness 0 = 0% bias, straightness 10 = 90% bias - const continueProb = straightness * 0.09 + if (rng() < continueProb) { + // Find the neighbor most "opposite" to where we came from + // This approximates continuing straight + // For rectangular grids: opposite of lastNeighbor + // For polar grids: similar concept applies + const lastKey = grid.cellKey(lastNeighbor) + const oppositeNeighbor = unvisitedNeighbors.find((n) => { + // Check if this neighbor has lastNeighbor as a neighbor + // (meaning it's "continuing" past the current cell) + const nNeighbors = grid.getNeighbors(n) + return !nNeighbors.some((nn) => grid.cellKey(nn) === lastKey) + }) - if (rng() < continueProb) { - chosenNeighbor = sameDirectionNeighbor + if (oppositeNeighbor) { + chosenNeighbor = oppositeNeighbor } } } @@ -63,14 +54,12 @@ export const backtracker = (grid, { width, height, rng, straightness = 0 }) => { chosenNeighbor = unvisitedNeighbors[idx] } - const { dir, nx, ny } = chosenNeighbor - - // Remove wall between current cell and chosen neighbor - grid[cy][cx] |= dir - grid[ny][nx] |= OPPOSITE[dir] | IN + // Link current cell to chosen neighbor + grid.link(cell, chosenNeighbor) + grid.markVisited(chosenNeighbor) - // Push neighbor onto stack with current direction - stack.push([nx, ny, dir]) + // Push neighbor onto stack, tracking where we came from + stack.push({ cell: chosenNeighbor, lastNeighbor: cell }) } else { // Backtrack stack.pop() diff --git a/src/features/shapes/maze/algorithms/console.js b/src/features/shapes/maze/algorithms/console.js index b0fc2776..529c652e 100644 --- a/src/features/shapes/maze/algorithms/console.js +++ b/src/features/shapes/maze/algorithms/console.js @@ -1,3 +1,5 @@ +/* global console */ + // Utility for debugging maze generation - displays ASCII art in console const S = 2 @@ -33,6 +35,5 @@ export const consoleDisplay = (grid, { width, height }) => { mazeOutput += "\n" }) - // eslint-disable-next-line no-console console.log(mazeOutput) } diff --git a/src/features/shapes/maze/algorithms/division.js b/src/features/shapes/maze/algorithms/division.js index e5291645..da801c47 100644 --- a/src/features/shapes/maze/algorithms/division.js +++ b/src/features/shapes/maze/algorithms/division.js @@ -1,81 +1,94 @@ // Recursive Division algorithm for maze generation // Divides space with walls, leaving passages - creates long straight corridors +// NOTE: Only works with rectangular grids -const N = 1 -const S = 2 -const E = 4 -const W = 8 -const IN = 0x10 -const DX = { [E]: 1, [W]: -1, [N]: 0, [S]: 0 } -const DY = { [E]: 0, [W]: 0, [N]: -1, [S]: 1 } -const OPPOSITE = { [E]: W, [W]: E, [N]: S, [S]: N } +const divide = (grid, cells, rng, horizontalBias) => { + if (cells.length < 2) return -const divide = (grid, x, y, width, height, rng, horizontalBias = 0) => { - if (width < 2 || height < 2) return + // Find bounds + const xs = cells.map((c) => c.x) + const ys = cells.map((c) => c.y) + const minX = Math.min(...xs) + const maxX = Math.max(...xs) + const minY = Math.min(...ys) + const maxY = Math.max(...ys) + + const width = maxX - minX + 1 + const height = maxY - minY + 1 + + if (width < 2 && height < 2) return // Calculate horizontal probability based on horizontalBias - // horizontalBias 0 = 0.1 (prefer vertical walls/horizontal corridors), 10 = 0.9 (prefer horizontal walls/vertical corridors) - const horizontalProb = 0.1 + (horizontalBias * 0.08) - const horizontal = height > width || (height === width && rng() < horizontalProb) - - if (horizontal) { - // Divide horizontally - const wallY = y + Math.floor(rng() * (height - 1)) - const passageX = x + Math.floor(rng() * width) - - // Add horizontal wall with passage - for (let wx = x; wx < x + width; wx++) { - if (wx !== passageX) { - // Remove south passage from cells above the wall - grid[wallY][wx] &= ~S - // Remove north passage from cells below the wall - if (wallY + 1 < grid.length) { - grid[wallY + 1][wx] &= ~N - } + const horizontalProb = 0.1 + horizontalBias * 0.08 + const horizontal = + height > width || (height === width && rng() < horizontalProb) + + if (horizontal && height >= 2) { + // Divide horizontally - pick a y value to put wall below + const wallY = minY + Math.floor(rng() * (height - 1)) + + // Get cells in the wall row and the row below + const topCells = cells.filter((c) => c.y === wallY) + const bottomCells = cells.filter((c) => c.y === wallY + 1) + + // Pick one passage to leave open + const passageX = minX + Math.floor(rng() * width) + + // Unlink all except the passage + for (const topCell of topCells) { + if (topCell.x === passageX) continue + const bottomCell = bottomCells.find((c) => c.x === topCell.x) + if (bottomCell) { + grid.unlink(topCell, bottomCell) } } - // Recursively divide the two sections - divide(grid, x, y, width, wallY - y + 1, rng, horizontalBias) - divide(grid, x, wallY + 1, width, y + height - wallY - 1, rng, horizontalBias) - } else { - // Divide vertically - const wallX = x + Math.floor(rng() * (width - 1)) - const passageY = y + Math.floor(rng() * height) - - // Add vertical wall with passage - for (let wy = y; wy < y + height; wy++) { - if (wy !== passageY) { - // Remove east passage from cells left of the wall - grid[wy][wallX] &= ~E - // Remove west passage from cells right of the wall - if (wallX + 1 < grid[wy].length) { - grid[wy][wallX + 1] &= ~W - } + // Recursively divide top and bottom sections + const topSection = cells.filter((c) => c.y <= wallY) + const bottomSection = cells.filter((c) => c.y > wallY) + + divide(grid, topSection, rng, horizontalBias) + divide(grid, bottomSection, rng, horizontalBias) + } else if (width >= 2) { + // Divide vertically - pick an x value to put wall right of + const wallX = minX + Math.floor(rng() * (width - 1)) + + // Get cells in the wall column and the column to the right + const leftCells = cells.filter((c) => c.x === wallX) + const rightCells = cells.filter((c) => c.x === wallX + 1) + + // Pick one passage to leave open + const passageY = minY + Math.floor(rng() * height) + + // Unlink all except the passage + for (const leftCell of leftCells) { + if (leftCell.y === passageY) continue + const rightCell = rightCells.find((c) => c.y === leftCell.y) + if (rightCell) { + grid.unlink(leftCell, rightCell) } } - // Recursively divide the two sections - divide(grid, x, y, wallX - x + 1, height, rng, horizontalBias) - divide(grid, wallX + 1, y, x + width - wallX - 1, height, rng, horizontalBias) + // Recursively divide left and right sections + const leftSection = cells.filter((c) => c.x <= wallX) + const rightSection = cells.filter((c) => c.x > wallX) + + divide(grid, leftSection, rng, horizontalBias) + divide(grid, rightSection, rng, horizontalBias) } } -export const division = (grid, { width, height, rng, horizontalBias = 0 }) => { - // Start with all passages open (no walls) - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - let cell = IN - - if (y > 0) cell |= N - if (y < height - 1) cell |= S - if (x > 0) cell |= W - if (x < width - 1) cell |= E +export const division = (grid, { rng, horizontalBias = 5 }) => { + const allCells = grid.getAllCells() - grid[y][x] = cell + // Start with all cells fully connected + for (const cell of allCells) { + grid.markVisited(cell) + for (const neighbor of grid.getNeighbors(cell)) { + grid.link(cell, neighbor) } } - // Recursively divide the space - divide(grid, 0, 0, width, height, rng, horizontalBias) + // Recursively divide + divide(grid, allCells, rng, horizontalBias) } diff --git a/src/features/shapes/maze/algorithms/eller.js b/src/features/shapes/maze/algorithms/eller.js new file mode 100644 index 00000000..fc0e51e9 --- /dev/null +++ b/src/features/shapes/maze/algorithms/eller.js @@ -0,0 +1,168 @@ +// Eller's algorithm for maze generation +// Processes one row at a time using disjoint sets - very memory efficient +// Creates horizontal bias similar to Sidewinder but more variety +// NOTE: Only works with rectangular grids (row-based processing) +// Reference: https://weblog.jamisbuck.org/2010/12/29/maze-generation-eller-s-algorithm + +export const eller = (grid, { rng, horizontalBias = 5 }) => { + // Mark all cells as visited + const allCells = grid.getAllCells() + + for (const cell of allCells) { + grid.markVisited(cell) + } + + // Set management: track which set each cell belongs to + // Sets are identified by integers; cells in the same set are connected + let nextSetId = 0 + const cellToSet = new Map() // cellKey -> setId + const setToCells = new Map() // setId -> array of cells in current row + + // Helper to get or create a set for a cell + const getSet = (cell) => { + const key = grid.cellKey(cell) + + if (!cellToSet.has(key)) { + cellToSet.set(key, nextSetId) + setToCells.set(nextSetId, [cell]) + nextSetId++ + } + + return cellToSet.get(key) + } + + // Helper to merge two sets (move all cells from set2 into set1) + const mergeSets = (set1, set2) => { + if (set1 === set2) return + + const cells2 = setToCells.get(set2) || [] + const cells1 = setToCells.get(set1) || [] + + for (const cell of cells2) { + cellToSet.set(grid.cellKey(cell), set1) + cells1.push(cell) + } + + setToCells.delete(set2) + } + + // Helper to check if two cells are in the same set + const sameSet = (cell1, cell2) => { + return getSet(cell1) === getSet(cell2) + } + + // Calculate probabilities based on horizontalBias (centered on 5 = neutral) + // horizontalBias 0 = vertical feel, 5 = neutral, 10 = horizontal feel + const mergeProbability = 0.1 + horizontalBias * 0.08 // 0.1 at 0, 0.5 at 5, 0.9 at 10 + + // Vertical connection probability (per cell after the required first one) + const verticalProbability = 0.9 - horizontalBias * 0.08 // 0.9 at 0, 0.5 at 5, 0.1 at 10 + + // Process each row + for (let y = 0; y < grid.height; y++) { + const isLastRow = y === grid.height - 1 + + // Get all cells in this row + const rowCells = [] + for (let x = 0; x < grid.width; x++) { + rowCells.push(grid.getCell(x, y)) + } + + // Initialize sets for cells in this row (new cells get new sets) + for (const cell of rowCells) { + getSet(cell) + } + + // PHASE 1: Horizontal connections + // Randomly join adjacent cells if they're in different sets + for (let x = 0; x < grid.width - 1; x++) { + const cell = rowCells[x] + const eastCell = rowCells[x + 1] + + // On last row: must connect if in different sets + // Otherwise: randomly connect if in different sets + const shouldConnect = isLastRow + ? !sameSet(cell, eastCell) + : !sameSet(cell, eastCell) && rng() < mergeProbability + + if (shouldConnect) { + grid.link(cell, eastCell) + mergeSets(getSet(cell), getSet(eastCell)) + } + } + + // PHASE 2: Vertical connections (skip on last row) + if (!isLastRow) { + // Group cells by their current set + const setGroups = new Map() + + for (const cell of rowCells) { + const setId = getSet(cell) + if (!setGroups.has(setId)) { + setGroups.set(setId, []) + } + setGroups.get(setId).push(cell) + } + + // For each set, at least one cell must connect down + for (const [setId, cells] of setGroups) { + // Shuffle the cells in this set + const shuffled = [...cells] + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)) + ;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]] + } + + // First cell always connects (required for maze connectivity) + // Additional cells connect based on verticalProbability + for (let i = 0; i < shuffled.length; i++) { + const isRequired = i === 0 + if (!isRequired && rng() >= verticalProbability) { + continue // Skip this cell + } + + const cell = shuffled[i] + const southCell = grid.getCell(cell.x, cell.y + 1) + + if (southCell) { + grid.link(cell, southCell) + + // South cell inherits this cell's set + const southKey = grid.cellKey(southCell) + cellToSet.set(southKey, setId) + + if (!setToCells.has(setId)) { + setToCells.set(setId, []) + } + setToCells.get(setId).push(southCell) + } + } + } + + // Clear set tracking for cells that didn't connect down + // (they'll get new sets when processing the next row) + for (const cell of rowCells) { + cellToSet.delete(grid.cellKey(cell)) + } + + // Also rebuild setToCells to only include next row's cells + const nextRowSets = new Map() + + for (let x = 0; x < grid.width; x++) { + const nextCell = grid.getCell(x, y + 1) + const nextKey = grid.cellKey(nextCell) + if (cellToSet.has(nextKey)) { + const setId = cellToSet.get(nextKey) + if (!nextRowSets.has(setId)) { + nextRowSets.set(setId, []) + } + nextRowSets.get(setId).push(nextCell) + } + } + setToCells.clear() + for (const [setId, cells] of nextRowSets) { + setToCells.set(setId, cells) + } + } + } +} diff --git a/src/features/shapes/maze/algorithms/kruskal.js b/src/features/shapes/maze/algorithms/kruskal.js index f620d3ad..a9f76c54 100644 --- a/src/features/shapes/maze/algorithms/kruskal.js +++ b/src/features/shapes/maze/algorithms/kruskal.js @@ -1,119 +1,92 @@ // Kruskal's algorithm for maze generation // Works on edges, creates a "random forest" that merges together +// Works with any grid type (RectangularGrid, PolarGrid, etc.) -const N = 1 -const S = 2 -const E = 4 -const W = 8 -const IN = 0x10 -const DX = { [E]: 1, [W]: -1, [N]: 0, [S]: 0 } -const DY = { [E]: 0, [W]: 0, [N]: -1, [S]: 1 } -const OPPOSITE = { [E]: W, [W]: E, [N]: S, [S]: N } - -// Simple union-find data structure +// Simple union-find data structure using cell keys class UnionFind { - constructor(size) { - this.parent = Array(size) - .fill(0) - .map((_, i) => i) - this.rank = Array(size).fill(0) + constructor() { + this.parent = new Map() + this.rank = new Map() + } + + makeSet(key) { + if (!this.parent.has(key)) { + this.parent.set(key, key) + this.rank.set(key, 0) + } } - find(x) { - if (this.parent[x] !== x) { - this.parent[x] = this.find(this.parent[x]) // path compression + find(key) { + if (this.parent.get(key) !== key) { + this.parent.set(key, this.find(this.parent.get(key))) // path compression } - return this.parent[x] + return this.parent.get(key) } - union(x, y) { - const rootX = this.find(x) - const rootY = this.find(y) + union(key1, key2) { + const root1 = this.find(key1) + const root2 = this.find(key2) - if (rootX === rootY) return false + if (root1 === root2) return false // Union by rank - if (this.rank[rootX] < this.rank[rootY]) { - this.parent[rootX] = rootY - } else if (this.rank[rootX] > this.rank[rootY]) { - this.parent[rootY] = rootX + const rank1 = this.rank.get(root1) + const rank2 = this.rank.get(root2) + + if (rank1 < rank2) { + this.parent.set(root1, root2) + } else if (rank1 > rank2) { + this.parent.set(root2, root1) } else { - this.parent[rootY] = rootX - this.rank[rootX]++ + this.parent.set(root2, root1) + this.rank.set(root1, rank1 + 1) } return true } } -export const kruskal = (grid, { width, height, rng, horizontalBias = 0 }) => { - // Initialize all cells as separate sets - const uf = new UnionFind(width * height) +export const kruskal = (grid, { rng, horizontalBias = 5 }) => { + const uf = new UnionFind() + const allCells = grid.getAllCells() - // Mark all cells as IN - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - grid[y][x] = IN - } + // Initialize union-find with all cells + for (const cell of allCells) { + const key = grid.cellKey(cell) + uf.makeSet(key) + grid.markVisited(cell) } - // Create separate lists for horizontal and vertical edges - const horizontalEdges = [] - const verticalEdges = [] - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - // Add south edge (vertical) - if (y < height - 1) { - verticalEdges.push({ x, y, dir: S }) - } - // Add east edge (horizontal) - if (x < width - 1) { - horizontalEdges.push({ x, y, dir: E }) - } - } - } + // Collect all unique edges (cell pairs) + const edges = [] + const seenEdges = new Set() - // Shuffle each list independently - const shuffle = (arr) => { - for (let i = arr.length - 1; i > 0; i--) { - const j = Math.floor(rng() * (i + 1)) - ;[arr[i], arr[j]] = [arr[j], arr[i]] + for (const cell of allCells) { + const cellKey = grid.cellKey(cell) + for (const neighbor of grid.getNeighbors(cell)) { + const neighborKey = grid.cellKey(neighbor) + // Create a canonical edge key to avoid duplicates + const edgeKey = [cellKey, neighborKey].sort().join("|") + if (!seenEdges.has(edgeKey)) { + seenEdges.add(edgeKey) + edges.push({ cell, neighbor }) + } } } - shuffle(horizontalEdges) - shuffle(verticalEdges) - // Interleave edges based on horizontalBias - // horizontalBias 0 = prefer horizontal passages, 10 = prefer vertical passages - const horizontalProb = 0.9 - (horizontalBias * 0.08) - const edges = [] - let hIdx = 0 - let vIdx = 0 - - while (hIdx < horizontalEdges.length || vIdx < verticalEdges.length) { - if (hIdx >= horizontalEdges.length) { - edges.push(verticalEdges[vIdx++]) - } else if (vIdx >= verticalEdges.length) { - edges.push(horizontalEdges[hIdx++]) - } else if (rng() < horizontalProb) { - edges.push(horizontalEdges[hIdx++]) - } else { - edges.push(verticalEdges[vIdx++]) - } + // Shuffle edges + for (let i = edges.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)) + ;[edges[i], edges[j]] = [edges[j], edges[i]] } - // Process edges - for (const edge of edges) { - const { x, y, dir } = edge - const cell1 = y * width + x - const nx = x + DX[dir] - const ny = y + DY[dir] - const cell2 = ny * width + nx + // Process edges - connect if in different sets + for (const { cell, neighbor } of edges) { + const cellKey = grid.cellKey(cell) + const neighborKey = grid.cellKey(neighbor) - // If cells are in different sets, connect them - if (uf.union(cell1, cell2)) { - grid[y][x] |= dir - grid[ny][nx] |= OPPOSITE[dir] + if (uf.union(cellKey, neighborKey)) { + grid.link(cell, neighbor) } } } diff --git a/src/features/shapes/maze/algorithms/prim.js b/src/features/shapes/maze/algorithms/prim.js index c84a1b25..76288332 100644 --- a/src/features/shapes/maze/algorithms/prim.js +++ b/src/features/shapes/maze/algorithms/prim.js @@ -1,71 +1,47 @@ // Prim's algorithm for maze generation // Creates lots of short branches and dead ends - "bushy" appearance +// Works with any grid type (RectangularGrid, PolarGrid, etc.) -const N = 1 -const S = 2 -const E = 4 -const W = 8 -const IN = 0x10 -const DX = { [E]: 1, [W]: -1, [N]: 0, [S]: 0 } -const DY = { [E]: 0, [W]: 0, [N]: -1, [S]: 1 } -const OPPOSITE = { [E]: W, [W]: E, [N]: S, [S]: N } - -export const prim = (grid, { width, height, rng, branchLevel = 0 }) => { - const frontier = [] +export const prim = (grid, { rng, branchLevel = 5 }) => { + const frontier = [] // Array of { cell, parent } objects + const inFrontier = new Set() // Track cells already in frontier // Start with a random cell - const startX = Math.floor(rng() * width) - const startY = Math.floor(rng() * height) + const startCell = grid.getRandomCell() - grid[startY][startX] = IN + grid.markVisited(startCell) // Add neighbors to frontier - const addFrontier = (x, y) => { - const directions = [N, S, E, W] - for (const dir of directions) { - const nx = x + DX[dir] - const ny = y + DY[dir] - - if ( - nx >= 0 && - ny >= 0 && - ny < height && - nx < width && - grid[ny][nx] === 0 - ) { - grid[ny][nx] = OPPOSITE[dir] // mark with direction back to parent - frontier.push([nx, ny]) + const addToFrontier = (cell) => { + for (const neighbor of grid.getNeighbors(cell)) { + const key = grid.cellKey(neighbor) + if (!grid.isVisited(neighbor) && !inFrontier.has(key)) { + frontier.push({ cell: neighbor, parent: cell }) + inFrontier.add(key) } } } - addFrontier(startX, startY) + addToFrontier(startCell) // Process frontier while (frontier.length > 0) { // Pick cell from frontier based on branchLevel - // branchLevel 0 = bushy (pick from start/FIFO), 10 = winding (pick from end/LIFO) + // branchLevel 0 = bushy (random), 5 = balanced, 10 = winding (LIFO-like) let idx - // Use power distribution to bias selection - // power > 1 → picks from start (bushy), power < 1 → picks from end (winding) - const t = branchLevel / 10 // 0 (bushy) to 1 (winding) + const t = branchLevel / 10 // 0 to 1 const power = 2 - 1.5 * t // 2 (bushy) to 0.5 (winding) idx = Math.floor(Math.pow(rng(), power) * frontier.length) - const [fx, fy] = frontier[idx] - frontier.splice(idx, 1) - // Get direction back to parent (stored in grid) - const dir = grid[fy][fx] + const { cell, parent } = frontier[idx] - // Find the parent cell (go in the stored direction) - const px = fx + DX[dir] - const py = fy + DY[dir] + frontier.splice(idx, 1) // Connect frontier cell to parent - grid[fy][fx] = IN | dir - grid[py][px] |= OPPOSITE[dir] + grid.link(cell, parent) + grid.markVisited(cell) // Add new neighbors to frontier - addFrontier(fx, fy) + addToFrontier(cell) } } diff --git a/src/features/shapes/maze/algorithms/sidewinder.js b/src/features/shapes/maze/algorithms/sidewinder.js index 6b3e33e3..4b401aa1 100644 --- a/src/features/shapes/maze/algorithms/sidewinder.js +++ b/src/features/shapes/maze/algorithms/sidewinder.js @@ -1,53 +1,55 @@ // Sidewinder algorithm for maze generation // Works row by row, creates horizontal bias with long east-west corridors +// NOTE: Only works with rectangular grids -const N = 1 -const S = 2 -const E = 4 -const W = 8 -const IN = 0x10 -const OPPOSITE = { [E]: W, [W]: E, [N]: S, [S]: N } - -export const sidewinder = (grid, { width, height, rng, straightness = 0 }) => { - // Mark all cells as IN - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - grid[y][x] = IN - } +export const sidewinder = (grid, { rng, straightness = 0 }) => { + // Mark all cells as visited + const allCells = grid.getAllCells() + + for (const cell of allCells) { + grid.markVisited(cell) } // Calculate close probability based on straightness // straightness 0 = 0.5 (default), straightness 10 = 0.1 (long corridors) - const closeProbability = 0.5 - (straightness * 0.04) + const closeProbability = 0.5 - straightness * 0.04 // Process each row - for (let y = 0; y < height; y++) { - let run = [] + for (let y = 0; y < grid.height; y++) { + const run = [] + + for (let x = 0; x < grid.width; x++) { + const cell = grid.getCell(x, y) - for (let x = 0; x < width; x++) { - run.push(x) + run.push(cell) // At east boundary or randomly decide to close out the run - const atEastBoundary = x === width - 1 + const atEastBoundary = x === grid.width - 1 const atNorthBoundary = y === 0 - const shouldCloseRun = atEastBoundary || (!atNorthBoundary && rng() < closeProbability) + const shouldCloseRun = + atEastBoundary || (!atNorthBoundary && rng() < closeProbability) if (shouldCloseRun) { // Pick random cell from run and carve north (unless at north boundary) if (!atNorthBoundary) { const randomIdx = Math.floor(rng() * run.length) - const cellX = run[randomIdx] + const runCell = run[randomIdx] + const northCell = grid.getCell(runCell.x, runCell.y - 1) - grid[y][cellX] |= N - grid[y - 1][cellX] |= S + if (northCell) { + grid.link(runCell, northCell) + } } // Clear the run - run = [] + run.length = 0 } else { // Carve east - grid[y][x] |= E - grid[y][x + 1] |= W + const eastCell = grid.getCell(x + 1, y) + + if (eastCell) { + grid.link(cell, eastCell) + } } } } diff --git a/src/features/shapes/maze/algorithms/wilson.js b/src/features/shapes/maze/algorithms/wilson.js index 47e21b72..283ea88f 100644 --- a/src/features/shapes/maze/algorithms/wilson.js +++ b/src/features/shapes/maze/algorithms/wilson.js @@ -1,96 +1,61 @@ // Wilson's algorithm for maze generation +// Uses loop-erased random walks to generate uniform spanning trees +// Works with any grid type (RectangularGrid, PolarGrid, etc.) // adapted from https://weblog.jamisbuck.org/2011/1/20/maze-generation-wilson-s-algorithm -const N = 1 -const S = 2 -const E = 4 -const W = 8 -const DX = { [E]: 1, [W]: -1, [N]: 0, [S]: 0 } -const DY = { [E]: 0, [W]: 0, [N]: -1, [S]: 1 } -const OPPOSITE = { [E]: W, [W]: E, [N]: S, [S]: N } +export const wilson = (grid, { rng }) => { + const allCells = grid.getAllCells() -const walk = (grid, rng) => { - while (true) { - let cx, cy + // Track visited cells (part of the maze tree) + const visited = new Set() - do { - cx = Math.floor(rng() * grid[0].length) - cy = Math.floor(rng() * grid.length) - } while (grid[cy][cx] !== 0) // find an unvisited cell - - const visits = new Map() - - visits.set(`${cx},${cy}`, 0) // store direction as 0 initially, meaning no direction yet - - const startX = cx - const startY = cy - let walking = true - - while (walking) { - walking = false - - // shuffle directions using the seeded random - const directions = [N, S, E, W].sort(() => rng() - 0.5) - - for (const dir of directions) { - const nx = cx + DX[dir] - const ny = cy + DY[dir] - - if ( - nx >= 0 && - ny >= 0 && - ny < grid.length && - nx < grid[ny].length - ) { - visits.set(`${cx},${cy}`, dir) + // Find the initially visited cell (set during grid construction) + for (const cell of allCells) { + if (grid.isVisited(cell)) { + visited.add(grid.cellKey(cell)) + break + } + } - if (grid[ny][nx] !== 0) { - // found a visited cell, break the loop and record the path - walking = false - break - } else { - // move to the next cell - cx = nx - cy = ny - walking = true - break - } - } + // Keep going until all cells are in the maze + while (visited.size < allCells.length) { + // Find an unvisited cell to start the walk + let startCell + do { + startCell = grid.getRandomCell() + } while (visited.has(grid.cellKey(startCell))) + + // Perform loop-erased random walk + // path: array of cells representing the walk + const path = [startCell] + + let current = startCell + while (!visited.has(grid.cellKey(current))) { + const neighbors = grid.getNeighbors(current) + const next = neighbors[Math.floor(rng() * neighbors.length)] + + // Check if we've visited this cell in the current walk (loop) + const existingIndex = path.findIndex((c) => grid.cellEquals(c, next)) + + if (existingIndex >= 0) { + // Loop detected - erase everything after the first visit + path.splice(existingIndex + 1) + current = path[path.length - 1] + } else { + // Continue the walk + path.push(next) + current = next } } - const path = [] - let x = startX - let y = startY + // Carve the path into the maze + for (let i = 0; i < path.length - 1; i++) { + const cell = path[i] + const next = path[i + 1] - while (true) { - const dir = visits.get(`${x},${y}`) - - if (dir === undefined || dir === 0) break - - path.push([x, y, dir]) - x = x + DX[dir] - y = y + DY[dir] + grid.link(cell, next) + grid.markVisited(cell) + visited.add(grid.cellKey(cell)) } - - return path - } -} - -export const wilson = (grid, { width, height, rng }) => { - let remaining = width * height - 1 - - while (remaining > 0) { - const currentPath = walk(grid, rng) - - currentPath.forEach(([x, y, dir]) => { - const nx = x + DX[dir] - const ny = y + DY[dir] - - grid[y][x] |= dir - grid[ny][nx] |= OPPOSITE[dir] - - remaining -= 1 - }) } } From 4040578c2348e22fd355e343883fc4454edfd714 Mon Sep 17 00:00:00 2001 From: Bob Carmichael Date: Thu, 18 Dec 2025 07:23:43 -0500 Subject: [PATCH 04/22] add hex grid --- src/features/shapes/maze/HexGrid.js | 235 ++++++++++++++++++++++++++++ src/features/shapes/maze/Maze.js | 34 +++- 2 files changed, 262 insertions(+), 7 deletions(-) create mode 100644 src/features/shapes/maze/HexGrid.js diff --git a/src/features/shapes/maze/HexGrid.js b/src/features/shapes/maze/HexGrid.js new file mode 100644 index 00000000..d4704738 --- /dev/null +++ b/src/features/shapes/maze/HexGrid.js @@ -0,0 +1,235 @@ +import Victor from "victor" + +// Hexagonal grid for hex mazes +// Uses pointy-top orientation with odd-r offset coordinates + +export default class HexGrid { + constructor(width, height, rng) { + this.gridType = "hex" + this.width = width + this.height = height + this.rng = rng + + this.xOffset = Math.sin(Math.PI / 3) // ~0.866 + this.yOffset1 = Math.cos(Math.PI / 3) // ~0.5 + this.yOffset2 = 2 - this.yOffset1 // ~1.5 + + const rawWidth = (2 * width + 1) * this.xOffset + const rawHeight = height * this.yOffset2 + this.yOffset1 + + this.yScale = rawWidth / rawHeight + this.cells = [] + + for (let r = 0; r < height; r++) { + for (let q = 0; q < width; q++) { + this.cells.push(this.createCell(q, r)) + } + } + + const startIndex = Math.floor(rng() * this.cells.length) + + this.cells[startIndex].visited = true + } + + createCell(q, r) { + return { + q, + r, + links: new Set(), + visited: false, + } + } + + getCell(q, r) { + if (q < 0 || q >= this.width || r < 0 || r >= this.height) return null + + return this.cells[r * this.width + q] + } + + getAllCells() { + return this.cells + } + + getRandomCell() { + return this.cells[Math.floor(this.rng() * this.cells.length)] + } + + getRowOffset(r) { + return (r + 1) % 2 + } + + getNeighbors(cell) { + const { q, r } = cell + const neighbors = [] + const rowOffset = this.getRowOffset(r) + + // East neighbor (same row, q+1) + const east = this.getCell(q + 1, r) + if (east) neighbors.push(east) + + // West neighbor (same row, q-1) + const west = this.getCell(q - 1, r) + if (west) neighbors.push(west) + + // Northeast neighbor (row above) + const ne = this.getCell(q + 1 - rowOffset, r - 1) + if (ne) neighbors.push(ne) + + // Northwest neighbor (row above) + const nw = this.getCell(q - rowOffset, r - 1) + if (nw) neighbors.push(nw) + + // Southeast neighbor (row below) + const se = this.getCell(q + 1 - rowOffset, r + 1) + if (se) neighbors.push(se) + + // Southwest neighbor (row below) + const sw = this.getCell(q - rowOffset, r + 1) + if (sw) neighbors.push(sw) + + return neighbors + } + + cellKey(cell) { + return `${cell.q},${cell.r}` + } + + link(cell1, cell2) { + cell1.links.add(this.cellKey(cell2)) + cell2.links.add(this.cellKey(cell1)) + } + + unlink(cell1, cell2) { + cell1.links.delete(this.cellKey(cell2)) + cell2.links.delete(this.cellKey(cell1)) + } + + isLinked(cell1, cell2) { + return cell1.links.has(this.cellKey(cell2)) + } + + markVisited(cell) { + cell.visited = true + } + + isVisited(cell) { + return cell.visited + } + + cellEquals(cell1, cell2) { + return cell1.q === cell2.q && cell1.r === cell2.r + } + + // Get the 6 corner vertices of a hexagon (pointy-top) + // Returns corners in order for wall drawing + getHexCorners(q, r) { + const rowXOffset = Math.abs(r % 2) * this.xOffset + const ys = this.yScale + const p1x = rowXOffset + q * 2 * this.xOffset + const p1y = (this.yOffset1 + r * this.yOffset2) * ys + const p2x = p1x + const p2y = (r + 1) * this.yOffset2 * ys + const p3x = rowXOffset + (2 * q + 1) * this.xOffset + const p3y = (r * this.yOffset2 + 2) * ys + const p4x = p2x + 2 * this.xOffset + const p4y = p2y + const p5x = p4x + const p5y = p1y + const p6x = p3x + const p6y = r * this.yOffset2 * ys + + return [ + [p1x, p1y], // top-left + [p2x, p2y], // bottom-left + [p3x, p3y], // bottom + [p4x, p4y], // bottom-right + [p5x, p5y], // top-right + [p6x, p6y], // top + ] + } + + extractWalls() { + const walls = [] + const vertexCache = new Map() + + const makeVertex = (x, y) => { + const rx = Math.round(x * 1000000) / 1000000 + const ry = Math.round(y * 1000000) / 1000000 + const key = `${rx},${ry}` + + if (!vertexCache.has(key)) { + vertexCache.set(key, new Victor(rx, ry)) + } + + return vertexCache.get(key) + } + + for (const cell of this.cells) { + const { q, r } = cell + const corners = this.getHexCorners(q, r) + const rowOffset = this.getRowOffset(r) + + // Edge between p1-p2 (west edge) + const west = this.getCell(q - 1, r) + + if (!west || !this.isLinked(cell, west)) { + walls.push([ + makeVertex(corners[0][0], corners[0][1]), + makeVertex(corners[1][0], corners[1][1]), + ]) + } + + // Edge between p2-p3 (southwest edge) + const sw = this.getCell(q - rowOffset, r + 1) + + if (!sw || !this.isLinked(cell, sw)) { + walls.push([ + makeVertex(corners[1][0], corners[1][1]), + makeVertex(corners[2][0], corners[2][1]), + ]) + } + + // Edge between p3-p4 (southeast edge) + const se = this.getCell(q + 1 - rowOffset, r + 1) + + if (!se || !this.isLinked(cell, se)) { + walls.push([ + makeVertex(corners[2][0], corners[2][1]), + makeVertex(corners[3][0], corners[3][1]), + ]) + } + + // Edge between p4-p5 (east edge) + const east = this.getCell(q + 1, r) + + if (!east || !this.isLinked(cell, east)) { + walls.push([ + makeVertex(corners[3][0], corners[3][1]), + makeVertex(corners[4][0], corners[4][1]), + ]) + } + + // Edge between p5-p6 (northeast edge) + const ne = this.getCell(q + 1 - rowOffset, r - 1) + + if (!ne || !this.isLinked(cell, ne)) { + walls.push([ + makeVertex(corners[4][0], corners[4][1]), + makeVertex(corners[5][0], corners[5][1]), + ]) + } + + // Edge between p6-p1 (northwest edge) + const nw = this.getCell(q - rowOffset, r - 1) + + if (!nw || !this.isLinked(cell, nw)) { + walls.push([ + makeVertex(corners[5][0], corners[5][1]), + makeVertex(corners[0][0], corners[0][1]), + ]) + } + } + + return walls + } +} diff --git a/src/features/shapes/maze/Maze.js b/src/features/shapes/maze/Maze.js index 5bae4458..fbde62ff 100644 --- a/src/features/shapes/maze/Maze.js +++ b/src/features/shapes/maze/Maze.js @@ -6,6 +6,7 @@ import { difference } from "@/common/util" import { cloneVertices, centerOnOrigin } from "@/common/geometry" import RectangularGrid from "./RectangularGrid" import PolarGrid from "./PolarGrid" +import HexGrid from "./HexGrid" import { wilson } from "./algorithms/wilson" import { backtracker } from "./algorithms/backtracker" import { division } from "./algorithms/division" @@ -28,7 +29,7 @@ const options = { mazeShape: { title: "Shape", type: "togglebutton", - choices: ["Rectangle", "Circle"], + choices: ["Rectangle", "Hexagon", "Circle"], }, mazeType: { title: "Algorithm", @@ -43,7 +44,7 @@ const options = { "Eller", ], isVisible: (layer, state) => { - return state.mazeShape !== "Circle" + return state.mazeShape === "Rectangle" }, }, mazeTypeCircle: { @@ -54,6 +55,14 @@ const options = { return state.mazeShape === "Circle" }, }, + mazeTypeHex: { + title: "Algorithm", + type: "dropdown", + choices: ["Wilson", "Backtracker", "Prim", "Kruskal"], + isVisible: (layer, state) => { + return state.mazeShape === "Hexagon" + }, + }, mazeWidth: { title: "Maze width", min: 1, @@ -109,10 +118,10 @@ const options = { max: 10, step: 1, isVisible: (layer, state) => { - return ( - state.mazeShape !== "Circle" && - (state.mazeType === "Backtracker" || state.mazeType === "Sidewinder") - ) + if (state.mazeShape === "Circle") return false + if (state.mazeShape === "Hexagon") return state.mazeTypeHex === "Backtracker" + + return state.mazeType === "Backtracker" || state.mazeType === "Sidewinder" }, }, mazeHorizontalBias: { @@ -164,6 +173,7 @@ export default class Maze extends Shape { mazeShape: "Rectangle", mazeType: "Wilson", mazeTypeCircle: "Wilson", + mazeTypeHex: "Wilson", mazeWidth: 8, mazeHeight: 8, mazeRingCount: 6, @@ -183,6 +193,7 @@ export default class Maze extends Shape { mazeShape, mazeType, mazeTypeCircle, + mazeTypeHex, mazeStraightness, mazeHorizontalBias, mazeBranchLevel, @@ -191,7 +202,12 @@ export default class Maze extends Shape { const rng = seedrandom(seed) const grid = this.createGrid(state.shape, rng) - const algorithmName = mazeShape === "Circle" ? mazeTypeCircle : mazeType + const algorithmName = + mazeShape === "Circle" + ? mazeTypeCircle + : mazeShape === "Hexagon" + ? mazeTypeHex + : mazeType const algorithm = algorithms[algorithmName.toLowerCase()] algorithm(grid, { @@ -228,6 +244,10 @@ export default class Maze extends Shape { const width = Math.max(2, mazeWidth) const height = Math.max(2, mazeHeight) + if (mazeShape === "Hexagon") { + return new HexGrid(width, height, rng) + } + return new RectangularGrid(width, height, rng) } From 1e0e48a97208399175d311ba6c4155e1bb3c6297 Mon Sep 17 00:00:00 2001 From: Bob Carmichael Date: Thu, 18 Dec 2025 14:28:30 -0500 Subject: [PATCH 05/22] triangle grid --- src/features/shapes/maze/Maze.js | 55 ++++-- src/features/shapes/maze/TriangleGrid.js | 227 +++++++++++++++++++++++ 2 files changed, 271 insertions(+), 11 deletions(-) create mode 100644 src/features/shapes/maze/TriangleGrid.js diff --git a/src/features/shapes/maze/Maze.js b/src/features/shapes/maze/Maze.js index fbde62ff..0494736c 100644 --- a/src/features/shapes/maze/Maze.js +++ b/src/features/shapes/maze/Maze.js @@ -7,6 +7,7 @@ import { cloneVertices, centerOnOrigin } from "@/common/geometry" import RectangularGrid from "./RectangularGrid" import PolarGrid from "./PolarGrid" import HexGrid from "./HexGrid" +import TriangleGrid from "./TriangleGrid" import { wilson } from "./algorithms/wilson" import { backtracker } from "./algorithms/backtracker" import { division } from "./algorithms/division" @@ -29,7 +30,7 @@ const options = { mazeShape: { title: "Shape", type: "togglebutton", - choices: ["Rectangle", "Hexagon", "Circle"], + choices: ["Rectangle", "Hexagon", "Triangle", "Circle"], }, mazeType: { title: "Algorithm", @@ -63,6 +64,14 @@ const options = { return state.mazeShape === "Hexagon" }, }, + mazeTypeTriangle: { + title: "Algorithm", + type: "dropdown", + choices: ["Wilson", "Backtracker", "Prim", "Kruskal"], + isVisible: (layer, state) => { + return state.mazeShape === "Triangle" + }, + }, mazeWidth: { title: "Maze width", min: 1, @@ -119,7 +128,10 @@ const options = { step: 1, isVisible: (layer, state) => { if (state.mazeShape === "Circle") return false - if (state.mazeShape === "Hexagon") return state.mazeTypeHex === "Backtracker" + if (state.mazeShape === "Hexagon") + return state.mazeTypeHex === "Backtracker" + if (state.mazeShape === "Triangle") + return state.mazeTypeTriangle === "Backtracker" return state.mazeType === "Backtracker" || state.mazeType === "Sidewinder" }, @@ -146,9 +158,18 @@ const options = { max: 10, step: 1, isVisible: (layer, state) => { - // Works for both rectangular and circular Prim - const algo = - state.mazeShape === "Circle" ? state.mazeTypeCircle : state.mazeType + // Works for rectangular, hex, triangle, and circular Prim + let algo + + if (state.mazeShape === "Circle") { + algo = state.mazeTypeCircle + } else if (state.mazeShape === "Hexagon") { + algo = state.mazeTypeHex + } else if (state.mazeShape === "Triangle") { + algo = state.mazeTypeTriangle + } else { + algo = state.mazeType + } return algo === "Prim" }, @@ -174,6 +195,7 @@ export default class Maze extends Shape { mazeType: "Wilson", mazeTypeCircle: "Wilson", mazeTypeHex: "Wilson", + mazeTypeTriangle: "Wilson", mazeWidth: 8, mazeHeight: 8, mazeRingCount: 6, @@ -194,6 +216,7 @@ export default class Maze extends Shape { mazeType, mazeTypeCircle, mazeTypeHex, + mazeTypeTriangle, mazeStraightness, mazeHorizontalBias, mazeBranchLevel, @@ -202,12 +225,18 @@ export default class Maze extends Shape { const rng = seedrandom(seed) const grid = this.createGrid(state.shape, rng) - const algorithmName = - mazeShape === "Circle" - ? mazeTypeCircle - : mazeShape === "Hexagon" - ? mazeTypeHex - : mazeType + let algorithmName + + if (mazeShape === "Circle") { + algorithmName = mazeTypeCircle + } else if (mazeShape === "Hexagon") { + algorithmName = mazeTypeHex + } else if (mazeShape === "Triangle") { + algorithmName = mazeTypeTriangle + } else { + algorithmName = mazeType + } + const algorithm = algorithms[algorithmName.toLowerCase()] algorithm(grid, { @@ -248,6 +277,10 @@ export default class Maze extends Shape { return new HexGrid(width, height, rng) } + if (mazeShape === "Triangle") { + return new TriangleGrid(width, height, rng) + } + return new RectangularGrid(width, height, rng) } diff --git a/src/features/shapes/maze/TriangleGrid.js b/src/features/shapes/maze/TriangleGrid.js new file mode 100644 index 00000000..cea402b4 --- /dev/null +++ b/src/features/shapes/maze/TriangleGrid.js @@ -0,0 +1,227 @@ +import Victor from "victor" + +// Triangular grid for delta mazes +// Uses alternating up/down triangles based on coordinate parity + +export default class TriangleGrid { + constructor(width, height, rng) { + this.gridType = "triangle" + this.width = width + this.height = height + this.rng = rng + + this.triHeight = Math.sqrt(3) / 2 + + // Calculate raw dimensions for aspect ratio + // Screen width: triangles overlap, so width = (W-1)*0.5 + 1 = 0.5*W + 0.5 + // Screen height: H * triHeight + const rawWidth = 0.5 * width + 0.5 + const rawHeight = height * this.triHeight + + this.yScale = rawWidth / rawHeight + this.cells = [] + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + this.cells.push(this.createCell(x, y)) + } + } + + const startIndex = Math.floor(rng() * this.cells.length) + + this.cells[startIndex].visited = true + } + + createCell(x, y) { + return { + x, + y, + upward: this.isUpward(x, y), + links: new Set(), + visited: false, + } + } + + // Parity check determines triangle orientation + // Even sum = DOWN (base on north), Odd sum = UP (base on south) + isUpward(x, y) { + return (x + y) % 2 === 1 + } + + getCell(x, y) { + if (x < 0 || x >= this.width || y < 0 || y >= this.height) return null + + return this.cells[y * this.width + x] + } + + getAllCells() { + return this.cells + } + + getRandomCell() { + return this.cells[Math.floor(this.rng() * this.cells.length)] + } + + // 3 neighbors per cell: E, W, and either N (down) or S (up) + getNeighbors(cell) { + const { x, y, upward } = cell + const neighbors = [] + + // East neighbor (always) + const east = this.getCell(x + 1, y) + if (east) neighbors.push(east) + + // West neighbor (always) + const west = this.getCell(x - 1, y) + if (west) neighbors.push(west) + + // Vertical neighbor depends on orientation + if (upward) { + // UP triangle has base on south, connects to south neighbor + const south = this.getCell(x, y + 1) + if (south) neighbors.push(south) + } else { + // DOWN triangle has base on north, connects to north neighbor + const north = this.getCell(x, y - 1) + if (north) neighbors.push(north) + } + + return neighbors + } + + cellKey(cell) { + return `${cell.x},${cell.y}` + } + + link(cell1, cell2) { + cell1.links.add(this.cellKey(cell2)) + cell2.links.add(this.cellKey(cell1)) + } + + unlink(cell1, cell2) { + cell1.links.delete(this.cellKey(cell2)) + cell2.links.delete(this.cellKey(cell1)) + } + + isLinked(cell1, cell2) { + return cell1.links.has(this.cellKey(cell2)) + } + + markVisited(cell) { + cell.visited = true + } + + isVisited(cell) { + return cell.visited + } + + cellEquals(cell1, cell2) { + return cell1.x === cell2.x && cell1.y === cell2.y + } + + getTriangleCorners(x, y) { + const h = this.triHeight + const ys = this.yScale + const baseX = x * 0.5 + + if (this.isUpward(x, y)) { + // UP triangle: apex at top, base at bottom + return { + top: [baseX + 0.5, y * h * ys], + bottomLeft: [baseX, (y + 1) * h * ys], + bottomRight: [baseX + 1, (y + 1) * h * ys], + } + } else { + // DOWN triangle: base at top, apex at bottom + return { + topLeft: [baseX, y * h * ys], + topRight: [baseX + 1, y * h * ys], + bottom: [baseX + 0.5, (y + 1) * h * ys], + } + } + } + + extractWalls() { + const walls = [] + const vertexCache = new Map() + + const makeVertex = (x, y) => { + const rx = Math.round(x * 1000000) / 1000000 + const ry = Math.round(y * 1000000) / 1000000 + const key = `${rx},${ry}` + + if (!vertexCache.has(key)) { + vertexCache.set(key, new Victor(rx, ry)) + } + + return vertexCache.get(key) + } + + for (const cell of this.cells) { + const { x, y, upward } = cell + const corners = this.getTriangleCorners(x, y) + + // East neighbor + const east = this.getCell(x + 1, y) + + // West neighbor + const west = this.getCell(x - 1, y) + + if (upward) { + // UP triangle: top, bottomLeft, bottomRight + // Left edge: top to bottomLeft (shared with west) + if (!west || !this.isLinked(cell, west)) { + walls.push([ + makeVertex(corners.top[0], corners.top[1]), + makeVertex(corners.bottomLeft[0], corners.bottomLeft[1]), + ]) + } + + // Right edge: top to bottomRight (shared with east) + if (!east || !this.isLinked(cell, east)) { + walls.push([ + makeVertex(corners.top[0], corners.top[1]), + makeVertex(corners.bottomRight[0], corners.bottomRight[1]), + ]) + } + + // Bottom edge: bottomLeft to bottomRight (shared with south) + const south = this.getCell(x, y + 1) + if (!south || !this.isLinked(cell, south)) { + walls.push([ + makeVertex(corners.bottomLeft[0], corners.bottomLeft[1]), + makeVertex(corners.bottomRight[0], corners.bottomRight[1]), + ]) + } + } else { + // DOWN triangle: topLeft, topRight, bottom + // Left edge: topLeft to bottom (shared with west) + if (!west || !this.isLinked(cell, west)) { + walls.push([ + makeVertex(corners.topLeft[0], corners.topLeft[1]), + makeVertex(corners.bottom[0], corners.bottom[1]), + ]) + } + + // Right edge: topRight to bottom (shared with east) + if (!east || !this.isLinked(cell, east)) { + walls.push([ + makeVertex(corners.topRight[0], corners.topRight[1]), + makeVertex(corners.bottom[0], corners.bottom[1]), + ]) + } + + // Top edge: topLeft to topRight (shared with north) + const north = this.getCell(x, y - 1) + if (!north || !this.isLinked(cell, north)) { + walls.push([ + makeVertex(corners.topLeft[0], corners.topLeft[1]), + makeVertex(corners.topRight[0], corners.topRight[1]), + ]) + } + } + } + + return walls + } +} From 359c9bda2f5407327e5d9bf0cdc7cbacd8a02ba2 Mon Sep 17 00:00:00 2001 From: Bob Carmichael Date: Thu, 18 Dec 2025 14:33:39 -0500 Subject: [PATCH 06/22] some refactoring --- src/features/shapes/maze/Maze.js | 84 ++++++------------- .../shapes/maze/{ => grids}/HexGrid.js | 0 .../shapes/maze/{ => grids}/PolarGrid.js | 0 .../maze/{ => grids}/RectangularGrid.js | 0 .../shapes/maze/{ => grids}/TriangleGrid.js | 0 5 files changed, 27 insertions(+), 57 deletions(-) rename src/features/shapes/maze/{ => grids}/HexGrid.js (100%) rename src/features/shapes/maze/{ => grids}/PolarGrid.js (100%) rename src/features/shapes/maze/{ => grids}/RectangularGrid.js (100%) rename src/features/shapes/maze/{ => grids}/TriangleGrid.js (100%) diff --git a/src/features/shapes/maze/Maze.js b/src/features/shapes/maze/Maze.js index 0494736c..eee8b8c2 100644 --- a/src/features/shapes/maze/Maze.js +++ b/src/features/shapes/maze/Maze.js @@ -4,10 +4,10 @@ import Graph from "@/common/Graph" import { eulerianTrail } from "@/common/eulerian_trail/eulerianTrail" import { difference } from "@/common/util" import { cloneVertices, centerOnOrigin } from "@/common/geometry" -import RectangularGrid from "./RectangularGrid" -import PolarGrid from "./PolarGrid" -import HexGrid from "./HexGrid" -import TriangleGrid from "./TriangleGrid" +import RectangularGrid from "./grids/RectangularGrid" +import PolarGrid from "./grids/PolarGrid" +import HexGrid from "./grids/HexGrid" +import TriangleGrid from "./grids/TriangleGrid" import { wilson } from "./algorithms/wilson" import { backtracker } from "./algorithms/backtracker" import { division } from "./algorithms/division" @@ -26,6 +26,21 @@ const algorithms = { eller, } +const algorithmKeyByShape = { + Rectangle: "mazeType", + Circle: "mazeTypeCircle", + Hexagon: "mazeTypeHex", + Triangle: "mazeTypeTriangle", +} + +const getAlgorithm = (state) => state[algorithmKeyByShape[state.mazeShape]] + +const gridByShape = { + Rectangle: RectangularGrid, + Hexagon: HexGrid, + Triangle: TriangleGrid, +} + const options = { mazeShape: { title: "Shape", @@ -128,12 +143,9 @@ const options = { step: 1, isVisible: (layer, state) => { if (state.mazeShape === "Circle") return false - if (state.mazeShape === "Hexagon") - return state.mazeTypeHex === "Backtracker" - if (state.mazeShape === "Triangle") - return state.mazeTypeTriangle === "Backtracker" + const algo = getAlgorithm(state) - return state.mazeType === "Backtracker" || state.mazeType === "Sidewinder" + return algo === "Backtracker" || algo === "Sidewinder" }, }, mazeHorizontalBias: { @@ -158,20 +170,7 @@ const options = { max: 10, step: 1, isVisible: (layer, state) => { - // Works for rectangular, hex, triangle, and circular Prim - let algo - - if (state.mazeShape === "Circle") { - algo = state.mazeTypeCircle - } else if (state.mazeShape === "Hexagon") { - algo = state.mazeTypeHex - } else if (state.mazeShape === "Triangle") { - algo = state.mazeTypeTriangle - } else { - algo = state.mazeType - } - - return algo === "Prim" + return getAlgorithm(state) === "Prim" }, }, seed: { @@ -211,32 +210,12 @@ export default class Maze extends Shape { } getVertices(state) { - const { - mazeShape, - mazeType, - mazeTypeCircle, - mazeTypeHex, - mazeTypeTriangle, - mazeStraightness, - mazeHorizontalBias, - mazeBranchLevel, - seed, - } = state.shape + const { mazeStraightness, mazeHorizontalBias, mazeBranchLevel, seed } = + state.shape const rng = seedrandom(seed) const grid = this.createGrid(state.shape, rng) - let algorithmName - - if (mazeShape === "Circle") { - algorithmName = mazeTypeCircle - } else if (mazeShape === "Hexagon") { - algorithmName = mazeTypeHex - } else if (mazeShape === "Triangle") { - algorithmName = mazeTypeTriangle - } else { - algorithmName = mazeType - } - + const algorithmName = getAlgorithm(state.shape) const algorithm = algorithms[algorithmName.toLowerCase()] algorithm(grid, { @@ -270,18 +249,9 @@ export default class Maze extends Shape { ) } - const width = Math.max(2, mazeWidth) - const height = Math.max(2, mazeHeight) - - if (mazeShape === "Hexagon") { - return new HexGrid(width, height, rng) - } - - if (mazeShape === "Triangle") { - return new TriangleGrid(width, height, rng) - } + const GridClass = gridByShape[mazeShape] - return new RectangularGrid(width, height, rng) + return new GridClass(Math.max(2, mazeWidth), Math.max(2, mazeHeight), rng) } drawMaze(grid) { diff --git a/src/features/shapes/maze/HexGrid.js b/src/features/shapes/maze/grids/HexGrid.js similarity index 100% rename from src/features/shapes/maze/HexGrid.js rename to src/features/shapes/maze/grids/HexGrid.js diff --git a/src/features/shapes/maze/PolarGrid.js b/src/features/shapes/maze/grids/PolarGrid.js similarity index 100% rename from src/features/shapes/maze/PolarGrid.js rename to src/features/shapes/maze/grids/PolarGrid.js diff --git a/src/features/shapes/maze/RectangularGrid.js b/src/features/shapes/maze/grids/RectangularGrid.js similarity index 100% rename from src/features/shapes/maze/RectangularGrid.js rename to src/features/shapes/maze/grids/RectangularGrid.js diff --git a/src/features/shapes/maze/TriangleGrid.js b/src/features/shapes/maze/grids/TriangleGrid.js similarity index 100% rename from src/features/shapes/maze/TriangleGrid.js rename to src/features/shapes/maze/grids/TriangleGrid.js From 33d80117b11179cdd694afb5f29ed90dee01cdc0 Mon Sep 17 00:00:00 2001 From: Bob Carmichael Date: Fri, 19 Dec 2025 06:01:15 -0500 Subject: [PATCH 07/22] bug fix related to picking a starting node; add chinese postman for better maze traversal --- src/common/chinesePostman.js | 183 ++++++++++++++++++ src/features/shapes/maze/Maze.js | 55 ++---- src/features/shapes/maze/algorithms/wilson.js | 12 +- src/features/shapes/maze/grids/HexGrid.js | 4 - src/features/shapes/maze/grids/PolarGrid.js | 28 ++- .../shapes/maze/grids/RectangularGrid.js | 48 ++++- .../shapes/maze/grids/TriangleGrid.js | 4 - 7 files changed, 268 insertions(+), 66 deletions(-) create mode 100644 src/common/chinesePostman.js diff --git a/src/common/chinesePostman.js b/src/common/chinesePostman.js new file mode 100644 index 00000000..ad590a18 --- /dev/null +++ b/src/common/chinesePostman.js @@ -0,0 +1,183 @@ +// Chinese Postman (Route Inspection) Algorithm +// Finds the minimum-cost path that visits every edge at least once. +// Works with plain edge arrays; uses provided Dijkstra function for shortest paths. + +function buildAdjacencyList(edges) { + const adj = new Map() + + for (const [n1, n2] of edges) { + if (!adj.has(n1)) adj.set(n1, []) + if (!adj.has(n2)) adj.set(n2, []) + adj.get(n1).push(n2) + adj.get(n2).push(n1) + } + + return adj +} + +function findOddVertices(adjacencyList) { + const odd = [] + + for (const [nodeKey, neighbors] of adjacencyList) { + if (neighbors.length % 2 !== 0) { + odd.push(nodeKey) + } + } + + return odd +} + +function computePairwiseDistances(vertices, dijkstraFn) { + const distances = new Map() + + for (let i = 0; i < vertices.length; i++) { + for (let j = i + 1; j < vertices.length; j++) { + const v1 = vertices[i] + const v2 = vertices[j] + const path = dijkstraFn(v1, v2) + const distance = path ? path.length - 1 : Infinity + const key = [v1, v2].sort().join("|") + + distances.set(key, { distance, path, v1, v2 }) + } + } + + return distances +} + +function greedyMinimumMatching(vertices, distances) { + if (vertices.length === 0 || vertices.length % 2 !== 0) { + return [] + } + + const edges = Array.from(distances.values()) + + edges.sort((a, b) => a.distance - b.distance) + + const matched = new Set() + const matching = [] + + for (const edge of edges) { + if (!matched.has(edge.v1) && !matched.has(edge.v2)) { + matching.push(edge) + matched.add(edge.v1) + matched.add(edge.v2) + + if (matched.size === vertices.length) { + break + } + } + } + + return matching +} + +const MAX_ODD_VERTICES_FOR_FULL = 30 + +function nearestNeighborMatching(vertices, adjacencyList) { + if (vertices.length === 0 || vertices.length % 2 !== 0) { + return [] + } + + const matching = [] + const unmatched = new Set(vertices) + + while (unmatched.size > 0) { + const v1 = unmatched.values().next().value + + unmatched.delete(v1) + + // Find nearest unmatched vertex by graph distance (BFS) + let nearest = null + let nearestPath = null + const visited = new Set([v1]) + const queue = [[v1, [v1]]] + + while (queue.length > 0 && !nearest) { + const [current, path] = queue.shift() + const neighbors = adjacencyList.get(current) || [] + + for (const neighbor of neighbors) { + if (visited.has(neighbor)) continue + + visited.add(neighbor) + const newPath = [...path, neighbor] + + if (unmatched.has(neighbor)) { + nearest = neighbor + nearestPath = newPath + break + } + + queue.push([neighbor, newPath]) + } + } + + if (nearest) { + unmatched.delete(nearest) + matching.push({ v1, v2: nearest, pathKeys: nearestPath }) + } + } + + return matching +} + +/** + * Eulerize an edge array using Chinese Postman algorithm + * Returns a new edge array with duplicate edges added to make all vertices even-degree + */ +export function eulerizeEdges(edges, dijkstraFn, nodeMap = null) { + const adjacencyList = buildAdjacencyList(edges) + const oddVertices = findOddVertices(adjacencyList) + + if (oddVertices.length === 0 || oddVertices.length % 2 !== 0) { + return { + edges: [...edges], + oddVertices, + matching: [], + duplicateCount: 0, + } + } + + const verticesToMatch = oddVertices + + let matching + + if (verticesToMatch.length <= MAX_ODD_VERTICES_FOR_FULL) { + // Full pairwise Dijkstra for small vertex sets + const distances = computePairwiseDistances(verticesToMatch, dijkstraFn) + + matching = greedyMinimumMatching(verticesToMatch, distances) + } else { + // Fast nearest-neighbor for large vertex sets + matching = nearestNeighborMatching(verticesToMatch, adjacencyList) + } + + // Build new edge array with duplicates + const newEdges = [...edges] + let duplicateCount = 0 + + for (const match of matching) { + // Handle both formats: {path} from Dijkstra or {pathKeys} from BFS + const path = match.path || (match.pathKeys && nodeMap + ? match.pathKeys.map(k => nodeMap[k] || { toString: () => k }) + : match.pathKeys?.map(k => ({ toString: () => k }))) + + if (!path || path.length < 2) continue + + for (let i = 0; i < path.length - 1; i++) { + const n1 = path[i].toString() + const n2 = path[i + 1].toString() + + newEdges.push([n1, n2]) + duplicateCount++ + } + } + + return { + edges: newEdges, + oddVertices, + matching, + duplicateCount, + } +} diff --git a/src/features/shapes/maze/Maze.js b/src/features/shapes/maze/Maze.js index eee8b8c2..0592c19c 100644 --- a/src/features/shapes/maze/Maze.js +++ b/src/features/shapes/maze/Maze.js @@ -2,7 +2,7 @@ import Shape from "../Shape" import seedrandom from "seedrandom" import Graph from "@/common/Graph" import { eulerianTrail } from "@/common/eulerian_trail/eulerianTrail" -import { difference } from "@/common/util" +import { eulerizeEdges } from "@/common/chinesePostman" import { cloneVertices, centerOnOrigin } from "@/common/geometry" import RectangularGrid from "./grids/RectangularGrid" import PolarGrid from "./grids/PolarGrid" @@ -26,6 +26,9 @@ const algorithms = { eller, } +// Set to true to debug maze generation +const DEBUG_MAZE = true + const algorithmKeyByShape = { Rectangle: "mazeType", Circle: "mazeTypeCircle", @@ -225,6 +228,11 @@ export default class Maze extends Shape { branchLevel: mazeBranchLevel, }) + if (DEBUG_MAZE && grid.dump) { + console.log(`\n=== ${algorithmName} on ${state.shape.mazeShape} ===`) + grid.dump() + } + return this.drawMaze(grid) } @@ -264,51 +272,18 @@ export default class Maze extends Shape { graph.addEdge(v1, v2) }) - // Calculate Eulerian trail and track which edges we walk - const trail = eulerianTrail({ edges: Object.values(graph.edgeMap) }) - const walkedEdges = new Set( - trail.slice(0, -1).map((key, i) => [key, trail[i + 1]].sort().toString()), - ) - const missingEdges = {} - - for (const edgeStr of difference(walkedEdges, graph.edgeKeys)) { - const [x1, y1, x2, y2] = edgeStr.split(",") - missingEdges[`${x1},${y1}`] = `${x2},${y2}` + const edges = Object.values(graph.edgeMap) + const dijkstraFn = (startKey, endKey) => { + return graph.dijkstraShortestPath(startKey, endKey) } - - // Walk the trail, filling gaps with Dijkstra shortest paths + const { edges: eulerizedEdges } = eulerizeEdges(edges, dijkstraFn, graph.nodeMap) + const trail = eulerianTrail({ edges: eulerizedEdges }) const walkedVertices = [] - let prevKey trail.forEach((key) => { const vertex = graph.nodeMap[key] - if (prevKey) { - if (!graph.hasEdge(key, prevKey)) { - const path = graph.dijkstraShortestPath(prevKey, key) - - path.shift() - walkedVertices.push(...path, vertex) - } else { - walkedVertices.push(vertex) - } - } else { - walkedVertices.push(vertex) - } - - // Add back any missing edges - if (missingEdges[key]) { - const missingVertex = graph.nodeMap[missingEdges[key]] - const edgeKey = [key, missingEdges[key]].sort().toString() - - if (graph.edgeMap[edgeKey]) { - walkedVertices.push(missingVertex, vertex) - } - - delete missingEdges[key] - } - - prevKey = key + walkedVertices.push(vertex) }) const vertices = cloneVertices(walkedVertices) diff --git a/src/features/shapes/maze/algorithms/wilson.js b/src/features/shapes/maze/algorithms/wilson.js index 283ea88f..1157e201 100644 --- a/src/features/shapes/maze/algorithms/wilson.js +++ b/src/features/shapes/maze/algorithms/wilson.js @@ -9,13 +9,11 @@ export const wilson = (grid, { rng }) => { // Track visited cells (part of the maze tree) const visited = new Set() - // Find the initially visited cell (set during grid construction) - for (const cell of allCells) { - if (grid.isVisited(cell)) { - visited.add(grid.cellKey(cell)) - break - } - } + // Pick a random cell to seed the maze tree + const seedCell = grid.getRandomCell() + + grid.markVisited(seedCell) + visited.add(grid.cellKey(seedCell)) // Keep going until all cells are in the maze while (visited.size < allCells.length) { diff --git a/src/features/shapes/maze/grids/HexGrid.js b/src/features/shapes/maze/grids/HexGrid.js index d4704738..a4d75883 100644 --- a/src/features/shapes/maze/grids/HexGrid.js +++ b/src/features/shapes/maze/grids/HexGrid.js @@ -25,10 +25,6 @@ export default class HexGrid { this.cells.push(this.createCell(q, r)) } } - - const startIndex = Math.floor(rng() * this.cells.length) - - this.cells[startIndex].visited = true } createCell(q, r) { diff --git a/src/features/shapes/maze/grids/PolarGrid.js b/src/features/shapes/maze/grids/PolarGrid.js index 51c8d625..c12f1260 100644 --- a/src/features/shapes/maze/grids/PolarGrid.js +++ b/src/features/shapes/maze/grids/PolarGrid.js @@ -36,12 +36,6 @@ export default class PolarGrid { this.rings[r][w] = this.createCell(r, w) } } - - // Mark a random cell as visited (for algorithm initialization) - const startRing = Math.floor(rng() * (ringCount + 1)) - const startWedge = Math.floor(rng() * this.rings[startRing].length) - - this.rings[startRing][startWedge].visited = true } createCell(ring, wedge) { @@ -183,6 +177,28 @@ export default class PolarGrid { return cell1.ring === cell2.ring && cell1.wedge === cell2.wedge } + // Debug: dump maze structure + dump() { + let output = "" + + for (let r = 0; r <= this.ringCount; r++) { + const wedgeCount = this.rings[r].length + + output += `Ring ${r} (${wedgeCount} wedge${wedgeCount > 1 ? "s" : ""}):\n` + + for (let w = 0; w < wedgeCount; w++) { + const cell = this.rings[r][w] + const links = Array.from(cell.links).sort().join(", ") + + output += ` [${r}:${w}] -> ${links || "(none)"}\n` + } + } + + console.log(output) + + return output + } + extractWalls() { const walls = [] const vertexCache = new Map() diff --git a/src/features/shapes/maze/grids/RectangularGrid.js b/src/features/shapes/maze/grids/RectangularGrid.js index 60d8cdd7..d26436e1 100644 --- a/src/features/shapes/maze/grids/RectangularGrid.js +++ b/src/features/shapes/maze/grids/RectangularGrid.js @@ -16,11 +16,6 @@ export default class RectangularGrid { this.cells.push(this.createCell(x, y)) } } - - // Mark a random cell as visited (for algorithm initialization) - const startIndex = Math.floor(rng() * this.cells.length) - - this.cells[startIndex].visited = true } createCell(x, y) { @@ -141,4 +136,47 @@ export default class RectangularGrid { return walls } + + // Debug: dump maze as ASCII art (y=0 at bottom) + dump() { + let output = "" + + for (let y = this.height - 1; y >= 0; y--) { + let topLine = "" + + for (let x = 0; x < this.width; x++) { + const cell = this.getCell(x, y) + const southCell = this.getCell(x, y + 1) + const hasSouthLink = southCell && this.isLinked(cell, southCell) + + topLine += "+" + (hasSouthLink ? " " : "---") + } + topLine += "+" + output += topLine + "\n" + + let cellLine = "" + + for (let x = 0; x < this.width; x++) { + const cell = this.getCell(x, y) + const westCell = this.getCell(x - 1, y) + const hasWestLink = westCell && this.isLinked(cell, westCell) + + cellLine += (hasWestLink ? " " : "|") + " " + } + cellLine += "|" + output += cellLine + "\n" + } + + let bottomLine = "" + + for (let x = 0; x < this.width; x++) { + bottomLine += "+---" + } + bottomLine += "+" + output += bottomLine + "\n" + + console.log(output) + + return output + } } diff --git a/src/features/shapes/maze/grids/TriangleGrid.js b/src/features/shapes/maze/grids/TriangleGrid.js index cea402b4..67cf06d7 100644 --- a/src/features/shapes/maze/grids/TriangleGrid.js +++ b/src/features/shapes/maze/grids/TriangleGrid.js @@ -26,10 +26,6 @@ export default class TriangleGrid { this.cells.push(this.createCell(x, y)) } } - - const startIndex = Math.floor(rng() * this.cells.length) - - this.cells[startIndex].visited = true } createCell(x, y) { From 8e1b6a5da355c6f12f0c683dc141d56e6f45d5dd Mon Sep 17 00:00:00 2001 From: Bob Carmichael Date: Sat, 20 Dec 2025 07:38:53 -0500 Subject: [PATCH 08/22] add entry and exit arrows --- src/common/chinesePostman.js | 8 +- src/features/shapes/maze/Maze.js | 52 ++++-- src/features/shapes/maze/grids/Grid.js | 170 ++++++++++++++++++ src/features/shapes/maze/grids/HexGrid.js | 123 +++++++++++-- src/features/shapes/maze/grids/PolarGrid.js | 151 +++++++++++++++- .../shapes/maze/grids/RectangularGrid.js | 132 ++++++++++++-- .../shapes/maze/grids/TriangleGrid.js | 126 ++++++++++++- 7 files changed, 710 insertions(+), 52 deletions(-) create mode 100644 src/features/shapes/maze/grids/Grid.js diff --git a/src/common/chinesePostman.js b/src/common/chinesePostman.js index ad590a18..16763bdf 100644 --- a/src/common/chinesePostman.js +++ b/src/common/chinesePostman.js @@ -159,9 +159,11 @@ export function eulerizeEdges(edges, dijkstraFn, nodeMap = null) { for (const match of matching) { // Handle both formats: {path} from Dijkstra or {pathKeys} from BFS - const path = match.path || (match.pathKeys && nodeMap - ? match.pathKeys.map(k => nodeMap[k] || { toString: () => k }) - : match.pathKeys?.map(k => ({ toString: () => k }))) + const path = + match.path || + (match.pathKeys && nodeMap + ? match.pathKeys.map((k) => nodeMap[k] || { toString: () => k }) + : match.pathKeys?.map((k) => ({ toString: () => k }))) if (!path || path.length < 2) continue diff --git a/src/features/shapes/maze/Maze.js b/src/features/shapes/maze/Maze.js index 0592c19c..f8eeb490 100644 --- a/src/features/shapes/maze/Maze.js +++ b/src/features/shapes/maze/Maze.js @@ -1,3 +1,4 @@ +/* global console */ import Shape from "../Shape" import seedrandom from "seedrandom" import Graph from "@/common/Graph" @@ -176,6 +177,10 @@ const options = { return getAlgorithm(state) === "Prim" }, }, + mazeShowExits: { + title: "Show entry/exit", + type: "checkbox", + }, seed: { title: "Random seed", min: 1, @@ -207,14 +212,20 @@ export default class Maze extends Shape { mazeStraightness: 0, mazeHorizontalBias: 5, mazeBranchLevel: 5, + mazeShowExits: true, seed: 1, }, } } getVertices(state) { - const { mazeStraightness, mazeHorizontalBias, mazeBranchLevel, seed } = - state.shape + const { + mazeStraightness, + mazeHorizontalBias, + mazeBranchLevel, + mazeShowExits, + seed, + } = state.shape const rng = seedrandom(seed) const grid = this.createGrid(state.shape, rng) @@ -228,12 +239,30 @@ export default class Maze extends Shape { branchLevel: mazeBranchLevel, }) + // Find hardest exits (start/end openings) - supported for grids with getEdgeCells + let exitWalls = null + + if (mazeShowExits && grid.findHardestExits) { + const exits = grid.findHardestExits() + + if (exits && grid.getExitVertices) { + // Mark entrance vs exit for door swing direction + exits.startCell.exitType = "entrance" + exits.endCell.exitType = "exit" + + exitWalls = { + start: grid.getExitVertices(exits.startCell), + end: grid.getExitVertices(exits.endCell), + } + } + } + if (DEBUG_MAZE && grid.dump) { console.log(`\n=== ${algorithmName} on ${state.shape.mazeShape} ===`) grid.dump() } - return this.drawMaze(grid) + return this.drawMaze(grid, exitWalls) } createGrid(shape, rng) { @@ -262,7 +291,7 @@ export default class Maze extends Shape { return new GridClass(Math.max(2, mazeWidth), Math.max(2, mazeHeight), rng) } - drawMaze(grid) { + drawMaze(grid, exitWalls = null) { const wallSegments = grid.extractWalls() const graph = new Graph() @@ -276,16 +305,13 @@ export default class Maze extends Shape { const dijkstraFn = (startKey, endKey) => { return graph.dijkstraShortestPath(startKey, endKey) } - const { edges: eulerizedEdges } = eulerizeEdges(edges, dijkstraFn, graph.nodeMap) + const { edges: eulerizedEdges } = eulerizeEdges( + edges, + dijkstraFn, + graph.nodeMap, + ) const trail = eulerianTrail({ edges: eulerizedEdges }) - const walkedVertices = [] - - trail.forEach((key) => { - const vertex = graph.nodeMap[key] - - walkedVertices.push(vertex) - }) - + const walkedVertices = trail.map((key) => graph.nodeMap[key]) const vertices = cloneVertices(walkedVertices) centerOnOrigin(vertices) diff --git a/src/features/shapes/maze/grids/Grid.js b/src/features/shapes/maze/grids/Grid.js new file mode 100644 index 00000000..4d69f4e3 --- /dev/null +++ b/src/features/shapes/maze/grids/Grid.js @@ -0,0 +1,170 @@ +// Base class for all maze grids +// Provides shared functionality like finding hardest exits + +const ARROW_HEAD_WIDTH = 0.625 // Arrow head width as fraction of wall length (25% bigger) +const ARROW_HEAD_HEIGHT = 0.8 // Arrow head height relative to width + +export default class Grid { + // Subclasses must implement: + // - getEdgeCells() -> [{cell, direction, edge}, ...] + // - getExitVertices(cell) -> [{x, y}, {x, y}] (wall endpoints) + // - cellKey(cell) -> string + // - getNeighbors(cell) -> [cell, ...] + // - isLinked(cell1, cell2) -> boolean + + // Draw arrow marker inside the maze (no shaft) + // Exit: tip touches wall, arrow head extends inward (pointing out) + // Entrance: base touches wall, tip points into maze (pointing in) + addExitArrow( + walls, + makeVertex, + x1, + y1, + x2, + y2, + exitType, + inwardDx, + inwardDy, + ) { + const wallDx = x2 - x1 + const wallDy = y2 - y1 + const wallLen = Math.sqrt(wallDx * wallDx + wallDy * wallDy) + + const wallUnitX = wallDx / wallLen + const wallUnitY = wallDy / wallLen + const inLen = Math.sqrt(inwardDx * inwardDx + inwardDy * inwardDy) + const inUnitX = inwardDx / inLen + const inUnitY = inwardDy / inLen + const headWidth = wallLen * ARROW_HEAD_WIDTH + const headHeight = headWidth * ARROW_HEAD_HEIGHT + const mx = (x1 + x2) / 2 + const my = (y1 + y2) / 2 + + if (exitType === "exit") { + // Exit: tip touches wall, base inside maze, pointing OUT + + // Tip on wall + const tipX = mx + const tipY = my + + // Base center inside maze (inward from tip) + const baseCenterX = mx + inUnitX * headHeight + const baseCenterY = my + inUnitY * headHeight + + // Base points + const baseLeftX = baseCenterX - (wallUnitX * headWidth) / 2 + const baseLeftY = baseCenterY - (wallUnitY * headWidth) / 2 + const baseRightX = baseCenterX + (wallUnitX * headWidth) / 2 + const baseRightY = baseCenterY + (wallUnitY * headWidth) / 2 + + // Draw arrow head - connected through tip (on wall) + walls.push([makeVertex(tipX, tipY), makeVertex(baseLeftX, baseLeftY)]) + walls.push([ + makeVertex(baseLeftX, baseLeftY), + makeVertex(baseCenterX, baseCenterY), + ]) + walls.push([ + makeVertex(baseCenterX, baseCenterY), + makeVertex(baseRightX, baseRightY), + ]) + walls.push([makeVertex(baseRightX, baseRightY), makeVertex(tipX, tipY)]) + } else { + // Entrance: base touches wall, tip inside maze, pointing IN + + // Base center on wall + const baseCenterX = mx + const baseCenterY = my + + // Base points (on wall) + const baseLeftX = mx - (wallUnitX * headWidth) / 2 + const baseLeftY = my - (wallUnitY * headWidth) / 2 + const baseRightX = mx + (wallUnitX * headWidth) / 2 + const baseRightY = my + (wallUnitY * headWidth) / 2 + + // Tip inside maze (inward from base) + const tipX = mx + inUnitX * headHeight + const tipY = my + inUnitY * headHeight + + // Draw arrow head - connected through baseCenter (on wall) + walls.push([ + makeVertex(baseCenterX, baseCenterY), + makeVertex(baseLeftX, baseLeftY), + ]) + walls.push([makeVertex(baseLeftX, baseLeftY), makeVertex(tipX, tipY)]) + walls.push([makeVertex(tipX, tipY), makeVertex(baseRightX, baseRightY)]) + walls.push([ + makeVertex(baseRightX, baseRightY), + makeVertex(baseCenterX, baseCenterY), + ]) + } + } + + // Find the two edge cells with maximum distance (hardest path) + findHardestExits() { + const edgeCells = this.getEdgeCells() + + if (edgeCells.length < 2) { + return null + } + + const bfsDistances = (startCell) => { + const distances = new Map() + const queue = [startCell] + + distances.set(this.cellKey(startCell), 0) + + while (queue.length > 0) { + const current = queue.shift() + const currentDist = distances.get(this.cellKey(current)) + + for (const neighbor of this.getNeighbors(current)) { + if (this.isLinked(current, neighbor)) { + const neighborKey = this.cellKey(neighbor) + + if (!distances.has(neighborKey)) { + distances.set(neighborKey, currentDist + 1) + queue.push(neighbor) + } + } + } + } + + return distances + } + + let maxDistance = -1 + let bestStart = null + let bestEnd = null + + // Check all pairs of edge cells + for (let i = 0; i < edgeCells.length; i++) { + const startEdge = edgeCells[i] + const distances = bfsDistances(startEdge.cell) + + for (let j = i + 1; j < edgeCells.length; j++) { + const endEdge = edgeCells[j] + const dist = distances.get(this.cellKey(endEdge.cell)) + + if (dist !== undefined && dist > maxDistance) { + maxDistance = dist + bestStart = startEdge + bestEnd = endEdge + } + } + } + + if (!bestStart || !bestEnd) { + return null + } + + // Mark the cells with their exit directions + bestStart.cell.exitDirection = bestStart.direction + bestEnd.cell.exitDirection = bestEnd.direction + + return { + startCell: bestStart.cell, + endCell: bestEnd.cell, + distance: maxDistance, + } + } +} diff --git a/src/features/shapes/maze/grids/HexGrid.js b/src/features/shapes/maze/grids/HexGrid.js index a4d75883..f90b2cf1 100644 --- a/src/features/shapes/maze/grids/HexGrid.js +++ b/src/features/shapes/maze/grids/HexGrid.js @@ -1,10 +1,12 @@ import Victor from "victor" +import Grid from "./Grid" // Hexagonal grid for hex mazes // Uses pointy-top orientation with odd-r offset coordinates -export default class HexGrid { +export default class HexGrid extends Grid { constructor(width, height, rng) { + super() this.gridType = "hex" this.width = width this.height = height @@ -116,8 +118,49 @@ export default class HexGrid { return cell1.q === cell2.q && cell1.r === cell2.r } - // Get the 6 corner vertices of a hexagon (pointy-top) - // Returns corners in order for wall drawing + // Get cells on the grid perimeter with their exit directions + // For hex grids, use e/w (east/west) exits for opposite edges + getEdgeCells() { + const edgeCells = [] + + for (const cell of this.cells) { + const { q } = cell + + // Left edge (q=0): west exit + if (q === 0) { + edgeCells.push({ cell, direction: "w", edge: "w" }) + } + + // Right edge (q=width-1): east exit + if (q === this.width - 1) { + edgeCells.push({ cell, direction: "e", edge: "e" }) + } + } + + return edgeCells + } + + getExitVertices(cell) { + const corners = this.getHexCorners(cell.q, cell.r) + const dir = cell.exitDirection + + // corners: [p1(top-left), p2(bottom-left), p3(bottom), p4(bottom-right), p5(top-right), p6(top)] + switch (dir) { + case "w": // west edge: p1 to p2 + return [ + { x: corners[0][0], y: corners[0][1] }, + { x: corners[1][0], y: corners[1][1] }, + ] + case "e": // east edge: p4 to p5 + return [ + { x: corners[3][0], y: corners[3][1] }, + { x: corners[4][0], y: corners[4][1] }, + ] + default: + return null + } + } + getHexCorners(q, r) { const rowXOffset = Math.abs(r % 2) * this.xOffset const ys = this.yScale @@ -160,6 +203,42 @@ export default class HexGrid { return vertexCache.get(key) } + // Draw exit wall split at midpoint + arrow on top + // Scale up arrow for hex's edges + const arrowScale = 1.25 + const addExitWithArrow = (x1, y1, x2, y2, direction, exitType) => { + // For pointy-top hex, west/east edges are vertical + // West (q=0): inward points right (+x) + // East (q=max): inward points left (-x) + const inwardDx = direction === "w" ? 1 : -1 + const inwardDy = 0 + const mx = (x1 + x2) / 2 + const my = (y1 + y2) / 2 + + // Split wall at midpoint so arrow connects to graph + walls.push([makeVertex(x1, y1), makeVertex(mx, my)]) + walls.push([makeVertex(mx, my), makeVertex(x2, y2)]) + + // Scale wall coords away from midpoint to enlarge arrow + const sx1 = mx + (x1 - mx) * arrowScale + const sy1 = my + (y1 - my) * arrowScale + const sx2 = mx + (x2 - mx) * arrowScale + const sy2 = my + (y2 - my) * arrowScale + + // Add arrow (connects at midpoint) + this.addExitArrow( + walls, + makeVertex, + sx1, + sy1, + sx2, + sy2, + exitType, + inwardDx, + inwardDy, + ) + } + for (const cell of this.cells) { const { q, r } = cell const corners = this.getHexCorners(q, r) @@ -169,10 +248,21 @@ export default class HexGrid { const west = this.getCell(q - 1, r) if (!west || !this.isLinked(cell, west)) { - walls.push([ - makeVertex(corners[0][0], corners[0][1]), - makeVertex(corners[1][0], corners[1][1]), - ]) + if (cell.exitDirection === "w") { + addExitWithArrow( + corners[0][0], + corners[0][1], + corners[1][0], + corners[1][1], + "w", + cell.exitType, + ) + } else { + walls.push([ + makeVertex(corners[0][0], corners[0][1]), + makeVertex(corners[1][0], corners[1][1]), + ]) + } } // Edge between p2-p3 (southwest edge) @@ -199,10 +289,21 @@ export default class HexGrid { const east = this.getCell(q + 1, r) if (!east || !this.isLinked(cell, east)) { - walls.push([ - makeVertex(corners[3][0], corners[3][1]), - makeVertex(corners[4][0], corners[4][1]), - ]) + if (cell.exitDirection === "e") { + addExitWithArrow( + corners[3][0], + corners[3][1], + corners[4][0], + corners[4][1], + "e", + cell.exitType, + ) + } else { + walls.push([ + makeVertex(corners[3][0], corners[3][1]), + makeVertex(corners[4][0], corners[4][1]), + ]) + } } // Edge between p5-p6 (northeast edge) diff --git a/src/features/shapes/maze/grids/PolarGrid.js b/src/features/shapes/maze/grids/PolarGrid.js index c12f1260..06c71ac4 100644 --- a/src/features/shapes/maze/grids/PolarGrid.js +++ b/src/features/shapes/maze/grids/PolarGrid.js @@ -1,10 +1,12 @@ +/* global console */ import Victor from "victor" +import Grid from "./Grid" // Polar grid for circular mazes // Cells are arranged in concentric rings, with ring 0 being a single center cell // Outer rings can have more wedges (doubling) to maintain proportional cell sizes -export default class PolarGrid { +export default class PolarGrid extends Grid { constructor( ringCount, baseWedgeCount, @@ -12,6 +14,7 @@ export default class PolarGrid { rng, useArcs = false, ) { + super() this.gridType = "polar" this.ringCount = ringCount this.baseWedgeCount = baseWedgeCount @@ -177,6 +180,40 @@ export default class PolarGrid { return cell1.ring === cell2.ring && cell1.wedge === cell2.wedge } + // Get cells on the outer ring (perimeter) with their exit directions + getEdgeCells() { + const edgeCells = [] + const outerRing = this.ringCount + + for (const cell of this.rings[outerRing]) { + edgeCells.push({ cell, direction: "out", edge: "out" }) + } + + return edgeCells + } + + // Get the two vertices of an exit wall (outer arc endpoints) for a cell + getExitVertices(cell) { + if (cell.ring !== this.ringCount) return null + + const wedgeCount = this.rings[cell.ring].length + const anglePerWedge = (Math.PI * 2) / wedgeCount + const radius = this.ringCount + 1 + const startAngle = cell.wedge * anglePerWedge + const endAngle = (cell.wedge + 1) * anglePerWedge + + return [ + { + x: Math.round(radius * Math.cos(startAngle) * 1000000) / 1000000, + y: Math.round(radius * Math.sin(startAngle) * 1000000) / 1000000, + }, + { + x: Math.round(radius * Math.cos(endAngle) * 1000000) / 1000000, + y: Math.round(radius * Math.sin(endAngle) * 1000000) / 1000000, + }, + ] + } + // Debug: dump maze structure dump() { let output = "" @@ -295,17 +332,125 @@ export default class PolarGrid { } } - // 3. OUTER PERIMETER (always walls) + // Helper to create vertex from cartesian coords (for arrow drawing) + const makeVertexXY = (x, y) => { + const rx = Math.round(x * 1000000) / 1000000 + const ry = Math.round(y * 1000000) / 1000000 + const key = `${rx},${ry}` + + if (!vertexCache.has(key)) { + vertexCache.set(key, new Victor(rx, ry)) + } + + return vertexCache.get(key) + } + + // Draw exit arc wall split at midpoint + arrow + const addExitArcWithArrow = (radius, startAngle, endAngle, exitType) => { + const midAngle = (startAngle + endAngle) / 2 + + // Draw arc in two halves (split at midpoint) + if (this.useArcs) { + const resolution = (Math.PI * 2.0) / 128.0 + + // First half: startAngle to midAngle + const delta1 = midAngle - startAngle + const numSteps1 = Math.max(1, Math.ceil(delta1 / resolution)) + + for (let step = 0; step < numSteps1; step++) { + const t1 = step / numSteps1 + const t2 = (step + 1) / numSteps1 + const a1 = startAngle + t1 * delta1 + const a2 = startAngle + t2 * delta1 + + walls.push([makeVertex(radius, a1), makeVertex(radius, a2)]) + } + + // Second half: midAngle to endAngle + const delta2 = endAngle - midAngle + const numSteps2 = Math.max(1, Math.ceil(delta2 / resolution)) + + for (let step = 0; step < numSteps2; step++) { + const t1 = step / numSteps2 + const t2 = (step + 1) / numSteps2 + const a1 = midAngle + t1 * delta2 + const a2 = midAngle + t2 * delta2 + + walls.push([makeVertex(radius, a1), makeVertex(radius, a2)]) + } + } else { + // Straight segments split at midpoint + walls.push([makeVertex(radius, startAngle), makeVertex(radius, midAngle)]) + walls.push([makeVertex(radius, midAngle), makeVertex(radius, endAngle)]) + } + + // Arrow at arc's true midpoint (not chord midpoint) + const arcMidX = radius * Math.cos(midAngle) + const arcMidY = radius * Math.sin(midAngle) + + // Inward direction points toward center + const inwardDx = -Math.cos(midAngle) + const inwardDy = -Math.sin(midAngle) + + // Arrow sizing based on ring width (constant = 1) for consistent size + const ringWidth = 1 + const arrowScale = 0.5 + const headWidth = ringWidth * arrowScale + const headHeight = headWidth * 0.8 + + // Tangent direction (perpendicular to inward, along the arc) + const tangentX = -Math.sin(midAngle) + const tangentY = Math.cos(midAngle) + + if (exitType === "exit") { + // Exit: tip on arc, base inside maze, pointing OUT + const tipX = arcMidX + const tipY = arcMidY + const baseCenterX = arcMidX + inwardDx * headHeight + const baseCenterY = arcMidY + inwardDy * headHeight + const baseLeftX = baseCenterX - (tangentX * headWidth) / 2 + const baseLeftY = baseCenterY - (tangentY * headWidth) / 2 + const baseRightX = baseCenterX + (tangentX * headWidth) / 2 + const baseRightY = baseCenterY + (tangentY * headWidth) / 2 + + walls.push([makeVertexXY(tipX, tipY), makeVertexXY(baseLeftX, baseLeftY)]) + walls.push([makeVertexXY(baseLeftX, baseLeftY), makeVertexXY(baseCenterX, baseCenterY)]) + walls.push([makeVertexXY(baseCenterX, baseCenterY), makeVertexXY(baseRightX, baseRightY)]) + walls.push([makeVertexXY(baseRightX, baseRightY), makeVertexXY(tipX, tipY)]) + } else { + // Entrance: base on arc, tip inside maze, pointing IN + const baseCenterX = arcMidX + const baseCenterY = arcMidY + const baseLeftX = arcMidX - (tangentX * headWidth) / 2 + const baseLeftY = arcMidY - (tangentY * headWidth) / 2 + const baseRightX = arcMidX + (tangentX * headWidth) / 2 + const baseRightY = arcMidY + (tangentY * headWidth) / 2 + const tipX = arcMidX + inwardDx * headHeight + const tipY = arcMidY + inwardDy * headHeight + + walls.push([makeVertexXY(baseCenterX, baseCenterY), makeVertexXY(baseLeftX, baseLeftY)]) + walls.push([makeVertexXY(baseLeftX, baseLeftY), makeVertexXY(tipX, tipY)]) + walls.push([makeVertexXY(tipX, tipY), makeVertexXY(baseRightX, baseRightY)]) + walls.push([makeVertexXY(baseRightX, baseRightY), makeVertexXY(baseCenterX, baseCenterY)]) + } + } + + // 3. OUTER PERIMETER (always walls, with exits) const outerRing = this.ringCount const outerWedgeCount = this.rings[outerRing].length const outerAnglePerWedge = (Math.PI * 2) / outerWedgeCount const outerRadius = this.ringCount + 1 for (let w = 0; w < outerWedgeCount; w++) { + const cell = this.rings[outerRing][w] const startAngle = w * outerAnglePerWedge const endAngle = (w + 1) * outerAnglePerWedge - addArcWall(outerRadius, startAngle, endAngle) + if (cell.exitDirection === "out") { + addExitArcWithArrow(outerRadius, startAngle, endAngle, cell.exitType) + } else { + addArcWall(outerRadius, startAngle, endAngle) + } } return walls diff --git a/src/features/shapes/maze/grids/RectangularGrid.js b/src/features/shapes/maze/grids/RectangularGrid.js index d26436e1..8910bfab 100644 --- a/src/features/shapes/maze/grids/RectangularGrid.js +++ b/src/features/shapes/maze/grids/RectangularGrid.js @@ -1,10 +1,13 @@ +/* global console */ import Victor from "victor" +import Grid from "./Grid" // Rectangular grid for standard mazes // Implements the same interface as PolarGrid for algorithm compatibility -export default class RectangularGrid { +export default class RectangularGrid extends Grid { constructor(width, height, rng) { + super() this.gridType = "rectangular" this.width = width this.height = height @@ -91,8 +94,96 @@ export default class RectangularGrid { return cell1.x === cell2.x && cell1.y === cell2.y } + // Get the two vertices of an exit wall for a cell + // Returns [v1, v2] where v1 and v2 are {x, y} objects + getExitVertices(cell) { + const { x, y } = cell + const dir = cell.exitDirection + + switch (dir) { + case "n": + return [ + { x, y: 0 }, + { x: x + 1, y: 0 }, + ] + case "s": + return [ + { x, y: this.height }, + { x: x + 1, y: this.height }, + ] + case "w": + return [ + { x: 0, y }, + { x: 0, y: y + 1 }, + ] + case "e": + return [ + { x: this.width, y }, + { x: this.width, y: y + 1 }, + ] + default: + return null + } + } + + // Get all cells on the grid perimeter with their exit directions + // edge property allows filtering to ensure exits are on opposite edges + getEdgeCells() { + const edgeCells = [] + + for (const cell of this.cells) { + const { x, y } = cell + + // Prefer corners: pick one direction (priority: N, S, E, W) + if (y === 0) { + edgeCells.push({ cell, direction: "n", edge: "n" }) + } else if (y === this.height - 1) { + edgeCells.push({ cell, direction: "s", edge: "s" }) + } else if (x === 0) { + edgeCells.push({ cell, direction: "w", edge: "w" }) + } else if (x === this.width - 1) { + edgeCells.push({ cell, direction: "e", edge: "e" }) + } + } + + return edgeCells + } + extractWalls() { const walls = [] + const vertexCache = new Map() + + const makeVertex = (x, y) => { + const key = `${x},${y}` + + if (!vertexCache.has(key)) { + vertexCache.set(key, new Victor(x, y)) + } + + return vertexCache.get(key) + } + + // Inward directions for each edge (into the maze) + const inwardDir = { + n: { dx: 0, dy: 1 }, + s: { dx: 0, dy: -1 }, + w: { dx: 1, dy: 0 }, + e: { dx: -1, dy: 0 }, + } + + // Draw exit wall split at midpoint + arrow on top + const addExitWithArrow = (x1, y1, x2, y2, direction, exitType) => { + const { dx, dy } = inwardDir[direction] + const mx = (x1 + x2) / 2 + const my = (y1 + y2) / 2 + + // Split wall at midpoint so arrow connects to graph + walls.push([makeVertex(x1, y1), makeVertex(mx, my)]) + walls.push([makeVertex(mx, my), makeVertex(x2, y2)]) + + // Add arrow (connects at midpoint) + this.addExitArrow(walls, makeVertex, x1, y1, x2, y2, exitType, dx, dy) + } for (let y = 0; y < this.height; y++) { for (let x = 0; x < this.width; x++) { @@ -100,36 +191,50 @@ export default class RectangularGrid { // North wall (top of cell) if (y === 0) { - // Boundary - always a wall - walls.push([new Victor(x, y), new Victor(x + 1, y)]) + if (cell.exitDirection === "n") { + addExitWithArrow(x, y, x + 1, y, "n", cell.exitType) + } else { + walls.push([makeVertex(x, y), makeVertex(x + 1, y)]) + } } else { const northNeighbor = this.getCell(x, y - 1) if (!this.isLinked(cell, northNeighbor)) { - walls.push([new Victor(x, y), new Victor(x + 1, y)]) + walls.push([makeVertex(x, y), makeVertex(x + 1, y)]) } } // West wall (left of cell) if (x === 0) { - // Boundary - always a wall - walls.push([new Victor(x, y), new Victor(x, y + 1)]) + if (cell.exitDirection === "w") { + addExitWithArrow(x, y, x, y + 1, "w", cell.exitType) + } else { + walls.push([makeVertex(x, y), makeVertex(x, y + 1)]) + } } else { const westNeighbor = this.getCell(x - 1, y) if (!this.isLinked(cell, westNeighbor)) { - walls.push([new Victor(x, y), new Victor(x, y + 1)]) + walls.push([makeVertex(x, y), makeVertex(x, y + 1)]) } } // South wall (bottom edge only for last row) if (y === this.height - 1) { - walls.push([new Victor(x, y + 1), new Victor(x + 1, y + 1)]) + if (cell.exitDirection === "s") { + addExitWithArrow(x, y + 1, x + 1, y + 1, "s", cell.exitType) + } else { + walls.push([makeVertex(x, y + 1), makeVertex(x + 1, y + 1)]) + } } // East wall (right edge only for last column) if (x === this.width - 1) { - walls.push([new Victor(x + 1, y), new Victor(x + 1, y + 1)]) + if (cell.exitDirection === "e") { + addExitWithArrow(x + 1, y, x + 1, y + 1, "e", cell.exitType) + } else { + walls.push([makeVertex(x + 1, y), makeVertex(x + 1, y + 1)]) + } } } } @@ -137,7 +242,7 @@ export default class RectangularGrid { return walls } - // Debug: dump maze as ASCII art (y=0 at bottom) + // Debug: dump maze as ASCII art (y=0 at bottom, with cell coords) dump() { let output = "" @@ -149,7 +254,7 @@ export default class RectangularGrid { const southCell = this.getCell(x, y + 1) const hasSouthLink = southCell && this.isLinked(cell, southCell) - topLine += "+" + (hasSouthLink ? " " : "---") + topLine += "+" + (hasSouthLink ? " " : "-----") } topLine += "+" output += topLine + "\n" @@ -160,8 +265,9 @@ export default class RectangularGrid { const cell = this.getCell(x, y) const westCell = this.getCell(x - 1, y) const hasWestLink = westCell && this.isLinked(cell, westCell) + const coord = `${x},${y}`.padStart(3).padEnd(5) - cellLine += (hasWestLink ? " " : "|") + " " + cellLine += (hasWestLink ? " " : "|") + coord } cellLine += "|" output += cellLine + "\n" @@ -170,7 +276,7 @@ export default class RectangularGrid { let bottomLine = "" for (let x = 0; x < this.width; x++) { - bottomLine += "+---" + bottomLine += "+-----" } bottomLine += "+" output += bottomLine + "\n" diff --git a/src/features/shapes/maze/grids/TriangleGrid.js b/src/features/shapes/maze/grids/TriangleGrid.js index 67cf06d7..920f18a8 100644 --- a/src/features/shapes/maze/grids/TriangleGrid.js +++ b/src/features/shapes/maze/grids/TriangleGrid.js @@ -1,10 +1,12 @@ import Victor from "victor" +import Grid from "./Grid" // Triangular grid for delta mazes // Uses alternating up/down triangles based on coordinate parity -export default class TriangleGrid { +export default class TriangleGrid extends Grid { constructor(width, height, rng) { + super() this.gridType = "triangle" this.width = width this.height = height @@ -115,6 +117,54 @@ export default class TriangleGrid { return cell1.x === cell2.x && cell1.y === cell2.y } + // Get cells on the grid perimeter with their exit directions + // For triangles: top/bottom rows and left/right edges + getEdgeCells() { + const edgeCells = [] + + for (const cell of this.cells) { + const { y, upward } = cell + + // Top row: DOWN triangles have horizontal top edge + if (y === 0 && !upward) { + edgeCells.push({ cell, direction: "n", edge: "n" }) + } + + // Bottom row: UP triangles have horizontal bottom edge + if (y === this.height - 1 && upward) { + edgeCells.push({ cell, direction: "s", edge: "s" }) + } + } + + return edgeCells + } + + // Get the two vertices of an exit wall for a cell + getExitVertices(cell) { + const corners = this.getTriangleCorners(cell.x, cell.y) + const dir = cell.exitDirection + + if (cell.upward) { + // UP triangle: top, bottomLeft, bottomRight + if (dir === "s") { + return [ + { x: corners.bottomLeft[0], y: corners.bottomLeft[1] }, + { x: corners.bottomRight[0], y: corners.bottomRight[1] }, + ] + } + } else { + // DOWN triangle: topLeft, topRight, bottom + if (dir === "n") { + return [ + { x: corners.topLeft[0], y: corners.topLeft[1] }, + { x: corners.topRight[0], y: corners.topRight[1] }, + ] + } + } + + return null + } + getTriangleCorners(x, y) { const h = this.triHeight const ys = this.yScale @@ -153,6 +203,40 @@ export default class TriangleGrid { return vertexCache.get(key) } + // Draw exit wall split at midpoint + arrow on top + // Scale down arrow for triangle's smaller edges + const arrowScale = 0.6 + const addExitWithArrow = (x1, y1, x2, y2, direction, exitType) => { + // For horizontal edges: n = inward down, s = inward up + const inwardDx = 0 + const inwardDy = direction === "n" ? 1 : -1 + const mx = (x1 + x2) / 2 + const my = (y1 + y2) / 2 + + // Split wall at midpoint so arrow connects to graph + walls.push([makeVertex(x1, y1), makeVertex(mx, my)]) + walls.push([makeVertex(mx, my), makeVertex(x2, y2)]) + + // Scale wall coords toward midpoint to shrink arrow + const sx1 = mx + (x1 - mx) * arrowScale + const sy1 = my + (y1 - my) * arrowScale + const sx2 = mx + (x2 - mx) * arrowScale + const sy2 = my + (y2 - my) * arrowScale + + // Add arrow (connects at midpoint) + this.addExitArrow( + walls, + makeVertex, + sx1, + sy1, + sx2, + sy2, + exitType, + inwardDx, + inwardDy, + ) + } + for (const cell of this.cells) { const { x, y, upward } = cell const corners = this.getTriangleCorners(x, y) @@ -183,11 +267,23 @@ export default class TriangleGrid { // Bottom edge: bottomLeft to bottomRight (shared with south) const south = this.getCell(x, y + 1) + if (!south || !this.isLinked(cell, south)) { - walls.push([ - makeVertex(corners.bottomLeft[0], corners.bottomLeft[1]), - makeVertex(corners.bottomRight[0], corners.bottomRight[1]), - ]) + if (cell.exitDirection === "s") { + addExitWithArrow( + corners.bottomLeft[0], + corners.bottomLeft[1], + corners.bottomRight[0], + corners.bottomRight[1], + "s", + cell.exitType, + ) + } else { + walls.push([ + makeVertex(corners.bottomLeft[0], corners.bottomLeft[1]), + makeVertex(corners.bottomRight[0], corners.bottomRight[1]), + ]) + } } } else { // DOWN triangle: topLeft, topRight, bottom @@ -209,11 +305,23 @@ export default class TriangleGrid { // Top edge: topLeft to topRight (shared with north) const north = this.getCell(x, y - 1) + if (!north || !this.isLinked(cell, north)) { - walls.push([ - makeVertex(corners.topLeft[0], corners.topLeft[1]), - makeVertex(corners.topRight[0], corners.topRight[1]), - ]) + if (cell.exitDirection === "n") { + addExitWithArrow( + corners.topLeft[0], + corners.topLeft[1], + corners.topRight[0], + corners.topRight[1], + "n", + cell.exitType, + ) + } else { + walls.push([ + makeVertex(corners.topLeft[0], corners.topLeft[1]), + makeVertex(corners.topRight[0], corners.topRight[1]), + ]) + } } } } From 83db04be2cb5cfcb8cc0c3925b4ac097fd6a974a Mon Sep 17 00:00:00 2001 From: Bob Carmichael Date: Sat, 20 Dec 2025 14:12:45 -0500 Subject: [PATCH 09/22] traverse maze solution --- src/common/geometry.js | 40 ++++++++ src/features/shapes/maze/Maze.js | 92 ++++++++++++++++++- src/features/shapes/maze/grids/Grid.js | 77 ++++++++++++---- src/features/shapes/maze/grids/HexGrid.js | 28 ++++-- src/features/shapes/maze/grids/PolarGrid.js | 79 ++++++++++++---- .../shapes/maze/grids/RectangularGrid.js | 37 ++++++-- .../shapes/maze/grids/TriangleGrid.js | 40 ++++++-- 7 files changed, 331 insertions(+), 62 deletions(-) diff --git a/src/common/geometry.js b/src/common/geometry.js index af84f183..48cf3eaa 100644 --- a/src/common/geometry.js +++ b/src/common/geometry.js @@ -551,6 +551,46 @@ export const annotateVertices = (vertices, attrs) => { return vertices } +// returns the closest point on line segment ab to point p +export const closestPointOnSegment = (p, a, b) => { + const abX = b.x - a.x + const abY = b.y - a.y + const apX = p.x - a.x + const apY = p.y - a.y + const abLenSq = abX * abX + abY * abY + + if (abLenSq === 0) { + return { x: a.x, y: a.y } + } + + const t = Math.max(0, Math.min(1, (apX * abX + apY * abY) / abLenSq)) + + return { x: a.x + t * abX, y: a.y + t * abY } +} + +// returns the closest point across multiple line segments to point p +// also returns the segment it's on (for finding nearest graph vertices) +export const closestPointOnSegments = (p, segments) => { + let closest = null + let closestSegment = null + let minDistSq = Infinity + + for (const [a, b] of segments) { + const point = closestPointOnSegment(p, a, b) + const dx = point.x - p.x + const dy = point.y - p.y + const distSq = dx * dx + dy * dy + + if (distSq < minDistSq) { + minDistSq = distSq + closest = point + closestSegment = [a, b] + } + } + + return { point: closest, segment: closestSegment } +} + // returns the intersection point of two line segments export const calculateIntersection = (p1, p2, p3, p4) => { var denominator = diff --git a/src/features/shapes/maze/Maze.js b/src/features/shapes/maze/Maze.js index f8eeb490..53002064 100644 --- a/src/features/shapes/maze/Maze.js +++ b/src/features/shapes/maze/Maze.js @@ -4,7 +4,11 @@ import seedrandom from "seedrandom" import Graph from "@/common/Graph" import { eulerianTrail } from "@/common/eulerian_trail/eulerianTrail" import { eulerizeEdges } from "@/common/chinesePostman" -import { cloneVertices, centerOnOrigin } from "@/common/geometry" +import { + cloneVertices, + centerOnOrigin, + closestPointOnSegments, +} from "@/common/geometry" import RectangularGrid from "./grids/RectangularGrid" import PolarGrid from "./grids/PolarGrid" import HexGrid from "./grids/HexGrid" @@ -181,6 +185,13 @@ const options = { title: "Show entry/exit", type: "checkbox", }, + mazeShowSolution: { + title: "Show solution", + type: "checkbox", + isVisible: (layer, state) => { + return state.mazeShowExits + }, + }, seed: { title: "Random seed", min: 1, @@ -213,6 +224,7 @@ export default class Maze extends Shape { mazeHorizontalBias: 5, mazeBranchLevel: 5, mazeShowExits: true, + mazeShowSolution: false, seed: 1, }, } @@ -224,6 +236,7 @@ export default class Maze extends Shape { mazeHorizontalBias, mazeBranchLevel, mazeShowExits, + mazeShowSolution, seed, } = state.shape @@ -239,14 +252,13 @@ export default class Maze extends Shape { branchLevel: mazeBranchLevel, }) - // Find hardest exits (start/end openings) - supported for grids with getEdgeCells let exitWalls = null + let solutionPath = null if (mazeShowExits && grid.findHardestExits) { const exits = grid.findHardestExits() if (exits && grid.getExitVertices) { - // Mark entrance vs exit for door swing direction exits.startCell.exitType = "entrance" exits.endCell.exitType = "exit" @@ -254,6 +266,10 @@ export default class Maze extends Shape { start: grid.getExitVertices(exits.startCell), end: grid.getExitVertices(exits.endCell), } + + if (mazeShowSolution && exits.path) { + solutionPath = exits.path + } } } @@ -262,7 +278,7 @@ export default class Maze extends Shape { grid.dump() } - return this.drawMaze(grid, exitWalls) + return this.drawMaze(grid, exitWalls, solutionPath) } createGrid(shape, rng) { @@ -291,7 +307,7 @@ export default class Maze extends Shape { return new GridClass(Math.max(2, mazeWidth), Math.max(2, mazeHeight), rng) } - drawMaze(grid, exitWalls = null) { + drawMaze(grid, exitWalls = null, solutionPath = null) { const wallSegments = grid.extractWalls() const graph = new Graph() @@ -312,6 +328,18 @@ export default class Maze extends Shape { ) const trail = eulerianTrail({ edges: eulerizedEdges }) const walkedVertices = trail.map((key) => graph.nodeMap[key]) + + if (solutionPath && solutionPath.length > 0 && exitWalls) { + this.drawSolution( + walkedVertices, + graph, + trail, + grid, + exitWalls, + solutionPath, + ) + } + const vertices = cloneVertices(walkedVertices) centerOnOrigin(vertices) @@ -319,6 +347,60 @@ export default class Maze extends Shape { return vertices } + drawSolution(walkedVertices, graph, trail, grid, exitWalls, solutionPath) { + const startCell = solutionPath[0] + const endCell = solutionPath[solutionPath.length - 1] + + if (!startCell.arrowEdges || !endCell.arrowEdges) { + return + } + + const secondCenter = solutionPath.length > 1 + ? grid.getCellCenter(solutionPath[1]) + : grid.getCellCenter(solutionPath[0]) + const secondToLastCenter = solutionPath.length > 1 + ? grid.getCellCenter(solutionPath[solutionPath.length - 2]) + : grid.getCellCenter(solutionPath[0]) + const entrance = closestPointOnSegments(secondCenter, startCell.arrowEdges) + const [entA, entB] = entrance.segment + const distToA = + (entrance.point.x - entA.x) ** 2 + (entrance.point.y - entA.y) ** 2 + const distToB = + (entrance.point.x - entB.x) ** 2 + (entrance.point.y - entB.y) ** 2 + const entranceVertex = distToA < distToB ? entA : entB + const entranceKey = entranceVertex.toString() + const trailEndKey = trail[trail.length - 1] + + if (graph.nodeMap[entranceKey]) { + const pathKeys = graph.dijkstraShortestPath(trailEndKey, entranceKey) + + if (pathKeys && pathKeys.length > 1) { + for (let i = 1; i < pathKeys.length; i++) { + walkedVertices.push(graph.nodeMap[pathKeys[i]]) + } + } + } + + walkedVertices.push(entrance.point) + + for (let i = 1; i < solutionPath.length - 1; i++) { + const center = grid.getCellCenter(solutionPath[i]) + + walkedVertices.push({ x: center.x, y: center.y }) + } + + const exit = closestPointOnSegments(secondToLastCenter, endCell.arrowEdges) + const [exitA, exitB] = exit.segment + const distToExitA = + (exit.point.x - exitA.x) ** 2 + (exit.point.y - exitA.y) ** 2 + const distToExitB = + (exit.point.x - exitB.x) ** 2 + (exit.point.y - exitB.y) ** 2 + const exitVertex = distToExitA < distToExitB ? exitA : exitB + + walkedVertices.push(exitVertex) + walkedVertices.push(exit.point) + } + getOptions() { return options } diff --git a/src/features/shapes/maze/grids/Grid.js b/src/features/shapes/maze/grids/Grid.js index 4d69f4e3..425f3f3d 100644 --- a/src/features/shapes/maze/grids/Grid.js +++ b/src/features/shapes/maze/grids/Grid.js @@ -40,22 +40,26 @@ export default class Grid { const mx = (x1 + x2) / 2 const my = (y1 + y2) / 2 + let tipX, tipY, baseCenterX, baseCenterY + + let baseLeftX, baseLeftY, baseRightX, baseRightY + if (exitType === "exit") { // Exit: tip touches wall, base inside maze, pointing OUT // Tip on wall - const tipX = mx - const tipY = my + tipX = mx + tipY = my // Base center inside maze (inward from tip) - const baseCenterX = mx + inUnitX * headHeight - const baseCenterY = my + inUnitY * headHeight + baseCenterX = mx + inUnitX * headHeight + baseCenterY = my + inUnitY * headHeight // Base points - const baseLeftX = baseCenterX - (wallUnitX * headWidth) / 2 - const baseLeftY = baseCenterY - (wallUnitY * headWidth) / 2 - const baseRightX = baseCenterX + (wallUnitX * headWidth) / 2 - const baseRightY = baseCenterY + (wallUnitY * headWidth) / 2 + baseLeftX = baseCenterX - (wallUnitX * headWidth) / 2 + baseLeftY = baseCenterY - (wallUnitY * headWidth) / 2 + baseRightX = baseCenterX + (wallUnitX * headWidth) / 2 + baseRightY = baseCenterY + (wallUnitY * headWidth) / 2 // Draw arrow head - connected through tip (on wall) walls.push([makeVertex(tipX, tipY), makeVertex(baseLeftX, baseLeftY)]) @@ -72,18 +76,18 @@ export default class Grid { // Entrance: base touches wall, tip inside maze, pointing IN // Base center on wall - const baseCenterX = mx - const baseCenterY = my + baseCenterX = mx + baseCenterY = my // Base points (on wall) - const baseLeftX = mx - (wallUnitX * headWidth) / 2 - const baseLeftY = my - (wallUnitY * headWidth) / 2 - const baseRightX = mx + (wallUnitX * headWidth) / 2 - const baseRightY = my + (wallUnitY * headWidth) / 2 + baseLeftX = mx - (wallUnitX * headWidth) / 2 + baseLeftY = my - (wallUnitY * headWidth) / 2 + baseRightX = mx + (wallUnitX * headWidth) / 2 + baseRightY = my + (wallUnitY * headWidth) / 2 // Tip inside maze (inward from base) - const tipX = mx + inUnitX * headHeight - const tipY = my + inUnitY * headHeight + tipX = mx + inUnitX * headHeight + tipY = my + inUnitY * headHeight // Draw arrow head - connected through baseCenter (on wall) walls.push([ @@ -97,9 +101,28 @@ export default class Grid { makeVertex(baseCenterX, baseCenterY), ]) } + + // Build arrow vertices (same Victor objects that go into walls/graph) + const tipV = makeVertex(tipX, tipY) + const baseLeftV = makeVertex(baseLeftX, baseLeftY) + const baseCenterV = makeVertex(baseCenterX, baseCenterY) + const baseRightV = makeVertex(baseRightX, baseRightY) + + // Return vertices and edges + return { + tip: tipV, + base: baseCenterV, + edges: [ + [tipV, baseLeftV], + [baseLeftV, baseCenterV], + [baseCenterV, baseRightV], + [baseRightV, tipV], + ], + } } // Find the two edge cells with maximum distance (hardest path) + // Returns { startCell, endCell, distance, path } where path is array of cells findHardestExits() { const edgeCells = this.getEdgeCells() @@ -107,11 +130,14 @@ export default class Grid { return null } - const bfsDistances = (startCell) => { + // BFS that tracks both distances and parent pointers for path reconstruction + const bfsWithParents = (startCell) => { const distances = new Map() + const parents = new Map() const queue = [startCell] distances.set(this.cellKey(startCell), 0) + parents.set(this.cellKey(startCell), null) while (queue.length > 0) { const current = queue.shift() @@ -123,23 +149,25 @@ export default class Grid { if (!distances.has(neighborKey)) { distances.set(neighborKey, currentDist + 1) + parents.set(neighborKey, current) queue.push(neighbor) } } } } - return distances + return { distances, parents } } let maxDistance = -1 let bestStart = null let bestEnd = null + let bestParents = null // Check all pairs of edge cells for (let i = 0; i < edgeCells.length; i++) { const startEdge = edgeCells[i] - const distances = bfsDistances(startEdge.cell) + const { distances, parents } = bfsWithParents(startEdge.cell) for (let j = i + 1; j < edgeCells.length; j++) { const endEdge = edgeCells[j] @@ -149,6 +177,7 @@ export default class Grid { maxDistance = dist bestStart = startEdge bestEnd = endEdge + bestParents = parents } } } @@ -157,6 +186,15 @@ export default class Grid { return null } + // Reconstruct path from start to end using parent pointers + const path = [] + let current = bestEnd.cell + + while (current !== null) { + path.unshift(current) + current = bestParents.get(this.cellKey(current)) + } + // Mark the cells with their exit directions bestStart.cell.exitDirection = bestStart.direction bestEnd.cell.exitDirection = bestEnd.direction @@ -165,6 +203,7 @@ export default class Grid { startCell: bestStart.cell, endCell: bestEnd.cell, distance: maxDistance, + path, } } } diff --git a/src/features/shapes/maze/grids/HexGrid.js b/src/features/shapes/maze/grids/HexGrid.js index f90b2cf1..fbfb0287 100644 --- a/src/features/shapes/maze/grids/HexGrid.js +++ b/src/features/shapes/maze/grids/HexGrid.js @@ -118,6 +118,17 @@ export default class HexGrid extends Grid { return cell1.q === cell2.q && cell1.r === cell2.r } + // Get the center point of a cell (for solution path drawing) + getCellCenter(cell) { + const { q, r } = cell + const rowXOffset = Math.abs(r % 2) * this.xOffset + + return { + x: rowXOffset + (2 * q + 1) * this.xOffset, + y: (r * this.yOffset2 + 1) * this.yScale, + } + } + // Get cells on the grid perimeter with their exit directions // For hex grids, use e/w (east/west) exits for opposite edges getEdgeCells() { @@ -205,8 +216,9 @@ export default class HexGrid extends Grid { // Draw exit wall split at midpoint + arrow on top // Scale up arrow for hex's edges + // Stores arrow tip on cell for solution path drawing const arrowScale = 1.25 - const addExitWithArrow = (x1, y1, x2, y2, direction, exitType) => { + const addExitWithArrow = (cell, x1, y1, x2, y2, direction) => { // For pointy-top hex, west/east edges are vertical // West (q=0): inward points right (+x) // East (q=max): inward points left (-x) @@ -225,18 +237,22 @@ export default class HexGrid extends Grid { const sx2 = mx + (x2 - mx) * arrowScale const sy2 = my + (y2 - my) * arrowScale - // Add arrow (connects at midpoint) - this.addExitArrow( + // Add arrow (connects at midpoint) and store tip/base on cell + const arrow = this.addExitArrow( walls, makeVertex, sx1, sy1, sx2, sy2, - exitType, + cell.exitType, inwardDx, inwardDy, ) + + cell.arrowTip = arrow.tip + cell.arrowBase = arrow.base + cell.arrowEdges = arrow.edges } for (const cell of this.cells) { @@ -250,12 +266,12 @@ export default class HexGrid extends Grid { if (!west || !this.isLinked(cell, west)) { if (cell.exitDirection === "w") { addExitWithArrow( + cell, corners[0][0], corners[0][1], corners[1][0], corners[1][1], "w", - cell.exitType, ) } else { walls.push([ @@ -291,12 +307,12 @@ export default class HexGrid extends Grid { if (!east || !this.isLinked(cell, east)) { if (cell.exitDirection === "e") { addExitWithArrow( + cell, corners[3][0], corners[3][1], corners[4][0], corners[4][1], "e", - cell.exitType, ) } else { walls.push([ diff --git a/src/features/shapes/maze/grids/PolarGrid.js b/src/features/shapes/maze/grids/PolarGrid.js index 06c71ac4..df77e347 100644 --- a/src/features/shapes/maze/grids/PolarGrid.js +++ b/src/features/shapes/maze/grids/PolarGrid.js @@ -180,6 +180,26 @@ export default class PolarGrid extends Grid { return cell1.ring === cell2.ring && cell1.wedge === cell2.wedge } + // Get the center point of a cell (for solution path drawing) + getCellCenter(cell) { + const { ring, wedge } = cell + + // Ring 0 is the center cell + if (ring === 0) { + return { x: 0, y: 0 } + } + + const wedgeCount = this.rings[ring].length + const anglePerWedge = (Math.PI * 2) / wedgeCount + const radius = ring + 0.5 + const angle = (wedge + 0.5) * anglePerWedge + + return { + x: radius * Math.cos(angle), + y: radius * Math.sin(angle), + } + } + // Get cells on the outer ring (perimeter) with their exit directions getEdgeCells() { const edgeCells = [] @@ -346,7 +366,8 @@ export default class PolarGrid extends Grid { } // Draw exit arc wall split at midpoint + arrow - const addExitArcWithArrow = (radius, startAngle, endAngle, exitType) => { + // Stores arrow tip on cell for solution path drawing + const addExitArcWithArrow = (cell, radius, startAngle, endAngle) => { const midAngle = (startAngle + endAngle) / 2 // Draw arc in two halves (split at midpoint) @@ -402,16 +423,19 @@ export default class PolarGrid extends Grid { const tangentX = -Math.sin(midAngle) const tangentY = Math.cos(midAngle) - if (exitType === "exit") { + let tipX, tipY, baseCenterX, baseCenterY + let baseLeftX, baseLeftY, baseRightX, baseRightY + + if (cell.exitType === "exit") { // Exit: tip on arc, base inside maze, pointing OUT - const tipX = arcMidX - const tipY = arcMidY - const baseCenterX = arcMidX + inwardDx * headHeight - const baseCenterY = arcMidY + inwardDy * headHeight - const baseLeftX = baseCenterX - (tangentX * headWidth) / 2 - const baseLeftY = baseCenterY - (tangentY * headWidth) / 2 - const baseRightX = baseCenterX + (tangentX * headWidth) / 2 - const baseRightY = baseCenterY + (tangentY * headWidth) / 2 + tipX = arcMidX + tipY = arcMidY + baseCenterX = arcMidX + inwardDx * headHeight + baseCenterY = arcMidY + inwardDy * headHeight + baseLeftX = baseCenterX - (tangentX * headWidth) / 2 + baseLeftY = baseCenterY - (tangentY * headWidth) / 2 + baseRightX = baseCenterX + (tangentX * headWidth) / 2 + baseRightY = baseCenterY + (tangentY * headWidth) / 2 walls.push([makeVertexXY(tipX, tipY), makeVertexXY(baseLeftX, baseLeftY)]) walls.push([makeVertexXY(baseLeftX, baseLeftY), makeVertexXY(baseCenterX, baseCenterY)]) @@ -419,20 +443,37 @@ export default class PolarGrid extends Grid { walls.push([makeVertexXY(baseRightX, baseRightY), makeVertexXY(tipX, tipY)]) } else { // Entrance: base on arc, tip inside maze, pointing IN - const baseCenterX = arcMidX - const baseCenterY = arcMidY - const baseLeftX = arcMidX - (tangentX * headWidth) / 2 - const baseLeftY = arcMidY - (tangentY * headWidth) / 2 - const baseRightX = arcMidX + (tangentX * headWidth) / 2 - const baseRightY = arcMidY + (tangentY * headWidth) / 2 - const tipX = arcMidX + inwardDx * headHeight - const tipY = arcMidY + inwardDy * headHeight + baseCenterX = arcMidX + baseCenterY = arcMidY + baseLeftX = arcMidX - (tangentX * headWidth) / 2 + baseLeftY = arcMidY - (tangentY * headWidth) / 2 + baseRightX = arcMidX + (tangentX * headWidth) / 2 + baseRightY = arcMidY + (tangentY * headWidth) / 2 + + tipX = arcMidX + inwardDx * headHeight + tipY = arcMidY + inwardDy * headHeight walls.push([makeVertexXY(baseCenterX, baseCenterY), makeVertexXY(baseLeftX, baseLeftY)]) walls.push([makeVertexXY(baseLeftX, baseLeftY), makeVertexXY(tipX, tipY)]) walls.push([makeVertexXY(tipX, tipY), makeVertexXY(baseRightX, baseRightY)]) walls.push([makeVertexXY(baseRightX, baseRightY), makeVertexXY(baseCenterX, baseCenterY)]) } + + // Build arrow vertices (same Victor objects that go into walls/graph) + const tipV = makeVertexXY(tipX, tipY) + const baseLeftV = makeVertexXY(baseLeftX, baseLeftY) + const baseCenterV = makeVertexXY(baseCenterX, baseCenterY) + const baseRightV = makeVertexXY(baseRightX, baseRightY) + + // Store tip, base, and edges + cell.arrowTip = tipV + cell.arrowBase = baseCenterV + cell.arrowEdges = [ + [tipV, baseLeftV], + [baseLeftV, baseCenterV], + [baseCenterV, baseRightV], + [baseRightV, tipV], + ] } // 3. OUTER PERIMETER (always walls, with exits) @@ -447,7 +488,7 @@ export default class PolarGrid extends Grid { const endAngle = (w + 1) * outerAnglePerWedge if (cell.exitDirection === "out") { - addExitArcWithArrow(outerRadius, startAngle, endAngle, cell.exitType) + addExitArcWithArrow(cell, outerRadius, startAngle, endAngle) } else { addArcWall(outerRadius, startAngle, endAngle) } diff --git a/src/features/shapes/maze/grids/RectangularGrid.js b/src/features/shapes/maze/grids/RectangularGrid.js index 8910bfab..000c2662 100644 --- a/src/features/shapes/maze/grids/RectangularGrid.js +++ b/src/features/shapes/maze/grids/RectangularGrid.js @@ -94,6 +94,14 @@ export default class RectangularGrid extends Grid { return cell1.x === cell2.x && cell1.y === cell2.y } + // Get the center point of a cell (for solution path drawing) + getCellCenter(cell) { + return { + x: cell.x + 0.5, + y: cell.y + 0.5, + } + } + // Get the two vertices of an exit wall for a cell // Returns [v1, v2] where v1 and v2 are {x, y} objects getExitVertices(cell) { @@ -172,7 +180,8 @@ export default class RectangularGrid extends Grid { } // Draw exit wall split at midpoint + arrow on top - const addExitWithArrow = (x1, y1, x2, y2, direction, exitType) => { + // Stores arrow tip on cell for solution path drawing + const addExitWithArrow = (cell, x1, y1, x2, y2, direction) => { const { dx, dy } = inwardDir[direction] const mx = (x1 + x2) / 2 const my = (y1 + y2) / 2 @@ -181,8 +190,22 @@ export default class RectangularGrid extends Grid { walls.push([makeVertex(x1, y1), makeVertex(mx, my)]) walls.push([makeVertex(mx, my), makeVertex(x2, y2)]) - // Add arrow (connects at midpoint) - this.addExitArrow(walls, makeVertex, x1, y1, x2, y2, exitType, dx, dy) + // Add arrow (connects at midpoint) and store tip/base on cell + const arrow = this.addExitArrow( + walls, + makeVertex, + x1, + y1, + x2, + y2, + cell.exitType, + dx, + dy, + ) + + cell.arrowTip = arrow.tip + cell.arrowBase = arrow.base + cell.arrowEdges = arrow.edges } for (let y = 0; y < this.height; y++) { @@ -192,7 +215,7 @@ export default class RectangularGrid extends Grid { // North wall (top of cell) if (y === 0) { if (cell.exitDirection === "n") { - addExitWithArrow(x, y, x + 1, y, "n", cell.exitType) + addExitWithArrow(cell, x, y, x + 1, y, "n") } else { walls.push([makeVertex(x, y), makeVertex(x + 1, y)]) } @@ -207,7 +230,7 @@ export default class RectangularGrid extends Grid { // West wall (left of cell) if (x === 0) { if (cell.exitDirection === "w") { - addExitWithArrow(x, y, x, y + 1, "w", cell.exitType) + addExitWithArrow(cell, x, y, x, y + 1, "w") } else { walls.push([makeVertex(x, y), makeVertex(x, y + 1)]) } @@ -222,7 +245,7 @@ export default class RectangularGrid extends Grid { // South wall (bottom edge only for last row) if (y === this.height - 1) { if (cell.exitDirection === "s") { - addExitWithArrow(x, y + 1, x + 1, y + 1, "s", cell.exitType) + addExitWithArrow(cell, x, y + 1, x + 1, y + 1, "s") } else { walls.push([makeVertex(x, y + 1), makeVertex(x + 1, y + 1)]) } @@ -231,7 +254,7 @@ export default class RectangularGrid extends Grid { // East wall (right edge only for last column) if (x === this.width - 1) { if (cell.exitDirection === "e") { - addExitWithArrow(x + 1, y, x + 1, y + 1, "e", cell.exitType) + addExitWithArrow(cell, x + 1, y, x + 1, y + 1, "e") } else { walls.push([makeVertex(x + 1, y), makeVertex(x + 1, y + 1)]) } diff --git a/src/features/shapes/maze/grids/TriangleGrid.js b/src/features/shapes/maze/grids/TriangleGrid.js index 920f18a8..1b07fa09 100644 --- a/src/features/shapes/maze/grids/TriangleGrid.js +++ b/src/features/shapes/maze/grids/TriangleGrid.js @@ -117,6 +117,29 @@ export default class TriangleGrid extends Grid { return cell1.x === cell2.x && cell1.y === cell2.y } + // Get the center point of a cell (centroid of triangle) + getCellCenter(cell) { + const { x, y } = cell + const h = this.triHeight + const ys = this.yScale + const baseX = x * 0.5 + + // Centroid is at 1/3 from base for triangles + if (this.isUpward(x, y)) { + // UP triangle: apex at top, base at bottom + return { + x: baseX + 0.5, + y: (y + 2 / 3) * h * ys, + } + } else { + // DOWN triangle: apex at bottom, base at top + return { + x: baseX + 0.5, + y: (y + 1 / 3) * h * ys, + } + } + } + // Get cells on the grid perimeter with their exit directions // For triangles: top/bottom rows and left/right edges getEdgeCells() { @@ -205,8 +228,9 @@ export default class TriangleGrid extends Grid { // Draw exit wall split at midpoint + arrow on top // Scale down arrow for triangle's smaller edges + // Stores arrow tip on cell for solution path drawing const arrowScale = 0.6 - const addExitWithArrow = (x1, y1, x2, y2, direction, exitType) => { + const addExitWithArrow = (cell, x1, y1, x2, y2, direction) => { // For horizontal edges: n = inward down, s = inward up const inwardDx = 0 const inwardDy = direction === "n" ? 1 : -1 @@ -223,18 +247,22 @@ export default class TriangleGrid extends Grid { const sx2 = mx + (x2 - mx) * arrowScale const sy2 = my + (y2 - my) * arrowScale - // Add arrow (connects at midpoint) - this.addExitArrow( + // Add arrow (connects at midpoint) and store tip/base on cell + const arrow = this.addExitArrow( walls, makeVertex, sx1, sy1, sx2, sy2, - exitType, + cell.exitType, inwardDx, inwardDy, ) + + cell.arrowTip = arrow.tip + cell.arrowBase = arrow.base + cell.arrowEdges = arrow.edges } for (const cell of this.cells) { @@ -271,12 +299,12 @@ export default class TriangleGrid extends Grid { if (!south || !this.isLinked(cell, south)) { if (cell.exitDirection === "s") { addExitWithArrow( + cell, corners.bottomLeft[0], corners.bottomLeft[1], corners.bottomRight[0], corners.bottomRight[1], "s", - cell.exitType, ) } else { walls.push([ @@ -309,12 +337,12 @@ export default class TriangleGrid extends Grid { if (!north || !this.isLinked(cell, north)) { if (cell.exitDirection === "n") { addExitWithArrow( + cell, corners.topLeft[0], corners.topLeft[1], corners.topRight[0], corners.topRight[1], "n", - cell.exitType, ) } else { walls.push([ From c40761066091cfc7c86dff70db1a22f3d9de85fc Mon Sep 17 00:00:00 2001 From: Bob Carmichael Date: Sat, 20 Dec 2025 15:28:41 -0500 Subject: [PATCH 10/22] some cleanup --- src/features/shapes/maze/Maze.js | 27 +-- src/features/shapes/maze/grids/Grid.js | 103 +++++++++-- src/features/shapes/maze/grids/HexGrid.js | 114 ++---------- src/features/shapes/maze/grids/PolarGrid.js | 165 ++++-------------- .../shapes/maze/grids/RectangularGrid.js | 103 +---------- .../shapes/maze/grids/TriangleGrid.js | 102 ++--------- 6 files changed, 160 insertions(+), 454 deletions(-) diff --git a/src/features/shapes/maze/Maze.js b/src/features/shapes/maze/Maze.js index 53002064..56706579 100644 --- a/src/features/shapes/maze/Maze.js +++ b/src/features/shapes/maze/Maze.js @@ -32,7 +32,7 @@ const algorithms = { } // Set to true to debug maze generation -const DEBUG_MAZE = true +const DEBUG_MAZE = false const algorithmKeyByShape = { Rectangle: "mazeType", @@ -252,21 +252,15 @@ export default class Maze extends Shape { branchLevel: mazeBranchLevel, }) - let exitWalls = null let solutionPath = null if (mazeShowExits && grid.findHardestExits) { const exits = grid.findHardestExits() - if (exits && grid.getExitVertices) { + if (exits) { exits.startCell.exitType = "entrance" exits.endCell.exitType = "exit" - exitWalls = { - start: grid.getExitVertices(exits.startCell), - end: grid.getExitVertices(exits.endCell), - } - if (mazeShowSolution && exits.path) { solutionPath = exits.path } @@ -278,7 +272,7 @@ export default class Maze extends Shape { grid.dump() } - return this.drawMaze(grid, exitWalls, solutionPath) + return this.drawMaze(grid, solutionPath) } createGrid(shape, rng) { @@ -307,7 +301,7 @@ export default class Maze extends Shape { return new GridClass(Math.max(2, mazeWidth), Math.max(2, mazeHeight), rng) } - drawMaze(grid, exitWalls = null, solutionPath = null) { + drawMaze(grid, solutionPath = null) { const wallSegments = grid.extractWalls() const graph = new Graph() @@ -329,15 +323,8 @@ export default class Maze extends Shape { const trail = eulerianTrail({ edges: eulerizedEdges }) const walkedVertices = trail.map((key) => graph.nodeMap[key]) - if (solutionPath && solutionPath.length > 0 && exitWalls) { - this.drawSolution( - walkedVertices, - graph, - trail, - grid, - exitWalls, - solutionPath, - ) + if (solutionPath && solutionPath.length > 0) { + this.drawSolution(walkedVertices, graph, trail, grid, solutionPath) } const vertices = cloneVertices(walkedVertices) @@ -347,7 +334,7 @@ export default class Maze extends Shape { return vertices } - drawSolution(walkedVertices, graph, trail, grid, exitWalls, solutionPath) { + drawSolution(walkedVertices, graph, trail, grid, solutionPath) { const startCell = solutionPath[0] const endCell = solutionPath[solutionPath.length - 1] diff --git a/src/features/shapes/maze/grids/Grid.js b/src/features/shapes/maze/grids/Grid.js index 425f3f3d..96ebba2e 100644 --- a/src/features/shapes/maze/grids/Grid.js +++ b/src/features/shapes/maze/grids/Grid.js @@ -1,16 +1,97 @@ // Base class for all maze grids // Provides shared functionality like finding hardest exits +import Victor from "victor" + const ARROW_HEAD_WIDTH = 0.625 // Arrow head width as fraction of wall length (25% bigger) const ARROW_HEAD_HEIGHT = 0.8 // Arrow head height relative to width export default class Grid { // Subclasses must implement: // - getEdgeCells() -> [{cell, direction, edge}, ...] - // - getExitVertices(cell) -> [{x, y}, {x, y}] (wall endpoints) // - cellKey(cell) -> string // - getNeighbors(cell) -> [cell, ...] - // - isLinked(cell1, cell2) -> boolean + + link(cell1, cell2) { + cell1.links.add(this.cellKey(cell2)) + cell2.links.add(this.cellKey(cell1)) + } + + unlink(cell1, cell2) { + cell1.links.delete(this.cellKey(cell2)) + cell2.links.delete(this.cellKey(cell1)) + } + + isLinked(cell1, cell2) { + return cell1.links.has(this.cellKey(cell2)) + } + + markVisited(cell) { + cell.visited = true + } + + isVisited(cell) { + return cell.visited + } + + // Factory to create a cached vertex function + // round=true rounds to 6 decimals (needed for non-integer coordinates) + createMakeVertex(vertexCache, round = true) { + return (x, y) => { + const rx = round ? Math.round(x * 1000000) / 1000000 : x + const ry = round ? Math.round(y * 1000000) / 1000000 : y + const key = `${rx},${ry}` + + if (!vertexCache.has(key)) { + vertexCache.set(key, new Victor(rx, ry)) + } + + return vertexCache.get(key) + } + } + + // Helper to add an exit arrow with wall splitting + // This handles the common pattern: split wall, scale coords, draw arrow, store edges + addExitWithArrow( + walls, + makeVertex, + cell, + x1, + y1, + x2, + y2, + inwardDx, + inwardDy, + arrowScale = 1.0, + ) { + const mx = (x1 + x2) / 2 + const my = (y1 + y2) / 2 + + // Split wall at midpoint so arrow connects to graph + walls.push([makeVertex(x1, y1), makeVertex(mx, my)]) + walls.push([makeVertex(mx, my), makeVertex(x2, y2)]) + + // Scale wall coords for arrow sizing + const sx1 = mx + (x1 - mx) * arrowScale + const sy1 = my + (y1 - my) * arrowScale + const sx2 = mx + (x2 - mx) * arrowScale + const sy2 = my + (y2 - my) * arrowScale + + // Add arrow (connects at midpoint) and store edges on cell + const arrow = this.addExitArrow( + walls, + makeVertex, + sx1, + sy1, + sx2, + sy2, + cell.exitType, + inwardDx, + inwardDy, + ) + + cell.arrowEdges = arrow.edges + } // Draw arrow marker inside the maze (no shaft) // Exit: tip touches wall, arrow head extends inward (pointing out) @@ -102,21 +183,13 @@ export default class Grid { ]) } - // Build arrow vertices (same Victor objects that go into walls/graph) - const tipV = makeVertex(tipX, tipY) - const baseLeftV = makeVertex(baseLeftX, baseLeftY) - const baseCenterV = makeVertex(baseCenterX, baseCenterY) - const baseRightV = makeVertex(baseRightX, baseRightY) - - // Return vertices and edges + // Return edges (Victor objects that go into walls/graph) return { - tip: tipV, - base: baseCenterV, edges: [ - [tipV, baseLeftV], - [baseLeftV, baseCenterV], - [baseCenterV, baseRightV], - [baseRightV, tipV], + [makeVertex(tipX, tipY), makeVertex(baseLeftX, baseLeftY)], + [makeVertex(baseLeftX, baseLeftY), makeVertex(baseCenterX, baseCenterY)], + [makeVertex(baseCenterX, baseCenterY), makeVertex(baseRightX, baseRightY)], + [makeVertex(baseRightX, baseRightY), makeVertex(tipX, tipY)], ], } } diff --git a/src/features/shapes/maze/grids/HexGrid.js b/src/features/shapes/maze/grids/HexGrid.js index fbfb0287..fcef5feb 100644 --- a/src/features/shapes/maze/grids/HexGrid.js +++ b/src/features/shapes/maze/grids/HexGrid.js @@ -7,7 +7,6 @@ import Grid from "./Grid" export default class HexGrid extends Grid { constructor(width, height, rng) { super() - this.gridType = "hex" this.width = width this.height = height this.rng = rng @@ -92,28 +91,6 @@ export default class HexGrid extends Grid { return `${cell.q},${cell.r}` } - link(cell1, cell2) { - cell1.links.add(this.cellKey(cell2)) - cell2.links.add(this.cellKey(cell1)) - } - - unlink(cell1, cell2) { - cell1.links.delete(this.cellKey(cell2)) - cell2.links.delete(this.cellKey(cell1)) - } - - isLinked(cell1, cell2) { - return cell1.links.has(this.cellKey(cell2)) - } - - markVisited(cell) { - cell.visited = true - } - - isVisited(cell) { - return cell.visited - } - cellEquals(cell1, cell2) { return cell1.q === cell2.q && cell1.r === cell2.r } @@ -130,19 +107,16 @@ export default class HexGrid extends Grid { } // Get cells on the grid perimeter with their exit directions - // For hex grids, use e/w (east/west) exits for opposite edges getEdgeCells() { const edgeCells = [] for (const cell of this.cells) { const { q } = cell - // Left edge (q=0): west exit if (q === 0) { edgeCells.push({ cell, direction: "w", edge: "w" }) } - // Right edge (q=width-1): east exit if (q === this.width - 1) { edgeCells.push({ cell, direction: "e", edge: "e" }) } @@ -151,27 +125,6 @@ export default class HexGrid extends Grid { return edgeCells } - getExitVertices(cell) { - const corners = this.getHexCorners(cell.q, cell.r) - const dir = cell.exitDirection - - // corners: [p1(top-left), p2(bottom-left), p3(bottom), p4(bottom-right), p5(top-right), p6(top)] - switch (dir) { - case "w": // west edge: p1 to p2 - return [ - { x: corners[0][0], y: corners[0][1] }, - { x: corners[1][0], y: corners[1][1] }, - ] - case "e": // east edge: p4 to p5 - return [ - { x: corners[3][0], y: corners[3][1] }, - { x: corners[4][0], y: corners[4][1] }, - ] - default: - return null - } - } - getHexCorners(q, r) { const rowXOffset = Math.abs(r % 2) * this.xOffset const ys = this.yScale @@ -201,58 +154,29 @@ export default class HexGrid extends Grid { extractWalls() { const walls = [] const vertexCache = new Map() + const makeVertex = this.createMakeVertex(vertexCache) - const makeVertex = (x, y) => { - const rx = Math.round(x * 1000000) / 1000000 - const ry = Math.round(y * 1000000) / 1000000 - const key = `${rx},${ry}` - - if (!vertexCache.has(key)) { - vertexCache.set(key, new Victor(rx, ry)) - } - - return vertexCache.get(key) - } - - // Draw exit wall split at midpoint + arrow on top - // Scale up arrow for hex's edges - // Stores arrow tip on cell for solution path drawing const arrowScale = 1.25 - const addExitWithArrow = (cell, x1, y1, x2, y2, direction) => { + + const addExit = (cell, x1, y1, x2, y2, direction) => { // For pointy-top hex, west/east edges are vertical // West (q=0): inward points right (+x) // East (q=max): inward points left (-x) const inwardDx = direction === "w" ? 1 : -1 const inwardDy = 0 - const mx = (x1 + x2) / 2 - const my = (y1 + y2) / 2 - - // Split wall at midpoint so arrow connects to graph - walls.push([makeVertex(x1, y1), makeVertex(mx, my)]) - walls.push([makeVertex(mx, my), makeVertex(x2, y2)]) - // Scale wall coords away from midpoint to enlarge arrow - const sx1 = mx + (x1 - mx) * arrowScale - const sy1 = my + (y1 - my) * arrowScale - const sx2 = mx + (x2 - mx) * arrowScale - const sy2 = my + (y2 - my) * arrowScale - - // Add arrow (connects at midpoint) and store tip/base on cell - const arrow = this.addExitArrow( + this.addExitWithArrow( walls, makeVertex, - sx1, - sy1, - sx2, - sy2, - cell.exitType, + cell, + x1, + y1, + x2, + y2, inwardDx, inwardDy, + arrowScale, ) - - cell.arrowTip = arrow.tip - cell.arrowBase = arrow.base - cell.arrowEdges = arrow.edges } for (const cell of this.cells) { @@ -265,14 +189,7 @@ export default class HexGrid extends Grid { if (!west || !this.isLinked(cell, west)) { if (cell.exitDirection === "w") { - addExitWithArrow( - cell, - corners[0][0], - corners[0][1], - corners[1][0], - corners[1][1], - "w", - ) + addExit(cell, corners[0][0], corners[0][1], corners[1][0], corners[1][1], "w") } else { walls.push([ makeVertex(corners[0][0], corners[0][1]), @@ -306,14 +223,7 @@ export default class HexGrid extends Grid { if (!east || !this.isLinked(cell, east)) { if (cell.exitDirection === "e") { - addExitWithArrow( - cell, - corners[3][0], - corners[3][1], - corners[4][0], - corners[4][1], - "e", - ) + addExit(cell, corners[3][0], corners[3][1], corners[4][0], corners[4][1], "e") } else { walls.push([ makeVertex(corners[3][0], corners[3][1]), diff --git a/src/features/shapes/maze/grids/PolarGrid.js b/src/features/shapes/maze/grids/PolarGrid.js index df77e347..24e3000a 100644 --- a/src/features/shapes/maze/grids/PolarGrid.js +++ b/src/features/shapes/maze/grids/PolarGrid.js @@ -1,5 +1,4 @@ /* global console */ -import Victor from "victor" import Grid from "./Grid" // Polar grid for circular mazes @@ -15,7 +14,6 @@ export default class PolarGrid extends Grid { useArcs = false, ) { super() - this.gridType = "polar" this.ringCount = ringCount this.baseWedgeCount = baseWedgeCount this.doublingInterval = doublingInterval @@ -154,28 +152,6 @@ export default class PolarGrid extends Grid { return `${cell.ring},${cell.wedge}` } - link(cell1, cell2) { - cell1.links.add(this.cellKey(cell2)) - cell2.links.add(this.cellKey(cell1)) - } - - unlink(cell1, cell2) { - cell1.links.delete(this.cellKey(cell2)) - cell2.links.delete(this.cellKey(cell1)) - } - - isLinked(cell1, cell2) { - return cell1.links.has(this.cellKey(cell2)) - } - - markVisited(cell) { - cell.visited = true - } - - isVisited(cell) { - return cell.visited - } - cellEquals(cell1, cell2) { return cell1.ring === cell2.ring && cell1.wedge === cell2.wedge } @@ -212,28 +188,6 @@ export default class PolarGrid extends Grid { return edgeCells } - // Get the two vertices of an exit wall (outer arc endpoints) for a cell - getExitVertices(cell) { - if (cell.ring !== this.ringCount) return null - - const wedgeCount = this.rings[cell.ring].length - const anglePerWedge = (Math.PI * 2) / wedgeCount - const radius = this.ringCount + 1 - const startAngle = cell.wedge * anglePerWedge - const endAngle = (cell.wedge + 1) * anglePerWedge - - return [ - { - x: Math.round(radius * Math.cos(startAngle) * 1000000) / 1000000, - y: Math.round(radius * Math.sin(startAngle) * 1000000) / 1000000, - }, - { - x: Math.round(radius * Math.cos(endAngle) * 1000000) / 1000000, - y: Math.round(radius * Math.sin(endAngle) * 1000000) / 1000000, - }, - ] - } - // Debug: dump maze structure dump() { let output = "" @@ -259,18 +213,14 @@ export default class PolarGrid extends Grid { extractWalls() { const walls = [] const vertexCache = new Map() + const makeVertexXY = this.createMakeVertex(vertexCache) - // Helper to create/reuse vertices (ensures exact same object for same coords) + // Helper to create/reuse vertices from polar coords const makeVertex = (r, angle) => { - const x = Math.round(r * Math.cos(angle) * 1000000) / 1000000 - const y = Math.round(r * Math.sin(angle) * 1000000) / 1000000 - const key = `${x},${y}` - - if (!vertexCache.has(key)) { - vertexCache.set(key, new Victor(x, y)) - } + const x = r * Math.cos(angle) + const y = r * Math.sin(angle) - return vertexCache.get(key) + return makeVertexXY(x, y) } // 1. RADIAL WALLS (between adjacent wedges in the same ring) @@ -352,21 +302,6 @@ export default class PolarGrid extends Grid { } } - // Helper to create vertex from cartesian coords (for arrow drawing) - const makeVertexXY = (x, y) => { - const rx = Math.round(x * 1000000) / 1000000 - const ry = Math.round(y * 1000000) / 1000000 - const key = `${rx},${ry}` - - if (!vertexCache.has(key)) { - vertexCache.set(key, new Victor(rx, ry)) - } - - return vertexCache.get(key) - } - - // Draw exit arc wall split at midpoint + arrow - // Stores arrow tip on cell for solution path drawing const addExitArcWithArrow = (cell, radius, startAngle, endAngle) => { const midAngle = (startAngle + endAngle) / 2 @@ -405,75 +340,41 @@ export default class PolarGrid extends Grid { walls.push([makeVertex(radius, midAngle), makeVertex(radius, endAngle)]) } - // Arrow at arc's true midpoint (not chord midpoint) + // Arrow at arc's angular midpoint const arcMidX = radius * Math.cos(midAngle) const arcMidY = radius * Math.sin(midAngle) - // Inward direction points toward center - const inwardDx = -Math.cos(midAngle) - const inwardDy = -Math.sin(midAngle) - - // Arrow sizing based on ring width (constant = 1) for consistent size - const ringWidth = 1 - const arrowScale = 0.5 - const headWidth = ringWidth * arrowScale - const headHeight = headWidth * 0.8 - - // Tangent direction (perpendicular to inward, along the arc) + // Tangent direction (perpendicular to radial, along the arc) const tangentX = -Math.sin(midAngle) const tangentY = Math.cos(midAngle) - let tipX, tipY, baseCenterX, baseCenterY - let baseLeftX, baseLeftY, baseRightX, baseRightY - - if (cell.exitType === "exit") { - // Exit: tip on arc, base inside maze, pointing OUT - tipX = arcMidX - tipY = arcMidY - baseCenterX = arcMidX + inwardDx * headHeight - baseCenterY = arcMidY + inwardDy * headHeight - baseLeftX = baseCenterX - (tangentX * headWidth) / 2 - baseLeftY = baseCenterY - (tangentY * headWidth) / 2 - baseRightX = baseCenterX + (tangentX * headWidth) / 2 - baseRightY = baseCenterY + (tangentY * headWidth) / 2 - - walls.push([makeVertexXY(tipX, tipY), makeVertexXY(baseLeftX, baseLeftY)]) - walls.push([makeVertexXY(baseLeftX, baseLeftY), makeVertexXY(baseCenterX, baseCenterY)]) - walls.push([makeVertexXY(baseCenterX, baseCenterY), makeVertexXY(baseRightX, baseRightY)]) - walls.push([makeVertexXY(baseRightX, baseRightY), makeVertexXY(tipX, tipY)]) - } else { - // Entrance: base on arc, tip inside maze, pointing IN - baseCenterX = arcMidX - baseCenterY = arcMidY - baseLeftX = arcMidX - (tangentX * headWidth) / 2 - baseLeftY = arcMidY - (tangentY * headWidth) / 2 - baseRightX = arcMidX + (tangentX * headWidth) / 2 - baseRightY = arcMidY + (tangentY * headWidth) / 2 - - tipX = arcMidX + inwardDx * headHeight - tipY = arcMidY + inwardDy * headHeight - - walls.push([makeVertexXY(baseCenterX, baseCenterY), makeVertexXY(baseLeftX, baseLeftY)]) - walls.push([makeVertexXY(baseLeftX, baseLeftY), makeVertexXY(tipX, tipY)]) - walls.push([makeVertexXY(tipX, tipY), makeVertexXY(baseRightX, baseRightY)]) - walls.push([makeVertexXY(baseRightX, baseRightY), makeVertexXY(baseCenterX, baseCenterY)]) - } + // Create virtual wall endpoints along tangent for arrow sizing + // We want headWidth = 0.5, and addExitArrow uses headWidth = wallLen * 0.625 + // So wallLen = 0.5 / 0.625 = 0.8, half on each side = 0.4 + const halfWall = 0.4 + const x1 = arcMidX - tangentX * halfWall + const y1 = arcMidY - tangentY * halfWall + const x2 = arcMidX + tangentX * halfWall + const y2 = arcMidY + tangentY * halfWall + + // Inward direction points toward center + const inwardDx = -Math.cos(midAngle) + const inwardDy = -Math.sin(midAngle) - // Build arrow vertices (same Victor objects that go into walls/graph) - const tipV = makeVertexXY(tipX, tipY) - const baseLeftV = makeVertexXY(baseLeftX, baseLeftY) - const baseCenterV = makeVertexXY(baseCenterX, baseCenterY) - const baseRightV = makeVertexXY(baseRightX, baseRightY) - - // Store tip, base, and edges - cell.arrowTip = tipV - cell.arrowBase = baseCenterV - cell.arrowEdges = [ - [tipV, baseLeftV], - [baseLeftV, baseCenterV], - [baseCenterV, baseRightV], - [baseRightV, tipV], - ] + // Use base class arrow drawing (walls already split above, so just draw arrow) + const arrow = this.addExitArrow( + walls, + makeVertexXY, + x1, + y1, + x2, + y2, + cell.exitType, + inwardDx, + inwardDy, + ) + + cell.arrowEdges = arrow.edges } // 3. OUTER PERIMETER (always walls, with exits) diff --git a/src/features/shapes/maze/grids/RectangularGrid.js b/src/features/shapes/maze/grids/RectangularGrid.js index 000c2662..c1470e05 100644 --- a/src/features/shapes/maze/grids/RectangularGrid.js +++ b/src/features/shapes/maze/grids/RectangularGrid.js @@ -8,7 +8,6 @@ import Grid from "./Grid" export default class RectangularGrid extends Grid { constructor(width, height, rng) { super() - this.gridType = "rectangular" this.width = width this.height = height this.rng = rng @@ -68,28 +67,6 @@ export default class RectangularGrid extends Grid { return `${cell.x},${cell.y}` } - link(cell1, cell2) { - cell1.links.add(this.cellKey(cell2)) - cell2.links.add(this.cellKey(cell1)) - } - - unlink(cell1, cell2) { - cell1.links.delete(this.cellKey(cell2)) - cell2.links.delete(this.cellKey(cell1)) - } - - isLinked(cell1, cell2) { - return cell1.links.has(this.cellKey(cell2)) - } - - markVisited(cell) { - cell.visited = true - } - - isVisited(cell) { - return cell.visited - } - cellEquals(cell1, cell2) { return cell1.x === cell2.x && cell1.y === cell2.y } @@ -102,38 +79,6 @@ export default class RectangularGrid extends Grid { } } - // Get the two vertices of an exit wall for a cell - // Returns [v1, v2] where v1 and v2 are {x, y} objects - getExitVertices(cell) { - const { x, y } = cell - const dir = cell.exitDirection - - switch (dir) { - case "n": - return [ - { x, y: 0 }, - { x: x + 1, y: 0 }, - ] - case "s": - return [ - { x, y: this.height }, - { x: x + 1, y: this.height }, - ] - case "w": - return [ - { x: 0, y }, - { x: 0, y: y + 1 }, - ] - case "e": - return [ - { x: this.width, y }, - { x: this.width, y: y + 1 }, - ] - default: - return null - } - } - // Get all cells on the grid perimeter with their exit directions // edge property allows filtering to ensure exits are on opposite edges getEdgeCells() { @@ -160,16 +105,7 @@ export default class RectangularGrid extends Grid { extractWalls() { const walls = [] const vertexCache = new Map() - - const makeVertex = (x, y) => { - const key = `${x},${y}` - - if (!vertexCache.has(key)) { - vertexCache.set(key, new Victor(x, y)) - } - - return vertexCache.get(key) - } + const makeVertex = this.createMakeVertex(vertexCache, false) // Inward directions for each edge (into the maze) const inwardDir = { @@ -179,33 +115,10 @@ export default class RectangularGrid extends Grid { e: { dx: -1, dy: 0 }, } - // Draw exit wall split at midpoint + arrow on top - // Stores arrow tip on cell for solution path drawing - const addExitWithArrow = (cell, x1, y1, x2, y2, direction) => { + const addExit = (cell, x1, y1, x2, y2, direction) => { const { dx, dy } = inwardDir[direction] - const mx = (x1 + x2) / 2 - const my = (y1 + y2) / 2 - - // Split wall at midpoint so arrow connects to graph - walls.push([makeVertex(x1, y1), makeVertex(mx, my)]) - walls.push([makeVertex(mx, my), makeVertex(x2, y2)]) - - // Add arrow (connects at midpoint) and store tip/base on cell - const arrow = this.addExitArrow( - walls, - makeVertex, - x1, - y1, - x2, - y2, - cell.exitType, - dx, - dy, - ) - - cell.arrowTip = arrow.tip - cell.arrowBase = arrow.base - cell.arrowEdges = arrow.edges + + this.addExitWithArrow(walls, makeVertex, cell, x1, y1, x2, y2, dx, dy) } for (let y = 0; y < this.height; y++) { @@ -215,7 +128,7 @@ export default class RectangularGrid extends Grid { // North wall (top of cell) if (y === 0) { if (cell.exitDirection === "n") { - addExitWithArrow(cell, x, y, x + 1, y, "n") + addExit(cell, x, y, x + 1, y, "n") } else { walls.push([makeVertex(x, y), makeVertex(x + 1, y)]) } @@ -230,7 +143,7 @@ export default class RectangularGrid extends Grid { // West wall (left of cell) if (x === 0) { if (cell.exitDirection === "w") { - addExitWithArrow(cell, x, y, x, y + 1, "w") + addExit(cell, x, y, x, y + 1, "w") } else { walls.push([makeVertex(x, y), makeVertex(x, y + 1)]) } @@ -245,7 +158,7 @@ export default class RectangularGrid extends Grid { // South wall (bottom edge only for last row) if (y === this.height - 1) { if (cell.exitDirection === "s") { - addExitWithArrow(cell, x, y + 1, x + 1, y + 1, "s") + addExit(cell, x, y + 1, x + 1, y + 1, "s") } else { walls.push([makeVertex(x, y + 1), makeVertex(x + 1, y + 1)]) } @@ -254,7 +167,7 @@ export default class RectangularGrid extends Grid { // East wall (right edge only for last column) if (x === this.width - 1) { if (cell.exitDirection === "e") { - addExitWithArrow(cell, x + 1, y, x + 1, y + 1, "e") + addExit(cell, x + 1, y, x + 1, y + 1, "e") } else { walls.push([makeVertex(x + 1, y), makeVertex(x + 1, y + 1)]) } diff --git a/src/features/shapes/maze/grids/TriangleGrid.js b/src/features/shapes/maze/grids/TriangleGrid.js index 1b07fa09..1d225f68 100644 --- a/src/features/shapes/maze/grids/TriangleGrid.js +++ b/src/features/shapes/maze/grids/TriangleGrid.js @@ -7,7 +7,6 @@ import Grid from "./Grid" export default class TriangleGrid extends Grid { constructor(width, height, rng) { super() - this.gridType = "triangle" this.width = width this.height = height this.rng = rng @@ -91,28 +90,6 @@ export default class TriangleGrid extends Grid { return `${cell.x},${cell.y}` } - link(cell1, cell2) { - cell1.links.add(this.cellKey(cell2)) - cell2.links.add(this.cellKey(cell1)) - } - - unlink(cell1, cell2) { - cell1.links.delete(this.cellKey(cell2)) - cell2.links.delete(this.cellKey(cell1)) - } - - isLinked(cell1, cell2) { - return cell1.links.has(this.cellKey(cell2)) - } - - markVisited(cell) { - cell.visited = true - } - - isVisited(cell) { - return cell.visited - } - cellEquals(cell1, cell2) { return cell1.x === cell2.x && cell1.y === cell2.y } @@ -162,32 +139,6 @@ export default class TriangleGrid extends Grid { return edgeCells } - // Get the two vertices of an exit wall for a cell - getExitVertices(cell) { - const corners = this.getTriangleCorners(cell.x, cell.y) - const dir = cell.exitDirection - - if (cell.upward) { - // UP triangle: top, bottomLeft, bottomRight - if (dir === "s") { - return [ - { x: corners.bottomLeft[0], y: corners.bottomLeft[1] }, - { x: corners.bottomRight[0], y: corners.bottomRight[1] }, - ] - } - } else { - // DOWN triangle: topLeft, topRight, bottom - if (dir === "n") { - return [ - { x: corners.topLeft[0], y: corners.topLeft[1] }, - { x: corners.topRight[0], y: corners.topRight[1] }, - ] - } - } - - return null - } - getTriangleCorners(x, y) { const h = this.triHeight const ys = this.yScale @@ -213,56 +164,27 @@ export default class TriangleGrid extends Grid { extractWalls() { const walls = [] const vertexCache = new Map() + const makeVertex = this.createMakeVertex(vertexCache) - const makeVertex = (x, y) => { - const rx = Math.round(x * 1000000) / 1000000 - const ry = Math.round(y * 1000000) / 1000000 - const key = `${rx},${ry}` - - if (!vertexCache.has(key)) { - vertexCache.set(key, new Victor(rx, ry)) - } - - return vertexCache.get(key) - } - - // Draw exit wall split at midpoint + arrow on top - // Scale down arrow for triangle's smaller edges - // Stores arrow tip on cell for solution path drawing const arrowScale = 0.6 - const addExitWithArrow = (cell, x1, y1, x2, y2, direction) => { + + const addExit = (cell, x1, y1, x2, y2, direction) => { // For horizontal edges: n = inward down, s = inward up const inwardDx = 0 const inwardDy = direction === "n" ? 1 : -1 - const mx = (x1 + x2) / 2 - const my = (y1 + y2) / 2 - // Split wall at midpoint so arrow connects to graph - walls.push([makeVertex(x1, y1), makeVertex(mx, my)]) - walls.push([makeVertex(mx, my), makeVertex(x2, y2)]) - - // Scale wall coords toward midpoint to shrink arrow - const sx1 = mx + (x1 - mx) * arrowScale - const sy1 = my + (y1 - my) * arrowScale - const sx2 = mx + (x2 - mx) * arrowScale - const sy2 = my + (y2 - my) * arrowScale - - // Add arrow (connects at midpoint) and store tip/base on cell - const arrow = this.addExitArrow( + this.addExitWithArrow( walls, makeVertex, - sx1, - sy1, - sx2, - sy2, - cell.exitType, + cell, + x1, + y1, + x2, + y2, inwardDx, inwardDy, + arrowScale, ) - - cell.arrowTip = arrow.tip - cell.arrowBase = arrow.base - cell.arrowEdges = arrow.edges } for (const cell of this.cells) { @@ -298,7 +220,7 @@ export default class TriangleGrid extends Grid { if (!south || !this.isLinked(cell, south)) { if (cell.exitDirection === "s") { - addExitWithArrow( + addExit( cell, corners.bottomLeft[0], corners.bottomLeft[1], @@ -336,7 +258,7 @@ export default class TriangleGrid extends Grid { if (!north || !this.isLinked(cell, north)) { if (cell.exitDirection === "n") { - addExitWithArrow( + addExit( cell, corners.topLeft[0], corners.topLeft[1], From 67071726b86a174a54d6cb268d6cfa5dc6440c20 Mon Sep 17 00:00:00 2001 From: Bob Carmichael Date: Sat, 20 Dec 2025 16:20:46 -0500 Subject: [PATCH 11/22] walk a splined (more natural) solution path --- src/common/geometry.js | 82 ++++++---- src/features/shapes/maze/Maze.js | 143 ++++++++++++++---- src/features/shapes/maze/grids/Grid.js | 10 ++ src/features/shapes/maze/grids/HexGrid.js | 41 +++++ src/features/shapes/maze/grids/PolarGrid.js | 54 +++++++ .../shapes/maze/grids/RectangularGrid.js | 20 +++ .../shapes/maze/grids/TriangleGrid.js | 58 +++++-- 7 files changed, 339 insertions(+), 69 deletions(-) diff --git a/src/common/geometry.js b/src/common/geometry.js index 48cf3eaa..e36733a6 100644 --- a/src/common/geometry.js +++ b/src/common/geometry.js @@ -551,44 +551,68 @@ export const annotateVertices = (vertices, attrs) => { return vertices } -// returns the closest point on line segment ab to point p -export const closestPointOnSegment = (p, a, b) => { - const abX = b.x - a.x - const abY = b.y - a.y - const apX = p.x - a.x - const apY = p.y - a.y - const abLenSq = abX * abX + abY * abY - - if (abLenSq === 0) { - return { x: a.x, y: a.y } - } +// Check if 4 points are approximately collinear +const areCollinear = (p0, p1, p2, p3, tolerance = 0.01) => { + const dx = p3.x - p0.x + const dy = p3.y - p0.y + const len = Math.sqrt(dx * dx + dy * dy) + + if (len < tolerance) return true - const t = Math.max(0, Math.min(1, (apX * abX + apY * abY) / abLenSq)) + // Distance from p1 and p2 to the line through p0-p3 + const dist1 = Math.abs(dx * (p0.y - p1.y) - dy * (p0.x - p1.x)) / len + const dist2 = Math.abs(dx * (p0.y - p2.y) - dy * (p0.x - p2.x)) / len - return { x: a.x + t * abX, y: a.y + t * abY } + return dist1 < tolerance && dist2 < tolerance } -// returns the closest point across multiple line segments to point p -// also returns the segment it's on (for finding nearest graph vertices) -export const closestPointOnSegments = (p, segments) => { - let closest = null - let closestSegment = null - let minDistSq = Infinity +// Attempt to subdivide a path using Catmull-Rom spline interpolation +// Returns subdivided points that pass through all original points with smooth curves +// Preserves straight segments when points are collinear +export const catmullRomSpline = (points, segmentsPerCurve = 8) => { + if (points.length < 2) return points + if (points.length === 2) return points + + const result = [] + + for (let i = 0; i < points.length - 1; i++) { + const p0 = points[Math.max(0, i - 1)] + const p1 = points[i] + const p2 = points[i + 1] + const p3 = points[Math.min(points.length - 1, i + 2)] - for (const [a, b] of segments) { - const point = closestPointOnSegment(p, a, b) - const dx = point.x - p.x - const dy = point.y - p.y - const distSq = dx * dx + dy * dy + result.push(p1) - if (distSq < minDistSq) { - minDistSq = distSq - closest = point - closestSegment = [a, b] + // Skip interpolation for collinear segments (keep straight lines straight) + if (areCollinear(p0, p1, p2, p3)) { + continue + } + + for (let t = 1; t < segmentsPerCurve; t++) { + const s = t / segmentsPerCurve + const s2 = s * s + const s3 = s2 * s + + result.push({ + x: + 0.5 * + (2 * p1.x + + (-p0.x + p2.x) * s + + (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * s2 + + (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * s3), + y: + 0.5 * + (2 * p1.y + + (-p0.y + p2.y) * s + + (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * s2 + + (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * s3), + }) } } - return { point: closest, segment: closestSegment } + result.push(points[points.length - 1]) + + return result } // returns the intersection point of two line segments diff --git a/src/features/shapes/maze/Maze.js b/src/features/shapes/maze/Maze.js index 56706579..ecdaebcc 100644 --- a/src/features/shapes/maze/Maze.js +++ b/src/features/shapes/maze/Maze.js @@ -7,7 +7,8 @@ import { eulerizeEdges } from "@/common/chinesePostman" import { cloneVertices, centerOnOrigin, - closestPointOnSegments, + catmullRomSpline, + calculateIntersection, } from "@/common/geometry" import RectangularGrid from "./grids/RectangularGrid" import PolarGrid from "./grids/PolarGrid" @@ -342,20 +343,14 @@ export default class Maze extends Shape { return } - const secondCenter = solutionPath.length > 1 - ? grid.getCellCenter(solutionPath[1]) - : grid.getCellCenter(solutionPath[0]) - const secondToLastCenter = solutionPath.length > 1 - ? grid.getCellCenter(solutionPath[solutionPath.length - 2]) - : grid.getCellCenter(solutionPath[0]) - const entrance = closestPointOnSegments(secondCenter, startCell.arrowEdges) - const [entA, entB] = entrance.segment - const distToA = - (entrance.point.x - entA.x) ** 2 + (entrance.point.y - entA.y) ** 2 - const distToB = - (entrance.point.x - entB.x) ** 2 + (entrance.point.y - entB.y) ** 2 - const entranceVertex = distToA < distToB ? entA : entB - const entranceKey = entranceVertex.toString() + // Arrow geometry: edges[0][0] = tip, edges[1][1] = baseCenter + // Entrance arrow points IN: tip is inside maze (start here) + // Exit arrow points OUT: baseCenter is inside maze (end here) + const entranceTip = startCell.arrowEdges[0][0] + const exitBaseCenter = endCell.arrowEdges[1][1] + + // Walk graph from trail end to entrance arrow + const entranceKey = entranceTip.toString() const trailEndKey = trail[trail.length - 1] if (graph.nodeMap[entranceKey]) { @@ -368,24 +363,116 @@ export default class Maze extends Shape { } } - walkedVertices.push(entrance.point) + // Collect passage midpoints (shared edge centers between consecutive cells) + const passageMidpoints = [] + + for (let i = 0; i < solutionPath.length - 1; i++) { + const midpoint = grid.getSharedEdgeMidpoint( + solutionPath[i], + solutionPath[i + 1], + ) + + passageMidpoints.push(midpoint) + } + + // Build path using only passage midpoints (doorways between cells) + const solutionWaypoints = [entranceTip, ...passageMidpoints, exitBaseCenter] + + // Apply spline smoothing, then clip at arrow edges + let smoothed = catmullRomSpline(solutionWaypoints, 6) + + // Clip at entrance arrow and walk from tip to intersection + const entrance = this.clipAtArrow(smoothed, startCell.arrowEdges, true) + + if (entrance.hit) { + smoothed = [ + ...this.walkArrowToTip(entrance.edgeIndex, startCell.arrowEdges, true), + entrance.hit, + ...entrance.spline, + ] + } + + // Clip at exit arrow and walk from intersection to tip + const exit = this.clipAtArrow(smoothed, endCell.arrowEdges, false) + const exitTip = endCell.arrowEdges[0][0] + + if (exit.hit) { + smoothed = [ + ...exit.spline, + exit.hit, + ...this.walkArrowToTip(exit.edgeIndex, endCell.arrowEdges, false), + ] + } else { + // No intersection - walk arrow edge: baseCenter → baseRight → tip + const baseRight = endCell.arrowEdges[2][1] + + smoothed.push(baseRight) + smoothed.push(exitTip) + } - for (let i = 1; i < solutionPath.length - 1; i++) { - const center = grid.getCellCenter(solutionPath[i]) + for (const pt of smoothed) { + walkedVertices.push(pt) + } + } - walkedVertices.push({ x: center.x, y: center.y }) + // Clip spline where it crosses an arrow edge + // Returns { spline, hit, edgeIndex } + clipAtArrow(spline, arrowEdges, fromStart) { + if (fromStart) { + for (let i = 0; i < spline.length - 1; i++) { + for (let e = 0; e < arrowEdges.length; e++) { + const [a, b] = arrowEdges[e] + const hit = calculateIntersection(spline[i], spline[i + 1], a, b) + + if (hit) { + return { spline: spline.slice(i + 1), hit, edgeIndex: e } + } + } + } + } else { + for (let i = spline.length - 1; i > 0; i--) { + for (let e = 0; e < arrowEdges.length; e++) { + const [a, b] = arrowEdges[e] + const hit = calculateIntersection(spline[i - 1], spline[i], a, b) + + if (hit) { + return { spline: spline.slice(0, i), hit, edgeIndex: e } + } + } + } } - const exit = closestPointOnSegments(secondToLastCenter, endCell.arrowEdges) - const [exitA, exitB] = exit.segment - const distToExitA = - (exit.point.x - exitA.x) ** 2 + (exit.point.y - exitA.y) ** 2 - const distToExitB = - (exit.point.x - exitB.x) ** 2 + (exit.point.y - exitB.y) ** 2 - const exitVertex = distToExitA < distToExitB ? exitA : exitB + return { spline, hit: null, edgeIndex: -1 } + } - walkedVertices.push(exitVertex) - walkedVertices.push(exit.point) + // Walk arrow edges from intersection to tip (or tip to intersection) + // Arrow edges: [tip→baseLeft, baseLeft→baseCenter, baseCenter→baseRight, baseRight→tip] + // Tip is at edges[0][0] (start of first edge) + walkArrowToTip(edgeIndex, arrowEdges, towardsTip) { + if (edgeIndex < 0) return [] + + const tip = arrowEdges[0][0] + const baseLeft = arrowEdges[0][1] + const baseRight = arrowEdges[3][0] + + // Which side of the arrow did we hit? + // edges 0,1 are left side (tip→baseLeft→baseCenter) + // edges 2,3 are right side (baseCenter→baseRight→tip) + if (towardsTip) { + // Walking TO the tip (prepend to path) + if (edgeIndex <= 1) { + return edgeIndex === 0 ? [tip] : [tip, baseLeft] + } else { + return edgeIndex === 3 ? [tip] : [tip, baseRight] + } + } else { + // Walking FROM the tip (append to path) + if (edgeIndex <= 1) { + return edgeIndex === 0 ? [tip] : [baseLeft, tip] + } else { + return edgeIndex === 3 ? [tip] : [baseRight, tip] + } + } } getOptions() { diff --git a/src/features/shapes/maze/grids/Grid.js b/src/features/shapes/maze/grids/Grid.js index 96ebba2e..210c6b29 100644 --- a/src/features/shapes/maze/grids/Grid.js +++ b/src/features/shapes/maze/grids/Grid.js @@ -34,6 +34,16 @@ export default class Grid { return cell.visited } + // Get the midpoint of the shared edge between two linked cells + // Subclasses should override this for accurate passage midpoints + getSharedEdgeMidpoint(cell1, cell2) { + // Default: average of cell centers (fallback) + const c1 = this.getCellCenter(cell1) + const c2 = this.getCellCenter(cell2) + + return { x: (c1.x + c2.x) / 2, y: (c1.y + c2.y) / 2 } + } + // Factory to create a cached vertex function // round=true rounds to 6 decimals (needed for non-integer coordinates) createMakeVertex(vertexCache, round = true) { diff --git a/src/features/shapes/maze/grids/HexGrid.js b/src/features/shapes/maze/grids/HexGrid.js index fcef5feb..98b43b32 100644 --- a/src/features/shapes/maze/grids/HexGrid.js +++ b/src/features/shapes/maze/grids/HexGrid.js @@ -106,6 +106,47 @@ export default class HexGrid extends Grid { } } + // Get midpoint of shared edge between two adjacent cells + getSharedEdgeMidpoint(cell1, cell2) { + const corners = this.getHexCorners(cell1.q, cell1.r) + const dq = cell2.q - cell1.q + const dr = cell2.r - cell1.r + const rowOffset = this.getRowOffset(cell1.r) + + let p1, p2 + + if (dr === 0 && dq === 1) { + // East + p1 = corners[4] + p2 = corners[3] + } else if (dr === 0 && dq === -1) { + // West + p1 = corners[0] + p2 = corners[1] + } else if (dr === -1 && dq === 1 - rowOffset) { + // Northeast + p1 = corners[5] + p2 = corners[4] + } else if (dr === -1 && dq === -rowOffset) { + // Northwest + p1 = corners[0] + p2 = corners[5] + } else if (dr === 1 && dq === 1 - rowOffset) { + // Southeast + p1 = corners[3] + p2 = corners[2] + } else { + // Southwest + p1 = corners[2] + p2 = corners[1] + } + + return { + x: (p1[0] + p2[0]) / 2, + y: (p1[1] + p2[1]) / 2, + } + } + // Get cells on the grid perimeter with their exit directions getEdgeCells() { const edgeCells = [] diff --git a/src/features/shapes/maze/grids/PolarGrid.js b/src/features/shapes/maze/grids/PolarGrid.js index 24e3000a..5d9591ef 100644 --- a/src/features/shapes/maze/grids/PolarGrid.js +++ b/src/features/shapes/maze/grids/PolarGrid.js @@ -176,6 +176,60 @@ export default class PolarGrid extends Grid { } } + // Get midpoint of shared edge between two adjacent cells + getSharedEdgeMidpoint(cell1, cell2) { + const { ring: r1, wedge: w1 } = cell1 + const { ring: r2, wedge: w2 } = cell2 + + // Center cell to ring 1 + if (r1 === 0) { + const wedgeCount = this.rings[1].length + const anglePerWedge = (Math.PI * 2) / wedgeCount + const angle = (w2 + 0.5) * anglePerWedge + + return { x: Math.cos(angle), y: Math.sin(angle) } + } + + if (r2 === 0) { + const wedgeCount = this.rings[1].length + const anglePerWedge = (Math.PI * 2) / wedgeCount + const angle = (w1 + 0.5) * anglePerWedge + + return { x: Math.cos(angle), y: Math.sin(angle) } + } + + // Same ring (CW/CCW neighbors) - radial edge + if (r1 === r2) { + const wedgeCount = this.rings[r1].length + const anglePerWedge = (Math.PI * 2) / wedgeCount + const sharedAngle = Math.max(w1, w2) * anglePerWedge + + // Handle wraparound + const angle = + Math.abs(w1 - w2) > 1 ? 0 : sharedAngle + const radius = r1 + 0.5 + + return { + x: radius * Math.cos(angle), + y: radius * Math.sin(angle), + } + } + + // Different rings (inward/outward) - arc edge + const innerRing = Math.min(r1, r2) + const outerRing = Math.max(r1, r2) + const outerWedge = r1 > r2 ? w1 : w2 + const outerWedgeCount = this.rings[outerRing].length + const anglePerWedge = (Math.PI * 2) / outerWedgeCount + const angle = (outerWedge + 0.5) * anglePerWedge + const radius = outerRing + + return { + x: radius * Math.cos(angle), + y: radius * Math.sin(angle), + } + } + // Get cells on the outer ring (perimeter) with their exit directions getEdgeCells() { const edgeCells = [] diff --git a/src/features/shapes/maze/grids/RectangularGrid.js b/src/features/shapes/maze/grids/RectangularGrid.js index c1470e05..05c952a6 100644 --- a/src/features/shapes/maze/grids/RectangularGrid.js +++ b/src/features/shapes/maze/grids/RectangularGrid.js @@ -79,6 +79,26 @@ export default class RectangularGrid extends Grid { } } + // Get midpoint of shared edge between two adjacent cells + getSharedEdgeMidpoint(cell1, cell2) { + const dx = cell2.x - cell1.x + const dy = cell2.y - cell1.y + + if (dy === -1) { + // cell2 is north + return { x: cell1.x + 0.5, y: cell1.y } + } else if (dy === 1) { + // cell2 is south + return { x: cell1.x + 0.5, y: cell1.y + 1 } + } else if (dx === 1) { + // cell2 is east + return { x: cell1.x + 1, y: cell1.y + 0.5 } + } else { + // cell2 is west + return { x: cell1.x, y: cell1.y + 0.5 } + } + } + // Get all cells on the grid perimeter with their exit directions // edge property allows filtering to ensure exits are on opposite edges getEdgeCells() { diff --git a/src/features/shapes/maze/grids/TriangleGrid.js b/src/features/shapes/maze/grids/TriangleGrid.js index 1d225f68..6c05d4ea 100644 --- a/src/features/shapes/maze/grids/TriangleGrid.js +++ b/src/features/shapes/maze/grids/TriangleGrid.js @@ -94,26 +94,60 @@ export default class TriangleGrid extends Grid { return cell1.x === cell2.x && cell1.y === cell2.y } - // Get the center point of a cell (centroid of triangle) + // Get the center point of a cell (geometric center, not centroid) + // Using y + 0.5 ensures line-of-sight to all edges getCellCenter(cell) { const { x, y } = cell const h = this.triHeight const ys = this.yScale const baseX = x * 0.5 - // Centroid is at 1/3 from base for triangles - if (this.isUpward(x, y)) { - // UP triangle: apex at top, base at bottom - return { - x: baseX + 0.5, - y: (y + 2 / 3) * h * ys, + return { + x: baseX + 0.5, + y: (y + 0.5) * h * ys, + } + } + + // Get midpoint of shared edge between two adjacent cells + getSharedEdgeMidpoint(cell1, cell2) { + const c1 = this.getTriangleCorners(cell1.x, cell1.y) + const c2 = this.getTriangleCorners(cell2.x, cell2.y) + const dx = cell2.x - cell1.x + const dy = cell2.y - cell1.y + + let p1, p2 + + if (dx === 1) { + // cell2 is east + if (cell1.upward) { + p1 = c1.top + p2 = c1.bottomRight + } else { + p1 = c1.topRight + p2 = c1.bottom } - } else { - // DOWN triangle: apex at bottom, base at top - return { - x: baseX + 0.5, - y: (y + 1 / 3) * h * ys, + } else if (dx === -1) { + // cell2 is west + if (cell1.upward) { + p1 = c1.top + p2 = c1.bottomLeft + } else { + p1 = c1.topLeft + p2 = c1.bottom } + } else if (dy === 1) { + // cell2 is south (cell1 must be upward) + p1 = c1.bottomLeft + p2 = c1.bottomRight + } else { + // cell2 is north (cell1 must be downward) + p1 = c1.topLeft + p2 = c1.topRight + } + + return { + x: (p1[0] + p2[0]) / 2, + y: (p1[1] + p2[1]) / 2, } } From 0e9d49e3acb16654802bd7169606858841d34a4f Mon Sep 17 00:00:00 2001 From: Bob Carmichael Date: Sat, 20 Dec 2025 16:52:34 -0500 Subject: [PATCH 12/22] some tweaks --- src/features/shapes/maze/Maze.js | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/features/shapes/maze/Maze.js b/src/features/shapes/maze/Maze.js index ecdaebcc..9805bba5 100644 --- a/src/features/shapes/maze/Maze.js +++ b/src/features/shapes/maze/Maze.js @@ -99,7 +99,7 @@ const options = { mazeWidth: { title: "Maze width", min: 1, - max: 20, + max: 30, isVisible: (layer, state) => { return state.mazeShape !== "Circle" }, @@ -107,7 +107,7 @@ const options = { mazeHeight: { title: "Maze height", min: 1, - max: 20, + max: 30, isVisible: (layer, state) => { return state.mazeShape !== "Circle" }, @@ -343,13 +343,8 @@ export default class Maze extends Shape { return } - // Arrow geometry: edges[0][0] = tip, edges[1][1] = baseCenter - // Entrance arrow points IN: tip is inside maze (start here) - // Exit arrow points OUT: baseCenter is inside maze (end here) const entranceTip = startCell.arrowEdges[0][0] const exitBaseCenter = endCell.arrowEdges[1][1] - - // Walk graph from trail end to entrance arrow const entranceKey = entranceTip.toString() const trailEndKey = trail[trail.length - 1] From ce010c8e861754d4200e5b454aeab6d59cf7a64f Mon Sep 17 00:00:00 2001 From: Bob Carmichael Date: Sat, 20 Dec 2025 17:31:08 -0500 Subject: [PATCH 13/22] adjust defaults --- src/features/shapes/maze/Maze.js | 159 ++++++++++++++++++++++++------- 1 file changed, 124 insertions(+), 35 deletions(-) diff --git a/src/features/shapes/maze/Maze.js b/src/features/shapes/maze/Maze.js index 9805bba5..74dce926 100644 --- a/src/features/shapes/maze/Maze.js +++ b/src/features/shapes/maze/Maze.js @@ -2,6 +2,7 @@ import Shape from "../Shape" import seedrandom from "seedrandom" import Graph from "@/common/Graph" +import { getMachine } from "@/features/machines/machineFactory" import { eulerianTrail } from "@/common/eulerian_trail/eulerianTrail" import { eulerizeEdges } from "@/common/chinesePostman" import { @@ -54,19 +55,19 @@ const options = { mazeShape: { title: "Shape", type: "togglebutton", - choices: ["Rectangle", "Hexagon", "Triangle", "Circle"], + choices: ["Rectangle", "Circle", "Hexagon", "Triangle"], }, mazeType: { title: "Algorithm", type: "dropdown", choices: [ - "Wilson", "Backtracker", "Division", - "Prim", + "Eller", "Kruskal", + "Prim", "Sidewinder", - "Eller", + "Wilson", ], isVisible: (layer, state) => { return state.mazeShape === "Rectangle" @@ -75,7 +76,7 @@ const options = { mazeTypeCircle: { title: "Algorithm", type: "dropdown", - choices: ["Wilson", "Backtracker", "Prim", "Kruskal"], + choices: ["Backtracker", "Kruskal", "Prim", "Wilson"], isVisible: (layer, state) => { return state.mazeShape === "Circle" }, @@ -83,7 +84,7 @@ const options = { mazeTypeHex: { title: "Algorithm", type: "dropdown", - choices: ["Wilson", "Backtracker", "Prim", "Kruskal"], + choices: ["Backtracker", "Kruskal", "Prim", "Wilson"], isVisible: (layer, state) => { return state.mazeShape === "Hexagon" }, @@ -91,14 +92,14 @@ const options = { mazeTypeTriangle: { title: "Algorithm", type: "dropdown", - choices: ["Wilson", "Backtracker", "Prim", "Kruskal"], + choices: ["Backtracker", "Kruskal", "Prim", "Wilson"], isVisible: (layer, state) => { return state.mazeShape === "Triangle" }, }, mazeWidth: { title: "Maze width", - min: 1, + min: 2, max: 30, isVisible: (layer, state) => { return state.mazeShape !== "Circle" @@ -106,7 +107,7 @@ const options = { }, mazeHeight: { title: "Maze height", - min: 1, + min: 2, max: 30, isVisible: (layer, state) => { return state.mazeShape !== "Circle" @@ -119,6 +120,16 @@ const options = { isVisible: (layer, state) => { return state.mazeShape === "Circle" }, + onChange: (model, changes, state) => { + // Auto-adjust doubling interval to prevent overflow + const minDoubling = Math.ceil((changes.mazeRingCount - 1) / 5) + + if (state.mazeWedgeDoubling < minDoubling) { + changes.mazeWedgeDoubling = minDoubling + } + + return changes + }, }, mazeWedgeCount: { title: "Wedges", @@ -130,7 +141,7 @@ const options = { }, mazeWedgeDoubling: { title: "Doubling interval", - min: 1, + min: (state) => Math.ceil((state.mazeRingCount - 1) / 5), max: 10, isVisible: (layer, state) => { return state.mazeShape === "Circle" @@ -215,10 +226,10 @@ export default class Maze extends Shape { mazeTypeCircle: "Wilson", mazeTypeHex: "Wilson", mazeTypeTriangle: "Wilson", - mazeWidth: 8, - mazeHeight: 8, - mazeRingCount: 6, - mazeWedgeCount: 8, + mazeWidth: 4, + mazeHeight: 12, + mazeRingCount: 10, + mazeWedgeCount: 10, mazeWedgeDoubling: 3, mazeWallType: "Arc", mazeStraightness: 0, @@ -227,10 +238,76 @@ export default class Maze extends Shape { mazeShowExits: true, mazeShowSolution: false, seed: 1, + maintainAspectRatio: true, }, } } + // Override to ensure uniform scaling (aspectRatio: 1.0 prevents distortion in resizeVertices) + initialDimensions(props) { + if (!props) { + return { width: 0, height: 0, aspectRatio: 1.0 } + } + + const { width, height } = super.initialDimensions(props) + const machine = getMachine(props.machine) + const maxSize = Math.min(machine.width, machine.height) * 0.6 + const scale = maxSize / Math.max(width, height) + + return { + width: width * scale, + height: height * scale, + aspectRatio: 1.0, + } + } + + getMazeAspectRatio(state) { + // Rectangle: width/height ratio; others are square due to yScale normalization + if (state.mazeShape === "Rectangle") { + return state.mazeWidth / state.mazeHeight + } + + return 1 + } + + handleUpdate(layer, changes) { + // Enforce minimum dimensions + if (changes.mazeWidth !== undefined) { + changes.mazeWidth = Math.max(2, changes.mazeWidth) + } + if (changes.mazeHeight !== undefined) { + changes.mazeHeight = Math.max(2, changes.mazeHeight) + } + + const relevantChange = + changes.mazeShape !== undefined || + changes.mazeWidth !== undefined || + changes.mazeHeight !== undefined + + if (!relevantChange) { + return + } + + const oldRatio = this.getMazeAspectRatio(layer) + const newState = { ...layer, ...changes } + const newRatio = this.getMazeAspectRatio(newState) + + if (oldRatio === newRatio) { + return + } + + // Scale dimensions to match new ratio, keeping max dimension the same + const maxDim = Math.max(layer.width, layer.height) + + if (newRatio >= 1) { + changes.width = maxDim + changes.height = maxDim / newRatio + } else { + changes.width = maxDim * newRatio + changes.height = maxDim + } + } + getVertices(state) { const { mazeStraightness, @@ -253,27 +330,14 @@ export default class Maze extends Shape { branchLevel: mazeBranchLevel, }) - let solutionPath = null - - if (mazeShowExits && grid.findHardestExits) { - const exits = grid.findHardestExits() - - if (exits) { - exits.startCell.exitType = "entrance" - exits.endCell.exitType = "exit" - - if (mazeShowSolution && exits.path) { - solutionPath = exits.path - } - } - } - - if (DEBUG_MAZE && grid.dump) { - console.log(`\n=== ${algorithmName} on ${state.shape.mazeShape} ===`) - grid.dump() + if (DEBUG_MAZE) { + this.debugMaze(algorithmName, state.shape.mazeShape, grid) } - return this.drawMaze(grid, solutionPath) + return this.drawMaze( + grid, + this.setupExits(grid, mazeShowExits, mazeShowSolution), + ) } createGrid(shape, rng) { @@ -288,10 +352,13 @@ export default class Maze extends Shape { } = shape if (mazeShape === "Circle") { + const rings = Math.max(2, mazeRingCount) + const minDoubling = Math.ceil((rings - 1) / 5) + return new PolarGrid( - Math.max(2, mazeRingCount), + rings, Math.max(4, mazeWedgeCount), - Math.max(1, mazeWedgeDoubling), + Math.max(minDoubling, mazeWedgeDoubling), rng, mazeWallType === "Arc", ) @@ -302,6 +369,28 @@ export default class Maze extends Shape { return new GridClass(Math.max(2, mazeWidth), Math.max(2, mazeHeight), rng) } + debugMaze(algorithmName, mazeShape, grid) { + console.log(`\n=== ${algorithmName} on ${mazeShape} ===`) + grid.dump() + } + + setupExits(grid, showExits, showSolution) { + if (!showExits || !grid.findHardestExits) { + return null + } + + const exits = grid.findHardestExits() + + if (!exits) { + return null + } + + exits.startCell.exitType = "entrance" + exits.endCell.exitType = "exit" + + return showSolution && exits.path ? exits.path : null + } + drawMaze(grid, solutionPath = null) { const wallSegments = grid.extractWalls() const graph = new Graph() From 8183f57f367aa59390c6b1985f5a5bd2711222e1 Mon Sep 17 00:00:00 2001 From: Bob Carmichael Date: Sat, 20 Dec 2025 18:12:38 -0500 Subject: [PATCH 14/22] future ideas --- src/features/shapes/maze/EVOLUTION.md | 46 +++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/features/shapes/maze/EVOLUTION.md diff --git a/src/features/shapes/maze/EVOLUTION.md b/src/features/shapes/maze/EVOLUTION.md new file mode 100644 index 00000000..27de80ed --- /dev/null +++ b/src/features/shapes/maze/EVOLUTION.md @@ -0,0 +1,46 @@ +# Maze Shape Evolution + +Future directions and ideas for the maze feature. + +## New Algorithms + +**Growing Tree Algorithm** +- Generalization of Prim/Backtracker +- Configurable cell selection strategy (newest, random, oldest, mixed) +- Would provide more control over maze characteristics + +**Eller for Polar/Hex** +- Adapt Eller's algorithm to work ring-by-ring for polar grids +- Would require rethinking "horizontal" merging in circular context + +## New Grid Types + +**Sigma (Brick) Grid** +- Offset rectangular cells like brick pattern +- 6 neighbors (2 horizontal, 4 diagonal) + +## Polar Enhancements + +**Radial Bias Parameter** +- Add bias toward inward/outward movement for spiral-like patterns +- Could apply to Backtracker and Prim for circular mazes + +**Adaptive Subdivision** +- Currently uses fixed `doublingInterval` +- Could auto-calculate based on maintaining uniform cell size + +## Hexagonal Enhancements + +**Flat-top Orientation** +- Alternative hex orientation option +- Would require different neighbor and corner calculations + +**Directional Bias Along Hex Axes** +- Bias along the 3 hex directions rather than just horizontal/vertical +- Could create interesting flow patterns unique to hex topology + +## Other Ideas + +- **Wall thickness**: Configurable line weight +- **Straightness for Circle**: Adapt straightness bias for polar backtracker +- **Masks**: Remove cells to create shapes (hearts, text, holes) From 2c3b24c6a8a0fa59b96a3706f630f896987ce331 Mon Sep 17 00:00:00 2001 From: Bob Carmichael Date: Sat, 20 Dec 2025 19:06:06 -0500 Subject: [PATCH 15/22] triangle and hex shape should handle dimension changes like rectangle --- src/features/shapes/maze/Maze.js | 50 ++++++++++++++----- src/features/shapes/maze/grids/HexGrid.js | 7 ++- .../shapes/maze/grids/TriangleGrid.js | 10 ++-- 3 files changed, 44 insertions(+), 23 deletions(-) diff --git a/src/features/shapes/maze/Maze.js b/src/features/shapes/maze/Maze.js index 74dce926..ba220d5d 100644 --- a/src/features/shapes/maze/Maze.js +++ b/src/features/shapes/maze/Maze.js @@ -215,6 +215,7 @@ export default class Maze extends Shape { constructor() { super("maze") this.label = "Maze" + this.stretch = true } getInitialState() { @@ -226,7 +227,7 @@ export default class Maze extends Shape { mazeTypeCircle: "Wilson", mazeTypeHex: "Wilson", mazeTypeTriangle: "Wilson", - mazeWidth: 4, + mazeWidth: 12, mazeHeight: 12, mazeRingCount: 10, mazeWedgeCount: 10, @@ -238,18 +239,17 @@ export default class Maze extends Shape { mazeShowExits: true, mazeShowSolution: false, seed: 1, - maintainAspectRatio: true, }, } } - // Override to ensure uniform scaling (aspectRatio: 1.0 prevents distortion in resizeVertices) + // Size initial transformer to fit machine, using actual vertex aspect ratio initialDimensions(props) { if (!props) { return { width: 0, height: 0, aspectRatio: 1.0 } } - const { width, height } = super.initialDimensions(props) + const { width, height, aspectRatio } = super.initialDimensions(props) const machine = getMachine(props.machine) const maxSize = Math.min(machine.width, machine.height) * 0.6 const scale = maxSize / Math.max(width, height) @@ -257,21 +257,38 @@ export default class Maze extends Shape { return { width: width * scale, height: height * scale, - aspectRatio: 1.0, + aspectRatio, } } getMazeAspectRatio(state) { - // Rectangle: width/height ratio; others are square due to yScale normalization if (state.mazeShape === "Rectangle") { return state.mazeWidth / state.mazeHeight } + if (state.mazeShape === "Triangle") { + const triHeight = Math.sqrt(3) / 2 + const rawWidth = (state.mazeWidth + 1) * 0.5 + const rawHeight = state.mazeHeight * triHeight + + return rawWidth / rawHeight + } + + if (state.mazeShape === "Hexagon") { + const xOffset = Math.sin(Math.PI / 3) + const rawWidth = + state.mazeHeight >= 2 + ? (2 * state.mazeWidth + 1) * xOffset + : 2 * state.mazeWidth * xOffset + const rawHeight = 1.5 * state.mazeHeight + 0.5 + + return rawWidth / rawHeight + } + return 1 } handleUpdate(layer, changes) { - // Enforce minimum dimensions if (changes.mazeWidth !== undefined) { changes.mazeWidth = Math.max(2, changes.mazeWidth) } @@ -279,12 +296,21 @@ export default class Maze extends Shape { changes.mazeHeight = Math.max(2, changes.mazeHeight) } - const relevantChange = - changes.mazeShape !== undefined || - changes.mazeWidth !== undefined || - changes.mazeHeight !== undefined + // Scale transformer proportionally when maze dimensions change + // This preserves any manual distortion the user applied + if (changes.mazeWidth !== undefined && changes.mazeWidth !== layer.mazeWidth) { + const scale = changes.mazeWidth / layer.mazeWidth + + changes.width = layer.width * scale + } + if (changes.mazeHeight !== undefined && changes.mazeHeight !== layer.mazeHeight) { + const scale = changes.mazeHeight / layer.mazeHeight + + changes.height = layer.height * scale + } - if (!relevantChange) { + // When shape type changes, reset to natural aspect ratio + if (changes.mazeShape === undefined) { return } diff --git a/src/features/shapes/maze/grids/HexGrid.js b/src/features/shapes/maze/grids/HexGrid.js index 98b43b32..6ab0281b 100644 --- a/src/features/shapes/maze/grids/HexGrid.js +++ b/src/features/shapes/maze/grids/HexGrid.js @@ -15,10 +15,9 @@ export default class HexGrid extends Grid { this.yOffset1 = Math.cos(Math.PI / 3) // ~0.5 this.yOffset2 = 2 - this.yOffset1 // ~1.5 - const rawWidth = (2 * width + 1) * this.xOffset - const rawHeight = height * this.yOffset2 + this.yOffset1 - - this.yScale = rawWidth / rawHeight + // Keep hexagons regular (no distortion) + // Maze.js getMazeAspectRatio() handles the actual grid aspect ratio + this.yScale = 1 this.cells = [] for (let r = 0; r < height; r++) { diff --git a/src/features/shapes/maze/grids/TriangleGrid.js b/src/features/shapes/maze/grids/TriangleGrid.js index 6c05d4ea..60695168 100644 --- a/src/features/shapes/maze/grids/TriangleGrid.js +++ b/src/features/shapes/maze/grids/TriangleGrid.js @@ -13,13 +13,9 @@ export default class TriangleGrid extends Grid { this.triHeight = Math.sqrt(3) / 2 - // Calculate raw dimensions for aspect ratio - // Screen width: triangles overlap, so width = (W-1)*0.5 + 1 = 0.5*W + 0.5 - // Screen height: H * triHeight - const rawWidth = 0.5 * width + 0.5 - const rawHeight = height * this.triHeight - - this.yScale = rawWidth / rawHeight + // Keep triangles regular (equilateral) - no distortion + // Maze.js getMazeAspectRatio() handles the actual grid aspect ratio + this.yScale = 1 this.cells = [] for (let y = 0; y < height; y++) { From cf0662527cb7c1da9ebef7cd61deef5e69f9f745 Mon Sep 17 00:00:00 2001 From: Bob Carmichael Date: Sat, 20 Dec 2025 19:29:15 -0500 Subject: [PATCH 16/22] add attribution --- src/features/shapes/maze/LICENSE | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/features/shapes/maze/LICENSE b/src/features/shapes/maze/LICENSE index 050823b3..5f6cfb3e 100644 --- a/src/features/shapes/maze/LICENSE +++ b/src/features/shapes/maze/LICENSE @@ -1,2 +1,20 @@ "Maze Generation: Wilson's Algorithm" by Jamis Buck, licensed under CC BY-NC-SA 4.0 (https://creativecommons.org/licenses/by-nc-sa/4.0/deed.en). Modified from the original. + +Maze Generator by Rob Dawson, licensed under MIT license. +https://github.com/codebox/mazes + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT +NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. From 4fee099622152fcc24dca004129cbf9a0b5814de Mon Sep 17 00:00:00 2001 From: Bob Carmichael Date: Sat, 20 Dec 2025 19:37:37 -0500 Subject: [PATCH 17/22] kruskal doesn't use horizontal bias --- src/features/shapes/maze/Maze.js | 4 +--- src/features/shapes/maze/algorithms/kruskal.js | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/features/shapes/maze/Maze.js b/src/features/shapes/maze/Maze.js index ba220d5d..34eb0093 100644 --- a/src/features/shapes/maze/Maze.js +++ b/src/features/shapes/maze/Maze.js @@ -177,9 +177,7 @@ const options = { isVisible: (layer, state) => { return ( state.mazeShape !== "Circle" && - (state.mazeType === "Division" || - state.mazeType === "Kruskal" || - state.mazeType === "Eller") + (state.mazeType === "Division" || state.mazeType === "Eller") ) }, }, diff --git a/src/features/shapes/maze/algorithms/kruskal.js b/src/features/shapes/maze/algorithms/kruskal.js index a9f76c54..546896f7 100644 --- a/src/features/shapes/maze/algorithms/kruskal.js +++ b/src/features/shapes/maze/algorithms/kruskal.js @@ -46,7 +46,7 @@ class UnionFind { } } -export const kruskal = (grid, { rng, horizontalBias = 5 }) => { +export const kruskal = (grid, { rng }) => { const uf = new UnionFind() const allCells = grid.getAllCells() From cfdf298670ef0bcae9620ead2f3513e82bc8e1f6 Mon Sep 17 00:00:00 2001 From: Bob Carmichael Date: Sat, 20 Dec 2025 21:49:39 -0500 Subject: [PATCH 18/22] more ideas --- src/features/shapes/maze/EVOLUTION.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/features/shapes/maze/EVOLUTION.md b/src/features/shapes/maze/EVOLUTION.md index 27de80ed..178fbb20 100644 --- a/src/features/shapes/maze/EVOLUTION.md +++ b/src/features/shapes/maze/EVOLUTION.md @@ -19,6 +19,16 @@ Future directions and ideas for the maze feature. - Offset rectangular cells like brick pattern - 6 neighbors (2 horizontal, 4 diagonal) +**Upsilon Grid** +- Octagons with small squares at intersections +- 8 neighbors for octagons, 4 for squares +- Visually interesting tiling + +**Zeta Grid** +- Rectangular cells with 45-degree diagonal passages allowed +- 8 neighbors per cell instead of 4 +- Creates more organic-looking mazes + ## Polar Enhancements **Radial Bias Parameter** From 7b8e26ec8dd95d5ea0920860864653ef696ffcf3 Mon Sep 17 00:00:00 2001 From: Bob Carmichael Date: Sun, 21 Dec 2025 20:07:01 -0500 Subject: [PATCH 19/22] lint fixes --- src/features/shapes/maze/Maze.js | 10 ++++++++-- src/features/shapes/maze/grids/Grid.js | 10 ++++++++-- src/features/shapes/maze/grids/HexGrid.js | 19 ++++++++++++++++--- src/features/shapes/maze/grids/PolarGrid.js | 9 +++++---- .../shapes/maze/grids/RectangularGrid.js | 1 - .../shapes/maze/grids/TriangleGrid.js | 2 -- 6 files changed, 37 insertions(+), 14 deletions(-) diff --git a/src/features/shapes/maze/Maze.js b/src/features/shapes/maze/Maze.js index 34eb0093..555e6187 100644 --- a/src/features/shapes/maze/Maze.js +++ b/src/features/shapes/maze/Maze.js @@ -296,12 +296,18 @@ export default class Maze extends Shape { // Scale transformer proportionally when maze dimensions change // This preserves any manual distortion the user applied - if (changes.mazeWidth !== undefined && changes.mazeWidth !== layer.mazeWidth) { + if ( + changes.mazeWidth !== undefined && + changes.mazeWidth !== layer.mazeWidth + ) { const scale = changes.mazeWidth / layer.mazeWidth changes.width = layer.width * scale } - if (changes.mazeHeight !== undefined && changes.mazeHeight !== layer.mazeHeight) { + if ( + changes.mazeHeight !== undefined && + changes.mazeHeight !== layer.mazeHeight + ) { const scale = changes.mazeHeight / layer.mazeHeight changes.height = layer.height * scale diff --git a/src/features/shapes/maze/grids/Grid.js b/src/features/shapes/maze/grids/Grid.js index 210c6b29..50147d4c 100644 --- a/src/features/shapes/maze/grids/Grid.js +++ b/src/features/shapes/maze/grids/Grid.js @@ -197,8 +197,14 @@ export default class Grid { return { edges: [ [makeVertex(tipX, tipY), makeVertex(baseLeftX, baseLeftY)], - [makeVertex(baseLeftX, baseLeftY), makeVertex(baseCenterX, baseCenterY)], - [makeVertex(baseCenterX, baseCenterY), makeVertex(baseRightX, baseRightY)], + [ + makeVertex(baseLeftX, baseLeftY), + makeVertex(baseCenterX, baseCenterY), + ], + [ + makeVertex(baseCenterX, baseCenterY), + makeVertex(baseRightX, baseRightY), + ], [makeVertex(baseRightX, baseRightY), makeVertex(tipX, tipY)], ], } diff --git a/src/features/shapes/maze/grids/HexGrid.js b/src/features/shapes/maze/grids/HexGrid.js index 6ab0281b..25ffa0f9 100644 --- a/src/features/shapes/maze/grids/HexGrid.js +++ b/src/features/shapes/maze/grids/HexGrid.js @@ -1,4 +1,3 @@ -import Victor from "victor" import Grid from "./Grid" // Hexagonal grid for hex mazes @@ -229,7 +228,14 @@ export default class HexGrid extends Grid { if (!west || !this.isLinked(cell, west)) { if (cell.exitDirection === "w") { - addExit(cell, corners[0][0], corners[0][1], corners[1][0], corners[1][1], "w") + addExit( + cell, + corners[0][0], + corners[0][1], + corners[1][0], + corners[1][1], + "w", + ) } else { walls.push([ makeVertex(corners[0][0], corners[0][1]), @@ -263,7 +269,14 @@ export default class HexGrid extends Grid { if (!east || !this.isLinked(cell, east)) { if (cell.exitDirection === "e") { - addExit(cell, corners[3][0], corners[3][1], corners[4][0], corners[4][1], "e") + addExit( + cell, + corners[3][0], + corners[3][1], + corners[4][0], + corners[4][1], + "e", + ) } else { walls.push([ makeVertex(corners[3][0], corners[3][1]), diff --git a/src/features/shapes/maze/grids/PolarGrid.js b/src/features/shapes/maze/grids/PolarGrid.js index 5d9591ef..41c82cc4 100644 --- a/src/features/shapes/maze/grids/PolarGrid.js +++ b/src/features/shapes/maze/grids/PolarGrid.js @@ -205,8 +205,7 @@ export default class PolarGrid extends Grid { const sharedAngle = Math.max(w1, w2) * anglePerWedge // Handle wraparound - const angle = - Math.abs(w1 - w2) > 1 ? 0 : sharedAngle + const angle = Math.abs(w1 - w2) > 1 ? 0 : sharedAngle const radius = r1 + 0.5 return { @@ -216,7 +215,6 @@ export default class PolarGrid extends Grid { } // Different rings (inward/outward) - arc edge - const innerRing = Math.min(r1, r2) const outerRing = Math.max(r1, r2) const outerWedge = r1 > r2 ? w1 : w2 const outerWedgeCount = this.rings[outerRing].length @@ -390,7 +388,10 @@ export default class PolarGrid extends Grid { } } else { // Straight segments split at midpoint - walls.push([makeVertex(radius, startAngle), makeVertex(radius, midAngle)]) + walls.push([ + makeVertex(radius, startAngle), + makeVertex(radius, midAngle), + ]) walls.push([makeVertex(radius, midAngle), makeVertex(radius, endAngle)]) } diff --git a/src/features/shapes/maze/grids/RectangularGrid.js b/src/features/shapes/maze/grids/RectangularGrid.js index 05c952a6..cbb13378 100644 --- a/src/features/shapes/maze/grids/RectangularGrid.js +++ b/src/features/shapes/maze/grids/RectangularGrid.js @@ -1,5 +1,4 @@ /* global console */ -import Victor from "victor" import Grid from "./Grid" // Rectangular grid for standard mazes diff --git a/src/features/shapes/maze/grids/TriangleGrid.js b/src/features/shapes/maze/grids/TriangleGrid.js index 60695168..872f7daf 100644 --- a/src/features/shapes/maze/grids/TriangleGrid.js +++ b/src/features/shapes/maze/grids/TriangleGrid.js @@ -1,4 +1,3 @@ -import Victor from "victor" import Grid from "./Grid" // Triangular grid for delta mazes @@ -107,7 +106,6 @@ export default class TriangleGrid extends Grid { // Get midpoint of shared edge between two adjacent cells getSharedEdgeMidpoint(cell1, cell2) { const c1 = this.getTriangleCorners(cell1.x, cell1.y) - const c2 = this.getTriangleCorners(cell2.x, cell2.y) const dx = cell2.x - cell1.x const dy = cell2.y - cell1.y From 8802404d55f75928ef9456d782eaef769863c19e Mon Sep 17 00:00:00 2001 From: Bob Carmichael Date: Fri, 2 Jan 2026 10:41:26 -0500 Subject: [PATCH 20/22] evolve EVOLUTION into README with more info --- src/features/shapes/maze/EVOLUTION.md | 56 ----------------- src/features/shapes/maze/README.md | 86 +++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 56 deletions(-) delete mode 100644 src/features/shapes/maze/EVOLUTION.md create mode 100644 src/features/shapes/maze/README.md diff --git a/src/features/shapes/maze/EVOLUTION.md b/src/features/shapes/maze/EVOLUTION.md deleted file mode 100644 index 178fbb20..00000000 --- a/src/features/shapes/maze/EVOLUTION.md +++ /dev/null @@ -1,56 +0,0 @@ -# Maze Shape Evolution - -Future directions and ideas for the maze feature. - -## New Algorithms - -**Growing Tree Algorithm** -- Generalization of Prim/Backtracker -- Configurable cell selection strategy (newest, random, oldest, mixed) -- Would provide more control over maze characteristics - -**Eller for Polar/Hex** -- Adapt Eller's algorithm to work ring-by-ring for polar grids -- Would require rethinking "horizontal" merging in circular context - -## New Grid Types - -**Sigma (Brick) Grid** -- Offset rectangular cells like brick pattern -- 6 neighbors (2 horizontal, 4 diagonal) - -**Upsilon Grid** -- Octagons with small squares at intersections -- 8 neighbors for octagons, 4 for squares -- Visually interesting tiling - -**Zeta Grid** -- Rectangular cells with 45-degree diagonal passages allowed -- 8 neighbors per cell instead of 4 -- Creates more organic-looking mazes - -## Polar Enhancements - -**Radial Bias Parameter** -- Add bias toward inward/outward movement for spiral-like patterns -- Could apply to Backtracker and Prim for circular mazes - -**Adaptive Subdivision** -- Currently uses fixed `doublingInterval` -- Could auto-calculate based on maintaining uniform cell size - -## Hexagonal Enhancements - -**Flat-top Orientation** -- Alternative hex orientation option -- Would require different neighbor and corner calculations - -**Directional Bias Along Hex Axes** -- Bias along the 3 hex directions rather than just horizontal/vertical -- Could create interesting flow patterns unique to hex topology - -## Other Ideas - -- **Wall thickness**: Configurable line weight -- **Straightness for Circle**: Adapt straightness bias for polar backtracker -- **Masks**: Remove cells to create shapes (hearts, text, holes) diff --git a/src/features/shapes/maze/README.md b/src/features/shapes/maze/README.md new file mode 100644 index 00000000..77e713c4 --- /dev/null +++ b/src/features/shapes/maze/README.md @@ -0,0 +1,86 @@ +# Maze + +Generates perfect mazes (no loops, exactly one path between any two cells) on various grid topologies, then converts them to continuous drawing paths for sand tables. + +## How It Works + +1. **Build grid**: Create a cell structure based on the selected shape (rectangular, polar, hexagonal, or triangular). + +2. **Generate maze**: Run a maze algorithm to carve passages between cells, producing a spanning tree. + +3. **Extract walls**: Collect the wall segments (edges between unconnected cells). + +4. **Eulerize**: Apply Chinese Postman to make all vertices even-degree, enabling a single continuous path. + +5. **Traverse**: An Eulerian trail visits every wall segment exactly once. + +## Algorithms + +| Algorithm | Description | Characteristics | +|-----------|-------------|-----------------| +| Backtracker | Depth-first search with random neighbor selection | Long, winding passages; configurable straightness bias | +| Wilson | Loop-erased random walks | Uniform spanning tree; unbiased but slower on large grids | +| Prim | Frontier-based growth from random edges | Short branches, "bushy" appearance; configurable branch level | +| Kruskal | Randomly merges disjoint sets via edges | Even texture; uses union-find for efficiency | +| Division | Recursively divides space with walls | Long straight corridors; configurable horizontal bias. Rectangle only. | +| Sidewinder | Row-by-row with horizontal runs | Strong horizontal bias; fast and memory-efficient. Rectangle only. | +| Eller | Row-by-row with disjoint set tracking | Memory-efficient streaming; configurable horizontal bias. Rectangle only. | + +## Grid Types + +| Grid | Cell Shape | Neighbors | Notes | +|------|------------|-----------|-------| +| Rectangular | Square | 4 (north/south/east/west) | Standard orthogonal maze | +| Polar | Wedge | 3-4 (inward/outward/clockwise/counter-clockwise) | Concentric rings; cells subdivide as radius increases | +| Hexagonal | Hexagon | 6 | Pointy-top orientation | +| Triangle | Triangle | 3 | Alternating up/down triangles | + +## Future Directions + +### New Algorithms + +- **Growing Tree Algorithm** + - Generalization of Prim/Backtracker + - Configurable cell selection strategy (newest, random, oldest, mixed) + - Would provide more control over maze characteristics +- **Eller for Polar/Hex** + - Adapt Eller's algorithm to work ring-by-ring for polar grids + - Would require rethinking "horizontal" merging in circular context + +### New Grid Types + +- **Sigma (Brick) Grid** + - Offset rectangular cells like brick pattern + - 6 neighbors (2 horizontal, 4 diagonal) +- **Upsilon Grid** + - Octagons with small squares at intersections + - 8 neighbors for octagons, 4 for squares + - Visually interesting tiling +- **Zeta Grid** + - Rectangular cells with 45-degree diagonal passages allowed + - 8 neighbors per cell instead of 4 + - Creates more organic-looking mazes + +### Polar Enhancements + +- **Radial Bias Parameter** + - Add bias toward inward/outward movement for spiral-like patterns + - Could apply to Backtracker and Prim for circular mazes +- **Adaptive Subdivision** + - Currently uses fixed `doublingInterval` + - Could auto-calculate based on maintaining uniform cell size + +### Hexagonal Enhancements + +- **Flat-top Orientation** + - Alternative hex orientation option + - Would require different neighbor and corner calculations +- **Directional Bias Along Hex Axes** + - Bias along the 3 hex directions rather than just horizontal/vertical + - Could create interesting flow patterns unique to hex topology + +### Other Ideas + +- **Wall thickness** - Configurable line weight +- **Straightness for Circle** - Adapt straightness bias for polar backtracker +- **Masks** - Remove cells to create shapes (hearts, text, holes) From 680b0244d14b65b0be1c241bbec3c3cfdf6ea3c2 Mon Sep 17 00:00:00 2001 From: Bob Carmichael Date: Fri, 9 Jan 2026 06:17:57 -0500 Subject: [PATCH 21/22] remove incorrect attribution --- src/features/shapes/maze/LICENSE | 3 --- src/features/shapes/maze/algorithms/eller.js | 1 - src/features/shapes/maze/algorithms/wilson.js | 1 - 3 files changed, 5 deletions(-) diff --git a/src/features/shapes/maze/LICENSE b/src/features/shapes/maze/LICENSE index 5f6cfb3e..e91006ad 100644 --- a/src/features/shapes/maze/LICENSE +++ b/src/features/shapes/maze/LICENSE @@ -1,6 +1,3 @@ -"Maze Generation: Wilson's Algorithm" by Jamis Buck, licensed under CC BY-NC-SA 4.0 -(https://creativecommons.org/licenses/by-nc-sa/4.0/deed.en). Modified from the original. - Maze Generator by Rob Dawson, licensed under MIT license. https://github.com/codebox/mazes diff --git a/src/features/shapes/maze/algorithms/eller.js b/src/features/shapes/maze/algorithms/eller.js index fc0e51e9..0dbb7e81 100644 --- a/src/features/shapes/maze/algorithms/eller.js +++ b/src/features/shapes/maze/algorithms/eller.js @@ -2,7 +2,6 @@ // Processes one row at a time using disjoint sets - very memory efficient // Creates horizontal bias similar to Sidewinder but more variety // NOTE: Only works with rectangular grids (row-based processing) -// Reference: https://weblog.jamisbuck.org/2010/12/29/maze-generation-eller-s-algorithm export const eller = (grid, { rng, horizontalBias = 5 }) => { // Mark all cells as visited diff --git a/src/features/shapes/maze/algorithms/wilson.js b/src/features/shapes/maze/algorithms/wilson.js index 1157e201..de6056b2 100644 --- a/src/features/shapes/maze/algorithms/wilson.js +++ b/src/features/shapes/maze/algorithms/wilson.js @@ -1,7 +1,6 @@ // Wilson's algorithm for maze generation // Uses loop-erased random walks to generate uniform spanning trees // Works with any grid type (RectangularGrid, PolarGrid, etc.) -// adapted from https://weblog.jamisbuck.org/2011/1/20/maze-generation-wilson-s-algorithm export const wilson = (grid, { rng }) => { const allCells = grid.getAllCells() From a896cd3ac45d879e69cf574795b0465b881145b0 Mon Sep 17 00:00:00 2001 From: Bob Carmichael Date: Fri, 9 Jan 2026 06:24:59 -0500 Subject: [PATCH 22/22] fix: preserve maze type if possible when shape changes; change defaults a bit --- src/features/shapes/maze/Maze.js | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/features/shapes/maze/Maze.js b/src/features/shapes/maze/Maze.js index 555e6187..2b1acc29 100644 --- a/src/features/shapes/maze/Maze.js +++ b/src/features/shapes/maze/Maze.js @@ -56,6 +56,19 @@ const options = { title: "Shape", type: "togglebutton", choices: ["Rectangle", "Circle", "Hexagon", "Triangle"], + onChange: (model, changes, state) => { + // Try to preserve the current algorithm when switching shapes + const oldAlgoKey = algorithmKeyByShape[state.mazeShape] + const newAlgoKey = algorithmKeyByShape[changes.mazeShape] + const currentAlgo = state[oldAlgoKey] + const newShapeChoices = options[newAlgoKey].choices + + if (newShapeChoices.includes(currentAlgo)) { + changes[newAlgoKey] = currentAlgo + } + + return changes + }, }, mazeType: { title: "Algorithm", @@ -221,12 +234,12 @@ export default class Maze extends Shape { ...super.getInitialState(), ...{ mazeShape: "Rectangle", - mazeType: "Wilson", - mazeTypeCircle: "Wilson", - mazeTypeHex: "Wilson", - mazeTypeTriangle: "Wilson", - mazeWidth: 12, - mazeHeight: 12, + mazeType: "Backtracker", + mazeTypeCircle: "Backtracker", + mazeTypeHex: "Backtracker", + mazeTypeTriangle: "Backtracker", + mazeWidth: 20, + mazeHeight: 20, mazeRingCount: 10, mazeWedgeCount: 10, mazeWedgeDoubling: 3,