미로 게임 만들기 9단계 — 벽 걷기·물결 자국·최단 거리 계산
미로 게임 만들기는 미로를 그리는 일이 아니라 벽을 걷어내는 일이다 — 02번은 무작위 프론티어가 남긴 물결 띠에서 파낸 순서를 읽고, 06번은 A 열린 목록이 출구 쪽으로 뻗는 모습을 보여 주며, 07번은 왼쪽 벽을 짚는 규칙 세 개만으로 출구에 닿는다.
아홉 개의 미로를 이 화면에서 파고 나가 본다
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 깊이 우선으로 파고 들어갔다 막히면 되돌아와 갈래를 파는 격자 미로
- 02 프론티어가 물결 모양으로 바깥으로 번지며 자라나는 미로
- 03 섞어 놓은 벽 목록에서 하나씩 걷으며 섬이 하나로 합쳐지는 미로
- 04 막다른 골목을 다시 이어 여러 갈래 길을 남기는 미로
- 05 입구에서 번지는 파도가 모든 칸까지의 거리를 계산해 남기는 미로
- 06 목표까지의 추정 거리가 가장 작은 칸부터 열어 나가는 길 찾기
- 07 왼쪽 벽에 손을 댄 채 규칙 세 개만으로 출구를 찾는 걷기
- 08 거리 값을 온도 색으로 바꿔 정답 경로가 골짜기로 드러나는 판
- 09 울타리 정원 미로를 내려다보며 안개는 중심 거리로, 그림자는 높이와 해 방향으로 계산하는 장면
미로 게임 만들기 9단계 — 벽 걷기·물결 자국·최단 거리로 만드는 길 찾기 판
아홉 판은 미로 한 장이 태어나서 풀리기까지를 순서대로 나눈 것이다. 01~03 은 벽을 걷는 세 가지 방식으로 미로를 만들고, 04 는 다 만든 미로의 막다른 골목을 다시 이어 고치며, 05~08 은 만들어진 미로를 네 가지 방식으로 풀고, 09 는 그 결과를 울타리 정원 한 장면으로 세운다. 판은 아홉 개 모두 <canvas> 2D 하나에 그려지고, 스타일시트는 무대 크기와 버튼, 포커스 테두리만 맡는다. 움직임을 만드는 @keyframes 는 없고 전부 requestAnimationFrame 스크립트가 돌린다.
칸 하나에 벽 네 장을 비트로 담는다
칸마다 북 1·동 2·남 4·서 8 의 비트를 하나씩 배정하고, 모든 칸은 벽 네 장이 다 선 값 15(ALL_WALLS)에서 출발해 벽을 걷을 때마다 해당 비트를 지운다. knock 은 벽 한 장을 지울 때 건너편 칸의 마주 보는 비트도 같이 지워서, 이쪽에선 뚫렸는데 저쪽에선 막힌 벽이 생기지 않는다. 방향 번호는 0 북·1 동·2 남·3 서의 시계 방향이라 지금 방향에 1 을 더하면 오른쪽으로, 3 을 더하면 왼쪽으로 돈 방향이 나온다. 07 의 손 짚기는 이 덧셈 두 개로 돈다.
시드가 같으면 미로도 같다
난수는 32비트 정수를 뒤섞는 makeRng(seed) 한 가지이고 기본 시드는 464, 자동 시연이 다음 판으로 넘어갈 때마다 1씩 오른다. 아홉 index.html 에는 Math.random·Date.now·fetch·localStorage 가 한 줄도 없다. 그래서 이 글에 나오는 막다른 골목 96개, 최단 320칸 같은 수치는 zip 을 열고 시드 464 로 돌리면 같은 값으로 다시 나온다.
아홉 판을 2초 동안 찍어 잰 움직임
캡처 창은 480×300, 2초 동안 24장을 찍어 앞뒤 장을 비교했으므로 움직인 프레임의 분모는 23 이다.
| 판 | 누적면적 | 강도 평균 | 움직인 프레임 |
|---|---|---|---|
| 01 깊이 우선 파기 | 5.2% | 105.4 | 23/23 |
| 02 프론티어 물결 | 16.9% | 106.4 | 23/23 |
| 03 섬 합치기 | 25.4% | 43.1 | 23/23 |
| 04 막다른 골목 잇기 | 19.1% | 33.6 | 23/23 |
| 05 거리 파도 열지도 | 41.1% | 122.5 | 19/23 |
| 06 추정 거리 길 찾기 | 12.8% | 55.6 | 23/23 |
| 07 왼손 걷기 | 4.7% | 70.1 | 23/23 |
| 08 돌아가는 거리 골짜기 | 40.3% | 120.3 | 13/23 |
| 09 울타리 정원 | 28.2% | 25.5 | 23/23 |
01 과 07 의 면적이 5% 안팎인 건 걸음 하나가 바꾸는 자리가 9px 칸 하나 둘레에 그치기 때문이다. 01 은 24ms 에 한 걸음이라 2초 동안 약 83걸음을 걷고, 스택 안 칸의 분홍은 머리가 한 칸 깊어질 때 e^(−1/72), 곧 약 1.4%씩만 옅어진다. 07 은 16ms 마다 한 걸음이라 2초에 약 125걸음을 걷지만, 지나간 자취는 걸음마다 0.996배여서 한 번에 0.4%씩만 빠진다. 05 와 08 은 파도가 끝까지 번지거나 수위가 다 차면 다음 판으로 넘어가기 전까지 화면이 서 있는 구간이 있어서 23 에 못 미친다.
이 판들이 들어갈 만한 화면
게임의 레벨 지도나 아이들에게 나눠 줄 인쇄용 미로 활동지라면 01 이 바탕이고, 미로가 생겨나는 과정 자체를 로딩 배경으로 보여 주고 싶다면 02 의 물결이 어울린다. 03 은 알고리즘 수업 자료나 기술 글의 그림으로, 04 는 어린이 퍼즐이나 방 탈출 힌트 판처럼 길이 여러 갈래여야 하는 곳에 맞는다. 05 는 난이도 지도, 06 은 경로 안내 데모, 07 은 보드게임 규칙 설명이나 로딩 중 미니게임, 08 은 정답을 공개하는 장면, 09 는 서비스 첫 화면 히어로 영역의 출발점이 된다. 작은 미로 게임 사이트를 직접 꾸린다면 01 로 판을 만들고 07 이나 08 로 풀이를 보여 주는 조합부터 시작할 수 있다.
미로는 왜 벽을 그리는 대신 걷어내는 방식으로 생기는가?
31×31 격자의 안쪽 벽 후보는 2×31×30 = 1,860장이다. 벽이 전부 선 상태에서 「아직 이어지지 않은 두 칸 사이의 벽」만 걷어 나가면, 칸 961개를 하나로 잇는 데 걷히는 벽은 정확히 960장(칸 수 − 1)이고 그 순간 어느 두 칸 사이에도 길이 딱 하나뿐인 완전 미로가 된다. 01·02·03 은 다음 벽을 고르는 방식이 전혀 다른데도 실측에서 셋 다 960장을 걷었고, 남은 벽은 900장으로 같았다. 반대로 빈 판에 벽을 그려 넣으면 어느 방이 갇혔는지, 고리가 생겼는지를 따로 검사해야 하지만, 걷어내는 쪽은 「이미 이어진 두 칸이면 걷지 않는다」는 조건 하나가 그 검사를 대신한다. 그래서 미로 만들기 알고리즘이 저마다 달라 보여도 실제 차이는 「다음에 걷을 벽을 무엇으로 고르는가」 한 가지로 좁혀진다. 04 는 이 조건을 일부러 어겨 고리를 만든다.
01깊이 우선으로 파고 들어갔다 막히면 되돌아와 갈래를 파는 격자 미로
스택 머리 칸에서 아직 파지 않은 이웃 하나를 시드 난수로 골라 그쪽 벽을 걷고 들어가며, 갈 곳이 없으면 스택에서 한 칸을 빼 되돌아간다. 961칸 판은 넣기 960번과 빼기 961번으로 시드와 상관없이 1,921걸음에 끝나고, 스택 안 칸은 머리에서 k칸 아래일수록 e^(−k/72)로 분홍이 옅어지며 막 빠져나온 칸은 연달아 되돌아간 걸음 수에 비례해 하늘색이 짙다. 자동 시연은 640걸음을 미리 걸어 둔 자리(스택 깊이 463)에서 출발하지만 「다시 파기」 를 누르면 0걸음부터 파며, 렌더 수치는 누적면적 5.2%·강도 105.4·23/23 프레임이다.

