From ce02a3e9a4b1acc3dc58329da7f30592fbec8028 Mon Sep 17 00:00:00 2001 From: Eric Xu Date: Wed, 10 Jul 2024 02:46:48 -0400 Subject: [PATCH] Add solving and simplify the drawing interface --- src/app/page.tsx | 4 + src/lib/MazeController.ts | 150 +++++++++-- src/lib/MazeDrawer.ts | 232 ++++++++++++++---- src/lib/algorithms/Algorithm.ts | 56 +++++ .../algorithms/generating/MazeGenerator.ts | 62 +---- src/lib/algorithms/solving/MazeSolver.ts | 23 ++ src/lib/algorithms/solving/Tremaux.ts | 35 +++ 7 files changed, 431 insertions(+), 131 deletions(-) create mode 100644 src/lib/algorithms/Algorithm.ts create mode 100644 src/lib/algorithms/solving/MazeSolver.ts create mode 100644 src/lib/algorithms/solving/Tremaux.ts diff --git a/src/app/page.tsx b/src/app/page.tsx index 25c268d..e05b98c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -61,6 +61,10 @@ export default function Home(): ReactElement { { + if (mazeController === null) return; + mazeController.zoomTo(parseFloat(value)); + }} className="glass-tube-container flex items-center gap-2 px-4 py-2 text-sm" > diff --git a/src/lib/MazeController.ts b/src/lib/MazeController.ts index 8d93eec..7327a3a 100644 --- a/src/lib/MazeController.ts +++ b/src/lib/MazeController.ts @@ -1,18 +1,20 @@ -import { match } from "ts-pattern"; +import { Mutex } from "async-mutex"; import { type GenerationAlgorithm, type SolveAlgorithm, } from "./algorithms/algorithmTypes"; -import { Backtracking } from "./algorithms/generating/Backtracking"; -import { Prims } from "./algorithms/generating/Prims"; -import { MazeCell } from "./MazeCell"; -import { clamp, deepEqual, Direction, easeOutQuad } from "./utils"; -import { Wilsons } from "./algorithms/generating/Wilsons"; -import { Idx2d, Coord, RectSize } from "./twoDimens"; import { AnimationPromise } from "./AnimationPromise"; -import { Mutex } from "async-mutex"; -import colors from "tailwindcss/colors"; +import { MazeCell } from "./MazeCell"; import MazeDrawer from "./MazeDrawer"; +import { RectSize } from "./twoDimens"; +import { clamp, deepEqual } from "./utils"; +import { Tremaux } from "./algorithms/solving/Tremaux"; +import { match } from "ts-pattern"; +import { MazeGenerator } from "./algorithms/generating/MazeGenerator"; +import { MazeSolver } from "./algorithms/solving/MazeSolver"; +import { Wilsons } from "./algorithms/generating/Wilsons"; + +export type MazeEvent = "generate" | "solve"; export interface MazeDimensions { rows: number; @@ -47,7 +49,7 @@ export class MazeController { dimensions: { rows: 20, cols: 20, - cellWallRatio: 4.0, + cellWallRatio: MazeDrawer.DEFAULT_VALUES.cellWallRatio, }, doAnimateGenerating: true, doAnimateSolving: true, @@ -55,7 +57,8 @@ export class MazeController { private drawer: MazeDrawer; private mazeAnimation: AnimationPromise | null = null; - private isCanvasEmpty: boolean = true; + private shouldSweep: boolean = true; + private lastEvent: MazeEvent | null = null; private mutex = new Mutex(); private dimensions: MazeDimensions = MazeController.DEFAULTS.dimensions; @@ -74,23 +77,28 @@ export class MazeController { } public async generate(settings: MazeSettings): Promise { + if (this.lastEvent === "solve") this.shouldSweep = true; + this.lastEvent = "generate"; + this.drawer.mazeEvent = "generate"; + if (!deepEqual(this.dimensions, settings.dimensions)) { this.dimensions = settings.dimensions; this.drawer.resize(this.dimensions); - this.isCanvasEmpty = true; + this.shouldSweep = false; } const { rows, cols } = this.dimensions; - this.stopMazeAnimation(); - const alg = new Backtracking({ rows, cols }); + await this.stopMazeAnimation(); + const alg: MazeGenerator = new Wilsons({ rows, cols }); this.maze = alg.maze; + this.drawer.maze = this.maze; if (settings.doAnimateGenerating) { // Do a sweep animation if the resize didn't already do one - if (!this.isCanvasEmpty) { + if (this.shouldSweep) { this.drawer.fillWithWall(); - this.isCanvasEmpty = true; + this.shouldSweep = false; } // Dynamically decide the number of steps to take based on total number // of cells. Increases exponentially with number of cells. @@ -100,17 +108,16 @@ export class MazeController { // Define the animation const animation = new AnimationPromise( () => { - const modifiedCells: Readonly[] = []; for (let i = 0; i < steps; i++) { const cells = alg.step(); - modifiedCells.push(...cells); + this.drawer.changeList.push(...cells); } - this.drawer.drawModifiedCells(modifiedCells); + this.drawer.draw(); }, () => { if (alg.finished) { - if (this.maze === null) throw new Error("Maze is null"); - this.drawer.drawFinishedMaze(this.maze); + this.drawer.isComplete = true; + this.drawer.draw(); return true; } return false; @@ -121,6 +128,7 @@ export class MazeController { this.mutex.runExclusive(async () => { await this.stopMazeAnimation(); await this.drawer.waitForSweepAnimations(); + this.drawer.isComplete = false; this.mazeAnimation = animation; this.mazeAnimation.start(); this.mazeAnimation.promise.then(() => { @@ -129,24 +137,91 @@ export class MazeController { }); } else { alg.finish(); + this.drawer.isComplete = true; this.drawer.useHiddenCtx = true; - this.drawer.drawFinishedMaze(this.maze); + this.drawer.draw(); this.drawer.animateCanvasCopyFill(); this.drawer.useHiddenCtx = false; } - this.isCanvasEmpty = false; + this.shouldSweep = true; } - public solve(settings: MazeSettings): void {} + public async solve(settings: MazeSettings): Promise { + if (this.maze === null) throw new Error("Can't solve null maze"); + if (this.lastEvent === "generate") this.shouldSweep = false; + this.lastEvent = "solve"; + this.drawer.mazeEvent = "solve"; + await this.stopMazeAnimation(); + + const [start, end] = this.randomStartEnd(); + this.drawer.startEnd = [start, end]; + const alg: MazeSolver = new Tremaux(this.maze, start, end); + + if (settings.doAnimateSolving) { + if (this.shouldSweep) { + this.drawer.useHiddenCtx = true; + this.drawer.mazeEvent = "generate"; + this.drawer.isComplete = true; + this.drawer.draw(); + this.drawer.animateCanvasCopyFill(); + this.drawer.useHiddenCtx = false; + this.drawer.mazeEvent = "solve"; + } + + const animation = new AnimationPromise( + () => { + this.drawer.changeList = alg.step(); + this.drawer.path = alg.path; + this.drawer.draw(); + }, + () => { + if (!alg.finished) return false; + this.drawer.isComplete = true; + this.drawer.draw(); + return true; + }, + 60, + ); + + this.mutex.runExclusive(async () => { + await this.stopMazeAnimation(); + await this.drawer.waitForSweepAnimations(); + this.drawer.isComplete = false; + this.mazeAnimation = animation; + this.mazeAnimation.start(); + this.shouldSweep = true; + this.mazeAnimation.promise.then(() => { + this.mazeAnimation = null; + }); + }); + } else { + alg.finish(); + this.drawer.path = alg.path; + this.drawer.isComplete = true; + this.drawer.useHiddenCtx = true; + this.drawer.draw(); + this.drawer.animateCanvasCopyFill(); + this.drawer.useHiddenCtx = false; + } + this.shouldSweep = true; + } /** * Empties the canvas and stops all animations. */ public clear(): void { + this.maze = null; + this.shouldSweep = false; + this.lastEvent = null; + this.drawer.mazeEvent = null; this.stopMazeAnimation(); this.drawer.fillWithWall(); } + public zoomTo(zoomLevel: number): void { + this.drawer.zoomTo(zoomLevel); + } + /** * Stops all maze animations. */ @@ -155,4 +230,31 @@ export class MazeController { this.mazeAnimation?.cancel(); await promise; } + + private randomStartEnd(): [MazeCell, MazeCell] { + if (this.maze === null) throw new Error("Maze is null"); + return match(Math.random() < 0.5) + .returnType<[MazeCell, MazeCell]>() + .with(true, () => { + // Horizontal + const start: MazeCell = + this.maze![Math.floor(Math.random() * this.dimensions.rows)][0]; + const end: MazeCell = + this.maze![Math.floor(Math.random() * this.dimensions.rows)][ + this.dimensions.cols - 1 + ]; + return [start, end]; + }) + .with(false, () => { + // Vertical + const start: MazeCell = + this.maze![0][Math.floor(Math.random() * this.dimensions.cols)]; + const end: MazeCell = + this.maze![this.dimensions.rows - 1][ + Math.floor(Math.random() * this.dimensions.cols) + ]; + return [start, end]; + }) + .exhaustive(); + } } diff --git a/src/lib/MazeDrawer.ts b/src/lib/MazeDrawer.ts index e56cc73..c10cafa 100644 --- a/src/lib/MazeDrawer.ts +++ b/src/lib/MazeDrawer.ts @@ -4,12 +4,17 @@ import { type MazeCell } from "./MazeCell"; import { RectSize, type Coord, type GridSize, type Idx2d } from "./twoDimens"; import { clamp, easeOutQuad, type Direction } from "./utils"; import { AnimationPromise } from "./AnimationPromise"; +import { MazeDimensions, MazeEvent } from "./MazeController"; /** Colors used by the renderer. */ const COLOR = { empty: colors.blue[500], partial: colors.blue[300], solid: colors.blue[50], + path: colors.slate[700], + partialPath: "#33415566" /* colors.slate[700] / 40 */, + start: colors.amber[500], + end: colors.fuchsia[500], } as const; export default class MazeDrawer { @@ -18,7 +23,7 @@ export default class MazeDrawer { zoomLevel: [0.25, 0.5, 1], } as const; public static readonly DEFAULT_VALUES = { - cellWallRatio: 1.5, + cellWallRatio: 4, zoomLevel: 1, } as const; @@ -28,11 +33,18 @@ export default class MazeDrawer { /** Whether to use the hidden canvas rendering context. */ public useHiddenCtx: boolean = false; + /** Ratio of cell width to vertical wall width. */ + public cellWallRatio: number = MazeDrawer.DEFAULT_VALUES.cellWallRatio; + + public mazeEvent: MazeEvent | null = null; + public maze: MazeCell[][] | null = null; + public startEnd: [MazeCell, MazeCell] | null = null; + public path: readonly Readonly[] | null = null; + public isComplete: boolean = false; + public changeList: Readonly[] = []; /** Dimensions of the maze. */ private gridSize: Readonly; - /** Ratio of cell width to vertical wall width. */ - private cellWallRatio: number = MazeDrawer.DEFAULT_VALUES.cellWallRatio; /** Zoom level of the maze. */ private zoomLevel: number = MazeDrawer.DEFAULT_VALUES.zoomLevel; @@ -71,7 +83,7 @@ export default class MazeDrawer { }) .otherwise((ctx) => ctx); this.gridSize = initialGridSize; - this.resize(this.gridSize); + this.resize({ ...this.gridSize, cellWallRatio: this.cellWallRatio }); } ///////////////////////////////////// @@ -134,9 +146,10 @@ export default class MazeDrawer { ///////////////////////////////////// /** Updates the renderer and canvas with the new size. */ - public resize(size: Readonly): void { + public resize(dims: Readonly): void { this.animateFloodFill(null); - this.gridSize = size; + this.gridSize = dims; + this.cellWallRatio = dims.cellWallRatio; this.updateCanvasSize(); this.setContainerSize({ width: this.width, height: this.height }); this.visibleCtx.canvas.style.width = `${this.width}px`; @@ -144,17 +157,21 @@ export default class MazeDrawer { this.fillWithWall(); } - public zoomTo( - zoomLevel: number, - maze: readonly Readonly[][] | null, - ): void { + public zoomTo(zoomLevel: number): void { this.zoomLevel = zoomLevel; this.updateCanvasSize(); - if (maze !== null) this.drawIncompleteMaze(maze); + const redraw = () => { + this.ctx.fillStyle = COLOR.empty; + this.ctx.fillRect(0, 0, this.width, this.height); + this.isComplete = true; + this.draw(); + this.isComplete = false; + }; + redraw(); this.setContainerSize({ width: this.width, height: this.height }); this.visibleCtx.canvas.style.width = `${this.width}px`; this.visibleCtx.canvas.style.height = `${this.height}px`; - if (maze !== null) this.drawIncompleteMaze(maze); + redraw(); } /** Returns a promise that resolves when the animations are done. */ @@ -212,16 +229,49 @@ export default class MazeDrawer { }); } + public draw(): void { + if (this.maze === null) { + this.ctx.fillStyle = COLOR.empty; + this.ctx.fillRect(0, 0, this.width, this.height); + return; + } + + if (this.mazeEvent === "generate") { + if (this.isComplete) { + this.drawMaze(); + } else { + this.drawModifiedCells(); + } + return; + } + + if (this.mazeEvent === "solve") { + if (this.isComplete) { + this.drawMaze(); + this.drawStartEnd(); + this.drawPath(false); + } else { + this.drawModifiedCells(); + this.drawStartEnd(); + this.drawPath(true); + } + return; + } + + throw new Error("Invalid maze event"); + } + /** * Draw only the modified cells and their walls. * * Comparable to a dirty rect update. */ - public drawModifiedCells(modifiedCells: readonly Readonly[]): void { - for (const cell of modifiedCells) { - this.drawCell(cell); + private drawModifiedCells(): void { + for (const cell of this.changeList) { + this.fillCell(cell); this.drawCellWalls(cell); } + this.changeList = []; } /** @@ -230,7 +280,8 @@ export default class MazeDrawer { * - `this.maze` must not be null. * - Clears the canvas and draws the maze from scratch. */ - public drawFinishedMaze(maze: readonly Readonly[][]): void { + private drawFinishedMaze(): void { + if (this.maze === null) throw new Error(); // Fill the canvas with empty color const ctx = this.ctx; this.ctx.fillStyle = COLOR.empty; @@ -240,20 +291,20 @@ export default class MazeDrawer { ctx.fillStyle = COLOR.solid; for (let row = 0; row < this.gridSize.rows; ++row) { for (let col = 0; col < this.gridSize.cols; ++col) { - const cell: Readonly = maze[row][col]; - this.drawCell(cell); + const cell: Readonly = this.maze[row][col]; + this.fillCell(cell); // Clear the right wall if connected if ( col !== this.gridSize.cols - 1 && // Can't call `includes` with `readonly` for some reason - cell.connections.includes(maze[row][col + 1] as MazeCell) + cell.connections.includes(this.maze[row][col + 1] as MazeCell) ) { this.drawWall(cell, "right"); } // Clear the bottom wall if connected if ( row !== this.gridSize.rows - 1 && - cell.connections.includes(maze[row + 1][col] as MazeCell) + cell.connections.includes(this.maze[row + 1][col] as MazeCell) ) { this.drawWall(cell, "down"); } @@ -264,7 +315,8 @@ export default class MazeDrawer { /** * Draws the maze assuming the maze is incomplete. */ - public drawIncompleteMaze(maze: readonly Readonly[][]): void { + private drawMaze(): void { + if (this.maze === null) throw new Error(); // Fill the canvas with empty color const ctx = this.ctx; ctx.fillStyle = COLOR.empty; @@ -273,34 +325,69 @@ export default class MazeDrawer { // Draw from top to bottom, left to right for (let row = 0; row < this.gridSize.rows; ++row) { for (let col = 0; col < this.gridSize.cols; ++col) { - const cell: Readonly = maze[row][col]; - this.drawCell(cell); + const cell: Readonly = this.maze[row][col]; + this.fillCell(cell); // Draw right wall - ctx.fillStyle = this.determineWallColor(cell, maze[row][col + 1]); - this.drawWall(cell, "right"); + if (col !== this.gridSize.cols - 1) { + ctx.fillStyle = this.determineWallColor( + cell, + this.maze[row][col + 1], + ); + this.drawWall(cell, "right"); + } // Draw bottom wall - ctx.fillStyle = this.determineWallColor(cell, maze[row + 1][col]); - this.drawWall(cell, "down"); + if (row !== this.gridSize.rows - 1) { + ctx.fillStyle = this.determineWallColor( + cell, + this.maze[row + 1][col], + ); + this.drawWall(cell, "down"); + } } } } - ///////////////////////////////////// - // Private Methods - ///////////////////////////////////// + private drawPath(isPartial: boolean): void { + if (this.path === null || this.path.length <= 1) return; + const halfCellSize: number = this.cellSize / 2; - /** Pauses all animations. */ - private pauseAnimations(): void { - this.sweepAnimations.forEach((animation) => { - animation.pause(); - }); + const calcPathCoord = (pathCell: Readonly): Coord => { + const topLeft: Coord = this.calcCellTopLeft(pathCell); + return { + x: topLeft.x + halfCellSize, + y: topLeft.y + halfCellSize, + }; + }; + + this.ctx.beginPath(); + let coord: Coord = calcPathCoord(this.path[0]); + this.ctx.moveTo(coord.x, coord.y); + coord = calcPathCoord(this.path[1]); + this.ctx.lineTo(coord.x, coord.y); + this.ctx.strokeStyle = COLOR.path; + this.ctx.stroke(); + + this.ctx.beginPath(); + for (let i = 0; i < this.path.length; ++i) { + coord = calcPathCoord(this.path[i]); + if (i === 0) { + this.ctx.moveTo(coord.x, coord.y); + } else { + this.ctx.lineTo(coord.x, coord.y); + } + } + this.ctx.strokeStyle = isPartial ? COLOR.partialPath : COLOR.path; + this.ctx.lineWidth = + this.cellWallRatio < 1 + ? Math.ceil(this.cellSize * 0.8) + : Math.max(1, Math.round(this.cellSize * 0.2)); + this.ctx.stroke(); } - /** Unpauses all animations. */ - private unpauseAnimations(): void { - this.sweepAnimations.forEach((animation) => { - animation.unpause(); - }); + private drawStartEnd(): void { + if (this.startEnd === null) throw Error(); + this.drawStartEndCell(this.startEnd[0], true); + this.drawStartEndCell(this.startEnd[1], false); } /** @@ -384,13 +471,19 @@ export default class MazeDrawer { /** * Draws the given cell. */ - private drawCell(cell: Readonly): void { + private fillCell( + cell: Readonly, + color: string | null = null, + ): void { const topLeft: Coord = this.calcCellTopLeft(cell.idx2d); - this.ctx.fillStyle = match(cell.state) - .with("empty", () => COLOR.empty) - .with("partial", () => COLOR.partial) - .with("solid", () => COLOR.solid) - .exhaustive(); + this.ctx.fillStyle = + color === null + ? match(cell.state) + .with("empty", () => COLOR.empty) + .with("partial", () => COLOR.partial) + .with("solid", () => COLOR.solid) + .exhaustive() + : color; this.ctx.fillRect(topLeft.x, topLeft.y, this.cellSize, this.cellSize); } @@ -426,6 +519,21 @@ export default class MazeDrawer { .exhaustive(); } + private drawStartEndCell(cell: Readonly, isStart: boolean): void { + const center: Coord = this.calcCellCenter(cell.idx2d); + const width: number = Math.max( + this.cellSize, + Math.round(this.fullSize * 0.75), + ); + this.ctx.fillStyle = isStart ? COLOR.start : COLOR.end; + this.ctx.fillRect( + center.x - Math.round(width / 2), + center.y - Math.round(width / 2), + width, + width, + ); + } + private updateCanvasSize(): void { const cellSize: number = this.cellSize; const wallSize: number = this.wallSize; @@ -440,12 +548,22 @@ export default class MazeDrawer { neighbor: Readonly, ): string { if (!cell.connections.includes(neighbor as MazeCell)) return COLOR.empty; - return match([cell.state, neighbor.state]) - .when( - (states) => states.includes("partial"), - () => COLOR.partial, - ) - .otherwise(() => COLOR.solid); + + if (this.mazeEvent === "solve") { + return match([cell.state, neighbor.state]) + .when( + (states) => states.includes("solid"), + () => COLOR.solid, + ) + .otherwise(() => COLOR.partial); + } else { + return match([cell.state, neighbor.state]) + .when( + (states) => states.includes("partial"), + () => COLOR.partial, + ) + .otherwise(() => COLOR.solid); + } } /** @@ -479,4 +597,14 @@ export default class MazeDrawer { y: fullSize * row + wallSize, }; } + + private calcCellCenter({ row, col }: Idx2d): Coord { + const halfCellSize: number = Math.round(this.cellSize / 2); + const wallSize: number = this.wallSize; + const fullSize: number = this.cellSize + wallSize; + return { + x: fullSize * col + wallSize + halfCellSize, + y: fullSize * row + wallSize + halfCellSize, + }; + } } diff --git a/src/lib/algorithms/Algorithm.ts b/src/lib/algorithms/Algorithm.ts new file mode 100644 index 0000000..d8acb3c --- /dev/null +++ b/src/lib/algorithms/Algorithm.ts @@ -0,0 +1,56 @@ +import { MazeCell } from "@/lib/MazeCell"; + +export abstract class Algorithm { + private _finished: boolean = false; + private initialized: boolean = false; + + /** + * @param maze - Cells that make up the maze. + */ + constructor(public maze: MazeCell[][]) {} + + public get finished(): boolean { + return this._finished; + } + + /** + * Takes a single step in the algorithm. + * @returns The cells that were modified in this step. + */ + public step(): Readonly[] { + if (this._finished) return []; + // Initialize here instead of constructor so the animation makes sense. + // I.e., you don't expect there to be a change before the first step. + if (!this.initialized) { + this.initialized = true; + return this.initialize(); + } + + const [isFinished, modifiedCells] = this._step(); + + this._finished = isFinished; + return modifiedCells; + } + + /** + * Generates the entire maze. + */ + public finish(): void { + while (!this._finished) { + this.step(); + } + } + + /** + * Algorithm-specific step in the algorithm. + * @returns A tuple of whether the algorithm is finished and the cells that + * were modified in this step. + */ + protected abstract _step(): [boolean, Readonly[]]; + + /** + * Algorithm-specific initialization. + * @returns The cells that were modified during initialization. + */ + protected abstract initialize(): Readonly[]; +} diff --git a/src/lib/algorithms/generating/MazeGenerator.ts b/src/lib/algorithms/generating/MazeGenerator.ts index 6bf1839..bc802ef 100644 --- a/src/lib/algorithms/generating/MazeGenerator.ts +++ b/src/lib/algorithms/generating/MazeGenerator.ts @@ -1,73 +1,25 @@ import { MazeCell } from "@/lib/MazeCell"; import { GridSize } from "@/lib/twoDimens"; +import { Algorithm } from "../Algorithm"; -export abstract class MazeGenerator { - /** Cells that make up the maze. */ - maze: MazeCell[][]; - private _finished: boolean = false; - private initialized: boolean = false; - +export abstract class MazeGenerator extends Algorithm { constructor({ rows, cols }: GridSize) { - this.maze = Array.from({ length: rows }, (_, row) => + const maze: MazeCell[][] = Array.from({ length: rows }, (_, row) => Array.from({ length: cols }, (_, col) => new MazeCell({ row, col })), ); for (let row = 0; row < rows; ++row) { for (let col = 0; col < cols; ++col) { - const cell: MazeCell = this.maze[row][col]; + const cell: MazeCell = maze[row][col]; // Add right neighbor if not in last column. if (col < cols - 1) { - cell.addNeighbor(this.maze[row][col + 1]); + cell.addNeighbor(maze[row][col + 1]); } // Add down neighbor if not in last row. if (row < rows - 1) { - cell.addNeighbor(this.maze[row + 1][col]); + cell.addNeighbor(maze[row + 1][col]); } } } + super(maze); } - - /** - * Takes a single step in the algorithm. - * @returns The cells that were modified in this step. - */ - step(): Readonly[] { - if (this._finished) return []; - // Initialize here instead of constructor so the animation makes sense. - // I.e., you don't expect there to be a change before the first step. - if (!this.initialized) { - this.initialized = true; - return this.initialize(); - } - - const [isFinished, modifiedCells] = this._step(); - - this._finished = isFinished; - return modifiedCells; - } - - /** - * Generates the entire maze. - */ - finish(): void { - while (!this._finished) { - this.step(); - } - } - - get finished(): boolean { - return this._finished; - } - - /** - * Algorithm-specific step in the algorithm. - * @returns A tuple of whether the algorithm is finished and the cells that - * were modified in this step. - */ - protected abstract _step(): [boolean, Readonly[]]; - - /** - * Algorithm-specific initialization. - * @returns The cells that were modified during initialization. - */ - protected abstract initialize(): Readonly[]; } diff --git a/src/lib/algorithms/solving/MazeSolver.ts b/src/lib/algorithms/solving/MazeSolver.ts new file mode 100644 index 0000000..0aa72c2 --- /dev/null +++ b/src/lib/algorithms/solving/MazeSolver.ts @@ -0,0 +1,23 @@ +import { MazeCell } from "@/lib/MazeCell"; +import { Algorithm } from "../Algorithm"; + +export abstract class MazeSolver extends Algorithm { + protected _path: MazeCell[] = []; + + constructor( + maze: MazeCell[][], + protected start: MazeCell, + protected end: MazeCell, + ) { + maze.forEach((row) => + row.forEach((cell) => { + cell.state = "solid"; + }), + ); + super(maze); + } + + public get path(): readonly Readonly[] { + return this._path; + } +} diff --git a/src/lib/algorithms/solving/Tremaux.ts b/src/lib/algorithms/solving/Tremaux.ts new file mode 100644 index 0000000..54e5fa4 --- /dev/null +++ b/src/lib/algorithms/solving/Tremaux.ts @@ -0,0 +1,35 @@ +import { MazeCell } from "@/lib/MazeCell"; +import { MazeSolver } from "./MazeSolver"; +import { randomFromArray } from "@/lib/utils"; + +export class Tremaux extends MazeSolver { + protected _step(): [boolean, Readonly[]] { + const current: MazeCell = this._path[this._path.length - 1]; + + const unvisitedConnections: MazeCell[] = current.connections.filter( + (connection) => connection.state === "solid", + ); + + // Nowhere to go, go back + if (unvisitedConnections.length === 0) { + this._path.pop(); + const changes: MazeCell[] = [current]; + if (this._path.length > 0) { + changes.push(this._path[this._path.length - 1]); + } + return [false, changes]; + } + + // Choose a random unvisited connection + const next: MazeCell = randomFromArray(unvisitedConnections); + next.state = "partial"; + this._path.push(next); + return [next === this.end, [current, next]]; + } + + protected initialize(): Readonly[] { + this._path = [this.start]; + this.start.state = "partial"; + return [this.start]; + } +}