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};