<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="깊이 우선으로 벽을 걷으며 파 들어가다 막히면 스택을 되짚어 갈래를 여는 31×31 미로. 칸을 누르면 그 칸에서 다시 판다"></canvas>
<p class="hs__status" data-role="status" aria-live="off">파 들어가는 중</p>
<div class="hs__tools">
<button class="hs__btn" data-role="restart" type="button">다시 파기</button>
<button class="hs__btn" data-role="pause" type="button" aria-pressed="false">멈춤</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 로 옅어진다.
// 스택에서 빠진 칸은 그때 연달아 되돌아간 걸음 수를 같은 눈금으로 하늘색에 섞는다
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"]');
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);
// 이 칸이 스택에서 빠질 때 연달아 되돌아간 걸음 수
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;
}
// 한 걸음 — 머리 칸에 안 판 이웃이 있으면 하나를 골라 그쪽 벽을 걷고 들어가고(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, '판 칸', String(carved + 1) + ' / ' + String(CELL_N));
panelRow(1, '스택 깊이', String(top));
panelRow(2, '되돌아간 걸음', String(backs));
panelRow(3, '걸음', 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 · 깊이 우선으로 파고 들어갔다 막히면 되돌아와 갈래를 파는 격자 미로 — 측정 누적면적 5.212% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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;
02프론티어가 물결 모양으로 바깥으로 번지며 자라나는 미로
이미 판 영역에 닿아 있는 칸들을 프론티어 배열에 모아 두고, 그중 하나를 꺼내 판 안쪽 이웃 하나와 벽 한 장으로 이은 다음 그 칸의 새 이웃을 다시 배열에 넣는다. 칸마다 편입 순번을 적어 26칸(RIPPLE_W)씩 한 띠로 칠하는데, 띠 37개마다 가운데 칸에서 떨어진 거리의 평균이 첫 띠 2.1칸에서 마지막 띠 14.4칸으로 커져 가운데에서 퍼져 나간 물결처럼 읽힌다. 가운데 칸에서 8ms 에 한 칸씩 붙여 960칸을 7,680ms 에 채우고, 렌더 수치는 누적면적 16.9%·강도 106.4·23/23 프레임이다.

<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="판 영역에 닿은 칸 목록에서 하나씩 골라 붙이며 편입 순번이 물결 띠로 남는 미로. 칸을 누르면 그 칸에서 다시 키운다"></canvas>
<p class="hs__status" data-role="status" aria-live="off">프론티어에서 한 칸씩 골라 붙이는 중</p>
<div class="hs__tools">
<button class="hs__btn" data-role="restart" type="button">다시 키우기</button>
<button class="hs__btn" data-role="mode" type="button">고르는 법 바꾸기</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;
}
// 프론티어(판 영역에 닿은 안 판 칸)에서 하나를 골라 이미 판 이웃 하나와 벽 한 장으로 잇는다
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;
}
// 프론티어에서 다음 칸을 고르는 법 — random 은 아무거나, newest 는 막 들어온 것, oldest 는 가장 먼저 들어온 것
const FRONTIER_PICK = '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 = ['random', 'newest', 'oldest'];
const TXT_GROW = '프론티어에서 한 칸씩 골라 붙이는 중';
const TXT_DONE = '완성 — 벽 960장을 걷어 한 판이 됨';
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' ? '무작위' : (m === 'newest' ? '막 들어온 칸' : '먼저 들어온 칸');
}
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, '판 칸', 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() {
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 · 프론티어가 물결 모양으로 바깥으로 번지며 자라나는 미로 — 측정 누적면적 16.87% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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)]);
03섞어 놓은 벽 목록에서 하나씩 걷으며 섬이 하나로 합쳐지는 미로
안쪽 벽 1,860장을 시드로 섞어 앞에서부터 한 장씩 검사하고, 양쪽 칸이 속한 섬의 대표가 다르면 벽을 걷어 작은 섬을 큰 섬 대표 밑에 붙이며 대표가 같으면 벽을 남긴다. 대표를 찾을 때 지나온 칸을 모두 대표에 바로 붙이는 경로 압축과 크기 합치기를 함께 써서 섬이 한 줄로 길게 늘어지지 않게 하고, 섬 색은 대표 번호를 6 으로 나눈 나머지로 고른다. 시드 464 에서는 1,738번째 검사에서 섬이 하나로 모여 뒤의 122장은 하나도 걷히지 않으며, 렌더 수치는 누적면적 25.4%·강도 43.1·23/23 프레임이다.

