| 1 | // This canvas is based on the maze generation algo in Tanks. This was |
| 2 | // originally written in C++ as a single function in 2018, and was ported to TS |
| 3 | // by Chloe in 2025 for the cotyledon canvas. |
| 4 | // |
| 5 | // The main difference is that this version is a visualization, rather than the |
| 6 | // practical function. Instead of taking a millisecond, only 5 steps are |
| 7 | // performed per second, visualizing the whole ordeal. It also isn't a playable |
| 8 | // game, obviously. |
| 9 | // |
| 10 | // Ported with love because I care about my old self |
| 11 | // She deserves the world, but instead gave it to me. |
| 12 | (globalThis as any).canvas_2018 = function(canvas: HTMLCanvasElement) { |
| 13 | const isStandalone = canvas.getAttribute("data-standalone") === "true"; |
| 14 | if (isStandalone) { |
| 15 | canvas.style.backgroundColor = "#27201E"; |
| 16 | } |
| 17 | interface Cell { |
| 18 | down: boolean; |
| 19 | right: boolean; |
| 20 | visited: boolean; |
| 21 | |
| 22 | cell_flash: number; |
| 23 | down_flash: number; |
| 24 | right_flash: number; |
| 25 | } |
| 26 | interface Pos { |
| 27 | x: number; |
| 28 | y: number; |
| 29 | /** Where the wall is relative to x, y. */ |
| 30 | dir: "left" | "right" | "up" | "down"; |
| 31 | } |
| 32 | interface Maze { |
| 33 | grid: Grid; |
| 34 | cursor: { x: number; y: number }; |
| 35 | lastTick: number; |
| 36 | /* Pixels */ |
| 37 | transform: number; |
| 38 | newCellsToVisit: Pos[]; |
| 39 | randomWallBag: Cell[]; |
| 40 | randomWallTarget: number; |
| 41 | renderOffset: { x: number; y: number }; |
| 42 | done: boolean; |
| 43 | } |
| 44 | const hex = (color: number[]) => "#" + color.map((c) => c.toString(16).padStart(2, "0")).join(""); |
| 45 | let cellSize: number; |
| 46 | let borderThickness: number; |
| 47 | const cellFlashModifier = isStandalone ? 0.4 : 0.2; |
| 48 | const color = isStandalone ? "#170d0b" : "#231C1A"; |
| 49 | const bg = [0x27, 0x20, 0x1E]; |
| 50 | const wallFlashColor = [0xFF, 0xA8, 0x7A]; |
| 51 | const cellFlashColor = "#FFA87A"; |
| 52 | const updateTime = 1000 / 7; |
| 53 | const randomWallBreakInterval = [6, 12]; // every 10 to 18 walls. |
| 54 | function randomBetween(min: number, max: number) { |
| 55 | return Math.round( |
| 56 | Math.random() * (max - min), |
| 57 | ) + min; |
| 58 | } |
| 59 | function randomOf<T>(array: T[]): T { |
| 60 | return array[randomBetween(0, array.length - 1)]; |
| 61 | } |
| 62 | function randomWallTarget() { |
| 63 | return randomBetween( |
| 64 | randomWallBreakInterval[0], |
| 65 | randomWallBreakInterval[1], |
| 66 | ); |
| 67 | } |
| 68 | |
| 69 | // Originally, this used a 2-dimensional array. However, I wanted to make sure |
| 70 | // that the grid could be infinitely sized. This grid constructs new cells on |
| 71 | // demand, as needed. |
| 72 | class Grid { |
| 73 | cells = new Map<number, Cell>(); |
| 74 | cell({ x, y }: { x: number; y: number }) { |
| 75 | const k = ((x | 0) << 16) + (y | 0); |
| 76 | const { cells } = this; |
| 77 | let existing = this.cells.get(k); |
| 78 | if (!existing) { |
| 79 | existing = { |
| 80 | cell_flash: 0, |
| 81 | down: true, |
| 82 | down_flash: 0, |
| 83 | right: true, |
| 84 | right_flash: 0, |
| 85 | visited: false, |
| 86 | }; |
| 87 | cells.set(k, existing); |
| 88 | } |
| 89 | return existing; |
| 90 | } |
| 91 | forAll( |
| 92 | renderOffset: { x: number; y: number }, |
| 93 | width: number, |
| 94 | height: number, |
| 95 | cb: (cell: Cell, pos: { x: number; y: number }) => void, |
| 96 | ) { |
| 97 | const { x: offsetX, y: offsetY } = renderOffset; |
| 98 | const startX = Math.floor(-offsetX / cellSize); |
| 99 | const startY = Math.floor(-offsetY / cellSize); |
| 100 | const endX = Math.ceil((width - offsetX) / cellSize); |
| 101 | const endY = Math.ceil((height - offsetY) / cellSize); |
| 102 | for (let x = startX; x <= endX; x++) { |
| 103 | for (let y = startY; y <= endY; y++) { |
| 104 | const cellX = offsetX + x * cellSize; |
| 105 | const cellY = offsetY + y * cellSize; |
| 106 | cb(this.cell({ x, y }), { x: cellX, y: cellY }); |
| 107 | } |
| 108 | } |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | const ctx = canvas.getContext("2d")!; |
| 113 | if (!ctx) { |
| 114 | console.error("Could not get canvas context"); |
| 115 | return () => {}; |
| 116 | } |
| 117 | |
| 118 | let width: number, height: number; |
| 119 | const updateDimensions = () => { |
| 120 | width = canvas.width = canvas.offsetWidth; |
| 121 | height = canvas.height = canvas.offsetHeight; |
| 122 | cellSize = 100; |
| 123 | borderThickness = 8; |
| 124 | }; |
| 125 | updateDimensions(); |
| 126 | |
| 127 | setTimeout(() => { |
| 128 | updateDimensions(); |
| 129 | }, 10); |
| 130 | |
| 131 | let maze = initMaze(); |
| 132 | let nextMaze: Maze | null = null; |
| 133 | let completeFade = 0; |
| 134 | function initMaze(): Maze { |
| 135 | return { |
| 136 | grid: new Grid(), |
| 137 | transform: 0, |
| 138 | cursor: { |
| 139 | x: randomBetween(0, Math.ceil(width / cellSize)), |
| 140 | y: randomBetween(0, Math.ceil(height / cellSize)), |
| 141 | }, |
| 142 | lastTick: performance.now(), |
| 143 | randomWallBag: [], |
| 144 | randomWallTarget: randomWallTarget(), |
| 145 | newCellsToVisit: [], |
| 146 | renderOffset: { x: 0, y: 0 }, |
| 147 | done: false, |
| 148 | }; |
| 149 | } |
| 150 | |
| 151 | function isOnScreen(maze: Maze, x: number, y: number) { |
| 152 | const { x: offsetX, y: offsetY } = maze.renderOffset; |
| 153 | const cellX = offsetX + x * cellSize; |
| 154 | const cellY = offsetY + y * cellSize; |
| 155 | return ( |
| 156 | cellX + cellSize > 0 |
| 157 | && cellX < width |
| 158 | && cellY + cellSize > 0 |
| 159 | && cellY < height |
| 160 | ); |
| 161 | } |
| 162 | |
| 163 | function tick(maze: Maze, other?: Maze) { |
| 164 | if (maze.done) return; |
| 165 | |
| 166 | // The original maze algorithm broke down 4%-8% of random right facing |
| 167 | // walls, and 4%-8% of down facing walls. It did this at the end. |
| 168 | // To make this visual more interesting, two random walls will be broken |
| 169 | // down every 12-25 cell visits. This way, the main trail is always running. |
| 170 | if (maze.randomWallBag.length > maze.randomWallTarget) { |
| 171 | const down: Cell = randomOf(maze.randomWallBag); |
| 172 | const right: Cell = randomOf(maze.randomWallBag); |
| 173 | maze.randomWallBag.forEach((cell) => cell.cell_flash = Math.min(cell.cell_flash + 0.2, 1)); |
| 174 | down.cell_flash = 1; |
| 175 | down.down = false; |
| 176 | down.down_flash = 1; |
| 177 | right.cell_flash = 1; |
| 178 | right.right = false; |
| 179 | right.right_flash = 1; |
| 180 | maze.randomWallBag = []; |
| 181 | maze.randomWallTarget = randomWallTarget(); |
| 182 | return; |
| 183 | } |
| 184 | |
| 185 | // The main algorithm was simple: Have a cursor position, and move it in a |
| 186 | // random direction that it had not seen before. Once it had run out of |
| 187 | // options, branch off of a previous location. Only visit each cell once. |
| 188 | // |
| 189 | // In this visualization, cells that are too far offscreen are softly |
| 190 | // treated as "visited", which is how the simulation always stays in frame. |
| 191 | const current = maze.grid.cell(maze.cursor); |
| 192 | current.visited = true; |
| 193 | current.cell_flash = 1; |
| 194 | maze.randomWallBag.push(current); |
| 195 | const adjacent = ([ |
| 196 | { x: maze.cursor.x + 1, y: maze.cursor.y, dir: "left" }, |
| 197 | { x: maze.cursor.x - 1, y: maze.cursor.y, dir: "right" }, |
| 198 | { x: maze.cursor.x, y: maze.cursor.y + 1, dir: "up" }, |
| 199 | { x: maze.cursor.x, y: maze.cursor.y - 1, dir: "down" }, |
| 200 | ] as Pos[]).filter((pos) => |
| 201 | isOnScreen(maze, pos.x, pos.y) |
| 202 | && maze.grid.cell(pos).visited === false |
| 203 | ); |
| 204 | if (adjacent.length === 0) { |
| 205 | // move cursor to a random cell that has not been visited. |
| 206 | const cells = maze.newCellsToVisit.filter((pos) => |
| 207 | isOnScreen(maze, pos.x, pos.y) |
| 208 | && maze.grid.cell(pos).visited === false |
| 209 | ); |
| 210 | if (cells.length === 0) { |
| 211 | maze.done = true; |
| 212 | return; |
| 213 | } |
| 214 | const continuePos = randomOf(cells); |
| 215 | breakWall(maze, continuePos, other); |
| 216 | maze.cursor = { x: continuePos.x, y: continuePos.y }; |
| 217 | return; |
| 218 | } |
| 219 | |
| 220 | // break a random wall |
| 221 | const toBreak = randomOf(adjacent); |
| 222 | breakWall(maze, toBreak, other); |
| 223 | maze.cursor = { x: toBreak.x, y: toBreak.y }; |
| 224 | |
| 225 | // add the other directions to the new cells to visit. |
| 226 | maze.newCellsToVisit.push( |
| 227 | ...adjacent.filter((pos) => pos.dir !== toBreak.dir), |
| 228 | ); |
| 229 | } |
| 230 | |
| 231 | function breakWall(maze: Maze, pos: Pos, other?: Maze) { |
| 232 | if (pos.dir === "right") { |
| 233 | const cell = maze.grid.cell(pos); |
| 234 | cell.right = false; |
| 235 | cell.right_flash = 1; |
| 236 | if (other) cell.right = false; |
| 237 | } else if (pos.dir === "down") { |
| 238 | const cell = maze.grid.cell(pos); |
| 239 | cell.down = false; |
| 240 | cell.down_flash = 1; |
| 241 | if (other) cell.down = false; |
| 242 | } else if (pos.dir === "left") { |
| 243 | const cell = maze.grid.cell({ x: pos.x - 1, y: pos.y }); |
| 244 | cell.right = false; |
| 245 | cell.right_flash = 1; |
| 246 | if (other) cell.right = false; |
| 247 | } else if (pos.dir === "up") { |
| 248 | const cell = maze.grid.cell({ x: pos.x, y: pos.y - 1 }); |
| 249 | cell.down = false; |
| 250 | cell.down_flash = 1; |
| 251 | if (other) cell.down = false; |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | function renderOffset(maze: Maze) { |
| 256 | return { x: maze.transform, y: maze.transform }; |
| 257 | } |
| 258 | |
| 259 | let animationFrameId: number; |
| 260 | let last = performance.now(); |
| 261 | let dt: number = 0; |
| 262 | |
| 263 | function renderMazeBorders(maze: Maze, opacity: number) { |
| 264 | ctx.globalAlpha = opacity; |
| 265 | maze.grid.forAll( |
| 266 | maze.renderOffset, |
| 267 | width, |
| 268 | height, |
| 269 | (cell, { x: cellX, y: cellY }) => { |
| 270 | // Walls |
| 271 | if (cell.right) { |
| 272 | ctx.fillStyle = color; |
| 273 | ctx.fillRect( |
| 274 | cellX + cellSize - borderThickness / 2, |
| 275 | cellY - borderThickness / 2, |
| 276 | borderThickness, |
| 277 | cellSize + borderThickness, |
| 278 | ); |
| 279 | } |
| 280 | if (cell.down) { |
| 281 | ctx.fillStyle = color; |
| 282 | ctx.fillRect( |
| 283 | cellX - borderThickness / 2, |
| 284 | cellY + cellSize - borderThickness / 2, |
| 285 | cellSize + borderThickness, |
| 286 | borderThickness, |
| 287 | ); |
| 288 | } |
| 289 | }, |
| 290 | ); |
| 291 | ctx.globalAlpha = 1; |
| 292 | } |
| 293 | |
| 294 | function renderCellFlash(maze: Maze) { |
| 295 | maze.grid.forAll( |
| 296 | maze.renderOffset, |
| 297 | width, |
| 298 | height, |
| 299 | (cell, { x: cellX, y: cellY }) => { |
| 300 | // Cell flash to show visiting path. |
| 301 | if (cell.cell_flash > 0) { |
| 302 | cell.cell_flash = Math.max(0, cell.cell_flash - dt / 1000); |
| 303 | ctx.fillStyle = cellFlashColor; |
| 304 | ctx.globalAlpha = cell.cell_flash * cellFlashModifier; |
| 305 | ctx.fillRect(cellX, cellY, cellSize, cellSize); |
| 306 | ctx.globalAlpha = 1; |
| 307 | } |
| 308 | }, |
| 309 | ); |
| 310 | } |
| 311 | |
| 312 | function renderBorderFlash(maze: Maze) { |
| 313 | maze.grid.forAll( |
| 314 | maze.renderOffset, |
| 315 | width, |
| 316 | height, |
| 317 | (cell, { x: cellX, y: cellY }) => { |
| 318 | if (cell.right_flash == 0 && cell.down_flash == 0) { |
| 319 | return; |
| 320 | } |
| 321 | |
| 322 | // Walls |
| 323 | const cellFlash = cell.cell_flash * cellFlashModifier; |
| 324 | if (cell.right_flash > 0) { |
| 325 | cell.right_flash = Math.max(0, cell.right_flash - dt / 500); |
| 326 | ctx.fillStyle = interpolateColor( |
| 327 | bg, |
| 328 | wallFlashColor, |
| 329 | Math.max(cell.right_flash, cellFlash), |
| 330 | ); |
| 331 | if (cellFlash > cell.right_flash) { |
| 332 | ctx.globalAlpha = cell.right_flash / cellFlash; |
| 333 | } |
| 334 | ctx.fillRect( |
| 335 | cellX + cellSize - borderThickness / 2, |
| 336 | cellY + borderThickness / 2, |
| 337 | borderThickness, |
| 338 | cellSize - borderThickness, |
| 339 | ); |
| 340 | ctx.globalAlpha = 1; |
| 341 | } |
| 342 | if (cell.down_flash > 0) { |
| 343 | if (cellFlash > cell.down_flash) { |
| 344 | ctx.globalAlpha = cell.down_flash / cellFlash; |
| 345 | } |
| 346 | cell.down_flash = Math.max(0, cell.down_flash - dt / 500); |
| 347 | ctx.fillStyle = interpolateColor( |
| 348 | bg, |
| 349 | wallFlashColor, |
| 350 | Math.max(cell.down_flash, cellFlash), |
| 351 | ); |
| 352 | ctx.fillRect( |
| 353 | cellX + borderThickness / 2, |
| 354 | cellY + cellSize - borderThickness / 2, |
| 355 | cellSize - borderThickness, |
| 356 | borderThickness, |
| 357 | ); |
| 358 | ctx.globalAlpha = 1; |
| 359 | } |
| 360 | }, |
| 361 | ); |
| 362 | } |
| 363 | |
| 364 | function render() { |
| 365 | const now = performance.now(); |
| 366 | dt = now - last; |
| 367 | maze.transform += dt * 0.005; |
| 368 | maze.renderOffset = renderOffset(maze); |
| 369 | if (!maze.done) { |
| 370 | if (now - maze.lastTick >= updateTime) { |
| 371 | tick(maze); |
| 372 | maze.lastTick = now; |
| 373 | |
| 374 | if (maze.done) { |
| 375 | nextMaze = initMaze(); |
| 376 | nextMaze.transform = (maze.transform % cellSize) - dt * 0.005; |
| 377 | nextMaze.lastTick = now; |
| 378 | completeFade = 0; |
| 379 | } |
| 380 | } |
| 381 | } |
| 382 | if (nextMaze) { |
| 383 | nextMaze.transform += dt * 0.005; |
| 384 | nextMaze.renderOffset = renderOffset(nextMaze); |
| 385 | if (!nextMaze.done && now - nextMaze.lastTick >= updateTime) { |
| 386 | tick(nextMaze, maze); |
| 387 | nextMaze.lastTick = now; |
| 388 | } |
| 389 | } |
| 390 | last = now; |
| 391 | |
| 392 | ctx.clearRect(0, 0, width, height); |
| 393 | |
| 394 | renderCellFlash(maze); |
| 395 | if (nextMaze) renderCellFlash(nextMaze); |
| 396 | |
| 397 | renderMazeBorders(maze, 1); |
| 398 | if (nextMaze) { |
| 399 | renderMazeBorders(nextMaze, completeFade); |
| 400 | completeFade += dt / 3000; |
| 401 | if (completeFade >= 1) { |
| 402 | maze = nextMaze; |
| 403 | nextMaze = null; |
| 404 | } |
| 405 | } |
| 406 | |
| 407 | renderBorderFlash(maze); |
| 408 | if (nextMaze) { |
| 409 | renderCellFlash(nextMaze); |
| 410 | renderBorderFlash(nextMaze); |
| 411 | } |
| 412 | |
| 413 | animationFrameId = requestAnimationFrame(render); |
| 414 | } |
| 415 | |
| 416 | function interpolateColor(start: number[], end: number[], t: number) { |
| 417 | return hex(start.map((s, i) => Math.round(s + (end[i] - s) * t))); |
| 418 | } |
| 419 | |
| 420 | window.addEventListener("resize", updateDimensions); |
| 421 | animationFrameId = requestAnimationFrame(render); |
| 422 | |
| 423 | // cleanup function |
| 424 | return () => { |
| 425 | window.removeEventListener("resize", updateDimensions); |
| 426 | cancelAnimationFrame(animationFrameId); |
| 427 | }; |
| 428 | }; |