Maze Generator: 9 Carve-and-Solve Scenes
In this maze generator set, mazes are carved, not drawn — 02 reads carve order from a random frontier's ripple bands, 06 watches the A-star open list reach
All nine boards share one 31×31 grid of 961 cells and a fixed seed, so rebuilding a board brings back the very same maze.
Watch Nine Mazes Carve, Merge and Solve Themselves in the Grid Below
01 mz-grid-dfs-carve-passage
02 mz-frontier-ripple-growth
03 mz-wall-shuffle-merge-islands
04 mz-braid-open-loop-deadend
05 mz-distance-bfs-flood-heat
06 mz-astar-open-set-greedy-trace
07 mz-left-hand-wall-follower
08 mz-solution-thermo-reveal-path
09 mz-hero-hedge-garden-scene
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 A Grid Maze That Digs Deeper First and Backs Out to Carve New Branches
- 02 A Maze Whose Frontier Ripples Outward as It Grows
- 03 A Maze Built From a Shuffled Wall List Where Islands Merge Into One
- 04 A Maze That Reopens Dead Ends to Leave Multiple Ways Through
- 05 A Maze Where a Spreading Wave Computes the Distance to Every Cell
- 06 A Path Search That Opens the Cell With the Smallest Estimated Distance to the Goal First
- 07 A Walk That Finds the Exit by Keeping One Hand on the Left Wall
- 08 A Board Where Distance Becomes Heat Color and the Answer Appears as a Valley
- 09 A Hedge-Garden Maze Seen From Above, With Fog From Center Distance and Shadows From Height and Sun Angle
Maze Generator: 9 Carve-and-Solve Boards From Wall Removal, Ripple Growth and Shortest Paths
The nine boards split the life of one maze into stages. Boards 01 to 03 build a maze in three different ways by taking walls out, 04 edits a finished maze by reopening its dead ends, 05 to 08 solve the result four ways, and 09 raises the answer into a single hedge-garden scene. Each board paints onto one 2D <canvas>, the stylesheet only sizes the stage, the buttons and the focus ring, and there is no @keyframes behind the motion: every move comes from a requestAnimationFrame script.
Four walls per cell, packed into four bits
Each cell owns one bit per wall, north 1, east 2, south 4 and west 8, so every cell starts at 15 (ALL_WALLS) and loses a bit whenever a wall comes out. knock clears the matching bit on the cell across the wall in the same call, which rules out a wall that is open from one side and shut from the other. Headings are numbered clockwise, 0 north, 1 east, 2 south, 3 west, so adding 1 to the current heading turns right and adding 3 turns left; the wall follower in 07 runs on exactly those two additions.
A fixed seed rebuilds the same maze
The only random source is makeRng(seed), a mixer over 32-bit integers, with a default seed of 464 that the auto demo bumps by one for each new board. None of the nine index.html files contains Math.random, Date.now, fetch or localStorage. That is why counts in this post, such as 96 dead ends or a 320-cell shortest route, come back unchanged once you open the zip and run it at seed 464.
What a two-second capture of each board recorded
Every board was filmed in a 480×300 window for 2 seconds at 24 frames, and motion was read from the 23 gaps between neighboring frames, so the moved-frame column counts out of 23.
| Board | Cumulative area | Mean intensity | Moved frames |
|---|---|---|---|
| 01 Depth-first carve | 5.2% | 105.4 | 23/23 |
| 02 Frontier ripples | 16.9% | 106.4 | 23/23 |
| 03 Island merging | 25.4% | 43.1 | 23/23 |
| 04 Reopened dead ends | 19.1% | 33.6 | 23/23 |
| 05 Distance flood heat map | 41.1% | 122.5 | 19/23 |
| 06 Estimated-distance search | 12.8% | 55.6 | 23/23 |
| 07 Left-hand walk | 4.7% | 70.1 | 23/23 |
| 08 Detour valley | 40.3% | 120.3 | 13/23 |
| 09 Hedge garden | 28.2% | 25.5 | 23/23 |
Boards 01 and 07 sit near 5% of the area because one step only repaints the rim of a 9px cell or two. Board 01 takes a step every 24ms and walks about 83 steps in the 2 seconds, backtracks included, and the pink of a stack cell fades by e^(−1/72), roughly 1.4%, each time the head digs one cell deeper. Board 07 steps every 16ms, about 125 steps in the window, yet its trail only loses 0.4% per step because every cell is multiplied by 0.996. Boards 05 and 08 fall short of 23 because each has a stretch where the screen holds still: once the wave has reached the last cell or the level has risen all the way, nothing moves until the next board starts.
Pages these boards suit
If you came looking for a classroom worksheet, the kind of thing people print as a grid maze for kids, board 01 is the base, and if you want the birth of a maze itself as a loading backdrop, the ripples of 02 fit better. Board 03 suits algorithm lessons and figures in technical posts, while 04 belongs wherever a puzzle needs several ways through, such as a children's activity page or an escape-room hint card. Board 05 works as a difficulty map, 06 as a route-guidance demo, 07 as a board-game rules explainer or a mini game during loading, 08 as the answer reveal, and 09 as the starting point for a hero section on a landing page. For a small maze game site of your own, pairing 01 to build the board with 07 or 08 to show the solution is a sensible first combination.
Why Does Every Maze Generator Algorithm Here Remove Walls Instead of Drawing Them?
A 31×31 grid has 2×31×30 = 1,860 inner wall candidates. Start with all of them standing, take out only walls between two cells that are not yet connected, and joining all 961 cells removes exactly 960 walls (cells minus one); at that moment every pair of cells is linked by one route only, which is what a perfect maze means. Boards 01, 02 and 03 choose their next wall in completely different ways, yet in measurement all three removed 960 walls and left the same 900 standing. Drawing walls onto an empty board would force a separate check for sealed-off rooms and accidental loops, while the removal approach replaces that check with a single condition: skip any wall whose two cells are already joined. So however different the methods look, the real difference narrows to one question, which wall to take out next. Board 04 breaks that condition on purpose to create loops.
01A Grid Maze That Digs Deeper First and Backs Out to Carve New Branches
From the cell on top of the stack it picks one uncarved neighbor with the seeded random source, removes the wall toward it and steps in, and when no neighbor is left it pops one cell and backs out. A 961-cell board finishes after 960 pushes and 961 pops, 1,921 steps whatever the seed, with stack cells fading from pink as e^(−k/72) at k cells below the head and freshly popped cells tinted sky blue in proportion to the backtrack run that left them. The auto demo resumes from a board already 640 steps in (stack depth 463), the Carve again button digs from step 0, and the render measured 5.2% cumulative area, 105.4 mean intensity and 23/23 moved frames.