<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="섞어 놓은 벽 목록을 앞에서부터 검사해 다른 섬 사이 벽만 걷고 섬을 합치는 미로. 칸을 누르면 그 칸의 섬을 밝힌다"></canvas>
<p class="hs__status" data-role="status" aria-live="off">섞은 벽을 앞에서부터 검사하는 중</p>
<div class="hs__tools">
<button class="hs__btn" data-role="shuffle" type="button">다시 섞기</button>
<button class="hs__btn" data-role="pause" type="button" aria-pressed="false">멈춤</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;
// 섬 색은 대표 원소 번호를 이 수로 나눈 나머지로 고른다
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 = '섞은 벽을 앞에서부터 검사하는 중';
const TXT_DONE = '완성 — 섬이 하나로 합쳐짐';
const TXT_PAUSE = '멈춤';
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;
}
// 벽 한 장 검사 — 양쪽 칸의 대표가 다르면 벽을 걷고 작은 섬을 큰 섬 대표 밑에 붙인다(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, '섬', String(islands));
panelRow(1, '검사한 벽', String(wi) + ' / ' + String(order.length));
panelRow(2, '걷은 벽', String(removed));
panelRow(3, '남긴 벽', 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();
}
// 칸을 누르면 그 칸이 속한 섬 전체를 밝힌다 — 대표가 같은 칸이 모두 한 섬이다
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 · 섞어 놓은 벽 목록에서 하나씩 걷으며 섬이 하나로 합쳐지는 미로 — 측정 누적면적 25.362% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 대표 원소 찾기 + 경로 압축 — 올라간 길의 칸들을 모두 대표에 바로 붙여 다음 찾기를 짧게 한다
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;
04막다른 골목을 다시 이어 여러 갈래 길을 남기는 미로
완성된 깊이 우선 미로를 0번 칸부터 훑다가 열린 쪽이 하나뿐인 칸을 만나면 BRAID_KEEP 확률로 남기고, 나머지는 끝의 닫힌 벽 하나를 걷어 옆길에 잇는다. 옆 칸도 막다른 골목이면 그쪽 벽을 먼저 골라 두 골목을 한 번에 없애기 때문에, 시드 464 미로의 막다른 골목 96개가 95번의 잇기로 0개가 된다. 기본값 0 에서 입구→출구 거리는 320칸에서 90칸으로 짧아지고, 렌더 수치는 누적면적 19.1%·강도 33.6·23/23 프레임이다.

