迷路 ゲーム 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
自動再生 · タイルを押すとその項目へ · すべて1つの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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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枚をシードで混ぜて前から一枚ずつ調べ、両側のマスが属する島の代表が違えば壁を払って小さい島を大きい島の代表の下につなぎ、代表が同じなら壁を残します(壁リストのシャッフルと島の結合、union-find)。代表を探すときに通ったマスをすべて代表へ直接つなぎ直す経路圧縮と、大きさを見て結合する工夫を併用しているので島が一列に細長く伸びず、払った壁の位置には360ms(MERGE_FLASH_MS)のあいだ広がりながら薄れる光の円が灯ります。シード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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 代表の要素を探す + 経路圧縮 — たどった道のマスをすべて代表へ直接つなぎ、次の探索を短くする
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 行き止まりの先の壁を一枚払って隣の通路とつなぐ — 隣のマスも行き止まりならそちらを先に選び、二つの路地を一度に消す
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 規則は三つ — ① ついた手の側が開いていればそちらへ曲がって一マス ② そうでなく前が開いていれば一マス ③ 両方塞がっていれば反対側へその場で回る
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// マスごとに「入口→マスの距離 + マス→目標の距離 − 最短距離」 = そのマスを経由すると余分に歩くマス数。最短経路の上はちょうど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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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にとどまり、道が短いうえに行き止まりも三つの中で最も少なくなり、Enterか「選び方を変える」で三つの方式を順に回せます。
01の歩く間隔が変えるのは完成までの時間だけです
CARVE_STEP_MSを24から8に下げると、1,921歩が46,104msから15,368ms、つまり約46.1秒から15.4秒に縮みます。壁を選ぶ乱数は奥へ掘り進む一歩でだけ引かれ、時間とは無関係なので、同じシードなら同じ迷路が3倍速く仕上がるだけです。一度に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は残す割合ひとつで左手の歩数がちょうど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分の1です)。0.6では左手が209歩なのに右手は451歩で、同じ盤でもどちらの手で壁をたどるかによって2倍以上開きます。一回の走査には5,766msかかります。
再帰の深さからタブの復帰まで — 迷路の盤が壊れる八つの場所
ここに並べたのは、ソースの式を計算し直すか、計測スクリプトで関数を呼び出して確かめた内容です。端末ごとに一フレームがどれだけかかるかは測っていません。
再帰で掘るとスタックの深さがそのまま呼び出しの深さになります
01のスタックはシード464で499マスまで深くなります。この歩きを再帰関数で書くと同じ数だけ呼び出しが積み重なり、盤を大きくするほど深さも増えて、呼び出しスタックの上限に届くおそれがあります。ソースがマスの番号をInt32Arrayのスタックに出し入れするループで回っているのはそのためです。
マスを増やすと歩数はマス数×2−1で伸びます
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の固定ステップも一フレームに6回までしか回らないので、隠れていたあいだの歩数を一気に取り戻そうとせず、止まった場所から歩きを再開します。
手元でシードや選び方を書き換えて迷路を掘り直してみるなら、圧縮ファイルを開くパスワードは yh7mecs5 です。盤ごとにindex.htmlとstyle.scssが一組ずつ入っていて、同じ計算を移したReactのコンポーネントも同梱しています。
三つの値を掘り直す、そして道さがし盤のアクセシビリティ
| 名前 | 値 | 手触り |
|---|---|---|
| 掘る速さを変える | 01 CARVE_STEP_MS 24 → 8 | 1,921歩の完成が約46.1秒から15.4秒へ3倍速くなり、シードが同じなので迷路の形は変わりません。 |
| 推定距離の傾きを変える | 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歩なので、フレームを待たずに一度の計算で回しきれます。
格子の上で道を探す別の場面としては、ヘビが経路探索で自分で動くcss snake game の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ひとつで判定します。