<main class="stage">
<div class="hs is-demo" id="hs">
<canvas class="hs__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="A 31×31 maze that digs depth-first by removing walls and retraces its stack to open branches when stuck. Press a cell to carve again from there"></canvas>
<p class="hs__status" data-role="status" aria-live="off">Digging deeper</p>
<div class="hs__tools">
<button class="hs__btn" data-role="restart" type="button">Carve again</button>
<button class="hs__btn" data-role="pause" type="button" aria-pressed="false">Paused</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.hs {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.hs__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.hs__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.hs__btn:hover {
background: $subject-cream;
}
.hs__btn[aria-pressed="true"] {
background: $subject-mint;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.hs__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.hs__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.hs__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.hs__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hs {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.hs__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.hs__btn:hover {
background: #fff7e6;
}
.hs__btn[aria-pressed=true] {
background: #4cd4a6;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.hs__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.hs__btn {
font-size: 10px;
padding: 4px 8px;
}
.hs__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.hs__btn {
transition: none;
}
}(function () {
const root = document.getElementById("hs");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 바닥을 얹어 명도 차로 길이 읽힌다
const BG = [29, 26, 44];
const ROCK = [44, 39, 64];
const PAPER = [255, 248, 234];
const INK = [58, 48, 76];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 미로가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 격자 — 31×31 칸, 칸마다 벽 네 장을 비트 하나씩으로 든다
const GRID_N = 31;
const CELL_N = GRID_N * GRID_N;
const CS = 9;
const BOARD = GRID_N * CS;
let BX = W - 12 - BOARD;
const BY = (H - BOARD) / 2;
const ENTRY = 0;
const EXIT = CELL_N - 1;
// 방향 0 북 · 1 동 · 2 남 · 3 서 — 시계 방향 순서라 +1 이 오른쪽으로 돌기, +3 이 왼쪽으로 돌기
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
const BIT = [1, 2, 4, 8];
const ALL_WALLS = 15;
function neighbor(i, d) {
const x = (i % GRID_N) + DX[d];
const y = Math.floor(i / GRID_N) + DY[d];
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function isOpen(walls, i, d) {
return (walls[i] & BIT[d]) === 0 && neighbor(i, d) >= 0;
}
// 벽 한 장 걷기 — 양쪽 칸의 비트를 함께 지운다 (한쪽만 지우면 길이 일방통행이 된다)
function knock(walls, i, d) {
const n = neighbor(i, d);
walls[i] &= ~BIT[d];
walls[n] &= ~BIT[(d + 2) % 4];
return n;
}
function openCount(walls, i) {
let c = 0;
for (let d = 0; d < 4; d += 1) {
if (isOpen(walls, i, d)) {
c += 1;
}
}
return c;
}
function cellX(i) { return BX + (i % GRID_N) * CS; }
function cellY(i) { return BY + Math.floor(i / GRID_N) * CS; }
function cellAt(sx, sy) {
const x = Math.floor((sx - BX) / CS);
const y = Math.floor((sy - BY) / CS);
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function fillCell(i, c, a) {
ctx.fillStyle = rgba(c, a);
ctx.fillRect(cellX(i), cellY(i), CS, CS);
}
// 벽 비트를 선분으로 — 칸마다 동·남 벽만 긋고 맨 윗줄·맨 왼줄만 북·서 벽을 더 긋는다 (같은 벽을 두 번 긋지 않는다)
function strokeWalls(walls, color, lw) {
ctx.beginPath();
for (let i = 0; i < CELL_N; i += 1) {
const x = cellX(i);
const y = cellY(i);
const w = walls[i];
if ((w & 2) !== 0) {
ctx.moveTo(x + CS, y);
ctx.lineTo(x + CS, y + CS);
}
if ((w & 4) !== 0) {
ctx.moveTo(x, y + CS);
ctx.lineTo(x + CS, y + CS);
}
if (i < GRID_N && (w & 1) !== 0) {
ctx.moveTo(x, y);
ctx.lineTo(x + CS, y);
}
if (i % GRID_N === 0 && (w & 8) !== 0) {
ctx.moveTo(x, y);
ctx.lineTo(x, y + CS);
}
}
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineCap = 'square';
ctx.stroke();
}
// 입구(왼쪽 위)·출구(오른쪽 아래) 표시 — 바깥 벽 밖에 작은 삼각형
function drawGates() {
ctx.fillStyle = rgba(MINT, 0.95);
ctx.beginPath();
ctx.moveTo(BX - 9, BY + 1);
ctx.lineTo(BX - 2, BY + CS / 2);
ctx.lineTo(BX - 9, BY + CS - 1);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(PINK, 0.95);
ctx.beginPath();
ctx.moveTo(BX + BOARD + 2, BY + BOARD - CS + 1);
ctx.lineTo(BX + BOARD + 9, BY + BOARD - CS / 2);
ctx.lineTo(BX + BOARD + 2, BY + BOARD - 1);
ctx.closePath();
ctx.fill();
}
// 왼쪽 칸의 수치 — 이름표 한 줄 + 값 한 줄
function panelRow(row, label, value) {
const y = 104 + row * 34;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(PAPER, 0.62);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, 16, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 15px sans-serif';
ctx.fillText(value, 16, y + 18);
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
const CARVE_STEP_MS = 24;
// 스택 안에서 머리로부터 몇 칸 아래인가를 색으로 — 이 칸 수만큼 내려가면 색이 1/e 로 옅어진다.
// 스택에서 빠진 칸은 그때 연달아 Backtrack steps 수를 같은 눈금으로 하늘색에 섞는다
const BACKTRACK_TINT = 72;
const SEED = 464;
// 시연은 앞 640걸음을 미리 걸어 둔 상태에서 이어 판다 — 2초 안에 스택 색이 움직이는 모습이 보이게
const DEMO_WARM = 640;
const HOLD_MS = 1400;
const TXT_DIG = 'Digging deeper';
const TXT_BACK = 'Stuck, backing out';
const TXT_DONE = 'Done — every cell is joined by one path';
const TXT_PAUSE = 'Paused';
const restartBtn = root.querySelector('[data-role="restart"]');
const pauseBtn = root.querySelector('[data-role="pause"]');
const walls = new Uint8Array(CELL_N);
const visited = new Uint8Array(CELL_N);
const stack = new Int32Array(CELL_N);
const stackPos = new Int32Array(CELL_N);
// 이 칸이 스택에서 빠질 때 연달아 Backtrack steps 수
const popRun = new Int32Array(CELL_N);
let top = 0;
let steps = 0;
let carved = 0;
let backs = 0;
let backRun = 0;
let lastMove = 0;
let rnd = makeRng(SEED);
let pass = 0;
let paused = false;
let stepAcc = 0;
let holdLeft = 0;
function reset(start, seed) {
walls.fill(ALL_WALLS);
visited.fill(0);
stackPos.fill(-1);
popRun.fill(0);
rnd = makeRng(seed);
steps = 0;
carved = 0;
backs = 0;
backRun = 0;
lastMove = 0;
stepAcc = 0;
holdLeft = 0;
stack[0] = start;
stackPos[start] = 0;
top = 1;
visited[start] = 1;
}
// 한 Steps — 머리 칸에 안 판 이웃이 있으면 하나를 골라 그쪽 벽을 걷고 들어가고(0), 없으면 스택에서 하나 빼 되돌아간다(1). 끝나면 2
function carveStep() {
if (top === 0) {
return 2;
}
const cur = stack[top - 1];
const opts = [];
for (let d = 0; d < 4; d += 1) {
const n = neighbor(cur, d);
if (n >= 0 && visited[n] === 0) {
opts.push(d);
}
}
steps += 1;
if (opts.length > 0) {
const n = knock(walls, cur, opts[Math.floor(rnd() * opts.length)]);
visited[n] = 1;
stackPos[n] = top;
stack[top] = n;
top += 1;
carved += 1;
backRun = 0;
return 0;
}
top -= 1;
stackPos[cur] = -1;
backRun += 1;
popRun[cur] = backRun;
backs += 1;
return 1;
}
function report() {
if (top === 0) {
say(TXT_DONE);
}
else if (paused) {
say(TXT_PAUSE);
}
else {
say(lastMove === 1 ? TXT_BACK : TXT_DIG);
}
}
function warm(n) {
for (let k = 0; k < n && top > 0; k += 1) {
lastMove = carveStep();
}
}
function step() {
if (paused || top === 0) {
return;
}
stepAcc += FIXED_DT;
while (stepAcc >= CARVE_STEP_MS && top > 0) {
stepAcc -= CARVE_STEP_MS;
lastMove = carveStep();
}
report();
}
function settle() {
while (carveStep() !== 2) { /* 끝까지 */ }
report();
}
function demoTick(t) {
if (top > 0 || t < 0) {
return;
}
holdLeft += FIXED_DT;
if (holdLeft >= HOLD_MS) {
pass += 1;
reset(ENTRY, SEED + pass);
warm(DEMO_WARM);
}
}
const STACK_PALE = mix(PAPER, LILAC, 0.3);
const DONE_PALE = mix(PAPER, BG, 0.06);
function draw() {
clearStage();
ctx.fillStyle = rgba(ROCK, 1);
ctx.fillRect(BX, BY, BOARD, BOARD);
for (let i = 0; i < CELL_N; i += 1) {
if (visited[i] === 0) {
continue;
}
if (stackPos[i] >= 0) {
const k = Math.exp(-(top - 1 - stackPos[i]) / BACKTRACK_TINT);
fillCell(i, mix(STACK_PALE, PINK, k), 1);
}
else {
fillCell(i, mix(DONE_PALE, SKY, clamp(popRun[i] / BACKTRACK_TINT, 0, 1) * 0.8), 1);
}
}
strokeWalls(walls, rgba(INK, 1), 2);
if (top > 0) {
const h = stack[top - 1];
ctx.fillStyle = rgba(PAPER, 1);
ctx.strokeStyle = rgba(PINK, 1);
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cellX(h) + CS / 2, cellY(h) + CS / 2, 3.2, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
drawGates();
panelRow(0, 'Carved cells', String(carved + 1) + ' / ' + String(CELL_N));
panelRow(1, 'Stack depth', String(top));
panelRow(2, 'Backtrack steps', String(backs));
panelRow(3, 'Steps', String(steps));
}
function after() {
if (still) {
settle();
}
report();
draw();
}
function restart(start) {
pass += 1;
reset(start, SEED + pass);
after();
}
function setPaused(v) {
paused = v;
if (pauseBtn) {
pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false');
}
report();
draw();
}
cv.addEventListener('pointerdown', function (ev) {
wake();
const p = toScene(ev);
const c = cellAt(p[0], p[1]);
if (c >= 0) {
restart(c);
}
});
cv.addEventListener('keydown', function (ev) {
if (ev.key === ' ' || ev.key === 'Spacebar') {
ev.preventDefault();
wake();
setPaused(!paused);
}
else if (ev.key === 'ArrowRight') {
ev.preventDefault();
wake();
warm(20);
after();
}
else if (ev.key === 'Home' || ev.key === 'Enter') {
ev.preventDefault();
wake();
restart(ENTRY);
}
});
if (restartBtn) {
restartBtn.addEventListener('click', function () { wake(); restart(ENTRY); });
}
if (pauseBtn) {
pauseBtn.addEventListener('click', function () { wake(); setPaused(!paused); });
}
reset(ENTRY, SEED);
if (demo && !still) {
warm(DEMO_WARM);
}
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// MzGridDfsCarvePassage - vanilla 판(01-mz-grid-dfs-carve-passage) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./MzGridDfsCarvePassage.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function MzGridDfsCarvePassage() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 바닥을 얹어 명도 차로 길이 읽힌다
const BG: RGB = [29, 26, 44];
const ROCK: RGB = [44, 39, 64];
const PAPER: RGB = [255, 248, 234];
const INK: RGB = [58, 48, 76];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 미로가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 격자 — 31×31 칸, 칸마다 벽 네 장을 비트 하나씩으로 든다
const GRID_N = 31;
const CELL_N = GRID_N * GRID_N;
const CS = 9;
const BOARD = GRID_N * CS;
let BX = W - 12 - BOARD;
const BY = (H - BOARD) / 2;
const ENTRY = 0;
const EXIT = CELL_N - 1;
// 방향 0 북 · 1 동 · 2 남 · 3 서 — 시계 방향 순서라 +1 이 오른쪽으로 돌기, +3 이 왼쪽으로 돌기
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
const BIT = [1, 2, 4, 8];
const ALL_WALLS = 15;
function neighbor(i: number, d: number): number {
const x = (i % GRID_N) + DX[d];
const y = Math.floor(i / GRID_N) + DY[d];
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function isOpen(walls: Uint8Array, i: number, d: number): boolean {
return (walls[i] & BIT[d]) === 0 && neighbor(i, d) >= 0;
}
// 벽 한 장 걷기 — 양쪽 칸의 비트를 함께 지운다 (한쪽만 지우면 길이 일방통행이 된다)
function knock(walls: Uint8Array, i: number, d: number): number {
const n = neighbor(i, d);
walls[i] &= ~BIT[d];
walls[n] &= ~BIT[(d + 2) % 4];
return n;
}
function openCount(walls: Uint8Array, i: number): number {
let c = 0;
for (let d = 0; d < 4; d += 1) { if (isOpen(walls, i, d)) { c += 1; } }
return c;
}
function cellX(i: number): number { return BX + (i % GRID_N) * CS; }
function cellY(i: number): number { return BY + Math.floor(i / GRID_N) * CS; }
function cellAt(sx: number, sy: number): number {
const x = Math.floor((sx - BX) / CS);
const y = Math.floor((sy - BY) / CS);
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function fillCell(i: number, c: RGB, a: number): void {
ctx.fillStyle = rgba(c, a);
ctx.fillRect(cellX(i), cellY(i), CS, CS);
}
// 벽 비트를 선분으로 — 칸마다 동·남 벽만 긋고 맨 윗줄·맨 왼줄만 북·서 벽을 더 긋는다 (같은 벽을 두 번 긋지 않는다)
function strokeWalls(walls: Uint8Array, color: string, lw: number): void {
ctx.beginPath();
for (let i = 0; i < CELL_N; i += 1) {
const x = cellX(i);
const y = cellY(i);
const w = walls[i];
if ((w & 2) !== 0) { ctx.moveTo(x + CS, y); ctx.lineTo(x + CS, y + CS); }
if ((w & 4) !== 0) { ctx.moveTo(x, y + CS); ctx.lineTo(x + CS, y + CS); }
if (i < GRID_N && (w & 1) !== 0) { ctx.moveTo(x, y); ctx.lineTo(x + CS, y); }
if (i % GRID_N === 0 && (w & 8) !== 0) { ctx.moveTo(x, y); ctx.lineTo(x, y + CS); }
}
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineCap = 'square';
ctx.stroke();
}
// 입구(왼쪽 위)·출구(오른쪽 아래) 표시 — 바깥 벽 밖에 작은 삼각형
function drawGates(): void {
ctx.fillStyle = rgba(MINT, 0.95);
ctx.beginPath();
ctx.moveTo(BX - 9, BY + 1);
ctx.lineTo(BX - 2, BY + CS / 2);
ctx.lineTo(BX - 9, BY + CS - 1);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(PINK, 0.95);
ctx.beginPath();
ctx.moveTo(BX + BOARD + 2, BY + BOARD - CS + 1);
ctx.lineTo(BX + BOARD + 9, BY + BOARD - CS / 2);
ctx.lineTo(BX + BOARD + 2, BY + BOARD - 1);
ctx.closePath();
ctx.fill();
}
// 왼쪽 칸의 수치 — 이름표 한 줄 + 값 한 줄
function panelRow(row: number, label: string, value: string): void {
const y = 104 + row * 34;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(PAPER, 0.62);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, 16, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 15px sans-serif';
ctx.fillText(value, 16, y + 18);
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
const CARVE_STEP_MS = 24;
// 스택 안에서 머리로부터 몇 칸 아래인가를 색으로 — 이 칸 수만큼 내려가면 색이 1/e 로 옅어진다.
// 스택에서 빠진 칸은 그때 연달아 되돌아간 걸음 수를 같은 눈금으로 하늘색에 섞는다
const BACKTRACK_TINT = 72;
const SEED = 464;
// 시연은 앞 640걸음을 미리 걸어 둔 상태에서 이어 판다 — 2초 안에 스택 색이 움직이는 모습이 보이게
const DEMO_WARM = 640;
const HOLD_MS = 1400;
const TXT_DIG = '파 들어가는 중';
const TXT_BACK = '막혀서 되돌아가는 중';
const TXT_DONE = '완성 — 모든 칸이 한 길로 이어짐';
const TXT_PAUSE = '멈춤';
const restartBtn = root.querySelector('[data-role="restart"]') as HTMLButtonElement | null;
const pauseBtn = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const walls = new Uint8Array(CELL_N);
const visited = new Uint8Array(CELL_N);
const stack = new Int32Array(CELL_N);
const stackPos = new Int32Array(CELL_N);
// 이 칸이 스택에서 빠질 때 연달아 되돌아간 걸음 수
const popRun = new Int32Array(CELL_N);
let top = 0;
let steps = 0;
let carved = 0;
let backs = 0;
let backRun = 0;
let lastMove = 0;
let rnd = makeRng(SEED);
let pass = 0;
let paused = false;
let stepAcc = 0;
let holdLeft = 0;
function reset(start: number, seed: number): void {
walls.fill(ALL_WALLS);
visited.fill(0);
stackPos.fill(-1);
popRun.fill(0);
rnd = makeRng(seed);
steps = 0;
carved = 0;
backs = 0;
backRun = 0;
lastMove = 0;
stepAcc = 0;
holdLeft = 0;
stack[0] = start;
stackPos[start] = 0;
top = 1;
visited[start] = 1;
}
// 한 걸음 — 머리 칸에 안 판 이웃이 있으면 하나를 골라 그쪽 벽을 걷고 들어가고(0), 없으면 스택에서 하나 빼 되돌아간다(1). 끝나면 2
function carveStep(): number {
if (top === 0) { return 2; }
const cur = stack[top - 1];
const opts: number[] = [];
for (let d = 0; d < 4; d += 1) {
const n = neighbor(cur, d);
if (n >= 0 && visited[n] === 0) { opts.push(d); }
}
steps += 1;
if (opts.length > 0) {
const n = knock(walls, cur, opts[Math.floor(rnd() * opts.length)]);
visited[n] = 1;
stackPos[n] = top;
stack[top] = n;
top += 1;
carved += 1;
backRun = 0;
return 0;
}
top -= 1;
stackPos[cur] = -1;
backRun += 1;
popRun[cur] = backRun;
backs += 1;
return 1;
}
function report(): void {
if (top === 0) { say(TXT_DONE); }
else if (paused) { say(TXT_PAUSE); }
else { say(lastMove === 1 ? TXT_BACK : TXT_DIG); }
}
function warm(n: number): void {
for (let k = 0; k < n && top > 0; k += 1) { lastMove = carveStep(); }
}
function step(): void {
if (paused || top === 0) { return; }
stepAcc += FIXED_DT;
while (stepAcc >= CARVE_STEP_MS && top > 0) {
stepAcc -= CARVE_STEP_MS;
lastMove = carveStep();
}
report();
}
function settle(): void {
while (carveStep() !== 2) { /* 끝까지 */ }
report();
}
function demoTick(t: number): void {
if (top > 0 || t < 0) { return; }
holdLeft += FIXED_DT;
if (holdLeft >= HOLD_MS) {
pass += 1;
reset(ENTRY, SEED + pass);
warm(DEMO_WARM);
}
}
const STACK_PALE = mix(PAPER, LILAC, 0.3);
const DONE_PALE = mix(PAPER, BG, 0.06);
function draw(): void {
clearStage();
ctx.fillStyle = rgba(ROCK, 1);
ctx.fillRect(BX, BY, BOARD, BOARD);
for (let i = 0; i < CELL_N; i += 1) {
if (visited[i] === 0) { continue; }
if (stackPos[i] >= 0) {
const k = Math.exp(-(top - 1 - stackPos[i]) / BACKTRACK_TINT);
fillCell(i, mix(STACK_PALE, PINK, k), 1);
} else {
fillCell(i, mix(DONE_PALE, SKY, clamp(popRun[i] / BACKTRACK_TINT, 0, 1) * 0.8), 1);
}
}
strokeWalls(walls, rgba(INK, 1), 2);
if (top > 0) {
const h = stack[top - 1];
ctx.fillStyle = rgba(PAPER, 1);
ctx.strokeStyle = rgba(PINK, 1);
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cellX(h) + CS / 2, cellY(h) + CS / 2, 3.2, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
drawGates();
panelRow(0, '판 칸', String(carved + 1) + ' / ' + String(CELL_N));
panelRow(1, '스택 깊이', String(top));
panelRow(2, '되돌아간 걸음', String(backs));
panelRow(3, '걸음', String(steps));
}
function after(): void {
if (still) { settle(); }
report();
draw();
}
function restart(start: number): void {
pass += 1;
reset(start, SEED + pass);
after();
}
function setPaused(v: boolean): void {
paused = v;
if (pauseBtn) { pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false'); }
report();
draw();
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
const c = cellAt(p[0], p[1]);
if (c >= 0) { restart(c); }
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key === ' ' || ev.key === 'Spacebar') { ev.preventDefault(); wake(); setPaused(!paused); }
else if (ev.key === 'ArrowRight') { ev.preventDefault(); wake(); warm(20); after(); }
else if (ev.key === 'Home' || ev.key === 'Enter') { ev.preventDefault(); wake(); restart(ENTRY); }
});
if (restartBtn) { restartBtn.addEventListener('click', function (): void { wake(); restart(ENTRY); }); }
if (pauseBtn) { pauseBtn.addEventListener('click', function (): void { wake(); setPaused(!paused); }); }
reset(ENTRY, SEED);
if (demo && !still) { warm(DEMO_WARM); }
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("hs") + " is-demo"} ref={rootRef}>
<canvas className={cx("hs__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="깊이 우선으로 벽을 걷으며 파 들어가다 막히면 스택을 되짚어 갈래를 여는 31×31 미로. 칸을 누르면 그 칸에서 다시 판다"></canvas>
<p className={cx("hs__status")} data-role="status" aria-live="off">파 들어가는 중</p>
<div className={cx("hs__tools")}>
<button className={cx("hs__btn")} data-role="restart" type="button">다시 파기</button>
<button className={cx("hs__btn")} data-role="pause" type="button" aria-pressed="false">멈춤</button>
</div>
</div>
);
}01 · A Grid Maze That Digs Deeper First and Backs Out to Carve New Branches — Measured area 5.212% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
const opts = [];
for (let d = 0; d < 4; d += 1) {
const n = neighbor(cur, d);
if (n >= 0 && visited[n] === 0) {
opts.push(d);
}
}
steps += 1;
if (opts.length > 0) {
const n = knock(walls, cur, opts[Math.floor(rnd() * opts.length)]);
visited[n] = 1;
stackPos[n] = top;
stack[top] = n;
top += 1;
carved += 1;
backRun = 0;
return 0;
}
top -= 1;
stackPos[cur] = -1;
backRun += 1;
popRun[cur] = backRun;
backs += 1;
return 1;
02A Maze Whose Frontier Ripples Outward as It Grows
Cells touching the carved area wait in a frontier list, and each round one of them is pulled out, joined to a carved neighbor through a single wall, and replaced in the list by its own uncarved neighbors. Every cell's join number is painted in bands of 26 (RIPPLE_W), and across the 37 bands the mean distance from the center cell grows from 2.1 cells in the first band to 14.4 in the last, which is why the bands read as ripples moving out from the middle. Growing from the center cell at one join per 8ms, the board completes its 960 joins in 7,680ms, and the render measured 16.9% cumulative area, 106.4 mean intensity and 23/23 moved frames.

<main class="stage">
<div class="hs is-demo" id="hs">
<canvas class="hs__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="A maze that picks one cell at a time from the list touching the carved area, leaving join order as ripple bands. Press a cell to grow again from there"></canvas>
<p class="hs__status" data-role="status" aria-live="off">Picking frontier cells one at a time</p>
<div class="hs__tools">
<button class="hs__btn" data-role="restart" type="button">Grow again</button>
<button class="hs__btn" data-role="mode" type="button">Change pick rule</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.hs {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.hs__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.hs__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.hs__btn:hover {
background: $subject-cream;
}
.hs__btn[aria-pressed="true"] {
background: $subject-mint;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.hs__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.hs__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.hs__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.hs__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hs {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.hs__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.hs__btn:hover {
background: #fff7e6;
}
.hs__btn[aria-pressed=true] {
background: #4cd4a6;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.hs__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.hs__btn {
font-size: 10px;
padding: 4px 8px;
}
.hs__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.hs__btn {
transition: none;
}
}(function () {
const root = document.getElementById("hs");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 바닥을 얹어 명도 차로 길이 읽힌다
const BG = [29, 26, 44];
const ROCK = [44, 39, 64];
const PAPER = [255, 248, 234];
const INK = [58, 48, 76];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 미로가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 격자 — 31×31 칸, 칸마다 벽 네 장을 비트 하나씩으로 든다
const GRID_N = 31;
const CELL_N = GRID_N * GRID_N;
const CS = 9;
const BOARD = GRID_N * CS;
let BX = W - 12 - BOARD;
const BY = (H - BOARD) / 2;
const ENTRY = 0;
const EXIT = CELL_N - 1;
// 방향 0 북 · 1 동 · 2 남 · 3 서 — 시계 방향 순서라 +1 이 오른쪽으로 돌기, +3 이 왼쪽으로 돌기
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
const BIT = [1, 2, 4, 8];
const ALL_WALLS = 15;
function neighbor(i, d) {
const x = (i % GRID_N) + DX[d];
const y = Math.floor(i / GRID_N) + DY[d];
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function isOpen(walls, i, d) {
return (walls[i] & BIT[d]) === 0 && neighbor(i, d) >= 0;
}
// 벽 한 장 걷기 — 양쪽 칸의 비트를 함께 지운다 (한쪽만 지우면 길이 일방통행이 된다)
function knock(walls, i, d) {
const n = neighbor(i, d);
walls[i] &= ~BIT[d];
walls[n] &= ~BIT[(d + 2) % 4];
return n;
}
function openCount(walls, i) {
let c = 0;
for (let d = 0; d < 4; d += 1) {
if (isOpen(walls, i, d)) {
c += 1;
}
}
return c;
}
function cellX(i) { return BX + (i % GRID_N) * CS; }
function cellY(i) { return BY + Math.floor(i / GRID_N) * CS; }
function cellAt(sx, sy) {
const x = Math.floor((sx - BX) / CS);
const y = Math.floor((sy - BY) / CS);
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function fillCell(i, c, a) {
ctx.fillStyle = rgba(c, a);
ctx.fillRect(cellX(i), cellY(i), CS, CS);
}
// 벽 비트를 선분으로 — 칸마다 동·남 벽만 긋고 맨 윗줄·맨 왼줄만 북·서 벽을 더 긋는다 (같은 벽을 두 번 긋지 않는다)
function strokeWalls(walls, color, lw) {
ctx.beginPath();
for (let i = 0; i < CELL_N; i += 1) {
const x = cellX(i);
const y = cellY(i);
const w = walls[i];
if ((w & 2) !== 0) {
ctx.moveTo(x + CS, y);
ctx.lineTo(x + CS, y + CS);
}
if ((w & 4) !== 0) {
ctx.moveTo(x, y + CS);
ctx.lineTo(x + CS, y + CS);
}
if (i < GRID_N && (w & 1) !== 0) {
ctx.moveTo(x, y);
ctx.lineTo(x + CS, y);
}
if (i % GRID_N === 0 && (w & 8) !== 0) {
ctx.moveTo(x, y);
ctx.lineTo(x, y + CS);
}
}
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineCap = 'square';
ctx.stroke();
}
// 입구(왼쪽 위)·출구(오른쪽 아래) 표시 — 바깥 벽 밖에 작은 삼각형
function drawGates() {
ctx.fillStyle = rgba(MINT, 0.95);
ctx.beginPath();
ctx.moveTo(BX - 9, BY + 1);
ctx.lineTo(BX - 2, BY + CS / 2);
ctx.lineTo(BX - 9, BY + CS - 1);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(PINK, 0.95);
ctx.beginPath();
ctx.moveTo(BX + BOARD + 2, BY + BOARD - CS + 1);
ctx.lineTo(BX + BOARD + 9, BY + BOARD - CS / 2);
ctx.lineTo(BX + BOARD + 2, BY + BOARD - 1);
ctx.closePath();
ctx.fill();
}
// 왼쪽 칸의 수치 — 이름표 한 줄 + 값 한 줄
function panelRow(row, label, value) {
const y = 104 + row * 34;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(PAPER, 0.62);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, 16, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 15px sans-serif';
ctx.fillText(value, 16, y + 18);
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
function addFront(g, c) {
for (let d = 0; d < 4; d += 1) {
const n = neighbor(c, d);
if (n < 0 || g.inMaze[n] === 1 || g.inFront[n] === 1) {
continue;
}
g.inFront[n] = 1;
g.front.push(n);
}
}
function growStart(seed, start) {
const g = { walls: new Uint8Array(CELL_N).fill(ALL_WALLS), inMaze: new Uint8Array(CELL_N), inFront: new Uint8Array(CELL_N), front: [], order: new Int32Array(CELL_N).fill(-1), count: 0, last: start, rnd: makeRng(seed) };
g.inMaze[start] = 1;
g.order[start] = 0;
g.count = 1;
addFront(g, start);
return g;
}
// Frontier(판 영역에 닿은 안 Carved cells)에서 하나를 골라 이미 판 이웃 하나와 벽 한 장으로 잇는다
function growOne(g, mode) {
const len = g.front.length;
if (len === 0) {
return -1;
}
let c = 0;
if (mode === 'newest') {
c = g.front.pop();
}
else if (mode === 'oldest') {
c = g.front.shift();
}
else {
const k = Math.floor(g.rnd() * len);
c = g.front[k];
g.front[k] = g.front[len - 1];
g.front.pop();
}
g.inFront[c] = 0;
const links = [];
for (let d = 0; d < 4; d += 1) {
const n = neighbor(c, d);
if (n >= 0 && g.inMaze[n] === 1) {
links.push(d);
}
}
knock(g.walls, c, links[Math.floor(g.rnd() * links.length)]);
g.inMaze[c] = 1;
g.order[c] = g.count;
g.count += 1;
g.last = c;
addFront(g, c);
return c;
}
function growAll(seed, start, mode) {
const g = growStart(seed, start);
while (growOne(g, mode) >= 0) { /* 프론티어가 빌 때까지 */ }
return g.walls;
}
// 프론티어에서 다음 칸을 Pick rule — random 은 아무거나, newest 는 막 들어온 것, oldest 는 가장 먼저 들어온 것
const FRONTIER_PICK = 'random';
// 편입 순번 몇 개마다 Ripple bands 하나를 두르나
const RIPPLE_W = 26;
// 띠가 생긴 뒤 칸이 하나 더 들어올 때마다 그 띠 색이 옅어지는 비율 (지수 감쇠)
const RING_FADE = 0.0035;
const GROW_MS = 8;
const SEED = 464;
const HOLD_MS = 1400;
const CENTER = Math.floor(GRID_N / 2) * GRID_N + Math.floor(GRID_N / 2);
const MODES = ['random', 'newest', 'oldest'];
const TXT_GROW = 'Picking frontier cells one at a time';
const TXT_DONE = 'Done — 960 walls removed, one board';
const restartBtn = root.querySelector('[data-role="restart"]');
const modeBtn = root.querySelector('[data-role="mode"]');
let mode = FRONTIER_PICK;
let pass = 0;
let start = CENTER;
let grower = growStart(SEED, start);
let stepAcc = 0;
let holdLeft = 0;
function modeName(m) {
return m === 'random' ? 'Random' : (m === 'newest' ? 'Newest cell' : 'Oldest cell');
}
function done() { return grower.front.length === 0; }
function report() { say(done() ? TXT_DONE : TXT_GROW); }
function reset(s) {
start = s;
grower = growStart(SEED + pass, s);
stepAcc = 0;
holdLeft = 0;
}
function grow(n) {
for (let k = 0; k < n && !done(); k += 1) {
growOne(grower, mode);
}
}
function step() {
if (done()) {
return;
}
stepAcc += FIXED_DT;
while (stepAcc >= GROW_MS && !done()) {
stepAcc -= GROW_MS;
growOne(grower, mode);
}
report();
}
function settle() {
grow(CELL_N);
report();
}
function demoTick(t) {
if (!done() || t < 0) {
return;
}
holdLeft += FIXED_DT;
if (holdLeft >= HOLD_MS) {
pass += 1;
reset(CENTER);
}
}
const BAND_A = SKY;
const BAND_B = LILAC;
const OLD_PALE = mix(PAPER, BG, 0.05);
function draw() {
clearStage();
ctx.fillStyle = rgba(ROCK, 1);
ctx.fillRect(BX, BY, BOARD, BOARD);
const g = grower;
for (let i = 0; i < CELL_N; i += 1) {
if (g.inMaze[i] === 1) {
const o = g.order[i];
const band = Math.floor(o / RIPPLE_W) % 2 === 0 ? BAND_A : BAND_B;
const k = Math.exp(-(g.count - o) * RING_FADE);
fillCell(i, mix(OLD_PALE, band, 0.22 + 0.78 * k), 1);
}
else if (g.inFront[i] === 1) {
ctx.fillStyle = rgba(MINT, 0.9);
ctx.fillRect(cellX(i) + 2, cellY(i) + 2, CS - 4, CS - 4);
}
}
strokeWalls(g.walls, rgba(INK, 1), 2);
if (!done()) {
ctx.fillStyle = rgba(PINK, 1);
ctx.fillRect(cellX(g.last) + 1, cellY(g.last) + 1, CS - 2, CS - 2);
}
drawGates();
panelRow(0, 'Carved cells', String(g.count) + ' / ' + String(CELL_N));
panelRow(1, 'Frontier', String(g.front.length));
panelRow(2, 'Ripple bands', String(Math.ceil(g.count / RIPPLE_W)));
panelRow(3, 'Pick rule', modeName(mode));
}
function after() {
if (still) {
settle();
}
report();
draw();
}
function restart(s) {
pass += 1;
reset(s);
after();
}
function nextMode() {
mode = MODES[(MODES.indexOf(mode) + 1) % MODES.length];
restart(start);
}
cv.addEventListener('pointerdown', function (ev) {
wake();
const p = toScene(ev);
const c = cellAt(p[0], p[1]);
if (c >= 0) {
restart(c);
}
});
cv.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter') {
ev.preventDefault();
wake();
nextMode();
}
else if (ev.key === 'ArrowRight') {
ev.preventDefault();
wake();
grow(40);
after();
}
else if (ev.key === 'Home') {
ev.preventDefault();
wake();
restart(CENTER);
}
});
if (restartBtn) {
restartBtn.addEventListener('click', function () { wake(); restart(CENTER); });
}
if (modeBtn) {
modeBtn.addEventListener('click', function () { wake(); nextMode(); });
}
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// MzFrontierRippleGrowth - vanilla 판(02-mz-frontier-ripple-growth) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./MzFrontierRippleGrowth.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function MzFrontierRippleGrowth() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 바닥을 얹어 명도 차로 길이 읽힌다
const BG: RGB = [29, 26, 44];
const ROCK: RGB = [44, 39, 64];
const PAPER: RGB = [255, 248, 234];
const INK: RGB = [58, 48, 76];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 미로가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 격자 — 31×31 칸, 칸마다 벽 네 장을 비트 하나씩으로 든다
const GRID_N = 31;
const CELL_N = GRID_N * GRID_N;
const CS = 9;
const BOARD = GRID_N * CS;
let BX = W - 12 - BOARD;
const BY = (H - BOARD) / 2;
const ENTRY = 0;
const EXIT = CELL_N - 1;
// 방향 0 북 · 1 동 · 2 남 · 3 서 — 시계 방향 순서라 +1 이 오른쪽으로 돌기, +3 이 왼쪽으로 돌기
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
const BIT = [1, 2, 4, 8];
const ALL_WALLS = 15;
function neighbor(i: number, d: number): number {
const x = (i % GRID_N) + DX[d];
const y = Math.floor(i / GRID_N) + DY[d];
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function isOpen(walls: Uint8Array, i: number, d: number): boolean {
return (walls[i] & BIT[d]) === 0 && neighbor(i, d) >= 0;
}
// 벽 한 장 걷기 — 양쪽 칸의 비트를 함께 지운다 (한쪽만 지우면 길이 일방통행이 된다)
function knock(walls: Uint8Array, i: number, d: number): number {
const n = neighbor(i, d);
walls[i] &= ~BIT[d];
walls[n] &= ~BIT[(d + 2) % 4];
return n;
}
function openCount(walls: Uint8Array, i: number): number {
let c = 0;
for (let d = 0; d < 4; d += 1) { if (isOpen(walls, i, d)) { c += 1; } }
return c;
}
function cellX(i: number): number { return BX + (i % GRID_N) * CS; }
function cellY(i: number): number { return BY + Math.floor(i / GRID_N) * CS; }
function cellAt(sx: number, sy: number): number {
const x = Math.floor((sx - BX) / CS);
const y = Math.floor((sy - BY) / CS);
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function fillCell(i: number, c: RGB, a: number): void {
ctx.fillStyle = rgba(c, a);
ctx.fillRect(cellX(i), cellY(i), CS, CS);
}
// 벽 비트를 선분으로 — 칸마다 동·남 벽만 긋고 맨 윗줄·맨 왼줄만 북·서 벽을 더 긋는다 (같은 벽을 두 번 긋지 않는다)
function strokeWalls(walls: Uint8Array, color: string, lw: number): void {
ctx.beginPath();
for (let i = 0; i < CELL_N; i += 1) {
const x = cellX(i);
const y = cellY(i);
const w = walls[i];
if ((w & 2) !== 0) { ctx.moveTo(x + CS, y); ctx.lineTo(x + CS, y + CS); }
if ((w & 4) !== 0) { ctx.moveTo(x, y + CS); ctx.lineTo(x + CS, y + CS); }
if (i < GRID_N && (w & 1) !== 0) { ctx.moveTo(x, y); ctx.lineTo(x + CS, y); }
if (i % GRID_N === 0 && (w & 8) !== 0) { ctx.moveTo(x, y); ctx.lineTo(x, y + CS); }
}
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineCap = 'square';
ctx.stroke();
}
// 입구(왼쪽 위)·출구(오른쪽 아래) 표시 — 바깥 벽 밖에 작은 삼각형
function drawGates(): void {
ctx.fillStyle = rgba(MINT, 0.95);
ctx.beginPath();
ctx.moveTo(BX - 9, BY + 1);
ctx.lineTo(BX - 2, BY + CS / 2);
ctx.lineTo(BX - 9, BY + CS - 1);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(PINK, 0.95);
ctx.beginPath();
ctx.moveTo(BX + BOARD + 2, BY + BOARD - CS + 1);
ctx.lineTo(BX + BOARD + 9, BY + BOARD - CS / 2);
ctx.lineTo(BX + BOARD + 2, BY + BOARD - 1);
ctx.closePath();
ctx.fill();
}
// 왼쪽 칸의 수치 — 이름표 한 줄 + 값 한 줄
function panelRow(row: number, label: string, value: string): void {
const y = 104 + row * 34;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(PAPER, 0.62);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, 16, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 15px sans-serif';
ctx.fillText(value, 16, y + 18);
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
type PickMode = 'random' | 'newest' | 'oldest';
type Grower = { walls: Uint8Array; inMaze: Uint8Array; inFront: Uint8Array; front: number[]; order: Int32Array; count: number; last: number; rnd: () => number };
function addFront(g: Grower, c: number): void {
for (let d = 0; d < 4; d += 1) {
const n = neighbor(c, d);
if (n < 0 || g.inMaze[n] === 1 || g.inFront[n] === 1) { continue; }
g.inFront[n] = 1;
g.front.push(n);
}
}
function growStart(seed: number, start: number): Grower {
const g: Grower = { walls: new Uint8Array(CELL_N).fill(ALL_WALLS), inMaze: new Uint8Array(CELL_N), inFront: new Uint8Array(CELL_N), front: [], order: new Int32Array(CELL_N).fill(-1), count: 0, last: start, rnd: makeRng(seed) };
g.inMaze[start] = 1;
g.order[start] = 0;
g.count = 1;
addFront(g, start);
return g;
}
// 프론티어(판 영역에 닿은 안 판 칸)에서 하나를 골라 이미 판 이웃 하나와 벽 한 장으로 잇는다
function growOne(g: Grower, mode: PickMode): number {
const len = g.front.length;
if (len === 0) { return -1; }
let c = 0;
if (mode === 'newest') {
c = g.front.pop() as number;
} else if (mode === 'oldest') {
c = g.front.shift() as number;
} else {
const k = Math.floor(g.rnd() * len);
c = g.front[k];
g.front[k] = g.front[len - 1];
g.front.pop();
}
g.inFront[c] = 0;
const links: number[] = [];
for (let d = 0; d < 4; d += 1) {
const n = neighbor(c, d);
if (n >= 0 && g.inMaze[n] === 1) { links.push(d); }
}
knock(g.walls, c, links[Math.floor(g.rnd() * links.length)]);
g.inMaze[c] = 1;
g.order[c] = g.count;
g.count += 1;
g.last = c;
addFront(g, c);
return c;
}
function growAll(seed: number, start: number, mode: PickMode): Uint8Array {
const g = growStart(seed, start);
while (growOne(g, mode) >= 0) { /* 프론티어가 빌 때까지 */ }
return g.walls;
}
// 프론티어에서 다음 칸을 고르는 법 — random 은 아무거나, newest 는 막 들어온 것, oldest 는 가장 먼저 들어온 것
const FRONTIER_PICK: PickMode = 'random';
// 편입 순번 몇 개마다 물결 띠 하나를 두르나
const RIPPLE_W = 26;
// 띠가 생긴 뒤 칸이 하나 더 들어올 때마다 그 띠 색이 옅어지는 비율 (지수 감쇠)
const RING_FADE = 0.0035;
const GROW_MS = 8;
const SEED = 464;
const HOLD_MS = 1400;
const CENTER = Math.floor(GRID_N / 2) * GRID_N + Math.floor(GRID_N / 2);
const MODES: PickMode[] = ['random', 'newest', 'oldest'];
const TXT_GROW = '프론티어에서 한 칸씩 골라 붙이는 중';
const TXT_DONE = '완성 — 벽 960장을 걷어 한 판이 됨';
const restartBtn = root.querySelector('[data-role="restart"]') as HTMLButtonElement | null;
const modeBtn = root.querySelector('[data-role="mode"]') as HTMLButtonElement | null;
let mode: PickMode = FRONTIER_PICK;
let pass = 0;
let start = CENTER;
let grower = growStart(SEED, start);
let stepAcc = 0;
let holdLeft = 0;
function modeName(m: PickMode): string {
return m === 'random' ? '무작위' : (m === 'newest' ? '막 들어온 칸' : '먼저 들어온 칸');
}
function done(): boolean { return grower.front.length === 0; }
function report(): void { say(done() ? TXT_DONE : TXT_GROW); }
function reset(s: number): void {
start = s;
grower = growStart(SEED + pass, s);
stepAcc = 0;
holdLeft = 0;
}
function grow(n: number): void {
for (let k = 0; k < n && !done(); k += 1) { growOne(grower, mode); }
}
function step(): void {
if (done()) { return; }
stepAcc += FIXED_DT;
while (stepAcc >= GROW_MS && !done()) {
stepAcc -= GROW_MS;
growOne(grower, mode);
}
report();
}
function settle(): void {
grow(CELL_N);
report();
}
function demoTick(t: number): void {
if (!done() || t < 0) { return; }
holdLeft += FIXED_DT;
if (holdLeft >= HOLD_MS) {
pass += 1;
reset(CENTER);
}
}
const BAND_A = SKY;
const BAND_B = LILAC;
const OLD_PALE = mix(PAPER, BG, 0.05);
function draw(): void {
clearStage();
ctx.fillStyle = rgba(ROCK, 1);
ctx.fillRect(BX, BY, BOARD, BOARD);
const g = grower;
for (let i = 0; i < CELL_N; i += 1) {
if (g.inMaze[i] === 1) {
const o = g.order[i];
const band = Math.floor(o / RIPPLE_W) % 2 === 0 ? BAND_A : BAND_B;
const k = Math.exp(-(g.count - o) * RING_FADE);
fillCell(i, mix(OLD_PALE, band, 0.22 + 0.78 * k), 1);
} else if (g.inFront[i] === 1) {
ctx.fillStyle = rgba(MINT, 0.9);
ctx.fillRect(cellX(i) + 2, cellY(i) + 2, CS - 4, CS - 4);
}
}
strokeWalls(g.walls, rgba(INK, 1), 2);
if (!done()) {
ctx.fillStyle = rgba(PINK, 1);
ctx.fillRect(cellX(g.last) + 1, cellY(g.last) + 1, CS - 2, CS - 2);
}
drawGates();
panelRow(0, '판 칸', String(g.count) + ' / ' + String(CELL_N));
panelRow(1, '프론티어', String(g.front.length));
panelRow(2, '물결 띠', String(Math.ceil(g.count / RIPPLE_W)));
panelRow(3, '고르는 법', modeName(mode));
}
function after(): void {
if (still) { settle(); }
report();
draw();
}
function restart(s: number): void {
pass += 1;
reset(s);
after();
}
function nextMode(): void {
mode = MODES[(MODES.indexOf(mode) + 1) % MODES.length];
restart(start);
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
const c = cellAt(p[0], p[1]);
if (c >= 0) { restart(c); }
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key === 'Enter') { ev.preventDefault(); wake(); nextMode(); }
else if (ev.key === 'ArrowRight') { ev.preventDefault(); wake(); grow(40); after(); }
else if (ev.key === 'Home') { ev.preventDefault(); wake(); restart(CENTER); }
});
if (restartBtn) { restartBtn.addEventListener('click', function (): void { wake(); restart(CENTER); }); }
if (modeBtn) { modeBtn.addEventListener('click', function (): void { wake(); nextMode(); }); }
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("hs") + " is-demo"} ref={rootRef}>
<canvas className={cx("hs__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="판 영역에 닿은 칸 목록에서 하나씩 골라 붙이며 편입 순번이 물결 띠로 남는 미로. 칸을 누르면 그 칸에서 다시 키운다"></canvas>
<p className={cx("hs__status")} data-role="status" aria-live="off">프론티어에서 한 칸씩 골라 붙이는 중</p>
<div className={cx("hs__tools")}>
<button className={cx("hs__btn")} data-role="restart" type="button">다시 키우기</button>
<button className={cx("hs__btn")} data-role="mode" type="button">고르는 법 바꾸기</button>
</div>
</div>
);
}02 · A Maze Whose Frontier Ripples Outward as It Grows — Measured area 16.87% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
let c = 0;
if (mode === 'newest') {
c = g.front.pop();
}
else if (mode === 'oldest') {
c = g.front.shift();
}
else {
const k = Math.floor(g.rnd() * len);
c = g.front[k];
g.front[k] = g.front[len - 1];
g.front.pop();
}
g.inFront[c] = 0;
const links = [];
for (let d = 0; d < 4; d += 1) {
const n = neighbor(c, d);
if (n >= 0 && g.inMaze[n] === 1) {
links.push(d);
}
}
knock(g.walls, c, links[Math.floor(g.rnd() * links.length)]);
03A Maze Built From a Shuffled Wall List Where Islands Merge Into One
All 1,860 inner walls are shuffled by the seed and checked front to back with island merging (union-find): when the two cells belong to islands with different representatives, the wall comes out and the smaller island hangs under the larger one's representative, and when the representatives match, the wall stays. Finding a representative re-hangs every cell on the way straight onto it, which together with merging by size keeps islands from stretching into long chains, and each removed wall gives off a small light circle that grows and fades over 360ms (MERGE_FLASH_MS). At seed 464 the islands become one on check 1,738, so all 122 remaining walls stay, and the render measured 25.4% cumulative area, 43.1 mean intensity and 23/23 moved frames.

<main class="stage">
<div class="hs is-demo" id="hs">
<canvas class="hs__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="A maze that checks a shuffled wall list front to back, removing only walls between different islands and merging them. Press a cell to light up its island"></canvas>
<p class="hs__status" data-role="status" aria-live="off">Checking the shuffled walls front to back</p>
<div class="hs__tools">
<button class="hs__btn" data-role="shuffle" type="button">Shuffle again</button>
<button class="hs__btn" data-role="pause" type="button" aria-pressed="false">Paused</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.hs {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.hs__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.hs__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.hs__btn:hover {
background: $subject-cream;
}
.hs__btn[aria-pressed="true"] {
background: $subject-mint;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.hs__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.hs__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.hs__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.hs__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hs {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.hs__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.hs__btn:hover {
background: #fff7e6;
}
.hs__btn[aria-pressed=true] {
background: #4cd4a6;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.hs__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.hs__btn {
font-size: 10px;
padding: 4px 8px;
}
.hs__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.hs__btn {
transition: none;
}
}(function () {
const root = document.getElementById("hs");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 바닥을 얹어 명도 차로 길이 읽힌다
const BG = [29, 26, 44];
const ROCK = [44, 39, 64];
const PAPER = [255, 248, 234];
const INK = [58, 48, 76];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 미로가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 격자 — 31×31 칸, 칸마다 벽 네 장을 비트 하나씩으로 든다
const GRID_N = 31;
const CELL_N = GRID_N * GRID_N;
const CS = 9;
const BOARD = GRID_N * CS;
let BX = W - 12 - BOARD;
const BY = (H - BOARD) / 2;
const ENTRY = 0;
const EXIT = CELL_N - 1;
// 방향 0 북 · 1 동 · 2 남 · 3 서 — 시계 방향 순서라 +1 이 오른쪽으로 돌기, +3 이 왼쪽으로 돌기
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
const BIT = [1, 2, 4, 8];
const ALL_WALLS = 15;
function neighbor(i, d) {
const x = (i % GRID_N) + DX[d];
const y = Math.floor(i / GRID_N) + DY[d];
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function isOpen(walls, i, d) {
return (walls[i] & BIT[d]) === 0 && neighbor(i, d) >= 0;
}
// 벽 한 장 걷기 — 양쪽 칸의 비트를 함께 지운다 (한쪽만 지우면 길이 일방통행이 된다)
function knock(walls, i, d) {
const n = neighbor(i, d);
walls[i] &= ~BIT[d];
walls[n] &= ~BIT[(d + 2) % 4];
return n;
}
function openCount(walls, i) {
let c = 0;
for (let d = 0; d < 4; d += 1) {
if (isOpen(walls, i, d)) {
c += 1;
}
}
return c;
}
function cellX(i) { return BX + (i % GRID_N) * CS; }
function cellY(i) { return BY + Math.floor(i / GRID_N) * CS; }
function cellAt(sx, sy) {
const x = Math.floor((sx - BX) / CS);
const y = Math.floor((sy - BY) / CS);
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function fillCell(i, c, a) {
ctx.fillStyle = rgba(c, a);
ctx.fillRect(cellX(i), cellY(i), CS, CS);
}
// 벽 비트를 선분으로 — 칸마다 동·남 벽만 긋고 맨 윗줄·맨 왼줄만 북·서 벽을 더 긋는다 (같은 벽을 두 번 긋지 않는다)
function strokeWalls(walls, color, lw) {
ctx.beginPath();
for (let i = 0; i < CELL_N; i += 1) {
const x = cellX(i);
const y = cellY(i);
const w = walls[i];
if ((w & 2) !== 0) {
ctx.moveTo(x + CS, y);
ctx.lineTo(x + CS, y + CS);
}
if ((w & 4) !== 0) {
ctx.moveTo(x, y + CS);
ctx.lineTo(x + CS, y + CS);
}
if (i < GRID_N && (w & 1) !== 0) {
ctx.moveTo(x, y);
ctx.lineTo(x + CS, y);
}
if (i % GRID_N === 0 && (w & 8) !== 0) {
ctx.moveTo(x, y);
ctx.lineTo(x, y + CS);
}
}
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineCap = 'square';
ctx.stroke();
}
// 입구(왼쪽 위)·출구(오른쪽 아래) 표시 — 바깥 벽 밖에 작은 삼각형
function drawGates() {
ctx.fillStyle = rgba(MINT, 0.95);
ctx.beginPath();
ctx.moveTo(BX - 9, BY + 1);
ctx.lineTo(BX - 2, BY + CS / 2);
ctx.lineTo(BX - 9, BY + CS - 1);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(PINK, 0.95);
ctx.beginPath();
ctx.moveTo(BX + BOARD + 2, BY + BOARD - CS + 1);
ctx.lineTo(BX + BOARD + 9, BY + BOARD - CS / 2);
ctx.lineTo(BX + BOARD + 2, BY + BOARD - 1);
ctx.closePath();
ctx.fill();
}
// 왼쪽 칸의 수치 — 이름표 한 줄 + 값 한 줄
function panelRow(row, label, value) {
const y = 104 + row * 34;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(PAPER, 0.62);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, 16, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 15px sans-serif';
ctx.fillText(value, 16, y + 18);
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 벽 목록을 섞는 시드 — 같은 시드면 같은 순서로 검사해 같은 미로가 나온다
const WALL_SHUFFLE_SEED = 464;
// Islands 색은 대표 원소 번호를 이 수로 나눈 나머지로 고른다
const ISLAND_HUE_N = 6;
const MERGE_FLASH_MS = 360;
const CHECK_MS = 5;
const HOLD_MS = 1400;
const HUES = [SKY, PINK, MINT, LILAC, PEACH, [250, 232, 160]];
const TXT_CHECK = 'Checking the shuffled walls front to back';
const TXT_DONE = 'Done — the islands merged into one';
const TXT_PAUSE = 'Paused';
const shuffleBtn = root.querySelector('[data-role="shuffle"]');
const pauseBtn = root.querySelector('[data-role="pause"]');
// 안쪽 벽 후보 — 칸 번호×2 + (0 동쪽 벽 · 1 남쪽 벽). 31×31 이면 2×31×30 = 1860장
const wallList = [];
for (let i = 0; i < CELL_N; i += 1) {
if (i % GRID_N < GRID_N - 1) {
wallList.push(i * 2);
}
if (Math.floor(i / GRID_N) < GRID_N - 1) {
wallList.push(i * 2 + 1);
}
}
const walls = new Uint8Array(CELL_N);
const parent = new Int32Array(CELL_N);
const size = new Int32Array(CELL_N);
let flashes = [];
let order = [];
let wi = 0;
let islands = CELL_N;
let removed = 0;
let skipped = 0;
let pass = 0;
let paused = false;
let stepAcc = 0;
let holdLeft = 0;
let hiRoot = -1;
let hiLeft = 0;
// 피셔–예이츠 섞기 — 뒤에서부터 한 장씩 앞쪽 아무 자리와 바꾼다
function shuffled(seed) {
const a = wallList.slice();
const rnd = makeRng(seed);
for (let k = a.length - 1; k > 0; k -= 1) {
const j = Math.floor(rnd() * (k + 1));
const t = a[k];
a[k] = a[j];
a[j] = t;
}
return a;
}
// 대표 원소 찾기 + 경로 압축 — 올라간 길의 칸들을 모두 대표에 바로 붙여 다음 찾기를 짧게 한다
function find(i) {
let r = i;
while (parent[r] !== r) {
r = parent[r];
}
let c = i;
while (parent[c] !== r) {
const nx = parent[c];
parent[c] = r;
c = nx;
}
return r;
}
function reset(seed) {
walls.fill(ALL_WALLS);
for (let i = 0; i < CELL_N; i += 1) {
parent[i] = i;
size[i] = 1;
}
order = shuffled(seed);
wi = 0;
islands = CELL_N;
removed = 0;
skipped = 0;
flashes = [];
stepAcc = 0;
holdLeft = 0;
}
// 벽 한 장 검사 — 양쪽 칸의 대표가 다르면 벽을 걷고 작은 섬을 큰 Islands 대표 밑에 붙인다(0), 같으면 남긴다(1). 끝나면 2
function checkStep() {
if (wi >= order.length) {
return 2;
}
const code = order[wi];
wi += 1;
const a = code >> 1;
const dir = (code & 1) === 0 ? 1 : 2;
const b = neighbor(a, dir);
let ra = find(a);
let rb = find(b);
if (ra === rb) {
skipped += 1;
return 1;
}
knock(walls, a, dir);
if (size[ra] < size[rb]) {
const t = ra;
ra = rb;
rb = t;
}
parent[rb] = ra;
size[ra] += size[rb];
islands -= 1;
removed += 1;
flashes.push({ cell: a, dir: dir, age: 0 });
return 0;
}
function done() { return wi >= order.length; }
function report() {
if (done()) {
say(TXT_DONE);
}
else if (paused) {
say(TXT_PAUSE);
}
else {
say(TXT_CHECK);
}
}
function run(n) {
for (let k = 0; k < n && !done(); k += 1) {
checkStep();
}
}
function step() {
for (const f of flashes) {
f.age += FIXED_DT;
}
flashes = flashes.filter(function (f) { return f.age < MERGE_FLASH_MS; });
if (hiLeft > 0) {
hiLeft -= FIXED_DT;
if (hiLeft <= 0) {
hiRoot = -1;
}
}
if (paused || done()) {
return;
}
stepAcc += FIXED_DT;
while (stepAcc >= CHECK_MS && !done()) {
stepAcc -= CHECK_MS;
checkStep();
}
report();
}
function settle() {
run(order.length);
flashes = [];
report();
}
function demoTick(t) {
if (!done() || t < 0) {
return;
}
holdLeft += FIXED_DT;
if (holdLeft >= HOLD_MS) {
pass += 1;
reset(WALL_SHUFFLE_SEED + pass);
}
}
function islandColor(r) { return HUES[r % ISLAND_HUE_N]; }
function draw() {
clearStage();
const hr = hiRoot >= 0 ? find(hiRoot) : -1;
for (let i = 0; i < CELL_N; i += 1) {
const r = find(i);
let c = mix(islandColor(r), PAPER, 0.28);
if (hr >= 0) {
c = r === hr ? mix(c, PAPER, 0.35) : mix(c, BG, 0.55);
}
fillCell(i, c, 1);
}
for (const f of flashes) {
const k = 1 - f.age / MERGE_FLASH_MS;
const x = cellX(f.cell) + CS / 2;
const y = cellY(f.cell) + CS / 2;
const mx = x + DX[f.dir] * CS / 2;
const my = y + DY[f.dir] * CS / 2;
ctx.fillStyle = rgba(PAPER, 0.9 * k);
ctx.beginPath();
ctx.arc(mx, my, 2 + 7 * (1 - k), 0, Math.PI * 2);
ctx.fill();
}
strokeWalls(walls, rgba(INK, 1), 2);
drawGates();
panelRow(0, 'Islands', String(islands));
panelRow(1, 'Walls checked', String(wi) + ' / ' + String(order.length));
panelRow(2, 'Walls removed', String(removed));
panelRow(3, 'Walls kept', String(skipped));
}
function after() {
if (still) {
settle();
}
report();
draw();
}
function reshuffle() {
pass += 1;
reset(WALL_SHUFFLE_SEED + pass);
after();
}
function setPaused(v) {
paused = v;
if (pauseBtn) {
pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false');
}
report();
draw();
}
// 칸을 누르면 그 칸이 속한 Islands 전체를 밝힌다 — 대표가 같은 칸이 모두 한 섬이다
cv.addEventListener('pointerdown', function (ev) {
wake();
const p = toScene(ev);
const c = cellAt(p[0], p[1]);
if (c < 0) {
return;
}
hiRoot = c;
hiLeft = 1400;
draw();
});
cv.addEventListener('keydown', function (ev) {
if (ev.key === ' ' || ev.key === 'Spacebar') {
ev.preventDefault();
wake();
setPaused(!paused);
}
else if (ev.key === 'ArrowRight') {
ev.preventDefault();
wake();
run(60);
after();
}
else if (ev.key === 'Home' || ev.key === 'Enter') {
ev.preventDefault();
wake();
reshuffle();
}
});
if (shuffleBtn) {
shuffleBtn.addEventListener('click', function () { wake(); reshuffle(); });
}
if (pauseBtn) {
pauseBtn.addEventListener('click', function () { wake(); setPaused(!paused); });
}
reset(WALL_SHUFFLE_SEED);
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// MzWallShuffleMergeIslands - vanilla 판(03-mz-wall-shuffle-merge-islands) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./MzWallShuffleMergeIslands.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function MzWallShuffleMergeIslands() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 바닥을 얹어 명도 차로 길이 읽힌다
const BG: RGB = [29, 26, 44];
const ROCK: RGB = [44, 39, 64];
const PAPER: RGB = [255, 248, 234];
const INK: RGB = [58, 48, 76];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 미로가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 격자 — 31×31 칸, 칸마다 벽 네 장을 비트 하나씩으로 든다
const GRID_N = 31;
const CELL_N = GRID_N * GRID_N;
const CS = 9;
const BOARD = GRID_N * CS;
let BX = W - 12 - BOARD;
const BY = (H - BOARD) / 2;
const ENTRY = 0;
const EXIT = CELL_N - 1;
// 방향 0 북 · 1 동 · 2 남 · 3 서 — 시계 방향 순서라 +1 이 오른쪽으로 돌기, +3 이 왼쪽으로 돌기
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
const BIT = [1, 2, 4, 8];
const ALL_WALLS = 15;
function neighbor(i: number, d: number): number {
const x = (i % GRID_N) + DX[d];
const y = Math.floor(i / GRID_N) + DY[d];
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function isOpen(walls: Uint8Array, i: number, d: number): boolean {
return (walls[i] & BIT[d]) === 0 && neighbor(i, d) >= 0;
}
// 벽 한 장 걷기 — 양쪽 칸의 비트를 함께 지운다 (한쪽만 지우면 길이 일방통행이 된다)
function knock(walls: Uint8Array, i: number, d: number): number {
const n = neighbor(i, d);
walls[i] &= ~BIT[d];
walls[n] &= ~BIT[(d + 2) % 4];
return n;
}
function openCount(walls: Uint8Array, i: number): number {
let c = 0;
for (let d = 0; d < 4; d += 1) { if (isOpen(walls, i, d)) { c += 1; } }
return c;
}
function cellX(i: number): number { return BX + (i % GRID_N) * CS; }
function cellY(i: number): number { return BY + Math.floor(i / GRID_N) * CS; }
function cellAt(sx: number, sy: number): number {
const x = Math.floor((sx - BX) / CS);
const y = Math.floor((sy - BY) / CS);
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function fillCell(i: number, c: RGB, a: number): void {
ctx.fillStyle = rgba(c, a);
ctx.fillRect(cellX(i), cellY(i), CS, CS);
}
// 벽 비트를 선분으로 — 칸마다 동·남 벽만 긋고 맨 윗줄·맨 왼줄만 북·서 벽을 더 긋는다 (같은 벽을 두 번 긋지 않는다)
function strokeWalls(walls: Uint8Array, color: string, lw: number): void {
ctx.beginPath();
for (let i = 0; i < CELL_N; i += 1) {
const x = cellX(i);
const y = cellY(i);
const w = walls[i];
if ((w & 2) !== 0) { ctx.moveTo(x + CS, y); ctx.lineTo(x + CS, y + CS); }
if ((w & 4) !== 0) { ctx.moveTo(x, y + CS); ctx.lineTo(x + CS, y + CS); }
if (i < GRID_N && (w & 1) !== 0) { ctx.moveTo(x, y); ctx.lineTo(x + CS, y); }
if (i % GRID_N === 0 && (w & 8) !== 0) { ctx.moveTo(x, y); ctx.lineTo(x, y + CS); }
}
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineCap = 'square';
ctx.stroke();
}
// 입구(왼쪽 위)·출구(오른쪽 아래) 표시 — 바깥 벽 밖에 작은 삼각형
function drawGates(): void {
ctx.fillStyle = rgba(MINT, 0.95);
ctx.beginPath();
ctx.moveTo(BX - 9, BY + 1);
ctx.lineTo(BX - 2, BY + CS / 2);
ctx.lineTo(BX - 9, BY + CS - 1);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(PINK, 0.95);
ctx.beginPath();
ctx.moveTo(BX + BOARD + 2, BY + BOARD - CS + 1);
ctx.lineTo(BX + BOARD + 9, BY + BOARD - CS / 2);
ctx.lineTo(BX + BOARD + 2, BY + BOARD - 1);
ctx.closePath();
ctx.fill();
}
// 왼쪽 칸의 수치 — 이름표 한 줄 + 값 한 줄
function panelRow(row: number, label: string, value: string): void {
const y = 104 + row * 34;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(PAPER, 0.62);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, 16, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 15px sans-serif';
ctx.fillText(value, 16, y + 18);
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 벽 목록을 섞는 시드 — 같은 시드면 같은 순서로 검사해 같은 미로가 나온다
const WALL_SHUFFLE_SEED = 464;
// 섬 색은 대표 원소 번호를 이 수로 나눈 나머지로 고른다
const ISLAND_HUE_N = 6;
const MERGE_FLASH_MS = 360;
const CHECK_MS = 5;
const HOLD_MS = 1400;
const HUES: RGB[] = [SKY, PINK, MINT, LILAC, PEACH, [250, 232, 160]];
const TXT_CHECK = '섞은 벽을 앞에서부터 검사하는 중';
const TXT_DONE = '완성 — 섬이 하나로 합쳐짐';
const TXT_PAUSE = '멈춤';
const shuffleBtn = root.querySelector('[data-role="shuffle"]') as HTMLButtonElement | null;
const pauseBtn = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
// 안쪽 벽 후보 — 칸 번호×2 + (0 동쪽 벽 · 1 남쪽 벽). 31×31 이면 2×31×30 = 1860장
const wallList: number[] = [];
for (let i = 0; i < CELL_N; i += 1) {
if (i % GRID_N < GRID_N - 1) { wallList.push(i * 2); }
if (Math.floor(i / GRID_N) < GRID_N - 1) { wallList.push(i * 2 + 1); }
}
const walls = new Uint8Array(CELL_N);
const parent = new Int32Array(CELL_N);
const size = new Int32Array(CELL_N);
type Flash = { cell: number; dir: number; age: number };
let flashes: Flash[] = [];
let order: number[] = [];
let wi = 0;
let islands = CELL_N;
let removed = 0;
let skipped = 0;
let pass = 0;
let paused = false;
let stepAcc = 0;
let holdLeft = 0;
let hiRoot = -1;
let hiLeft = 0;
// 피셔–예이츠 섞기 — 뒤에서부터 한 장씩 앞쪽 아무 자리와 바꾼다
function shuffled(seed: number): number[] {
const a = wallList.slice();
const rnd = makeRng(seed);
for (let k = a.length - 1; k > 0; k -= 1) {
const j = Math.floor(rnd() * (k + 1));
const t = a[k];
a[k] = a[j];
a[j] = t;
}
return a;
}
// 대표 원소 찾기 + 경로 압축 — 올라간 길의 칸들을 모두 대표에 바로 붙여 다음 찾기를 짧게 한다
function find(i: number): number {
let r = i;
while (parent[r] !== r) { r = parent[r]; }
let c = i;
while (parent[c] !== r) {
const nx = parent[c];
parent[c] = r;
c = nx;
}
return r;
}
function reset(seed: number): void {
walls.fill(ALL_WALLS);
for (let i = 0; i < CELL_N; i += 1) { parent[i] = i; size[i] = 1; }
order = shuffled(seed);
wi = 0;
islands = CELL_N;
removed = 0;
skipped = 0;
flashes = [];
stepAcc = 0;
holdLeft = 0;
}
// 벽 한 장 검사 — 양쪽 칸의 대표가 다르면 벽을 걷고 작은 섬을 큰 섬 대표 밑에 붙인다(0), 같으면 남긴다(1). 끝나면 2
function checkStep(): number {
if (wi >= order.length) { return 2; }
const code = order[wi];
wi += 1;
const a = code >> 1;
const dir = (code & 1) === 0 ? 1 : 2;
const b = neighbor(a, dir);
let ra = find(a);
let rb = find(b);
if (ra === rb) { skipped += 1; return 1; }
knock(walls, a, dir);
if (size[ra] < size[rb]) { const t = ra; ra = rb; rb = t; }
parent[rb] = ra;
size[ra] += size[rb];
islands -= 1;
removed += 1;
flashes.push({ cell: a, dir: dir, age: 0 });
return 0;
}
function done(): boolean { return wi >= order.length; }
function report(): void {
if (done()) { say(TXT_DONE); }
else if (paused) { say(TXT_PAUSE); }
else { say(TXT_CHECK); }
}
function run(n: number): void {
for (let k = 0; k < n && !done(); k += 1) { checkStep(); }
}
function step(): void {
for (const f of flashes) { f.age += FIXED_DT; }
flashes = flashes.filter(function (f: Flash): boolean { return f.age < MERGE_FLASH_MS; });
if (hiLeft > 0) { hiLeft -= FIXED_DT; if (hiLeft <= 0) { hiRoot = -1; } }
if (paused || done()) { return; }
stepAcc += FIXED_DT;
while (stepAcc >= CHECK_MS && !done()) {
stepAcc -= CHECK_MS;
checkStep();
}
report();
}
function settle(): void {
run(order.length);
flashes = [];
report();
}
function demoTick(t: number): void {
if (!done() || t < 0) { return; }
holdLeft += FIXED_DT;
if (holdLeft >= HOLD_MS) {
pass += 1;
reset(WALL_SHUFFLE_SEED + pass);
}
}
function islandColor(r: number): RGB { return HUES[r % ISLAND_HUE_N]; }
function draw(): void {
clearStage();
const hr = hiRoot >= 0 ? find(hiRoot) : -1;
for (let i = 0; i < CELL_N; i += 1) {
const r = find(i);
let c = mix(islandColor(r), PAPER, 0.28);
if (hr >= 0) { c = r === hr ? mix(c, PAPER, 0.35) : mix(c, BG, 0.55); }
fillCell(i, c, 1);
}
for (const f of flashes) {
const k = 1 - f.age / MERGE_FLASH_MS;
const x = cellX(f.cell) + CS / 2;
const y = cellY(f.cell) + CS / 2;
const mx = x + DX[f.dir] * CS / 2;
const my = y + DY[f.dir] * CS / 2;
ctx.fillStyle = rgba(PAPER, 0.9 * k);
ctx.beginPath();
ctx.arc(mx, my, 2 + 7 * (1 - k), 0, Math.PI * 2);
ctx.fill();
}
strokeWalls(walls, rgba(INK, 1), 2);
drawGates();
panelRow(0, '섬', String(islands));
panelRow(1, '검사한 벽', String(wi) + ' / ' + String(order.length));
panelRow(2, '걷은 벽', String(removed));
panelRow(3, '남긴 벽', String(skipped));
}
function after(): void {
if (still) { settle(); }
report();
draw();
}
function reshuffle(): void {
pass += 1;
reset(WALL_SHUFFLE_SEED + pass);
after();
}
function setPaused(v: boolean): void {
paused = v;
if (pauseBtn) { pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false'); }
report();
draw();
}
// 칸을 누르면 그 칸이 속한 섬 전체를 밝힌다 — 대표가 같은 칸이 모두 한 섬이다
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
const c = cellAt(p[0], p[1]);
if (c < 0) { return; }
hiRoot = c;
hiLeft = 1400;
draw();
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key === ' ' || ev.key === 'Spacebar') { ev.preventDefault(); wake(); setPaused(!paused); }
else if (ev.key === 'ArrowRight') { ev.preventDefault(); wake(); run(60); after(); }
else if (ev.key === 'Home' || ev.key === 'Enter') { ev.preventDefault(); wake(); reshuffle(); }
});
if (shuffleBtn) { shuffleBtn.addEventListener('click', function (): void { wake(); reshuffle(); }); }
if (pauseBtn) { pauseBtn.addEventListener('click', function (): void { wake(); setPaused(!paused); }); }
reset(WALL_SHUFFLE_SEED);
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("hs") + " is-demo"} ref={rootRef}>
<canvas className={cx("hs__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="섞어 놓은 벽 목록을 앞에서부터 검사해 다른 섬 사이 벽만 걷고 섬을 합치는 미로. 칸을 누르면 그 칸의 섬을 밝힌다"></canvas>
<p className={cx("hs__status")} data-role="status" aria-live="off">섞은 벽을 앞에서부터 검사하는 중</p>
<div className={cx("hs__tools")}>
<button className={cx("hs__btn")} data-role="shuffle" type="button">다시 섞기</button>
<button className={cx("hs__btn")} data-role="pause" type="button" aria-pressed="false">멈춤</button>
</div>
</div>
);
}03 · A Maze Built From a Shuffled Wall List Where Islands Merge Into One — Measured area 25.362% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
// Find the representative + path compression: hang every cell on the way up straight onto the representative so the next find is short
function find(i) {
let r = i;
while (parent[r] !== r) {
r = parent[r];
}
let c = i;
while (parent[c] !== r) {
const nx = parent[c];
parent[c] = r;
c = nx;
}
return r;
}
const code = order[wi];
wi += 1;
const a = code >> 1;
const dir = (code & 1) === 0 ? 1 : 2;
const b = neighbor(a, dir);
let ra = find(a);
let rb = find(b);
if (ra === rb) {
skipped += 1;
return 1;
}
knock(walls, a, dir);
if (size[ra] < size[rb]) {
const t = ra;
ra = rb;
rb = t;
}
parent[rb] = ra;
size[ra] += size[rb];
islands -= 1;
removed += 1;
flashes.push({ cell: a, dir: dir, age: 0 });
return 0;
04A Maze That Reopens Dead Ends to Leave Multiple Ways Through
The finished depth-first maze is scanned from cell 0, and each cell with exactly one open side is kept with probability BRAID_KEEP; otherwise one closed wall at its tip comes out and splices it into a side passage. When the cell behind that wall is a dead end too, that wall is chosen first so two alleys disappear in one splice, which is how the 96 dead ends of the seed-464 maze reach 0 after only 95 splices. At the default of 0 the entrance-to-exit distance shrinks from 320 cells to 90, and the render measured 19.1% cumulative area, 33.6 mean intensity and 23/23 moved frames.

<main class="stage">
<div class="hs is-demo" id="hs">
<canvas class="hs__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="A board that scans a finished maze cell by cell and removes the wall at each dead-end tip to join the next passage. Press to change the keep ratio"></canvas>
<p class="hs__status" data-role="status" aria-live="off">Finding dead ends and splicing them</p>
<div class="hs__tools">
<button class="hs__btn" data-role="new" type="button">New maze</button>
<button class="hs__btn" data-role="keep" type="button">Change keep ratio</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.hs {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.hs__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.hs__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.hs__btn:hover {
background: $subject-cream;
}
.hs__btn[aria-pressed="true"] {
background: $subject-mint;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.hs__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.hs__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.hs__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.hs__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hs {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.hs__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.hs__btn:hover {
background: #fff7e6;
}
.hs__btn[aria-pressed=true] {
background: #4cd4a6;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.hs__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.hs__btn {
font-size: 10px;
padding: 4px 8px;
}
.hs__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.hs__btn {
transition: none;
}
}(function () {
const root = document.getElementById("hs");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 바닥을 얹어 명도 차로 길이 읽힌다
const BG = [29, 26, 44];
const ROCK = [44, 39, 64];
const PAPER = [255, 248, 234];
const INK = [58, 48, 76];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 미로가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 격자 — 31×31 칸, 칸마다 벽 네 장을 비트 하나씩으로 든다
const GRID_N = 31;
const CELL_N = GRID_N * GRID_N;
const CS = 9;
const BOARD = GRID_N * CS;
let BX = W - 12 - BOARD;
const BY = (H - BOARD) / 2;
const ENTRY = 0;
const EXIT = CELL_N - 1;
// 방향 0 북 · 1 동 · 2 남 · 3 서 — 시계 방향 순서라 +1 이 오른쪽으로 돌기, +3 이 왼쪽으로 돌기
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
const BIT = [1, 2, 4, 8];
const ALL_WALLS = 15;
function neighbor(i, d) {
const x = (i % GRID_N) + DX[d];
const y = Math.floor(i / GRID_N) + DY[d];
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function isOpen(walls, i, d) {
return (walls[i] & BIT[d]) === 0 && neighbor(i, d) >= 0;
}
// 벽 한 장 걷기 — 양쪽 칸의 비트를 함께 지운다 (한쪽만 지우면 길이 일방통행이 된다)
function knock(walls, i, d) {
const n = neighbor(i, d);
walls[i] &= ~BIT[d];
walls[n] &= ~BIT[(d + 2) % 4];
return n;
}
function openCount(walls, i) {
let c = 0;
for (let d = 0; d < 4; d += 1) {
if (isOpen(walls, i, d)) {
c += 1;
}
}
return c;
}
function cellX(i) { return BX + (i % GRID_N) * CS; }
function cellY(i) { return BY + Math.floor(i / GRID_N) * CS; }
function cellAt(sx, sy) {
const x = Math.floor((sx - BX) / CS);
const y = Math.floor((sy - BY) / CS);
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function fillCell(i, c, a) {
ctx.fillStyle = rgba(c, a);
ctx.fillRect(cellX(i), cellY(i), CS, CS);
}
// 벽 비트를 선분으로 — 칸마다 동·남 벽만 긋고 맨 윗줄·맨 왼줄만 북·서 벽을 더 긋는다 (같은 벽을 두 번 긋지 않는다)
function strokeWalls(walls, color, lw) {
ctx.beginPath();
for (let i = 0; i < CELL_N; i += 1) {
const x = cellX(i);
const y = cellY(i);
const w = walls[i];
if ((w & 2) !== 0) {
ctx.moveTo(x + CS, y);
ctx.lineTo(x + CS, y + CS);
}
if ((w & 4) !== 0) {
ctx.moveTo(x, y + CS);
ctx.lineTo(x + CS, y + CS);
}
if (i < GRID_N && (w & 1) !== 0) {
ctx.moveTo(x, y);
ctx.lineTo(x + CS, y);
}
if (i % GRID_N === 0 && (w & 8) !== 0) {
ctx.moveTo(x, y);
ctx.lineTo(x, y + CS);
}
}
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineCap = 'square';
ctx.stroke();
}
// 입구(왼쪽 위)·출구(오른쪽 아래) 표시 — 바깥 벽 밖에 작은 삼각형
function drawGates() {
ctx.fillStyle = rgba(MINT, 0.95);
ctx.beginPath();
ctx.moveTo(BX - 9, BY + 1);
ctx.lineTo(BX - 2, BY + CS / 2);
ctx.lineTo(BX - 9, BY + CS - 1);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(PINK, 0.95);
ctx.beginPath();
ctx.moveTo(BX + BOARD + 2, BY + BOARD - CS + 1);
ctx.lineTo(BX + BOARD + 9, BY + BOARD - CS / 2);
ctx.lineTo(BX + BOARD + 2, BY + BOARD - 1);
ctx.closePath();
ctx.fill();
}
// 왼쪽 칸의 수치 — 이름표 한 줄 + 값 한 줄
function panelRow(row, label, value) {
const y = 104 + row * 34;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(PAPER, 0.62);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, 16, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 15px sans-serif';
ctx.fillText(value, 16, y + 18);
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 깊이 우선 파기 한 번에 끝까지 — 스택 머리에서 안 판 이웃을 골라 벽을 걷고, 없으면 하나 뺀다
function carveDfsAll(seed, start) {
const walls = new Uint8Array(CELL_N).fill(ALL_WALLS);
const seen = new Uint8Array(CELL_N);
const stk = new Int32Array(CELL_N);
const rnd = makeRng(seed);
let sp = 0;
stk[sp] = start;
sp = 1;
seen[start] = 1;
const opts = [];
while (sp > 0) {
const cur = stk[sp - 1];
opts.length = 0;
for (let d = 0; d < 4; d += 1) {
const n = neighbor(cur, d);
if (n >= 0 && seen[n] === 0) {
opts.push(d);
}
}
if (opts.length === 0) {
sp -= 1;
continue;
}
const n = knock(walls, cur, opts[Math.floor(rnd() * opts.length)]);
seen[n] = 1;
stk[sp] = n;
sp += 1;
}
return walls;
}
// 너비 우선 거리 — 큐에서 꺼낸 칸의 열린 이웃에 거리+1 을 적는다. 닿지 않은 칸은 -1
function bfsDist(walls, src) {
const dist = new Int32Array(CELL_N).fill(-1);
const q = new Int32Array(CELL_N);
let head = 0;
let tail = 0;
dist[src] = 0;
q[tail] = src;
tail += 1;
while (head < tail) {
const c = q[head];
head += 1;
for (let d = 0; d < 4; d += 1) {
if (!isOpen(walls, c, d)) {
continue;
}
const n = neighbor(c, d);
if (dist[n] >= 0) {
continue;
}
dist[n] = dist[c] + 1;
q[tail] = n;
tail += 1;
}
}
return dist;
}
// Dead ends = 열린 쪽이 하나뿐인 칸
function isDeadEnd(walls, i) { return openCount(walls, i) === 1; }
// Dead ends 끝의 벽 하나를 걷어 옆길과 잇는다 — 옆 칸도 막다른 골목이면 그쪽을 먼저 골라 두 골목을 한 번에 없앤다
function spliceAt(walls, i, rnd) {
const shut = [];
const dead = [];
for (let d = 0; d < 4; d += 1) {
const n = neighbor(i, d);
if (n < 0 || (walls[i] & BIT[d]) === 0) {
continue;
}
shut.push(d);
if (isDeadEnd(walls, n)) {
dead.push(d);
}
}
const pool = dead.length > 0 ? dead : shut;
if (pool.length === 0) {
return -1;
}
const d = pool[Math.floor(rnd() * pool.length)];
knock(walls, i, d);
return d;
}
// 판 전체를 한 번 훑어 막다른 골목마다 keep 확률로 남기고 나머지는 잇는다 — 이은 수를 돌려준다
function braidAll(walls, keep, rnd) {
let joined = 0;
for (let i = 0; i < CELL_N; i += 1) {
if (!isDeadEnd(walls, i)) {
continue;
}
if (rnd() < keep) {
continue;
}
if (spliceAt(walls, i, rnd) >= 0) {
joined += 1;
}
}
return joined;
}
// Dead ends 가운데 그대로 Keep ratio — 0 이면 하나도 남기지 않고 전부 옆길과 잇는다
const BRAID_KEEP = 0;
// 칸 하나를 훑는 데 쓰는 시간 — 961칸이면 한 번 훑는 데 약 5.8초
const DEADEND_SCAN_MS = 6;
// 이은 벽 자리에 번지는 빛의 반지름(px)
const SPLICE_GLOW = 11;
const GLOW_MS = 900;
const SEED = 464;
const HOLD_MS = 1400;
const KEEP_STEPS = [BRAID_KEEP, 0.3, 0.6, 1];
const TXT_SCAN = 'Finding dead ends and splicing them';
const TXT_DONE = 'Scan done — spliced by the keep ratio';
const newBtn = root.querySelector('[data-role="new"]');
const keepBtn = root.querySelector('[data-role="keep"]');
let keep = BRAID_KEEP;
let pass = 0;
let base = carveDfsAll(SEED, ENTRY);
const walls = new Uint8Array(CELL_N);
const joinedMark = new Uint8Array(CELL_N);
const keptMark = new Uint8Array(CELL_N);
let rnd = makeRng(SEED);
let glows = [];
let si = 0;
let joined = 0;
let kept = 0;
let distNow = 0;
let stepAcc = 0;
let holdLeft = 0;
function deadCount() {
let c = 0;
for (let i = 0; i < CELL_N; i += 1) {
if (isDeadEnd(walls, i)) {
c += 1;
}
}
return c;
}
// 같은 미로를 처음 상태로 되돌려 다시 훑는다 — 비율을 바꿔도 미로는 그대로라 차이만 보인다
function rescan() {
walls.set(base);
joinedMark.fill(0);
keptMark.fill(0);
rnd = makeRng(SEED + pass * 7 + 1);
glows = [];
si = 0;
joined = 0;
kept = 0;
distNow = bfsDist(walls, ENTRY)[EXIT];
stepAcc = 0;
holdLeft = 0;
}
// 칸 하나 훑기 — 지금 막다른 골목이면 keep 확률로 남기고, 아니면 끝의 벽 하나를 걷어 옆길과 잇는다
function scanStep() {
if (si >= CELL_N) {
return;
}
const i = si;
si += 1;
if (!isDeadEnd(walls, i)) {
return;
}
if (rnd() < keep) {
kept += 1;
keptMark[i] = 1;
return;
}
const d = spliceAt(walls, i, rnd);
if (d < 0) {
return;
}
joined += 1;
joinedMark[i] = 1;
joinedMark[neighbor(i, d)] = 1;
glows.push({ cell: i, dir: d, age: 0 });
distNow = bfsDist(walls, ENTRY)[EXIT];
}
function done() { return si >= CELL_N; }
function report() { say(done() ? TXT_DONE : TXT_SCAN); }
function scan(n) {
for (let k = 0; k < n && !done(); k += 1) {
scanStep();
}
}
function step() {
for (const g of glows) {
g.age += FIXED_DT;
}
glows = glows.filter(function (g) { return g.age < GLOW_MS; });
if (done()) {
return;
}
stepAcc += FIXED_DT;
while (stepAcc >= DEADEND_SCAN_MS && !done()) {
stepAcc -= DEADEND_SCAN_MS;
scanStep();
}
report();
}
function settle() {
scan(CELL_N);
glows = [];
report();
}
function demoTick(t) {
if (!done() || t < 0) {
return;
}
holdLeft += FIXED_DT;
if (holdLeft >= HOLD_MS) {
newMaze();
}
}
const FLOOR = mix(PAPER, BG, 0.04);
const SCANNED = mix(PAPER, SKY, 0.22);
function draw() {
clearStage();
for (let i = 0; i < CELL_N; i += 1) {
let c = i < si ? SCANNED : FLOOR;
if (joinedMark[i] === 1) {
c = mix(c, MINT, 0.55);
}
fillCell(i, c, 1);
}
if (!done()) {
// 지금 훑는 줄 — 줄 전체를 옅게 덮고 지금 칸은 진하게
const row = Math.floor(si / GRID_N);
ctx.fillStyle = rgba(LILAC, 0.35);
ctx.fillRect(BX, BY + row * CS, BOARD, CS);
ctx.fillStyle = rgba(LILAC, 0.9);
ctx.fillRect(cellX(si), cellY(si), CS, CS);
}
strokeWalls(walls, rgba(INK, 1), 2);
for (let i = 0; i < CELL_N; i += 1) {
const dead = isDeadEnd(walls, i);
if (!dead) {
continue;
}
ctx.fillStyle = rgba(PINK, keptMark[i] === 1 ? 1 : 0.85);
ctx.beginPath();
ctx.arc(cellX(i) + CS / 2, cellY(i) + CS / 2, keptMark[i] === 1 ? 2.6 : 1.9, 0, Math.PI * 2);
ctx.fill();
}
for (const g of glows) {
const k = 1 - g.age / GLOW_MS;
const x = cellX(g.cell) + CS / 2 + DX[g.dir] * CS / 2;
const y = cellY(g.cell) + CS / 2 + DY[g.dir] * CS / 2;
const gr = ctx.createRadialGradient(x, y, 0, x, y, SPLICE_GLOW);
gr.addColorStop(0, rgba(PAPER, 0.95 * k));
gr.addColorStop(0.45, rgba(MINT, 0.7 * k));
gr.addColorStop(1, rgba(MINT, 0));
ctx.fillStyle = gr;
ctx.fillRect(x - SPLICE_GLOW, y - SPLICE_GLOW, SPLICE_GLOW * 2, SPLICE_GLOW * 2);
}
drawGates();
panelRow(0, 'Dead ends', String(deadCount()));
panelRow(1, 'Spliced', String(joined));
panelRow(2, 'Keep ratio', keep.toFixed(1));
panelRow(3, 'Entrance→exit', String(distNow));
}
function after() {
if (still) {
settle();
}
report();
draw();
}
function newMaze() {
pass += 1;
base = carveDfsAll(SEED + pass, ENTRY);
rescan();
after();
}
function setKeep(k) {
keep = k;
rescan();
after();
}
function stepKeep(dirn) {
const i = KEEP_STEPS.indexOf(keep);
setKeep(KEEP_STEPS[(i + dirn + KEEP_STEPS.length) % KEEP_STEPS.length]);
}
cv.addEventListener('pointerdown', function () { wake(); stepKeep(1); });
cv.addEventListener('keydown', function (ev) {
if (ev.key === 'ArrowUp') {
ev.preventDefault();
wake();
stepKeep(1);
}
else if (ev.key === 'ArrowDown') {
ev.preventDefault();
wake();
stepKeep(-1);
}
else if (ev.key === 'ArrowRight') {
ev.preventDefault();
wake();
scan(60);
after();
}
else if (ev.key === 'Home' || ev.key === 'Enter') {
ev.preventDefault();
wake();
newMaze();
}
});
if (newBtn) {
newBtn.addEventListener('click', function () { wake(); newMaze(); });
}
if (keepBtn) {
keepBtn.addEventListener('click', function () { wake(); stepKeep(1); });
}
rescan();
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// MzBraidOpenLoopDeadend - vanilla 판(04-mz-braid-open-loop-deadend) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./MzBraidOpenLoopDeadend.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function MzBraidOpenLoopDeadend() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 바닥을 얹어 명도 차로 길이 읽힌다
const BG: RGB = [29, 26, 44];
const ROCK: RGB = [44, 39, 64];
const PAPER: RGB = [255, 248, 234];
const INK: RGB = [58, 48, 76];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 미로가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 격자 — 31×31 칸, 칸마다 벽 네 장을 비트 하나씩으로 든다
const GRID_N = 31;
const CELL_N = GRID_N * GRID_N;
const CS = 9;
const BOARD = GRID_N * CS;
let BX = W - 12 - BOARD;
const BY = (H - BOARD) / 2;
const ENTRY = 0;
const EXIT = CELL_N - 1;
// 방향 0 북 · 1 동 · 2 남 · 3 서 — 시계 방향 순서라 +1 이 오른쪽으로 돌기, +3 이 왼쪽으로 돌기
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
const BIT = [1, 2, 4, 8];
const ALL_WALLS = 15;
function neighbor(i: number, d: number): number {
const x = (i % GRID_N) + DX[d];
const y = Math.floor(i / GRID_N) + DY[d];
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function isOpen(walls: Uint8Array, i: number, d: number): boolean {
return (walls[i] & BIT[d]) === 0 && neighbor(i, d) >= 0;
}
// 벽 한 장 걷기 — 양쪽 칸의 비트를 함께 지운다 (한쪽만 지우면 길이 일방통행이 된다)
function knock(walls: Uint8Array, i: number, d: number): number {
const n = neighbor(i, d);
walls[i] &= ~BIT[d];
walls[n] &= ~BIT[(d + 2) % 4];
return n;
}
function openCount(walls: Uint8Array, i: number): number {
let c = 0;
for (let d = 0; d < 4; d += 1) { if (isOpen(walls, i, d)) { c += 1; } }
return c;
}
function cellX(i: number): number { return BX + (i % GRID_N) * CS; }
function cellY(i: number): number { return BY + Math.floor(i / GRID_N) * CS; }
function cellAt(sx: number, sy: number): number {
const x = Math.floor((sx - BX) / CS);
const y = Math.floor((sy - BY) / CS);
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function fillCell(i: number, c: RGB, a: number): void {
ctx.fillStyle = rgba(c, a);
ctx.fillRect(cellX(i), cellY(i), CS, CS);
}
// 벽 비트를 선분으로 — 칸마다 동·남 벽만 긋고 맨 윗줄·맨 왼줄만 북·서 벽을 더 긋는다 (같은 벽을 두 번 긋지 않는다)
function strokeWalls(walls: Uint8Array, color: string, lw: number): void {
ctx.beginPath();
for (let i = 0; i < CELL_N; i += 1) {
const x = cellX(i);
const y = cellY(i);
const w = walls[i];
if ((w & 2) !== 0) { ctx.moveTo(x + CS, y); ctx.lineTo(x + CS, y + CS); }
if ((w & 4) !== 0) { ctx.moveTo(x, y + CS); ctx.lineTo(x + CS, y + CS); }
if (i < GRID_N && (w & 1) !== 0) { ctx.moveTo(x, y); ctx.lineTo(x + CS, y); }
if (i % GRID_N === 0 && (w & 8) !== 0) { ctx.moveTo(x, y); ctx.lineTo(x, y + CS); }
}
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineCap = 'square';
ctx.stroke();
}
// 입구(왼쪽 위)·출구(오른쪽 아래) 표시 — 바깥 벽 밖에 작은 삼각형
function drawGates(): void {
ctx.fillStyle = rgba(MINT, 0.95);
ctx.beginPath();
ctx.moveTo(BX - 9, BY + 1);
ctx.lineTo(BX - 2, BY + CS / 2);
ctx.lineTo(BX - 9, BY + CS - 1);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(PINK, 0.95);
ctx.beginPath();
ctx.moveTo(BX + BOARD + 2, BY + BOARD - CS + 1);
ctx.lineTo(BX + BOARD + 9, BY + BOARD - CS / 2);
ctx.lineTo(BX + BOARD + 2, BY + BOARD - 1);
ctx.closePath();
ctx.fill();
}
// 왼쪽 칸의 수치 — 이름표 한 줄 + 값 한 줄
function panelRow(row: number, label: string, value: string): void {
const y = 104 + row * 34;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(PAPER, 0.62);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, 16, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 15px sans-serif';
ctx.fillText(value, 16, y + 18);
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 깊이 우선 파기 한 번에 끝까지 — 스택 머리에서 안 판 이웃을 골라 벽을 걷고, 없으면 하나 뺀다
function carveDfsAll(seed: number, start: number): Uint8Array {
const walls = new Uint8Array(CELL_N).fill(ALL_WALLS);
const seen = new Uint8Array(CELL_N);
const stk = new Int32Array(CELL_N);
const rnd = makeRng(seed);
let sp = 0;
stk[sp] = start;
sp = 1;
seen[start] = 1;
const opts: number[] = [];
while (sp > 0) {
const cur = stk[sp - 1];
opts.length = 0;
for (let d = 0; d < 4; d += 1) {
const n = neighbor(cur, d);
if (n >= 0 && seen[n] === 0) { opts.push(d); }
}
if (opts.length === 0) { sp -= 1; continue; }
const n = knock(walls, cur, opts[Math.floor(rnd() * opts.length)]);
seen[n] = 1;
stk[sp] = n;
sp += 1;
}
return walls;
}
// 너비 우선 거리 — 큐에서 꺼낸 칸의 열린 이웃에 거리+1 을 적는다. 닿지 않은 칸은 -1
function bfsDist(walls: Uint8Array, src: number): Int32Array {
const dist = new Int32Array(CELL_N).fill(-1);
const q = new Int32Array(CELL_N);
let head = 0;
let tail = 0;
dist[src] = 0;
q[tail] = src;
tail += 1;
while (head < tail) {
const c = q[head];
head += 1;
for (let d = 0; d < 4; d += 1) {
if (!isOpen(walls, c, d)) { continue; }
const n = neighbor(c, d);
if (dist[n] >= 0) { continue; }
dist[n] = dist[c] + 1;
q[tail] = n;
tail += 1;
}
}
return dist;
}
// 막다른 골목 = 열린 쪽이 하나뿐인 칸
function isDeadEnd(walls: Uint8Array, i: number): boolean { return openCount(walls, i) === 1; }
// 막다른 골목 끝의 벽 하나를 걷어 옆길과 잇는다 — 옆 칸도 막다른 골목이면 그쪽을 먼저 골라 두 골목을 한 번에 없앤다
function spliceAt(walls: Uint8Array, i: number, rnd: () => number): number {
const shut: number[] = [];
const dead: number[] = [];
for (let d = 0; d < 4; d += 1) {
const n = neighbor(i, d);
if (n < 0 || (walls[i] & BIT[d]) === 0) { continue; }
shut.push(d);
if (isDeadEnd(walls, n)) { dead.push(d); }
}
const pool = dead.length > 0 ? dead : shut;
if (pool.length === 0) { return -1; }
const d = pool[Math.floor(rnd() * pool.length)];
knock(walls, i, d);
return d;
}
// 판 전체를 한 번 훑어 막다른 골목마다 keep 확률로 남기고 나머지는 잇는다 — 이은 수를 돌려준다
function braidAll(walls: Uint8Array, keep: number, rnd: () => number): number {
let joined = 0;
for (let i = 0; i < CELL_N; i += 1) {
if (!isDeadEnd(walls, i)) { continue; }
if (rnd() < keep) { continue; }
if (spliceAt(walls, i, rnd) >= 0) { joined += 1; }
}
return joined;
}
// 막다른 골목 가운데 그대로 남길 비율 — 0 이면 하나도 남기지 않고 전부 옆길과 잇는다
const BRAID_KEEP = 0;
// 칸 하나를 훑는 데 쓰는 시간 — 961칸이면 한 번 훑는 데 약 5.8초
const DEADEND_SCAN_MS = 6;
// 이은 벽 자리에 번지는 빛의 반지름(px)
const SPLICE_GLOW = 11;
const GLOW_MS = 900;
const SEED = 464;
const HOLD_MS = 1400;
const KEEP_STEPS = [BRAID_KEEP, 0.3, 0.6, 1];
const TXT_SCAN = '막다른 골목을 찾아 잇는 중';
const TXT_DONE = '훑기 끝 — 남길 비율대로 이음';
const newBtn = root.querySelector('[data-role="new"]') as HTMLButtonElement | null;
const keepBtn = root.querySelector('[data-role="keep"]') as HTMLButtonElement | null;
type Glow = { cell: number; dir: number; age: number };
let keep = BRAID_KEEP;
let pass = 0;
let base = carveDfsAll(SEED, ENTRY);
const walls = new Uint8Array(CELL_N);
const joinedMark = new Uint8Array(CELL_N);
const keptMark = new Uint8Array(CELL_N);
let rnd = makeRng(SEED);
let glows: Glow[] = [];
let si = 0;
let joined = 0;
let kept = 0;
let distNow = 0;
let stepAcc = 0;
let holdLeft = 0;
function deadCount(): number {
let c = 0;
for (let i = 0; i < CELL_N; i += 1) { if (isDeadEnd(walls, i)) { c += 1; } }
return c;
}
// 같은 미로를 처음 상태로 되돌려 다시 훑는다 — 비율을 바꿔도 미로는 그대로라 차이만 보인다
function rescan(): void {
walls.set(base);
joinedMark.fill(0);
keptMark.fill(0);
rnd = makeRng(SEED + pass * 7 + 1);
glows = [];
si = 0;
joined = 0;
kept = 0;
distNow = bfsDist(walls, ENTRY)[EXIT];
stepAcc = 0;
holdLeft = 0;
}
// 칸 하나 훑기 — 지금 막다른 골목이면 keep 확률로 남기고, 아니면 끝의 벽 하나를 걷어 옆길과 잇는다
function scanStep(): void {
if (si >= CELL_N) { return; }
const i = si;
si += 1;
if (!isDeadEnd(walls, i)) { return; }
if (rnd() < keep) { kept += 1; keptMark[i] = 1; return; }
const d = spliceAt(walls, i, rnd);
if (d < 0) { return; }
joined += 1;
joinedMark[i] = 1;
joinedMark[neighbor(i, d)] = 1;
glows.push({ cell: i, dir: d, age: 0 });
distNow = bfsDist(walls, ENTRY)[EXIT];
}
function done(): boolean { return si >= CELL_N; }
function report(): void { say(done() ? TXT_DONE : TXT_SCAN); }
function scan(n: number): void {
for (let k = 0; k < n && !done(); k += 1) { scanStep(); }
}
function step(): void {
for (const g of glows) { g.age += FIXED_DT; }
glows = glows.filter(function (g: Glow): boolean { return g.age < GLOW_MS; });
if (done()) { return; }
stepAcc += FIXED_DT;
while (stepAcc >= DEADEND_SCAN_MS && !done()) {
stepAcc -= DEADEND_SCAN_MS;
scanStep();
}
report();
}
function settle(): void {
scan(CELL_N);
glows = [];
report();
}
function demoTick(t: number): void {
if (!done() || t < 0) { return; }
holdLeft += FIXED_DT;
if (holdLeft >= HOLD_MS) { newMaze(); }
}
const FLOOR = mix(PAPER, BG, 0.04);
const SCANNED = mix(PAPER, SKY, 0.22);
function draw(): void {
clearStage();
for (let i = 0; i < CELL_N; i += 1) {
let c = i < si ? SCANNED : FLOOR;
if (joinedMark[i] === 1) { c = mix(c, MINT, 0.55); }
fillCell(i, c, 1);
}
if (!done()) {
// 지금 훑는 줄 — 줄 전체를 옅게 덮고 지금 칸은 진하게
const row = Math.floor(si / GRID_N);
ctx.fillStyle = rgba(LILAC, 0.35);
ctx.fillRect(BX, BY + row * CS, BOARD, CS);
ctx.fillStyle = rgba(LILAC, 0.9);
ctx.fillRect(cellX(si), cellY(si), CS, CS);
}
strokeWalls(walls, rgba(INK, 1), 2);
for (let i = 0; i < CELL_N; i += 1) {
const dead = isDeadEnd(walls, i);
if (!dead) { continue; }
ctx.fillStyle = rgba(PINK, keptMark[i] === 1 ? 1 : 0.85);
ctx.beginPath();
ctx.arc(cellX(i) + CS / 2, cellY(i) + CS / 2, keptMark[i] === 1 ? 2.6 : 1.9, 0, Math.PI * 2);
ctx.fill();
}
for (const g of glows) {
const k = 1 - g.age / GLOW_MS;
const x = cellX(g.cell) + CS / 2 + DX[g.dir] * CS / 2;
const y = cellY(g.cell) + CS / 2 + DY[g.dir] * CS / 2;
const gr = ctx.createRadialGradient(x, y, 0, x, y, SPLICE_GLOW);
gr.addColorStop(0, rgba(PAPER, 0.95 * k));
gr.addColorStop(0.45, rgba(MINT, 0.7 * k));
gr.addColorStop(1, rgba(MINT, 0));
ctx.fillStyle = gr;
ctx.fillRect(x - SPLICE_GLOW, y - SPLICE_GLOW, SPLICE_GLOW * 2, SPLICE_GLOW * 2);
}
drawGates();
panelRow(0, '막다른 골목', String(deadCount()));
panelRow(1, '이은 골목', String(joined));
panelRow(2, '남길 비율', keep.toFixed(1));
panelRow(3, '입구→출구 거리', String(distNow));
}
function after(): void {
if (still) { settle(); }
report();
draw();
}
function newMaze(): void {
pass += 1;
base = carveDfsAll(SEED + pass, ENTRY);
rescan();
after();
}
function setKeep(k: number): void {
keep = k;
rescan();
after();
}
function stepKeep(dirn: number): void {
const i = KEEP_STEPS.indexOf(keep);
setKeep(KEEP_STEPS[(i + dirn + KEEP_STEPS.length) % KEEP_STEPS.length]);
}
cv.addEventListener('pointerdown', function (): void { wake(); stepKeep(1); });
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key === 'ArrowUp') { ev.preventDefault(); wake(); stepKeep(1); }
else if (ev.key === 'ArrowDown') { ev.preventDefault(); wake(); stepKeep(-1); }
else if (ev.key === 'ArrowRight') { ev.preventDefault(); wake(); scan(60); after(); }
else if (ev.key === 'Home' || ev.key === 'Enter') { ev.preventDefault(); wake(); newMaze(); }
});
if (newBtn) { newBtn.addEventListener('click', function (): void { wake(); newMaze(); }); }
if (keepBtn) { keepBtn.addEventListener('click', function (): void { wake(); stepKeep(1); }); }
rescan();
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("hs") + " is-demo"} ref={rootRef}>
<canvas className={cx("hs__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="완성된 미로를 한 칸씩 훑어 막다른 골목 끝의 벽을 걷어 옆길과 잇는 판. 누르면 남길 비율이 바뀐다"></canvas>
<p className={cx("hs__status")} data-role="status" aria-live="off">막다른 골목을 찾아 잇는 중</p>
<div className={cx("hs__tools")}>
<button className={cx("hs__btn")} data-role="new" type="button">새 미로</button>
<button className={cx("hs__btn")} data-role="keep" type="button">남길 비율 바꾸기</button>
</div>
</div>
);
}04 · A Maze That Reopens Dead Ends to Leave Multiple Ways Through — Measured area 19.073% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
// Remove one wall at a dead end's tip to join a side passage: if the neighbor is also a dead end, pick it first and clear two alleys at once
function spliceAt(walls, i, rnd) {
const shut = [];
const dead = [];
for (let d = 0; d < 4; d += 1) {
const n = neighbor(i, d);
if (n < 0 || (walls[i] & BIT[d]) === 0) {
continue;
}
shut.push(d);
if (isDeadEnd(walls, n)) {
dead.push(d);
}
}
const pool = dead.length > 0 ? dead : shut;
if (pool.length === 0) {
return -1;
}
const d = pool[Math.floor(rnd() * pool.length)];
knock(walls, i, d);
return d;
}
05A Maze Where a Spreading Wave Computes the Distance to Every Cell
The entrance cell gets 0, and every 20ms each cell in the current ring writes its distance plus one into every open neighbor that is still empty, which forms the next ring of a breadth-first distance flood. Color divides each distance by the larger of the current ring and 60 (HEAT_MAX_D) and lays it on a sky, mint, peach and pink ramp; on seed 464 the wave covers all 961 cells in 66 rings (1,320ms), and cell 932 in the last ring surfaces as the farthest room. Pressing From farthest room floods again from that cell, the cell reached at the end of 69 rings marks the far end of the longest path (69 cells), and the render measured 41.1% cumulative area, 122.5 mean intensity and 19/23 moved frames.

<main class="stage">
<div class="hs is-demo" id="hs">
<canvas class="hs__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="A maze where a wave spreading one ring at a time from the entrance writes each cell's distance as a heat map and marks the farthest room. Press a cell to spread from there"></canvas>
<p class="hs__status" data-role="status" aria-live="off">Spreading from the entrance one ring at a time</p>
<div class="hs__tools">
<button class="hs__btn" data-role="far" type="button">From farthest room</button>
<button class="hs__btn" data-role="new" type="button">New maze</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.hs {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.hs__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.hs__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.hs__btn:hover {
background: $subject-cream;
}
.hs__btn[aria-pressed="true"] {
background: $subject-mint;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.hs__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.hs__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.hs__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.hs__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hs {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.hs__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.hs__btn:hover {
background: #fff7e6;
}
.hs__btn[aria-pressed=true] {
background: #4cd4a6;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.hs__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.hs__btn {
font-size: 10px;
padding: 4px 8px;
}
.hs__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.hs__btn {
transition: none;
}
}(function () {
const root = document.getElementById("hs");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 바닥을 얹어 명도 차로 길이 읽힌다
const BG = [29, 26, 44];
const ROCK = [44, 39, 64];
const PAPER = [255, 248, 234];
const INK = [58, 48, 76];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 미로가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 격자 — 31×31 칸, 칸마다 벽 네 장을 비트 하나씩으로 든다
const GRID_N = 31;
const CELL_N = GRID_N * GRID_N;
const CS = 9;
const BOARD = GRID_N * CS;
let BX = W - 12 - BOARD;
const BY = (H - BOARD) / 2;
const ENTRY = 0;
const EXIT = CELL_N - 1;
// 방향 0 북 · 1 동 · 2 남 · 3 서 — 시계 방향 순서라 +1 이 오른쪽으로 돌기, +3 이 왼쪽으로 돌기
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
const BIT = [1, 2, 4, 8];
const ALL_WALLS = 15;
function neighbor(i, d) {
const x = (i % GRID_N) + DX[d];
const y = Math.floor(i / GRID_N) + DY[d];
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function isOpen(walls, i, d) {
return (walls[i] & BIT[d]) === 0 && neighbor(i, d) >= 0;
}
// 벽 한 장 걷기 — 양쪽 칸의 비트를 함께 지운다 (한쪽만 지우면 길이 일방통행이 된다)
function knock(walls, i, d) {
const n = neighbor(i, d);
walls[i] &= ~BIT[d];
walls[n] &= ~BIT[(d + 2) % 4];
return n;
}
function openCount(walls, i) {
let c = 0;
for (let d = 0; d < 4; d += 1) {
if (isOpen(walls, i, d)) {
c += 1;
}
}
return c;
}
function cellX(i) { return BX + (i % GRID_N) * CS; }
function cellY(i) { return BY + Math.floor(i / GRID_N) * CS; }
function cellAt(sx, sy) {
const x = Math.floor((sx - BX) / CS);
const y = Math.floor((sy - BY) / CS);
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function fillCell(i, c, a) {
ctx.fillStyle = rgba(c, a);
ctx.fillRect(cellX(i), cellY(i), CS, CS);
}
// 벽 비트를 선분으로 — 칸마다 동·남 벽만 긋고 맨 윗줄·맨 왼줄만 북·서 벽을 더 긋는다 (같은 벽을 두 번 긋지 않는다)
function strokeWalls(walls, color, lw) {
ctx.beginPath();
for (let i = 0; i < CELL_N; i += 1) {
const x = cellX(i);
const y = cellY(i);
const w = walls[i];
if ((w & 2) !== 0) {
ctx.moveTo(x + CS, y);
ctx.lineTo(x + CS, y + CS);
}
if ((w & 4) !== 0) {
ctx.moveTo(x, y + CS);
ctx.lineTo(x + CS, y + CS);
}
if (i < GRID_N && (w & 1) !== 0) {
ctx.moveTo(x, y);
ctx.lineTo(x + CS, y);
}
if (i % GRID_N === 0 && (w & 8) !== 0) {
ctx.moveTo(x, y);
ctx.lineTo(x, y + CS);
}
}
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineCap = 'square';
ctx.stroke();
}
// 입구(왼쪽 위)·출구(오른쪽 아래) 표시 — 바깥 벽 밖에 작은 삼각형
function drawGates() {
ctx.fillStyle = rgba(MINT, 0.95);
ctx.beginPath();
ctx.moveTo(BX - 9, BY + 1);
ctx.lineTo(BX - 2, BY + CS / 2);
ctx.lineTo(BX - 9, BY + CS - 1);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(PINK, 0.95);
ctx.beginPath();
ctx.moveTo(BX + BOARD + 2, BY + BOARD - CS + 1);
ctx.lineTo(BX + BOARD + 9, BY + BOARD - CS / 2);
ctx.lineTo(BX + BOARD + 2, BY + BOARD - 1);
ctx.closePath();
ctx.fill();
}
// 왼쪽 칸의 수치 — 이름표 한 줄 + 값 한 줄
function panelRow(row, label, value) {
const y = 104 + row * 34;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(PAPER, 0.62);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, 16, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 15px sans-serif';
ctx.fillText(value, 16, y + 18);
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
function addFront(g, c) {
for (let d = 0; d < 4; d += 1) {
const n = neighbor(c, d);
if (n < 0 || g.inMaze[n] === 1 || g.inFront[n] === 1) {
continue;
}
g.inFront[n] = 1;
g.front.push(n);
}
}
function growStart(seed, start) {
const g = { walls: new Uint8Array(CELL_N).fill(ALL_WALLS), inMaze: new Uint8Array(CELL_N), inFront: new Uint8Array(CELL_N), front: [], order: new Int32Array(CELL_N).fill(-1), count: 0, last: start, rnd: makeRng(seed) };
g.inMaze[start] = 1;
g.order[start] = 0;
g.count = 1;
addFront(g, start);
return g;
}
// Frontier(판 영역에 닿은 안 Carved cells)에서 하나를 골라 이미 판 이웃 하나와 벽 한 장으로 잇는다
function growOne(g, mode) {
const len = g.front.length;
if (len === 0) {
return -1;
}
let c = 0;
if (mode === 'newest') {
c = g.front.pop();
}
else if (mode === 'oldest') {
c = g.front.shift();
}
else {
const k = Math.floor(g.rnd() * len);
c = g.front[k];
g.front[k] = g.front[len - 1];
g.front.pop();
}
g.inFront[c] = 0;
const links = [];
for (let d = 0; d < 4; d += 1) {
const n = neighbor(c, d);
if (n >= 0 && g.inMaze[n] === 1) {
links.push(d);
}
}
knock(g.walls, c, links[Math.floor(g.rnd() * links.length)]);
g.inMaze[c] = 1;
g.order[c] = g.count;
g.count += 1;
g.last = c;
addFront(g, c);
return c;
}
function growAll(seed, start, mode) {
const g = growStart(seed, start);
while (growOne(g, mode) >= 0) { /* 프론티어가 빌 때까지 */ }
return g.walls;
}
// 한 겹(같은 거리의 칸들)을 번지는 데 쓰는 시간
const FLOOD_STEP_MS = 20;
// 열지도 눈금의 바닥 — 파도가 이 거리를 넘기 전까지는 눈금을 이 값으로 고정해 초반 색이 출렁이지 않게 한다
const HEAT_MAX_D = 60;
// 지금 막 닿은 겹(파도 앞머리)을 덮는 밝기
const RING_ALPHA = 0.85;
const SEED = 464;
const HOLD_MS = 1400;
const GAP_MS = 500;
const HEAT_RAMP = [SKY, MINT, PEACH, PINK];
const TXT_FLOOD = 'Spreading from the entrance one ring at a time';
const TXT_FLOOD2 = 'Spreading again from the farthest room';
const TXT_DONE1 = 'Done — found the farthest room';
const TXT_DONE2 = 'Done — found the two rooms farthest apart';
const farBtn = root.querySelector('[data-role="far"]');
const newBtn = root.querySelector('[data-role="new"]');
let pass = 0;
let walls = growAll(SEED, Math.floor(CELL_N / 2), 'random');
let dist = new Int32Array(CELL_N).fill(-1);
let layer = [];
let front = 0;
let reached = 0;
let src = ENTRY;
let farCell = ENTRY;
let sweep = 1;
let longest = -1;
let longEnd = -1;
let stepAcc = 0;
let holdLeft = 0;
function startFlood(s, sw) {
src = s;
sweep = sw;
dist = new Int32Array(CELL_N).fill(-1);
dist[s] = 0;
layer = [s];
front = 0;
reached = 1;
farCell = s;
stepAcc = 0;
holdLeft = 0;
}
// 한 겹 번지기 — 지금 겹의 칸마다 열린 이웃 중 아직 거리가 없는 칸에 거리+1 을 적어 다음 겹을 만든다
function floodStep() {
if (layer.length === 0) {
return;
}
const next = [];
for (const c of layer) {
for (let d = 0; d < 4; d += 1) {
if (!isOpen(walls, c, d)) {
continue;
}
const n = neighbor(c, d);
if (dist[n] >= 0) {
continue;
}
dist[n] = front + 1;
next.push(n);
}
}
if (next.length === 0) {
layer = [];
if (sweep === 2) {
longest = front;
longEnd = farCell;
}
return;
}
front += 1;
layer = next;
reached += next.length;
farCell = next[next.length - 1];
}
function flooding() { return layer.length > 0; }
function report() {
if (flooding()) {
say(sweep === 2 ? TXT_FLOOD2 : TXT_FLOOD);
}
else {
say(sweep === 2 ? TXT_DONE2 : TXT_DONE1);
}
}
function flood(n) {
for (let k = 0; k < n && flooding(); k += 1) {
floodStep();
}
}
function step() {
if (!flooding()) {
return;
}
stepAcc += FIXED_DT;
while (stepAcc >= FLOOD_STEP_MS && flooding()) {
stepAcc -= FLOOD_STEP_MS;
floodStep();
}
report();
}
function settle() {
flood(CELL_N);
report();
}
// 시연 — 입구에서 번진 뒤 From farthest room 한 번 더 번진다 (가지 없는 길이 하나뿐인 미로에서 두 번째 끝이 가장 긴 길의 양 끝)
function demoTick(t) {
if (flooding() || t < 0) {
return;
}
holdLeft += FIXED_DT;
if (sweep === 1 && holdLeft >= GAP_MS) {
startFlood(farCell, 2);
}
else if (sweep === 2 && holdLeft >= HOLD_MS) {
newMaze();
}
}
function heat(t) {
const x = clamp(t, 0, 1) * (HEAT_RAMP.length - 1);
const i = Math.min(Math.floor(x), HEAT_RAMP.length - 2);
return mix(HEAT_RAMP[i], HEAT_RAMP[i + 1], x - i);
}
function ringAt(c, col, r) {
ctx.strokeStyle = rgba(col, 1);
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cellX(c) + CS / 2, cellY(c) + CS / 2, r, 0, Math.PI * 2);
ctx.stroke();
}
const UNREACHED = mix(ROCK, PAPER, 0.12);
function draw() {
clearStage();
const scale = Math.max(front, HEAT_MAX_D);
for (let i = 0; i < CELL_N; i += 1) {
const d = dist[i];
if (d < 0) {
fillCell(i, UNREACHED, 1);
continue;
}
fillCell(i, mix(heat(d / scale), PAPER, 0.18), 1);
if (flooding() && d === front) {
fillCell(i, PAPER, RING_ALPHA);
}
}
strokeWalls(walls, rgba(INK, 1), 2);
ringAt(src, PAPER, 5);
ringAt(farCell, PAPER, 6.5);
drawGates();
panelRow(0, 'Distance ring', String(front));
panelRow(1, 'Cells reached', String(reached) + ' / ' + String(CELL_N));
panelRow(2, 'To farthest room', String(dist[farCell]));
panelRow(3, 'Longest path', longest >= 0 ? String(longest) : '—');
}
function after() {
if (still) {
settle();
}
report();
draw();
}
function newMaze() {
pass += 1;
walls = growAll(SEED + pass, Math.floor(CELL_N / 2), 'random');
longest = -1;
longEnd = -1;
startFlood(ENTRY, 1);
after();
}
function fromFar() {
if (flooding()) {
settle();
}
startFlood(farCell, 2);
after();
}
cv.addEventListener('pointerdown', function (ev) {
wake();
const p = toScene(ev);
const c = cellAt(p[0], p[1]);
if (c < 0) {
return;
}
longest = -1;
startFlood(c, 1);
after();
});
cv.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter') {
ev.preventDefault();
wake();
fromFar();
}
else if (ev.key === 'ArrowRight') {
ev.preventDefault();
wake();
flood(5);
after();
}
else if (ev.key === 'Home') {
ev.preventDefault();
wake();
newMaze();
}
});
if (farBtn) {
farBtn.addEventListener('click', function () { wake(); fromFar(); });
}
if (newBtn) {
newBtn.addEventListener('click', function () { wake(); newMaze(); });
}
startFlood(ENTRY, 1);
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// MzDistanceBfsFloodHeat - vanilla 판(05-mz-distance-bfs-flood-heat) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./MzDistanceBfsFloodHeat.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function MzDistanceBfsFloodHeat() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 바닥을 얹어 명도 차로 길이 읽힌다
const BG: RGB = [29, 26, 44];
const ROCK: RGB = [44, 39, 64];
const PAPER: RGB = [255, 248, 234];
const INK: RGB = [58, 48, 76];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 미로가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 격자 — 31×31 칸, 칸마다 벽 네 장을 비트 하나씩으로 든다
const GRID_N = 31;
const CELL_N = GRID_N * GRID_N;
const CS = 9;
const BOARD = GRID_N * CS;
let BX = W - 12 - BOARD;
const BY = (H - BOARD) / 2;
const ENTRY = 0;
const EXIT = CELL_N - 1;
// 방향 0 북 · 1 동 · 2 남 · 3 서 — 시계 방향 순서라 +1 이 오른쪽으로 돌기, +3 이 왼쪽으로 돌기
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
const BIT = [1, 2, 4, 8];
const ALL_WALLS = 15;
function neighbor(i: number, d: number): number {
const x = (i % GRID_N) + DX[d];
const y = Math.floor(i / GRID_N) + DY[d];
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function isOpen(walls: Uint8Array, i: number, d: number): boolean {
return (walls[i] & BIT[d]) === 0 && neighbor(i, d) >= 0;
}
// 벽 한 장 걷기 — 양쪽 칸의 비트를 함께 지운다 (한쪽만 지우면 길이 일방통행이 된다)
function knock(walls: Uint8Array, i: number, d: number): number {
const n = neighbor(i, d);
walls[i] &= ~BIT[d];
walls[n] &= ~BIT[(d + 2) % 4];
return n;
}
function openCount(walls: Uint8Array, i: number): number {
let c = 0;
for (let d = 0; d < 4; d += 1) { if (isOpen(walls, i, d)) { c += 1; } }
return c;
}
function cellX(i: number): number { return BX + (i % GRID_N) * CS; }
function cellY(i: number): number { return BY + Math.floor(i / GRID_N) * CS; }
function cellAt(sx: number, sy: number): number {
const x = Math.floor((sx - BX) / CS);
const y = Math.floor((sy - BY) / CS);
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function fillCell(i: number, c: RGB, a: number): void {
ctx.fillStyle = rgba(c, a);
ctx.fillRect(cellX(i), cellY(i), CS, CS);
}
// 벽 비트를 선분으로 — 칸마다 동·남 벽만 긋고 맨 윗줄·맨 왼줄만 북·서 벽을 더 긋는다 (같은 벽을 두 번 긋지 않는다)
function strokeWalls(walls: Uint8Array, color: string, lw: number): void {
ctx.beginPath();
for (let i = 0; i < CELL_N; i += 1) {
const x = cellX(i);
const y = cellY(i);
const w = walls[i];
if ((w & 2) !== 0) { ctx.moveTo(x + CS, y); ctx.lineTo(x + CS, y + CS); }
if ((w & 4) !== 0) { ctx.moveTo(x, y + CS); ctx.lineTo(x + CS, y + CS); }
if (i < GRID_N && (w & 1) !== 0) { ctx.moveTo(x, y); ctx.lineTo(x + CS, y); }
if (i % GRID_N === 0 && (w & 8) !== 0) { ctx.moveTo(x, y); ctx.lineTo(x, y + CS); }
}
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineCap = 'square';
ctx.stroke();
}
// 입구(왼쪽 위)·출구(오른쪽 아래) 표시 — 바깥 벽 밖에 작은 삼각형
function drawGates(): void {
ctx.fillStyle = rgba(MINT, 0.95);
ctx.beginPath();
ctx.moveTo(BX - 9, BY + 1);
ctx.lineTo(BX - 2, BY + CS / 2);
ctx.lineTo(BX - 9, BY + CS - 1);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(PINK, 0.95);
ctx.beginPath();
ctx.moveTo(BX + BOARD + 2, BY + BOARD - CS + 1);
ctx.lineTo(BX + BOARD + 9, BY + BOARD - CS / 2);
ctx.lineTo(BX + BOARD + 2, BY + BOARD - 1);
ctx.closePath();
ctx.fill();
}
// 왼쪽 칸의 수치 — 이름표 한 줄 + 값 한 줄
function panelRow(row: number, label: string, value: string): void {
const y = 104 + row * 34;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(PAPER, 0.62);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, 16, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 15px sans-serif';
ctx.fillText(value, 16, y + 18);
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
type PickMode = 'random' | 'newest' | 'oldest';
type Grower = { walls: Uint8Array; inMaze: Uint8Array; inFront: Uint8Array; front: number[]; order: Int32Array; count: number; last: number; rnd: () => number };
function addFront(g: Grower, c: number): void {
for (let d = 0; d < 4; d += 1) {
const n = neighbor(c, d);
if (n < 0 || g.inMaze[n] === 1 || g.inFront[n] === 1) { continue; }
g.inFront[n] = 1;
g.front.push(n);
}
}
function growStart(seed: number, start: number): Grower {
const g: Grower = { walls: new Uint8Array(CELL_N).fill(ALL_WALLS), inMaze: new Uint8Array(CELL_N), inFront: new Uint8Array(CELL_N), front: [], order: new Int32Array(CELL_N).fill(-1), count: 0, last: start, rnd: makeRng(seed) };
g.inMaze[start] = 1;
g.order[start] = 0;
g.count = 1;
addFront(g, start);
return g;
}
// 프론티어(판 영역에 닿은 안 판 칸)에서 하나를 골라 이미 판 이웃 하나와 벽 한 장으로 잇는다
function growOne(g: Grower, mode: PickMode): number {
const len = g.front.length;
if (len === 0) { return -1; }
let c = 0;
if (mode === 'newest') {
c = g.front.pop() as number;
} else if (mode === 'oldest') {
c = g.front.shift() as number;
} else {
const k = Math.floor(g.rnd() * len);
c = g.front[k];
g.front[k] = g.front[len - 1];
g.front.pop();
}
g.inFront[c] = 0;
const links: number[] = [];
for (let d = 0; d < 4; d += 1) {
const n = neighbor(c, d);
if (n >= 0 && g.inMaze[n] === 1) { links.push(d); }
}
knock(g.walls, c, links[Math.floor(g.rnd() * links.length)]);
g.inMaze[c] = 1;
g.order[c] = g.count;
g.count += 1;
g.last = c;
addFront(g, c);
return c;
}
function growAll(seed: number, start: number, mode: PickMode): Uint8Array {
const g = growStart(seed, start);
while (growOne(g, mode) >= 0) { /* 프론티어가 빌 때까지 */ }
return g.walls;
}
// 한 겹(같은 거리의 칸들)을 번지는 데 쓰는 시간
const FLOOD_STEP_MS = 20;
// 열지도 눈금의 바닥 — 파도가 이 거리를 넘기 전까지는 눈금을 이 값으로 고정해 초반 색이 출렁이지 않게 한다
const HEAT_MAX_D = 60;
// 지금 막 닿은 겹(파도 앞머리)을 덮는 밝기
const RING_ALPHA = 0.85;
const SEED = 464;
const HOLD_MS = 1400;
const GAP_MS = 500;
const HEAT_RAMP: RGB[] = [SKY, MINT, PEACH, PINK];
const TXT_FLOOD = '입구에서 한 겹씩 번지는 중';
const TXT_FLOOD2 = '가장 먼 방에서 다시 번지는 중';
const TXT_DONE1 = '끝 — 가장 먼 방을 찾음';
const TXT_DONE2 = '끝 — 서로 가장 먼 두 방을 찾음';
const farBtn = root.querySelector('[data-role="far"]') as HTMLButtonElement | null;
const newBtn = root.querySelector('[data-role="new"]') as HTMLButtonElement | null;
let pass = 0;
let walls = growAll(SEED, Math.floor(CELL_N / 2), 'random');
let dist = new Int32Array(CELL_N).fill(-1);
let layer: number[] = [];
let front = 0;
let reached = 0;
let src = ENTRY;
let farCell = ENTRY;
let sweep = 1;
let longest = -1;
let longEnd = -1;
let stepAcc = 0;
let holdLeft = 0;
function startFlood(s: number, sw: number): void {
src = s;
sweep = sw;
dist = new Int32Array(CELL_N).fill(-1);
dist[s] = 0;
layer = [s];
front = 0;
reached = 1;
farCell = s;
stepAcc = 0;
holdLeft = 0;
}
// 한 겹 번지기 — 지금 겹의 칸마다 열린 이웃 중 아직 거리가 없는 칸에 거리+1 을 적어 다음 겹을 만든다
function floodStep(): void {
if (layer.length === 0) { return; }
const next: number[] = [];
for (const c of layer) {
for (let d = 0; d < 4; d += 1) {
if (!isOpen(walls, c, d)) { continue; }
const n = neighbor(c, d);
if (dist[n] >= 0) { continue; }
dist[n] = front + 1;
next.push(n);
}
}
if (next.length === 0) {
layer = [];
if (sweep === 2) { longest = front; longEnd = farCell; }
return;
}
front += 1;
layer = next;
reached += next.length;
farCell = next[next.length - 1];
}
function flooding(): boolean { return layer.length > 0; }
function report(): void {
if (flooding()) { say(sweep === 2 ? TXT_FLOOD2 : TXT_FLOOD); }
else { say(sweep === 2 ? TXT_DONE2 : TXT_DONE1); }
}
function flood(n: number): void {
for (let k = 0; k < n && flooding(); k += 1) { floodStep(); }
}
function step(): void {
if (!flooding()) { return; }
stepAcc += FIXED_DT;
while (stepAcc >= FLOOD_STEP_MS && flooding()) {
stepAcc -= FLOOD_STEP_MS;
floodStep();
}
report();
}
function settle(): void {
flood(CELL_N);
report();
}
// 시연 — 입구에서 번진 뒤 가장 먼 방에서 한 번 더 번진다 (가지 없는 길이 하나뿐인 미로에서 두 번째 끝이 가장 긴 길의 양 끝)
function demoTick(t: number): void {
if (flooding() || t < 0) { return; }
holdLeft += FIXED_DT;
if (sweep === 1 && holdLeft >= GAP_MS) { startFlood(farCell, 2); }
else if (sweep === 2 && holdLeft >= HOLD_MS) { newMaze(); }
}
function heat(t: number): RGB {
const x = clamp(t, 0, 1) * (HEAT_RAMP.length - 1);
const i = Math.min(Math.floor(x), HEAT_RAMP.length - 2);
return mix(HEAT_RAMP[i], HEAT_RAMP[i + 1], x - i);
}
function ringAt(c: number, col: RGB, r: number): void {
ctx.strokeStyle = rgba(col, 1);
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cellX(c) + CS / 2, cellY(c) + CS / 2, r, 0, Math.PI * 2);
ctx.stroke();
}
const UNREACHED = mix(ROCK, PAPER, 0.12);
function draw(): void {
clearStage();
const scale = Math.max(front, HEAT_MAX_D);
for (let i = 0; i < CELL_N; i += 1) {
const d = dist[i];
if (d < 0) { fillCell(i, UNREACHED, 1); continue; }
fillCell(i, mix(heat(d / scale), PAPER, 0.18), 1);
if (flooding() && d === front) { fillCell(i, PAPER, RING_ALPHA); }
}
strokeWalls(walls, rgba(INK, 1), 2);
ringAt(src, PAPER, 5);
ringAt(farCell, PAPER, 6.5);
drawGates();
panelRow(0, '거리 겹', String(front));
panelRow(1, '닿은 칸', String(reached) + ' / ' + String(CELL_N));
panelRow(2, '가장 먼 방까지', String(dist[farCell]));
panelRow(3, '가장 긴 길', longest >= 0 ? String(longest) : '—');
}
function after(): void {
if (still) { settle(); }
report();
draw();
}
function newMaze(): void {
pass += 1;
walls = growAll(SEED + pass, Math.floor(CELL_N / 2), 'random');
longest = -1;
longEnd = -1;
startFlood(ENTRY, 1);
after();
}
function fromFar(): void {
if (flooding()) { settle(); }
startFlood(farCell, 2);
after();
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
const c = cellAt(p[0], p[1]);
if (c < 0) { return; }
longest = -1;
startFlood(c, 1);
after();
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key === 'Enter') { ev.preventDefault(); wake(); fromFar(); }
else if (ev.key === 'ArrowRight') { ev.preventDefault(); wake(); flood(5); after(); }
else if (ev.key === 'Home') { ev.preventDefault(); wake(); newMaze(); }
});
if (farBtn) { farBtn.addEventListener('click', function (): void { wake(); fromFar(); }); }
if (newBtn) { newBtn.addEventListener('click', function (): void { wake(); newMaze(); }); }
startFlood(ENTRY, 1);
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("hs") + " is-demo"} ref={rootRef}>
<canvas className={cx("hs__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="입구에서 한 겹씩 번지는 파도가 칸마다 거리를 적어 열지도로 물들이고 가장 먼 방을 표시하는 미로. 칸을 누르면 그 칸에서 번진다"></canvas>
<p className={cx("hs__status")} data-role="status" aria-live="off">입구에서 한 겹씩 번지는 중</p>
<div className={cx("hs__tools")}>
<button className={cx("hs__btn")} data-role="far" type="button">가장 먼 방에서</button>
<button className={cx("hs__btn")} data-role="new" type="button">새 미로</button>
</div>
</div>
);
}05 · A Maze Where a Spreading Wave Computes the Distance to Every Cell — Measured area 41.073% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
const next = [];
for (const c of layer) {
for (let d = 0; d < 4; d += 1) {
if (!isOpen(walls, c, d)) {
continue;
}
const n = neighbor(c, d);
if (dist[n] >= 0) {
continue;
}
dist[n] = front + 1;
next.push(n);
}
}
if (next.length === 0) {
layer = [];
if (sweep === 2) {
longest = front;
longEnd = farCell;
}
return;
}
front += 1;
layer = next;
reached += next.length;
06A Path Search That Opens the Cell With the Smallest Estimated Distance to the Goal First
A min-heap hands over the cell with the smallest f, the distance walked so far g plus slope K times the horizontal and vertical cells left to the exit, and closes it; whenever an open neighbor's g drops, its parent switches to this cell and it goes back into the heap. This board alone runs on a maze that keeps 40% of its dead ends and splices the rest into loops (LOOP_KEEP 0.4), so several routes reach the exit, and once the exit closes, the path traced back through parents is drawn at one cell per 18ms. On the seed-464 maze K 1.0 closes 264 cells and returns the shortest 90-cell path, and the render measured 12.8% cumulative area, 55.6 mean intensity and 23/23 moved frames.

<main class="stage">
<div class="hs is-demo" id="hs">
<canvas class="hs__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="A path search that opens the cell with the smallest traveled-plus-estimated distance first and, on arrival, traces parent cells backward to draw the path. Press to change the slope"></canvas>
<p class="hs__status" data-role="status" aria-live="off">Opening the cell with the smallest estimate first</p>
<div class="hs__tools">
<button class="hs__btn" data-role="k" type="button">Change slope</button>
<button class="hs__btn" data-role="new" type="button">New maze</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.hs {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.hs__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.hs__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.hs__btn:hover {
background: $subject-cream;
}
.hs__btn[aria-pressed="true"] {
background: $subject-mint;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.hs__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.hs__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.hs__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.hs__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hs {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.hs__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.hs__btn:hover {
background: #fff7e6;
}
.hs__btn[aria-pressed=true] {
background: #4cd4a6;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.hs__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.hs__btn {
font-size: 10px;
padding: 4px 8px;
}
.hs__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.hs__btn {
transition: none;
}
}(function () {
const root = document.getElementById("hs");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 바닥을 얹어 명도 차로 길이 읽힌다
const BG = [29, 26, 44];
const ROCK = [44, 39, 64];
const PAPER = [255, 248, 234];
const INK = [58, 48, 76];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 미로가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 격자 — 31×31 칸, 칸마다 벽 네 장을 비트 하나씩으로 든다
const GRID_N = 31;
const CELL_N = GRID_N * GRID_N;
const CS = 9;
const BOARD = GRID_N * CS;
let BX = W - 12 - BOARD;
const BY = (H - BOARD) / 2;
const ENTRY = 0;
const EXIT = CELL_N - 1;
// 방향 0 북 · 1 동 · 2 남 · 3 서 — 시계 방향 순서라 +1 이 오른쪽으로 돌기, +3 이 왼쪽으로 돌기
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
const BIT = [1, 2, 4, 8];
const ALL_WALLS = 15;
function neighbor(i, d) {
const x = (i % GRID_N) + DX[d];
const y = Math.floor(i / GRID_N) + DY[d];
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function isOpen(walls, i, d) {
return (walls[i] & BIT[d]) === 0 && neighbor(i, d) >= 0;
}
// 벽 한 장 걷기 — 양쪽 칸의 비트를 함께 지운다 (한쪽만 지우면 길이 일방통행이 된다)
function knock(walls, i, d) {
const n = neighbor(i, d);
walls[i] &= ~BIT[d];
walls[n] &= ~BIT[(d + 2) % 4];
return n;
}
function openCount(walls, i) {
let c = 0;
for (let d = 0; d < 4; d += 1) {
if (isOpen(walls, i, d)) {
c += 1;
}
}
return c;
}
function cellX(i) { return BX + (i % GRID_N) * CS; }
function cellY(i) { return BY + Math.floor(i / GRID_N) * CS; }
function cellAt(sx, sy) {
const x = Math.floor((sx - BX) / CS);
const y = Math.floor((sy - BY) / CS);
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function fillCell(i, c, a) {
ctx.fillStyle = rgba(c, a);
ctx.fillRect(cellX(i), cellY(i), CS, CS);
}
// 벽 비트를 선분으로 — 칸마다 동·남 벽만 긋고 맨 윗줄·맨 왼줄만 북·서 벽을 더 긋는다 (같은 벽을 두 번 긋지 않는다)
function strokeWalls(walls, color, lw) {
ctx.beginPath();
for (let i = 0; i < CELL_N; i += 1) {
const x = cellX(i);
const y = cellY(i);
const w = walls[i];
if ((w & 2) !== 0) {
ctx.moveTo(x + CS, y);
ctx.lineTo(x + CS, y + CS);
}
if ((w & 4) !== 0) {
ctx.moveTo(x, y + CS);
ctx.lineTo(x + CS, y + CS);
}
if (i < GRID_N && (w & 1) !== 0) {
ctx.moveTo(x, y);
ctx.lineTo(x + CS, y);
}
if (i % GRID_N === 0 && (w & 8) !== 0) {
ctx.moveTo(x, y);
ctx.lineTo(x, y + CS);
}
}
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineCap = 'square';
ctx.stroke();
}
// 입구(왼쪽 위)·출구(오른쪽 아래) 표시 — 바깥 벽 밖에 작은 삼각형
function drawGates() {
ctx.fillStyle = rgba(MINT, 0.95);
ctx.beginPath();
ctx.moveTo(BX - 9, BY + 1);
ctx.lineTo(BX - 2, BY + CS / 2);
ctx.lineTo(BX - 9, BY + CS - 1);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(PINK, 0.95);
ctx.beginPath();
ctx.moveTo(BX + BOARD + 2, BY + BOARD - CS + 1);
ctx.lineTo(BX + BOARD + 9, BY + BOARD - CS / 2);
ctx.lineTo(BX + BOARD + 2, BY + BOARD - 1);
ctx.closePath();
ctx.fill();
}
// 왼쪽 칸의 수치 — 이름표 한 줄 + 값 한 줄
function panelRow(row, label, value) {
const y = 104 + row * 34;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(PAPER, 0.62);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, 16, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 15px sans-serif';
ctx.fillText(value, 16, y + 18);
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 깊이 우선 파기 한 번에 끝까지 — 스택 머리에서 안 판 이웃을 골라 벽을 걷고, 없으면 하나 뺀다
function carveDfsAll(seed, start) {
const walls = new Uint8Array(CELL_N).fill(ALL_WALLS);
const seen = new Uint8Array(CELL_N);
const stk = new Int32Array(CELL_N);
const rnd = makeRng(seed);
let sp = 0;
stk[sp] = start;
sp = 1;
seen[start] = 1;
const opts = [];
while (sp > 0) {
const cur = stk[sp - 1];
opts.length = 0;
for (let d = 0; d < 4; d += 1) {
const n = neighbor(cur, d);
if (n >= 0 && seen[n] === 0) {
opts.push(d);
}
}
if (opts.length === 0) {
sp -= 1;
continue;
}
const n = knock(walls, cur, opts[Math.floor(rnd() * opts.length)]);
seen[n] = 1;
stk[sp] = n;
sp += 1;
}
return walls;
}
// 너비 우선 거리 — 큐에서 꺼낸 칸의 열린 이웃에 거리+1 을 적는다. 닿지 않은 칸은 -1
function bfsDist(walls, src) {
const dist = new Int32Array(CELL_N).fill(-1);
const q = new Int32Array(CELL_N);
let head = 0;
let tail = 0;
dist[src] = 0;
q[tail] = src;
tail += 1;
while (head < tail) {
const c = q[head];
head += 1;
for (let d = 0; d < 4; d += 1) {
if (!isOpen(walls, c, d)) {
continue;
}
const n = neighbor(c, d);
if (dist[n] >= 0) {
continue;
}
dist[n] = dist[c] + 1;
q[tail] = n;
tail += 1;
}
}
return dist;
}
// Dead ends = 열린 쪽이 하나뿐인 칸
function isDeadEnd(walls, i) { return openCount(walls, i) === 1; }
// Dead ends 끝의 벽 하나를 걷어 옆길과 잇는다 — 옆 칸도 막다른 골목이면 그쪽을 먼저 골라 두 골목을 한 번에 없앤다
function spliceAt(walls, i, rnd) {
const shut = [];
const dead = [];
for (let d = 0; d < 4; d += 1) {
const n = neighbor(i, d);
if (n < 0 || (walls[i] & BIT[d]) === 0) {
continue;
}
shut.push(d);
if (isDeadEnd(walls, n)) {
dead.push(d);
}
}
const pool = dead.length > 0 ? dead : shut;
if (pool.length === 0) {
return -1;
}
const d = pool[Math.floor(rnd() * pool.length)];
knock(walls, i, d);
return d;
}
// 판 전체를 한 번 훑어 막다른 골목마다 keep 확률로 남기고 나머지는 잇는다 — 이은 수를 돌려준다
function braidAll(walls, keep, rnd) {
let joined = 0;
for (let i = 0; i < CELL_N; i += 1) {
if (!isDeadEnd(walls, i)) {
continue;
}
if (rnd() < keep) {
continue;
}
if (spliceAt(walls, i, rnd) >= 0) {
joined += 1;
}
}
return joined;
}
// 남은 거리 추정값(맨해튼 거리)에 곱하는 기울기 — f = g + ASTAR_H_K × h
const ASTAR_H_K = 1.0;
// 열린 목록(아직 안 연 후보) 칸을 덮는 민트의 짙기
const OPEN_TINT = 0.55;
// 도착 뒤 부모 칸을 거꾸로 짚어 경로를 한 칸 긋는 데 쓰는 시간
const TRACE_MS = 18;
const EXPAND_MS = 8;
// 이 판은 막다른 골목의 60% 를 이어 고리를 만든 미로다 — 길이 여럿이어야 기울기에 따라 답이 갈린다
const LOOP_KEEP = 0.4;
const SEED = 464;
const HOLD_MS = 1100;
const K_STEPS = [ASTAR_H_K, 2.2, 0];
const TXT_OPEN = 'Opening the cell with the smallest estimate first';
const TXT_TRACE = 'Tracing parent cells backward';
const TXT_DONE = 'Arrived — path found';
const kBtn = root.querySelector('[data-role="k"]');
const newBtn = root.querySelector('[data-role="new"]');
let k = ASTAR_H_K;
let pass = 0;
let walls = new Uint8Array(CELL_N);
let shortest = 0;
const g = new Float64Array(CELL_N);
const parent = new Int32Array(CELL_N);
const closed = new Uint8Array(CELL_N);
const inOpen = new Uint8Array(CELL_N);
// 최소 힙 — f 가 가장 작은 후보가 늘 맨 위에 온다 (f 와 칸 번호를 나란히 든다)
const heapF = new Float64Array(CELL_N * 4);
const heapC = new Int32Array(CELL_N * 4);
let heapN = 0;
let openN = 0;
let closedN = 0;
let cur = ENTRY;
let path = [];
let traceN = 0;
let phase = 0;
let stepAcc = 0;
let holdLeft = 0;
function heapPush(f, c) {
let i = heapN;
heapN += 1;
while (i > 0) {
const p = (i - 1) >> 1;
if (heapF[p] <= f) {
break;
}
heapF[i] = heapF[p];
heapC[i] = heapC[p];
i = p;
}
heapF[i] = f;
heapC[i] = c;
}
function heapPop() {
const top = heapC[0];
heapN -= 1;
const f = heapF[heapN];
const c = heapC[heapN];
let i = 0;
for (;;) {
let m = i * 2 + 1;
if (m >= heapN) {
break;
}
if (m + 1 < heapN && heapF[m + 1] < heapF[m]) {
m += 1;
}
if (heapF[m] >= f) {
break;
}
heapF[i] = heapF[m];
heapC[i] = heapC[m];
i = m;
}
heapF[i] = f;
heapC[i] = c;
return top;
}
// 목표까지 남은 거리 추정 — 벽을 무시한 가로+세로 칸 수
function hEst(c) {
return Math.abs((c % GRID_N) - (EXIT % GRID_N)) + Math.abs(Math.floor(c / GRID_N) - Math.floor(EXIT / GRID_N));
}
function buildMaze(seed) {
walls = carveDfsAll(seed, ENTRY);
braidAll(walls, LOOP_KEEP, makeRng(seed + 99));
shortest = bfsDist(walls, ENTRY)[EXIT];
}
function startSearch() {
g.fill(Infinity);
parent.fill(-1);
closed.fill(0);
inOpen.fill(0);
heapN = 0;
openN = 1;
closedN = 0;
g[ENTRY] = 0;
inOpen[ENTRY] = 1;
heapPush(k * hEst(ENTRY), ENTRY);
cur = ENTRY;
path = [];
traceN = 0;
phase = 0;
stepAcc = 0;
holdLeft = 0;
}
// 한 칸 열기 — 힙에서 f 가 가장 작은 칸을 꺼내 닫고, 열린 이웃의 g 가 줄면 부모를 이 칸으로 바꿔 다시 넣는다
function expandOne() {
while (heapN > 0) {
const c = heapPop();
if (closed[c] === 1) {
continue;
}
cur = c;
inOpen[c] = 0;
openN -= 1;
closed[c] = 1;
closedN += 1;
if (c === EXIT) {
for (let p = EXIT; p >= 0; p = parent[p]) {
path.push(p);
}
path.reverse();
phase = 1;
return;
}
for (let d = 0; d < 4; d += 1) {
if (!isOpen(walls, c, d)) {
continue;
}
const n = neighbor(c, d);
if (closed[n] === 1) {
continue;
}
const ng = g[c] + 1;
if (ng >= g[n]) {
continue;
}
g[n] = ng;
parent[n] = c;
if (inOpen[n] === 0) {
inOpen[n] = 1;
openN += 1;
}
heapPush(ng + k * hEst(n), n);
}
return;
}
phase = 2;
}
function report() { say(phase === 0 ? TXT_OPEN : (phase === 1 ? TXT_TRACE : TXT_DONE)); }
function tick() {
if (phase === 0) {
expandOne();
}
else if (phase === 1) {
traceN += 1;
if (traceN >= path.length) {
traceN = path.length;
phase = 2;
}
}
}
function step() {
if (phase === 2) {
return;
}
stepAcc += FIXED_DT;
let need = phase === 0 ? EXPAND_MS : TRACE_MS;
while (stepAcc >= need && phase !== 2) {
stepAcc -= need;
tick();
need = phase === 0 ? EXPAND_MS : TRACE_MS;
}
report();
}
function settle() {
while (phase !== 2) {
tick();
}
report();
}
// 시연 — 같은 미로를 기울기 1.0 으로 한 번, 2.2 로 한 번 풀고 다음 미로로
function demoTick(t) {
if (phase !== 2 || t < 0) {
return;
}
holdLeft += FIXED_DT;
if (holdLeft < HOLD_MS) {
return;
}
if (k === ASTAR_H_K) {
k = K_STEPS[1];
startSearch();
}
else {
k = ASTAR_H_K;
pass += 1;
buildMaze(SEED + pass);
startSearch();
}
}
const FLOOR = mix(PAPER, BG, 0.05);
function draw() {
clearStage();
const gMax = Math.max(1, shortest);
for (let i = 0; i < CELL_N; i += 1) {
if (closed[i] === 1) {
fillCell(i, mix(mix(PAPER, LILAC, 0.35), SKY, clamp(g[i] / gMax, 0, 1) * 0.8), 1);
}
else {
fillCell(i, FLOOR, 1);
}
if (inOpen[i] === 1) {
fillCell(i, MINT, OPEN_TINT);
}
}
strokeWalls(walls, rgba(INK, 1), 2);
if (traceN > 1) {
ctx.strokeStyle = rgba(PINK, 1);
ctx.lineWidth = 3.4;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
for (let j = 0; j < traceN; j += 1) {
const c = path[j];
const x = cellX(c) + CS / 2;
const y = cellY(c) + CS / 2;
if (j === 0) {
ctx.moveTo(x, y);
}
else {
ctx.lineTo(x, y);
}
}
ctx.stroke();
}
if (phase === 0) {
ctx.fillStyle = rgba(PINK, 1);
ctx.fillRect(cellX(cur) + 1, cellY(cur) + 1, CS - 2, CS - 2);
}
drawGates();
panelRow(0, 'Slope K', k.toFixed(1));
panelRow(1, 'Open cells', String(openN));
panelRow(2, 'Closed cells', String(closedN));
panelRow(3, 'Path / shortest', (path.length > 0 ? String(path.length - 1) : '—') + ' / ' + String(shortest));
}
function after() {
if (still) {
settle();
}
report();
draw();
}
function stepK(dirn) {
const i = K_STEPS.indexOf(k);
k = K_STEPS[(i + dirn + K_STEPS.length) % K_STEPS.length];
startSearch();
after();
}
function newMaze() {
pass += 1;
buildMaze(SEED + pass);
startSearch();
after();
}
cv.addEventListener('pointerdown', function () { wake(); stepK(1); });
cv.addEventListener('keydown', function (ev) {
if (ev.key === 'ArrowUp') {
ev.preventDefault();
wake();
stepK(1);
}
else if (ev.key === 'ArrowDown') {
ev.preventDefault();
wake();
stepK(-1);
}
else if (ev.key === 'Enter') {
ev.preventDefault();
wake();
startSearch();
after();
}
else if (ev.key === 'Home') {
ev.preventDefault();
wake();
newMaze();
}
});
if (kBtn) {
kBtn.addEventListener('click', function () { wake(); stepK(1); });
}
if (newBtn) {
newBtn.addEventListener('click', function () { wake(); newMaze(); });
}
buildMaze(SEED);
startSearch();
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// MzAstarOpenSetGreedyTrace - vanilla 판(06-mz-astar-open-set-greedy-trace) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./MzAstarOpenSetGreedyTrace.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function MzAstarOpenSetGreedyTrace() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 바닥을 얹어 명도 차로 길이 읽힌다
const BG: RGB = [29, 26, 44];
const ROCK: RGB = [44, 39, 64];
const PAPER: RGB = [255, 248, 234];
const INK: RGB = [58, 48, 76];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 미로가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 격자 — 31×31 칸, 칸마다 벽 네 장을 비트 하나씩으로 든다
const GRID_N = 31;
const CELL_N = GRID_N * GRID_N;
const CS = 9;
const BOARD = GRID_N * CS;
let BX = W - 12 - BOARD;
const BY = (H - BOARD) / 2;
const ENTRY = 0;
const EXIT = CELL_N - 1;
// 방향 0 북 · 1 동 · 2 남 · 3 서 — 시계 방향 순서라 +1 이 오른쪽으로 돌기, +3 이 왼쪽으로 돌기
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
const BIT = [1, 2, 4, 8];
const ALL_WALLS = 15;
function neighbor(i: number, d: number): number {
const x = (i % GRID_N) + DX[d];
const y = Math.floor(i / GRID_N) + DY[d];
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function isOpen(walls: Uint8Array, i: number, d: number): boolean {
return (walls[i] & BIT[d]) === 0 && neighbor(i, d) >= 0;
}
// 벽 한 장 걷기 — 양쪽 칸의 비트를 함께 지운다 (한쪽만 지우면 길이 일방통행이 된다)
function knock(walls: Uint8Array, i: number, d: number): number {
const n = neighbor(i, d);
walls[i] &= ~BIT[d];
walls[n] &= ~BIT[(d + 2) % 4];
return n;
}
function openCount(walls: Uint8Array, i: number): number {
let c = 0;
for (let d = 0; d < 4; d += 1) { if (isOpen(walls, i, d)) { c += 1; } }
return c;
}
function cellX(i: number): number { return BX + (i % GRID_N) * CS; }
function cellY(i: number): number { return BY + Math.floor(i / GRID_N) * CS; }
function cellAt(sx: number, sy: number): number {
const x = Math.floor((sx - BX) / CS);
const y = Math.floor((sy - BY) / CS);
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function fillCell(i: number, c: RGB, a: number): void {
ctx.fillStyle = rgba(c, a);
ctx.fillRect(cellX(i), cellY(i), CS, CS);
}
// 벽 비트를 선분으로 — 칸마다 동·남 벽만 긋고 맨 윗줄·맨 왼줄만 북·서 벽을 더 긋는다 (같은 벽을 두 번 긋지 않는다)
function strokeWalls(walls: Uint8Array, color: string, lw: number): void {
ctx.beginPath();
for (let i = 0; i < CELL_N; i += 1) {
const x = cellX(i);
const y = cellY(i);
const w = walls[i];
if ((w & 2) !== 0) { ctx.moveTo(x + CS, y); ctx.lineTo(x + CS, y + CS); }
if ((w & 4) !== 0) { ctx.moveTo(x, y + CS); ctx.lineTo(x + CS, y + CS); }
if (i < GRID_N && (w & 1) !== 0) { ctx.moveTo(x, y); ctx.lineTo(x + CS, y); }
if (i % GRID_N === 0 && (w & 8) !== 0) { ctx.moveTo(x, y); ctx.lineTo(x, y + CS); }
}
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineCap = 'square';
ctx.stroke();
}
// 입구(왼쪽 위)·출구(오른쪽 아래) 표시 — 바깥 벽 밖에 작은 삼각형
function drawGates(): void {
ctx.fillStyle = rgba(MINT, 0.95);
ctx.beginPath();
ctx.moveTo(BX - 9, BY + 1);
ctx.lineTo(BX - 2, BY + CS / 2);
ctx.lineTo(BX - 9, BY + CS - 1);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(PINK, 0.95);
ctx.beginPath();
ctx.moveTo(BX + BOARD + 2, BY + BOARD - CS + 1);
ctx.lineTo(BX + BOARD + 9, BY + BOARD - CS / 2);
ctx.lineTo(BX + BOARD + 2, BY + BOARD - 1);
ctx.closePath();
ctx.fill();
}
// 왼쪽 칸의 수치 — 이름표 한 줄 + 값 한 줄
function panelRow(row: number, label: string, value: string): void {
const y = 104 + row * 34;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(PAPER, 0.62);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, 16, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 15px sans-serif';
ctx.fillText(value, 16, y + 18);
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 깊이 우선 파기 한 번에 끝까지 — 스택 머리에서 안 판 이웃을 골라 벽을 걷고, 없으면 하나 뺀다
function carveDfsAll(seed: number, start: number): Uint8Array {
const walls = new Uint8Array(CELL_N).fill(ALL_WALLS);
const seen = new Uint8Array(CELL_N);
const stk = new Int32Array(CELL_N);
const rnd = makeRng(seed);
let sp = 0;
stk[sp] = start;
sp = 1;
seen[start] = 1;
const opts: number[] = [];
while (sp > 0) {
const cur = stk[sp - 1];
opts.length = 0;
for (let d = 0; d < 4; d += 1) {
const n = neighbor(cur, d);
if (n >= 0 && seen[n] === 0) { opts.push(d); }
}
if (opts.length === 0) { sp -= 1; continue; }
const n = knock(walls, cur, opts[Math.floor(rnd() * opts.length)]);
seen[n] = 1;
stk[sp] = n;
sp += 1;
}
return walls;
}
// 너비 우선 거리 — 큐에서 꺼낸 칸의 열린 이웃에 거리+1 을 적는다. 닿지 않은 칸은 -1
function bfsDist(walls: Uint8Array, src: number): Int32Array {
const dist = new Int32Array(CELL_N).fill(-1);
const q = new Int32Array(CELL_N);
let head = 0;
let tail = 0;
dist[src] = 0;
q[tail] = src;
tail += 1;
while (head < tail) {
const c = q[head];
head += 1;
for (let d = 0; d < 4; d += 1) {
if (!isOpen(walls, c, d)) { continue; }
const n = neighbor(c, d);
if (dist[n] >= 0) { continue; }
dist[n] = dist[c] + 1;
q[tail] = n;
tail += 1;
}
}
return dist;
}
// 막다른 골목 = 열린 쪽이 하나뿐인 칸
function isDeadEnd(walls: Uint8Array, i: number): boolean { return openCount(walls, i) === 1; }
// 막다른 골목 끝의 벽 하나를 걷어 옆길과 잇는다 — 옆 칸도 막다른 골목이면 그쪽을 먼저 골라 두 골목을 한 번에 없앤다
function spliceAt(walls: Uint8Array, i: number, rnd: () => number): number {
const shut: number[] = [];
const dead: number[] = [];
for (let d = 0; d < 4; d += 1) {
const n = neighbor(i, d);
if (n < 0 || (walls[i] & BIT[d]) === 0) { continue; }
shut.push(d);
if (isDeadEnd(walls, n)) { dead.push(d); }
}
const pool = dead.length > 0 ? dead : shut;
if (pool.length === 0) { return -1; }
const d = pool[Math.floor(rnd() * pool.length)];
knock(walls, i, d);
return d;
}
// 판 전체를 한 번 훑어 막다른 골목마다 keep 확률로 남기고 나머지는 잇는다 — 이은 수를 돌려준다
function braidAll(walls: Uint8Array, keep: number, rnd: () => number): number {
let joined = 0;
for (let i = 0; i < CELL_N; i += 1) {
if (!isDeadEnd(walls, i)) { continue; }
if (rnd() < keep) { continue; }
if (spliceAt(walls, i, rnd) >= 0) { joined += 1; }
}
return joined;
}
// 남은 거리 추정값(맨해튼 거리)에 곱하는 기울기 — f = g + ASTAR_H_K × h
const ASTAR_H_K = 1.0;
// 열린 목록(아직 안 연 후보) 칸을 덮는 민트의 짙기
const OPEN_TINT = 0.55;
// 도착 뒤 부모 칸을 거꾸로 짚어 경로를 한 칸 긋는 데 쓰는 시간
const TRACE_MS = 18;
const EXPAND_MS = 8;
// 이 판은 막다른 골목의 60% 를 이어 고리를 만든 미로다 — 길이 여럿이어야 기울기에 따라 답이 갈린다
const LOOP_KEEP = 0.4;
const SEED = 464;
const HOLD_MS = 1100;
const K_STEPS = [ASTAR_H_K, 2.2, 0];
const TXT_OPEN = '추정 거리가 가장 작은 칸부터 여는 중';
const TXT_TRACE = '부모 칸을 거꾸로 짚는 중';
const TXT_DONE = '도착 — 경로를 찾음';
const kBtn = root.querySelector('[data-role="k"]') as HTMLButtonElement | null;
const newBtn = root.querySelector('[data-role="new"]') as HTMLButtonElement | null;
let k = ASTAR_H_K;
let pass = 0;
let walls: Uint8Array = new Uint8Array(CELL_N);
let shortest = 0;
const g = new Float64Array(CELL_N);
const parent = new Int32Array(CELL_N);
const closed = new Uint8Array(CELL_N);
const inOpen = new Uint8Array(CELL_N);
// 최소 힙 — f 가 가장 작은 후보가 늘 맨 위에 온다 (f 와 칸 번호를 나란히 든다)
const heapF = new Float64Array(CELL_N * 4);
const heapC = new Int32Array(CELL_N * 4);
let heapN = 0;
let openN = 0;
let closedN = 0;
let cur = ENTRY;
let path: number[] = [];
let traceN = 0;
let phase = 0;
let stepAcc = 0;
let holdLeft = 0;
function heapPush(f: number, c: number): void {
let i = heapN;
heapN += 1;
while (i > 0) {
const p = (i - 1) >> 1;
if (heapF[p] <= f) { break; }
heapF[i] = heapF[p];
heapC[i] = heapC[p];
i = p;
}
heapF[i] = f;
heapC[i] = c;
}
function heapPop(): number {
const top = heapC[0];
heapN -= 1;
const f = heapF[heapN];
const c = heapC[heapN];
let i = 0;
for (;;) {
let m = i * 2 + 1;
if (m >= heapN) { break; }
if (m + 1 < heapN && heapF[m + 1] < heapF[m]) { m += 1; }
if (heapF[m] >= f) { break; }
heapF[i] = heapF[m];
heapC[i] = heapC[m];
i = m;
}
heapF[i] = f;
heapC[i] = c;
return top;
}
// 목표까지 남은 거리 추정 — 벽을 무시한 가로+세로 칸 수
function hEst(c: number): number {
return Math.abs((c % GRID_N) - (EXIT % GRID_N)) + Math.abs(Math.floor(c / GRID_N) - Math.floor(EXIT / GRID_N));
}
function buildMaze(seed: number): void {
walls = carveDfsAll(seed, ENTRY);
braidAll(walls, LOOP_KEEP, makeRng(seed + 99));
shortest = bfsDist(walls, ENTRY)[EXIT];
}
function startSearch(): void {
g.fill(Infinity);
parent.fill(-1);
closed.fill(0);
inOpen.fill(0);
heapN = 0;
openN = 1;
closedN = 0;
g[ENTRY] = 0;
inOpen[ENTRY] = 1;
heapPush(k * hEst(ENTRY), ENTRY);
cur = ENTRY;
path = [];
traceN = 0;
phase = 0;
stepAcc = 0;
holdLeft = 0;
}
// 한 칸 열기 — 힙에서 f 가 가장 작은 칸을 꺼내 닫고, 열린 이웃의 g 가 줄면 부모를 이 칸으로 바꿔 다시 넣는다
function expandOne(): void {
while (heapN > 0) {
const c = heapPop();
if (closed[c] === 1) { continue; }
cur = c;
inOpen[c] = 0;
openN -= 1;
closed[c] = 1;
closedN += 1;
if (c === EXIT) {
for (let p = EXIT; p >= 0; p = parent[p]) { path.push(p); }
path.reverse();
phase = 1;
return;
}
for (let d = 0; d < 4; d += 1) {
if (!isOpen(walls, c, d)) { continue; }
const n = neighbor(c, d);
if (closed[n] === 1) { continue; }
const ng = g[c] + 1;
if (ng >= g[n]) { continue; }
g[n] = ng;
parent[n] = c;
if (inOpen[n] === 0) { inOpen[n] = 1; openN += 1; }
heapPush(ng + k * hEst(n), n);
}
return;
}
phase = 2;
}
function report(): void { say(phase === 0 ? TXT_OPEN : (phase === 1 ? TXT_TRACE : TXT_DONE)); }
function tick(): void {
if (phase === 0) { expandOne(); }
else if (phase === 1) { traceN += 1; if (traceN >= path.length) { traceN = path.length; phase = 2; } }
}
function step(): void {
if (phase === 2) { return; }
stepAcc += FIXED_DT;
let need = phase === 0 ? EXPAND_MS : TRACE_MS;
while (stepAcc >= need && phase !== 2) {
stepAcc -= need;
tick();
need = phase === 0 ? EXPAND_MS : TRACE_MS;
}
report();
}
function settle(): void {
while (phase !== 2) { tick(); }
report();
}
// 시연 — 같은 미로를 기울기 1.0 으로 한 번, 2.2 로 한 번 풀고 다음 미로로
function demoTick(t: number): void {
if (phase !== 2 || t < 0) { return; }
holdLeft += FIXED_DT;
if (holdLeft < HOLD_MS) { return; }
if (k === ASTAR_H_K) { k = K_STEPS[1]; startSearch(); }
else { k = ASTAR_H_K; pass += 1; buildMaze(SEED + pass); startSearch(); }
}
const FLOOR = mix(PAPER, BG, 0.05);
function draw(): void {
clearStage();
const gMax = Math.max(1, shortest);
for (let i = 0; i < CELL_N; i += 1) {
if (closed[i] === 1) { fillCell(i, mix(mix(PAPER, LILAC, 0.35), SKY, clamp(g[i] / gMax, 0, 1) * 0.8), 1); }
else { fillCell(i, FLOOR, 1); }
if (inOpen[i] === 1) { fillCell(i, MINT, OPEN_TINT); }
}
strokeWalls(walls, rgba(INK, 1), 2);
if (traceN > 1) {
ctx.strokeStyle = rgba(PINK, 1);
ctx.lineWidth = 3.4;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
for (let j = 0; j < traceN; j += 1) {
const c = path[j];
const x = cellX(c) + CS / 2;
const y = cellY(c) + CS / 2;
if (j === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); }
}
ctx.stroke();
}
if (phase === 0) {
ctx.fillStyle = rgba(PINK, 1);
ctx.fillRect(cellX(cur) + 1, cellY(cur) + 1, CS - 2, CS - 2);
}
drawGates();
panelRow(0, '기울기 K', k.toFixed(1));
panelRow(1, '열린 칸', String(openN));
panelRow(2, '닫은 칸', String(closedN));
panelRow(3, '경로 / 최단', (path.length > 0 ? String(path.length - 1) : '—') + ' / ' + String(shortest));
}
function after(): void {
if (still) { settle(); }
report();
draw();
}
function stepK(dirn: number): void {
const i = K_STEPS.indexOf(k);
k = K_STEPS[(i + dirn + K_STEPS.length) % K_STEPS.length];
startSearch();
after();
}
function newMaze(): void {
pass += 1;
buildMaze(SEED + pass);
startSearch();
after();
}
cv.addEventListener('pointerdown', function (): void { wake(); stepK(1); });
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key === 'ArrowUp') { ev.preventDefault(); wake(); stepK(1); }
else if (ev.key === 'ArrowDown') { ev.preventDefault(); wake(); stepK(-1); }
else if (ev.key === 'Enter') { ev.preventDefault(); wake(); startSearch(); after(); }
else if (ev.key === 'Home') { ev.preventDefault(); wake(); newMaze(); }
});
if (kBtn) { kBtn.addEventListener('click', function (): void { wake(); stepK(1); }); }
if (newBtn) { newBtn.addEventListener('click', function (): void { wake(); newMaze(); }); }
buildMaze(SEED);
startSearch();
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("hs") + " is-demo"} ref={rootRef}>
<canvas className={cx("hs__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="지나온 거리와 추정 거리의 합이 가장 작은 칸부터 열고 도착하면 부모 칸을 거꾸로 짚어 경로를 긋는 길 찾기. 누르면 기울기가 바뀐다"></canvas>
<p className={cx("hs__status")} data-role="status" aria-live="off">추정 거리가 가장 작은 칸부터 여는 중</p>
<div className={cx("hs__tools")}>
<button className={cx("hs__btn")} data-role="k" type="button">기울기 바꾸기</button>
<button className={cx("hs__btn")} data-role="new" type="button">새 미로</button>
</div>
</div>
);
}06 · A Path Search That Opens the Cell With the Smallest Estimated Distance to the Goal First — Measured area 12.835% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
for (let d = 0; d < 4; d += 1) {
if (!isOpen(walls, c, d)) {
continue;
}
const n = neighbor(c, d);
if (closed[n] === 1) {
continue;
}
const ng = g[c] + 1;
if (ng >= g[n]) {
continue;
}
g[n] = ng;
parent[n] = c;
if (inOpen[n] === 0) {
inOpen[n] = 1;
openN += 1;
}
heapPush(ng + k * hEst(n), n);
}
07A Walk That Finds the Exit by Keeping One Hand on the Left Wall
Every 16ms the walker applies three rules in order: if the left is open, turn left and step; otherwise, if the front is open, step ahead; if both are blocked, turn right in place. Each step multiplies every cell's trail by 0.996, a half-life of about 173 steps, so old footprints dim, and cells passed once are painted mint while cells passed twice or more turn lilac. On the seed-464 maze the left hand reaches the exit in 1,095 steps (810 cells forward, 570 turns); the auto demo continues a walk already 420 steps in, From the start walks again from step 0, and the render measured 4.7% cumulative area, 70.1 mean intensity and 23/23 moved frames.

<main class="stage">
<div class="hs is-demo" id="hs">
<canvas class="hs__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="A maze walked with one hand on the wall by three rules until the exit, with the trail fading behind. Press to pause or resume"></canvas>
<p class="hs__status" data-role="status" aria-live="off">Walking with a hand on the left wall</p>
<div class="hs__tools">
<button class="hs__btn" data-role="hand" type="button" aria-pressed="false">Use right hand</button>
<button class="hs__btn" data-role="restart" type="button">From the start</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.hs {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.hs__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.hs__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.hs__btn:hover {
background: $subject-cream;
}
.hs__btn[aria-pressed="true"] {
background: $subject-mint;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.hs__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.hs__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.hs__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.hs__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hs {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.hs__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.hs__btn:hover {
background: #fff7e6;
}
.hs__btn[aria-pressed=true] {
background: #4cd4a6;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.hs__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.hs__btn {
font-size: 10px;
padding: 4px 8px;
}
.hs__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.hs__btn {
transition: none;
}
}(function () {
const root = document.getElementById("hs");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 바닥을 얹어 명도 차로 길이 읽힌다
const BG = [29, 26, 44];
const ROCK = [44, 39, 64];
const PAPER = [255, 248, 234];
const INK = [58, 48, 76];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 미로가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 격자 — 31×31 칸, 칸마다 벽 네 장을 비트 하나씩으로 든다
const GRID_N = 31;
const CELL_N = GRID_N * GRID_N;
const CS = 9;
const BOARD = GRID_N * CS;
let BX = W - 12 - BOARD;
const BY = (H - BOARD) / 2;
const ENTRY = 0;
const EXIT = CELL_N - 1;
// 방향 0 북 · 1 동 · 2 남 · 3 서 — 시계 방향 순서라 +1 이 오른쪽으로 돌기, +3 이 왼쪽으로 돌기
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
const BIT = [1, 2, 4, 8];
const ALL_WALLS = 15;
function neighbor(i, d) {
const x = (i % GRID_N) + DX[d];
const y = Math.floor(i / GRID_N) + DY[d];
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function isOpen(walls, i, d) {
return (walls[i] & BIT[d]) === 0 && neighbor(i, d) >= 0;
}
// 벽 한 장 걷기 — 양쪽 칸의 비트를 함께 지운다 (한쪽만 지우면 길이 일방통행이 된다)
function knock(walls, i, d) {
const n = neighbor(i, d);
walls[i] &= ~BIT[d];
walls[n] &= ~BIT[(d + 2) % 4];
return n;
}
function openCount(walls, i) {
let c = 0;
for (let d = 0; d < 4; d += 1) {
if (isOpen(walls, i, d)) {
c += 1;
}
}
return c;
}
function cellX(i) { return BX + (i % GRID_N) * CS; }
function cellY(i) { return BY + Math.floor(i / GRID_N) * CS; }
function cellAt(sx, sy) {
const x = Math.floor((sx - BX) / CS);
const y = Math.floor((sy - BY) / CS);
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function fillCell(i, c, a) {
ctx.fillStyle = rgba(c, a);
ctx.fillRect(cellX(i), cellY(i), CS, CS);
}
// 벽 비트를 선분으로 — 칸마다 동·남 벽만 긋고 맨 윗줄·맨 왼줄만 북·서 벽을 더 긋는다 (같은 벽을 두 번 긋지 않는다)
function strokeWalls(walls, color, lw) {
ctx.beginPath();
for (let i = 0; i < CELL_N; i += 1) {
const x = cellX(i);
const y = cellY(i);
const w = walls[i];
if ((w & 2) !== 0) {
ctx.moveTo(x + CS, y);
ctx.lineTo(x + CS, y + CS);
}
if ((w & 4) !== 0) {
ctx.moveTo(x, y + CS);
ctx.lineTo(x + CS, y + CS);
}
if (i < GRID_N && (w & 1) !== 0) {
ctx.moveTo(x, y);
ctx.lineTo(x + CS, y);
}
if (i % GRID_N === 0 && (w & 8) !== 0) {
ctx.moveTo(x, y);
ctx.lineTo(x, y + CS);
}
}
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineCap = 'square';
ctx.stroke();
}
// 입구(왼쪽 위)·출구(오른쪽 아래) 표시 — 바깥 벽 밖에 작은 삼각형
function drawGates() {
ctx.fillStyle = rgba(MINT, 0.95);
ctx.beginPath();
ctx.moveTo(BX - 9, BY + 1);
ctx.lineTo(BX - 2, BY + CS / 2);
ctx.lineTo(BX - 9, BY + CS - 1);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(PINK, 0.95);
ctx.beginPath();
ctx.moveTo(BX + BOARD + 2, BY + BOARD - CS + 1);
ctx.lineTo(BX + BOARD + 9, BY + BOARD - CS / 2);
ctx.lineTo(BX + BOARD + 2, BY + BOARD - 1);
ctx.closePath();
ctx.fill();
}
// 왼쪽 칸의 수치 — 이름표 한 줄 + 값 한 줄
function panelRow(row, label, value) {
const y = 104 + row * 34;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(PAPER, 0.62);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, 16, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 15px sans-serif';
ctx.fillText(value, 16, y + 18);
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 깊이 우선 파기 한 번에 끝까지 — 스택 머리에서 안 판 이웃을 골라 벽을 걷고, 없으면 하나 뺀다
function carveDfsAll(seed, start) {
const walls = new Uint8Array(CELL_N).fill(ALL_WALLS);
const seen = new Uint8Array(CELL_N);
const stk = new Int32Array(CELL_N);
const rnd = makeRng(seed);
let sp = 0;
stk[sp] = start;
sp = 1;
seen[start] = 1;
const opts = [];
while (sp > 0) {
const cur = stk[sp - 1];
opts.length = 0;
for (let d = 0; d < 4; d += 1) {
const n = neighbor(cur, d);
if (n >= 0 && seen[n] === 0) {
opts.push(d);
}
}
if (opts.length === 0) {
sp -= 1;
continue;
}
const n = knock(walls, cur, opts[Math.floor(rnd() * opts.length)]);
seen[n] = 1;
stk[sp] = n;
sp += 1;
}
return walls;
}
// 너비 우선 거리 — 큐에서 꺼낸 칸의 열린 이웃에 거리+1 을 적는다. 닿지 않은 칸은 -1
function bfsDist(walls, src) {
const dist = new Int32Array(CELL_N).fill(-1);
const q = new Int32Array(CELL_N);
let head = 0;
let tail = 0;
dist[src] = 0;
q[tail] = src;
tail += 1;
while (head < tail) {
const c = q[head];
head += 1;
for (let d = 0; d < 4; d += 1) {
if (!isOpen(walls, c, d)) {
continue;
}
const n = neighbor(c, d);
if (dist[n] >= 0) {
continue;
}
dist[n] = dist[c] + 1;
q[tail] = n;
tail += 1;
}
}
return dist;
}
// 상태기계 한 번(앞으로 한 칸 또는 제자리 돌기)에 쓰는 시간
const WALK_STEP_MS = 16;
// 어느 손으로 벽을 짚나 — left 면 왼쪽이 열려 있을 때 먼저 왼쪽으로 돈다
const HAND_SIDE = 'left';
// 걸음마다 지나온 자취의 밝기에 곱하는 값 (지수 감쇠 — 0.996 이면 173Steps 뒤 절반)
const TRAIL_FADE = 0.996;
const SEED = 464;
// 시연은 앞 420걸음을 미리 걸어 둔 상태에서 이어 걷는다 — 2초 안에 자취가 옅어지는 모습이 보이게
const DEMO_WARM = 420;
const HOLD_MS = 1400;
const TXT_LEFT = 'Walking with a hand on the left wall';
const TXT_RIGHT = 'Walking with a hand on the right wall';
const TXT_DONE = 'Reached the exit';
const TXT_PAUSE = 'Paused';
const handBtn = root.querySelector('[data-role="hand"]');
const restartBtn = root.querySelector('[data-role="restart"]');
let pass = 0;
let walls = carveDfsAll(SEED, ENTRY);
let shortest = bfsDist(walls, ENTRY)[EXIT];
const visits = new Uint16Array(CELL_N);
const trail = new Float32Array(CELL_N);
let hand = HAND_SIDE;
let pos = ENTRY;
let dir = 1;
let steps = 0;
let moves = 0;
let turns = 0;
let paused = false;
let stepAcc = 0;
let holdLeft = 0;
function resetWalk() {
visits.fill(0);
trail.fill(0);
pos = ENTRY;
dir = 1;
steps = 0;
moves = 0;
turns = 0;
visits[pos] = 1;
trail[pos] = 1;
stepAcc = 0;
holdLeft = 0;
}
function moveAhead() {
pos = neighbor(pos, dir);
moves += 1;
visits[pos] += 1;
trail[pos] = 1;
}
// 규칙 세 개 — ① Hand 쪽이 열려 있으면 그쪽으로 돌아 한 칸 ② 아니고 앞이 열려 있으면 한 칸 ③ 둘 다 막혔으면 반대쪽으로 제자리 돌기
function walkStep() {
if (pos === EXIT) {
return false;
}
const side = hand === 'left' ? (dir + 3) % 4 : (dir + 1) % 4;
const other = hand === 'left' ? (dir + 1) % 4 : (dir + 3) % 4;
steps += 1;
for (let i = 0; i < CELL_N; i += 1) {
trail[i] *= TRAIL_FADE;
}
if (isOpen(walls, pos, side)) {
dir = side;
turns += 1;
moveAhead();
}
else if (isOpen(walls, pos, dir)) {
moveAhead();
}
else {
dir = other;
turns += 1;
}
return true;
}
function done() { return pos === EXIT; }
function report() {
if (done()) {
say(TXT_DONE);
}
else if (paused) {
say(TXT_PAUSE);
}
else {
say(hand === 'left' ? TXT_LEFT : TXT_RIGHT);
}
}
function walk(n) {
for (let k = 0; k < n && walkStep(); k += 1) { /* n Steps */ }
}
function step() {
if (paused || done()) {
return;
}
stepAcc += FIXED_DT;
while (stepAcc >= WALK_STEP_MS && !done()) {
stepAcc -= WALK_STEP_MS;
walkStep();
}
report();
}
function settle() {
walk(CELL_N * 8);
report();
}
function demoTick(t) {
if (!done() || t < 0) {
return;
}
holdLeft += FIXED_DT;
if (holdLeft >= HOLD_MS) {
pass += 1;
walls = carveDfsAll(SEED + pass, ENTRY);
shortest = bfsDist(walls, ENTRY)[EXIT];
resetWalk();
walk(DEMO_WARM);
}
}
const FLOOR = mix(PAPER, BG, 0.06);
const ONCE = mix(PAPER, MINT, 0.5);
const TWICE = mix(PAPER, LILAC, 0.55);
function draw() {
clearStage();
for (let i = 0; i < CELL_N; i += 1) {
if (visits[i] === 0) {
fillCell(i, FLOOR, 1);
continue;
}
// 한 번 지난 칸은 민트, 두 번 이상(막다른 골목에서 되나온 칸)은 라일락 — 자취가 밝을수록 진하게
const baseC = visits[i] === 1 ? ONCE : TWICE;
const hot = visits[i] === 1 ? MINT : LILAC;
fillCell(i, mix(baseC, mix(hot, PINK, 0.25), trail[i]), 1);
}
strokeWalls(walls, rgba(INK, 1), 2);
// 걷는 이 — 방향 삼각형과 Hand 점
const cx = cellX(pos) + CS / 2;
const cy = cellY(pos) + CS / 2;
const fx = DX[dir];
const fy = DY[dir];
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.moveTo(cx + fx * 4.2, cy + fy * 4.2);
ctx.lineTo(cx - fx * 3 - fy * 3.2, cy - fy * 3 + fx * 3.2);
ctx.lineTo(cx - fx * 3 + fy * 3.2, cy - fy * 3 - fx * 3.2);
ctx.closePath();
ctx.fill();
const sd = hand === 'left' ? (dir + 3) % 4 : (dir + 1) % 4;
ctx.fillStyle = rgba(PAPER, 1);
ctx.beginPath();
ctx.arc(cx + DX[sd] * 3.6, cy + DY[sd] * 3.6, 1.6, 0, Math.PI * 2);
ctx.fill();
drawGates();
panelRow(0, 'Steps', String(steps));
panelRow(1, 'Cells moved', String(moves));
panelRow(2, 'Shortest', String(shortest));
panelRow(3, 'Hand', hand === 'left' ? 'Left' : 'Right');
}
function after() {
if (still) {
settle();
}
report();
draw();
}
function restart() {
resetWalk();
after();
}
function toggleHand() {
hand = hand === 'left' ? 'right' : 'left';
if (handBtn) {
handBtn.setAttribute('aria-pressed', hand === 'right' ? 'true' : 'false');
}
restart();
}
function setPaused(v) {
paused = v;
report();
draw();
}
cv.addEventListener('pointerdown', function () { wake(); setPaused(!paused); });
cv.addEventListener('keydown', function (ev) {
if (ev.key === ' ' || ev.key === 'Spacebar') {
ev.preventDefault();
wake();
setPaused(!paused);
}
else if (ev.key === 'Enter') {
ev.preventDefault();
wake();
toggleHand();
}
else if (ev.key === 'ArrowRight') {
ev.preventDefault();
wake();
walk(20);
after();
}
else if (ev.key === 'Home') {
ev.preventDefault();
wake();
restart();
}
});
if (handBtn) {
handBtn.addEventListener('click', function () { wake(); toggleHand(); });
}
if (restartBtn) {
restartBtn.addEventListener('click', function () { wake(); restart(); });
}
resetWalk();
if (demo && !still) {
walk(DEMO_WARM);
}
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// MzLeftHandWallFollower - vanilla 판(07-mz-left-hand-wall-follower) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./MzLeftHandWallFollower.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function MzLeftHandWallFollower() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 바닥을 얹어 명도 차로 길이 읽힌다
const BG: RGB = [29, 26, 44];
const ROCK: RGB = [44, 39, 64];
const PAPER: RGB = [255, 248, 234];
const INK: RGB = [58, 48, 76];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 미로가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 격자 — 31×31 칸, 칸마다 벽 네 장을 비트 하나씩으로 든다
const GRID_N = 31;
const CELL_N = GRID_N * GRID_N;
const CS = 9;
const BOARD = GRID_N * CS;
let BX = W - 12 - BOARD;
const BY = (H - BOARD) / 2;
const ENTRY = 0;
const EXIT = CELL_N - 1;
// 방향 0 북 · 1 동 · 2 남 · 3 서 — 시계 방향 순서라 +1 이 오른쪽으로 돌기, +3 이 왼쪽으로 돌기
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
const BIT = [1, 2, 4, 8];
const ALL_WALLS = 15;
function neighbor(i: number, d: number): number {
const x = (i % GRID_N) + DX[d];
const y = Math.floor(i / GRID_N) + DY[d];
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function isOpen(walls: Uint8Array, i: number, d: number): boolean {
return (walls[i] & BIT[d]) === 0 && neighbor(i, d) >= 0;
}
// 벽 한 장 걷기 — 양쪽 칸의 비트를 함께 지운다 (한쪽만 지우면 길이 일방통행이 된다)
function knock(walls: Uint8Array, i: number, d: number): number {
const n = neighbor(i, d);
walls[i] &= ~BIT[d];
walls[n] &= ~BIT[(d + 2) % 4];
return n;
}
function openCount(walls: Uint8Array, i: number): number {
let c = 0;
for (let d = 0; d < 4; d += 1) { if (isOpen(walls, i, d)) { c += 1; } }
return c;
}
function cellX(i: number): number { return BX + (i % GRID_N) * CS; }
function cellY(i: number): number { return BY + Math.floor(i / GRID_N) * CS; }
function cellAt(sx: number, sy: number): number {
const x = Math.floor((sx - BX) / CS);
const y = Math.floor((sy - BY) / CS);
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function fillCell(i: number, c: RGB, a: number): void {
ctx.fillStyle = rgba(c, a);
ctx.fillRect(cellX(i), cellY(i), CS, CS);
}
// 벽 비트를 선분으로 — 칸마다 동·남 벽만 긋고 맨 윗줄·맨 왼줄만 북·서 벽을 더 긋는다 (같은 벽을 두 번 긋지 않는다)
function strokeWalls(walls: Uint8Array, color: string, lw: number): void {
ctx.beginPath();
for (let i = 0; i < CELL_N; i += 1) {
const x = cellX(i);
const y = cellY(i);
const w = walls[i];
if ((w & 2) !== 0) { ctx.moveTo(x + CS, y); ctx.lineTo(x + CS, y + CS); }
if ((w & 4) !== 0) { ctx.moveTo(x, y + CS); ctx.lineTo(x + CS, y + CS); }
if (i < GRID_N && (w & 1) !== 0) { ctx.moveTo(x, y); ctx.lineTo(x + CS, y); }
if (i % GRID_N === 0 && (w & 8) !== 0) { ctx.moveTo(x, y); ctx.lineTo(x, y + CS); }
}
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineCap = 'square';
ctx.stroke();
}
// 입구(왼쪽 위)·출구(오른쪽 아래) 표시 — 바깥 벽 밖에 작은 삼각형
function drawGates(): void {
ctx.fillStyle = rgba(MINT, 0.95);
ctx.beginPath();
ctx.moveTo(BX - 9, BY + 1);
ctx.lineTo(BX - 2, BY + CS / 2);
ctx.lineTo(BX - 9, BY + CS - 1);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(PINK, 0.95);
ctx.beginPath();
ctx.moveTo(BX + BOARD + 2, BY + BOARD - CS + 1);
ctx.lineTo(BX + BOARD + 9, BY + BOARD - CS / 2);
ctx.lineTo(BX + BOARD + 2, BY + BOARD - 1);
ctx.closePath();
ctx.fill();
}
// 왼쪽 칸의 수치 — 이름표 한 줄 + 값 한 줄
function panelRow(row: number, label: string, value: string): void {
const y = 104 + row * 34;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(PAPER, 0.62);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, 16, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 15px sans-serif';
ctx.fillText(value, 16, y + 18);
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 깊이 우선 파기 한 번에 끝까지 — 스택 머리에서 안 판 이웃을 골라 벽을 걷고, 없으면 하나 뺀다
function carveDfsAll(seed: number, start: number): Uint8Array {
const walls = new Uint8Array(CELL_N).fill(ALL_WALLS);
const seen = new Uint8Array(CELL_N);
const stk = new Int32Array(CELL_N);
const rnd = makeRng(seed);
let sp = 0;
stk[sp] = start;
sp = 1;
seen[start] = 1;
const opts: number[] = [];
while (sp > 0) {
const cur = stk[sp - 1];
opts.length = 0;
for (let d = 0; d < 4; d += 1) {
const n = neighbor(cur, d);
if (n >= 0 && seen[n] === 0) { opts.push(d); }
}
if (opts.length === 0) { sp -= 1; continue; }
const n = knock(walls, cur, opts[Math.floor(rnd() * opts.length)]);
seen[n] = 1;
stk[sp] = n;
sp += 1;
}
return walls;
}
// 너비 우선 거리 — 큐에서 꺼낸 칸의 열린 이웃에 거리+1 을 적는다. 닿지 않은 칸은 -1
function bfsDist(walls: Uint8Array, src: number): Int32Array {
const dist = new Int32Array(CELL_N).fill(-1);
const q = new Int32Array(CELL_N);
let head = 0;
let tail = 0;
dist[src] = 0;
q[tail] = src;
tail += 1;
while (head < tail) {
const c = q[head];
head += 1;
for (let d = 0; d < 4; d += 1) {
if (!isOpen(walls, c, d)) { continue; }
const n = neighbor(c, d);
if (dist[n] >= 0) { continue; }
dist[n] = dist[c] + 1;
q[tail] = n;
tail += 1;
}
}
return dist;
}
type Hand = 'left' | 'right';
// 상태기계 한 번(앞으로 한 칸 또는 제자리 돌기)에 쓰는 시간
const WALK_STEP_MS = 16;
// 어느 손으로 벽을 짚나 — left 면 왼쪽이 열려 있을 때 먼저 왼쪽으로 돈다
const HAND_SIDE: Hand = 'left';
// 걸음마다 지나온 자취의 밝기에 곱하는 값 (지수 감쇠 — 0.996 이면 173걸음 뒤 절반)
const TRAIL_FADE = 0.996;
const SEED = 464;
// 시연은 앞 420걸음을 미리 걸어 둔 상태에서 이어 걷는다 — 2초 안에 자취가 옅어지는 모습이 보이게
const DEMO_WARM = 420;
const HOLD_MS = 1400;
const TXT_LEFT = '왼쪽 벽을 짚고 걷는 중';
const TXT_RIGHT = '오른쪽 벽을 짚고 걷는 중';
const TXT_DONE = '출구 도착';
const TXT_PAUSE = '멈춤';
const handBtn = root.querySelector('[data-role="hand"]') as HTMLButtonElement | null;
const restartBtn = root.querySelector('[data-role="restart"]') as HTMLButtonElement | null;
let pass = 0;
let walls = carveDfsAll(SEED, ENTRY);
let shortest = bfsDist(walls, ENTRY)[EXIT];
const visits = new Uint16Array(CELL_N);
const trail = new Float32Array(CELL_N);
let hand: Hand = HAND_SIDE;
let pos = ENTRY;
let dir = 1;
let steps = 0;
let moves = 0;
let turns = 0;
let paused = false;
let stepAcc = 0;
let holdLeft = 0;
function resetWalk(): void {
visits.fill(0);
trail.fill(0);
pos = ENTRY;
dir = 1;
steps = 0;
moves = 0;
turns = 0;
visits[pos] = 1;
trail[pos] = 1;
stepAcc = 0;
holdLeft = 0;
}
function moveAhead(): void {
pos = neighbor(pos, dir);
moves += 1;
visits[pos] += 1;
trail[pos] = 1;
}
// 규칙 세 개 — ① 짚은 손 쪽이 열려 있으면 그쪽으로 돌아 한 칸 ② 아니고 앞이 열려 있으면 한 칸 ③ 둘 다 막혔으면 반대쪽으로 제자리 돌기
function walkStep(): boolean {
if (pos === EXIT) { return false; }
const side = hand === 'left' ? (dir + 3) % 4 : (dir + 1) % 4;
const other = hand === 'left' ? (dir + 1) % 4 : (dir + 3) % 4;
steps += 1;
for (let i = 0; i < CELL_N; i += 1) { trail[i] *= TRAIL_FADE; }
if (isOpen(walls, pos, side)) { dir = side; turns += 1; moveAhead(); }
else if (isOpen(walls, pos, dir)) { moveAhead(); }
else { dir = other; turns += 1; }
return true;
}
function done(): boolean { return pos === EXIT; }
function report(): void {
if (done()) { say(TXT_DONE); }
else if (paused) { say(TXT_PAUSE); }
else { say(hand === 'left' ? TXT_LEFT : TXT_RIGHT); }
}
function walk(n: number): void {
for (let k = 0; k < n && walkStep(); k += 1) { /* n 걸음 */ }
}
function step(): void {
if (paused || done()) { return; }
stepAcc += FIXED_DT;
while (stepAcc >= WALK_STEP_MS && !done()) {
stepAcc -= WALK_STEP_MS;
walkStep();
}
report();
}
function settle(): void {
walk(CELL_N * 8);
report();
}
function demoTick(t: number): void {
if (!done() || t < 0) { return; }
holdLeft += FIXED_DT;
if (holdLeft >= HOLD_MS) {
pass += 1;
walls = carveDfsAll(SEED + pass, ENTRY);
shortest = bfsDist(walls, ENTRY)[EXIT];
resetWalk();
walk(DEMO_WARM);
}
}
const FLOOR = mix(PAPER, BG, 0.06);
const ONCE = mix(PAPER, MINT, 0.5);
const TWICE = mix(PAPER, LILAC, 0.55);
function draw(): void {
clearStage();
for (let i = 0; i < CELL_N; i += 1) {
if (visits[i] === 0) { fillCell(i, FLOOR, 1); continue; }
// 한 번 지난 칸은 민트, 두 번 이상(막다른 골목에서 되나온 칸)은 라일락 — 자취가 밝을수록 진하게
const baseC = visits[i] === 1 ? ONCE : TWICE;
const hot = visits[i] === 1 ? MINT : LILAC;
fillCell(i, mix(baseC, mix(hot, PINK, 0.25), trail[i]), 1);
}
strokeWalls(walls, rgba(INK, 1), 2);
// 걷는 이 — 방향 삼각형과 짚은 손 점
const cx = cellX(pos) + CS / 2;
const cy = cellY(pos) + CS / 2;
const fx = DX[dir];
const fy = DY[dir];
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.moveTo(cx + fx * 4.2, cy + fy * 4.2);
ctx.lineTo(cx - fx * 3 - fy * 3.2, cy - fy * 3 + fx * 3.2);
ctx.lineTo(cx - fx * 3 + fy * 3.2, cy - fy * 3 - fx * 3.2);
ctx.closePath();
ctx.fill();
const sd = hand === 'left' ? (dir + 3) % 4 : (dir + 1) % 4;
ctx.fillStyle = rgba(PAPER, 1);
ctx.beginPath();
ctx.arc(cx + DX[sd] * 3.6, cy + DY[sd] * 3.6, 1.6, 0, Math.PI * 2);
ctx.fill();
drawGates();
panelRow(0, '걸음', String(steps));
panelRow(1, '앞으로 간 칸', String(moves));
panelRow(2, '최단 거리', String(shortest));
panelRow(3, '짚은 손', hand === 'left' ? '왼손' : '오른손');
}
function after(): void {
if (still) { settle(); }
report();
draw();
}
function restart(): void {
resetWalk();
after();
}
function toggleHand(): void {
hand = hand === 'left' ? 'right' : 'left';
if (handBtn) { handBtn.setAttribute('aria-pressed', hand === 'right' ? 'true' : 'false'); }
restart();
}
function setPaused(v: boolean): void {
paused = v;
report();
draw();
}
cv.addEventListener('pointerdown', function (): void { wake(); setPaused(!paused); });
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key === ' ' || ev.key === 'Spacebar') { ev.preventDefault(); wake(); setPaused(!paused); }
else if (ev.key === 'Enter') { ev.preventDefault(); wake(); toggleHand(); }
else if (ev.key === 'ArrowRight') { ev.preventDefault(); wake(); walk(20); after(); }
else if (ev.key === 'Home') { ev.preventDefault(); wake(); restart(); }
});
if (handBtn) { handBtn.addEventListener('click', function (): void { wake(); toggleHand(); }); }
if (restartBtn) { restartBtn.addEventListener('click', function (): void { wake(); restart(); }); }
resetWalk();
if (demo && !still) { walk(DEMO_WARM); }
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("hs") + " is-demo"} ref={rootRef}>
<canvas className={cx("hs__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="한쪽 손을 벽에 댄 채 규칙 세 개로 걸어 출구를 찾고 지나온 자취가 옅어지는 미로. 누르면 멈추거나 다시 걷는다"></canvas>
<p className={cx("hs__status")} data-role="status" aria-live="off">왼쪽 벽을 짚고 걷는 중</p>
<div className={cx("hs__tools")}>
<button className={cx("hs__btn")} data-role="hand" type="button" aria-pressed="false">오른손으로</button>
<button className={cx("hs__btn")} data-role="restart" type="button">처음부터</button>
</div>
</div>
);
}07 · A Walk That Finds the Exit by Keeping One Hand on the Left Wall — Measured area 4.677% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
// Three rules: (1) if the hand side is open, turn that way and step (2) otherwise, if the front is open, step (3) if both are blocked, turn the other way in place
function walkStep() {
if (pos === EXIT) {
return false;
}
const side = hand === 'left' ? (dir + 3) % 4 : (dir + 1) % 4;
const other = hand === 'left' ? (dir + 1) % 4 : (dir + 3) % 4;
steps += 1;
for (let i = 0; i < CELL_N; i += 1) {
trail[i] *= TRAIL_FADE;
}
if (isOpen(walls, pos, side)) {
dir = side;
turns += 1;
moveAhead();
}
else if (isOpen(walls, pos, dir)) {
moveAhead();
}
else {
dir = other;
turns += 1;
}
return true;
}
08A Board Where Distance Becomes Heat Color and the Answer Appears as a Valley
Breadth-first distance runs twice, once from the entrance and once from the goal, and for each cell the entrance distance plus the goal distance minus the shortest distance gives the extra cells a route through that cell costs, a value that is exactly 0 on the shortest path. That value goes onto a five-stop heat ramp and is pressed darker the nearer it is to 0, by up to 45% (VALLEY_DEPTH), while the level rises along a squared curve over 1.5 seconds so the lowest band surfaces first like the floor of a valley. At seed 464, 321 cells read 0, the cell count of the 320-step shortest path (shortest plus one on all 10 seeds tried), and the render measured 40.3% cumulative area, 120.3 mean intensity and 13/23 moved frames.

<main class="stage">
<div class="hs is-demo" id="hs">
<canvas class="hs__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="A board that paints each cell's detour as a heat color, fills from the lowest cells up, and reveals the shortest path as the valley floor. Press a cell to move the goal"></canvas>
<p class="hs__status" data-role="status" aria-live="off">Filling from the cells with the least detour</p>
<div class="hs__tools">
<button class="hs__btn" data-role="replay" type="button">Reveal again</button>
<button class="hs__btn" data-role="new" type="button">New maze</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.hs {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.hs__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.hs__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.hs__btn:hover {
background: $subject-cream;
}
.hs__btn[aria-pressed="true"] {
background: $subject-mint;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.hs__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.hs__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.hs__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.hs__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hs {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.hs__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.hs__btn:hover {
background: #fff7e6;
}
.hs__btn[aria-pressed=true] {
background: #4cd4a6;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.hs__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.hs__btn {
font-size: 10px;
padding: 4px 8px;
}
.hs__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.hs__btn {
transition: none;
}
}(function () {
const root = document.getElementById("hs");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 바닥을 얹어 명도 차로 길이 읽힌다
const BG = [29, 26, 44];
const ROCK = [44, 39, 64];
const PAPER = [255, 248, 234];
const INK = [58, 48, 76];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 미로가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 격자 — 31×31 칸, 칸마다 벽 네 장을 비트 하나씩으로 든다
const GRID_N = 31;
const CELL_N = GRID_N * GRID_N;
const CS = 9;
const BOARD = GRID_N * CS;
let BX = W - 12 - BOARD;
const BY = (H - BOARD) / 2;
const ENTRY = 0;
const EXIT = CELL_N - 1;
// 방향 0 북 · 1 동 · 2 남 · 3 서 — 시계 방향 순서라 +1 이 오른쪽으로 돌기, +3 이 왼쪽으로 돌기
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
const BIT = [1, 2, 4, 8];
const ALL_WALLS = 15;
function neighbor(i, d) {
const x = (i % GRID_N) + DX[d];
const y = Math.floor(i / GRID_N) + DY[d];
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function isOpen(walls, i, d) {
return (walls[i] & BIT[d]) === 0 && neighbor(i, d) >= 0;
}
// 벽 한 장 걷기 — 양쪽 칸의 비트를 함께 지운다 (한쪽만 지우면 길이 일방통행이 된다)
function knock(walls, i, d) {
const n = neighbor(i, d);
walls[i] &= ~BIT[d];
walls[n] &= ~BIT[(d + 2) % 4];
return n;
}
function openCount(walls, i) {
let c = 0;
for (let d = 0; d < 4; d += 1) {
if (isOpen(walls, i, d)) {
c += 1;
}
}
return c;
}
function cellX(i) { return BX + (i % GRID_N) * CS; }
function cellY(i) { return BY + Math.floor(i / GRID_N) * CS; }
function cellAt(sx, sy) {
const x = Math.floor((sx - BX) / CS);
const y = Math.floor((sy - BY) / CS);
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function fillCell(i, c, a) {
ctx.fillStyle = rgba(c, a);
ctx.fillRect(cellX(i), cellY(i), CS, CS);
}
// 벽 비트를 선분으로 — 칸마다 동·남 벽만 긋고 맨 윗줄·맨 왼줄만 북·서 벽을 더 긋는다 (같은 벽을 두 번 긋지 않는다)
function strokeWalls(walls, color, lw) {
ctx.beginPath();
for (let i = 0; i < CELL_N; i += 1) {
const x = cellX(i);
const y = cellY(i);
const w = walls[i];
if ((w & 2) !== 0) {
ctx.moveTo(x + CS, y);
ctx.lineTo(x + CS, y + CS);
}
if ((w & 4) !== 0) {
ctx.moveTo(x, y + CS);
ctx.lineTo(x + CS, y + CS);
}
if (i < GRID_N && (w & 1) !== 0) {
ctx.moveTo(x, y);
ctx.lineTo(x + CS, y);
}
if (i % GRID_N === 0 && (w & 8) !== 0) {
ctx.moveTo(x, y);
ctx.lineTo(x, y + CS);
}
}
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineCap = 'square';
ctx.stroke();
}
// 입구(왼쪽 위)·출구(오른쪽 아래) 표시 — 바깥 벽 밖에 작은 삼각형
function drawGates() {
ctx.fillStyle = rgba(MINT, 0.95);
ctx.beginPath();
ctx.moveTo(BX - 9, BY + 1);
ctx.lineTo(BX - 2, BY + CS / 2);
ctx.lineTo(BX - 9, BY + CS - 1);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(PINK, 0.95);
ctx.beginPath();
ctx.moveTo(BX + BOARD + 2, BY + BOARD - CS + 1);
ctx.lineTo(BX + BOARD + 9, BY + BOARD - CS / 2);
ctx.lineTo(BX + BOARD + 2, BY + BOARD - 1);
ctx.closePath();
ctx.fill();
}
// 왼쪽 칸의 수치 — 이름표 한 줄 + 값 한 줄
function panelRow(row, label, value) {
const y = 104 + row * 34;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(PAPER, 0.62);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, 16, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 15px sans-serif';
ctx.fillText(value, 16, y + 18);
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 깊이 우선 파기 한 번에 끝까지 — 스택 머리에서 안 판 이웃을 골라 벽을 걷고, 없으면 하나 뺀다
function carveDfsAll(seed, start) {
const walls = new Uint8Array(CELL_N).fill(ALL_WALLS);
const seen = new Uint8Array(CELL_N);
const stk = new Int32Array(CELL_N);
const rnd = makeRng(seed);
let sp = 0;
stk[sp] = start;
sp = 1;
seen[start] = 1;
const opts = [];
while (sp > 0) {
const cur = stk[sp - 1];
opts.length = 0;
for (let d = 0; d < 4; d += 1) {
const n = neighbor(cur, d);
if (n >= 0 && seen[n] === 0) {
opts.push(d);
}
}
if (opts.length === 0) {
sp -= 1;
continue;
}
const n = knock(walls, cur, opts[Math.floor(rnd() * opts.length)]);
seen[n] = 1;
stk[sp] = n;
sp += 1;
}
return walls;
}
// 너비 우선 거리 — 큐에서 꺼낸 칸의 열린 이웃에 거리+1 을 적는다. 닿지 않은 칸은 -1
function bfsDist(walls, src) {
const dist = new Int32Array(CELL_N).fill(-1);
const q = new Int32Array(CELL_N);
let head = 0;
let tail = 0;
dist[src] = 0;
q[tail] = src;
tail += 1;
while (head < tail) {
const c = q[head];
head += 1;
for (let d = 0; d < 4; d += 1) {
if (!isOpen(walls, c, d)) {
continue;
}
const n = neighbor(c, d);
if (dist[n] >= 0) {
continue;
}
dist[n] = dist[c] + 1;
q[tail] = n;
tail += 1;
}
}
return dist;
}
// 온도 색 눈금 — 앞이 차갑고(돌아가지 않는 칸) 뒤로 갈수록 뜨겁다. 칸 값은 이웃한 두 눈금 사이를 선형 보간
const THERMO_STOPS = [SKY, MINT, [250, 238, 186], PEACH, PINK];
// 골짜기 바닥(Shortest path)을 얼마나 어둡게 파나 — 0 이면 음영 없음
const VALLEY_DEPTH = 0.45;
// 낮은 칸부터 차올라 판 전체가 드러나는 데 걸리는 시간
const REVEAL_MS = 1500;
// 돌아가는 거리 몇 칸까지를 골짜기 비탈로 보나
const VALLEY_W = 28;
const SEED = 464;
const HOLD_MS = 1300;
const TXT_RISE = 'Filling from the cells with the least detour';
const TXT_DONE = 'Revealed — the valley floor is the shortest path';
const replayBtn = root.querySelector('[data-role="replay"]');
const newBtn = root.querySelector('[data-role="new"]');
let pass = 0;
let walls = carveDfsAll(SEED, ENTRY);
let goal = EXIT;
let excess = new Int32Array(CELL_N);
let shortest = 0;
let maxEx = 1;
let floorN = 0;
let revealT = 0;
let holdLeft = 0;
// 칸마다 「입구→칸 거리 + 칸→목표 거리 − Shortest」 = 그 칸을 거쳐 가면 더 걷는 칸 수. Shortest path 위는 정확히 0
function computeField() {
const ds = bfsDist(walls, ENTRY);
const dg = bfsDist(walls, goal);
shortest = ds[goal];
maxEx = 1;
floorN = 0;
for (let i = 0; i < CELL_N; i += 1) {
excess[i] = ds[i] + dg[i] - shortest;
if (excess[i] > maxEx) {
maxEx = excess[i];
}
if (excess[i] === 0) {
floorN += 1;
}
}
revealT = 0;
holdLeft = 0;
}
function thermo(t) {
const x = clamp(t, 0, 1) * (THERMO_STOPS.length - 1);
const i = Math.min(Math.floor(x), THERMO_STOPS.length - 2);
return mix(THERMO_STOPS[i], THERMO_STOPS[i + 1], x - i);
}
// 차오른 수위 — 처음엔 천천히(제곱) 올라 골짜기 바닥이 먼저 또렷하게 보인다
function level() {
const p = clamp(revealT / REVEAL_MS, 0, 1);
return maxEx * p * p;
}
function revealed() { return revealT >= REVEAL_MS; }
function report() { say(revealed() ? TXT_DONE : TXT_RISE); }
function step() {
if (revealed()) {
return;
}
revealT = Math.min(REVEAL_MS, revealT + FIXED_DT);
report();
}
function settle() {
revealT = REVEAL_MS;
report();
}
function demoTick(t) {
if (!revealed() || t < 0) {
return;
}
holdLeft += FIXED_DT;
if (holdLeft >= HOLD_MS) {
newMaze();
}
}
function shade(c, ex) {
const dip = VALLEY_DEPTH * (1 - smooth(ex / VALLEY_W));
return [c[0] * (1 - dip), c[1] * (1 - dip), c[2] * (1 - dip)];
}
function draw() {
clearStage();
const lv = level();
let shown = 0;
for (let i = 0; i < CELL_N; i += 1) {
const ex = excess[i];
if (ex > lv) {
fillCell(i, ROCK, 1);
continue;
}
shown += 1;
let c = shade(thermo(ex / maxEx), ex);
// 수면 가장자리 — 막 잠긴 칸은 밝게
if (!revealed() && lv - ex < maxEx * 0.04) {
c = mix(c, PAPER, 0.6);
}
fillCell(i, c, 1);
}
strokeWalls(walls, rgba(INK, 1), 2);
// 골짜기 바닥선 — 돌아가는 거리가 0 인 칸들을 잇는다
ctx.fillStyle = rgba(PAPER, 0.9);
for (let i = 0; i < CELL_N; i += 1) {
if (excess[i] === 0) {
ctx.fillRect(cellX(i) + 3.5, cellY(i) + 3.5, 2, 2);
}
}
ctx.strokeStyle = rgba(PAPER, 1);
ctx.lineWidth = 2;
ctx.strokeRect(cellX(goal) + 1, cellY(goal) + 1, CS - 2, CS - 2);
drawGates();
panelRow(0, 'Shortest path', String(shortest));
panelRow(1, 'Valley-floor cells', String(floorN));
panelRow(2, 'Largest detour', String(maxEx));
panelRow(3, 'Cells revealed', String(shown) + ' / ' + String(CELL_N));
}
function after() {
if (still) {
settle();
}
report();
draw();
}
function newMaze() {
pass += 1;
walls = carveDfsAll(SEED + pass, ENTRY);
goal = EXIT;
computeField();
after();
}
function setGoal(c) {
if (c < 0 || c === ENTRY) {
return;
}
goal = c;
computeField();
after();
}
function moveGoal(dx, dy) {
const x = clamp((goal % GRID_N) + dx, 0, GRID_N - 1);
const y = clamp(Math.floor(goal / GRID_N) + dy, 0, GRID_N - 1);
setGoal(y * GRID_N + x);
}
cv.addEventListener('pointerdown', function (ev) {
wake();
const p = toScene(ev);
setGoal(cellAt(p[0], p[1]));
});
cv.addEventListener('keydown', function (ev) {
let used = true;
if (ev.key === 'ArrowLeft') {
moveGoal(-1, 0);
}
else if (ev.key === 'ArrowRight') {
moveGoal(1, 0);
}
else if (ev.key === 'ArrowUp') {
moveGoal(0, -1);
}
else if (ev.key === 'ArrowDown') {
moveGoal(0, 1);
}
else if (ev.key === 'Enter') {
computeField();
after();
}
else if (ev.key === 'Home') {
newMaze();
}
else {
used = false;
}
if (used) {
ev.preventDefault();
wake();
}
});
if (replayBtn) {
replayBtn.addEventListener('click', function () { wake(); computeField(); after(); });
}
if (newBtn) {
newBtn.addEventListener('click', function () { wake(); newMaze(); });
}
computeField();
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// MzSolutionThermoRevealPath - vanilla 판(08-mz-solution-thermo-reveal-path) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./MzSolutionThermoRevealPath.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function MzSolutionThermoRevealPath() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 바닥을 얹어 명도 차로 길이 읽힌다
const BG: RGB = [29, 26, 44];
const ROCK: RGB = [44, 39, 64];
const PAPER: RGB = [255, 248, 234];
const INK: RGB = [58, 48, 76];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 미로가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 격자 — 31×31 칸, 칸마다 벽 네 장을 비트 하나씩으로 든다
const GRID_N = 31;
const CELL_N = GRID_N * GRID_N;
const CS = 9;
const BOARD = GRID_N * CS;
let BX = W - 12 - BOARD;
const BY = (H - BOARD) / 2;
const ENTRY = 0;
const EXIT = CELL_N - 1;
// 방향 0 북 · 1 동 · 2 남 · 3 서 — 시계 방향 순서라 +1 이 오른쪽으로 돌기, +3 이 왼쪽으로 돌기
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
const BIT = [1, 2, 4, 8];
const ALL_WALLS = 15;
function neighbor(i: number, d: number): number {
const x = (i % GRID_N) + DX[d];
const y = Math.floor(i / GRID_N) + DY[d];
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function isOpen(walls: Uint8Array, i: number, d: number): boolean {
return (walls[i] & BIT[d]) === 0 && neighbor(i, d) >= 0;
}
// 벽 한 장 걷기 — 양쪽 칸의 비트를 함께 지운다 (한쪽만 지우면 길이 일방통행이 된다)
function knock(walls: Uint8Array, i: number, d: number): number {
const n = neighbor(i, d);
walls[i] &= ~BIT[d];
walls[n] &= ~BIT[(d + 2) % 4];
return n;
}
function openCount(walls: Uint8Array, i: number): number {
let c = 0;
for (let d = 0; d < 4; d += 1) { if (isOpen(walls, i, d)) { c += 1; } }
return c;
}
function cellX(i: number): number { return BX + (i % GRID_N) * CS; }
function cellY(i: number): number { return BY + Math.floor(i / GRID_N) * CS; }
function cellAt(sx: number, sy: number): number {
const x = Math.floor((sx - BX) / CS);
const y = Math.floor((sy - BY) / CS);
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function fillCell(i: number, c: RGB, a: number): void {
ctx.fillStyle = rgba(c, a);
ctx.fillRect(cellX(i), cellY(i), CS, CS);
}
// 벽 비트를 선분으로 — 칸마다 동·남 벽만 긋고 맨 윗줄·맨 왼줄만 북·서 벽을 더 긋는다 (같은 벽을 두 번 긋지 않는다)
function strokeWalls(walls: Uint8Array, color: string, lw: number): void {
ctx.beginPath();
for (let i = 0; i < CELL_N; i += 1) {
const x = cellX(i);
const y = cellY(i);
const w = walls[i];
if ((w & 2) !== 0) { ctx.moveTo(x + CS, y); ctx.lineTo(x + CS, y + CS); }
if ((w & 4) !== 0) { ctx.moveTo(x, y + CS); ctx.lineTo(x + CS, y + CS); }
if (i < GRID_N && (w & 1) !== 0) { ctx.moveTo(x, y); ctx.lineTo(x + CS, y); }
if (i % GRID_N === 0 && (w & 8) !== 0) { ctx.moveTo(x, y); ctx.lineTo(x, y + CS); }
}
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineCap = 'square';
ctx.stroke();
}
// 입구(왼쪽 위)·출구(오른쪽 아래) 표시 — 바깥 벽 밖에 작은 삼각형
function drawGates(): void {
ctx.fillStyle = rgba(MINT, 0.95);
ctx.beginPath();
ctx.moveTo(BX - 9, BY + 1);
ctx.lineTo(BX - 2, BY + CS / 2);
ctx.lineTo(BX - 9, BY + CS - 1);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(PINK, 0.95);
ctx.beginPath();
ctx.moveTo(BX + BOARD + 2, BY + BOARD - CS + 1);
ctx.lineTo(BX + BOARD + 9, BY + BOARD - CS / 2);
ctx.lineTo(BX + BOARD + 2, BY + BOARD - 1);
ctx.closePath();
ctx.fill();
}
// 왼쪽 칸의 수치 — 이름표 한 줄 + 값 한 줄
function panelRow(row: number, label: string, value: string): void {
const y = 104 + row * 34;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(PAPER, 0.62);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, 16, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 15px sans-serif';
ctx.fillText(value, 16, y + 18);
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 깊이 우선 파기 한 번에 끝까지 — 스택 머리에서 안 판 이웃을 골라 벽을 걷고, 없으면 하나 뺀다
function carveDfsAll(seed: number, start: number): Uint8Array {
const walls = new Uint8Array(CELL_N).fill(ALL_WALLS);
const seen = new Uint8Array(CELL_N);
const stk = new Int32Array(CELL_N);
const rnd = makeRng(seed);
let sp = 0;
stk[sp] = start;
sp = 1;
seen[start] = 1;
const opts: number[] = [];
while (sp > 0) {
const cur = stk[sp - 1];
opts.length = 0;
for (let d = 0; d < 4; d += 1) {
const n = neighbor(cur, d);
if (n >= 0 && seen[n] === 0) { opts.push(d); }
}
if (opts.length === 0) { sp -= 1; continue; }
const n = knock(walls, cur, opts[Math.floor(rnd() * opts.length)]);
seen[n] = 1;
stk[sp] = n;
sp += 1;
}
return walls;
}
// 너비 우선 거리 — 큐에서 꺼낸 칸의 열린 이웃에 거리+1 을 적는다. 닿지 않은 칸은 -1
function bfsDist(walls: Uint8Array, src: number): Int32Array {
const dist = new Int32Array(CELL_N).fill(-1);
const q = new Int32Array(CELL_N);
let head = 0;
let tail = 0;
dist[src] = 0;
q[tail] = src;
tail += 1;
while (head < tail) {
const c = q[head];
head += 1;
for (let d = 0; d < 4; d += 1) {
if (!isOpen(walls, c, d)) { continue; }
const n = neighbor(c, d);
if (dist[n] >= 0) { continue; }
dist[n] = dist[c] + 1;
q[tail] = n;
tail += 1;
}
}
return dist;
}
// 온도 색 눈금 — 앞이 차갑고(돌아가지 않는 칸) 뒤로 갈수록 뜨겁다. 칸 값은 이웃한 두 눈금 사이를 선형 보간
const THERMO_STOPS: RGB[] = [SKY, MINT, [250, 238, 186], PEACH, PINK];
// 골짜기 바닥(최단 경로)을 얼마나 어둡게 파나 — 0 이면 음영 없음
const VALLEY_DEPTH = 0.45;
// 낮은 칸부터 차올라 판 전체가 드러나는 데 걸리는 시간
const REVEAL_MS = 1500;
// 돌아가는 거리 몇 칸까지를 골짜기 비탈로 보나
const VALLEY_W = 28;
const SEED = 464;
const HOLD_MS = 1300;
const TXT_RISE = '돌아가는 거리가 낮은 칸부터 차오르는 중';
const TXT_DONE = '드러남 — 골짜기 바닥이 최단 경로';
const replayBtn = root.querySelector('[data-role="replay"]') as HTMLButtonElement | null;
const newBtn = root.querySelector('[data-role="new"]') as HTMLButtonElement | null;
let pass = 0;
let walls = carveDfsAll(SEED, ENTRY);
let goal = EXIT;
let excess = new Int32Array(CELL_N);
let shortest = 0;
let maxEx = 1;
let floorN = 0;
let revealT = 0;
let holdLeft = 0;
// 칸마다 「입구→칸 거리 + 칸→목표 거리 − 최단 거리」 = 그 칸을 거쳐 가면 더 걷는 칸 수. 최단 경로 위는 정확히 0
function computeField(): void {
const ds = bfsDist(walls, ENTRY);
const dg = bfsDist(walls, goal);
shortest = ds[goal];
maxEx = 1;
floorN = 0;
for (let i = 0; i < CELL_N; i += 1) {
excess[i] = ds[i] + dg[i] - shortest;
if (excess[i] > maxEx) { maxEx = excess[i]; }
if (excess[i] === 0) { floorN += 1; }
}
revealT = 0;
holdLeft = 0;
}
function thermo(t: number): RGB {
const x = clamp(t, 0, 1) * (THERMO_STOPS.length - 1);
const i = Math.min(Math.floor(x), THERMO_STOPS.length - 2);
return mix(THERMO_STOPS[i], THERMO_STOPS[i + 1], x - i);
}
// 차오른 수위 — 처음엔 천천히(제곱) 올라 골짜기 바닥이 먼저 또렷하게 보인다
function level(): number {
const p = clamp(revealT / REVEAL_MS, 0, 1);
return maxEx * p * p;
}
function revealed(): boolean { return revealT >= REVEAL_MS; }
function report(): void { say(revealed() ? TXT_DONE : TXT_RISE); }
function step(): void {
if (revealed()) { return; }
revealT = Math.min(REVEAL_MS, revealT + FIXED_DT);
report();
}
function settle(): void {
revealT = REVEAL_MS;
report();
}
function demoTick(t: number): void {
if (!revealed() || t < 0) { return; }
holdLeft += FIXED_DT;
if (holdLeft >= HOLD_MS) { newMaze(); }
}
function shade(c: RGB, ex: number): RGB {
const dip = VALLEY_DEPTH * (1 - smooth(ex / VALLEY_W));
return [c[0] * (1 - dip), c[1] * (1 - dip), c[2] * (1 - dip)];
}
function draw(): void {
clearStage();
const lv = level();
let shown = 0;
for (let i = 0; i < CELL_N; i += 1) {
const ex = excess[i];
if (ex > lv) { fillCell(i, ROCK, 1); continue; }
shown += 1;
let c = shade(thermo(ex / maxEx), ex);
// 수면 가장자리 — 막 잠긴 칸은 밝게
if (!revealed() && lv - ex < maxEx * 0.04) { c = mix(c, PAPER, 0.6); }
fillCell(i, c, 1);
}
strokeWalls(walls, rgba(INK, 1), 2);
// 골짜기 바닥선 — 돌아가는 거리가 0 인 칸들을 잇는다
ctx.fillStyle = rgba(PAPER, 0.9);
for (let i = 0; i < CELL_N; i += 1) {
if (excess[i] === 0) { ctx.fillRect(cellX(i) + 3.5, cellY(i) + 3.5, 2, 2); }
}
ctx.strokeStyle = rgba(PAPER, 1);
ctx.lineWidth = 2;
ctx.strokeRect(cellX(goal) + 1, cellY(goal) + 1, CS - 2, CS - 2);
drawGates();
panelRow(0, '최단 경로', String(shortest));
panelRow(1, '골짜기 바닥 칸', String(floorN));
panelRow(2, '가장 많이 돌아감', String(maxEx));
panelRow(3, '드러난 칸', String(shown) + ' / ' + String(CELL_N));
}
function after(): void {
if (still) { settle(); }
report();
draw();
}
function newMaze(): void {
pass += 1;
walls = carveDfsAll(SEED + pass, ENTRY);
goal = EXIT;
computeField();
after();
}
function setGoal(c: number): void {
if (c < 0 || c === ENTRY) { return; }
goal = c;
computeField();
after();
}
function moveGoal(dx: number, dy: number): void {
const x = clamp((goal % GRID_N) + dx, 0, GRID_N - 1);
const y = clamp(Math.floor(goal / GRID_N) + dy, 0, GRID_N - 1);
setGoal(y * GRID_N + x);
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
setGoal(cellAt(p[0], p[1]));
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
let used = true;
if (ev.key === 'ArrowLeft') { moveGoal(-1, 0); }
else if (ev.key === 'ArrowRight') { moveGoal(1, 0); }
else if (ev.key === 'ArrowUp') { moveGoal(0, -1); }
else if (ev.key === 'ArrowDown') { moveGoal(0, 1); }
else if (ev.key === 'Enter') { computeField(); after(); }
else if (ev.key === 'Home') { newMaze(); }
else { used = false; }
if (used) { ev.preventDefault(); wake(); }
});
if (replayBtn) { replayBtn.addEventListener('click', function (): void { wake(); computeField(); after(); }); }
if (newBtn) { newBtn.addEventListener('click', function (): void { wake(); newMaze(); }); }
computeField();
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("hs") + " is-demo"} ref={rootRef}>
<canvas className={cx("hs__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="칸마다 돌아가는 거리를 온도 색으로 칠해 낮은 칸부터 차오르게 하고 최단 경로를 골짜기 바닥으로 드러내는 판. 칸을 누르면 목표가 바뀐다"></canvas>
<p className={cx("hs__status")} data-role="status" aria-live="off">돌아가는 거리가 낮은 칸부터 차오르는 중</p>
<div className={cx("hs__tools")}>
<button className={cx("hs__btn")} data-role="replay" type="button">다시 드러내기</button>
<button className={cx("hs__btn")} data-role="new" type="button">새 미로</button>
</div>
</div>
);
}08 · A Board Where Distance Becomes Heat Color and the Answer Appears as a Valley — Measured area 40.303% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
// Per cell: entrance-to-cell distance + cell-to-goal distance - shortest = extra cells a route through it costs. Exactly 0 on the shortest path
function computeField() {
const ds = bfsDist(walls, ENTRY);
const dg = bfsDist(walls, goal);
shortest = ds[goal];
maxEx = 1;
floorN = 0;
for (let i = 0; i < CELL_N; i += 1) {
excess[i] = ds[i] + dg[i] - shortest;
if (excess[i] > maxEx) {
maxEx = excess[i];
}
if (excess[i] === 0) {
floorN += 1;
}
}
revealT = 0;
holdLeft = 0;
}
09A Hedge-Garden Maze Seen From Above, With Fog From Center Distance and Shadows From Height and Sun Angle
The wall bits turn into 1,024 segments (seed 464), each pushed away from the sun into a parallelogram shadow that joins one path and gets filled once, and then side faces plus a top face lifted 4px (HEDGE_H_PX) stand the hedges up. Horizontal hedges brighten along −sin and vertical ones along −cos as they turn toward the sun, and the fog density 1 − e^(−0.0042·r) climbs to 0.443 at the midpoint of a board edge and 0.695 at a canvas corner, pulling the eye to the middle. The sun circles once every 5,712ms while a lantern follows the 321-cell answer path at one cell per 70ms, and the render measured 28.2% cumulative area, 25.5 mean intensity and 23/23 moved frames.

<main class="stage">
<div class="hs is-demo" id="hs">
<canvas class="hs__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="A top-down hedge-garden maze where the circling sun lays shadows down and fog thickens toward the edges. Wherever you press becomes the sun"></canvas>
<p class="hs__status" data-role="status" aria-live="off">The sun circles and shadows lean</p>
<div class="hs__tools">
<button class="hs__btn" data-role="new" type="button">New garden</button>
<button class="hs__btn" data-role="sun" type="button" aria-pressed="false">Stop the sun</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.hs {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.hs__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.hs__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.hs__btn:hover {
background: $subject-cream;
}
.hs__btn[aria-pressed="true"] {
background: $subject-mint;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.hs__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.hs__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.hs__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.hs__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hs {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.hs__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.hs__btn:hover {
background: #fff7e6;
}
.hs__btn[aria-pressed=true] {
background: #4cd4a6;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.hs__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.hs__btn {
font-size: 10px;
padding: 4px 8px;
}
.hs__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.hs__btn {
transition: none;
}
}(function () {
const root = document.getElementById("hs");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 바닥을 얹어 명도 차로 길이 읽힌다
const BG = [29, 26, 44];
const ROCK = [44, 39, 64];
const PAPER = [255, 248, 234];
const INK = [58, 48, 76];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 미로가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 격자 — 31×31 칸, 칸마다 벽 네 장을 비트 하나씩으로 든다
const GRID_N = 31;
const CELL_N = GRID_N * GRID_N;
const CS = 9;
const BOARD = GRID_N * CS;
let BX = W - 12 - BOARD;
const BY = (H - BOARD) / 2;
const ENTRY = 0;
const EXIT = CELL_N - 1;
// 방향 0 북 · 1 동 · 2 남 · 3 서 — 시계 방향 순서라 +1 이 오른쪽으로 돌기, +3 이 왼쪽으로 돌기
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
const BIT = [1, 2, 4, 8];
const ALL_WALLS = 15;
function neighbor(i, d) {
const x = (i % GRID_N) + DX[d];
const y = Math.floor(i / GRID_N) + DY[d];
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function isOpen(walls, i, d) {
return (walls[i] & BIT[d]) === 0 && neighbor(i, d) >= 0;
}
// 벽 한 장 걷기 — 양쪽 칸의 비트를 함께 지운다 (한쪽만 지우면 길이 일방통행이 된다)
function knock(walls, i, d) {
const n = neighbor(i, d);
walls[i] &= ~BIT[d];
walls[n] &= ~BIT[(d + 2) % 4];
return n;
}
function openCount(walls, i) {
let c = 0;
for (let d = 0; d < 4; d += 1) {
if (isOpen(walls, i, d)) {
c += 1;
}
}
return c;
}
function cellX(i) { return BX + (i % GRID_N) * CS; }
function cellY(i) { return BY + Math.floor(i / GRID_N) * CS; }
function cellAt(sx, sy) {
const x = Math.floor((sx - BX) / CS);
const y = Math.floor((sy - BY) / CS);
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function fillCell(i, c, a) {
ctx.fillStyle = rgba(c, a);
ctx.fillRect(cellX(i), cellY(i), CS, CS);
}
// 벽 비트를 선분으로 — 칸마다 동·남 벽만 긋고 맨 윗줄·맨 왼줄만 북·서 벽을 더 긋는다 (같은 벽을 두 번 긋지 않는다)
function strokeWalls(walls, color, lw) {
ctx.beginPath();
for (let i = 0; i < CELL_N; i += 1) {
const x = cellX(i);
const y = cellY(i);
const w = walls[i];
if ((w & 2) !== 0) {
ctx.moveTo(x + CS, y);
ctx.lineTo(x + CS, y + CS);
}
if ((w & 4) !== 0) {
ctx.moveTo(x, y + CS);
ctx.lineTo(x + CS, y + CS);
}
if (i < GRID_N && (w & 1) !== 0) {
ctx.moveTo(x, y);
ctx.lineTo(x + CS, y);
}
if (i % GRID_N === 0 && (w & 8) !== 0) {
ctx.moveTo(x, y);
ctx.lineTo(x, y + CS);
}
}
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineCap = 'square';
ctx.stroke();
}
// 입구(왼쪽 위)·출구(오른쪽 아래) 표시 — 바깥 벽 밖에 작은 삼각형
function drawGates() {
ctx.fillStyle = rgba(MINT, 0.95);
ctx.beginPath();
ctx.moveTo(BX - 9, BY + 1);
ctx.lineTo(BX - 2, BY + CS / 2);
ctx.lineTo(BX - 9, BY + CS - 1);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(PINK, 0.95);
ctx.beginPath();
ctx.moveTo(BX + BOARD + 2, BY + BOARD - CS + 1);
ctx.lineTo(BX + BOARD + 9, BY + BOARD - CS / 2);
ctx.lineTo(BX + BOARD + 2, BY + BOARD - 1);
ctx.closePath();
ctx.fill();
}
// 왼쪽 칸의 수치 — 이름표 한 줄 + 값 한 줄
function panelRow(row, label, value) {
const y = 104 + row * 34;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(PAPER, 0.62);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, 16, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 15px sans-serif';
ctx.fillText(value, 16, y + 18);
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 깊이 우선 파기 한 번에 끝까지 — 스택 머리에서 안 판 이웃을 골라 벽을 걷고, 없으면 하나 뺀다
function carveDfsAll(seed, start) {
const walls = new Uint8Array(CELL_N).fill(ALL_WALLS);
const seen = new Uint8Array(CELL_N);
const stk = new Int32Array(CELL_N);
const rnd = makeRng(seed);
let sp = 0;
stk[sp] = start;
sp = 1;
seen[start] = 1;
const opts = [];
while (sp > 0) {
const cur = stk[sp - 1];
opts.length = 0;
for (let d = 0; d < 4; d += 1) {
const n = neighbor(cur, d);
if (n >= 0 && seen[n] === 0) {
opts.push(d);
}
}
if (opts.length === 0) {
sp -= 1;
continue;
}
const n = knock(walls, cur, opts[Math.floor(rnd() * opts.length)]);
seen[n] = 1;
stk[sp] = n;
sp += 1;
}
return walls;
}
// 너비 우선 거리 — 큐에서 꺼낸 칸의 열린 이웃에 거리+1 을 적는다. 닿지 않은 칸은 -1
function bfsDist(walls, src) {
const dist = new Int32Array(CELL_N).fill(-1);
const q = new Int32Array(CELL_N);
let head = 0;
let tail = 0;
dist[src] = 0;
q[tail] = src;
tail += 1;
while (head < tail) {
const c = q[head];
head += 1;
for (let d = 0; d < 4; d += 1) {
if (!isOpen(walls, c, d)) {
continue;
}
const n = neighbor(c, d);
if (dist[n] >= 0) {
continue;
}
dist[n] = dist[c] + 1;
q[tail] = n;
tail += 1;
}
}
return dist;
}
// 울타리 높이(px) — 벽 선분을 이만큼 위로 밀어 올려 윗면을 그리고, 그 사이를 옆면으로 채운다
const HEDGE_H_PX = 4;
// 판 중심에서 r px 떨어진 곳의 안개 짙기 = 1 − e^(−FOG_K·r)
const FOG_K = 0.0042;
// 그림자 길이 = SHADOW_LEN × 울타리 높이 — 해 반대쪽으로 눕는다 (해 질 녘 안개는 무대색에 라일락을 섞은 어스름)
const SHADOW_LEN = 1.6;
// 해가 도는 빠르기(rad/ms)
const SUN_SPEED = 0.0011;
// 등불이 정답 길을 한 칸 가는 데 쓰는 시간
const LANTERN_MS = 70;
const SEED = 464;
const TXT_SUN = 'The sun circles and shadows lean';
const TXT_STOP = 'Stop the sun';
const newBtn = root.querySelector('[data-role="new"]');
const sunBtn = root.querySelector('[data-role="sun"]');
BX = (W - BOARD) / 2;
const MIDX = BX + BOARD / 2;
const MIDY = BY + BOARD / 2;
const GRASS_D = mix([92, 150, 118], BG, 0.35);
const GRASS_L = [150, 204, 166];
const GRAVEL = mix(mix(PAPER, PEACH, 0.55), BG, 0.12);
const HEDGE_SIDE = [52, 104, 80];
const HEDGE_TOP = [118, 196, 140];
const FOG = mix(BG, LILAC, 0.22);
let pass = 0;
let walls = carveDfsAll(SEED, ENTRY);
let segs = [];
let route = [];
let sun = -2.3;
let sunOn = true;
let lanternT = 0;
// 정수 좌표 해시 → 0~1 (시드가 같으면 같은 결)
function hash2(x, y, s) {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(s, 2246822519)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 바닥 한 장을 미리 굽는다 — 잔디는 2px 칸마다 해시 결, 판 안은 자갈 결
const groundEl = document.createElement('canvas');
groundEl.width = W;
groundEl.height = H;
const gctx = groundEl.getContext('2d');
function bakeGround() {
if (!gctx) {
return;
}
const img = gctx.createImageData(W, H);
for (let y = 0; y < H; y += 1) {
for (let x = 0; x < W; x += 1) {
const inBoard = x >= BX && x < BX + BOARD && y >= BY && y < BY + BOARD;
const n = hash2(x >> 1, y >> 1, SEED);
const c = inBoard ? mix(GRAVEL, PAPER, n * 0.5) : mix(GRASS_D, GRASS_L, n * 0.55);
const o = (y * W + x) * 4;
img.data[o] = c[0];
img.data[o + 1] = c[1];
img.data[o + 2] = c[2];
img.data[o + 3] = 255;
}
}
gctx.putImageData(img, 0, 0);
}
// 벽 비트 → 선분 목록 [x1, y1, x2, y2, …] (strokeWalls 와 같은 규칙으로 겹치지 않게)
function buildSegs() {
segs = [];
for (let i = 0; i < CELL_N; i += 1) {
const x = cellX(i);
const y = cellY(i);
const w = walls[i];
if ((w & 2) !== 0) {
segs.push(x + CS, y, x + CS, y + CS);
}
if ((w & 4) !== 0) {
segs.push(x, y + CS, x + CS, y + CS);
}
if (i < GRID_N && (w & 1) !== 0) {
segs.push(x, y, x + CS, y);
}
if (i % GRID_N === 0 && (w & 8) !== 0) {
segs.push(x, y, x, y + CS);
}
}
const dist = bfsDist(walls, ENTRY);
route = [EXIT];
let c = EXIT;
while (c !== ENTRY) {
for (let d = 0; d < 4; d += 1) {
if (isOpen(walls, c, d) && dist[neighbor(c, d)] === dist[c] - 1) {
c = neighbor(c, d);
break;
}
}
route.push(c);
}
route.reverse();
lanternT = 0;
}
function report() { say(sunOn && !still ? TXT_SUN : TXT_STOP); }
function step() {
if (sunOn) {
sun += SUN_SPEED * FIXED_DT;
}
lanternT += FIXED_DT;
if (lanternT >= route.length * LANTERN_MS + 800) {
lanternT = 0;
}
}
function settle() { report(); }
function demoTick(t) {
if (t < 0) {
report();
}
}
function lanternXY() {
const f = Math.min(lanternT / LANTERN_MS, route.length - 1);
const i = Math.floor(f);
const j = Math.min(i + 1, route.length - 1);
const k = smooth(f - i);
const ax = cellX(route[i]) + CS / 2;
const ay = cellY(route[i]) + CS / 2;
return [lerp(ax, cellX(route[j]) + CS / 2, k), lerp(ay, cellY(route[j]) + CS / 2, k)];
}
function draw() {
ctx.drawImage(groundEl, 0, 0, W, H);
const len = SHADOW_LEN * HEDGE_H_PX;
const sx = Math.cos(sun) * len;
const sy = Math.sin(sun) * len;
// 그림자 — 선분 하나를 해 반대쪽으로 민 평행사변형
ctx.fillStyle = rgba(BG, 0.24);
ctx.beginPath();
for (let s = 0; s < segs.length; s += 4) {
const x1 = segs[s];
const y1 = segs[s + 1];
const x2 = segs[s + 2];
const y2 = segs[s + 3];
const nx = y1 === y2 ? 0 : 1.6;
const ny = y1 === y2 ? 1.6 : 0;
ctx.moveTo(x1 - nx, y1 - ny);
ctx.lineTo(x2 + nx, y2 + ny);
ctx.lineTo(x2 + nx + sx, y2 + ny + sy);
ctx.lineTo(x1 - nx + sx, y1 - ny + sy);
ctx.closePath();
}
ctx.fill();
// 옆면 — 해를 마주 보는 면일수록 밝다 (가로 울타리는 남쪽 면, 세로 울타리는 동쪽 면을 본다)
const litH = 0.55 + 0.45 * Math.max(0, -Math.sin(sun));
const litV = 0.55 + 0.45 * Math.max(0, -Math.cos(sun));
ctx.lineCap = 'square';
ctx.lineWidth = 2.6;
for (let pass2 = 0; pass2 < 2; pass2 += 1) {
const horiz = pass2 === 0;
ctx.strokeStyle = rgba(mix(BG, HEDGE_SIDE, horiz ? litH : litV), 1);
ctx.beginPath();
for (let s = 0; s < segs.length; s += 4) {
if ((segs[s + 1] === segs[s + 3]) !== horiz) {
continue;
}
ctx.moveTo(segs[s], segs[s + 1]);
ctx.lineTo(segs[s + 2], segs[s + 3]);
ctx.moveTo(segs[s], segs[s + 1] - HEDGE_H_PX / 2);
ctx.lineTo(segs[s + 2], segs[s + 3] - HEDGE_H_PX / 2);
}
ctx.stroke();
}
ctx.strokeStyle = rgba(HEDGE_TOP, 1);
ctx.beginPath();
for (let s = 0; s < segs.length; s += 4) {
ctx.moveTo(segs[s], segs[s + 1] - HEDGE_H_PX);
ctx.lineTo(segs[s + 2], segs[s + 3] - HEDGE_H_PX);
}
ctx.stroke();
// 잎 결 — 선분마다 해시로 고른 자리에 밝은 점
ctx.fillStyle = rgba(mix(HEDGE_TOP, PAPER, 0.45), 1);
for (let s = 0; s < segs.length; s += 4) {
const t = hash2(segs[s], segs[s + 1], SEED + 7);
ctx.fillRect(lerp(segs[s], segs[s + 2], t) - 0.6, lerp(segs[s + 1], segs[s + 3], t) - HEDGE_H_PX - 0.6, 1.3, 1.3);
}
// 등불 — 정답 길을 따라가며 주위를 밝힌다
const lp = lanternXY();
const lg = ctx.createRadialGradient(lp[0], lp[1], 0, lp[0], lp[1], 26);
lg.addColorStop(0, rgba(PEACH, 0.85));
lg.addColorStop(0.35, rgba(PEACH, 0.35));
lg.addColorStop(1, rgba(PEACH, 0));
ctx.fillStyle = lg;
ctx.fillRect(lp[0] - 26, lp[1] - 26, 52, 52);
ctx.fillStyle = rgba(PAPER, 1);
ctx.beginPath();
ctx.arc(lp[0], lp[1], 2.2, 0, Math.PI * 2);
ctx.fill();
// 안개 — 중심 거리 r 의 짙기 1 − e^(−FOG_K·r) 를 눈금 9개로 찍은 방사형 그라디언트
const rMax = Math.hypot(W / 2, H / 2);
const fg = ctx.createRadialGradient(MIDX, MIDY, 0, MIDX, MIDY, rMax);
for (let s = 0; s <= 8; s += 1) {
const r = (s / 8) * rMax;
fg.addColorStop(s / 8, rgba(FOG, 1 - Math.exp(-FOG_K * r)));
}
ctx.fillStyle = fg;
ctx.fillRect(0, 0, W, H);
// 해 위치 표시 — 그림자 반대쪽 테두리에 작은 해
const ux = MIDX - Math.cos(sun) * (BOARD / 2 + 70);
const uy = MIDY - Math.sin(sun) * (BOARD / 2 + 4);
ctx.fillStyle = rgba(PAPER, 0.95);
ctx.beginPath();
ctx.arc(clamp(ux, 8, W - 8), clamp(uy, 8, H - 8), 5, 0, Math.PI * 2);
ctx.fill();
}
function after() {
report();
draw();
}
function setSunOn(v) {
sunOn = v;
if (sunBtn) {
sunBtn.setAttribute('aria-pressed', v ? 'false' : 'true');
}
after();
}
function newGarden() {
pass += 1;
walls = carveDfsAll(SEED + pass, ENTRY);
buildSegs();
after();
}
// 누른 자리가 해 — 그림자는 판 중심에서 누른 자리의 반대쪽으로 눕는다
let dragId = -1;
function aimSun(ev) {
const p = toScene(ev);
sun = Math.atan2(MIDY - p[1], MIDX - p[0]);
draw();
}
cv.addEventListener('pointerdown', function (ev) {
wake();
dragId = ev.pointerId;
cv.setPointerCapture(ev.pointerId);
if (sunOn) {
setSunOn(false);
}
aimSun(ev);
});
cv.addEventListener('pointermove', function (ev) { if (ev.pointerId === dragId) {
aimSun(ev);
} });
function endDrag(ev) { if (ev.pointerId === dragId) {
dragId = -1;
} }
cv.addEventListener('pointerup', endDrag);
cv.addEventListener('pointercancel', endDrag);
cv.addEventListener('keydown', function (ev) {
if (ev.key === 'ArrowLeft' || ev.key === 'ArrowRight') {
ev.preventDefault();
wake();
sun += (ev.key === 'ArrowRight' ? 1 : -1) * (Math.PI / 12);
after();
}
else if (ev.key === ' ' || ev.key === 'Spacebar') {
ev.preventDefault();
wake();
setSunOn(!sunOn);
}
else if (ev.key === 'Home' || ev.key === 'Enter') {
ev.preventDefault();
wake();
newGarden();
}
});
if (newBtn) {
newBtn.addEventListener('click', function () { wake(); newGarden(); });
}
if (sunBtn) {
sunBtn.addEventListener('click', function () { wake(); setSunOn(!sunOn); });
}
bakeGround();
buildSegs();
if (still) {
sunOn = false;
if (sunBtn) {
sunBtn.setAttribute('aria-pressed', 'true');
}
}
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// MzHeroHedgeGardenScene - vanilla 판(09-mz-hero-hedge-garden-scene) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./MzHeroHedgeGardenScene.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function MzHeroHedgeGardenScene() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 바닥을 얹어 명도 차로 길이 읽힌다
const BG: RGB = [29, 26, 44];
const ROCK: RGB = [44, 39, 64];
const PAPER: RGB = [255, 248, 234];
const INK: RGB = [58, 48, 76];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 미로가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 격자 — 31×31 칸, 칸마다 벽 네 장을 비트 하나씩으로 든다
const GRID_N = 31;
const CELL_N = GRID_N * GRID_N;
const CS = 9;
const BOARD = GRID_N * CS;
let BX = W - 12 - BOARD;
const BY = (H - BOARD) / 2;
const ENTRY = 0;
const EXIT = CELL_N - 1;
// 방향 0 북 · 1 동 · 2 남 · 3 서 — 시계 방향 순서라 +1 이 오른쪽으로 돌기, +3 이 왼쪽으로 돌기
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
const BIT = [1, 2, 4, 8];
const ALL_WALLS = 15;
function neighbor(i: number, d: number): number {
const x = (i % GRID_N) + DX[d];
const y = Math.floor(i / GRID_N) + DY[d];
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function isOpen(walls: Uint8Array, i: number, d: number): boolean {
return (walls[i] & BIT[d]) === 0 && neighbor(i, d) >= 0;
}
// 벽 한 장 걷기 — 양쪽 칸의 비트를 함께 지운다 (한쪽만 지우면 길이 일방통행이 된다)
function knock(walls: Uint8Array, i: number, d: number): number {
const n = neighbor(i, d);
walls[i] &= ~BIT[d];
walls[n] &= ~BIT[(d + 2) % 4];
return n;
}
function openCount(walls: Uint8Array, i: number): number {
let c = 0;
for (let d = 0; d < 4; d += 1) { if (isOpen(walls, i, d)) { c += 1; } }
return c;
}
function cellX(i: number): number { return BX + (i % GRID_N) * CS; }
function cellY(i: number): number { return BY + Math.floor(i / GRID_N) * CS; }
function cellAt(sx: number, sy: number): number {
const x = Math.floor((sx - BX) / CS);
const y = Math.floor((sy - BY) / CS);
return x < 0 || y < 0 || x >= GRID_N || y >= GRID_N ? -1 : y * GRID_N + x;
}
function fillCell(i: number, c: RGB, a: number): void {
ctx.fillStyle = rgba(c, a);
ctx.fillRect(cellX(i), cellY(i), CS, CS);
}
// 벽 비트를 선분으로 — 칸마다 동·남 벽만 긋고 맨 윗줄·맨 왼줄만 북·서 벽을 더 긋는다 (같은 벽을 두 번 긋지 않는다)
function strokeWalls(walls: Uint8Array, color: string, lw: number): void {
ctx.beginPath();
for (let i = 0; i < CELL_N; i += 1) {
const x = cellX(i);
const y = cellY(i);
const w = walls[i];
if ((w & 2) !== 0) { ctx.moveTo(x + CS, y); ctx.lineTo(x + CS, y + CS); }
if ((w & 4) !== 0) { ctx.moveTo(x, y + CS); ctx.lineTo(x + CS, y + CS); }
if (i < GRID_N && (w & 1) !== 0) { ctx.moveTo(x, y); ctx.lineTo(x + CS, y); }
if (i % GRID_N === 0 && (w & 8) !== 0) { ctx.moveTo(x, y); ctx.lineTo(x, y + CS); }
}
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineCap = 'square';
ctx.stroke();
}
// 입구(왼쪽 위)·출구(오른쪽 아래) 표시 — 바깥 벽 밖에 작은 삼각형
function drawGates(): void {
ctx.fillStyle = rgba(MINT, 0.95);
ctx.beginPath();
ctx.moveTo(BX - 9, BY + 1);
ctx.lineTo(BX - 2, BY + CS / 2);
ctx.lineTo(BX - 9, BY + CS - 1);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(PINK, 0.95);
ctx.beginPath();
ctx.moveTo(BX + BOARD + 2, BY + BOARD - CS + 1);
ctx.lineTo(BX + BOARD + 9, BY + BOARD - CS / 2);
ctx.lineTo(BX + BOARD + 2, BY + BOARD - 1);
ctx.closePath();
ctx.fill();
}
// 왼쪽 칸의 수치 — 이름표 한 줄 + 값 한 줄
function panelRow(row: number, label: string, value: string): void {
const y = 104 + row * 34;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(PAPER, 0.62);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, 16, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 15px sans-serif';
ctx.fillText(value, 16, y + 18);
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 깊이 우선 파기 한 번에 끝까지 — 스택 머리에서 안 판 이웃을 골라 벽을 걷고, 없으면 하나 뺀다
function carveDfsAll(seed: number, start: number): Uint8Array {
const walls = new Uint8Array(CELL_N).fill(ALL_WALLS);
const seen = new Uint8Array(CELL_N);
const stk = new Int32Array(CELL_N);
const rnd = makeRng(seed);
let sp = 0;
stk[sp] = start;
sp = 1;
seen[start] = 1;
const opts: number[] = [];
while (sp > 0) {
const cur = stk[sp - 1];
opts.length = 0;
for (let d = 0; d < 4; d += 1) {
const n = neighbor(cur, d);
if (n >= 0 && seen[n] === 0) { opts.push(d); }
}
if (opts.length === 0) { sp -= 1; continue; }
const n = knock(walls, cur, opts[Math.floor(rnd() * opts.length)]);
seen[n] = 1;
stk[sp] = n;
sp += 1;
}
return walls;
}
// 너비 우선 거리 — 큐에서 꺼낸 칸의 열린 이웃에 거리+1 을 적는다. 닿지 않은 칸은 -1
function bfsDist(walls: Uint8Array, src: number): Int32Array {
const dist = new Int32Array(CELL_N).fill(-1);
const q = new Int32Array(CELL_N);
let head = 0;
let tail = 0;
dist[src] = 0;
q[tail] = src;
tail += 1;
while (head < tail) {
const c = q[head];
head += 1;
for (let d = 0; d < 4; d += 1) {
if (!isOpen(walls, c, d)) { continue; }
const n = neighbor(c, d);
if (dist[n] >= 0) { continue; }
dist[n] = dist[c] + 1;
q[tail] = n;
tail += 1;
}
}
return dist;
}
// 울타리 높이(px) — 벽 선분을 이만큼 위로 밀어 올려 윗면을 그리고, 그 사이를 옆면으로 채운다
const HEDGE_H_PX = 4;
// 판 중심에서 r px 떨어진 곳의 안개 짙기 = 1 − e^(−FOG_K·r)
const FOG_K = 0.0042;
// 그림자 길이 = SHADOW_LEN × 울타리 높이 — 해 반대쪽으로 눕는다 (해 질 녘 안개는 무대색에 라일락을 섞은 어스름)
const SHADOW_LEN = 1.6;
// 해가 도는 빠르기(rad/ms)
const SUN_SPEED = 0.0011;
// 등불이 정답 길을 한 칸 가는 데 쓰는 시간
const LANTERN_MS = 70;
const SEED = 464;
const TXT_SUN = '해가 돌며 그림자가 눕는 중';
const TXT_STOP = '해 멈춤';
const newBtn = root.querySelector('[data-role="new"]') as HTMLButtonElement | null;
const sunBtn = root.querySelector('[data-role="sun"]') as HTMLButtonElement | null;
BX = (W - BOARD) / 2;
const MIDX = BX + BOARD / 2;
const MIDY = BY + BOARD / 2;
const GRASS_D: RGB = mix([92, 150, 118], BG, 0.35);
const GRASS_L: RGB = [150, 204, 166];
const GRAVEL: RGB = mix(mix(PAPER, PEACH, 0.55), BG, 0.12);
const HEDGE_SIDE: RGB = [52, 104, 80];
const HEDGE_TOP: RGB = [118, 196, 140];
const FOG: RGB = mix(BG, LILAC, 0.22);
let pass = 0;
let walls = carveDfsAll(SEED, ENTRY);
let segs: number[] = [];
let route: number[] = [];
let sun = -2.3;
let sunOn = true;
let lanternT = 0;
// 정수 좌표 해시 → 0~1 (시드가 같으면 같은 결)
function hash2(x: number, y: number, s: number): number {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(s, 2246822519)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 바닥 한 장을 미리 굽는다 — 잔디는 2px 칸마다 해시 결, 판 안은 자갈 결
const groundEl = document.createElement('canvas');
groundEl.width = W;
groundEl.height = H;
const gctx = groundEl.getContext('2d');
function bakeGround(): void {
if (!gctx) { return; }
const img = gctx.createImageData(W, H);
for (let y = 0; y < H; y += 1) {
for (let x = 0; x < W; x += 1) {
const inBoard = x >= BX && x < BX + BOARD && y >= BY && y < BY + BOARD;
const n = hash2(x >> 1, y >> 1, SEED);
const c = inBoard ? mix(GRAVEL, PAPER, n * 0.5) : mix(GRASS_D, GRASS_L, n * 0.55);
const o = (y * W + x) * 4;
img.data[o] = c[0];
img.data[o + 1] = c[1];
img.data[o + 2] = c[2];
img.data[o + 3] = 255;
}
}
gctx.putImageData(img, 0, 0);
}
// 벽 비트 → 선분 목록 [x1, y1, x2, y2, …] (strokeWalls 와 같은 규칙으로 겹치지 않게)
function buildSegs(): void {
segs = [];
for (let i = 0; i < CELL_N; i += 1) {
const x = cellX(i);
const y = cellY(i);
const w = walls[i];
if ((w & 2) !== 0) { segs.push(x + CS, y, x + CS, y + CS); }
if ((w & 4) !== 0) { segs.push(x, y + CS, x + CS, y + CS); }
if (i < GRID_N && (w & 1) !== 0) { segs.push(x, y, x + CS, y); }
if (i % GRID_N === 0 && (w & 8) !== 0) { segs.push(x, y, x, y + CS); }
}
const dist = bfsDist(walls, ENTRY);
route = [EXIT];
let c = EXIT;
while (c !== ENTRY) {
for (let d = 0; d < 4; d += 1) {
if (isOpen(walls, c, d) && dist[neighbor(c, d)] === dist[c] - 1) { c = neighbor(c, d); break; }
}
route.push(c);
}
route.reverse();
lanternT = 0;
}
function report(): void { say(sunOn && !still ? TXT_SUN : TXT_STOP); }
function step(): void {
if (sunOn) { sun += SUN_SPEED * FIXED_DT; }
lanternT += FIXED_DT;
if (lanternT >= route.length * LANTERN_MS + 800) { lanternT = 0; }
}
function settle(): void { report(); }
function demoTick(t: number): void {
if (t < 0) { report(); }
}
function lanternXY(): [number, number] {
const f = Math.min(lanternT / LANTERN_MS, route.length - 1);
const i = Math.floor(f);
const j = Math.min(i + 1, route.length - 1);
const k = smooth(f - i);
const ax = cellX(route[i]) + CS / 2;
const ay = cellY(route[i]) + CS / 2;
return [lerp(ax, cellX(route[j]) + CS / 2, k), lerp(ay, cellY(route[j]) + CS / 2, k)];
}
function draw(): void {
ctx.drawImage(groundEl, 0, 0, W, H);
const len = SHADOW_LEN * HEDGE_H_PX;
const sx = Math.cos(sun) * len;
const sy = Math.sin(sun) * len;
// 그림자 — 선분 하나를 해 반대쪽으로 민 평행사변형
ctx.fillStyle = rgba(BG, 0.24);
ctx.beginPath();
for (let s = 0; s < segs.length; s += 4) {
const x1 = segs[s];
const y1 = segs[s + 1];
const x2 = segs[s + 2];
const y2 = segs[s + 3];
const nx = y1 === y2 ? 0 : 1.6;
const ny = y1 === y2 ? 1.6 : 0;
ctx.moveTo(x1 - nx, y1 - ny);
ctx.lineTo(x2 + nx, y2 + ny);
ctx.lineTo(x2 + nx + sx, y2 + ny + sy);
ctx.lineTo(x1 - nx + sx, y1 - ny + sy);
ctx.closePath();
}
ctx.fill();
// 옆면 — 해를 마주 보는 면일수록 밝다 (가로 울타리는 남쪽 면, 세로 울타리는 동쪽 면을 본다)
const litH = 0.55 + 0.45 * Math.max(0, -Math.sin(sun));
const litV = 0.55 + 0.45 * Math.max(0, -Math.cos(sun));
ctx.lineCap = 'square';
ctx.lineWidth = 2.6;
for (let pass2 = 0; pass2 < 2; pass2 += 1) {
const horiz = pass2 === 0;
ctx.strokeStyle = rgba(mix(BG, HEDGE_SIDE, horiz ? litH : litV), 1);
ctx.beginPath();
for (let s = 0; s < segs.length; s += 4) {
if ((segs[s + 1] === segs[s + 3]) !== horiz) { continue; }
ctx.moveTo(segs[s], segs[s + 1]);
ctx.lineTo(segs[s + 2], segs[s + 3]);
ctx.moveTo(segs[s], segs[s + 1] - HEDGE_H_PX / 2);
ctx.lineTo(segs[s + 2], segs[s + 3] - HEDGE_H_PX / 2);
}
ctx.stroke();
}
ctx.strokeStyle = rgba(HEDGE_TOP, 1);
ctx.beginPath();
for (let s = 0; s < segs.length; s += 4) {
ctx.moveTo(segs[s], segs[s + 1] - HEDGE_H_PX);
ctx.lineTo(segs[s + 2], segs[s + 3] - HEDGE_H_PX);
}
ctx.stroke();
// 잎 결 — 선분마다 해시로 고른 자리에 밝은 점
ctx.fillStyle = rgba(mix(HEDGE_TOP, PAPER, 0.45), 1);
for (let s = 0; s < segs.length; s += 4) {
const t = hash2(segs[s], segs[s + 1], SEED + 7);
ctx.fillRect(lerp(segs[s], segs[s + 2], t) - 0.6, lerp(segs[s + 1], segs[s + 3], t) - HEDGE_H_PX - 0.6, 1.3, 1.3);
}
// 등불 — 정답 길을 따라가며 주위를 밝힌다
const lp = lanternXY();
const lg = ctx.createRadialGradient(lp[0], lp[1], 0, lp[0], lp[1], 26);
lg.addColorStop(0, rgba(PEACH, 0.85));
lg.addColorStop(0.35, rgba(PEACH, 0.35));
lg.addColorStop(1, rgba(PEACH, 0));
ctx.fillStyle = lg;
ctx.fillRect(lp[0] - 26, lp[1] - 26, 52, 52);
ctx.fillStyle = rgba(PAPER, 1);
ctx.beginPath();
ctx.arc(lp[0], lp[1], 2.2, 0, Math.PI * 2);
ctx.fill();
// 안개 — 중심 거리 r 의 짙기 1 − e^(−FOG_K·r) 를 눈금 9개로 찍은 방사형 그라디언트
const rMax = Math.hypot(W / 2, H / 2);
const fg = ctx.createRadialGradient(MIDX, MIDY, 0, MIDX, MIDY, rMax);
for (let s = 0; s <= 8; s += 1) {
const r = (s / 8) * rMax;
fg.addColorStop(s / 8, rgba(FOG, 1 - Math.exp(-FOG_K * r)));
}
ctx.fillStyle = fg;
ctx.fillRect(0, 0, W, H);
// 해 위치 표시 — 그림자 반대쪽 테두리에 작은 해
const ux = MIDX - Math.cos(sun) * (BOARD / 2 + 70);
const uy = MIDY - Math.sin(sun) * (BOARD / 2 + 4);
ctx.fillStyle = rgba(PAPER, 0.95);
ctx.beginPath();
ctx.arc(clamp(ux, 8, W - 8), clamp(uy, 8, H - 8), 5, 0, Math.PI * 2);
ctx.fill();
}
function after(): void {
report();
draw();
}
function setSunOn(v: boolean): void {
sunOn = v;
if (sunBtn) { sunBtn.setAttribute('aria-pressed', v ? 'false' : 'true'); }
after();
}
function newGarden(): void {
pass += 1;
walls = carveDfsAll(SEED + pass, ENTRY);
buildSegs();
after();
}
// 누른 자리가 해 — 그림자는 판 중심에서 누른 자리의 반대쪽으로 눕는다
let dragId = -1;
function aimSun(ev: PointerEvent): void {
const p = toScene(ev);
sun = Math.atan2(MIDY - p[1], MIDX - p[0]);
draw();
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
dragId = ev.pointerId;
cv.setPointerCapture(ev.pointerId);
if (sunOn) { setSunOn(false); }
aimSun(ev);
});
cv.addEventListener('pointermove', function (ev: PointerEvent): void { if (ev.pointerId === dragId) { aimSun(ev); } });
function endDrag(ev: PointerEvent): void { if (ev.pointerId === dragId) { dragId = -1; } }
cv.addEventListener('pointerup', endDrag);
cv.addEventListener('pointercancel', endDrag);
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key === 'ArrowLeft' || ev.key === 'ArrowRight') {
ev.preventDefault();
wake();
sun += (ev.key === 'ArrowRight' ? 1 : -1) * (Math.PI / 12);
after();
} else if (ev.key === ' ' || ev.key === 'Spacebar') { ev.preventDefault(); wake(); setSunOn(!sunOn); }
else if (ev.key === 'Home' || ev.key === 'Enter') { ev.preventDefault(); wake(); newGarden(); }
});
if (newBtn) { newBtn.addEventListener('click', function (): void { wake(); newGarden(); }); }
if (sunBtn) { sunBtn.addEventListener('click', function (): void { wake(); setSunOn(!sunOn); }); }
bakeGround();
buildSegs();
if (still) {
sunOn = false;
if (sunBtn) { sunBtn.setAttribute('aria-pressed', 'true'); }
}
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("hs") + " is-demo"} ref={rootRef}>
<canvas className={cx("hs__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="울타리를 세운 정원 미로를 내려다보며 해가 돌아 그림자가 눕고 가장자리로 갈수록 안개가 짙어지는 장면. 누른 자리가 해가 된다"></canvas>
<p className={cx("hs__status")} data-role="status" aria-live="off">해가 돌며 그림자가 눕는 중</p>
<div className={cx("hs__tools")}>
<button className={cx("hs__btn")} data-role="new" type="button">새 정원</button>
<button className={cx("hs__btn")} data-role="sun" type="button" aria-pressed="false">해 멈춤</button>
</div>
</div>
);
}09 · A Hedge-Garden Maze Seen From Above, With Fog From Center Distance and Shadows From Height and Sun Angle — Measured area 28.202% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
const len = SHADOW_LEN * HEDGE_H_PX;
const sx = Math.cos(sun) * len;
const sy = Math.sin(sun) * len;
// Shadow: a parallelogram that pushes one segment away from the sun
ctx.fillStyle = rgba(BG, 0.24);
ctx.beginPath();
for (let s = 0; s < segs.length; s += 4) {
const x1 = segs[s];
const y1 = segs[s + 1];
const x2 = segs[s + 2];
const y2 = segs[s + 3];
const nx = y1 === y2 ? 0 : 1.6;
const ny = y1 === y2 ? 1.6 : 0;
ctx.moveTo(x1 - nx, y1 - ny);
ctx.lineTo(x2 + nx, y2 + ny);
ctx.lineTo(x2 + nx + sx, y2 + ny + sy);
ctx.lineTo(x1 - nx + sx, y1 - ny + sy);
ctx.closePath();
}
ctx.fill();
Pick Order, Step Delay, Heuristic Slope: Three Settings That Reshape the Corridors
The counts in this section come from a measuring script that loads each board's index.html functions and calls them directly at a fixed seed, not from the capture.
In 02, the pick rule alone moves the dead-end count between 266 and 326
With the same seed and the same center start, random gives 326 dead ends, a farthest cell 35 away from the center and a 62-cell entrance-to-exit route; newest gives 319, 487 and 426, and oldest gives 266, 30 and 60. The random rule swaps a seeded random index with the last element and pops it, so pulling from the middle of the list costs no shifting. Newest takes back the cell it just added, so long depth-first corridors form and the farthest cell drifts out to 487, while oldest serves the longest-waiting cell first, keeps the farthest cell at 30 and leaves the fewest dead ends. Enter or the Change pick rule button cycles through the three.
In 01, the step delay changes only how long the carve takes
Dropping CARVE_STEP_MS from 24 to 8 cuts the 1,921 steps from 46,104ms to 15,368ms, about 46.1 seconds down to 15.4. The random source is drawn only on the steps that dig one cell deeper, not on a backtrack, and it does not depend on time, so the same seed yields the same maze three times faster. Backtrack runs of up to 122 steps in a row shrink by the same ratio, from 2,928ms to 976ms (calculated, not measured).
In 06, a slope of 2.2 closes fewer than half the cells
On the seed-464 maze K 1.0 closes 264 cells and K 2.2 closes 126, both with a 90-cell route, while K 0, which ignores the remaining distance, closes 804. Widened to 20 seeds, the mean closed count falls from 556.85 to 249.45 and drops on every one of them; K 1.0 found the shortest route on all 20 mazes, but K 2.2 returned a longer route than the shortest on 12, with a widest gap of 14 cells.
In 04, the keep ratio decides how far a wall follower has to walk
Each row is one sweep over the seed-464 depth-first maze, which starts with 96 dead ends.
| Keep ratio | Splices | Dead ends left | Entrance to exit | Left-hand walk | Right-hand walk |
|---|---|---|---|---|---|
| 0 (default) | 95 | 0 | 90 | 219 | 253 |
| 0.3 | 69 | 27 | 112 | 251 | 271 |
| 0.6 | 37 | 59 | 112 | 209 | 451 |
| 1 | 0 | 96 | 320 | 1,095 | 1,484 |
Ratio 1 splices nothing, so the left-hand walk takes the original 1,095 steps; at 0 it falls to 219, exactly a fifth, and the right-hand walk falls from 1,484 to 253. At 0.6 the left hand needs 209 steps but the right hand 451, so the same board more than doubles depending on which hand touches the wall. One sweep takes 5,766ms.
Where the Boards Break: Deep Recursion, Thousands of Cells, Reopened Loops and a Hidden Tab
What follows was confirmed by recomputing the formulas in the source or by calling its functions through the measuring script. Frame times on specific devices were not measured.
Recursive carving turns stack depth into call depth
The stack of 01 reaches 499 cells at seed 464. Written as a recursive function, that walk would pile up as many nested calls, and a bigger board pushes the depth further until it can hit the call stack limit. That is why the source runs a loop that pushes and pops cell numbers on an Int32Array stack.
More cells means a step count of cells × 2 − 1
The step count of 01 is cells × 2 − 1, so a 61×61 board (3,721 cells) works out to 7,441 steps, and at 24ms per step that means about 178.6 seconds of waiting; both figures are calculated from the formula, not measured. Board 07 also outran its cell count, 1,095 steps against 961 cells at seed 464, and across seeds 464 to 473 it ranged from 496 to 1,641 steps. The oldest rule of 02 pulls with Array.shift, which slides the whole array forward each time, but this board's frontier peaks at 138 cells, so it does not slow down at the current size.
Reopened alleys can trap a wall follower on an island
A maze with loops like 04 loses the rule that two cells share one route. These boards place the entrance and exit on the outer wall, so the left-hand walk still gets out, but move the exit inside the board or start the walker on an island ringed by a loop and it can circle that island without end. The trick 05 uses, flooding twice to find the longest path, also depends on single-route mazes, so on looped mazes like 04 and 06 it gives no guarantee.
Reading the mint cells of 07 as the answer path is a trap
At seed 464 the mint cells passed exactly once number 350, while the shortest path crosses 321 cells. A cell at the tip of a dead end is entered, turned around twice in place and left, so it counts as passed once and stays mint, while a junction cell can be passed twice or more as the walker visits side branches and turns lilac. To paint only the answer path, measure distance from two ends the way 08 does.
The heap in 06 can outgrow the cell count
Stale heap entries are not deleted; a closed flag skips them when they come out, so a cell whose g dropped can sit in the heap several times. That is why the heap array is sized at four times the cell count, and shrinking that multiple while enlarging the board can overflow it.
Two islands in one color are not one island
Island color in 03 is the representative number modulo 6, so right after check 400 the 561 islands still standing share six colors. Two touching islands can match in color and still have different representatives, and pressing a cell lights its whole island for 1.4 seconds to show the true extent. Checking the wall list in creation order instead of shuffling it would remove same-direction walls row by row and leave long horizontal corridors.
Taller hedges bury the 9px corridors
Board 09 lifts the top face of each hedge by HEDGE_H_PX above a board whose cells are 9px wide. Raise the height and the top faces and shadows cover the corridors until the route vanishes. Filling each shadow separately would also mean 1,024 fills, which is why the parallelograms go into one path and get filled once.
A hidden tab does not unleash a burst of steps
While a tab is hidden requestAnimationFrame rests, and the time added on the first frame back is clipped at 100ms. The 16ms fixed step also runs at most six times per frame, so the board does not race to catch up on the hidden stretch and resumes walking where it stopped.
The zip that lets you change a seed or a pick rule and carve the boards again on your own machine opens with yh7mecs5. Each board comes as an index.html and style.scss pair, joined by React components that port the same math.
Re-Carving With Three Changed Values, and Accessibility for a Keyboard-Solvable Maze
| Variant | Value | Result |
|---|---|---|
| Change the carving speed | 01 CARVE_STEP_MS 24 → 8 | The 1,921-step carve finishes in about 15.4 seconds instead of 46.1, three times faster, and the same seed keeps the maze shape unchanged. |
| Change the heuristic slope | 06 ASTAR_H_K 1.0 → 2.2 | At seed 464 closed cells fall from 264 to 126 with the route still 90 cells, and 12 of 20 seeds return a route longer than the shortest. |
| Change the alley-keeping ratio | 04 BRAID_KEEP 0 → 0.6 | Dead ends left rise from 0 to 59, splices fall from 95 to 37, and entrance to exit grows from 90 to 112 cells. |
All nine boards can be handled without a mouse. Every canvas carries tabindex="0" and an aria-label describing what the board does, and every board has two real <button> elements; the pause buttons of 01 and 03, Use right hand in 07 and Stop the sun in 09 announce their state through aria-pressed. Keys: 01 takes Space to pause, → for 20 steps and Home or Enter to restart; 02 takes Enter to change the pick rule and → for 40 cells; 03 takes Space to pause and → for 60 walls; 04 uses ↑ and ↓ for the keep ratio; 05 takes Enter to flood from the farthest room and → for 5 rings; 06 uses ↑ and ↓ for the slope and Enter to solve again; 07 takes Space to pause, Enter to switch hands and → for 20 steps; 08 moves the goal cell with the arrow keys; 09 turns the sun 15° at a time with ← and →.
The status line under each board stays silent at aria-live="off" during the auto demo and switches to polite the moment a person first touches the pointer, a key or a button, so only changes after that are read aloud. A screen reader is spared a stream of announcements for every demo step.
When prefers-reduced-motion is on, none of the nine boards starts its frame loop; settle() runs the whole process to the end in one go and draw() is called once. Boards 01 to 03 show a fully carved maze, 05 a finished heat map, 07 the trail walked all the way to the exit and 08 a fully revealed valley, and 09 holds the garden with the sun and the lantern at their starting spots. Input still works in this mode, and each press makes after() compute straight through to the result and repaint.
Do Maze Generator Online Tools Do Anything a Browser Cannot?
Not for building and solving. All it takes is a cell array, wall bits per cell, a seeded random source and one stack or queue. Instead of handing back a finished picture the way a maze generator online does, these boards run every step in plain sight with no library, CDN or WebGL, using only fillRect, line strokes and requestAnimationFrame. If a single printable maze is the goal, ignore the demo pacing, run the board to the end in one go the way settle() does and draw the result; the depth-first carve of a 961-cell board takes only 1,921 steps.
If you came here looking for a css maze game, note that the stylesheet only frames the stage; the maze itself lives in the script. For other grid pathfinding scenes, 9 snake game autopilot boards steer a snake with the same breadth-first distance, and in the slime mold network simulation a slime mold finds the shortest route through a maze. For more small games you control by hand, continue to the CSS mini games collection.
Questions Readers Raise Before Putting a Maze Game on Their Own Page
Can I print a bigger maze, the way a maze generator with grid output does?
Change GRID_N and the cell size CS together. One side of the board is GRID_N × CS, and since the canvas works in 480×300 internal units, the current 31×9 = 279px is the size that fits inside a height of 300. More cells also raise the step count of 01 to cells × 2 − 1, so lower CARVE_STEP_MS at the same time to keep the wait short.
Which board should I read to pull out only the answer path?
The simplest place to look is the second half of buildSegs() in 09. It takes the distance array measured from the entrance, starts at the exit, and keeps moving to an open neighbor whose distance is exactly one less until it reaches the entrance, then reverses the order. If you only need to know whether a cell lies on the answer path, the zero-extra-cells test in 08 gives the same answer.
How do I let a player walk the maze with the arrow keys?
Reuse the isOpen(walls, pos, dir) check that walkStep() in 07 relies on. Instead of the three rules, pass the pressed arrow key as dir and call moveAhead() only when that side is open, and the player moves without passing through walls. Arrival at the exit is still decided by a single pos === EXIT test.