<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="완성된 미로를 한 칸씩 훑어 막다른 골목 끝의 벽을 걷어 옆길과 잇는 판. 누르면 남길 비율이 바뀐다"></canvas>
<p class="hs__status" data-role="status" aria-live="off">막다른 골목을 찾아 잇는 중</p>
<div class="hs__tools">
<button class="hs__btn" data-role="new" type="button">새 미로</button>
<button class="hs__btn" data-role="keep" type="button">남길 비율 바꾸기</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;
}
// 막다른 골목 = 열린 쪽이 하나뿐인 칸
function isDeadEnd(walls, i) { return openCount(walls, i) === 1; }
// 막다른 골목 끝의 벽 하나를 걷어 옆길과 잇는다 — 옆 칸도 막다른 골목이면 그쪽을 먼저 골라 두 골목을 한 번에 없앤다
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;
}
// 막다른 골목 가운데 그대로 남길 비율 — 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"]');
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, '막다른 골목', String(deadCount()));
panelRow(1, '이은 골목', String(joined));
panelRow(2, '남길 비율', keep.toFixed(1));
panelRow(3, '입구→출구 거리', 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 · 막다른 골목을 다시 이어 여러 갈래 길을 남기는 미로 — 측정 누적면적 19.073% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 막다른 골목 끝의 벽 하나를 걷어 옆길과 잇는다 — 옆 칸도 막다른 골목이면 그쪽을 먼저 골라 두 골목을 한 번에 없앤다
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;
}
05입구에서 번지는 파도가 모든 칸까지의 거리를 계산해 남기는 미로
입구 칸에 0 을 적고, 지금 겹의 칸마다 열린 이웃 가운데 거리가 비어 있는 칸에 거리+1 을 적어 다음 겹을 만드는 일을 20ms 에 한 번씩 되풀이한다. 색은 거리를 「지금 겹」과 60(HEAT_MAX_D) 가운데 큰 값으로 나눠 하늘·민트·복숭아·분홍 눈금에 얹고, 시드 464 판은 66겹(1,320ms)에 961칸 전체에 닿으며 마지막 겹의 칸 932 가 가장 먼 방으로 드러난다. 「가장 먼 방에서」 를 누르면 그 방에서 다시 번져 69겹 끝에 닿는 칸이 가장 긴 길(69칸)의 반대쪽 끝이 되고, 렌더 수치는 누적면적 41.1%·강도 122.5·19/23 프레임이다.

<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="입구에서 한 겹씩 번지는 파도가 칸마다 거리를 적어 열지도로 물들이고 가장 먼 방을 표시하는 미로. 칸을 누르면 그 칸에서 번진다"></canvas>
<p class="hs__status" data-role="status" aria-live="off">입구에서 한 겹씩 번지는 중</p>
<div class="hs__tools">
<button class="hs__btn" data-role="far" type="button">가장 먼 방에서</button>
<button class="hs__btn" data-role="new" type="button">새 미로</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;
}
// 프론티어(판 영역에 닿은 안 판 칸)에서 하나를 골라 이미 판 이웃 하나와 벽 한 장으로 잇는다
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 = '입구에서 한 겹씩 번지는 중';
const TXT_FLOOD2 = '가장 먼 방에서 다시 번지는 중';
const TXT_DONE1 = '끝 — 가장 먼 방을 찾음';
const TXT_DONE2 = '끝 — 서로 가장 먼 두 방을 찾음';
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();
}
// 시연 — 입구에서 번진 뒤 가장 먼 방에서 한 번 더 번진다 (가지 없는 길이 하나뿐인 미로에서 두 번째 끝이 가장 긴 길의 양 끝)
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, '거리 겹', String(front));
panelRow(1, '닿은 칸', String(reached) + ' / ' + String(CELL_N));
panelRow(2, '가장 먼 방까지', String(dist[farCell]));
panelRow(3, '가장 긴 길', 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 · 입구에서 번지는 파도가 모든 칸까지의 거리를 계산해 남기는 미로 — 측정 누적면적 41.073% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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;
06목표까지의 추정 거리가 가장 작은 칸부터 열어 나가는 길 찾기
최소 힙에서 f(지나온 거리 g + 기울기 K × 출구까지 남은 가로·세로 칸 수)가 가장 작은 칸을 꺼내 닫고, 열린 이웃의 g 가 줄어들면 부모를 이 칸으로 바꿔 힙에 다시 넣는다. 이 판만은 막다른 골목의 40%를 남기고 나머지를 이어 고리를 낸 미로(LOOP_KEEP 0.4)라 출구까지 길이 여럿이고, 출구를 닫는 순간 부모를 거꾸로 짚은 경로를 18ms 에 한 칸씩 긋는다. 시드 464 미로에서 K 1.0 은 264칸을 닫고 최단 90칸 경로를 내며, 렌더 수치는 누적면적 12.8%·강도 55.6·23/23 프레임이다.

<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="지나온 거리와 추정 거리의 합이 가장 작은 칸부터 열고 도착하면 부모 칸을 거꾸로 짚어 경로를 긋는 길 찾기. 누르면 기울기가 바뀐다"></canvas>
<p class="hs__status" data-role="status" aria-live="off">추정 거리가 가장 작은 칸부터 여는 중</p>
<div class="hs__tools">
<button class="hs__btn" data-role="k" type="button">기울기 바꾸기</button>
<button class="hs__btn" data-role="new" type="button">새 미로</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;
}
// 막다른 골목 = 열린 쪽이 하나뿐인 칸
function isDeadEnd(walls, i) { return openCount(walls, i) === 1; }
// 막다른 골목 끝의 벽 하나를 걷어 옆길과 잇는다 — 옆 칸도 막다른 골목이면 그쪽을 먼저 골라 두 골목을 한 번에 없앤다
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 = '추정 거리가 가장 작은 칸부터 여는 중';
const TXT_TRACE = '부모 칸을 거꾸로 짚는 중';
const TXT_DONE = '도착 — 경로를 찾음';
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, '기울기 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() {
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 · 목표까지의 추정 거리가 가장 작은 칸부터 열어 나가는 길 찾기 — 측정 누적면적 12.835% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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);
}
07왼쪽 벽에 손을 댄 채 규칙 세 개만으로 출구를 찾는 걷기
왼쪽이 열렸으면 왼쪽으로 돌아 한 칸, 아니고 앞이 열렸으면 곧장 한 칸, 둘 다 막혔으면 제자리에서 오른쪽으로 도는 순서를 16ms 마다 한 번씩 돌린다. 걸음마다 모든 칸의 자취에 0.996 을 곱해(반감 약 173걸음) 오래된 발자국이 흐려지고, 한 번 지난 칸은 민트, 두 번 이상 지난 칸은 라일락으로 칠한다. 시드 464 미로에서 왼손은 1,095걸음(앞으로 810칸·돌기 570번)에 출구에 닿는데, 자동 시연은 420걸음을 미리 걸어 둔 자리에서 이어 가고 「처음부터」 는 0걸음에서 다시 걸으며, 렌더 수치는 누적면적 4.7%·강도 70.1·23/23 프레임이다.

<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="한쪽 손을 벽에 댄 채 규칙 세 개로 걸어 출구를 찾고 지나온 자취가 옅어지는 미로. 누르면 멈추거나 다시 걷는다"></canvas>
<p class="hs__status" data-role="status" aria-live="off">왼쪽 벽을 짚고 걷는 중</p>
<div class="hs__tools">
<button class="hs__btn" data-role="hand" type="button" aria-pressed="false">오른손으로</button>
<button class="hs__btn" data-role="restart" type="button">처음부터</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 이면 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"]');
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;
}
// 규칙 세 개 — ① 짚은 손 쪽이 열려 있으면 그쪽으로 돌아 한 칸 ② 아니고 앞이 열려 있으면 한 칸 ③ 둘 다 막혔으면 반대쪽으로 제자리 돌기
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 걸음 */ }
}
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);
// 걷는 이 — 방향 삼각형과 짚은 손 점
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() {
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 · 왼쪽 벽에 손을 댄 채 규칙 세 개만으로 출구를 찾는 걷기 — 측정 누적면적 4.677% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 규칙 세 개 — ① 짚은 손 쪽이 열려 있으면 그쪽으로 돌아 한 칸 ② 아니고 앞이 열려 있으면 한 칸 ③ 둘 다 막혔으면 반대쪽으로 제자리 돌기
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;
}
08거리 값을 온도 색으로 바꿔 정답 경로가 골짜기로 드러나는 판
입구와 목표 두 곳에서 각각 너비 우선으로 거리를 잰 뒤 칸마다 「입구 거리 + 목표 거리 − 최단 거리」를 구하면 그 칸을 거쳐 갈 때 더 걷는 칸 수가 되고, 최단 경로 위에서는 이 값이 정확히 0 이다. 이 값을 다섯 눈금 온도 색에 얹고 0 에 가까울수록 최대 45%(VALLEY_DEPTH)까지 어둡게 누르며, 수위가 1.5초에 걸쳐 제곱 곡선으로 차오르기 때문에 가장 낮은 띠가 골짜기처럼 먼저 떠오른다. 시드 464 에서 0 인 칸은 321칸으로 320걸음짜리 최단 경로가 지나는 칸 수와 같고(시드 10개 모두 최단 + 1), 렌더 수치는 누적면적 40.3%·강도 120.3·13/23 프레임이다.

<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="칸마다 돌아가는 거리를 온도 색으로 칠해 낮은 칸부터 차오르게 하고 최단 경로를 골짜기 바닥으로 드러내는 판. 칸을 누르면 목표가 바뀐다"></canvas>
<p class="hs__status" data-role="status" aria-live="off">돌아가는 거리가 낮은 칸부터 차오르는 중</p>
<div class="hs__tools">
<button class="hs__btn" data-role="replay" type="button">다시 드러내기</button>
<button class="hs__btn" data-role="new" type="button">새 미로</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];
// 골짜기 바닥(최단 경로)을 얼마나 어둡게 파나 — 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"]');
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;
// 칸마다 「입구→칸 거리 + 칸→목표 거리 − 최단 거리」 = 그 칸을 거쳐 가면 더 걷는 칸 수. 최단 경로 위는 정확히 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, '최단 경로', String(shortest));
panelRow(1, '골짜기 바닥 칸', String(floorN));
panelRow(2, '가장 많이 돌아감', String(maxEx));
panelRow(3, '드러난 칸', 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 · 거리 값을 온도 색으로 바꿔 정답 경로가 골짜기로 드러나는 판 — 측정 누적면적 40.303% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 칸마다 「입구→칸 거리 + 칸→목표 거리 − 최단 거리」 = 그 칸을 거쳐 가면 더 걷는 칸 수. 최단 경로 위는 정확히 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;
}
09울타리 정원 미로를 내려다보며 안개는 중심 거리로, 그림자는 높이와 해 방향으로 계산하는 장면
벽 비트를 선분 1,024개(시드 464)로 바꾼 뒤 해 반대쪽으로 민 평행사변형 그림자를 한 경로에 모아 한 번에 칠하고, 그 위에 옆면과 4px(HEDGE_H_PX) 위로 올린 윗면을 그어 울타리를 세운다. 옆면은 가로 울타리가 −sin, 세로 울타리가 −cos 를 따라 해를 마주 볼수록 밝아지고, 안개 짙기 1 − e^(−0.0042·r) 는 판 가장자리 중점에서 0.443, 캔버스 모서리에서 0.695 까지 올라 눈길이 판 가운데로 모인다. 해는 5,712ms 에 한 바퀴를 돌고 등불이 정답 길 321칸을 70ms 에 한 칸씩 따라가며, 렌더 수치는 누적면적 28.2%·강도 25.5·23/23 프레임이다.

<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="울타리를 세운 정원 미로를 내려다보며 해가 돌아 그림자가 눕고 가장자리로 갈수록 안개가 짙어지는 장면. 누른 자리가 해가 된다"></canvas>
<p class="hs__status" data-role="status" aria-live="off">해가 돌며 그림자가 눕는 중</p>
<div class="hs__tools">
<button class="hs__btn" data-role="new" type="button">새 정원</button>
<button class="hs__btn" data-role="sun" type="button" aria-pressed="false">해 멈춤</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 = '해가 돌며 그림자가 눕는 중';
const TXT_STOP = '해 멈춤';
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 · 울타리 정원 미로를 내려다보며 안개는 중심 거리로, 그림자는 높이와 해 방향으로 계산하는 장면 — 측정 누적면적 28.202% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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();
걷는 순서·물결 폭·추정 거리 기울기 — 숫자 하나가 길의 생김새를 바꾸는 자리
아래 수치는 측정 스크립트가 각 판의 index.html 함수를 직접 불러 시드를 고정한 채 잰 값이다.
02 는 꺼내는 순서 한 줄로 막다른 골목이 266~326개로 갈린다
같은 시드, 같은 가운데 시작에서 random 은 막다른 골목 326개·가운데에서 가장 먼 칸까지 35칸·입구→출구 62칸이고, newest 는 319개·487칸·426칸, oldest 는 266개·30칸·60칸이다. newest 는 방금 넣은 칸을 곧바로 다시 꺼내므로 깊이 우선처럼 긴 복도가 이어져 가장 먼 칸이 487칸까지 멀어진다. oldest 는 오래 기다린 칸부터 꺼내 가장 먼 칸이 30칸에 그치고, 입구→출구도 60칸으로 셋 중 가장 짧으며 막다른 골목도 가장 적다. Enter 나 「고르는 법 바꾸기」 로 세 방식을 차례로 돌려 볼 수 있다.
01 의 걸음 간격은 완성 시간만 바꾼다
CARVE_STEP_MS 를 24 에서 8 로 내리면 1,921걸음이 46,104ms 에서 15,368ms 로, 약 46.1초에서 15.4초로 줄어든다. 벽을 고르는 난수는 파 들어가는 걸음에서만 뽑히고 시간과는 무관해서, 같은 시드면 같은 미로가 세 배 빠르게 나올 뿐이다. 한 번에 122걸음까지 이어지는 되돌아가기 구간도 같은 비율로 짧아진다(계산값 2,928ms → 976ms).
06 의 기울기를 2.2 로 올리면 닫는 칸이 절반 아래로 준다
시드 464 미로에서 K 1.0 은 264칸, K 2.2 는 126칸을 닫고 경로는 둘 다 90칸이다. 남은 거리를 아예 보지 않는 K 0 은 804칸을 닫는다. 시드 20개로 넓혀 보면 닫는 칸의 평균이 556.85 에서 249.45 로 줄고 20개 모두 줄었지만, K 1.0 이 20개 전부 최단 경로를 낸 반면 K 2.2 는 12개 미로에서 최단보다 긴 길을 내놓았고 가장 크게 벌어진 차이는 14칸이었다.
04 의 남길 비율을 0 으로 내리면 왼손 걷기 걸음이 5분의 1로 준다
시드 464 깊이 우선 미로(막다른 골목 96개)를 비율마다 한 번씩 훑은 결과다.
| 남길 비율 | 이은 수 | 남은 막다른 골목 | 입구→출구 | 왼손 걷기 | 오른손 걷기 |
|---|---|---|---|---|---|
| 0 (기본) | 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 |
비율 1 은 아무것도 잇지 않은 원래 미로라 왼손 걷기가 1,095걸음이고, 0 이면 219걸음으로 꼭 5분의 1이 된다. 오른손은 같은 두 비율에서 1,484걸음과 253걸음이라 약 5.9배 차이다. 0.6 에서는 왼손이 209걸음인데 오른손은 451걸음이라, 같은 판이라도 어느 손으로 벽을 짚느냐에 따라 두 배 넘게 벌어진다. 한 번 훑는 데는 5,766ms 가 걸린다.
칸이 수천 개일 때·골목이 다시 이어질 때·탭이 멈출 때 깨지는 곳
아래는 소스의 식을 다시 계산하거나 측정 스크립트로 함수를 직접 불러 확인한 내용이다. 기기마다 한 프레임이 얼마나 걸리는지는 재지 않았다.
재귀로 파면 스택 깊이가 호출 깊이가 된다
01 의 스택은 시드 464 에서 499칸까지 깊어진다. 이 걷기를 재귀 함수로 짜면 그만큼 호출이 겹겹이 쌓이고, 판을 키울수록 깊이도 늘어 호출 스택 한도에 닿을 수 있다. 소스가 칸 번호를 Int32Array 스택에 넣고 빼는 반복문으로 도는 이유다.
칸 수를 늘리면 걸음이 칸 수의 두 배로 붙는다
01 의 걸음 수는 칸 수 × 2 − 1 이어서 61×61 판(3,721칸)이면 7,441걸음, 24ms 간격으로 약 178.6초가 걸린다. 이 크기는 직접 돌려 잰 값이 아니라 식으로 낸 계산값이다. 07 도 시드 464 에서 1,095걸음으로 칸 수 961 을 넘었고, 시드 464~473 열 개 사이에서 496걸음부터 1,641걸음까지 벌어졌다. 02 의 oldest 는 Array.shift 로 꺼내 꺼낼 때마다 배열 앞을 당기는데, 이 판의 프론티어는 최대 138칸이라 지금 크기에서는 느려지지 않는다.
골목을 다시 이으면 손 짚기 걷기가 섬을 맴돌 수 있다
04 처럼 고리를 낸 미로에서는 「두 칸 사이 길은 하나」라는 성질이 깨진다. 이 판들은 입구와 출구가 바깥 벽에 붙어 있어서 왼손 걷기가 출구에 닿지만, 출구를 판 안쪽에 두거나 출발 칸이 고리에 둘러싸인 섬 위에 있으면 같은 섬을 끝없이 돈다. 05 가 쓰는 「두 번 번져 가장 긴 길 찾기」도 길이 하나뿐인 미로에서만 성립해서, 04·06 같은 고리 미로에서는 답을 보장하지 않는다.
07 의 민트 칸을 정답 길로 읽으면 틀린다
시드 464 에서 한 번만 지난 민트 칸은 350칸이고, 최단 경로가 지나는 칸은 321칸이다. 막다른 골목 끝 칸은 들어갔다가 제자리에서 두 번 돌아 나오므로 한 번만 지나 민트로 남고, 갈림길 칸은 곁가지를 드나들며 두 번 이상 지나 라일락이 되기도 한다. 정답 길만 따로 칠하려면 08 처럼 두 곳에서 거리를 재야 한다.
06 의 힙은 칸 수보다 커질 수 있다
힙에서 낡은 항목을 지우지 않고 꺼낼 때 닫힘 표시로 건너뛰는 방식이라, g 가 줄어든 칸은 같은 칸이 힙에 여러 번 들어간다. 그래서 힙 배열은 칸 수의 4배로 잡혀 있다. 판을 키우면서 이 배수를 줄이면 힙이 넘칠 수 있다.
같은 색 두 섬이 같은 섬이라는 뜻은 아니다
03 의 섬 색은 대표 번호 % 6 이라, 400번째 검사 직후 남아 있는 섬 561개가 여섯 색을 나눠 쓴다. 맞닿은 두 섬이 같은 색이어도 대표가 다르면 서로 다른 섬이고, 칸을 누르면 1.4초 동안 그 섬 전체를 밝혀 진짜 범위를 보여 준다. 벽 목록을 섞지 않고 만든 순서대로 검사하면 줄마다 같은 방향 벽만 걷혀 긴 가로 복도가 생긴다.
울타리를 높이면 9px 통로가 묻힌다
09 는 칸 한 변이 9px 인 판 위에 울타리 윗면을 HEDGE_H_PX 만큼 끌어올려 긋는다. 높이를 키우면 윗면과 그림자가 통로를 덮어 길이 보이지 않게 된다. 그림자도 선분마다 따로 fill 하면 1,024번을 칠해야 해서, 평행사변형을 한 경로에 모아 한 번만 칠한다.
탭을 가렸다 돌아와도 걸음이 몰려오지 않는다
탭이 숨으면 requestAnimationFrame 이 쉬고, 돌아온 첫 프레임에 더하는 시간은 100ms 에서 잘린다. 16ms 고정 스텝도 한 프레임에 여섯 번까지만 돌기 때문에 숨어 있던 동안의 걸음을 한꺼번에 따라잡지 않고 멈췄던 자리에서 다시 걷는다.
받은 판에서 시드나 고르는 법을 바꿔 직접 다시 파 볼 때 압축을 여는 비밀번호는 yh7mecs5 이다. 판마다 index.html 과 style.scss 한 쌍이 들어 있고, 같은 계산을 옮긴 리액트 컴포넌트도 함께 있다.
걷기 속도·추정 기울기·골목 비율로 다시 파 보기, 그리고 키보드로 푸는 미로 판의 접근성
| 이름 | 값 | 느낌 |
|---|---|---|
| 걷기 속도 바꾸기 | 01 CARVE_STEP_MS 24 → 8 | 1,921걸음 완성이 약 46.1초에서 15.4초로 세 배 빨라지고, 같은 시드라 미로 모양은 그대로다. |
| 추정 거리 기울기 바꾸기 | 06 ASTAR_H_K 1.0 → 2.2 | 시드 464 에서 닫는 칸이 264 에서 126 으로 줄고 경로는 90칸 그대로, 시드 20개 중 12개에서는 최단보다 긴 답이 나온다. |
| 골목 살리기 비율 바꾸기 | 04 BRAID_KEEP 0 → 0.6 | 남는 막다른 골목이 0 에서 59개로 늘고 이은 수는 95 에서 37, 입구→출구는 90 에서 112칸이 된다. |
마우스 없이도 아홉 판을 다 다룰 수 있다. 캔버스마다 tabindex="0" 과 판이 무엇을 하는지 적은 한국어 aria-label 이 붙어 있고, 판마다 진짜 <button> 이 두 개씩 있다. 01·03 의 「멈춤」, 07 의 「오른손으로」, 09 의 「해 멈춤」 은 aria-pressed 로 켜짐 상태를 알린다. 키는 01 이 Space 멈춤·→ 20걸음·Home/Enter 처음부터, 02 가 Enter 고르는 법 바꾸기·→ 40칸, 03 이 Space 멈춤·→ 60장, 04 가 ↑·↓ 로 남길 비율, 05 가 Enter 가장 먼 방에서·→ 5겹, 06 이 ↑·↓ 로 기울기와 Enter 다시 풀기, 07 이 Space 멈춤·Enter 손 바꾸기·→ 20걸음, 08 이 방향키로 목표 칸 옮기기, 09 가 ←·→ 로 해를 15° 씩 돌리기다.
판 아래 상태 줄은 자동 시연 동안 aria-live="off" 로 조용히 있다가, 사람이 처음 포인터나 키, 버튼을 건드리는 순간 polite 로 바뀌어 그 뒤의 변화만 읽어 준다. 그래서 화면 읽기 프로그램이 시연의 걸음마다 말을 쏟아 내지 않는다.
prefers-reduced-motion 이 켜져 있으면 아홉 판 모두 프레임 루프를 시작하지 않고 settle() 로 과정을 끝까지 한 번에 돌린 뒤 draw() 를 한 번 부른다. 01~03 은 다 파인 미로, 05 는 다 번진 열지도, 07 은 출구까지 걸은 자취, 08 은 골짜기가 다 드러난 판으로 서 있고, 09 는 해와 등불이 처음 자리에 멈춘 정원이다. 입력은 이 상태에서도 받으며, 누를 때마다 after() 가 곧바로 결과까지 계산해 다시 그린다.
미로 만들기 사이트 없이 브라우저에서 바로 되는가?
된다. 미로를 만들고 푸는 데 필요한 건 칸 배열, 칸마다 벽 비트, 시드 난수, 그리고 스택이나 큐 하나다. 이 편의 판은 라이브러리나 CDN, WebGL 없이 fillRect 와 선 긋기, requestAnimationFrame 만 쓴다. 인쇄할 미로 한 장이 목적이라면 시연 속도와 상관없이 settle() 처럼 끝까지 한 번에 돌린 판을 그리면 되고, 961칸 판의 깊이 우선 파기는 1,921걸음이면 끝난다.
격자 위에서 길을 찾는 다른 장면으로는 같은 너비 우선 거리로 뱀을 자동 조종하는 스네이크 게임 자동 조종 9종 과 점균이 미로의 최단 경로를 찾는 점균 네트워크 시뮬레이션 이 가깝다. 손으로 조작하는 작은 게임을 더 보려면 CSS 미니 게임 모음 으로 이어 가면 된다.
미로 찾기 게임을 내 페이지에 붙이기 전에 자주 나오는 궁금증
미로 게임 사이트에 올릴 판을 더 크게 만들려면 무엇을 바꾸나?
GRID_N 과 칸 크기 CS 를 같이 바꾼다. 판 한 변은 GRID_N × CS 이고 캔버스 내부 좌표가 480×300 이라, 지금의 31×9 = 279px 은 높이 300 안에 들어가도록 맞춘 크기다. 칸을 늘리면 01 의 걸음도 칸 수 × 2 − 1 로 늘어나니 CARVE_STEP_MS 를 함께 줄여야 기다림이 길어지지 않는다.
정답 경로만 뽑아 쓰려면 어느 판을 보면 되나?
09 의 buildSegs() 뒷부분이 가장 간단하다. 입구에서 잰 거리 배열을 들고 출구에서 출발해, 열린 이웃 가운데 거리가 정확히 1 작은 칸으로 옮겨 가기를 입구에 닿을 때까지 되풀이한 뒤 순서를 뒤집는다. 경로 전체가 아니라 「어느 칸이 정답 길 위에 있는가」만 필요하면 08 의 「더 걷는 칸 수가 0 인 칸」 판정이 같은 답을 준다.
방향키로 사람이 직접 걷게 하려면 어디를 고치나?
07 의 walkStep() 이 쓰는 isOpen(walls, pos, dir) 검사를 그대로 쓰면 된다. 규칙 세 개 대신 누른 방향키를 dir 로 넘기고, 열려 있을 때만 moveAhead() 를 부르면 벽을 뚫지 않는 플레이어 이동이 된다. 출구 도착은 지금처럼 pos === EXIT 하나로 판정한다.









