スライドパズル 9種 — 解ける盤の見分け方から最短手の探索まで
スライドパズルはなぜ解けない盤があるのでしょうか。02番はひっくり返った組の数で解ける盤を分け、05番はマンハッタン距離の上限を引き上げながら最短手を探し、07番はタイルをドラッグして離した位置でマスに吸いつけます。
スライドパズルは番号のタイルをやみくもに並べ替えるゲームではなく、空きマスの隣のタイルが入れ替わる規則のもとで、解ける盤と解けない盤が分かれる計算です — スライドパズルを作るには、混ぜ方と判定に使う値を決めることから始まります
01 sp-row-chain-slide
02 sp-parity-solvable-check
03 sp-manhattan-heat-map
04 sp-legal-walk-shuffle
05 sp-ida-star-solve
06 sp-image-tile-cut
07 sp-drag-clamp-snap
08 sp-undo-replay-cancel
09 sp-size-state-count-ramp
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 空きマスへ列ごと動くタイルの連鎖
- 02 ひっくり返った組の数で解ける盤を分ける
- 03 元の位置までの距離を色で塗るマンハッタンのヒートマップ
- 04 完成した盤から歩いて出る混ぜ方
- 05 上限を引き上げながら最短手を探す反復深化探索
- 06 一枚の絵をタイルに切って継ぎ目が見える盤
- 07 ドラッグして離すとマスに吸いつくポインター
- 08 取り消しの記録と解法の再生
- 09 盤の大きさごとの状態数と探索ノードの比較
九つの場面は同じ盤の配列を共有し、場面ごとに違うのは値を読む関数だけです。盤を番号の配列として見れば、パズルは状態ひとつで、押したり動かしたりする画面はその状態を塗る作業にすぎません。
盤の状態は実際のパズルの処理からだけ作ります
九つの場面のどれにも、盤の配列をまねた絵を別に描くところはありません。bd 配列は rowChain・walkMoves・idaStar のような実際の移動の関数でだけ書き換え、画面は書き換わった配列をマスの座標に移して描きます。
一歩は1/60秒で固定し、乱数は整数のハッシュにします
経過時間は DT_MAX の0.05秒で切って積み、STEP_S の1/60秒ごとに分けて消化します。乱数は使わず、混ぜ方も場面の選び方も整数のハッシュ hash2(a, b) で決めるので、同じ種なら同じ盤、同じデモになります。タブが隠れるあいだはループを止め、戻ったら積んだ時間を捨てます。
const order = [];
for (let k = 0; k < list.length; k += 1) {
order[list[k]] = k;
}
retarget(function (v) {
const o = order[v];
return (o ? o : 0) * STAGGER_MS / 1000;
}, SLIDE_MS / 1000);
recordPress(list, i);
}
上のコードは01で使う開始時刻のずらしで、order に押した順の番号を入れ、STAGGER_MS の分だけ遅らせて retarget に渡します。
スライドパズル 9種 — 列ごとの移動・解ける盤の見分け方・マンハッタン距離のヒートマップ・歩いて混ぜる・最短手の探索・絵のタイルの切り出し・ドラッグして吸着・取り消しと解法の再生・盤の大きさごとの状態数
九つの場面を描画して測った面積とフレーム
| 場面 | 累積面積 | 動いたフレーム | 強さの平均 |
|---|---|---|---|
| 01 列ごとの移動 | 27.1% | 23/23 | 97.9 |
| 02 盤の見分け方 | 23.4% | 22/23 | 120.5 |
| 03 マンハッタンのヒートマップ | 18.9% | 23/23 | 154.4 |
| 04 歩いて混ぜる | 27.6% | 23/23 | 119.9 |
| 05 最短手の探索 | 26.8% | 22/23 | 126.0 |
| 06 絵のタイル | 32.7% | 21/23 | 106.1 |
| 07 ドラッグして吸着 | 12.7% | 19/23 | 110.3 |
| 08 取り消しと再生 | 14.8% | 21/23 | 153.0 |
| 09 状態数の比較 | 12.9% | 21/23 | 143.8 |
累積面積が最も広いのは06の32.7%で、最も狭いのは07の12.7%です。動いたフレームは07が19/23で最少、01・03・04の三場面が23/23で、強さの平均は03の154.4が最高です。
01空きマスへ列ごと動くタイルの連鎖
空きマスと同じ列のタイルを押すと、その間のタイルが一マスずつ空きマスのほうへ動くので、一回の入力で何枚も動かしたいときに使えます。タイルごとに SLIDE_MS の140msで動き、始まりは STAGGER_MS の30msずつずれて順に付いていき、配列の入れ替えが先に終わるので、押した瞬間に盤の状態は決まっています。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="スライドパズルの舞台 — 矢印キーで空きマスの隣のタイルを動かす、タイルを押しても動く、EnterかSpaceで一時停止、Nで一歩、Rで最初から">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="暗い梅色の背景に4×4のスライドパズルの盤がある。空きマスと同じ行か同じ列のタイルを押すと、その間のタイルがすべて一マスずつ空きマスのほうへ140ミリ秒で動き、始まりは30ミリ秒ずつずれて順に付いていく。右のパネルには一回で動かしたタイル数の棒が十本、押した回数と動かした合計、直前の押しでのタイルごとの出発時刻と移動区間が出る。下の目盛りは定数名と今の値を示し、動きを減らす設定ではタイルがすぐ入れ替わる盤一枚だけを描きます"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">スライドパズルの場面</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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;
}
// 무대 상자 — 눌러서 한 번 깨뜨리는 곳(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause {
padding: $sp-1 $sp-3;
border: 2px solid $subject-cream;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 스크린리더에만 읽히는 낭독 문장 — 모드가 바뀔 때만 쓴다
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-ink;
color: $subject-cream;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면에만 있고 aria-live 는 늘 off
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 100px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 글리치 없는 원문 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
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;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause {
padding: 4px 12px;
border: 2px solid #fff7e6;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 100px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function easeOut(k) { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const SKY = [150, 192, 244];
const SUN = [250, 218, 146];
const NEU = [236, 230, 250];
const BGC = [46, 42, 68];
const PANEL = [62, 57, 92];
const DIM = [196, 190, 224];
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function hsl(h, s, l) {
const a = s * Math.min(l, 1 - l);
const f = function (n) {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a, b) {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s, a, b, c) {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v) { return String(Math.round(v * 10) / 10); }
function group(v) {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
o += ',';
}
o += s.charAt(i);
}
return o;
}
// ── 퍼즐 모델 — 판은 번호 배열, 0 은 빈칸, 타일 v 의 제자리는 v-1 번 칸, 빈칸의 제자리는 맨 끝
// 빈칸이 움직이는 방향 d: 0 오른쪽 · 1 왼쪽 · 2 아래 · 3 위. 반대 방향은 d ^ 1
const DX = [1, -1, 0, 0];
const DY = [0, 0, 1, -1];
function solvedBoard(n) {
const b = [];
for (let i = 1; i < n * n; i += 1) {
b.push(i);
}
b.push(0);
return b;
}
function canStep(b, n, d) {
const z = b.indexOf(0);
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
return c >= 0 && c < n && r >= 0 && r < n;
}
function blankStep(b, n, d) {
if (!canStep(b, n, d)) {
return false;
}
const z = b.indexOf(0);
const t = z + DX[d] + DY[d] * n;
b[z] = b[t];
b[t] = 0;
return true;
}
// 역전 — 번호를 한 줄로 늘어놓고 앞 번호가 뒤 번호보다 큰 쌍의 수(빈칸은 세지 않는다)
function inversions(b) {
let c = 0;
for (let i = 0; i < b.length; i += 1) {
for (let j = i + 1; j < b.length; j += 1) {
if (b[i] !== 0 && b[j] !== 0 && b[i] > b[j]) {
c += 1;
}
}
}
return c;
}
// 빈칸이 아래서 몇 번째 줄인지(맨 아래 줄 = 1)
function blankRow(b, n) { return n - Math.floor(b.indexOf(0) / n); }
// 판정 합 — 짝수 폭은 역전 + 빈칸 줄, 홀수 폭은 역전만
function parity(b, n) { return n % 2 === 0 ? inversions(b) + blankRow(b, n) : inversions(b); }
function solvable(b, n) {
return n % 2 === 0 ? parity(b, n) % 2 === 1 : parity(b, n) % 2 === 0;
}
function manhattan(b, n, i) {
const v = b[i];
if (v === 0) {
return 0;
}
const h = v - 1;
return Math.abs((i % n) - (h % n)) + Math.abs(Math.floor(i / n) - Math.floor(h / n));
}
function sumH(b, n) {
let s = 0;
for (let i = 0; i < b.length; i += 1) {
s += manhattan(b, n, i);
}
return s;
}
function placedCount(b) {
let c = 0;
for (let i = 0; i < b.length - 1; i += 1) {
if (b[i] === i + 1) {
c += 1;
}
}
return c;
}
function isSolved(b) { return placedCount(b) === b.length - 1 && b[b.length - 1] === 0; }
// 歩いて混ぜる — 完成 판에서 시작해 빈칸을 steps 번 이웃 칸으로 옮긴다. 방금 온 칸으로 되돌아가는 수는 건너뛰고
// 어느 이웃으로 갈지는 정수 해시로 고른다. 반환은 빈칸이 움직인 방향의 목록
function walkMoves(n, steps, seed) {
const b = solvedBoard(n);
const out = [];
let noBacktrack = -1;
for (let s = 0; s < steps; s += 1) {
const opts = [];
for (let d = 0; d < 4; d += 1) {
if (d !== noBacktrack && canStep(b, n, d)) {
opts.push(d);
}
}
const d = opts[Math.floor(hash2(s, seed) * opts.length)];
blankStep(b, n, d);
out.push(d);
noBacktrack = d ^ 1;
}
return out;
}
function applyMoves(b, n, ms) {
for (let i = 0; i < ms.length; i += 1) {
blankStep(b, n, ms[i]);
}
}
function reverseMoves(ms) {
const o = [];
for (let i = ms.length - 1; i >= 0; i -= 1) {
o.push(ms[i] ^ 1);
}
return o;
}
// 줄째 밀기 — 누른 칸 i 가 빈칸과 같은 줄이면 사이 타일이 모두 한 칸씩 빈칸 쪽으로. 배열 교환으로 먼저 정하고
// 옮겨진 타일 번호를 빈칸에 가까운 순서로 돌려준다
function rowChain(b, n, i) {
const z = b.indexOf(0);
if (i === z) {
return [];
}
let step = 0;
if (Math.floor(i / n) === Math.floor(z / n)) {
step = i > z ? 1 : -1;
}
else if (i % n === z % n) {
step = i > z ? n : -n;
}
else {
return [];
}
const moved = [];
for (let p = z; p !== i; p += step) {
b[p] = b[p + step];
moved.push(b[p]);
}
b[i] = 0;
return moved;
}
function idaStar(start, n, cap) {
const b = start.slice();
const path = [];
let nodes = 0;
let capHit = false;
const INF = 9999;
let z = b.indexOf(0);
function dfs(g, h, bound, last) {
nodes += 1;
if (nodes > cap) {
capHit = true;
return INF;
}
const f = g + h;
if (f > bound) {
return f;
}
if (h === 0) {
return -1;
}
let min = INF;
for (let d = 0; d < 4; d += 1) {
if (last >= 0 && d === (last ^ 1)) {
continue;
}
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
if (c < 0 || c >= n || r < 0 || r >= n) {
continue;
}
const t = z + DX[d] + DY[d] * n;
const v = b[t];
const hv = Math.abs((t % n) - ((v - 1) % n)) + Math.abs(Math.floor(t / n) - Math.floor((v - 1) / n));
const hn = Math.abs((z % n) - ((v - 1) % n)) + Math.abs(Math.floor(z / n) - Math.floor((v - 1) / n));
b[z] = v;
b[t] = 0;
const oz = z;
z = t;
path.push(d);
const res = dfs(g + 1, h - hv + hn, bound, d);
if (res === -1) {
return -1;
}
path.pop();
z = oz;
b[t] = v;
b[z] = 0;
if (capHit) {
return INF;
}
if (res < min) {
min = res;
}
}
return min;
}
let bound = sumH(b, n);
const bounds = [];
const perBound = [];
while (bound < INF) {
const before = nodes;
bounds.push(bound);
const res = dfs(0, sumH(b, n), bound, -1);
perBound.push(nodes - before);
if (res === -1) {
return { path: path.slice(), nodes: nodes, bounds: bounds, perBound: perBound, capHit: false };
}
if (capHit || res >= INF) {
break;
}
bound = res;
}
return { path: [], nodes: nodes, bounds: bounds, perBound: perBound, capHit: capHit };
}
// ── 판 화면 — 타일마다 지금 칸 → 목표 칸 보간. 배열은 먼저 바뀌고 화면이 뒤따른다
const BX = 24;
const BY = 44;
const BSZ = 216;
let bd = [];
let BN = 4;
let CS = 54;
let tl = [];
let dragV = -1;
let dragCx = 0;
let dragCy = 0;
function setBoard(b, n) {
bd = b.slice();
BN = n;
CS = BSZ / n;
tl = [];
dragV = -1;
for (let v = 0; v < n * n; v += 1) {
const i = bd.indexOf(v);
const c = i % n;
const r = Math.floor(i / n);
tl.push({ fx: c, fy: r, tx: c, ty: r, t0: 0, du: 0 });
}
}
let still = false;
function tilePos(v) {
const t = tl[v];
if (v === dragV) {
return [dragCx, dragCy];
}
if (still || t.du <= 0) {
return [t.tx, t.ty];
}
const e = easeOut((sceneT - t.t0) / t.du);
return [lerp(t.fx, t.tx, e), lerp(t.fy, t.ty, e)];
}
// 배열이 바뀐 뒤 타일마다 목표 칸을 다시 읽는다 — 옮겨진 타일만 지금 화면 자리에서 출발한다
function retarget(delayOf, du) {
for (let v = 1; v < BN * BN; v += 1) {
const i = bd.indexOf(v);
const c = i % BN;
const r = Math.floor(i / BN);
const t = tl[v];
if (c !== t.tx || r !== t.ty) {
const p = tilePos(v);
t.fx = p[0];
t.fy = p[1];
t.tx = c;
t.ty = r;
t.t0 = sceneT + delayOf(v);
t.du = du;
}
}
}
function stepBlank(d, du) {
if (!blankStep(bd, BN, d)) {
return false;
}
retarget(function () { return 0; }, du);
return true;
}
function cellAt(x, y) {
const c = Math.floor((x - BX) / CS);
const r = Math.floor((y - BY) / CS);
if (c < 0 || c >= BN || r < 0 || r >= BN) {
return -1;
}
return r * BN + c;
}
// ── 그리기 도구
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function disc(x, y, r, c, a) {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, c, a, w) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s, x, y, c, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x, y, w, h) { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name, val, frac) {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// 판 바탕 — 받침과 빈 칸 홈
function drawBoardBase() {
const s = CS * BN;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < BN * BN; i += 1) {
box(BX + (i % BN) * CS + 2, BY + Math.floor(i / BN) * CS + 2, CS - 4, CS - 4, 8, [40, 36, 62], 1);
}
}
function tileHue(v, n) {
return hsl(190 + 160 * ((v - 1) / (n * n - 2)), 0.55, 0.8);
}
function paintTile(label, cx, cy, c, cs, ox, oy) {
const s = cs - 4;
const x = ox + cx * cs + 2;
const y = oy + cy * cs + 2;
box(x, y + 2, s, s, 8, [26, 23, 42], 0.5);
box(x, y, s, s, 8, c, 1);
box(x + 3, y + 2, s - 6, 3, 1.5, [255, 255, 255], 0.4);
text(label, x + s / 2, y + s / 2 + 1, INK, Math.max(9, Math.round(cs * 0.38)), 800, 'center');
}
function drawTiles(fillOf) {
for (let v = 1; v < BN * BN; v += 1) {
const p = tilePos(v);
paintTile(String(v), p[0], p[1], fillOf(v), CS, BX, BY);
}
}
// 글자판 없이 배열만 그리는 작은 판 — 비교 장면용
function drawMini(b, n, ox, oy, size) {
const cs = size / n;
box(ox - 3, oy - 3, size + 6, size + 6, 8, PANEL, 1);
for (let i = 0; i < n * n; i += 1) {
const v = b[i];
if (v === 0) {
box(ox + (i % n) * cs + 1, oy + Math.floor(i / n) * cs + 1, cs - 2, cs - 2, 3, [40, 36, 62], 1);
continue;
}
const x = ox + (i % n) * cs + 1;
const y = oy + Math.floor(i / n) * cs + 1;
box(x, y, cs - 2, cs - 2, 3, tileHue(v, n), 1);
if (cs >= 20) {
text(String(v), x + (cs - 2) / 2, y + (cs - 2) / 2 + 1, INK, Math.round(cs * 0.4), 800, 'center');
}
}
}
// ── 상태 줄·낭독 문장
const SPK_PLACE = '{a}×{a} の盤、タイル {b} 枚のうち {c} 枚が元の位置にある';
const SPK_DONE = '{a}×{a} の盤が完成、タイル {b} 枚すべてが元の位置にある';
function speakBoard() {
const t = BN * BN - 1;
return isSolved(bd) ? fill(SPK_DONE, String(BN), String(t)) : fill(SPK_PLACE, String(BN), String(t), String(placedCount(bd)));
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
}
function takeOver() {
if (manual) {
return;
}
manual = true;
wake();
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 맞은 타일 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode) {
if (isSolved(bd)) {
return 1000;
}
return manual ? 100 + placedCount(bd) : sceneMode;
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say() {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
function advance(dt) {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
}
update(dt);
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow() {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) {
say();
}
}
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest() {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak) {
if (paused || still) {
sceneT += 1;
}
update(0);
showNow();
if (speak) {
say();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
say();
}
function restart() {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// N 한 걸음 — 시간표의 다음 일 하나를 지금 치른다. 시간표가 끝났으면 처음부터 다시 짠 뒤 첫 일
function stepOnce() {
takeOver();
if (evAt >= script.length) {
lap += 1;
begin();
}
if (evAt < script.length) {
const ev = script[evAt];
evAt += 1;
if (sceneT < ev.t) {
sceneT = ev.t;
}
ev.f();
}
afterInput(true);
}
function keyMove(dx, dy) {
// 방향키는 타일이 가는 방향 — 그 타일은 빈칸의 반대쪽에 있다. 빈칸이 가는 방향으로 바꿔 부른다
let d = 0;
for (let k = 0; k < 4; k += 1) {
if (DX[k] === -dx && DY[k] === -dy) {
d = k;
}
}
takeOver();
onDir(d);
afterInput(true);
}
function toCanvas(e) {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 빈칸 이웃 타일 이동, Enter·Space = 一時停止, N = 한 걸음, R = 처음부터
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'r' || k === 'R') {
restart();
}
else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
}
else if (k === 'ArrowLeft') {
e.preventDefault();
keyMove(-1, 0);
}
else if (k === 'ArrowRight') {
e.preventDefault();
keyMove(1, 0);
}
else if (k === 'ArrowUp') {
e.preventDefault();
keyMove(0, -1);
}
else if (k === 'ArrowDown') {
e.preventDefault();
keyMove(0, 1);
}
else if (keyHook !== null && keyHook(k)) {
e.preventDefault();
takeOver();
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
// 장면이 방향키·Enter·N·R 밖의 키를 받고 싶을 때 거는 갈고리 — 먹었으면 true
let keyHook = null;
pad.addEventListener('pointerdown', function (e) {
takeOver();
ptrDown = true;
try {
pad.setPointerCapture(e.pointerId);
}
catch (err) {
ptrDown = true;
}
const p = toCanvas(e);
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (!ptrDown) {
return;
}
const p = toCanvas(e);
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e) {
if (!ptrDown) {
return;
}
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 판 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// 누른 칸이 빈칸의 이웃이면 그 타일이 들어갈 빈칸의 방향(빈칸 기준), 아니면 -1
function dirOfCell(i) {
const z = bd.indexOf(0);
for (let d = 0; d < 4; d += 1) {
if (canStep(bd, BN, d) && z + DX[d] + DY[d] * BN === i) {
return d;
}
}
return -1;
}
// 01 — 빈칸과 같은 줄·같은 열의 타일을 누르면 사이 타일이 모두 한 칸씩 빈칸 쪽으로 간다. 이동은 배열 교환(rowChain)으로 먼저 정하고, 화면은 SLIDE_MS 보간으로 뒤따르되 시작은 STAGGER_MS 씩 어긋난다
const TILE_N = 4;
const SLIDE_MS = 140;
const STAGGER_MS = 30;
const SHUFFLE_01 = 40;
const LAP_S = 6.4;
const REST_S = 1.6;
const PX = 256;
const PW = 216;
let presses = 0;
let moved = 0;
let hist = [];
let lastMoved = [];
let ringCell = -1;
let ringT = -9;
const SPK_CHAIN = 'いま押した列でタイル {a} 枚が一マスずつ空きマスのほうへ動いた';
function recordPress(list, cell) {
lastMoved = list;
presses += 1;
moved += list.length;
hist.push(list.length);
if (hist.length > 10) {
hist.shift();
}
ringCell = cell;
ringT = sceneT;
}
function press(i) {
const list = rowChain(bd, BN, i);
if (list.length === 0) {
return;
}
const order = [];
for (let k = 0; k < list.length; k += 1) {
order[list[k]] = k;
}
retarget(function (v) {
const o = order[v];
return (o ? o : 0) * STAGGER_MS / 1000;
}, SLIDE_MS / 1000);
recordPress(list, i);
}
// 시연 — 빈칸과 같은 줄·열 타일 중 거리가 2 이상인 칸을 해시로 골라 누른다(없으면 이웃 칸)
function autoPress(k) {
const z = bd.indexOf(0);
const near = [];
const far = [];
for (let i = 0; i < BN * BN; i += 1) {
if (i === z) {
continue;
}
const sameRow = Math.floor(i / BN) === Math.floor(z / BN);
const sameCol = i % BN === z % BN;
if (!sameRow && !sameCol) {
continue;
}
const dist = Math.abs(Math.floor(i / BN) - Math.floor(z / BN)) + Math.abs((i % BN) - (z % BN));
if (dist >= 2) {
far.push(i);
}
else {
near.push(i);
}
}
const pool = far.length > 0 ? far : near;
press(pool[Math.floor(hash2(k, 7 + lap) * pool.length)]);
}
function pressAt(k) {
return function () { autoPress(k); };
}
function build() {
presses = 0;
moved = 0;
hist = [];
lastMoved = [];
ringCell = -1;
const b = solvedBoard(TILE_N);
applyMoves(b, TILE_N, walkMoves(TILE_N, SHUFFLE_01, 3 + lap));
setBoard(b, TILE_N);
for (let k = 0; k < 16; k += 1) {
script.push({ t: 0.25 + k * 0.36, f: pressAt(k) });
}
}
function update(dt) { if (dt < 0) {
return;
} }
function draw() {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoardBase();
drawTiles(function (v) { return tileHue(v, BN); });
const rk = (sceneT - ringT) / 0.4;
if (ringCell >= 0 && rk >= 0 && rk < 1) {
const cx = BX + ((ringCell % BN) + 0.5) * CS;
const cy = BY + (Math.floor(ringCell / BN) + 0.5) * CS;
ctx.beginPath();
ctx.arc(cx, cy, lerp(CS * 0.2, CS * 0.75, easeOut(rk)), 0, TAU);
ctx.strokeStyle = rgba(PAPER, 0.9 * (1 - rk));
ctx.lineWidth = 3;
ctx.stroke();
}
const s = CS * BN;
panel(PX, BY - 5, PW, s + 10);
text('一回で動かしたタイルの数', PX + 12, BY + 8, DIM, 11, 700, 'left');
// 막대 — 누른 횟수마다 한 줄. 막대 높이는 그 줄에서 밀린 타일 수
for (let i = 0; i < 10; i += 1) {
const x = PX + 12 + i * 20;
box(x, BY + 24, 16, 52, 4, [48, 44, 74], 1);
if (i < hist.length) {
const h = hist[i] * 16;
const hot = i === hist.length - 1;
box(x, BY + 76 - h, 16, h, 4, hot ? ROSE : MINT, 1);
text(String(hist[i]), x + 8, BY + 76 - h - 7, NEU, 10, 700, 'center');
}
}
text(fill('押した回数 {a} 回・動かした枚数 {b} 枚', String(presses), String(moved)), PX + 12, BY + 92, NEU, 11, 700, 'left');
// 시차 — 마지막 누름에서 타일마다 언제 출발해 얼마 동안 가는지
text('STAGGER_MS ' + STAGGER_MS, PX + 12, BY + 114, DIM, 11, 700, 'left');
const x0 = PX + 36;
const sc = 0.62;
const el = (sceneT - ringT) * 1000;
for (let k = 0; k < 3; k += 1) {
const y = BY + 128 + k * 24;
box(x0, y, 170, 18, 5, [48, 44, 74], 1);
if (k < lastMoved.length) {
paintTile(String(lastMoved[k]), 0, 0, tileHue(lastMoved[k], BN), 22, PX + 8, y - 2);
const a = k * STAGGER_MS * sc;
box(x0 + a, y + 2, SLIDE_MS * sc, 14, 4, k === 0 ? ROSE : SKY, 1);
}
}
if (lastMoved.length > 0 && el >= 0 && el < 260) {
const px = x0 + el * sc;
seg(px, BY + 124, px, BY + 124 + 3 * 24, PAPER, 0.9, 2);
}
hud('SLIDE_MS', String(SLIDE_MS), SLIDE_MS / 400);
}
function statusText() {
return fill('一回でタイル {a} 枚が移動、押した回数 {b}', String(lastMoved.length), String(presses));
}
function modeId() { return modeBase(Math.min(3, Math.floor(presses / 4))); }
function speakText() {
const m = lastMoved.length > 0 ? fill(SPK_CHAIN, String(lastMoved.length)) + '. ' : '';
return m + speakBoard();
}
function onDir(d) {
const z = bd.indexOf(0);
if (stepBlank(d, SLIDE_MS / 1000)) {
recordPress([bd[z]], bd.indexOf(0));
}
}
function onPtr(kind, x, y) {
if (kind !== 0) {
return;
}
const i = cellAt(x, y);
if (i >= 0) {
press(i);
}
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// RowChainSlide - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./RowChainSlide.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function RowChainSlide() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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 easeOut(k: number): number { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const SKY: RGB = [150, 192, 244];
const SUN: RGB = [250, 218, 146];
const NEU: RGB = [236, 230, 250];
const BGC: RGB = [46, 42, 68];
const PANEL: RGB = [62, 57, 92];
const DIM: RGB = [196, 190, 224];
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function hsl(h: number, s: number, l: number): RGB {
const a = s * Math.min(l, 1 - l);
const f = function (n: number): number {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a: number, b: number): number {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s: string, a: string, b?: string, c?: string): string {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v: number): string { return String(Math.round(v * 10) / 10); }
function group(v: number): string {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { o += ','; }
o += s.charAt(i);
}
return o;
}
// ── 퍼즐 모델 — 판은 번호 배열, 0 은 빈칸, 타일 v 의 제자리는 v-1 번 칸, 빈칸의 제자리는 맨 끝
// 빈칸이 움직이는 방향 d: 0 오른쪽 · 1 왼쪽 · 2 아래 · 3 위. 반대 방향은 d ^ 1
const DX = [1, -1, 0, 0];
const DY = [0, 0, 1, -1];
function solvedBoard(n: number): number[] {
const b: number[] = [];
for (let i = 1; i < n * n; i += 1) { b.push(i); }
b.push(0);
return b;
}
function canStep(b: number[], n: number, d: number): boolean {
const z = b.indexOf(0);
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
return c >= 0 && c < n && r >= 0 && r < n;
}
function blankStep(b: number[], n: number, d: number): boolean {
if (!canStep(b, n, d)) { return false; }
const z = b.indexOf(0);
const t = z + DX[d] + DY[d] * n;
b[z] = b[t];
b[t] = 0;
return true;
}
// 역전 — 번호를 한 줄로 늘어놓고 앞 번호가 뒤 번호보다 큰 쌍의 수(빈칸은 세지 않는다)
function inversions(b: number[]): number {
let c = 0;
for (let i = 0; i < b.length; i += 1) {
for (let j = i + 1; j < b.length; j += 1) {
if (b[i] !== 0 && b[j] !== 0 && b[i] > b[j]) { c += 1; }
}
}
return c;
}
// 빈칸이 아래서 몇 번째 줄인지(맨 아래 줄 = 1)
function blankRow(b: number[], n: number): number { return n - Math.floor(b.indexOf(0) / n); }
// 판정 합 — 짝수 폭은 역전 + 빈칸 줄, 홀수 폭은 역전만
function parity(b: number[], n: number): number { return n % 2 === 0 ? inversions(b) + blankRow(b, n) : inversions(b); }
function solvable(b: number[], n: number): boolean {
return n % 2 === 0 ? parity(b, n) % 2 === 1 : parity(b, n) % 2 === 0;
}
function manhattan(b: number[], n: number, i: number): number {
const v = b[i];
if (v === 0) { return 0; }
const h = v - 1;
return Math.abs((i % n) - (h % n)) + Math.abs(Math.floor(i / n) - Math.floor(h / n));
}
function sumH(b: number[], n: number): number {
let s = 0;
for (let i = 0; i < b.length; i += 1) { s += manhattan(b, n, i); }
return s;
}
function placedCount(b: number[]): number {
let c = 0;
for (let i = 0; i < b.length - 1; i += 1) { if (b[i] === i + 1) { c += 1; } }
return c;
}
function isSolved(b: number[]): boolean { return placedCount(b) === b.length - 1 && b[b.length - 1] === 0; }
// 걸어서 섞기 — 완성 판에서 시작해 빈칸을 steps 번 이웃 칸으로 옮긴다. 방금 온 칸으로 되돌아가는 수는 건너뛰고
// 어느 이웃으로 갈지는 정수 해시로 고른다. 반환은 빈칸이 움직인 방향의 목록
function walkMoves(n: number, steps: number, seed: number): number[] {
const b = solvedBoard(n);
const out: number[] = [];
let noBacktrack = -1;
for (let s = 0; s < steps; s += 1) {
const opts: number[] = [];
for (let d = 0; d < 4; d += 1) { if (d !== noBacktrack && canStep(b, n, d)) { opts.push(d); } }
const d = opts[Math.floor(hash2(s, seed) * opts.length)];
blankStep(b, n, d);
out.push(d);
noBacktrack = d ^ 1;
}
return out;
}
function applyMoves(b: number[], n: number, ms: number[]): void {
for (let i = 0; i < ms.length; i += 1) { blankStep(b, n, ms[i]); }
}
function reverseMoves(ms: number[]): number[] {
const o: number[] = [];
for (let i = ms.length - 1; i >= 0; i -= 1) { o.push(ms[i] ^ 1); }
return o;
}
// 줄째 밀기 — 누른 칸 i 가 빈칸과 같은 줄이면 사이 타일이 모두 한 칸씩 빈칸 쪽으로. 배열 교환으로 먼저 정하고
// 옮겨진 타일 번호를 빈칸에 가까운 순서로 돌려준다
function rowChain(b: number[], n: number, i: number): number[] {
const z = b.indexOf(0);
if (i === z) { return []; }
let step = 0;
if (Math.floor(i / n) === Math.floor(z / n)) { step = i > z ? 1 : -1; } else if (i % n === z % n) { step = i > z ? n : -n; } else { return []; }
const moved: number[] = [];
for (let p = z; p !== i; p += step) {
b[p] = b[p + step];
moved.push(b[p]);
}
b[i] = 0;
return moved;
}
// 반복 깊이 탐색 — 맨해튼 거리 합 h 를 길잡이로 깊이 우선 탐색하고, 수와 h 의 합이 한도(bound)를 넘으면 가지를 접는다.
// 한 바퀴가 끝나면 한도를 넘긴 값 중 가장 작은 것으로 올려 다시 돈다. 노드 수가 cap 에 닿으면 멈춘다
type Ida = { path: number[]; nodes: number; bounds: number[]; perBound: number[]; capHit: boolean };
function idaStar(start: number[], n: number, cap: number): Ida {
const b = start.slice();
const path: number[] = [];
let nodes = 0;
let capHit = false;
const INF = 9999;
let z = b.indexOf(0);
function dfs(g: number, h: number, bound: number, last: number): number {
nodes += 1;
if (nodes > cap) { capHit = true; return INF; }
const f = g + h;
if (f > bound) { return f; }
if (h === 0) { return -1; }
let min = INF;
for (let d = 0; d < 4; d += 1) {
if (last >= 0 && d === (last ^ 1)) { continue; }
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
if (c < 0 || c >= n || r < 0 || r >= n) { continue; }
const t = z + DX[d] + DY[d] * n;
const v = b[t];
const hv = Math.abs((t % n) - ((v - 1) % n)) + Math.abs(Math.floor(t / n) - Math.floor((v - 1) / n));
const hn = Math.abs((z % n) - ((v - 1) % n)) + Math.abs(Math.floor(z / n) - Math.floor((v - 1) / n));
b[z] = v;
b[t] = 0;
const oz = z;
z = t;
path.push(d);
const res = dfs(g + 1, h - hv + hn, bound, d);
if (res === -1) { return -1; }
path.pop();
z = oz;
b[t] = v;
b[z] = 0;
if (capHit) { return INF; }
if (res < min) { min = res; }
}
return min;
}
let bound = sumH(b, n);
const bounds: number[] = [];
const perBound: number[] = [];
while (bound < INF) {
const before = nodes;
bounds.push(bound);
const res = dfs(0, sumH(b, n), bound, -1);
perBound.push(nodes - before);
if (res === -1) { return { path: path.slice(), nodes: nodes, bounds: bounds, perBound: perBound, capHit: false }; }
if (capHit || res >= INF) { break; }
bound = res;
}
return { path: [], nodes: nodes, bounds: bounds, perBound: perBound, capHit: capHit };
}
// ── 판 화면 — 타일마다 지금 칸 → 목표 칸 보간. 배열은 먼저 바뀌고 화면이 뒤따른다
const BX = 24;
const BY = 44;
const BSZ = 216;
let bd: number[] = [];
let BN = 4;
let CS = 54;
type Tl = { fx: number; fy: number; tx: number; ty: number; t0: number; du: number };
let tl: Tl[] = [];
let dragV = -1;
let dragCx = 0;
let dragCy = 0;
function setBoard(b: number[], n: number): void {
bd = b.slice();
BN = n;
CS = BSZ / n;
tl = [];
dragV = -1;
for (let v = 0; v < n * n; v += 1) {
const i = bd.indexOf(v);
const c = i % n;
const r = Math.floor(i / n);
tl.push({ fx: c, fy: r, tx: c, ty: r, t0: 0, du: 0 });
}
}
let still = false;
function tilePos(v: number): number[] {
const t = tl[v];
if (v === dragV) { return [dragCx, dragCy]; }
if (still || t.du <= 0) { return [t.tx, t.ty]; }
const e = easeOut((sceneT - t.t0) / t.du);
return [lerp(t.fx, t.tx, e), lerp(t.fy, t.ty, e)];
}
// 배열이 바뀐 뒤 타일마다 목표 칸을 다시 읽는다 — 옮겨진 타일만 지금 화면 자리에서 출발한다
function retarget(delayOf: (v: number) => number, du: number): void {
for (let v = 1; v < BN * BN; v += 1) {
const i = bd.indexOf(v);
const c = i % BN;
const r = Math.floor(i / BN);
const t = tl[v];
if (c !== t.tx || r !== t.ty) {
const p = tilePos(v);
t.fx = p[0];
t.fy = p[1];
t.tx = c;
t.ty = r;
t.t0 = sceneT + delayOf(v);
t.du = du;
}
}
}
function stepBlank(d: number, du: number): boolean {
if (!blankStep(bd, BN, d)) { return false; }
retarget(function (): number { return 0; }, du);
return true;
}
function cellAt(x: number, y: number): number {
const c = Math.floor((x - BX) / CS);
const r = Math.floor((y - BY) / CS);
if (c < 0 || c >= BN || r < 0 || r >= BN) { return -1; }
return r * BN + c;
}
// ── 그리기 도구
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, c: RGB, a: number, w: number): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x: number, y: number, w: number, h: number): void { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name: string, val: string, frac: number): void {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// 판 바탕 — 받침과 빈 칸 홈
function drawBoardBase(): void {
const s = CS * BN;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < BN * BN; i += 1) {
box(BX + (i % BN) * CS + 2, BY + Math.floor(i / BN) * CS + 2, CS - 4, CS - 4, 8, [40, 36, 62], 1);
}
}
function tileHue(v: number, n: number): RGB {
return hsl(190 + 160 * ((v - 1) / (n * n - 2)), 0.55, 0.8);
}
function paintTile(label: string, cx: number, cy: number, c: RGB, cs: number, ox: number, oy: number): void {
const s = cs - 4;
const x = ox + cx * cs + 2;
const y = oy + cy * cs + 2;
box(x, y + 2, s, s, 8, [26, 23, 42], 0.5);
box(x, y, s, s, 8, c, 1);
box(x + 3, y + 2, s - 6, 3, 1.5, [255, 255, 255], 0.4);
text(label, x + s / 2, y + s / 2 + 1, INK, Math.max(9, Math.round(cs * 0.38)), 800, 'center');
}
function drawTiles(fillOf: (v: number) => RGB): void {
for (let v = 1; v < BN * BN; v += 1) {
const p = tilePos(v);
paintTile(String(v), p[0], p[1], fillOf(v), CS, BX, BY);
}
}
// 글자판 없이 배열만 그리는 작은 판 — 비교 장면용
function drawMini(b: number[], n: number, ox: number, oy: number, size: number): void {
const cs = size / n;
box(ox - 3, oy - 3, size + 6, size + 6, 8, PANEL, 1);
for (let i = 0; i < n * n; i += 1) {
const v = b[i];
if (v === 0) { box(ox + (i % n) * cs + 1, oy + Math.floor(i / n) * cs + 1, cs - 2, cs - 2, 3, [40, 36, 62], 1); continue; }
const x = ox + (i % n) * cs + 1;
const y = oy + Math.floor(i / n) * cs + 1;
box(x, y, cs - 2, cs - 2, 3, tileHue(v, n), 1);
if (cs >= 20) { text(String(v), x + (cs - 2) / 2, y + (cs - 2) / 2 + 1, INK, Math.round(cs * 0.4), 800, 'center'); }
}
}
// ── 상태 줄·낭독 문장
const SPK_PLACE = '{a}×{a} 판, 타일 {b}장 중 {c}장이 제자리에 있다';
const SPK_DONE = '{a}×{a} 판 완성, 타일 {b}장이 모두 제자리에 있다';
function speakBoard(): string {
const t = BN * BN - 1;
return isSolved(bd) ? fill(SPK_DONE, String(BN), String(t)) : fill(SPK_PLACE, String(BN), String(t), String(placedCount(bd)));
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
}
function takeOver(): void {
if (manual) { return; }
manual = true;
wake();
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 맞은 타일 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode: number): number {
if (isSolved(bd)) { return 1000; }
return manual ? 100 + placedCount(bd) : sceneMode;
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say(): void {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
function advance(dt: number): void {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
}
update(dt);
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow(): void {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) { say(); }
}
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest(): void {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak: boolean): void {
if (paused || still) { sceneT += 1; }
update(0);
showNow();
if (speak) { say(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
say();
}
function restart(): void {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// N 한 걸음 — 시간표의 다음 일 하나를 지금 치른다. 시간표가 끝났으면 처음부터 다시 짠 뒤 첫 일
function stepOnce(): void {
takeOver();
if (evAt >= script.length) { lap += 1; begin(); }
if (evAt < script.length) {
const ev = script[evAt];
evAt += 1;
if (sceneT < ev.t) { sceneT = ev.t; }
ev.f();
}
afterInput(true);
}
function keyMove(dx: number, dy: number): void {
// 방향키는 타일이 가는 방향 — 그 타일은 빈칸의 반대쪽에 있다. 빈칸이 가는 방향으로 바꿔 부른다
let d = 0;
for (let k = 0; k < 4; k += 1) { if (DX[k] === -dx && DY[k] === -dy) { d = k; } }
takeOver();
onDir(d);
afterInput(true);
}
function toCanvas(e: PointerEvent): number[] {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 빈칸 이웃 타일 이동, Enter·Space = 일시정지, N = 한 걸음, R = 처음부터
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'r' || k === 'R') {
restart();
} else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
} else if (k === 'ArrowLeft') {
e.preventDefault();
keyMove(-1, 0);
} else if (k === 'ArrowRight') {
e.preventDefault();
keyMove(1, 0);
} else if (k === 'ArrowUp') {
e.preventDefault();
keyMove(0, -1);
} else if (k === 'ArrowDown') {
e.preventDefault();
keyMove(0, 1);
} else if (keyHook !== null && keyHook(k)) {
e.preventDefault();
takeOver();
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
// 장면이 방향키·Enter·N·R 밖의 키를 받고 싶을 때 거는 갈고리 — 먹었으면 true
let keyHook: ((k: string) => boolean) | null = null;
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
ptrDown = true;
try { pad.setPointerCapture(e.pointerId); } catch (err) { ptrDown = true; }
const p = toCanvas(e);
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (!ptrDown) { return; }
const p = toCanvas(e);
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e: PointerEvent): void {
if (!ptrDown) { return; }
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 판 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// 누른 칸이 빈칸의 이웃이면 그 타일이 들어갈 빈칸의 방향(빈칸 기준), 아니면 -1
function dirOfCell(i: number): number {
const z = bd.indexOf(0);
for (let d = 0; d < 4; d += 1) { if (canStep(bd, BN, d) && z + DX[d] + DY[d] * BN === i) { return d; } }
return -1;
}
// 01 — 빈칸과 같은 줄·같은 열의 타일을 누르면 사이 타일이 모두 한 칸씩 빈칸 쪽으로 간다. 이동은 배열 교환(rowChain)으로 먼저 정하고, 화면은 SLIDE_MS 보간으로 뒤따르되 시작은 STAGGER_MS 씩 어긋난다
const TILE_N = 4;
const SLIDE_MS = 140;
const STAGGER_MS = 30;
const SHUFFLE_01 = 40;
const LAP_S = 6.4;
const REST_S = 1.6;
const PX = 256;
const PW = 216;
let presses = 0;
let moved = 0;
let hist: number[] = [];
let lastMoved: number[] = [];
let ringCell = -1;
let ringT = -9;
const SPK_CHAIN = '방금 누른 줄에서 타일 {a}장이 한 칸씩 빈칸 쪽으로 밀렸다';
function recordPress(list: number[], cell: number): void {
lastMoved = list;
presses += 1;
moved += list.length;
hist.push(list.length);
if (hist.length > 10) { hist.shift(); }
ringCell = cell;
ringT = sceneT;
}
function press(i: number): void {
const list = rowChain(bd, BN, i);
if (list.length === 0) { return; }
const order: number[] = [];
for (let k = 0; k < list.length; k += 1) { order[list[k]] = k; }
retarget(function (v: number): number {
const o = order[v];
return (o ? o : 0) * STAGGER_MS / 1000;
}, SLIDE_MS / 1000);
recordPress(list, i);
}
// 시연 — 빈칸과 같은 줄·열 타일 중 거리가 2 이상인 칸을 해시로 골라 누른다(없으면 이웃 칸)
function autoPress(k: number): void {
const z = bd.indexOf(0);
const near: number[] = [];
const far: number[] = [];
for (let i = 0; i < BN * BN; i += 1) {
if (i === z) { continue; }
const sameRow = Math.floor(i / BN) === Math.floor(z / BN);
const sameCol = i % BN === z % BN;
if (!sameRow && !sameCol) { continue; }
const dist = Math.abs(Math.floor(i / BN) - Math.floor(z / BN)) + Math.abs((i % BN) - (z % BN));
if (dist >= 2) { far.push(i); } else { near.push(i); }
}
const pool = far.length > 0 ? far : near;
press(pool[Math.floor(hash2(k, 7 + lap) * pool.length)]);
}
function pressAt(k: number): () => void {
return function (): void { autoPress(k); };
}
function build(): void {
presses = 0;
moved = 0;
hist = [];
lastMoved = [];
ringCell = -1;
const b = solvedBoard(TILE_N);
applyMoves(b, TILE_N, walkMoves(TILE_N, SHUFFLE_01, 3 + lap));
setBoard(b, TILE_N);
for (let k = 0; k < 16; k += 1) { script.push({ t: 0.25 + k * 0.36, f: pressAt(k) }); }
}
function update(dt: number): void { if (dt < 0) { return; } }
function draw(): void {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoardBase();
drawTiles(function (v: number): RGB { return tileHue(v, BN); });
const rk = (sceneT - ringT) / 0.4;
if (ringCell >= 0 && rk >= 0 && rk < 1) {
const cx = BX + ((ringCell % BN) + 0.5) * CS;
const cy = BY + (Math.floor(ringCell / BN) + 0.5) * CS;
ctx.beginPath();
ctx.arc(cx, cy, lerp(CS * 0.2, CS * 0.75, easeOut(rk)), 0, TAU);
ctx.strokeStyle = rgba(PAPER, 0.9 * (1 - rk));
ctx.lineWidth = 3;
ctx.stroke();
}
const s = CS * BN;
panel(PX, BY - 5, PW, s + 10);
text('한 번에 옮긴 타일 수', PX + 12, BY + 8, DIM, 11, 700, 'left');
// 막대 — 누른 횟수마다 한 줄. 막대 높이는 그 줄에서 밀린 타일 수
for (let i = 0; i < 10; i += 1) {
const x = PX + 12 + i * 20;
box(x, BY + 24, 16, 52, 4, [48, 44, 74], 1);
if (i < hist.length) {
const h = hist[i] * 16;
const hot = i === hist.length - 1;
box(x, BY + 76 - h, 16, h, 4, hot ? ROSE : MINT, 1);
text(String(hist[i]), x + 8, BY + 76 - h - 7, NEU, 10, 700, 'center');
}
}
text(fill('누름 {a}번 · 이동 {b}장', String(presses), String(moved)), PX + 12, BY + 92, NEU, 11, 700, 'left');
// 시차 — 마지막 누름에서 타일마다 언제 출발해 얼마 동안 가는지
text('STAGGER_MS ' + STAGGER_MS, PX + 12, BY + 114, DIM, 11, 700, 'left');
const x0 = PX + 36;
const sc = 0.62;
const el = (sceneT - ringT) * 1000;
for (let k = 0; k < 3; k += 1) {
const y = BY + 128 + k * 24;
box(x0, y, 170, 18, 5, [48, 44, 74], 1);
if (k < lastMoved.length) {
paintTile(String(lastMoved[k]), 0, 0, tileHue(lastMoved[k], BN), 22, PX + 8, y - 2);
const a = k * STAGGER_MS * sc;
box(x0 + a, y + 2, SLIDE_MS * sc, 14, 4, k === 0 ? ROSE : SKY, 1);
}
}
if (lastMoved.length > 0 && el >= 0 && el < 260) {
const px = x0 + el * sc;
seg(px, BY + 124, px, BY + 124 + 3 * 24, PAPER, 0.9, 2);
}
hud('SLIDE_MS', String(SLIDE_MS), SLIDE_MS / 400);
}
function statusText(): string {
return fill('한 번에 타일 {a}장 이동, 누른 횟수 {b}', String(lastMoved.length), String(presses));
}
function modeId(): number { return modeBase(Math.min(3, Math.floor(presses / 4))); }
function speakText(): string {
const m = lastMoved.length > 0 ? fill(SPK_CHAIN, String(lastMoved.length)) + '. ' : '';
return m + speakBoard();
}
function onDir(d: number): void {
const z = bd.indexOf(0);
if (stepBlank(d, SLIDE_MS / 1000)) { recordPress([bd[z]], bd.indexOf(0)); }
}
function onPtr(kind: number, x: number, y: number): void {
if (kind !== 0) { return; }
const i = cellAt(x, y);
if (i >= 0) { press(i); }
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'슬라이딩 퍼즐 시연 무대 — 방향키로 빈칸 옆 타일 이동, 타일 누르기도 가능, Enter 또는 Space 로 일시정지, N 으로 한 걸음, R 로 처음부터'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'어두운 자두색 바탕에 4×4 슬라이딩 퍼즐 판이 있다. 빈칸과 같은 줄이나 같은 열의 타일을 누르면 그 사이 타일이 모두 한 칸씩 빈칸 쪽으로 140밀리초 동안 움직이고 시작은 30밀리초씩 어긋나 차례로 따라간다. 오른쪽 패널에는 한 번에 옮긴 타일 수 막대 열 개, 누른 횟수와 옮긴 타일 합계, 마지막 누름의 타일별 출발 시각과 이동 구간이 나온다. 아래 눈금은 상수 이름과 지금 값을 보이고, 움직임 줄이기에서는 타일이 바로 자리를 바꾸는 판 한 장만 그려진다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'슬라이딩 퍼즐 장면'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}01 · 空きマスへ列ごと動くタイルの連鎖 — 計測 累積面積 27.141% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function rowChain(b, n, i) {
const z = b.indexOf(0);
if (i === z) {
return [];
}
let step = 0;
if (Math.floor(i / n) === Math.floor(z / n)) {
step = i > z ? 1 : -1;
}
else if (i % n === z % n) {
step = i > z ? n : -n;
}
else {
return [];
}
const moved = [];
for (let p = z; p !== i; p += step) {
b[p] = b[p + step];
moved.push(b[p]);
}
b[i] = 0;
return moved;
}
rowChain が返した動いたタイルの順に開始時刻をずらすので、三枚を押すと最後のタイルが動き終わるまでは SLIDE_MS + 2 × STAGGER_MS で200msです。
02ひっくり返った組の数で解ける盤を分ける
タイルの番号を一列に並べ、前の番号が後ろの番号より大きい組(逆転)の数を数えると、4×4の盤では逆転の数と空きマスが下から何行目かの合計が奇数のときだけ解けます。この判定を実行して確かめたところ、3×3の完成盤から幅優先探索で行き着く181,440の盤はどれも solvable が真で、二枚だけ入れ替えた盤から行き着く181,440はどれも偽、二つの集合の重なりは0でした。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="スライドパズルの舞台 — 矢印キーで空きマスの隣のタイルを動かす、タイルを押しても動く、EnterかSpaceで一時停止、Nで一歩、Rで最初から">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="4×4の盤の横のパネルに、逆転の数、下から数えた空きマスの行、その合計が出る。合計が奇数なら盤の縁がミント色で解ける、偶数なら薔薇色で解けないと表示される。盤を一列に読んだ番号のチップが十六個あり、二枚のタイルを入れ替えるとその二つのチップが薔薇色になって判定が反転する。一番下の点の列は一手ごとの合計の偶奇で、規則どおりの手では一度も変わらない。下の目盛りは定数名と今の値を示し、動きを減らす設定ではタイルがすぐ入れ替わる盤一枚だけを描きます"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">スライドパズルの場面</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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;
}
// 무대 상자 — 눌러서 한 번 깨뜨리는 곳(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause {
padding: $sp-1 $sp-3;
border: 2px solid $subject-cream;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 스크린리더에만 읽히는 낭독 문장 — 모드가 바뀔 때만 쓴다
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-ink;
color: $subject-cream;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면에만 있고 aria-live 는 늘 off
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 100px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 글리치 없는 원문 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
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;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause {
padding: 4px 12px;
border: 2px solid #fff7e6;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 100px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function easeOut(k) { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const SKY = [150, 192, 244];
const SUN = [250, 218, 146];
const NEU = [236, 230, 250];
const BGC = [46, 42, 68];
const PANEL = [62, 57, 92];
const DIM = [196, 190, 224];
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function hsl(h, s, l) {
const a = s * Math.min(l, 1 - l);
const f = function (n) {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a, b) {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s, a, b, c) {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v) { return String(Math.round(v * 10) / 10); }
function group(v) {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
o += ',';
}
o += s.charAt(i);
}
return o;
}
// ── 퍼즐 모델 — 판은 번호 배열, 0 은 빈칸, 타일 v 의 제자리는 v-1 번 칸, 빈칸의 제자리는 맨 끝
// 빈칸이 움직이는 방향 d: 0 오른쪽 · 1 왼쪽 · 2 아래 · 3 위. 반대 방향은 d ^ 1
const DX = [1, -1, 0, 0];
const DY = [0, 0, 1, -1];
function solvedBoard(n) {
const b = [];
for (let i = 1; i < n * n; i += 1) {
b.push(i);
}
b.push(0);
return b;
}
function canStep(b, n, d) {
const z = b.indexOf(0);
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
return c >= 0 && c < n && r >= 0 && r < n;
}
function blankStep(b, n, d) {
if (!canStep(b, n, d)) {
return false;
}
const z = b.indexOf(0);
const t = z + DX[d] + DY[d] * n;
b[z] = b[t];
b[t] = 0;
return true;
}
// 역전 — 번호를 한 줄로 늘어놓고 앞 번호가 뒤 번호보다 큰 쌍의 수(빈칸은 세지 않는다)
function inversions(b) {
let c = 0;
for (let i = 0; i < b.length; i += 1) {
for (let j = i + 1; j < b.length; j += 1) {
if (b[i] !== 0 && b[j] !== 0 && b[i] > b[j]) {
c += 1;
}
}
}
return c;
}
// 빈칸이 아래서 몇 번째 줄인지(맨 아래 줄 = 1)
function blankRow(b, n) { return n - Math.floor(b.indexOf(0) / n); }
// 판정 합 — 짝수 폭은 역전 + 빈칸 줄, 홀수 폭은 역전만
function parity(b, n) { return n % 2 === 0 ? inversions(b) + blankRow(b, n) : inversions(b); }
function solvable(b, n) {
return n % 2 === 0 ? parity(b, n) % 2 === 1 : parity(b, n) % 2 === 0;
}
function manhattan(b, n, i) {
const v = b[i];
if (v === 0) {
return 0;
}
const h = v - 1;
return Math.abs((i % n) - (h % n)) + Math.abs(Math.floor(i / n) - Math.floor(h / n));
}
function sumH(b, n) {
let s = 0;
for (let i = 0; i < b.length; i += 1) {
s += manhattan(b, n, i);
}
return s;
}
function placedCount(b) {
let c = 0;
for (let i = 0; i < b.length - 1; i += 1) {
if (b[i] === i + 1) {
c += 1;
}
}
return c;
}
function isSolved(b) { return placedCount(b) === b.length - 1 && b[b.length - 1] === 0; }
// 歩いて混ぜる — 完成 판에서 시작해 빈칸을 steps 번 이웃 칸으로 옮긴다. 방금 온 칸으로 되돌아가는 수는 건너뛰고
// 어느 이웃으로 갈지는 정수 해시로 고른다. 반환은 빈칸이 움직인 방향의 목록
function walkMoves(n, steps, seed) {
const b = solvedBoard(n);
const out = [];
let noBacktrack = -1;
for (let s = 0; s < steps; s += 1) {
const opts = [];
for (let d = 0; d < 4; d += 1) {
if (d !== noBacktrack && canStep(b, n, d)) {
opts.push(d);
}
}
const d = opts[Math.floor(hash2(s, seed) * opts.length)];
blankStep(b, n, d);
out.push(d);
noBacktrack = d ^ 1;
}
return out;
}
function applyMoves(b, n, ms) {
for (let i = 0; i < ms.length; i += 1) {
blankStep(b, n, ms[i]);
}
}
function reverseMoves(ms) {
const o = [];
for (let i = ms.length - 1; i >= 0; i -= 1) {
o.push(ms[i] ^ 1);
}
return o;
}
// 줄째 밀기 — 누른 칸 i 가 빈칸과 같은 줄이면 사이 타일이 모두 한 칸씩 빈칸 쪽으로. 배열 교환으로 먼저 정하고
// 옮겨진 타일 번호를 빈칸에 가까운 순서로 돌려준다
function rowChain(b, n, i) {
const z = b.indexOf(0);
if (i === z) {
return [];
}
let step = 0;
if (Math.floor(i / n) === Math.floor(z / n)) {
step = i > z ? 1 : -1;
}
else if (i % n === z % n) {
step = i > z ? n : -n;
}
else {
return [];
}
const moved = [];
for (let p = z; p !== i; p += step) {
b[p] = b[p + step];
moved.push(b[p]);
}
b[i] = 0;
return moved;
}
function idaStar(start, n, cap) {
const b = start.slice();
const path = [];
let nodes = 0;
let capHit = false;
const INF = 9999;
let z = b.indexOf(0);
function dfs(g, h, bound, last) {
nodes += 1;
if (nodes > cap) {
capHit = true;
return INF;
}
const f = g + h;
if (f > bound) {
return f;
}
if (h === 0) {
return -1;
}
let min = INF;
for (let d = 0; d < 4; d += 1) {
if (last >= 0 && d === (last ^ 1)) {
continue;
}
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
if (c < 0 || c >= n || r < 0 || r >= n) {
continue;
}
const t = z + DX[d] + DY[d] * n;
const v = b[t];
const hv = Math.abs((t % n) - ((v - 1) % n)) + Math.abs(Math.floor(t / n) - Math.floor((v - 1) / n));
const hn = Math.abs((z % n) - ((v - 1) % n)) + Math.abs(Math.floor(z / n) - Math.floor((v - 1) / n));
b[z] = v;
b[t] = 0;
const oz = z;
z = t;
path.push(d);
const res = dfs(g + 1, h - hv + hn, bound, d);
if (res === -1) {
return -1;
}
path.pop();
z = oz;
b[t] = v;
b[z] = 0;
if (capHit) {
return INF;
}
if (res < min) {
min = res;
}
}
return min;
}
let bound = sumH(b, n);
const bounds = [];
const perBound = [];
while (bound < INF) {
const before = nodes;
bounds.push(bound);
const res = dfs(0, sumH(b, n), bound, -1);
perBound.push(nodes - before);
if (res === -1) {
return { path: path.slice(), nodes: nodes, bounds: bounds, perBound: perBound, capHit: false };
}
if (capHit || res >= INF) {
break;
}
bound = res;
}
return { path: [], nodes: nodes, bounds: bounds, perBound: perBound, capHit: capHit };
}
// ── 판 화면 — 타일마다 지금 칸 → 목표 칸 보간. 배열은 먼저 바뀌고 화면이 뒤따른다
const BX = 24;
const BY = 44;
const BSZ = 216;
let bd = [];
let BN = 4;
let CS = 54;
let tl = [];
let dragV = -1;
let dragCx = 0;
let dragCy = 0;
function setBoard(b, n) {
bd = b.slice();
BN = n;
CS = BSZ / n;
tl = [];
dragV = -1;
for (let v = 0; v < n * n; v += 1) {
const i = bd.indexOf(v);
const c = i % n;
const r = Math.floor(i / n);
tl.push({ fx: c, fy: r, tx: c, ty: r, t0: 0, du: 0 });
}
}
let still = false;
function tilePos(v) {
const t = tl[v];
if (v === dragV) {
return [dragCx, dragCy];
}
if (still || t.du <= 0) {
return [t.tx, t.ty];
}
const e = easeOut((sceneT - t.t0) / t.du);
return [lerp(t.fx, t.tx, e), lerp(t.fy, t.ty, e)];
}
// 배열이 바뀐 뒤 타일마다 목표 칸을 다시 읽는다 — 옮겨진 타일만 지금 화면 자리에서 출발한다
function retarget(delayOf, du) {
for (let v = 1; v < BN * BN; v += 1) {
const i = bd.indexOf(v);
const c = i % BN;
const r = Math.floor(i / BN);
const t = tl[v];
if (c !== t.tx || r !== t.ty) {
const p = tilePos(v);
t.fx = p[0];
t.fy = p[1];
t.tx = c;
t.ty = r;
t.t0 = sceneT + delayOf(v);
t.du = du;
}
}
}
function stepBlank(d, du) {
if (!blankStep(bd, BN, d)) {
return false;
}
retarget(function () { return 0; }, du);
return true;
}
function cellAt(x, y) {
const c = Math.floor((x - BX) / CS);
const r = Math.floor((y - BY) / CS);
if (c < 0 || c >= BN || r < 0 || r >= BN) {
return -1;
}
return r * BN + c;
}
// ── 그리기 도구
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function disc(x, y, r, c, a) {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, c, a, w) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s, x, y, c, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x, y, w, h) { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name, val, frac) {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// 판 바탕 — 받침과 빈 칸 홈
function drawBoardBase() {
const s = CS * BN;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < BN * BN; i += 1) {
box(BX + (i % BN) * CS + 2, BY + Math.floor(i / BN) * CS + 2, CS - 4, CS - 4, 8, [40, 36, 62], 1);
}
}
function tileHue(v, n) {
return hsl(190 + 160 * ((v - 1) / (n * n - 2)), 0.55, 0.8);
}
function paintTile(label, cx, cy, c, cs, ox, oy) {
const s = cs - 4;
const x = ox + cx * cs + 2;
const y = oy + cy * cs + 2;
box(x, y + 2, s, s, 8, [26, 23, 42], 0.5);
box(x, y, s, s, 8, c, 1);
box(x + 3, y + 2, s - 6, 3, 1.5, [255, 255, 255], 0.4);
text(label, x + s / 2, y + s / 2 + 1, INK, Math.max(9, Math.round(cs * 0.38)), 800, 'center');
}
function drawTiles(fillOf) {
for (let v = 1; v < BN * BN; v += 1) {
const p = tilePos(v);
paintTile(String(v), p[0], p[1], fillOf(v), CS, BX, BY);
}
}
// 글자판 없이 배열만 그리는 작은 판 — 비교 장면용
function drawMini(b, n, ox, oy, size) {
const cs = size / n;
box(ox - 3, oy - 3, size + 6, size + 6, 8, PANEL, 1);
for (let i = 0; i < n * n; i += 1) {
const v = b[i];
if (v === 0) {
box(ox + (i % n) * cs + 1, oy + Math.floor(i / n) * cs + 1, cs - 2, cs - 2, 3, [40, 36, 62], 1);
continue;
}
const x = ox + (i % n) * cs + 1;
const y = oy + Math.floor(i / n) * cs + 1;
box(x, y, cs - 2, cs - 2, 3, tileHue(v, n), 1);
if (cs >= 20) {
text(String(v), x + (cs - 2) / 2, y + (cs - 2) / 2 + 1, INK, Math.round(cs * 0.4), 800, 'center');
}
}
}
// ── 상태 줄·낭독 문장
const SPK_PLACE = '{a}×{a} の盤、タイル {b} 枚のうち {c} 枚が元の位置にある';
const SPK_DONE = '{a}×{a} の盤が完成、タイル {b} 枚すべてが元の位置にある';
function speakBoard() {
const t = BN * BN - 1;
return isSolved(bd) ? fill(SPK_DONE, String(BN), String(t)) : fill(SPK_PLACE, String(BN), String(t), String(placedCount(bd)));
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
}
function takeOver() {
if (manual) {
return;
}
manual = true;
wake();
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 맞은 타일 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode) {
if (isSolved(bd)) {
return 1000;
}
return manual ? 100 + placedCount(bd) : sceneMode;
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say() {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
function advance(dt) {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
}
update(dt);
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow() {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) {
say();
}
}
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest() {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak) {
if (paused || still) {
sceneT += 1;
}
update(0);
showNow();
if (speak) {
say();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
say();
}
function restart() {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// N 한 걸음 — 시간표의 다음 일 하나를 지금 치른다. 시간표가 끝났으면 처음부터 다시 짠 뒤 첫 일
function stepOnce() {
takeOver();
if (evAt >= script.length) {
lap += 1;
begin();
}
if (evAt < script.length) {
const ev = script[evAt];
evAt += 1;
if (sceneT < ev.t) {
sceneT = ev.t;
}
ev.f();
}
afterInput(true);
}
function keyMove(dx, dy) {
// 방향키는 타일이 가는 방향 — 그 타일은 빈칸의 반대쪽에 있다. 빈칸이 가는 방향으로 바꿔 부른다
let d = 0;
for (let k = 0; k < 4; k += 1) {
if (DX[k] === -dx && DY[k] === -dy) {
d = k;
}
}
takeOver();
onDir(d);
afterInput(true);
}
function toCanvas(e) {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 빈칸 이웃 타일 이동, Enter·Space = 一時停止, N = 한 걸음, R = 처음부터
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'r' || k === 'R') {
restart();
}
else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
}
else if (k === 'ArrowLeft') {
e.preventDefault();
keyMove(-1, 0);
}
else if (k === 'ArrowRight') {
e.preventDefault();
keyMove(1, 0);
}
else if (k === 'ArrowUp') {
e.preventDefault();
keyMove(0, -1);
}
else if (k === 'ArrowDown') {
e.preventDefault();
keyMove(0, 1);
}
else if (keyHook !== null && keyHook(k)) {
e.preventDefault();
takeOver();
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
// 장면이 방향키·Enter·N·R 밖의 키를 받고 싶을 때 거는 갈고리 — 먹었으면 true
let keyHook = null;
pad.addEventListener('pointerdown', function (e) {
takeOver();
ptrDown = true;
try {
pad.setPointerCapture(e.pointerId);
}
catch (err) {
ptrDown = true;
}
const p = toCanvas(e);
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (!ptrDown) {
return;
}
const p = toCanvas(e);
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e) {
if (!ptrDown) {
return;
}
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 판 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// 누른 칸이 빈칸의 이웃이면 그 타일이 들어갈 빈칸의 방향(빈칸 기준), 아니면 -1
function dirOfCell(i) {
const z = bd.indexOf(0);
for (let d = 0; d < 4; d += 1) {
if (canStep(bd, BN, d) && z + DX[d] + DY[d] * BN === i) {
return d;
}
}
return -1;
}
// 02 — 타일 번호를 한 줄로 늘어놓고 역전(앞 번호가 뒤 번호보다 큰 쌍)을 센다. 4×4 판은 역전 + 빈칸 줄(아래서 몇 번째)이 홀수일 때만 풀 수 있다. 합법 이동은 그 홀짝을 바꾸지 못한다
const TILE_N = 4;
const SHUFFLE_02 = 40;
const SLIDE_S = 0.13;
const LAP_S = 5.8;
const REST_S = 2.2;
const PX = 256;
const PW = 216;
let parHist = [];
let swapped = false;
let swapA = 0;
let swapB = 0;
let flashT = -9;
let lastD = -1;
const STAT_OK = '逆転 {a} 組、合計 {b}: 解ける盤';
const STAT_NO = '逆転 {a} 組、合計 {b}: 解けない盤';
function note() {
parHist.push(parity(bd, BN) % 2);
if (parHist.length > 16) {
parHist.shift();
}
}
// 시연 이동 — 방금 온 칸으로 되돌아가지 않는 방향 중 해시로 하나
function walkOne(k) {
const opts = [];
for (let d = 0; d < 4; d += 1) {
if (d !== (lastD ^ 1) && canStep(bd, BN, d)) {
opts.push(d);
}
}
const d = opts[Math.floor(hash2(k, 21 + lap) * opts.length)];
if (stepBlank(d, SLIDE_S)) {
lastD = d;
note();
}
}
function walkAt(k) {
return function () { walkOne(k); };
}
// 두 타일의 자리만 맞바꾼다 — 합법 이동이 아니라 판을 뜯어 고친 것
function swapTiles() {
const a = 1 + Math.floor(hash2(3, 17 + lap) * 15);
let b = 1 + Math.floor(hash2(4, 17 + lap) * 15);
if (b === a) {
b = a % 15 + 1;
}
const i = bd.indexOf(a);
const j = bd.indexOf(b);
bd[i] = b;
bd[j] = a;
retarget(function () { return 0; }, 0.4);
swapped = true;
swapA = a;
swapB = b;
flashT = sceneT;
note();
}
function build() {
parHist = [];
swapped = false;
swapA = 0;
swapB = 0;
flashT = -9;
lastD = -1;
const b = solvedBoard(TILE_N);
applyMoves(b, TILE_N, walkMoves(TILE_N, SHUFFLE_02, 9 + lap));
setBoard(b, TILE_N);
note();
for (let k = 0; k < 7; k += 1) {
script.push({ t: 0.25 + k * 0.2, f: walkAt(k) });
}
script.push({ t: 1.8, f: swapTiles });
for (let k = 7; k < 15; k += 1) {
script.push({ t: 2.5 + (k - 7) * 0.22, f: walkAt(k) });
}
}
function update(dt) { if (dt < 0) {
return;
} }
function draw() {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoardBase();
const ok = solvable(bd, BN);
const fk = (sceneT - flashT) / 1.0;
drawTiles(function (v) { return tileHue(v, BN); });
// 판 테두리 — 풀 수 있으면 민트, 없으면 장미
const s = CS * BN;
ctx.beginPath();
ctx.roundRect(BX - 5, BY - 5, s + 10, s + 10, 12);
ctx.strokeStyle = rgba(ok ? MINT : ROSE, 1);
ctx.lineWidth = 3;
ctx.stroke();
if (swapped && fk >= 0 && fk < 1) {
const ids = [swapA, swapB];
for (let q = 0; q < 2; q += 1) {
const p = tilePos(ids[q]);
ctx.beginPath();
ctx.roundRect(BX + p[0] * CS, BY + p[1] * CS, CS, CS, 10);
ctx.strokeStyle = rgba(PAPER, 1 - fk);
ctx.lineWidth = 3;
ctx.stroke();
}
}
panel(PX, BY - 5, PW, s + 10);
const inv = inversions(bd);
const br = blankRow(bd, BN);
const sum = parity(bd, BN);
text('inversions', PX + 12, BY + 12, DIM, 11, 700, 'left');
text(String(inv), PX + PW - 12, BY + 12, NEU, 15, 800, 'right');
text('blankRow', PX + 12, BY + 34, DIM, 11, 700, 'left');
text(String(br), PX + PW - 12, BY + 34, NEU, 15, 800, 'right');
text('parity', PX + 12, BY + 56, DIM, 11, 700, 'left');
text(inv + ' + ' + br + ' = ' + sum, PX + PW - 12, BY + 56, NEU, 15, 800, 'right');
box(PX + 12, BY + 72, PW - 24, 22, 11, ok ? MINT : ROSE, 1);
text(ok ? '解ける' : '解けない', PX + PW / 2, BY + 83.5, INK, 12, 800, 'center');
// 번호 줄 — 판을 한 줄로 읽은 순서. 맞바꾼 두 타일은 장미 칩
for (let i = 0; i < BN * BN; i += 1) {
const x = PX + 12 + (i % 8) * 24.5;
const y = BY + 104 + Math.floor(i / 8) * 20;
const v = bd[i];
const hot = swapped && (v === swapA || v === swapB);
box(x, y, 22, 17, 4, hot ? ROSE : [88, 82, 122], 1);
text(v === 0 ? '.' : String(v), x + 11, y + 9, hot ? INK : NEU, 10, 800, 'center');
}
// 홀짝 기록 — 이동할 때마다 합의 홀짝. 이동으로는 줄이 한 번도 바뀌지 않는다
const yOdd = BY + 166;
const yEven = BY + 196;
seg(PX + 26, yOdd, PX + PW - 12, yOdd, [88, 82, 122], 1, 1);
seg(PX + 26, yEven, PX + PW - 12, yEven, [88, 82, 122], 1, 1);
text('odd', PX + 12, yOdd, DIM, 9, 700, 'center');
text('even', PX + 12, yEven, DIM, 9, 700, 'center');
for (let i = 0; i < parHist.length; i += 1) {
disc(PX + 36 + i * 10.6, parHist[i] === 1 ? yOdd : yEven, 3.6, parHist[i] === 1 ? MINT : ROSE, 1);
}
text('動かしても偶奇は変わらない', PX + PW / 2, BY + 218, DIM, 10, 700, 'center');
hud('parity', String(sum), sum / 60);
}
function statusText() {
return fill(solvable(bd, BN) ? STAT_OK : STAT_NO, String(inversions(bd)), String(parity(bd, BN)));
}
function modeId() { return modeBase(swapped ? 1 : 0); }
function speakText() { return statusText() + '. ' + speakBoard(); }
function onDir(d) {
if (stepBlank(d, SLIDE_S)) {
lastD = d;
note();
}
}
function onPtr(kind, x, y) {
if (kind !== 0) {
return;
}
const d = dirOfCell(cellAt(x, y));
if (d >= 0) {
onDir(d);
}
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// ParitySolvableCheck - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./ParitySolvableCheck.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ParitySolvableCheck() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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 easeOut(k: number): number { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const SKY: RGB = [150, 192, 244];
const SUN: RGB = [250, 218, 146];
const NEU: RGB = [236, 230, 250];
const BGC: RGB = [46, 42, 68];
const PANEL: RGB = [62, 57, 92];
const DIM: RGB = [196, 190, 224];
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function hsl(h: number, s: number, l: number): RGB {
const a = s * Math.min(l, 1 - l);
const f = function (n: number): number {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a: number, b: number): number {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s: string, a: string, b?: string, c?: string): string {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v: number): string { return String(Math.round(v * 10) / 10); }
function group(v: number): string {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { o += ','; }
o += s.charAt(i);
}
return o;
}
// ── 퍼즐 모델 — 판은 번호 배열, 0 은 빈칸, 타일 v 의 제자리는 v-1 번 칸, 빈칸의 제자리는 맨 끝
// 빈칸이 움직이는 방향 d: 0 오른쪽 · 1 왼쪽 · 2 아래 · 3 위. 반대 방향은 d ^ 1
const DX = [1, -1, 0, 0];
const DY = [0, 0, 1, -1];
function solvedBoard(n: number): number[] {
const b: number[] = [];
for (let i = 1; i < n * n; i += 1) { b.push(i); }
b.push(0);
return b;
}
function canStep(b: number[], n: number, d: number): boolean {
const z = b.indexOf(0);
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
return c >= 0 && c < n && r >= 0 && r < n;
}
function blankStep(b: number[], n: number, d: number): boolean {
if (!canStep(b, n, d)) { return false; }
const z = b.indexOf(0);
const t = z + DX[d] + DY[d] * n;
b[z] = b[t];
b[t] = 0;
return true;
}
// 역전 — 번호를 한 줄로 늘어놓고 앞 번호가 뒤 번호보다 큰 쌍의 수(빈칸은 세지 않는다)
function inversions(b: number[]): number {
let c = 0;
for (let i = 0; i < b.length; i += 1) {
for (let j = i + 1; j < b.length; j += 1) {
if (b[i] !== 0 && b[j] !== 0 && b[i] > b[j]) { c += 1; }
}
}
return c;
}
// 빈칸이 아래서 몇 번째 줄인지(맨 아래 줄 = 1)
function blankRow(b: number[], n: number): number { return n - Math.floor(b.indexOf(0) / n); }
// 판정 합 — 짝수 폭은 역전 + 빈칸 줄, 홀수 폭은 역전만
function parity(b: number[], n: number): number { return n % 2 === 0 ? inversions(b) + blankRow(b, n) : inversions(b); }
function solvable(b: number[], n: number): boolean {
return n % 2 === 0 ? parity(b, n) % 2 === 1 : parity(b, n) % 2 === 0;
}
function manhattan(b: number[], n: number, i: number): number {
const v = b[i];
if (v === 0) { return 0; }
const h = v - 1;
return Math.abs((i % n) - (h % n)) + Math.abs(Math.floor(i / n) - Math.floor(h / n));
}
function sumH(b: number[], n: number): number {
let s = 0;
for (let i = 0; i < b.length; i += 1) { s += manhattan(b, n, i); }
return s;
}
function placedCount(b: number[]): number {
let c = 0;
for (let i = 0; i < b.length - 1; i += 1) { if (b[i] === i + 1) { c += 1; } }
return c;
}
function isSolved(b: number[]): boolean { return placedCount(b) === b.length - 1 && b[b.length - 1] === 0; }
// 걸어서 섞기 — 완성 판에서 시작해 빈칸을 steps 번 이웃 칸으로 옮긴다. 방금 온 칸으로 되돌아가는 수는 건너뛰고
// 어느 이웃으로 갈지는 정수 해시로 고른다. 반환은 빈칸이 움직인 방향의 목록
function walkMoves(n: number, steps: number, seed: number): number[] {
const b = solvedBoard(n);
const out: number[] = [];
let noBacktrack = -1;
for (let s = 0; s < steps; s += 1) {
const opts: number[] = [];
for (let d = 0; d < 4; d += 1) { if (d !== noBacktrack && canStep(b, n, d)) { opts.push(d); } }
const d = opts[Math.floor(hash2(s, seed) * opts.length)];
blankStep(b, n, d);
out.push(d);
noBacktrack = d ^ 1;
}
return out;
}
function applyMoves(b: number[], n: number, ms: number[]): void {
for (let i = 0; i < ms.length; i += 1) { blankStep(b, n, ms[i]); }
}
function reverseMoves(ms: number[]): number[] {
const o: number[] = [];
for (let i = ms.length - 1; i >= 0; i -= 1) { o.push(ms[i] ^ 1); }
return o;
}
// 줄째 밀기 — 누른 칸 i 가 빈칸과 같은 줄이면 사이 타일이 모두 한 칸씩 빈칸 쪽으로. 배열 교환으로 먼저 정하고
// 옮겨진 타일 번호를 빈칸에 가까운 순서로 돌려준다
function rowChain(b: number[], n: number, i: number): number[] {
const z = b.indexOf(0);
if (i === z) { return []; }
let step = 0;
if (Math.floor(i / n) === Math.floor(z / n)) { step = i > z ? 1 : -1; } else if (i % n === z % n) { step = i > z ? n : -n; } else { return []; }
const moved: number[] = [];
for (let p = z; p !== i; p += step) {
b[p] = b[p + step];
moved.push(b[p]);
}
b[i] = 0;
return moved;
}
// 반복 깊이 탐색 — 맨해튼 거리 합 h 를 길잡이로 깊이 우선 탐색하고, 수와 h 의 합이 한도(bound)를 넘으면 가지를 접는다.
// 한 바퀴가 끝나면 한도를 넘긴 값 중 가장 작은 것으로 올려 다시 돈다. 노드 수가 cap 에 닿으면 멈춘다
type Ida = { path: number[]; nodes: number; bounds: number[]; perBound: number[]; capHit: boolean };
function idaStar(start: number[], n: number, cap: number): Ida {
const b = start.slice();
const path: number[] = [];
let nodes = 0;
let capHit = false;
const INF = 9999;
let z = b.indexOf(0);
function dfs(g: number, h: number, bound: number, last: number): number {
nodes += 1;
if (nodes > cap) { capHit = true; return INF; }
const f = g + h;
if (f > bound) { return f; }
if (h === 0) { return -1; }
let min = INF;
for (let d = 0; d < 4; d += 1) {
if (last >= 0 && d === (last ^ 1)) { continue; }
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
if (c < 0 || c >= n || r < 0 || r >= n) { continue; }
const t = z + DX[d] + DY[d] * n;
const v = b[t];
const hv = Math.abs((t % n) - ((v - 1) % n)) + Math.abs(Math.floor(t / n) - Math.floor((v - 1) / n));
const hn = Math.abs((z % n) - ((v - 1) % n)) + Math.abs(Math.floor(z / n) - Math.floor((v - 1) / n));
b[z] = v;
b[t] = 0;
const oz = z;
z = t;
path.push(d);
const res = dfs(g + 1, h - hv + hn, bound, d);
if (res === -1) { return -1; }
path.pop();
z = oz;
b[t] = v;
b[z] = 0;
if (capHit) { return INF; }
if (res < min) { min = res; }
}
return min;
}
let bound = sumH(b, n);
const bounds: number[] = [];
const perBound: number[] = [];
while (bound < INF) {
const before = nodes;
bounds.push(bound);
const res = dfs(0, sumH(b, n), bound, -1);
perBound.push(nodes - before);
if (res === -1) { return { path: path.slice(), nodes: nodes, bounds: bounds, perBound: perBound, capHit: false }; }
if (capHit || res >= INF) { break; }
bound = res;
}
return { path: [], nodes: nodes, bounds: bounds, perBound: perBound, capHit: capHit };
}
// ── 판 화면 — 타일마다 지금 칸 → 목표 칸 보간. 배열은 먼저 바뀌고 화면이 뒤따른다
const BX = 24;
const BY = 44;
const BSZ = 216;
let bd: number[] = [];
let BN = 4;
let CS = 54;
type Tl = { fx: number; fy: number; tx: number; ty: number; t0: number; du: number };
let tl: Tl[] = [];
let dragV = -1;
let dragCx = 0;
let dragCy = 0;
function setBoard(b: number[], n: number): void {
bd = b.slice();
BN = n;
CS = BSZ / n;
tl = [];
dragV = -1;
for (let v = 0; v < n * n; v += 1) {
const i = bd.indexOf(v);
const c = i % n;
const r = Math.floor(i / n);
tl.push({ fx: c, fy: r, tx: c, ty: r, t0: 0, du: 0 });
}
}
let still = false;
function tilePos(v: number): number[] {
const t = tl[v];
if (v === dragV) { return [dragCx, dragCy]; }
if (still || t.du <= 0) { return [t.tx, t.ty]; }
const e = easeOut((sceneT - t.t0) / t.du);
return [lerp(t.fx, t.tx, e), lerp(t.fy, t.ty, e)];
}
// 배열이 바뀐 뒤 타일마다 목표 칸을 다시 읽는다 — 옮겨진 타일만 지금 화면 자리에서 출발한다
function retarget(delayOf: (v: number) => number, du: number): void {
for (let v = 1; v < BN * BN; v += 1) {
const i = bd.indexOf(v);
const c = i % BN;
const r = Math.floor(i / BN);
const t = tl[v];
if (c !== t.tx || r !== t.ty) {
const p = tilePos(v);
t.fx = p[0];
t.fy = p[1];
t.tx = c;
t.ty = r;
t.t0 = sceneT + delayOf(v);
t.du = du;
}
}
}
function stepBlank(d: number, du: number): boolean {
if (!blankStep(bd, BN, d)) { return false; }
retarget(function (): number { return 0; }, du);
return true;
}
function cellAt(x: number, y: number): number {
const c = Math.floor((x - BX) / CS);
const r = Math.floor((y - BY) / CS);
if (c < 0 || c >= BN || r < 0 || r >= BN) { return -1; }
return r * BN + c;
}
// ── 그리기 도구
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, c: RGB, a: number, w: number): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x: number, y: number, w: number, h: number): void { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name: string, val: string, frac: number): void {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// 판 바탕 — 받침과 빈 칸 홈
function drawBoardBase(): void {
const s = CS * BN;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < BN * BN; i += 1) {
box(BX + (i % BN) * CS + 2, BY + Math.floor(i / BN) * CS + 2, CS - 4, CS - 4, 8, [40, 36, 62], 1);
}
}
function tileHue(v: number, n: number): RGB {
return hsl(190 + 160 * ((v - 1) / (n * n - 2)), 0.55, 0.8);
}
function paintTile(label: string, cx: number, cy: number, c: RGB, cs: number, ox: number, oy: number): void {
const s = cs - 4;
const x = ox + cx * cs + 2;
const y = oy + cy * cs + 2;
box(x, y + 2, s, s, 8, [26, 23, 42], 0.5);
box(x, y, s, s, 8, c, 1);
box(x + 3, y + 2, s - 6, 3, 1.5, [255, 255, 255], 0.4);
text(label, x + s / 2, y + s / 2 + 1, INK, Math.max(9, Math.round(cs * 0.38)), 800, 'center');
}
function drawTiles(fillOf: (v: number) => RGB): void {
for (let v = 1; v < BN * BN; v += 1) {
const p = tilePos(v);
paintTile(String(v), p[0], p[1], fillOf(v), CS, BX, BY);
}
}
// 글자판 없이 배열만 그리는 작은 판 — 비교 장면용
function drawMini(b: number[], n: number, ox: number, oy: number, size: number): void {
const cs = size / n;
box(ox - 3, oy - 3, size + 6, size + 6, 8, PANEL, 1);
for (let i = 0; i < n * n; i += 1) {
const v = b[i];
if (v === 0) { box(ox + (i % n) * cs + 1, oy + Math.floor(i / n) * cs + 1, cs - 2, cs - 2, 3, [40, 36, 62], 1); continue; }
const x = ox + (i % n) * cs + 1;
const y = oy + Math.floor(i / n) * cs + 1;
box(x, y, cs - 2, cs - 2, 3, tileHue(v, n), 1);
if (cs >= 20) { text(String(v), x + (cs - 2) / 2, y + (cs - 2) / 2 + 1, INK, Math.round(cs * 0.4), 800, 'center'); }
}
}
// ── 상태 줄·낭독 문장
const SPK_PLACE = '{a}×{a} 판, 타일 {b}장 중 {c}장이 제자리에 있다';
const SPK_DONE = '{a}×{a} 판 완성, 타일 {b}장이 모두 제자리에 있다';
function speakBoard(): string {
const t = BN * BN - 1;
return isSolved(bd) ? fill(SPK_DONE, String(BN), String(t)) : fill(SPK_PLACE, String(BN), String(t), String(placedCount(bd)));
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
}
function takeOver(): void {
if (manual) { return; }
manual = true;
wake();
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 맞은 타일 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode: number): number {
if (isSolved(bd)) { return 1000; }
return manual ? 100 + placedCount(bd) : sceneMode;
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say(): void {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
function advance(dt: number): void {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
}
update(dt);
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow(): void {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) { say(); }
}
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest(): void {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak: boolean): void {
if (paused || still) { sceneT += 1; }
update(0);
showNow();
if (speak) { say(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
say();
}
function restart(): void {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// N 한 걸음 — 시간표의 다음 일 하나를 지금 치른다. 시간표가 끝났으면 처음부터 다시 짠 뒤 첫 일
function stepOnce(): void {
takeOver();
if (evAt >= script.length) { lap += 1; begin(); }
if (evAt < script.length) {
const ev = script[evAt];
evAt += 1;
if (sceneT < ev.t) { sceneT = ev.t; }
ev.f();
}
afterInput(true);
}
function keyMove(dx: number, dy: number): void {
// 방향키는 타일이 가는 방향 — 그 타일은 빈칸의 반대쪽에 있다. 빈칸이 가는 방향으로 바꿔 부른다
let d = 0;
for (let k = 0; k < 4; k += 1) { if (DX[k] === -dx && DY[k] === -dy) { d = k; } }
takeOver();
onDir(d);
afterInput(true);
}
function toCanvas(e: PointerEvent): number[] {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 빈칸 이웃 타일 이동, Enter·Space = 일시정지, N = 한 걸음, R = 처음부터
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'r' || k === 'R') {
restart();
} else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
} else if (k === 'ArrowLeft') {
e.preventDefault();
keyMove(-1, 0);
} else if (k === 'ArrowRight') {
e.preventDefault();
keyMove(1, 0);
} else if (k === 'ArrowUp') {
e.preventDefault();
keyMove(0, -1);
} else if (k === 'ArrowDown') {
e.preventDefault();
keyMove(0, 1);
} else if (keyHook !== null && keyHook(k)) {
e.preventDefault();
takeOver();
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
// 장면이 방향키·Enter·N·R 밖의 키를 받고 싶을 때 거는 갈고리 — 먹었으면 true
let keyHook: ((k: string) => boolean) | null = null;
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
ptrDown = true;
try { pad.setPointerCapture(e.pointerId); } catch (err) { ptrDown = true; }
const p = toCanvas(e);
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (!ptrDown) { return; }
const p = toCanvas(e);
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e: PointerEvent): void {
if (!ptrDown) { return; }
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 판 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// 누른 칸이 빈칸의 이웃이면 그 타일이 들어갈 빈칸의 방향(빈칸 기준), 아니면 -1
function dirOfCell(i: number): number {
const z = bd.indexOf(0);
for (let d = 0; d < 4; d += 1) { if (canStep(bd, BN, d) && z + DX[d] + DY[d] * BN === i) { return d; } }
return -1;
}
// 02 — 타일 번호를 한 줄로 늘어놓고 역전(앞 번호가 뒤 번호보다 큰 쌍)을 센다. 4×4 판은 역전 + 빈칸 줄(아래서 몇 번째)이 홀수일 때만 풀 수 있다. 합법 이동은 그 홀짝을 바꾸지 못한다
const TILE_N = 4;
const SHUFFLE_02 = 40;
const SLIDE_S = 0.13;
const LAP_S = 5.8;
const REST_S = 2.2;
const PX = 256;
const PW = 216;
let parHist: number[] = [];
let swapped = false;
let swapA = 0;
let swapB = 0;
let flashT = -9;
let lastD = -1;
const STAT_OK = '역전 {a}쌍, 합 {b}: 풀 수 있는 판';
const STAT_NO = '역전 {a}쌍, 합 {b}: 풀 수 없는 판';
function note(): void {
parHist.push(parity(bd, BN) % 2);
if (parHist.length > 16) { parHist.shift(); }
}
// 시연 이동 — 방금 온 칸으로 되돌아가지 않는 방향 중 해시로 하나
function walkOne(k: number): void {
const opts: number[] = [];
for (let d = 0; d < 4; d += 1) { if (d !== (lastD ^ 1) && canStep(bd, BN, d)) { opts.push(d); } }
const d = opts[Math.floor(hash2(k, 21 + lap) * opts.length)];
if (stepBlank(d, SLIDE_S)) { lastD = d; note(); }
}
function walkAt(k: number): () => void {
return function (): void { walkOne(k); };
}
// 두 타일의 자리만 맞바꾼다 — 합법 이동이 아니라 판을 뜯어 고친 것
function swapTiles(): void {
const a = 1 + Math.floor(hash2(3, 17 + lap) * 15);
let b = 1 + Math.floor(hash2(4, 17 + lap) * 15);
if (b === a) { b = a % 15 + 1; }
const i = bd.indexOf(a);
const j = bd.indexOf(b);
bd[i] = b;
bd[j] = a;
retarget(function (): number { return 0; }, 0.4);
swapped = true;
swapA = a;
swapB = b;
flashT = sceneT;
note();
}
function build(): void {
parHist = [];
swapped = false;
swapA = 0;
swapB = 0;
flashT = -9;
lastD = -1;
const b = solvedBoard(TILE_N);
applyMoves(b, TILE_N, walkMoves(TILE_N, SHUFFLE_02, 9 + lap));
setBoard(b, TILE_N);
note();
for (let k = 0; k < 7; k += 1) { script.push({ t: 0.25 + k * 0.2, f: walkAt(k) }); }
script.push({ t: 1.8, f: swapTiles });
for (let k = 7; k < 15; k += 1) { script.push({ t: 2.5 + (k - 7) * 0.22, f: walkAt(k) }); }
}
function update(dt: number): void { if (dt < 0) { return; } }
function draw(): void {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoardBase();
const ok = solvable(bd, BN);
const fk = (sceneT - flashT) / 1.0;
drawTiles(function (v: number): RGB { return tileHue(v, BN); });
// 판 테두리 — 풀 수 있으면 민트, 없으면 장미
const s = CS * BN;
ctx.beginPath();
ctx.roundRect(BX - 5, BY - 5, s + 10, s + 10, 12);
ctx.strokeStyle = rgba(ok ? MINT : ROSE, 1);
ctx.lineWidth = 3;
ctx.stroke();
if (swapped && fk >= 0 && fk < 1) {
const ids = [swapA, swapB];
for (let q = 0; q < 2; q += 1) {
const p = tilePos(ids[q]);
ctx.beginPath();
ctx.roundRect(BX + p[0] * CS, BY + p[1] * CS, CS, CS, 10);
ctx.strokeStyle = rgba(PAPER, 1 - fk);
ctx.lineWidth = 3;
ctx.stroke();
}
}
panel(PX, BY - 5, PW, s + 10);
const inv = inversions(bd);
const br = blankRow(bd, BN);
const sum = parity(bd, BN);
text('inversions', PX + 12, BY + 12, DIM, 11, 700, 'left');
text(String(inv), PX + PW - 12, BY + 12, NEU, 15, 800, 'right');
text('blankRow', PX + 12, BY + 34, DIM, 11, 700, 'left');
text(String(br), PX + PW - 12, BY + 34, NEU, 15, 800, 'right');
text('parity', PX + 12, BY + 56, DIM, 11, 700, 'left');
text(inv + ' + ' + br + ' = ' + sum, PX + PW - 12, BY + 56, NEU, 15, 800, 'right');
box(PX + 12, BY + 72, PW - 24, 22, 11, ok ? MINT : ROSE, 1);
text(ok ? '풀 수 있음' : '풀 수 없음', PX + PW / 2, BY + 83.5, INK, 12, 800, 'center');
// 번호 줄 — 판을 한 줄로 읽은 순서. 맞바꾼 두 타일은 장미 칩
for (let i = 0; i < BN * BN; i += 1) {
const x = PX + 12 + (i % 8) * 24.5;
const y = BY + 104 + Math.floor(i / 8) * 20;
const v = bd[i];
const hot = swapped && (v === swapA || v === swapB);
box(x, y, 22, 17, 4, hot ? ROSE : [88, 82, 122], 1);
text(v === 0 ? '.' : String(v), x + 11, y + 9, hot ? INK : NEU, 10, 800, 'center');
}
// 홀짝 기록 — 이동할 때마다 합의 홀짝. 이동으로는 줄이 한 번도 바뀌지 않는다
const yOdd = BY + 166;
const yEven = BY + 196;
seg(PX + 26, yOdd, PX + PW - 12, yOdd, [88, 82, 122], 1, 1);
seg(PX + 26, yEven, PX + PW - 12, yEven, [88, 82, 122], 1, 1);
text('odd', PX + 12, yOdd, DIM, 9, 700, 'center');
text('even', PX + 12, yEven, DIM, 9, 700, 'center');
for (let i = 0; i < parHist.length; i += 1) {
disc(PX + 36 + i * 10.6, parHist[i] === 1 ? yOdd : yEven, 3.6, parHist[i] === 1 ? MINT : ROSE, 1);
}
text('이동해도 홀짝은 그대로', PX + PW / 2, BY + 218, DIM, 10, 700, 'center');
hud('parity', String(sum), sum / 60);
}
function statusText(): string {
return fill(solvable(bd, BN) ? STAT_OK : STAT_NO, String(inversions(bd)), String(parity(bd, BN)));
}
function modeId(): number { return modeBase(swapped ? 1 : 0); }
function speakText(): string { return statusText() + '. ' + speakBoard(); }
function onDir(d: number): void {
if (stepBlank(d, SLIDE_S)) { lastD = d; note(); }
}
function onPtr(kind: number, x: number, y: number): void {
if (kind !== 0) { return; }
const d = dirOfCell(cellAt(x, y));
if (d >= 0) { onDir(d); }
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'슬라이딩 퍼즐 시연 무대 — 방향키로 빈칸 옆 타일 이동, 타일 누르기도 가능, Enter 또는 Space 로 일시정지, N 으로 한 걸음, R 로 처음부터'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'4×4 판 옆 패널에 역전의 수, 아래서 센 빈칸 줄, 둘의 합이 나온다. 합이 홀수이면 판 테두리가 민트색이고 풀 수 있음, 짝수이면 장미색이고 풀 수 없음이라고 적힌다. 판을 한 줄로 읽은 번호 칩 열여섯 개가 있고, 두 타일을 맞바꾸면 그 두 칩이 장미색이 되며 판정이 뒤집힌다. 맨 아래 점 줄은 이동할 때마다의 합의 홀짝으로, 합법 이동에서는 한 번도 바뀌지 않는다. 아래 눈금은 상수 이름과 지금 값을 보이고, 움직임 줄이기에서는 타일이 바로 자리를 바꾸는 판 한 장만 그려진다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'슬라이딩 퍼즐 장면'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}02 · ひっくり返った組の数で解ける盤を分ける — 計測 累積面積 23.449% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function inversions(b) {
let c = 0;
for (let i = 0; i < b.length; i += 1) {
for (let j = i + 1; j < b.length; j += 1) {
if (b[i] !== 0 && b[j] !== 0 && b[i] > b[j]) {
c += 1;
}
}
}
return c;
}
// 空きマスが下から何行目か(一番下の行 = 1)
function blankRow(b, n) { return n - Math.floor(b.indexOf(0) / n); }
// 判定の合計 — 偶数幅は逆転 + 空きマスの行、奇数幅は逆転のみ
function parity(b, n) { return n % 2 === 0 ? inversions(b) + blankRow(b, n) : inversions(b); }
function solvable(b, n) {
return n % 2 === 0 ? parity(b, n) % 2 === 1 : parity(b, n) % 2 === 0;
}
最初の盤は逆転27・空きマスの行2・合計29(奇数)で解け、1.8秒にタイル5と9を入れ替えると合計が32(偶数)になり、続く8手で34・36・40と動いても偶奇は偶数のままでした。
03元の位置までの距離を色で塗るマンハッタンのヒートマップ
タイルごとに今のマスと元のマスの横と縦のマス数を足したマンハッタン距離を求め、色の濃さで塗るので、どのタイルが元の位置から遠いかがひと目で読めます。色は距離0の薄いミントから3の黄、6以上のバラ色まで heatColor が距離の比で混ぜて決め、盤全体の距離の合計Hも数字で並べて見せます。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="スライドパズルの舞台 — 矢印キーで空きマスの隣のタイルを動かす、タイルを押しても動く、EnterかSpaceで一時停止、Nで一歩、Rで最初から">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="4×4の盤のタイルが元の位置までの距離に応じて淡いミントから黄色、薔薇色に塗られ、タイルの角にその距離の数字が書かれる。右のパネルには距離の合計Hの大きな数字、直前の一手での変化(プラス1かマイナス1)、一手ごとにHが増減する折れ線、距離0から6までの色の凡例があり、Hが0になると完成と表示される。下の目盛りは定数名と今の値を示し、動きを減らす設定ではタイルがすぐ入れ替わる盤一枚だけを描きます"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">スライドパズルの場面</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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;
}
// 무대 상자 — 눌러서 한 번 깨뜨리는 곳(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause {
padding: $sp-1 $sp-3;
border: 2px solid $subject-cream;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 스크린리더에만 읽히는 낭독 문장 — 모드가 바뀔 때만 쓴다
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-ink;
color: $subject-cream;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면에만 있고 aria-live 는 늘 off
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 100px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 글리치 없는 원문 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
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;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause {
padding: 4px 12px;
border: 2px solid #fff7e6;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 100px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function easeOut(k) { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const SKY = [150, 192, 244];
const SUN = [250, 218, 146];
const NEU = [236, 230, 250];
const BGC = [46, 42, 68];
const PANEL = [62, 57, 92];
const DIM = [196, 190, 224];
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function hsl(h, s, l) {
const a = s * Math.min(l, 1 - l);
const f = function (n) {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a, b) {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s, a, b, c) {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v) { return String(Math.round(v * 10) / 10); }
function group(v) {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
o += ',';
}
o += s.charAt(i);
}
return o;
}
// ── 퍼즐 모델 — 판은 번호 배열, 0 은 빈칸, 타일 v 의 제자리는 v-1 번 칸, 빈칸의 제자리는 맨 끝
// 빈칸이 움직이는 방향 d: 0 오른쪽 · 1 왼쪽 · 2 아래 · 3 위. 반대 방향은 d ^ 1
const DX = [1, -1, 0, 0];
const DY = [0, 0, 1, -1];
function solvedBoard(n) {
const b = [];
for (let i = 1; i < n * n; i += 1) {
b.push(i);
}
b.push(0);
return b;
}
function canStep(b, n, d) {
const z = b.indexOf(0);
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
return c >= 0 && c < n && r >= 0 && r < n;
}
function blankStep(b, n, d) {
if (!canStep(b, n, d)) {
return false;
}
const z = b.indexOf(0);
const t = z + DX[d] + DY[d] * n;
b[z] = b[t];
b[t] = 0;
return true;
}
// 역전 — 번호를 한 줄로 늘어놓고 앞 번호가 뒤 번호보다 큰 쌍의 수(빈칸은 세지 않는다)
function inversions(b) {
let c = 0;
for (let i = 0; i < b.length; i += 1) {
for (let j = i + 1; j < b.length; j += 1) {
if (b[i] !== 0 && b[j] !== 0 && b[i] > b[j]) {
c += 1;
}
}
}
return c;
}
// 빈칸이 아래서 몇 번째 줄인지(맨 아래 줄 = 1)
function blankRow(b, n) { return n - Math.floor(b.indexOf(0) / n); }
// 판정 합 — 짝수 폭은 역전 + 빈칸 줄, 홀수 폭은 역전만
function parity(b, n) { return n % 2 === 0 ? inversions(b) + blankRow(b, n) : inversions(b); }
function solvable(b, n) {
return n % 2 === 0 ? parity(b, n) % 2 === 1 : parity(b, n) % 2 === 0;
}
function manhattan(b, n, i) {
const v = b[i];
if (v === 0) {
return 0;
}
const h = v - 1;
return Math.abs((i % n) - (h % n)) + Math.abs(Math.floor(i / n) - Math.floor(h / n));
}
function sumH(b, n) {
let s = 0;
for (let i = 0; i < b.length; i += 1) {
s += manhattan(b, n, i);
}
return s;
}
function placedCount(b) {
let c = 0;
for (let i = 0; i < b.length - 1; i += 1) {
if (b[i] === i + 1) {
c += 1;
}
}
return c;
}
function isSolved(b) { return placedCount(b) === b.length - 1 && b[b.length - 1] === 0; }
// 歩いて混ぜる — 完成 판에서 시작해 빈칸을 steps 번 이웃 칸으로 옮긴다. 방금 온 칸으로 되돌아가는 수는 건너뛰고
// 어느 이웃으로 갈지는 정수 해시로 고른다. 반환은 빈칸이 움직인 방향의 목록
function walkMoves(n, steps, seed) {
const b = solvedBoard(n);
const out = [];
let noBacktrack = -1;
for (let s = 0; s < steps; s += 1) {
const opts = [];
for (let d = 0; d < 4; d += 1) {
if (d !== noBacktrack && canStep(b, n, d)) {
opts.push(d);
}
}
const d = opts[Math.floor(hash2(s, seed) * opts.length)];
blankStep(b, n, d);
out.push(d);
noBacktrack = d ^ 1;
}
return out;
}
function applyMoves(b, n, ms) {
for (let i = 0; i < ms.length; i += 1) {
blankStep(b, n, ms[i]);
}
}
function reverseMoves(ms) {
const o = [];
for (let i = ms.length - 1; i >= 0; i -= 1) {
o.push(ms[i] ^ 1);
}
return o;
}
// 줄째 밀기 — 누른 칸 i 가 빈칸과 같은 줄이면 사이 타일이 모두 한 칸씩 빈칸 쪽으로. 배열 교환으로 먼저 정하고
// 옮겨진 타일 번호를 빈칸에 가까운 순서로 돌려준다
function rowChain(b, n, i) {
const z = b.indexOf(0);
if (i === z) {
return [];
}
let step = 0;
if (Math.floor(i / n) === Math.floor(z / n)) {
step = i > z ? 1 : -1;
}
else if (i % n === z % n) {
step = i > z ? n : -n;
}
else {
return [];
}
const moved = [];
for (let p = z; p !== i; p += step) {
b[p] = b[p + step];
moved.push(b[p]);
}
b[i] = 0;
return moved;
}
function idaStar(start, n, cap) {
const b = start.slice();
const path = [];
let nodes = 0;
let capHit = false;
const INF = 9999;
let z = b.indexOf(0);
function dfs(g, h, bound, last) {
nodes += 1;
if (nodes > cap) {
capHit = true;
return INF;
}
const f = g + h;
if (f > bound) {
return f;
}
if (h === 0) {
return -1;
}
let min = INF;
for (let d = 0; d < 4; d += 1) {
if (last >= 0 && d === (last ^ 1)) {
continue;
}
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
if (c < 0 || c >= n || r < 0 || r >= n) {
continue;
}
const t = z + DX[d] + DY[d] * n;
const v = b[t];
const hv = Math.abs((t % n) - ((v - 1) % n)) + Math.abs(Math.floor(t / n) - Math.floor((v - 1) / n));
const hn = Math.abs((z % n) - ((v - 1) % n)) + Math.abs(Math.floor(z / n) - Math.floor((v - 1) / n));
b[z] = v;
b[t] = 0;
const oz = z;
z = t;
path.push(d);
const res = dfs(g + 1, h - hv + hn, bound, d);
if (res === -1) {
return -1;
}
path.pop();
z = oz;
b[t] = v;
b[z] = 0;
if (capHit) {
return INF;
}
if (res < min) {
min = res;
}
}
return min;
}
let bound = sumH(b, n);
const bounds = [];
const perBound = [];
while (bound < INF) {
const before = nodes;
bounds.push(bound);
const res = dfs(0, sumH(b, n), bound, -1);
perBound.push(nodes - before);
if (res === -1) {
return { path: path.slice(), nodes: nodes, bounds: bounds, perBound: perBound, capHit: false };
}
if (capHit || res >= INF) {
break;
}
bound = res;
}
return { path: [], nodes: nodes, bounds: bounds, perBound: perBound, capHit: capHit };
}
// ── 판 화면 — 타일마다 지금 칸 → 목표 칸 보간. 배열은 먼저 바뀌고 화면이 뒤따른다
const BX = 24;
const BY = 44;
const BSZ = 216;
let bd = [];
let BN = 4;
let CS = 54;
let tl = [];
let dragV = -1;
let dragCx = 0;
let dragCy = 0;
function setBoard(b, n) {
bd = b.slice();
BN = n;
CS = BSZ / n;
tl = [];
dragV = -1;
for (let v = 0; v < n * n; v += 1) {
const i = bd.indexOf(v);
const c = i % n;
const r = Math.floor(i / n);
tl.push({ fx: c, fy: r, tx: c, ty: r, t0: 0, du: 0 });
}
}
let still = false;
function tilePos(v) {
const t = tl[v];
if (v === dragV) {
return [dragCx, dragCy];
}
if (still || t.du <= 0) {
return [t.tx, t.ty];
}
const e = easeOut((sceneT - t.t0) / t.du);
return [lerp(t.fx, t.tx, e), lerp(t.fy, t.ty, e)];
}
// 배열이 바뀐 뒤 타일마다 목표 칸을 다시 읽는다 — 옮겨진 타일만 지금 화면 자리에서 출발한다
function retarget(delayOf, du) {
for (let v = 1; v < BN * BN; v += 1) {
const i = bd.indexOf(v);
const c = i % BN;
const r = Math.floor(i / BN);
const t = tl[v];
if (c !== t.tx || r !== t.ty) {
const p = tilePos(v);
t.fx = p[0];
t.fy = p[1];
t.tx = c;
t.ty = r;
t.t0 = sceneT + delayOf(v);
t.du = du;
}
}
}
function stepBlank(d, du) {
if (!blankStep(bd, BN, d)) {
return false;
}
retarget(function () { return 0; }, du);
return true;
}
function cellAt(x, y) {
const c = Math.floor((x - BX) / CS);
const r = Math.floor((y - BY) / CS);
if (c < 0 || c >= BN || r < 0 || r >= BN) {
return -1;
}
return r * BN + c;
}
// ── 그리기 도구
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function disc(x, y, r, c, a) {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, c, a, w) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s, x, y, c, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x, y, w, h) { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name, val, frac) {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// 판 바탕 — 받침과 빈 칸 홈
function drawBoardBase() {
const s = CS * BN;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < BN * BN; i += 1) {
box(BX + (i % BN) * CS + 2, BY + Math.floor(i / BN) * CS + 2, CS - 4, CS - 4, 8, [40, 36, 62], 1);
}
}
function tileHue(v, n) {
return hsl(190 + 160 * ((v - 1) / (n * n - 2)), 0.55, 0.8);
}
function paintTile(label, cx, cy, c, cs, ox, oy) {
const s = cs - 4;
const x = ox + cx * cs + 2;
const y = oy + cy * cs + 2;
box(x, y + 2, s, s, 8, [26, 23, 42], 0.5);
box(x, y, s, s, 8, c, 1);
box(x + 3, y + 2, s - 6, 3, 1.5, [255, 255, 255], 0.4);
text(label, x + s / 2, y + s / 2 + 1, INK, Math.max(9, Math.round(cs * 0.38)), 800, 'center');
}
function drawTiles(fillOf) {
for (let v = 1; v < BN * BN; v += 1) {
const p = tilePos(v);
paintTile(String(v), p[0], p[1], fillOf(v), CS, BX, BY);
}
}
// 글자판 없이 배열만 그리는 작은 판 — 비교 장면용
function drawMini(b, n, ox, oy, size) {
const cs = size / n;
box(ox - 3, oy - 3, size + 6, size + 6, 8, PANEL, 1);
for (let i = 0; i < n * n; i += 1) {
const v = b[i];
if (v === 0) {
box(ox + (i % n) * cs + 1, oy + Math.floor(i / n) * cs + 1, cs - 2, cs - 2, 3, [40, 36, 62], 1);
continue;
}
const x = ox + (i % n) * cs + 1;
const y = oy + Math.floor(i / n) * cs + 1;
box(x, y, cs - 2, cs - 2, 3, tileHue(v, n), 1);
if (cs >= 20) {
text(String(v), x + (cs - 2) / 2, y + (cs - 2) / 2 + 1, INK, Math.round(cs * 0.4), 800, 'center');
}
}
}
// ── 상태 줄·낭독 문장
const SPK_PLACE = '{a}×{a} の盤、タイル {b} 枚のうち {c} 枚が元の位置にある';
const SPK_DONE = '{a}×{a} の盤が完成、タイル {b} 枚すべてが元の位置にある';
function speakBoard() {
const t = BN * BN - 1;
return isSolved(bd) ? fill(SPK_DONE, String(BN), String(t)) : fill(SPK_PLACE, String(BN), String(t), String(placedCount(bd)));
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
}
function takeOver() {
if (manual) {
return;
}
manual = true;
wake();
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 맞은 타일 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode) {
if (isSolved(bd)) {
return 1000;
}
return manual ? 100 + placedCount(bd) : sceneMode;
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say() {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
function advance(dt) {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
}
update(dt);
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow() {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) {
say();
}
}
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest() {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak) {
if (paused || still) {
sceneT += 1;
}
update(0);
showNow();
if (speak) {
say();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
say();
}
function restart() {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// N 한 걸음 — 시간표의 다음 일 하나를 지금 치른다. 시간표가 끝났으면 처음부터 다시 짠 뒤 첫 일
function stepOnce() {
takeOver();
if (evAt >= script.length) {
lap += 1;
begin();
}
if (evAt < script.length) {
const ev = script[evAt];
evAt += 1;
if (sceneT < ev.t) {
sceneT = ev.t;
}
ev.f();
}
afterInput(true);
}
function keyMove(dx, dy) {
// 방향키는 타일이 가는 방향 — 그 타일은 빈칸의 반대쪽에 있다. 빈칸이 가는 방향으로 바꿔 부른다
let d = 0;
for (let k = 0; k < 4; k += 1) {
if (DX[k] === -dx && DY[k] === -dy) {
d = k;
}
}
takeOver();
onDir(d);
afterInput(true);
}
function toCanvas(e) {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 빈칸 이웃 타일 이동, Enter·Space = 一時停止, N = 한 걸음, R = 처음부터
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'r' || k === 'R') {
restart();
}
else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
}
else if (k === 'ArrowLeft') {
e.preventDefault();
keyMove(-1, 0);
}
else if (k === 'ArrowRight') {
e.preventDefault();
keyMove(1, 0);
}
else if (k === 'ArrowUp') {
e.preventDefault();
keyMove(0, -1);
}
else if (k === 'ArrowDown') {
e.preventDefault();
keyMove(0, 1);
}
else if (keyHook !== null && keyHook(k)) {
e.preventDefault();
takeOver();
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
// 장면이 방향키·Enter·N·R 밖의 키를 받고 싶을 때 거는 갈고리 — 먹었으면 true
let keyHook = null;
pad.addEventListener('pointerdown', function (e) {
takeOver();
ptrDown = true;
try {
pad.setPointerCapture(e.pointerId);
}
catch (err) {
ptrDown = true;
}
const p = toCanvas(e);
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (!ptrDown) {
return;
}
const p = toCanvas(e);
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e) {
if (!ptrDown) {
return;
}
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 판 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// 누른 칸이 빈칸의 이웃이면 그 타일이 들어갈 빈칸의 방향(빈칸 기준), 아니면 -1
function dirOfCell(i) {
const z = bd.indexOf(0);
for (let d = 0; d < 4; d += 1) {
if (canStep(bd, BN, d) && z + DX[d] + DY[d] * BN === i) {
return d;
}
}
return -1;
}
// 03 — 타일마다 지금 칸과 제자리 칸의 가로·세로 칸 수를 더한 맨해튼 거리를 색의 진하기로 칠한다. 판 전체 합(sumH)은 숫자와 꺾은선으로 같이 보이고 한 수마다 1 만 오르내린다
const TILE_N = 4;
const SHUFFLE_03 = 30;
const SEEDS_03 = [7, 6, 12, 18];
const SLIDE_S = 0.12;
const MOVE_GAP = 0.14;
const LAP_S = 6.6;
const REST_S = 2.6;
const PX = 256;
const PW = 216;
let hHist = [];
let lastDelta = 0;
let hTop = 8;
const STAT_H = '距離の合計H {a}、直前の一手での変化 {b}';
const SPK_H = '距離の合計Hは {a}';
function noteH() {
const h = sumH(bd, BN);
if (hHist.length > 0) {
lastDelta = h - hHist[hHist.length - 1];
}
hHist.push(h);
}
function moveAt(d) {
return function () {
if (stepBlank(d, SLIDE_S)) {
noteH();
}
};
}
function build() {
hHist = [];
lastDelta = 0;
const ms = walkMoves(TILE_N, SHUFFLE_03, SEEDS_03[lap % SEEDS_03.length]);
const b = solvedBoard(TILE_N);
applyMoves(b, TILE_N, ms);
const found = idaStar(b, TILE_N, 60000);
const path = found.path.length > 0 ? found.path : reverseMoves(ms);
// 꺾은선의 세로 범위 — 길을 한 번 미리 걸어 H 의 최댓값을 잰다
const probe = b.slice();
hTop = sumH(probe, TILE_N);
for (let k = 0; k < path.length; k += 1) {
blankStep(probe, TILE_N, path[k]);
hTop = Math.max(hTop, sumH(probe, TILE_N));
}
hTop += 1;
setBoard(b, TILE_N);
noteH();
for (let k = 0; k < path.length; k += 1) {
script.push({ t: 0.3 + k * MOVE_GAP, f: moveAt(path[k]) });
}
}
function update(dt) { if (dt < 0) {
return;
} }
// 거리 0 은 연한 민트, 중간은 노랑, 멀수록 장미 — 숫자 글자는 늘 잉크색이라 어느 칸에서도 대비가 충분하다
function heatColor(d) {
const t = clamp(d / 6, 0, 1);
const lo = [196, 236, 214];
const mid = [250, 226, 152];
const hi = [244, 152, 178];
const a = t < 0.5 ? lo : mid;
const b = t < 0.5 ? mid : hi;
const k = t < 0.5 ? t * 2 : (t - 0.5) * 2;
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
function draw() {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoardBase();
// 색은 화면에 보이는 자리에서 제자리까지의 거리(소수) — 타일이 미끄러지는 동안 색도 같이 연속으로 변한다
drawTiles(function (v) {
const p = tilePos(v);
const hx = (v - 1) % BN;
const hy = Math.floor((v - 1) / BN);
return heatColor(Math.abs(p[0] - hx) + Math.abs(p[1] - hy));
});
for (let v = 1; v < BN * BN; v += 1) {
const p = tilePos(v);
const i = bd.indexOf(v);
text(String(manhattan(bd, BN, i)), BX + p[0] * CS + CS - 10, BY + p[1] * CS + 11, INK, 9, 800, 'center');
}
const s = CS * BN;
panel(PX, BY - 5, PW, s + 10);
const h = sumH(bd, BN);
text('sumH', PX + 12, BY + 14, DIM, 11, 700, 'left');
text(String(h), PX + PW - 12, BY + 16, NEU, 26, 800, 'right');
const dl = hHist.length > 1 ? lastDelta : 0;
box(PX + 12, BY + 30, 60, 18, 9, dl > 0 ? ROSE : (dl < 0 ? MINT : [88, 82, 122]), 1);
text((dl > 0 ? '+' : '') + dl, PX + 42, BY + 39.5, dl === 0 ? NEU : INK, 11, 800, 'center');
if (h === 0) {
box(PX + 84, BY + 30, 56, 18, 9, MINT, 1);
text('完成', PX + 112, BY + 39.5, INK, 11, 800, 'center');
}
// 꺾은선 — 한 수마다 한 칸씩 오른쪽. 세로는 합 H
const cx = PX + 14;
const cy = BY + 60;
const cw = PW - 28;
const ch = 74;
box(cx, cy, cw, ch, 6, [48, 44, 74], 1);
const top = hTop;
const nx = 30;
for (let i = 1; i < hHist.length; i += 1) {
seg(cx + 6 + (i - 1) * (cw - 12) / (nx - 1), cy + ch - 6 - hHist[i - 1] / top * (ch - 12), cx + 6 + i * (cw - 12) / (nx - 1), cy + ch - 6 - hHist[i] / top * (ch - 12), MINT, 1, 2);
}
if (hHist.length > 0) {
const li = hHist.length - 1;
disc(cx + 6 + li * (cw - 12) / (nx - 1), cy + ch - 6 - hHist[li] / top * (ch - 12), 4, ROSE, 1);
}
// 범례 — 거리 0 부터 6 까지
text('元の位置までの距離', PX + 12, BY + 150, DIM, 11, 700, 'left');
for (let d = 0; d <= 6; d += 1) {
const x = PX + 12 + d * 27;
box(x, BY + 164, 24, 20, 5, heatColor(d), 1);
text(String(d), x + 12, BY + 174.5, INK, 11, 800, 'center');
}
hud('manhattan', String(h), h / 40);
}
function statusText() {
const dl = hHist.length > 1 ? lastDelta : 0;
return fill(STAT_H, String(sumH(bd, BN)), (dl > 0 ? '+' : '') + dl);
}
function modeId() { return modeBase(Math.floor(sumH(bd, BN) / 6)); }
function speakText() { return fill(SPK_H, String(sumH(bd, BN))) + '. ' + speakBoard(); }
function onDir(d) {
if (stepBlank(d, SLIDE_S)) {
noteH();
}
}
function onPtr(kind, x, y) {
if (kind !== 0) {
return;
}
const d = dirOfCell(cellAt(x, y));
if (d >= 0) {
onDir(d);
}
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// ManhattanHeatMap - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./ManhattanHeatMap.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ManhattanHeatMap() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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 easeOut(k: number): number { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const SKY: RGB = [150, 192, 244];
const SUN: RGB = [250, 218, 146];
const NEU: RGB = [236, 230, 250];
const BGC: RGB = [46, 42, 68];
const PANEL: RGB = [62, 57, 92];
const DIM: RGB = [196, 190, 224];
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function hsl(h: number, s: number, l: number): RGB {
const a = s * Math.min(l, 1 - l);
const f = function (n: number): number {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a: number, b: number): number {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s: string, a: string, b?: string, c?: string): string {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v: number): string { return String(Math.round(v * 10) / 10); }
function group(v: number): string {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { o += ','; }
o += s.charAt(i);
}
return o;
}
// ── 퍼즐 모델 — 판은 번호 배열, 0 은 빈칸, 타일 v 의 제자리는 v-1 번 칸, 빈칸의 제자리는 맨 끝
// 빈칸이 움직이는 방향 d: 0 오른쪽 · 1 왼쪽 · 2 아래 · 3 위. 반대 방향은 d ^ 1
const DX = [1, -1, 0, 0];
const DY = [0, 0, 1, -1];
function solvedBoard(n: number): number[] {
const b: number[] = [];
for (let i = 1; i < n * n; i += 1) { b.push(i); }
b.push(0);
return b;
}
function canStep(b: number[], n: number, d: number): boolean {
const z = b.indexOf(0);
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
return c >= 0 && c < n && r >= 0 && r < n;
}
function blankStep(b: number[], n: number, d: number): boolean {
if (!canStep(b, n, d)) { return false; }
const z = b.indexOf(0);
const t = z + DX[d] + DY[d] * n;
b[z] = b[t];
b[t] = 0;
return true;
}
// 역전 — 번호를 한 줄로 늘어놓고 앞 번호가 뒤 번호보다 큰 쌍의 수(빈칸은 세지 않는다)
function inversions(b: number[]): number {
let c = 0;
for (let i = 0; i < b.length; i += 1) {
for (let j = i + 1; j < b.length; j += 1) {
if (b[i] !== 0 && b[j] !== 0 && b[i] > b[j]) { c += 1; }
}
}
return c;
}
// 빈칸이 아래서 몇 번째 줄인지(맨 아래 줄 = 1)
function blankRow(b: number[], n: number): number { return n - Math.floor(b.indexOf(0) / n); }
// 판정 합 — 짝수 폭은 역전 + 빈칸 줄, 홀수 폭은 역전만
function parity(b: number[], n: number): number { return n % 2 === 0 ? inversions(b) + blankRow(b, n) : inversions(b); }
function solvable(b: number[], n: number): boolean {
return n % 2 === 0 ? parity(b, n) % 2 === 1 : parity(b, n) % 2 === 0;
}
function manhattan(b: number[], n: number, i: number): number {
const v = b[i];
if (v === 0) { return 0; }
const h = v - 1;
return Math.abs((i % n) - (h % n)) + Math.abs(Math.floor(i / n) - Math.floor(h / n));
}
function sumH(b: number[], n: number): number {
let s = 0;
for (let i = 0; i < b.length; i += 1) { s += manhattan(b, n, i); }
return s;
}
function placedCount(b: number[]): number {
let c = 0;
for (let i = 0; i < b.length - 1; i += 1) { if (b[i] === i + 1) { c += 1; } }
return c;
}
function isSolved(b: number[]): boolean { return placedCount(b) === b.length - 1 && b[b.length - 1] === 0; }
// 걸어서 섞기 — 완성 판에서 시작해 빈칸을 steps 번 이웃 칸으로 옮긴다. 방금 온 칸으로 되돌아가는 수는 건너뛰고
// 어느 이웃으로 갈지는 정수 해시로 고른다. 반환은 빈칸이 움직인 방향의 목록
function walkMoves(n: number, steps: number, seed: number): number[] {
const b = solvedBoard(n);
const out: number[] = [];
let noBacktrack = -1;
for (let s = 0; s < steps; s += 1) {
const opts: number[] = [];
for (let d = 0; d < 4; d += 1) { if (d !== noBacktrack && canStep(b, n, d)) { opts.push(d); } }
const d = opts[Math.floor(hash2(s, seed) * opts.length)];
blankStep(b, n, d);
out.push(d);
noBacktrack = d ^ 1;
}
return out;
}
function applyMoves(b: number[], n: number, ms: number[]): void {
for (let i = 0; i < ms.length; i += 1) { blankStep(b, n, ms[i]); }
}
function reverseMoves(ms: number[]): number[] {
const o: number[] = [];
for (let i = ms.length - 1; i >= 0; i -= 1) { o.push(ms[i] ^ 1); }
return o;
}
// 줄째 밀기 — 누른 칸 i 가 빈칸과 같은 줄이면 사이 타일이 모두 한 칸씩 빈칸 쪽으로. 배열 교환으로 먼저 정하고
// 옮겨진 타일 번호를 빈칸에 가까운 순서로 돌려준다
function rowChain(b: number[], n: number, i: number): number[] {
const z = b.indexOf(0);
if (i === z) { return []; }
let step = 0;
if (Math.floor(i / n) === Math.floor(z / n)) { step = i > z ? 1 : -1; } else if (i % n === z % n) { step = i > z ? n : -n; } else { return []; }
const moved: number[] = [];
for (let p = z; p !== i; p += step) {
b[p] = b[p + step];
moved.push(b[p]);
}
b[i] = 0;
return moved;
}
// 반복 깊이 탐색 — 맨해튼 거리 합 h 를 길잡이로 깊이 우선 탐색하고, 수와 h 의 합이 한도(bound)를 넘으면 가지를 접는다.
// 한 바퀴가 끝나면 한도를 넘긴 값 중 가장 작은 것으로 올려 다시 돈다. 노드 수가 cap 에 닿으면 멈춘다
type Ida = { path: number[]; nodes: number; bounds: number[]; perBound: number[]; capHit: boolean };
function idaStar(start: number[], n: number, cap: number): Ida {
const b = start.slice();
const path: number[] = [];
let nodes = 0;
let capHit = false;
const INF = 9999;
let z = b.indexOf(0);
function dfs(g: number, h: number, bound: number, last: number): number {
nodes += 1;
if (nodes > cap) { capHit = true; return INF; }
const f = g + h;
if (f > bound) { return f; }
if (h === 0) { return -1; }
let min = INF;
for (let d = 0; d < 4; d += 1) {
if (last >= 0 && d === (last ^ 1)) { continue; }
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
if (c < 0 || c >= n || r < 0 || r >= n) { continue; }
const t = z + DX[d] + DY[d] * n;
const v = b[t];
const hv = Math.abs((t % n) - ((v - 1) % n)) + Math.abs(Math.floor(t / n) - Math.floor((v - 1) / n));
const hn = Math.abs((z % n) - ((v - 1) % n)) + Math.abs(Math.floor(z / n) - Math.floor((v - 1) / n));
b[z] = v;
b[t] = 0;
const oz = z;
z = t;
path.push(d);
const res = dfs(g + 1, h - hv + hn, bound, d);
if (res === -1) { return -1; }
path.pop();
z = oz;
b[t] = v;
b[z] = 0;
if (capHit) { return INF; }
if (res < min) { min = res; }
}
return min;
}
let bound = sumH(b, n);
const bounds: number[] = [];
const perBound: number[] = [];
while (bound < INF) {
const before = nodes;
bounds.push(bound);
const res = dfs(0, sumH(b, n), bound, -1);
perBound.push(nodes - before);
if (res === -1) { return { path: path.slice(), nodes: nodes, bounds: bounds, perBound: perBound, capHit: false }; }
if (capHit || res >= INF) { break; }
bound = res;
}
return { path: [], nodes: nodes, bounds: bounds, perBound: perBound, capHit: capHit };
}
// ── 판 화면 — 타일마다 지금 칸 → 목표 칸 보간. 배열은 먼저 바뀌고 화면이 뒤따른다
const BX = 24;
const BY = 44;
const BSZ = 216;
let bd: number[] = [];
let BN = 4;
let CS = 54;
type Tl = { fx: number; fy: number; tx: number; ty: number; t0: number; du: number };
let tl: Tl[] = [];
let dragV = -1;
let dragCx = 0;
let dragCy = 0;
function setBoard(b: number[], n: number): void {
bd = b.slice();
BN = n;
CS = BSZ / n;
tl = [];
dragV = -1;
for (let v = 0; v < n * n; v += 1) {
const i = bd.indexOf(v);
const c = i % n;
const r = Math.floor(i / n);
tl.push({ fx: c, fy: r, tx: c, ty: r, t0: 0, du: 0 });
}
}
let still = false;
function tilePos(v: number): number[] {
const t = tl[v];
if (v === dragV) { return [dragCx, dragCy]; }
if (still || t.du <= 0) { return [t.tx, t.ty]; }
const e = easeOut((sceneT - t.t0) / t.du);
return [lerp(t.fx, t.tx, e), lerp(t.fy, t.ty, e)];
}
// 배열이 바뀐 뒤 타일마다 목표 칸을 다시 읽는다 — 옮겨진 타일만 지금 화면 자리에서 출발한다
function retarget(delayOf: (v: number) => number, du: number): void {
for (let v = 1; v < BN * BN; v += 1) {
const i = bd.indexOf(v);
const c = i % BN;
const r = Math.floor(i / BN);
const t = tl[v];
if (c !== t.tx || r !== t.ty) {
const p = tilePos(v);
t.fx = p[0];
t.fy = p[1];
t.tx = c;
t.ty = r;
t.t0 = sceneT + delayOf(v);
t.du = du;
}
}
}
function stepBlank(d: number, du: number): boolean {
if (!blankStep(bd, BN, d)) { return false; }
retarget(function (): number { return 0; }, du);
return true;
}
function cellAt(x: number, y: number): number {
const c = Math.floor((x - BX) / CS);
const r = Math.floor((y - BY) / CS);
if (c < 0 || c >= BN || r < 0 || r >= BN) { return -1; }
return r * BN + c;
}
// ── 그리기 도구
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, c: RGB, a: number, w: number): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x: number, y: number, w: number, h: number): void { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name: string, val: string, frac: number): void {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// 판 바탕 — 받침과 빈 칸 홈
function drawBoardBase(): void {
const s = CS * BN;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < BN * BN; i += 1) {
box(BX + (i % BN) * CS + 2, BY + Math.floor(i / BN) * CS + 2, CS - 4, CS - 4, 8, [40, 36, 62], 1);
}
}
function tileHue(v: number, n: number): RGB {
return hsl(190 + 160 * ((v - 1) / (n * n - 2)), 0.55, 0.8);
}
function paintTile(label: string, cx: number, cy: number, c: RGB, cs: number, ox: number, oy: number): void {
const s = cs - 4;
const x = ox + cx * cs + 2;
const y = oy + cy * cs + 2;
box(x, y + 2, s, s, 8, [26, 23, 42], 0.5);
box(x, y, s, s, 8, c, 1);
box(x + 3, y + 2, s - 6, 3, 1.5, [255, 255, 255], 0.4);
text(label, x + s / 2, y + s / 2 + 1, INK, Math.max(9, Math.round(cs * 0.38)), 800, 'center');
}
function drawTiles(fillOf: (v: number) => RGB): void {
for (let v = 1; v < BN * BN; v += 1) {
const p = tilePos(v);
paintTile(String(v), p[0], p[1], fillOf(v), CS, BX, BY);
}
}
// 글자판 없이 배열만 그리는 작은 판 — 비교 장면용
function drawMini(b: number[], n: number, ox: number, oy: number, size: number): void {
const cs = size / n;
box(ox - 3, oy - 3, size + 6, size + 6, 8, PANEL, 1);
for (let i = 0; i < n * n; i += 1) {
const v = b[i];
if (v === 0) { box(ox + (i % n) * cs + 1, oy + Math.floor(i / n) * cs + 1, cs - 2, cs - 2, 3, [40, 36, 62], 1); continue; }
const x = ox + (i % n) * cs + 1;
const y = oy + Math.floor(i / n) * cs + 1;
box(x, y, cs - 2, cs - 2, 3, tileHue(v, n), 1);
if (cs >= 20) { text(String(v), x + (cs - 2) / 2, y + (cs - 2) / 2 + 1, INK, Math.round(cs * 0.4), 800, 'center'); }
}
}
// ── 상태 줄·낭독 문장
const SPK_PLACE = '{a}×{a} 판, 타일 {b}장 중 {c}장이 제자리에 있다';
const SPK_DONE = '{a}×{a} 판 완성, 타일 {b}장이 모두 제자리에 있다';
function speakBoard(): string {
const t = BN * BN - 1;
return isSolved(bd) ? fill(SPK_DONE, String(BN), String(t)) : fill(SPK_PLACE, String(BN), String(t), String(placedCount(bd)));
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
}
function takeOver(): void {
if (manual) { return; }
manual = true;
wake();
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 맞은 타일 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode: number): number {
if (isSolved(bd)) { return 1000; }
return manual ? 100 + placedCount(bd) : sceneMode;
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say(): void {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
function advance(dt: number): void {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
}
update(dt);
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow(): void {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) { say(); }
}
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest(): void {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak: boolean): void {
if (paused || still) { sceneT += 1; }
update(0);
showNow();
if (speak) { say(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
say();
}
function restart(): void {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// N 한 걸음 — 시간표의 다음 일 하나를 지금 치른다. 시간표가 끝났으면 처음부터 다시 짠 뒤 첫 일
function stepOnce(): void {
takeOver();
if (evAt >= script.length) { lap += 1; begin(); }
if (evAt < script.length) {
const ev = script[evAt];
evAt += 1;
if (sceneT < ev.t) { sceneT = ev.t; }
ev.f();
}
afterInput(true);
}
function keyMove(dx: number, dy: number): void {
// 방향키는 타일이 가는 방향 — 그 타일은 빈칸의 반대쪽에 있다. 빈칸이 가는 방향으로 바꿔 부른다
let d = 0;
for (let k = 0; k < 4; k += 1) { if (DX[k] === -dx && DY[k] === -dy) { d = k; } }
takeOver();
onDir(d);
afterInput(true);
}
function toCanvas(e: PointerEvent): number[] {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 빈칸 이웃 타일 이동, Enter·Space = 일시정지, N = 한 걸음, R = 처음부터
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'r' || k === 'R') {
restart();
} else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
} else if (k === 'ArrowLeft') {
e.preventDefault();
keyMove(-1, 0);
} else if (k === 'ArrowRight') {
e.preventDefault();
keyMove(1, 0);
} else if (k === 'ArrowUp') {
e.preventDefault();
keyMove(0, -1);
} else if (k === 'ArrowDown') {
e.preventDefault();
keyMove(0, 1);
} else if (keyHook !== null && keyHook(k)) {
e.preventDefault();
takeOver();
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
// 장면이 방향키·Enter·N·R 밖의 키를 받고 싶을 때 거는 갈고리 — 먹었으면 true
let keyHook: ((k: string) => boolean) | null = null;
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
ptrDown = true;
try { pad.setPointerCapture(e.pointerId); } catch (err) { ptrDown = true; }
const p = toCanvas(e);
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (!ptrDown) { return; }
const p = toCanvas(e);
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e: PointerEvent): void {
if (!ptrDown) { return; }
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 판 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// 누른 칸이 빈칸의 이웃이면 그 타일이 들어갈 빈칸의 방향(빈칸 기준), 아니면 -1
function dirOfCell(i: number): number {
const z = bd.indexOf(0);
for (let d = 0; d < 4; d += 1) { if (canStep(bd, BN, d) && z + DX[d] + DY[d] * BN === i) { return d; } }
return -1;
}
// 03 — 타일마다 지금 칸과 제자리 칸의 가로·세로 칸 수를 더한 맨해튼 거리를 색의 진하기로 칠한다. 판 전체 합(sumH)은 숫자와 꺾은선으로 같이 보이고 한 수마다 1 만 오르내린다
const TILE_N = 4;
const SHUFFLE_03 = 30;
const SEEDS_03 = [7, 6, 12, 18];
const SLIDE_S = 0.12;
const MOVE_GAP = 0.14;
const LAP_S = 6.6;
const REST_S = 2.6;
const PX = 256;
const PW = 216;
let hHist: number[] = [];
let lastDelta = 0;
let hTop = 8;
const STAT_H = '거리 합 H {a}, 마지막 수의 변화 {b}';
const SPK_H = '거리 합 H 는 {a}';
function noteH(): void {
const h = sumH(bd, BN);
if (hHist.length > 0) { lastDelta = h - hHist[hHist.length - 1]; }
hHist.push(h);
}
function moveAt(d: number): () => void {
return function (): void {
if (stepBlank(d, SLIDE_S)) { noteH(); }
};
}
function build(): void {
hHist = [];
lastDelta = 0;
const ms = walkMoves(TILE_N, SHUFFLE_03, SEEDS_03[lap % SEEDS_03.length]);
const b = solvedBoard(TILE_N);
applyMoves(b, TILE_N, ms);
const found = idaStar(b, TILE_N, 60000);
const path = found.path.length > 0 ? found.path : reverseMoves(ms);
// 꺾은선의 세로 범위 — 길을 한 번 미리 걸어 H 의 최댓값을 잰다
const probe = b.slice();
hTop = sumH(probe, TILE_N);
for (let k = 0; k < path.length; k += 1) {
blankStep(probe, TILE_N, path[k]);
hTop = Math.max(hTop, sumH(probe, TILE_N));
}
hTop += 1;
setBoard(b, TILE_N);
noteH();
for (let k = 0; k < path.length; k += 1) { script.push({ t: 0.3 + k * MOVE_GAP, f: moveAt(path[k]) }); }
}
function update(dt: number): void { if (dt < 0) { return; } }
// 거리 0 은 연한 민트, 중간은 노랑, 멀수록 장미 — 숫자 글자는 늘 잉크색이라 어느 칸에서도 대비가 충분하다
function heatColor(d: number): RGB {
const t = clamp(d / 6, 0, 1);
const lo: RGB = [196, 236, 214];
const mid: RGB = [250, 226, 152];
const hi: RGB = [244, 152, 178];
const a = t < 0.5 ? lo : mid;
const b = t < 0.5 ? mid : hi;
const k = t < 0.5 ? t * 2 : (t - 0.5) * 2;
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
function draw(): void {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoardBase();
// 색은 화면에 보이는 자리에서 제자리까지의 거리(소수) — 타일이 미끄러지는 동안 색도 같이 연속으로 변한다
drawTiles(function (v: number): RGB {
const p = tilePos(v);
const hx = (v - 1) % BN;
const hy = Math.floor((v - 1) / BN);
return heatColor(Math.abs(p[0] - hx) + Math.abs(p[1] - hy));
});
for (let v = 1; v < BN * BN; v += 1) {
const p = tilePos(v);
const i = bd.indexOf(v);
text(String(manhattan(bd, BN, i)), BX + p[0] * CS + CS - 10, BY + p[1] * CS + 11, INK, 9, 800, 'center');
}
const s = CS * BN;
panel(PX, BY - 5, PW, s + 10);
const h = sumH(bd, BN);
text('sumH', PX + 12, BY + 14, DIM, 11, 700, 'left');
text(String(h), PX + PW - 12, BY + 16, NEU, 26, 800, 'right');
const dl = hHist.length > 1 ? lastDelta : 0;
box(PX + 12, BY + 30, 60, 18, 9, dl > 0 ? ROSE : (dl < 0 ? MINT : [88, 82, 122]), 1);
text((dl > 0 ? '+' : '') + dl, PX + 42, BY + 39.5, dl === 0 ? NEU : INK, 11, 800, 'center');
if (h === 0) {
box(PX + 84, BY + 30, 56, 18, 9, MINT, 1);
text('완성', PX + 112, BY + 39.5, INK, 11, 800, 'center');
}
// 꺾은선 — 한 수마다 한 칸씩 오른쪽. 세로는 합 H
const cx = PX + 14;
const cy = BY + 60;
const cw = PW - 28;
const ch = 74;
box(cx, cy, cw, ch, 6, [48, 44, 74], 1);
const top = hTop;
const nx = 30;
for (let i = 1; i < hHist.length; i += 1) {
seg(cx + 6 + (i - 1) * (cw - 12) / (nx - 1), cy + ch - 6 - hHist[i - 1] / top * (ch - 12),
cx + 6 + i * (cw - 12) / (nx - 1), cy + ch - 6 - hHist[i] / top * (ch - 12), MINT, 1, 2);
}
if (hHist.length > 0) {
const li = hHist.length - 1;
disc(cx + 6 + li * (cw - 12) / (nx - 1), cy + ch - 6 - hHist[li] / top * (ch - 12), 4, ROSE, 1);
}
// 범례 — 거리 0 부터 6 까지
text('제자리까지 거리', PX + 12, BY + 150, DIM, 11, 700, 'left');
for (let d = 0; d <= 6; d += 1) {
const x = PX + 12 + d * 27;
box(x, BY + 164, 24, 20, 5, heatColor(d), 1);
text(String(d), x + 12, BY + 174.5, INK, 11, 800, 'center');
}
hud('manhattan', String(h), h / 40);
}
function statusText(): string {
const dl = hHist.length > 1 ? lastDelta : 0;
return fill(STAT_H, String(sumH(bd, BN)), (dl > 0 ? '+' : '') + dl);
}
function modeId(): number { return modeBase(Math.floor(sumH(bd, BN) / 6)); }
function speakText(): string { return fill(SPK_H, String(sumH(bd, BN))) + '. ' + speakBoard(); }
function onDir(d: number): void {
if (stepBlank(d, SLIDE_S)) { noteH(); }
}
function onPtr(kind: number, x: number, y: number): void {
if (kind !== 0) { return; }
const d = dirOfCell(cellAt(x, y));
if (d >= 0) { onDir(d); }
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'슬라이딩 퍼즐 시연 무대 — 방향키로 빈칸 옆 타일 이동, 타일 누르기도 가능, Enter 또는 Space 로 일시정지, N 으로 한 걸음, R 로 처음부터'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'4×4 판의 타일이 제자리까지의 거리에 따라 연한 민트에서 노랑, 장미색으로 칠해지고 타일 모서리에 그 거리 숫자가 적힌다. 오른쪽 패널에는 거리 합 H 의 큰 숫자, 마지막 수의 변화 플러스 1 또는 마이너스 1, 수마다 H 가 오르내리는 꺾은선, 거리 0 부터 6 까지의 색 범례가 있고 H 가 0 이 되면 완성이라고 적힌다. 아래 눈금은 상수 이름과 지금 값을 보이고, 움직임 줄이기에서는 타일이 바로 자리를 바꾸는 판 한 장만 그려진다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'슬라이딩 퍼즐 장면'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}03 · 元の位置までの距離を色で塗るマンハッタンのヒートマップ — 計測 累積面積 18.897% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function manhattan(b, n, i) {
const v = b[i];
if (v === 0) {
return 0;
}
const h = v - 1;
return Math.abs((i % n) - (h % n)) + Math.abs(Math.floor(i / n) - Math.floor(h / n));
}
四つの盤を測ると、一手ごとのHの変化は±1ずつで、終わりは0になります。種7の盤はH 22から始まって最短26手・ノード700で解け(上がった手2・下がった手24)、種18の盤はH 18・20手・ノード236で最も短くなりました。
04完成した盤から歩いて出る混ぜ方
完成した盤から始めて、空きマスを SHUFFLE_STEPS の60回、隣のマスへ動かす混ぜ方で、規則どおりに歩いて出た盤なので02番の判定は解けると出ます。いま来たマスへ戻る手は飛ばし、どの隣へ行くかは整数のハッシュで選びます。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="スライドパズルの舞台 — 矢印キーで空きマスの隣のタイルを動かす、タイルを押しても動く、EnterかSpaceで一時停止、Nで一歩、Rで最初から">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="完成した4×4の盤から空きマスが隣のマスへ60回歩いてタイルを混ぜる。いま来たマスへは戻らず、空きマスの通った道が白い線で残る。右のパネルには歩数の進行バー、でたらめに並べた配置十個のうち解けるものの数と、歩いて混ぜた盤十個のうち解けるものの数がチェックとバツで出て、一歩ごとの距離の合計Hの折れ線が描かれる。下の目盛りは定数名と今の値を示し、動きを減らす設定ではタイルがすぐ入れ替わる盤一枚だけを描きます"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">スライドパズルの場面</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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;
}
// 무대 상자 — 눌러서 한 번 깨뜨리는 곳(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause {
padding: $sp-1 $sp-3;
border: 2px solid $subject-cream;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 스크린리더에만 읽히는 낭독 문장 — 모드가 바뀔 때만 쓴다
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-ink;
color: $subject-cream;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면에만 있고 aria-live 는 늘 off
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 100px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 글리치 없는 원문 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
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;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause {
padding: 4px 12px;
border: 2px solid #fff7e6;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 100px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function easeOut(k) { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const SKY = [150, 192, 244];
const SUN = [250, 218, 146];
const NEU = [236, 230, 250];
const BGC = [46, 42, 68];
const PANEL = [62, 57, 92];
const DIM = [196, 190, 224];
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function hsl(h, s, l) {
const a = s * Math.min(l, 1 - l);
const f = function (n) {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a, b) {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s, a, b, c) {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v) { return String(Math.round(v * 10) / 10); }
function group(v) {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
o += ',';
}
o += s.charAt(i);
}
return o;
}
// ── 퍼즐 모델 — 판은 번호 배열, 0 은 빈칸, 타일 v 의 제자리는 v-1 번 칸, 빈칸의 제자리는 맨 끝
// 빈칸이 움직이는 방향 d: 0 오른쪽 · 1 왼쪽 · 2 아래 · 3 위. 반대 방향은 d ^ 1
const DX = [1, -1, 0, 0];
const DY = [0, 0, 1, -1];
function solvedBoard(n) {
const b = [];
for (let i = 1; i < n * n; i += 1) {
b.push(i);
}
b.push(0);
return b;
}
function canStep(b, n, d) {
const z = b.indexOf(0);
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
return c >= 0 && c < n && r >= 0 && r < n;
}
function blankStep(b, n, d) {
if (!canStep(b, n, d)) {
return false;
}
const z = b.indexOf(0);
const t = z + DX[d] + DY[d] * n;
b[z] = b[t];
b[t] = 0;
return true;
}
// 역전 — 번호를 한 줄로 늘어놓고 앞 번호가 뒤 번호보다 큰 쌍의 수(빈칸은 세지 않는다)
function inversions(b) {
let c = 0;
for (let i = 0; i < b.length; i += 1) {
for (let j = i + 1; j < b.length; j += 1) {
if (b[i] !== 0 && b[j] !== 0 && b[i] > b[j]) {
c += 1;
}
}
}
return c;
}
// 빈칸이 아래서 몇 번째 줄인지(맨 아래 줄 = 1)
function blankRow(b, n) { return n - Math.floor(b.indexOf(0) / n); }
// 판정 합 — 짝수 폭은 역전 + 빈칸 줄, 홀수 폭은 역전만
function parity(b, n) { return n % 2 === 0 ? inversions(b) + blankRow(b, n) : inversions(b); }
function solvable(b, n) {
return n % 2 === 0 ? parity(b, n) % 2 === 1 : parity(b, n) % 2 === 0;
}
function manhattan(b, n, i) {
const v = b[i];
if (v === 0) {
return 0;
}
const h = v - 1;
return Math.abs((i % n) - (h % n)) + Math.abs(Math.floor(i / n) - Math.floor(h / n));
}
function sumH(b, n) {
let s = 0;
for (let i = 0; i < b.length; i += 1) {
s += manhattan(b, n, i);
}
return s;
}
function placedCount(b) {
let c = 0;
for (let i = 0; i < b.length - 1; i += 1) {
if (b[i] === i + 1) {
c += 1;
}
}
return c;
}
function isSolved(b) { return placedCount(b) === b.length - 1 && b[b.length - 1] === 0; }
// 歩いて混ぜる — 完成 판에서 시작해 빈칸을 steps 번 이웃 칸으로 옮긴다. 방금 온 칸으로 되돌아가는 수는 건너뛰고
// 어느 이웃으로 갈지는 정수 해시로 고른다. 반환은 빈칸이 움직인 방향의 목록
function walkMoves(n, steps, seed) {
const b = solvedBoard(n);
const out = [];
let noBacktrack = -1;
for (let s = 0; s < steps; s += 1) {
const opts = [];
for (let d = 0; d < 4; d += 1) {
if (d !== noBacktrack && canStep(b, n, d)) {
opts.push(d);
}
}
const d = opts[Math.floor(hash2(s, seed) * opts.length)];
blankStep(b, n, d);
out.push(d);
noBacktrack = d ^ 1;
}
return out;
}
function applyMoves(b, n, ms) {
for (let i = 0; i < ms.length; i += 1) {
blankStep(b, n, ms[i]);
}
}
function reverseMoves(ms) {
const o = [];
for (let i = ms.length - 1; i >= 0; i -= 1) {
o.push(ms[i] ^ 1);
}
return o;
}
// 줄째 밀기 — 누른 칸 i 가 빈칸과 같은 줄이면 사이 타일이 모두 한 칸씩 빈칸 쪽으로. 배열 교환으로 먼저 정하고
// 옮겨진 타일 번호를 빈칸에 가까운 순서로 돌려준다
function rowChain(b, n, i) {
const z = b.indexOf(0);
if (i === z) {
return [];
}
let step = 0;
if (Math.floor(i / n) === Math.floor(z / n)) {
step = i > z ? 1 : -1;
}
else if (i % n === z % n) {
step = i > z ? n : -n;
}
else {
return [];
}
const moved = [];
for (let p = z; p !== i; p += step) {
b[p] = b[p + step];
moved.push(b[p]);
}
b[i] = 0;
return moved;
}
function idaStar(start, n, cap) {
const b = start.slice();
const path = [];
let nodes = 0;
let capHit = false;
const INF = 9999;
let z = b.indexOf(0);
function dfs(g, h, bound, last) {
nodes += 1;
if (nodes > cap) {
capHit = true;
return INF;
}
const f = g + h;
if (f > bound) {
return f;
}
if (h === 0) {
return -1;
}
let min = INF;
for (let d = 0; d < 4; d += 1) {
if (last >= 0 && d === (last ^ 1)) {
continue;
}
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
if (c < 0 || c >= n || r < 0 || r >= n) {
continue;
}
const t = z + DX[d] + DY[d] * n;
const v = b[t];
const hv = Math.abs((t % n) - ((v - 1) % n)) + Math.abs(Math.floor(t / n) - Math.floor((v - 1) / n));
const hn = Math.abs((z % n) - ((v - 1) % n)) + Math.abs(Math.floor(z / n) - Math.floor((v - 1) / n));
b[z] = v;
b[t] = 0;
const oz = z;
z = t;
path.push(d);
const res = dfs(g + 1, h - hv + hn, bound, d);
if (res === -1) {
return -1;
}
path.pop();
z = oz;
b[t] = v;
b[z] = 0;
if (capHit) {
return INF;
}
if (res < min) {
min = res;
}
}
return min;
}
let bound = sumH(b, n);
const bounds = [];
const perBound = [];
while (bound < INF) {
const before = nodes;
bounds.push(bound);
const res = dfs(0, sumH(b, n), bound, -1);
perBound.push(nodes - before);
if (res === -1) {
return { path: path.slice(), nodes: nodes, bounds: bounds, perBound: perBound, capHit: false };
}
if (capHit || res >= INF) {
break;
}
bound = res;
}
return { path: [], nodes: nodes, bounds: bounds, perBound: perBound, capHit: capHit };
}
// ── 판 화면 — 타일마다 지금 칸 → 목표 칸 보간. 배열은 먼저 바뀌고 화면이 뒤따른다
const BX = 24;
const BY = 44;
const BSZ = 216;
let bd = [];
let BN = 4;
let CS = 54;
let tl = [];
let dragV = -1;
let dragCx = 0;
let dragCy = 0;
function setBoard(b, n) {
bd = b.slice();
BN = n;
CS = BSZ / n;
tl = [];
dragV = -1;
for (let v = 0; v < n * n; v += 1) {
const i = bd.indexOf(v);
const c = i % n;
const r = Math.floor(i / n);
tl.push({ fx: c, fy: r, tx: c, ty: r, t0: 0, du: 0 });
}
}
let still = false;
function tilePos(v) {
const t = tl[v];
if (v === dragV) {
return [dragCx, dragCy];
}
if (still || t.du <= 0) {
return [t.tx, t.ty];
}
const e = easeOut((sceneT - t.t0) / t.du);
return [lerp(t.fx, t.tx, e), lerp(t.fy, t.ty, e)];
}
// 배열이 바뀐 뒤 타일마다 목표 칸을 다시 읽는다 — 옮겨진 타일만 지금 화면 자리에서 출발한다
function retarget(delayOf, du) {
for (let v = 1; v < BN * BN; v += 1) {
const i = bd.indexOf(v);
const c = i % BN;
const r = Math.floor(i / BN);
const t = tl[v];
if (c !== t.tx || r !== t.ty) {
const p = tilePos(v);
t.fx = p[0];
t.fy = p[1];
t.tx = c;
t.ty = r;
t.t0 = sceneT + delayOf(v);
t.du = du;
}
}
}
function stepBlank(d, du) {
if (!blankStep(bd, BN, d)) {
return false;
}
retarget(function () { return 0; }, du);
return true;
}
function cellAt(x, y) {
const c = Math.floor((x - BX) / CS);
const r = Math.floor((y - BY) / CS);
if (c < 0 || c >= BN || r < 0 || r >= BN) {
return -1;
}
return r * BN + c;
}
// ── 그리기 도구
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function disc(x, y, r, c, a) {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, c, a, w) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s, x, y, c, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x, y, w, h) { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name, val, frac) {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// 판 바탕 — 받침과 빈 칸 홈
function drawBoardBase() {
const s = CS * BN;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < BN * BN; i += 1) {
box(BX + (i % BN) * CS + 2, BY + Math.floor(i / BN) * CS + 2, CS - 4, CS - 4, 8, [40, 36, 62], 1);
}
}
function tileHue(v, n) {
return hsl(190 + 160 * ((v - 1) / (n * n - 2)), 0.55, 0.8);
}
function paintTile(label, cx, cy, c, cs, ox, oy) {
const s = cs - 4;
const x = ox + cx * cs + 2;
const y = oy + cy * cs + 2;
box(x, y + 2, s, s, 8, [26, 23, 42], 0.5);
box(x, y, s, s, 8, c, 1);
box(x + 3, y + 2, s - 6, 3, 1.5, [255, 255, 255], 0.4);
text(label, x + s / 2, y + s / 2 + 1, INK, Math.max(9, Math.round(cs * 0.38)), 800, 'center');
}
function drawTiles(fillOf) {
for (let v = 1; v < BN * BN; v += 1) {
const p = tilePos(v);
paintTile(String(v), p[0], p[1], fillOf(v), CS, BX, BY);
}
}
// 글자판 없이 배열만 그리는 작은 판 — 비교 장면용
function drawMini(b, n, ox, oy, size) {
const cs = size / n;
box(ox - 3, oy - 3, size + 6, size + 6, 8, PANEL, 1);
for (let i = 0; i < n * n; i += 1) {
const v = b[i];
if (v === 0) {
box(ox + (i % n) * cs + 1, oy + Math.floor(i / n) * cs + 1, cs - 2, cs - 2, 3, [40, 36, 62], 1);
continue;
}
const x = ox + (i % n) * cs + 1;
const y = oy + Math.floor(i / n) * cs + 1;
box(x, y, cs - 2, cs - 2, 3, tileHue(v, n), 1);
if (cs >= 20) {
text(String(v), x + (cs - 2) / 2, y + (cs - 2) / 2 + 1, INK, Math.round(cs * 0.4), 800, 'center');
}
}
}
// ── 상태 줄·낭독 문장
const SPK_PLACE = '{a}×{a} の盤、タイル {b} 枚のうち {c} 枚が元の位置にある';
const SPK_DONE = '{a}×{a} の盤が完成、タイル {b} 枚すべてが元の位置にある';
function speakBoard() {
const t = BN * BN - 1;
return isSolved(bd) ? fill(SPK_DONE, String(BN), String(t)) : fill(SPK_PLACE, String(BN), String(t), String(placedCount(bd)));
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
}
function takeOver() {
if (manual) {
return;
}
manual = true;
wake();
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 맞은 타일 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode) {
if (isSolved(bd)) {
return 1000;
}
return manual ? 100 + placedCount(bd) : sceneMode;
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say() {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
function advance(dt) {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
}
update(dt);
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow() {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) {
say();
}
}
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest() {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak) {
if (paused || still) {
sceneT += 1;
}
update(0);
showNow();
if (speak) {
say();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
say();
}
function restart() {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// N 한 걸음 — 시간표의 다음 일 하나를 지금 치른다. 시간표가 끝났으면 처음부터 다시 짠 뒤 첫 일
function stepOnce() {
takeOver();
if (evAt >= script.length) {
lap += 1;
begin();
}
if (evAt < script.length) {
const ev = script[evAt];
evAt += 1;
if (sceneT < ev.t) {
sceneT = ev.t;
}
ev.f();
}
afterInput(true);
}
function keyMove(dx, dy) {
// 방향키는 타일이 가는 방향 — 그 타일은 빈칸의 반대쪽에 있다. 빈칸이 가는 방향으로 바꿔 부른다
let d = 0;
for (let k = 0; k < 4; k += 1) {
if (DX[k] === -dx && DY[k] === -dy) {
d = k;
}
}
takeOver();
onDir(d);
afterInput(true);
}
function toCanvas(e) {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 빈칸 이웃 타일 이동, Enter·Space = 一時停止, N = 한 걸음, R = 처음부터
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'r' || k === 'R') {
restart();
}
else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
}
else if (k === 'ArrowLeft') {
e.preventDefault();
keyMove(-1, 0);
}
else if (k === 'ArrowRight') {
e.preventDefault();
keyMove(1, 0);
}
else if (k === 'ArrowUp') {
e.preventDefault();
keyMove(0, -1);
}
else if (k === 'ArrowDown') {
e.preventDefault();
keyMove(0, 1);
}
else if (keyHook !== null && keyHook(k)) {
e.preventDefault();
takeOver();
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
// 장면이 방향키·Enter·N·R 밖의 키를 받고 싶을 때 거는 갈고리 — 먹었으면 true
let keyHook = null;
pad.addEventListener('pointerdown', function (e) {
takeOver();
ptrDown = true;
try {
pad.setPointerCapture(e.pointerId);
}
catch (err) {
ptrDown = true;
}
const p = toCanvas(e);
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (!ptrDown) {
return;
}
const p = toCanvas(e);
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e) {
if (!ptrDown) {
return;
}
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 판 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// 누른 칸이 빈칸의 이웃이면 그 타일이 들어갈 빈칸의 방향(빈칸 기준), 아니면 -1
function dirOfCell(i) {
const z = bd.indexOf(0);
for (let d = 0; d < 4; d += 1) {
if (canStep(bd, BN, d) && z + DX[d] + DY[d] * BN === i) {
return d;
}
}
return -1;
}
// 04 — 完成 판에서 시작해 빈칸을 SHUFFLE_STEPS 번 이웃 칸으로 옮긴다. 방금 온 칸으로 되돌아가는 수는 건너뛰고(noBacktrack) 어느 이웃으로 갈지는 정수 해시로 고른다. 규칙대로 걸어 나온 판은 풀 수 있다
const TILE_N = 4;
const SHUFFLE_STEPS = 60;
const SLIDE_S = 0.07;
const STEP_GAP = 0.05;
const SAMPLES = 10;
const LAP_S = 6.4;
const REST_S = 2.4;
const PX = 256;
const PW = 216;
let walkList = [];
let stepNo = 0;
let blankPath = [];
let hs = [];
let randOk = [];
let walkOk = [];
const STAT_WALK = '混ぜ {a}/{b} 手目、判定はずっと解ける';
function randomLayout(t) {
// 0 ~ 15 를 해시로 뒤섞는 무작위 배치 — 합법 이동으로 만든 판이 아니다
const b = [];
for (let i = 0; i < TILE_N * TILE_N; i += 1) {
b.push(i);
}
for (let i = b.length - 1; i > 0; i -= 1) {
const j = Math.floor(hash2(i, 100 + t * 7 + lap * 31) * (i + 1));
const x = b[i];
b[i] = b[j];
b[j] = x;
}
return b;
}
function build() {
stepNo = 0;
blankPath = [];
hs = [];
walkList = walkMoves(TILE_N, SHUFFLE_STEPS, 41 + lap);
setBoard(solvedBoard(TILE_N), TILE_N);
blankPath.push(bd.indexOf(0));
hs.push(0);
randOk = [];
walkOk = [];
for (let t = 0; t < SAMPLES; t += 1) {
randOk.push(solvable(randomLayout(t), TILE_N));
const w = solvedBoard(TILE_N);
applyMoves(w, TILE_N, walkMoves(TILE_N, SHUFFLE_STEPS, 200 + t + lap * 17));
walkOk.push(solvable(w, TILE_N));
}
for (let k = 0; k < SHUFFLE_STEPS; k += 1) {
script.push({ t: 0.2 + k * STEP_GAP, f: stepAt(k) });
}
}
function stepAt(k) {
return function () {
if (stepBlank(walkList[k], SLIDE_S)) {
stepNo += 1;
blankPath.push(bd.indexOf(0));
hs.push(sumH(bd, BN));
}
};
}
function update(dt) { if (dt < 0) {
return;
} }
function mark(x, y, ok, a) {
box(x, y, 14, 14, 4, ok ? MINT : ROSE, a);
if (ok) {
seg(x + 3.5, y + 7.5, x + 6, y + 10, INK, a, 2);
seg(x + 6, y + 10, x + 10.5, y + 4, INK, a, 2);
}
else {
seg(x + 4, y + 4, x + 10, y + 10, INK, a, 2);
seg(x + 10, y + 4, x + 4, y + 10, INK, a, 2);
}
}
function draw() {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoardBase();
drawTiles(function (v) { return tileHue(v, BN); });
// 빈칸이 걸어간 길 — 최근 14칸. 오래된 칸일수록 옅다
const from = Math.max(0, blankPath.length - 14);
for (let i = from + 1; i < blankPath.length; i += 1) {
const a = (i - from) / 14;
const p0 = blankPath[i - 1];
const p1 = blankPath[i];
seg(BX + ((p0 % BN) + 0.5) * CS, BY + (Math.floor(p0 / BN) + 0.5) * CS, BX + ((p1 % BN) + 0.5) * CS, BY + (Math.floor(p1 / BN) + 0.5) * CS, PAPER, a, 3);
}
const s = CS * BN;
panel(PX, BY - 5, PW, s + 10);
text('SHUFFLE_STEPS', PX + 12, BY + 12, DIM, 11, 700, 'left');
text(stepNo + ' / ' + SHUFFLE_STEPS, PX + PW - 12, BY + 12, NEU, 15, 800, 'right');
box(PX + 12, BY + 26, PW - 24, 8, 4, [48, 44, 74], 1);
box(PX + 12, BY + 26, (PW - 24) * stepNo / SHUFFLE_STEPS, 8, 4, MINT, 1);
// 두 줄 비교 — 위는 해시로 뒤섞은 배치, 아래는 걸어서 섞은 판. 칸마다 02번 판정을 돌린 결과
const reveal = Math.floor((sceneT - 0.3) * 7);
let rc = 0;
let wc = 0;
for (let t = 0; t < SAMPLES; t += 1) {
if (t < reveal) {
if (randOk[t]) {
rc += 1;
}
if (walkOk[t]) {
wc += 1;
}
}
}
text('でたらめに並べる', PX + 12, BY + 52, DIM, 11, 700, 'left');
text(rc + ' / ' + Math.max(0, Math.min(reveal, SAMPLES)), PX + PW - 12, BY + 52, NEU, 11, 800, 'right');
text('歩いて混ぜる', PX + 12, BY + 98, DIM, 11, 700, 'left');
text(wc + ' / ' + Math.max(0, Math.min(reveal, SAMPLES)), PX + PW - 12, BY + 98, NEU, 11, 800, 'right');
for (let t = 0; t < SAMPLES; t += 1) {
const x = PX + 12 + t * 19.5;
const shown = clamp((sceneT - 0.3) * 7 - t, 0, 1);
box(x, BY + 62, 14, 14, 4, [48, 44, 74], 1);
box(x, BY + 108, 14, 14, 4, [48, 44, 74], 1);
if (shown > 0) {
mark(x, BY + 62, randOk[t], shown);
mark(x, BY + 108, walkOk[t], shown);
}
}
// 걸음마다 거리 합 H — 걸을수록 完成 판에서 멀어졌다 가까워졌다 한다
const cx = PX + 14;
const cy = BY + 138;
const cw = PW - 28;
const ch = 70;
box(cx, cy, cw, ch, 6, [48, 44, 74], 1);
for (let i = 1; i < Math.min(hs.length, SHUFFLE_STEPS + 1); i += 1) {
seg(cx + 6 + (i - 1) * (cw - 12) / SHUFFLE_STEPS, cy + ch - 6 - hs[i - 1] / 40 * (ch - 12), cx + 6 + i * (cw - 12) / SHUFFLE_STEPS, cy + ch - 6 - hs[i] / 40 * (ch - 12), SKY, 1, 2);
}
text('sumH', cx + 8, cy + 10, DIM, 10, 700, 'left');
hud('SHUFFLE_STEPS', String(SHUFFLE_STEPS), SHUFFLE_STEPS / 120);
}
function statusText() { return fill(STAT_WALK, String(stepNo), String(SHUFFLE_STEPS)); }
function modeId() { return modeBase(stepNo >= SHUFFLE_STEPS ? 1 : 0); }
function speakText() { return statusText() + '. ' + speakBoard(); }
function onDir(d) {
if (stepBlank(d, SLIDE_S)) {
blankPath.push(bd.indexOf(0));
hs.push(sumH(bd, BN));
}
}
function onPtr(kind, x, y) {
if (kind !== 0) {
return;
}
const d = dirOfCell(cellAt(x, y));
if (d >= 0) {
onDir(d);
}
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// LegalWalkShuffle - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./LegalWalkShuffle.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function LegalWalkShuffle() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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 easeOut(k: number): number { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const SKY: RGB = [150, 192, 244];
const SUN: RGB = [250, 218, 146];
const NEU: RGB = [236, 230, 250];
const BGC: RGB = [46, 42, 68];
const PANEL: RGB = [62, 57, 92];
const DIM: RGB = [196, 190, 224];
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function hsl(h: number, s: number, l: number): RGB {
const a = s * Math.min(l, 1 - l);
const f = function (n: number): number {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a: number, b: number): number {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s: string, a: string, b?: string, c?: string): string {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v: number): string { return String(Math.round(v * 10) / 10); }
function group(v: number): string {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { o += ','; }
o += s.charAt(i);
}
return o;
}
// ── 퍼즐 모델 — 판은 번호 배열, 0 은 빈칸, 타일 v 의 제자리는 v-1 번 칸, 빈칸의 제자리는 맨 끝
// 빈칸이 움직이는 방향 d: 0 오른쪽 · 1 왼쪽 · 2 아래 · 3 위. 반대 방향은 d ^ 1
const DX = [1, -1, 0, 0];
const DY = [0, 0, 1, -1];
function solvedBoard(n: number): number[] {
const b: number[] = [];
for (let i = 1; i < n * n; i += 1) { b.push(i); }
b.push(0);
return b;
}
function canStep(b: number[], n: number, d: number): boolean {
const z = b.indexOf(0);
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
return c >= 0 && c < n && r >= 0 && r < n;
}
function blankStep(b: number[], n: number, d: number): boolean {
if (!canStep(b, n, d)) { return false; }
const z = b.indexOf(0);
const t = z + DX[d] + DY[d] * n;
b[z] = b[t];
b[t] = 0;
return true;
}
// 역전 — 번호를 한 줄로 늘어놓고 앞 번호가 뒤 번호보다 큰 쌍의 수(빈칸은 세지 않는다)
function inversions(b: number[]): number {
let c = 0;
for (let i = 0; i < b.length; i += 1) {
for (let j = i + 1; j < b.length; j += 1) {
if (b[i] !== 0 && b[j] !== 0 && b[i] > b[j]) { c += 1; }
}
}
return c;
}
// 빈칸이 아래서 몇 번째 줄인지(맨 아래 줄 = 1)
function blankRow(b: number[], n: number): number { return n - Math.floor(b.indexOf(0) / n); }
// 판정 합 — 짝수 폭은 역전 + 빈칸 줄, 홀수 폭은 역전만
function parity(b: number[], n: number): number { return n % 2 === 0 ? inversions(b) + blankRow(b, n) : inversions(b); }
function solvable(b: number[], n: number): boolean {
return n % 2 === 0 ? parity(b, n) % 2 === 1 : parity(b, n) % 2 === 0;
}
function manhattan(b: number[], n: number, i: number): number {
const v = b[i];
if (v === 0) { return 0; }
const h = v - 1;
return Math.abs((i % n) - (h % n)) + Math.abs(Math.floor(i / n) - Math.floor(h / n));
}
function sumH(b: number[], n: number): number {
let s = 0;
for (let i = 0; i < b.length; i += 1) { s += manhattan(b, n, i); }
return s;
}
function placedCount(b: number[]): number {
let c = 0;
for (let i = 0; i < b.length - 1; i += 1) { if (b[i] === i + 1) { c += 1; } }
return c;
}
function isSolved(b: number[]): boolean { return placedCount(b) === b.length - 1 && b[b.length - 1] === 0; }
// 걸어서 섞기 — 완성 판에서 시작해 빈칸을 steps 번 이웃 칸으로 옮긴다. 방금 온 칸으로 되돌아가는 수는 건너뛰고
// 어느 이웃으로 갈지는 정수 해시로 고른다. 반환은 빈칸이 움직인 방향의 목록
function walkMoves(n: number, steps: number, seed: number): number[] {
const b = solvedBoard(n);
const out: number[] = [];
let noBacktrack = -1;
for (let s = 0; s < steps; s += 1) {
const opts: number[] = [];
for (let d = 0; d < 4; d += 1) { if (d !== noBacktrack && canStep(b, n, d)) { opts.push(d); } }
const d = opts[Math.floor(hash2(s, seed) * opts.length)];
blankStep(b, n, d);
out.push(d);
noBacktrack = d ^ 1;
}
return out;
}
function applyMoves(b: number[], n: number, ms: number[]): void {
for (let i = 0; i < ms.length; i += 1) { blankStep(b, n, ms[i]); }
}
function reverseMoves(ms: number[]): number[] {
const o: number[] = [];
for (let i = ms.length - 1; i >= 0; i -= 1) { o.push(ms[i] ^ 1); }
return o;
}
// 줄째 밀기 — 누른 칸 i 가 빈칸과 같은 줄이면 사이 타일이 모두 한 칸씩 빈칸 쪽으로. 배열 교환으로 먼저 정하고
// 옮겨진 타일 번호를 빈칸에 가까운 순서로 돌려준다
function rowChain(b: number[], n: number, i: number): number[] {
const z = b.indexOf(0);
if (i === z) { return []; }
let step = 0;
if (Math.floor(i / n) === Math.floor(z / n)) { step = i > z ? 1 : -1; } else if (i % n === z % n) { step = i > z ? n : -n; } else { return []; }
const moved: number[] = [];
for (let p = z; p !== i; p += step) {
b[p] = b[p + step];
moved.push(b[p]);
}
b[i] = 0;
return moved;
}
// 반복 깊이 탐색 — 맨해튼 거리 합 h 를 길잡이로 깊이 우선 탐색하고, 수와 h 의 합이 한도(bound)를 넘으면 가지를 접는다.
// 한 바퀴가 끝나면 한도를 넘긴 값 중 가장 작은 것으로 올려 다시 돈다. 노드 수가 cap 에 닿으면 멈춘다
type Ida = { path: number[]; nodes: number; bounds: number[]; perBound: number[]; capHit: boolean };
function idaStar(start: number[], n: number, cap: number): Ida {
const b = start.slice();
const path: number[] = [];
let nodes = 0;
let capHit = false;
const INF = 9999;
let z = b.indexOf(0);
function dfs(g: number, h: number, bound: number, last: number): number {
nodes += 1;
if (nodes > cap) { capHit = true; return INF; }
const f = g + h;
if (f > bound) { return f; }
if (h === 0) { return -1; }
let min = INF;
for (let d = 0; d < 4; d += 1) {
if (last >= 0 && d === (last ^ 1)) { continue; }
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
if (c < 0 || c >= n || r < 0 || r >= n) { continue; }
const t = z + DX[d] + DY[d] * n;
const v = b[t];
const hv = Math.abs((t % n) - ((v - 1) % n)) + Math.abs(Math.floor(t / n) - Math.floor((v - 1) / n));
const hn = Math.abs((z % n) - ((v - 1) % n)) + Math.abs(Math.floor(z / n) - Math.floor((v - 1) / n));
b[z] = v;
b[t] = 0;
const oz = z;
z = t;
path.push(d);
const res = dfs(g + 1, h - hv + hn, bound, d);
if (res === -1) { return -1; }
path.pop();
z = oz;
b[t] = v;
b[z] = 0;
if (capHit) { return INF; }
if (res < min) { min = res; }
}
return min;
}
let bound = sumH(b, n);
const bounds: number[] = [];
const perBound: number[] = [];
while (bound < INF) {
const before = nodes;
bounds.push(bound);
const res = dfs(0, sumH(b, n), bound, -1);
perBound.push(nodes - before);
if (res === -1) { return { path: path.slice(), nodes: nodes, bounds: bounds, perBound: perBound, capHit: false }; }
if (capHit || res >= INF) { break; }
bound = res;
}
return { path: [], nodes: nodes, bounds: bounds, perBound: perBound, capHit: capHit };
}
// ── 판 화면 — 타일마다 지금 칸 → 목표 칸 보간. 배열은 먼저 바뀌고 화면이 뒤따른다
const BX = 24;
const BY = 44;
const BSZ = 216;
let bd: number[] = [];
let BN = 4;
let CS = 54;
type Tl = { fx: number; fy: number; tx: number; ty: number; t0: number; du: number };
let tl: Tl[] = [];
let dragV = -1;
let dragCx = 0;
let dragCy = 0;
function setBoard(b: number[], n: number): void {
bd = b.slice();
BN = n;
CS = BSZ / n;
tl = [];
dragV = -1;
for (let v = 0; v < n * n; v += 1) {
const i = bd.indexOf(v);
const c = i % n;
const r = Math.floor(i / n);
tl.push({ fx: c, fy: r, tx: c, ty: r, t0: 0, du: 0 });
}
}
let still = false;
function tilePos(v: number): number[] {
const t = tl[v];
if (v === dragV) { return [dragCx, dragCy]; }
if (still || t.du <= 0) { return [t.tx, t.ty]; }
const e = easeOut((sceneT - t.t0) / t.du);
return [lerp(t.fx, t.tx, e), lerp(t.fy, t.ty, e)];
}
// 배열이 바뀐 뒤 타일마다 목표 칸을 다시 읽는다 — 옮겨진 타일만 지금 화면 자리에서 출발한다
function retarget(delayOf: (v: number) => number, du: number): void {
for (let v = 1; v < BN * BN; v += 1) {
const i = bd.indexOf(v);
const c = i % BN;
const r = Math.floor(i / BN);
const t = tl[v];
if (c !== t.tx || r !== t.ty) {
const p = tilePos(v);
t.fx = p[0];
t.fy = p[1];
t.tx = c;
t.ty = r;
t.t0 = sceneT + delayOf(v);
t.du = du;
}
}
}
function stepBlank(d: number, du: number): boolean {
if (!blankStep(bd, BN, d)) { return false; }
retarget(function (): number { return 0; }, du);
return true;
}
function cellAt(x: number, y: number): number {
const c = Math.floor((x - BX) / CS);
const r = Math.floor((y - BY) / CS);
if (c < 0 || c >= BN || r < 0 || r >= BN) { return -1; }
return r * BN + c;
}
// ── 그리기 도구
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, c: RGB, a: number, w: number): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x: number, y: number, w: number, h: number): void { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name: string, val: string, frac: number): void {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// 판 바탕 — 받침과 빈 칸 홈
function drawBoardBase(): void {
const s = CS * BN;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < BN * BN; i += 1) {
box(BX + (i % BN) * CS + 2, BY + Math.floor(i / BN) * CS + 2, CS - 4, CS - 4, 8, [40, 36, 62], 1);
}
}
function tileHue(v: number, n: number): RGB {
return hsl(190 + 160 * ((v - 1) / (n * n - 2)), 0.55, 0.8);
}
function paintTile(label: string, cx: number, cy: number, c: RGB, cs: number, ox: number, oy: number): void {
const s = cs - 4;
const x = ox + cx * cs + 2;
const y = oy + cy * cs + 2;
box(x, y + 2, s, s, 8, [26, 23, 42], 0.5);
box(x, y, s, s, 8, c, 1);
box(x + 3, y + 2, s - 6, 3, 1.5, [255, 255, 255], 0.4);
text(label, x + s / 2, y + s / 2 + 1, INK, Math.max(9, Math.round(cs * 0.38)), 800, 'center');
}
function drawTiles(fillOf: (v: number) => RGB): void {
for (let v = 1; v < BN * BN; v += 1) {
const p = tilePos(v);
paintTile(String(v), p[0], p[1], fillOf(v), CS, BX, BY);
}
}
// 글자판 없이 배열만 그리는 작은 판 — 비교 장면용
function drawMini(b: number[], n: number, ox: number, oy: number, size: number): void {
const cs = size / n;
box(ox - 3, oy - 3, size + 6, size + 6, 8, PANEL, 1);
for (let i = 0; i < n * n; i += 1) {
const v = b[i];
if (v === 0) { box(ox + (i % n) * cs + 1, oy + Math.floor(i / n) * cs + 1, cs - 2, cs - 2, 3, [40, 36, 62], 1); continue; }
const x = ox + (i % n) * cs + 1;
const y = oy + Math.floor(i / n) * cs + 1;
box(x, y, cs - 2, cs - 2, 3, tileHue(v, n), 1);
if (cs >= 20) { text(String(v), x + (cs - 2) / 2, y + (cs - 2) / 2 + 1, INK, Math.round(cs * 0.4), 800, 'center'); }
}
}
// ── 상태 줄·낭독 문장
const SPK_PLACE = '{a}×{a} 판, 타일 {b}장 중 {c}장이 제자리에 있다';
const SPK_DONE = '{a}×{a} 판 완성, 타일 {b}장이 모두 제자리에 있다';
function speakBoard(): string {
const t = BN * BN - 1;
return isSolved(bd) ? fill(SPK_DONE, String(BN), String(t)) : fill(SPK_PLACE, String(BN), String(t), String(placedCount(bd)));
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
}
function takeOver(): void {
if (manual) { return; }
manual = true;
wake();
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 맞은 타일 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode: number): number {
if (isSolved(bd)) { return 1000; }
return manual ? 100 + placedCount(bd) : sceneMode;
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say(): void {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
function advance(dt: number): void {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
}
update(dt);
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow(): void {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) { say(); }
}
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest(): void {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak: boolean): void {
if (paused || still) { sceneT += 1; }
update(0);
showNow();
if (speak) { say(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
say();
}
function restart(): void {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// N 한 걸음 — 시간표의 다음 일 하나를 지금 치른다. 시간표가 끝났으면 처음부터 다시 짠 뒤 첫 일
function stepOnce(): void {
takeOver();
if (evAt >= script.length) { lap += 1; begin(); }
if (evAt < script.length) {
const ev = script[evAt];
evAt += 1;
if (sceneT < ev.t) { sceneT = ev.t; }
ev.f();
}
afterInput(true);
}
function keyMove(dx: number, dy: number): void {
// 방향키는 타일이 가는 방향 — 그 타일은 빈칸의 반대쪽에 있다. 빈칸이 가는 방향으로 바꿔 부른다
let d = 0;
for (let k = 0; k < 4; k += 1) { if (DX[k] === -dx && DY[k] === -dy) { d = k; } }
takeOver();
onDir(d);
afterInput(true);
}
function toCanvas(e: PointerEvent): number[] {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 빈칸 이웃 타일 이동, Enter·Space = 일시정지, N = 한 걸음, R = 처음부터
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'r' || k === 'R') {
restart();
} else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
} else if (k === 'ArrowLeft') {
e.preventDefault();
keyMove(-1, 0);
} else if (k === 'ArrowRight') {
e.preventDefault();
keyMove(1, 0);
} else if (k === 'ArrowUp') {
e.preventDefault();
keyMove(0, -1);
} else if (k === 'ArrowDown') {
e.preventDefault();
keyMove(0, 1);
} else if (keyHook !== null && keyHook(k)) {
e.preventDefault();
takeOver();
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
// 장면이 방향키·Enter·N·R 밖의 키를 받고 싶을 때 거는 갈고리 — 먹었으면 true
let keyHook: ((k: string) => boolean) | null = null;
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
ptrDown = true;
try { pad.setPointerCapture(e.pointerId); } catch (err) { ptrDown = true; }
const p = toCanvas(e);
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (!ptrDown) { return; }
const p = toCanvas(e);
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e: PointerEvent): void {
if (!ptrDown) { return; }
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 판 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// 누른 칸이 빈칸의 이웃이면 그 타일이 들어갈 빈칸의 방향(빈칸 기준), 아니면 -1
function dirOfCell(i: number): number {
const z = bd.indexOf(0);
for (let d = 0; d < 4; d += 1) { if (canStep(bd, BN, d) && z + DX[d] + DY[d] * BN === i) { return d; } }
return -1;
}
// 04 — 완성 판에서 시작해 빈칸을 SHUFFLE_STEPS 번 이웃 칸으로 옮긴다. 방금 온 칸으로 되돌아가는 수는 건너뛰고(noBacktrack) 어느 이웃으로 갈지는 정수 해시로 고른다. 규칙대로 걸어 나온 판은 풀 수 있다
const TILE_N = 4;
const SHUFFLE_STEPS = 60;
const SLIDE_S = 0.07;
const STEP_GAP = 0.05;
const SAMPLES = 10;
const LAP_S = 6.4;
const REST_S = 2.4;
const PX = 256;
const PW = 216;
let walkList: number[] = [];
let stepNo = 0;
let blankPath: number[] = [];
let hs: number[] = [];
let randOk: boolean[] = [];
let walkOk: boolean[] = [];
const STAT_WALK = '섞기 {a}/{b}번째 수, 판정은 계속 풀 수 있음';
function randomLayout(t: number): number[] {
// 0 ~ 15 를 해시로 뒤섞는 무작위 배치 — 합법 이동으로 만든 판이 아니다
const b: number[] = [];
for (let i = 0; i < TILE_N * TILE_N; i += 1) { b.push(i); }
for (let i = b.length - 1; i > 0; i -= 1) {
const j = Math.floor(hash2(i, 100 + t * 7 + lap * 31) * (i + 1));
const x = b[i];
b[i] = b[j];
b[j] = x;
}
return b;
}
function build(): void {
stepNo = 0;
blankPath = [];
hs = [];
walkList = walkMoves(TILE_N, SHUFFLE_STEPS, 41 + lap);
setBoard(solvedBoard(TILE_N), TILE_N);
blankPath.push(bd.indexOf(0));
hs.push(0);
randOk = [];
walkOk = [];
for (let t = 0; t < SAMPLES; t += 1) {
randOk.push(solvable(randomLayout(t), TILE_N));
const w = solvedBoard(TILE_N);
applyMoves(w, TILE_N, walkMoves(TILE_N, SHUFFLE_STEPS, 200 + t + lap * 17));
walkOk.push(solvable(w, TILE_N));
}
for (let k = 0; k < SHUFFLE_STEPS; k += 1) { script.push({ t: 0.2 + k * STEP_GAP, f: stepAt(k) }); }
}
function stepAt(k: number): () => void {
return function (): void {
if (stepBlank(walkList[k], SLIDE_S)) {
stepNo += 1;
blankPath.push(bd.indexOf(0));
hs.push(sumH(bd, BN));
}
};
}
function update(dt: number): void { if (dt < 0) { return; } }
function mark(x: number, y: number, ok: boolean, a: number): void {
box(x, y, 14, 14, 4, ok ? MINT : ROSE, a);
if (ok) {
seg(x + 3.5, y + 7.5, x + 6, y + 10, INK, a, 2);
seg(x + 6, y + 10, x + 10.5, y + 4, INK, a, 2);
} else {
seg(x + 4, y + 4, x + 10, y + 10, INK, a, 2);
seg(x + 10, y + 4, x + 4, y + 10, INK, a, 2);
}
}
function draw(): void {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoardBase();
drawTiles(function (v: number): RGB { return tileHue(v, BN); });
// 빈칸이 걸어간 길 — 최근 14칸. 오래된 칸일수록 옅다
const from = Math.max(0, blankPath.length - 14);
for (let i = from + 1; i < blankPath.length; i += 1) {
const a = (i - from) / 14;
const p0 = blankPath[i - 1];
const p1 = blankPath[i];
seg(BX + ((p0 % BN) + 0.5) * CS, BY + (Math.floor(p0 / BN) + 0.5) * CS,
BX + ((p1 % BN) + 0.5) * CS, BY + (Math.floor(p1 / BN) + 0.5) * CS, PAPER, a, 3);
}
const s = CS * BN;
panel(PX, BY - 5, PW, s + 10);
text('SHUFFLE_STEPS', PX + 12, BY + 12, DIM, 11, 700, 'left');
text(stepNo + ' / ' + SHUFFLE_STEPS, PX + PW - 12, BY + 12, NEU, 15, 800, 'right');
box(PX + 12, BY + 26, PW - 24, 8, 4, [48, 44, 74], 1);
box(PX + 12, BY + 26, (PW - 24) * stepNo / SHUFFLE_STEPS, 8, 4, MINT, 1);
// 두 줄 비교 — 위는 해시로 뒤섞은 배치, 아래는 걸어서 섞은 판. 칸마다 02번 판정을 돌린 결과
const reveal = Math.floor((sceneT - 0.3) * 7);
let rc = 0;
let wc = 0;
for (let t = 0; t < SAMPLES; t += 1) {
if (t < reveal) {
if (randOk[t]) { rc += 1; }
if (walkOk[t]) { wc += 1; }
}
}
text('무작위로 섞기', PX + 12, BY + 52, DIM, 11, 700, 'left');
text(rc + ' / ' + Math.max(0, Math.min(reveal, SAMPLES)), PX + PW - 12, BY + 52, NEU, 11, 800, 'right');
text('걸어서 섞기', PX + 12, BY + 98, DIM, 11, 700, 'left');
text(wc + ' / ' + Math.max(0, Math.min(reveal, SAMPLES)), PX + PW - 12, BY + 98, NEU, 11, 800, 'right');
for (let t = 0; t < SAMPLES; t += 1) {
const x = PX + 12 + t * 19.5;
const shown = clamp((sceneT - 0.3) * 7 - t, 0, 1);
box(x, BY + 62, 14, 14, 4, [48, 44, 74], 1);
box(x, BY + 108, 14, 14, 4, [48, 44, 74], 1);
if (shown > 0) {
mark(x, BY + 62, randOk[t], shown);
mark(x, BY + 108, walkOk[t], shown);
}
}
// 걸음마다 거리 합 H — 걸을수록 완성 판에서 멀어졌다 가까워졌다 한다
const cx = PX + 14;
const cy = BY + 138;
const cw = PW - 28;
const ch = 70;
box(cx, cy, cw, ch, 6, [48, 44, 74], 1);
for (let i = 1; i < Math.min(hs.length, SHUFFLE_STEPS + 1); i += 1) {
seg(cx + 6 + (i - 1) * (cw - 12) / SHUFFLE_STEPS, cy + ch - 6 - hs[i - 1] / 40 * (ch - 12),
cx + 6 + i * (cw - 12) / SHUFFLE_STEPS, cy + ch - 6 - hs[i] / 40 * (ch - 12), SKY, 1, 2);
}
text('sumH', cx + 8, cy + 10, DIM, 10, 700, 'left');
hud('SHUFFLE_STEPS', String(SHUFFLE_STEPS), SHUFFLE_STEPS / 120);
}
function statusText(): string { return fill(STAT_WALK, String(stepNo), String(SHUFFLE_STEPS)); }
function modeId(): number { return modeBase(stepNo >= SHUFFLE_STEPS ? 1 : 0); }
function speakText(): string { return statusText() + '. ' + speakBoard(); }
function onDir(d: number): void {
if (stepBlank(d, SLIDE_S)) {
blankPath.push(bd.indexOf(0));
hs.push(sumH(bd, BN));
}
}
function onPtr(kind: number, x: number, y: number): void {
if (kind !== 0) { return; }
const d = dirOfCell(cellAt(x, y));
if (d >= 0) { onDir(d); }
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'슬라이딩 퍼즐 시연 무대 — 방향키로 빈칸 옆 타일 이동, 타일 누르기도 가능, Enter 또는 Space 로 일시정지, N 으로 한 걸음, R 로 처음부터'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'완성된 4×4 판에서 빈칸이 이웃 칸으로 60번 걸어 나가며 타일을 섞는다. 방금 온 칸으로 되돌아가지 않고 빈칸이 지난 길이 흰 선으로 남는다. 오른쪽 패널에는 걸음 수 진행 막대, 해시로 뒤섞은 배치 열 칸 중 풀 수 있는 판 수와 걸어서 섞은 판 열 칸 중 풀 수 있는 판 수가 체크와 가위표로 나오고, 걸음마다 거리 합 H 의 꺾은선이 그려진다. 아래 눈금은 상수 이름과 지금 값을 보이고, 움직임 줄이기에서는 타일이 바로 자리를 바꾸는 판 한 장만 그려진다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'슬라이딩 퍼즐 장면'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}04 · 完成した盤から歩いて出る混ぜ方 — 計測 累積面積 27.604% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function walkMoves(n, steps, seed) {
const b = solvedBoard(n);
const out = [];
let noBacktrack = -1;
for (let s = 0; s < steps; s += 1) {
const opts = [];
for (let d = 0; d < 4; d += 1) {
if (d !== noBacktrack && canStep(b, n, d)) {
opts.push(d);
}
}
const d = opts[Math.floor(hash2(s, seed) * opts.length)];
blankStep(b, n, d);
out.push(d);
noBacktrack = d ^ 1;
}
return out;
}
同じ種41の60歩を一歩ごとに調べると solvable は60/60で真、最初の10歩はHが一歩に1ずつ1から10まで上がり、60歩でH 30・元の位置にあるタイル0枚になります。比較に置いた上の段のハッシュによる無作為な配置は1,000盤中490盤(49.0%)だけが解けました。
05上限を引き上げながら最短手を探す反復深化探索
マンハッタン距離の合計Hを手がかりに深さ優先で探索し、ここまでの手数とHの合計が上限を超えたら枝を畳んで、上限を一段上げてやり直す方式です。ノード数が NODE_CAP の4000に届いたら止まったと示し、終わったら見つけた手を一手ずつ再生します。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="スライドパズルの舞台 — 矢印キーで空きマスの隣のタイルを動かす、タイルを押しても動く、EnterかSpaceで一時停止、Nで一歩、Rで最初から">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="混ぜた3×3の盤で反復深化探索がマンハッタン距離の上限を一段ずつ上げながら回る。右のパネルに上限ごとの棒とノード数、ノードの合計がNODE_CAPの4000のうちどれだけかを示す目盛りがあり、解を見つけると見つけた手を盤で一手ずつ再生する。最後に4×4の盤で同じ探索がノード4000個に届いて停止したと表示される。下の目盛りは定数名と今の値を示し、動きを減らす設定ではタイルがすぐ入れ替わる盤一枚だけを描きます"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">スライドパズルの場面</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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;
}
// 무대 상자 — 눌러서 한 번 깨뜨리는 곳(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause {
padding: $sp-1 $sp-3;
border: 2px solid $subject-cream;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 스크린리더에만 읽히는 낭독 문장 — 모드가 바뀔 때만 쓴다
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-ink;
color: $subject-cream;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면에만 있고 aria-live 는 늘 off
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 100px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 글리치 없는 원문 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
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;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause {
padding: 4px 12px;
border: 2px solid #fff7e6;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 100px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function easeOut(k) { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const SKY = [150, 192, 244];
const SUN = [250, 218, 146];
const NEU = [236, 230, 250];
const BGC = [46, 42, 68];
const PANEL = [62, 57, 92];
const DIM = [196, 190, 224];
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function hsl(h, s, l) {
const a = s * Math.min(l, 1 - l);
const f = function (n) {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a, b) {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s, a, b, c) {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v) { return String(Math.round(v * 10) / 10); }
function group(v) {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
o += ',';
}
o += s.charAt(i);
}
return o;
}
// ── 퍼즐 모델 — 판은 번호 배열, 0 은 빈칸, 타일 v 의 제자리는 v-1 번 칸, 빈칸의 제자리는 맨 끝
// 빈칸이 움직이는 방향 d: 0 오른쪽 · 1 왼쪽 · 2 아래 · 3 위. 반대 방향은 d ^ 1
const DX = [1, -1, 0, 0];
const DY = [0, 0, 1, -1];
function solvedBoard(n) {
const b = [];
for (let i = 1; i < n * n; i += 1) {
b.push(i);
}
b.push(0);
return b;
}
function canStep(b, n, d) {
const z = b.indexOf(0);
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
return c >= 0 && c < n && r >= 0 && r < n;
}
function blankStep(b, n, d) {
if (!canStep(b, n, d)) {
return false;
}
const z = b.indexOf(0);
const t = z + DX[d] + DY[d] * n;
b[z] = b[t];
b[t] = 0;
return true;
}
// 역전 — 번호를 한 줄로 늘어놓고 앞 번호가 뒤 번호보다 큰 쌍의 수(빈칸은 세지 않는다)
function inversions(b) {
let c = 0;
for (let i = 0; i < b.length; i += 1) {
for (let j = i + 1; j < b.length; j += 1) {
if (b[i] !== 0 && b[j] !== 0 && b[i] > b[j]) {
c += 1;
}
}
}
return c;
}
// 빈칸이 아래서 몇 번째 줄인지(맨 아래 줄 = 1)
function blankRow(b, n) { return n - Math.floor(b.indexOf(0) / n); }
// 판정 합 — 짝수 폭은 역전 + 빈칸 줄, 홀수 폭은 역전만
function parity(b, n) { return n % 2 === 0 ? inversions(b) + blankRow(b, n) : inversions(b); }
function solvable(b, n) {
return n % 2 === 0 ? parity(b, n) % 2 === 1 : parity(b, n) % 2 === 0;
}
function manhattan(b, n, i) {
const v = b[i];
if (v === 0) {
return 0;
}
const h = v - 1;
return Math.abs((i % n) - (h % n)) + Math.abs(Math.floor(i / n) - Math.floor(h / n));
}
function sumH(b, n) {
let s = 0;
for (let i = 0; i < b.length; i += 1) {
s += manhattan(b, n, i);
}
return s;
}
function placedCount(b) {
let c = 0;
for (let i = 0; i < b.length - 1; i += 1) {
if (b[i] === i + 1) {
c += 1;
}
}
return c;
}
function isSolved(b) { return placedCount(b) === b.length - 1 && b[b.length - 1] === 0; }
// 歩いて混ぜる — 完成 판에서 시작해 빈칸을 steps 번 이웃 칸으로 옮긴다. 방금 온 칸으로 되돌아가는 수는 건너뛰고
// 어느 이웃으로 갈지는 정수 해시로 고른다. 반환은 빈칸이 움직인 방향의 목록
function walkMoves(n, steps, seed) {
const b = solvedBoard(n);
const out = [];
let noBacktrack = -1;
for (let s = 0; s < steps; s += 1) {
const opts = [];
for (let d = 0; d < 4; d += 1) {
if (d !== noBacktrack && canStep(b, n, d)) {
opts.push(d);
}
}
const d = opts[Math.floor(hash2(s, seed) * opts.length)];
blankStep(b, n, d);
out.push(d);
noBacktrack = d ^ 1;
}
return out;
}
function applyMoves(b, n, ms) {
for (let i = 0; i < ms.length; i += 1) {
blankStep(b, n, ms[i]);
}
}
function reverseMoves(ms) {
const o = [];
for (let i = ms.length - 1; i >= 0; i -= 1) {
o.push(ms[i] ^ 1);
}
return o;
}
// 줄째 밀기 — 누른 칸 i 가 빈칸과 같은 줄이면 사이 타일이 모두 한 칸씩 빈칸 쪽으로. 배열 교환으로 먼저 정하고
// 옮겨진 타일 번호를 빈칸에 가까운 순서로 돌려준다
function rowChain(b, n, i) {
const z = b.indexOf(0);
if (i === z) {
return [];
}
let step = 0;
if (Math.floor(i / n) === Math.floor(z / n)) {
step = i > z ? 1 : -1;
}
else if (i % n === z % n) {
step = i > z ? n : -n;
}
else {
return [];
}
const moved = [];
for (let p = z; p !== i; p += step) {
b[p] = b[p + step];
moved.push(b[p]);
}
b[i] = 0;
return moved;
}
function idaStar(start, n, cap) {
const b = start.slice();
const path = [];
let nodes = 0;
let capHit = false;
const INF = 9999;
let z = b.indexOf(0);
function dfs(g, h, bound, last) {
nodes += 1;
if (nodes > cap) {
capHit = true;
return INF;
}
const f = g + h;
if (f > bound) {
return f;
}
if (h === 0) {
return -1;
}
let min = INF;
for (let d = 0; d < 4; d += 1) {
if (last >= 0 && d === (last ^ 1)) {
continue;
}
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
if (c < 0 || c >= n || r < 0 || r >= n) {
continue;
}
const t = z + DX[d] + DY[d] * n;
const v = b[t];
const hv = Math.abs((t % n) - ((v - 1) % n)) + Math.abs(Math.floor(t / n) - Math.floor((v - 1) / n));
const hn = Math.abs((z % n) - ((v - 1) % n)) + Math.abs(Math.floor(z / n) - Math.floor((v - 1) / n));
b[z] = v;
b[t] = 0;
const oz = z;
z = t;
path.push(d);
const res = dfs(g + 1, h - hv + hn, bound, d);
if (res === -1) {
return -1;
}
path.pop();
z = oz;
b[t] = v;
b[z] = 0;
if (capHit) {
return INF;
}
if (res < min) {
min = res;
}
}
return min;
}
let bound = sumH(b, n);
const bounds = [];
const perBound = [];
while (bound < INF) {
const before = nodes;
bounds.push(bound);
const res = dfs(0, sumH(b, n), bound, -1);
perBound.push(nodes - before);
if (res === -1) {
return { path: path.slice(), nodes: nodes, bounds: bounds, perBound: perBound, capHit: false };
}
if (capHit || res >= INF) {
break;
}
bound = res;
}
return { path: [], nodes: nodes, bounds: bounds, perBound: perBound, capHit: capHit };
}
// ── 판 화면 — 타일마다 지금 칸 → 목표 칸 보간. 배열은 먼저 바뀌고 화면이 뒤따른다
const BX = 24;
const BY = 44;
const BSZ = 216;
let bd = [];
let BN = 4;
let CS = 54;
let tl = [];
let dragV = -1;
let dragCx = 0;
let dragCy = 0;
function setBoard(b, n) {
bd = b.slice();
BN = n;
CS = BSZ / n;
tl = [];
dragV = -1;
for (let v = 0; v < n * n; v += 1) {
const i = bd.indexOf(v);
const c = i % n;
const r = Math.floor(i / n);
tl.push({ fx: c, fy: r, tx: c, ty: r, t0: 0, du: 0 });
}
}
let still = false;
function tilePos(v) {
const t = tl[v];
if (v === dragV) {
return [dragCx, dragCy];
}
if (still || t.du <= 0) {
return [t.tx, t.ty];
}
const e = easeOut((sceneT - t.t0) / t.du);
return [lerp(t.fx, t.tx, e), lerp(t.fy, t.ty, e)];
}
// 배열이 바뀐 뒤 타일마다 목표 칸을 다시 읽는다 — 옮겨진 타일만 지금 화면 자리에서 출발한다
function retarget(delayOf, du) {
for (let v = 1; v < BN * BN; v += 1) {
const i = bd.indexOf(v);
const c = i % BN;
const r = Math.floor(i / BN);
const t = tl[v];
if (c !== t.tx || r !== t.ty) {
const p = tilePos(v);
t.fx = p[0];
t.fy = p[1];
t.tx = c;
t.ty = r;
t.t0 = sceneT + delayOf(v);
t.du = du;
}
}
}
function stepBlank(d, du) {
if (!blankStep(bd, BN, d)) {
return false;
}
retarget(function () { return 0; }, du);
return true;
}
function cellAt(x, y) {
const c = Math.floor((x - BX) / CS);
const r = Math.floor((y - BY) / CS);
if (c < 0 || c >= BN || r < 0 || r >= BN) {
return -1;
}
return r * BN + c;
}
// ── 그리기 도구
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function disc(x, y, r, c, a) {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, c, a, w) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s, x, y, c, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x, y, w, h) { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name, val, frac) {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// 판 바탕 — 받침과 빈 칸 홈
function drawBoardBase() {
const s = CS * BN;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < BN * BN; i += 1) {
box(BX + (i % BN) * CS + 2, BY + Math.floor(i / BN) * CS + 2, CS - 4, CS - 4, 8, [40, 36, 62], 1);
}
}
function tileHue(v, n) {
return hsl(190 + 160 * ((v - 1) / (n * n - 2)), 0.55, 0.8);
}
function paintTile(label, cx, cy, c, cs, ox, oy) {
const s = cs - 4;
const x = ox + cx * cs + 2;
const y = oy + cy * cs + 2;
box(x, y + 2, s, s, 8, [26, 23, 42], 0.5);
box(x, y, s, s, 8, c, 1);
box(x + 3, y + 2, s - 6, 3, 1.5, [255, 255, 255], 0.4);
text(label, x + s / 2, y + s / 2 + 1, INK, Math.max(9, Math.round(cs * 0.38)), 800, 'center');
}
function drawTiles(fillOf) {
for (let v = 1; v < BN * BN; v += 1) {
const p = tilePos(v);
paintTile(String(v), p[0], p[1], fillOf(v), CS, BX, BY);
}
}
// 글자판 없이 배열만 그리는 작은 판 — 비교 장면용
function drawMini(b, n, ox, oy, size) {
const cs = size / n;
box(ox - 3, oy - 3, size + 6, size + 6, 8, PANEL, 1);
for (let i = 0; i < n * n; i += 1) {
const v = b[i];
if (v === 0) {
box(ox + (i % n) * cs + 1, oy + Math.floor(i / n) * cs + 1, cs - 2, cs - 2, 3, [40, 36, 62], 1);
continue;
}
const x = ox + (i % n) * cs + 1;
const y = oy + Math.floor(i / n) * cs + 1;
box(x, y, cs - 2, cs - 2, 3, tileHue(v, n), 1);
if (cs >= 20) {
text(String(v), x + (cs - 2) / 2, y + (cs - 2) / 2 + 1, INK, Math.round(cs * 0.4), 800, 'center');
}
}
}
// ── 상태 줄·낭독 문장
const SPK_PLACE = '{a}×{a} の盤、タイル {b} 枚のうち {c} 枚が元の位置にある';
const SPK_DONE = '{a}×{a} の盤が完成、タイル {b} 枚すべてが元の位置にある';
function speakBoard() {
const t = BN * BN - 1;
return isSolved(bd) ? fill(SPK_DONE, String(BN), String(t)) : fill(SPK_PLACE, String(BN), String(t), String(placedCount(bd)));
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
}
function takeOver() {
if (manual) {
return;
}
manual = true;
wake();
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 맞은 타일 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode) {
if (isSolved(bd)) {
return 1000;
}
return manual ? 100 + placedCount(bd) : sceneMode;
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say() {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
function advance(dt) {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
}
update(dt);
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow() {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) {
say();
}
}
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest() {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak) {
if (paused || still) {
sceneT += 1;
}
update(0);
showNow();
if (speak) {
say();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
say();
}
function restart() {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// N 한 걸음 — 시간표의 다음 일 하나를 지금 치른다. 시간표가 끝났으면 처음부터 다시 짠 뒤 첫 일
function stepOnce() {
takeOver();
if (evAt >= script.length) {
lap += 1;
begin();
}
if (evAt < script.length) {
const ev = script[evAt];
evAt += 1;
if (sceneT < ev.t) {
sceneT = ev.t;
}
ev.f();
}
afterInput(true);
}
function keyMove(dx, dy) {
// 방향키는 타일이 가는 방향 — 그 타일은 빈칸의 반대쪽에 있다. 빈칸이 가는 방향으로 바꿔 부른다
let d = 0;
for (let k = 0; k < 4; k += 1) {
if (DX[k] === -dx && DY[k] === -dy) {
d = k;
}
}
takeOver();
onDir(d);
afterInput(true);
}
function toCanvas(e) {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 빈칸 이웃 타일 이동, Enter·Space = 一時停止, N = 한 걸음, R = 처음부터
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'r' || k === 'R') {
restart();
}
else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
}
else if (k === 'ArrowLeft') {
e.preventDefault();
keyMove(-1, 0);
}
else if (k === 'ArrowRight') {
e.preventDefault();
keyMove(1, 0);
}
else if (k === 'ArrowUp') {
e.preventDefault();
keyMove(0, -1);
}
else if (k === 'ArrowDown') {
e.preventDefault();
keyMove(0, 1);
}
else if (keyHook !== null && keyHook(k)) {
e.preventDefault();
takeOver();
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
// 장면이 방향키·Enter·N·R 밖의 키를 받고 싶을 때 거는 갈고리 — 먹었으면 true
let keyHook = null;
pad.addEventListener('pointerdown', function (e) {
takeOver();
ptrDown = true;
try {
pad.setPointerCapture(e.pointerId);
}
catch (err) {
ptrDown = true;
}
const p = toCanvas(e);
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (!ptrDown) {
return;
}
const p = toCanvas(e);
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e) {
if (!ptrDown) {
return;
}
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 판 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// 누른 칸이 빈칸의 이웃이면 그 타일이 들어갈 빈칸의 방향(빈칸 기준), 아니면 -1
function dirOfCell(i) {
const z = bd.indexOf(0);
for (let d = 0; d < 4; d += 1) {
if (canStep(bd, BN, d) && z + DX[d] + DY[d] * BN === i) {
return d;
}
}
return -1;
}
// 05 — 맨해튼 거리 합 H 를 길잡이로 깊이 우선 탐색을 하되, 수와 H 의 합이 한도(bound)를 넘으면 가지를 접고 한도를 한 단계씩 올려 다시 돈다(反復深化探索). 노드 수가 NODE_CAP 에 닿으면 멈춘다고 표시하고, 해를 찾으면 찾은 수를 판에서 한 수씩 재생한다
const NODE_CAP = 4000;
const SEEDS_05 = [24, 2, 17, 5];
const SEEDS_4X4 = [5, 1, 8, 6];
const SHUFFLE_05 = 30;
const SHUFFLE_4X4 = 60;
const MOVE_GAP = 0.12;
const SEARCH_S = 1.5;
const LAP_S = 8.2;
const REST_S = 2.6;
const PX = 256;
const PW = 216;
let res = { path: [], nodes: 0, bounds: [], perBound: [], capHit: false };
let res4 = { path: [], nodes: 0, bounds: [], perBound: [], capHit: false };
let h0 = 0;
let h04 = 0;
let t0 = 0.3;
let replayAt = 0;
let fourAt = 0;
let doneMoves = 0;
const STAT_SEARCH = '上限 {a}、探索したノード {b} 個';
const STAT_REPLAY = '見つけた手 {a}/{b} を再生中';
const STAT_CAP = '4×4 の盤はノード {a} 個で停止';
const SPK_FOUND = '反復深化探索: ノード {a} 個、解の長さ {b} 手';
function phaseNow() { return BN === 4 ? 3 : (sceneT < replayAt ? 1 : 2); }
function toFour() {
const b = solvedBoard(4);
applyMoves(b, 4, walkMoves(4, SHUFFLE_4X4, SEEDS_4X4[lap % SEEDS_4X4.length]));
res4 = idaStar(b, 4, NODE_CAP);
h04 = sumH(b, 4);
setBoard(b, 4);
t0 = sceneT + 0.2;
}
function moveAt(d) {
return function () {
if (stepBlank(d, 0.1)) {
doneMoves += 1;
}
};
}
function build() {
const b = solvedBoard(3);
applyMoves(b, 3, walkMoves(3, SHUFFLE_05, SEEDS_05[lap % SEEDS_05.length]));
res = idaStar(b, 3, NODE_CAP);
h0 = sumH(b, 3);
setBoard(b, 3);
t0 = 0.3;
doneMoves = 0;
replayAt = t0 + SEARCH_S + 0.1;
for (let k = 0; k < res.path.length; k += 1) {
script.push({ t: replayAt + k * MOVE_GAP, f: moveAt(res.path[k]) });
}
fourAt = replayAt + res.path.length * MOVE_GAP + 0.6;
script.push({ t: fourAt, f: toFour });
}
function update(dt) { if (dt < 0) {
return;
} }
function shownNodes(r) {
return clamp((sceneT - t0) / SEARCH_S, 0, 1) * Math.min(r.nodes, NODE_CAP);
}
function currentBound(r, shown) {
let cum = 0;
for (let i = 0; i < r.bounds.length; i += 1) {
cum += r.perBound[i];
if (shown < cum) {
return r.bounds[i];
}
}
return r.bounds.length > 0 ? r.bounds[r.bounds.length - 1] : 0;
}
function draw() {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoardBase();
drawTiles(function (v) { return tileHue(v, BN); });
const ph = phaseNow();
const r = ph === 3 ? res4 : res;
const shown = shownNodes(r);
const s = CS * BN;
panel(PX, BY - 5, PW, s + 10);
text('反復深化探索', PX + 12, BY + 12, NEU, 15, 800, 'left');
text('H ' + (ph === 3 ? h04 : h0), PX + PW - 12, BY + 12, DIM, 11, 700, 'right');
// 노드 눈금 — 지금까지 센 노드 수가 NODE_CAP 의 얼마인지
text('NODE_CAP ' + NODE_CAP, PX + 12, BY + 36, DIM, 11, 700, 'left');
text(String(Math.round(shown)), PX + PW - 12, BY + 36, NEU, 12, 800, 'right');
box(PX + 12, BY + 46, PW - 24, 8, 4, [48, 44, 74], 1);
box(PX + 12, BY + 46, (PW - 24) * clamp(shown / NODE_CAP, 0, 1), 8, 4, r.capHit && shown >= Math.min(r.nodes, NODE_CAP) - 1 ? ROSE : MINT, 1);
// 한도별 줄 — 한도마다 센 노드 수
let maxPer = 1;
for (let i = 0; i < r.perBound.length; i += 1) {
maxPer = Math.max(maxPer, r.perBound[i]);
}
let cum = 0;
for (let i = 0; i < Math.min(r.bounds.length, 7); i += 1) {
const y = BY + 70 + i * 17;
const got = clamp(shown - cum, 0, r.perBound[i]);
cum += r.perBound[i];
text('bound ' + r.bounds[i], PX + 12, y + 7, got > 0 ? NEU : DIM, 10, 700, 'left');
box(PX + 70, y + 1, 94, 12, 4, [48, 44, 74], 1);
box(PX + 70, y + 1, 94 * got / maxPer, 12, 4, i === r.bounds.length - 1 && !r.capHit ? ROSE : SKY, 1);
if (got > 0) {
text(String(Math.round(got)), PX + PW - 12, y + 7, NEU, 10, 800, 'right');
}
}
// 결과 — 해를 찾았으면 길이와 재생 진행, 못 찾고 멈췄으면 멈춤 표시
const done = sceneT - t0 >= SEARCH_S;
if (ph === 3) {
if (done) {
box(PX + 12, BY + 192, PW - 24, 22, 11, ROSE, 1);
text('NODE_CAPに到達、停止', PX + PW / 2, BY + 203.5, INK, 11, 800, 'center');
}
}
else if (done) {
text(fill('解の長さ {a} 手、ノード {b} 個', String(res.path.length), String(res.nodes)), PX + 12, BY + 192, NEU, 11, 700, 'left');
box(PX + 12, BY + 202, PW - 24, 8, 4, [48, 44, 74], 1);
box(PX + 12, BY + 202, (PW - 24) * doneMoves / Math.max(1, res.path.length), 8, 4, MINT, 1);
}
hud('NODE_CAP', String(NODE_CAP), shown / NODE_CAP);
}
function statusText() {
const ph = phaseNow();
if (ph === 3) {
return fill(STAT_CAP, String(Math.round(Math.min(res4.nodes, NODE_CAP))));
}
const shown = shownNodes(res);
if (sceneT - t0 < SEARCH_S) {
return fill(STAT_SEARCH, String(currentBound(res, shown)), String(Math.round(shown)));
}
return fill(STAT_REPLAY, String(doneMoves), String(res.path.length));
}
function modeId() { return modeBase(phaseNow()); }
function speakText() {
const head = phaseNow() === 3 ? fill(STAT_CAP, String(Math.round(Math.min(res4.nodes, NODE_CAP)))) : fill(SPK_FOUND, String(res.nodes), String(res.path.length));
return head + '. ' + speakBoard();
}
function onDir(d) { stepBlank(d, 0.1); }
function onPtr(kind, x, y) {
if (kind !== 0) {
return;
}
const d = dirOfCell(cellAt(x, y));
if (d >= 0) {
onDir(d);
}
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// IdaStarSolve - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./IdaStarSolve.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function IdaStarSolve() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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 easeOut(k: number): number { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const SKY: RGB = [150, 192, 244];
const SUN: RGB = [250, 218, 146];
const NEU: RGB = [236, 230, 250];
const BGC: RGB = [46, 42, 68];
const PANEL: RGB = [62, 57, 92];
const DIM: RGB = [196, 190, 224];
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function hsl(h: number, s: number, l: number): RGB {
const a = s * Math.min(l, 1 - l);
const f = function (n: number): number {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a: number, b: number): number {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s: string, a: string, b?: string, c?: string): string {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v: number): string { return String(Math.round(v * 10) / 10); }
function group(v: number): string {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { o += ','; }
o += s.charAt(i);
}
return o;
}
// ── 퍼즐 모델 — 판은 번호 배열, 0 은 빈칸, 타일 v 의 제자리는 v-1 번 칸, 빈칸의 제자리는 맨 끝
// 빈칸이 움직이는 방향 d: 0 오른쪽 · 1 왼쪽 · 2 아래 · 3 위. 반대 방향은 d ^ 1
const DX = [1, -1, 0, 0];
const DY = [0, 0, 1, -1];
function solvedBoard(n: number): number[] {
const b: number[] = [];
for (let i = 1; i < n * n; i += 1) { b.push(i); }
b.push(0);
return b;
}
function canStep(b: number[], n: number, d: number): boolean {
const z = b.indexOf(0);
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
return c >= 0 && c < n && r >= 0 && r < n;
}
function blankStep(b: number[], n: number, d: number): boolean {
if (!canStep(b, n, d)) { return false; }
const z = b.indexOf(0);
const t = z + DX[d] + DY[d] * n;
b[z] = b[t];
b[t] = 0;
return true;
}
// 역전 — 번호를 한 줄로 늘어놓고 앞 번호가 뒤 번호보다 큰 쌍의 수(빈칸은 세지 않는다)
function inversions(b: number[]): number {
let c = 0;
for (let i = 0; i < b.length; i += 1) {
for (let j = i + 1; j < b.length; j += 1) {
if (b[i] !== 0 && b[j] !== 0 && b[i] > b[j]) { c += 1; }
}
}
return c;
}
// 빈칸이 아래서 몇 번째 줄인지(맨 아래 줄 = 1)
function blankRow(b: number[], n: number): number { return n - Math.floor(b.indexOf(0) / n); }
// 판정 합 — 짝수 폭은 역전 + 빈칸 줄, 홀수 폭은 역전만
function parity(b: number[], n: number): number { return n % 2 === 0 ? inversions(b) + blankRow(b, n) : inversions(b); }
function solvable(b: number[], n: number): boolean {
return n % 2 === 0 ? parity(b, n) % 2 === 1 : parity(b, n) % 2 === 0;
}
function manhattan(b: number[], n: number, i: number): number {
const v = b[i];
if (v === 0) { return 0; }
const h = v - 1;
return Math.abs((i % n) - (h % n)) + Math.abs(Math.floor(i / n) - Math.floor(h / n));
}
function sumH(b: number[], n: number): number {
let s = 0;
for (let i = 0; i < b.length; i += 1) { s += manhattan(b, n, i); }
return s;
}
function placedCount(b: number[]): number {
let c = 0;
for (let i = 0; i < b.length - 1; i += 1) { if (b[i] === i + 1) { c += 1; } }
return c;
}
function isSolved(b: number[]): boolean { return placedCount(b) === b.length - 1 && b[b.length - 1] === 0; }
// 걸어서 섞기 — 완성 판에서 시작해 빈칸을 steps 번 이웃 칸으로 옮긴다. 방금 온 칸으로 되돌아가는 수는 건너뛰고
// 어느 이웃으로 갈지는 정수 해시로 고른다. 반환은 빈칸이 움직인 방향의 목록
function walkMoves(n: number, steps: number, seed: number): number[] {
const b = solvedBoard(n);
const out: number[] = [];
let noBacktrack = -1;
for (let s = 0; s < steps; s += 1) {
const opts: number[] = [];
for (let d = 0; d < 4; d += 1) { if (d !== noBacktrack && canStep(b, n, d)) { opts.push(d); } }
const d = opts[Math.floor(hash2(s, seed) * opts.length)];
blankStep(b, n, d);
out.push(d);
noBacktrack = d ^ 1;
}
return out;
}
function applyMoves(b: number[], n: number, ms: number[]): void {
for (let i = 0; i < ms.length; i += 1) { blankStep(b, n, ms[i]); }
}
function reverseMoves(ms: number[]): number[] {
const o: number[] = [];
for (let i = ms.length - 1; i >= 0; i -= 1) { o.push(ms[i] ^ 1); }
return o;
}
// 줄째 밀기 — 누른 칸 i 가 빈칸과 같은 줄이면 사이 타일이 모두 한 칸씩 빈칸 쪽으로. 배열 교환으로 먼저 정하고
// 옮겨진 타일 번호를 빈칸에 가까운 순서로 돌려준다
function rowChain(b: number[], n: number, i: number): number[] {
const z = b.indexOf(0);
if (i === z) { return []; }
let step = 0;
if (Math.floor(i / n) === Math.floor(z / n)) { step = i > z ? 1 : -1; } else if (i % n === z % n) { step = i > z ? n : -n; } else { return []; }
const moved: number[] = [];
for (let p = z; p !== i; p += step) {
b[p] = b[p + step];
moved.push(b[p]);
}
b[i] = 0;
return moved;
}
// 반복 깊이 탐색 — 맨해튼 거리 합 h 를 길잡이로 깊이 우선 탐색하고, 수와 h 의 합이 한도(bound)를 넘으면 가지를 접는다.
// 한 바퀴가 끝나면 한도를 넘긴 값 중 가장 작은 것으로 올려 다시 돈다. 노드 수가 cap 에 닿으면 멈춘다
type Ida = { path: number[]; nodes: number; bounds: number[]; perBound: number[]; capHit: boolean };
function idaStar(start: number[], n: number, cap: number): Ida {
const b = start.slice();
const path: number[] = [];
let nodes = 0;
let capHit = false;
const INF = 9999;
let z = b.indexOf(0);
function dfs(g: number, h: number, bound: number, last: number): number {
nodes += 1;
if (nodes > cap) { capHit = true; return INF; }
const f = g + h;
if (f > bound) { return f; }
if (h === 0) { return -1; }
let min = INF;
for (let d = 0; d < 4; d += 1) {
if (last >= 0 && d === (last ^ 1)) { continue; }
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
if (c < 0 || c >= n || r < 0 || r >= n) { continue; }
const t = z + DX[d] + DY[d] * n;
const v = b[t];
const hv = Math.abs((t % n) - ((v - 1) % n)) + Math.abs(Math.floor(t / n) - Math.floor((v - 1) / n));
const hn = Math.abs((z % n) - ((v - 1) % n)) + Math.abs(Math.floor(z / n) - Math.floor((v - 1) / n));
b[z] = v;
b[t] = 0;
const oz = z;
z = t;
path.push(d);
const res = dfs(g + 1, h - hv + hn, bound, d);
if (res === -1) { return -1; }
path.pop();
z = oz;
b[t] = v;
b[z] = 0;
if (capHit) { return INF; }
if (res < min) { min = res; }
}
return min;
}
let bound = sumH(b, n);
const bounds: number[] = [];
const perBound: number[] = [];
while (bound < INF) {
const before = nodes;
bounds.push(bound);
const res = dfs(0, sumH(b, n), bound, -1);
perBound.push(nodes - before);
if (res === -1) { return { path: path.slice(), nodes: nodes, bounds: bounds, perBound: perBound, capHit: false }; }
if (capHit || res >= INF) { break; }
bound = res;
}
return { path: [], nodes: nodes, bounds: bounds, perBound: perBound, capHit: capHit };
}
// ── 판 화면 — 타일마다 지금 칸 → 목표 칸 보간. 배열은 먼저 바뀌고 화면이 뒤따른다
const BX = 24;
const BY = 44;
const BSZ = 216;
let bd: number[] = [];
let BN = 4;
let CS = 54;
type Tl = { fx: number; fy: number; tx: number; ty: number; t0: number; du: number };
let tl: Tl[] = [];
let dragV = -1;
let dragCx = 0;
let dragCy = 0;
function setBoard(b: number[], n: number): void {
bd = b.slice();
BN = n;
CS = BSZ / n;
tl = [];
dragV = -1;
for (let v = 0; v < n * n; v += 1) {
const i = bd.indexOf(v);
const c = i % n;
const r = Math.floor(i / n);
tl.push({ fx: c, fy: r, tx: c, ty: r, t0: 0, du: 0 });
}
}
let still = false;
function tilePos(v: number): number[] {
const t = tl[v];
if (v === dragV) { return [dragCx, dragCy]; }
if (still || t.du <= 0) { return [t.tx, t.ty]; }
const e = easeOut((sceneT - t.t0) / t.du);
return [lerp(t.fx, t.tx, e), lerp(t.fy, t.ty, e)];
}
// 배열이 바뀐 뒤 타일마다 목표 칸을 다시 읽는다 — 옮겨진 타일만 지금 화면 자리에서 출발한다
function retarget(delayOf: (v: number) => number, du: number): void {
for (let v = 1; v < BN * BN; v += 1) {
const i = bd.indexOf(v);
const c = i % BN;
const r = Math.floor(i / BN);
const t = tl[v];
if (c !== t.tx || r !== t.ty) {
const p = tilePos(v);
t.fx = p[0];
t.fy = p[1];
t.tx = c;
t.ty = r;
t.t0 = sceneT + delayOf(v);
t.du = du;
}
}
}
function stepBlank(d: number, du: number): boolean {
if (!blankStep(bd, BN, d)) { return false; }
retarget(function (): number { return 0; }, du);
return true;
}
function cellAt(x: number, y: number): number {
const c = Math.floor((x - BX) / CS);
const r = Math.floor((y - BY) / CS);
if (c < 0 || c >= BN || r < 0 || r >= BN) { return -1; }
return r * BN + c;
}
// ── 그리기 도구
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, c: RGB, a: number, w: number): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x: number, y: number, w: number, h: number): void { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name: string, val: string, frac: number): void {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// 판 바탕 — 받침과 빈 칸 홈
function drawBoardBase(): void {
const s = CS * BN;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < BN * BN; i += 1) {
box(BX + (i % BN) * CS + 2, BY + Math.floor(i / BN) * CS + 2, CS - 4, CS - 4, 8, [40, 36, 62], 1);
}
}
function tileHue(v: number, n: number): RGB {
return hsl(190 + 160 * ((v - 1) / (n * n - 2)), 0.55, 0.8);
}
function paintTile(label: string, cx: number, cy: number, c: RGB, cs: number, ox: number, oy: number): void {
const s = cs - 4;
const x = ox + cx * cs + 2;
const y = oy + cy * cs + 2;
box(x, y + 2, s, s, 8, [26, 23, 42], 0.5);
box(x, y, s, s, 8, c, 1);
box(x + 3, y + 2, s - 6, 3, 1.5, [255, 255, 255], 0.4);
text(label, x + s / 2, y + s / 2 + 1, INK, Math.max(9, Math.round(cs * 0.38)), 800, 'center');
}
function drawTiles(fillOf: (v: number) => RGB): void {
for (let v = 1; v < BN * BN; v += 1) {
const p = tilePos(v);
paintTile(String(v), p[0], p[1], fillOf(v), CS, BX, BY);
}
}
// 글자판 없이 배열만 그리는 작은 판 — 비교 장면용
function drawMini(b: number[], n: number, ox: number, oy: number, size: number): void {
const cs = size / n;
box(ox - 3, oy - 3, size + 6, size + 6, 8, PANEL, 1);
for (let i = 0; i < n * n; i += 1) {
const v = b[i];
if (v === 0) { box(ox + (i % n) * cs + 1, oy + Math.floor(i / n) * cs + 1, cs - 2, cs - 2, 3, [40, 36, 62], 1); continue; }
const x = ox + (i % n) * cs + 1;
const y = oy + Math.floor(i / n) * cs + 1;
box(x, y, cs - 2, cs - 2, 3, tileHue(v, n), 1);
if (cs >= 20) { text(String(v), x + (cs - 2) / 2, y + (cs - 2) / 2 + 1, INK, Math.round(cs * 0.4), 800, 'center'); }
}
}
// ── 상태 줄·낭독 문장
const SPK_PLACE = '{a}×{a} 판, 타일 {b}장 중 {c}장이 제자리에 있다';
const SPK_DONE = '{a}×{a} 판 완성, 타일 {b}장이 모두 제자리에 있다';
function speakBoard(): string {
const t = BN * BN - 1;
return isSolved(bd) ? fill(SPK_DONE, String(BN), String(t)) : fill(SPK_PLACE, String(BN), String(t), String(placedCount(bd)));
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
}
function takeOver(): void {
if (manual) { return; }
manual = true;
wake();
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 맞은 타일 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode: number): number {
if (isSolved(bd)) { return 1000; }
return manual ? 100 + placedCount(bd) : sceneMode;
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say(): void {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
function advance(dt: number): void {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
}
update(dt);
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow(): void {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) { say(); }
}
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest(): void {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak: boolean): void {
if (paused || still) { sceneT += 1; }
update(0);
showNow();
if (speak) { say(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
say();
}
function restart(): void {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// N 한 걸음 — 시간표의 다음 일 하나를 지금 치른다. 시간표가 끝났으면 처음부터 다시 짠 뒤 첫 일
function stepOnce(): void {
takeOver();
if (evAt >= script.length) { lap += 1; begin(); }
if (evAt < script.length) {
const ev = script[evAt];
evAt += 1;
if (sceneT < ev.t) { sceneT = ev.t; }
ev.f();
}
afterInput(true);
}
function keyMove(dx: number, dy: number): void {
// 방향키는 타일이 가는 방향 — 그 타일은 빈칸의 반대쪽에 있다. 빈칸이 가는 방향으로 바꿔 부른다
let d = 0;
for (let k = 0; k < 4; k += 1) { if (DX[k] === -dx && DY[k] === -dy) { d = k; } }
takeOver();
onDir(d);
afterInput(true);
}
function toCanvas(e: PointerEvent): number[] {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 빈칸 이웃 타일 이동, Enter·Space = 일시정지, N = 한 걸음, R = 처음부터
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'r' || k === 'R') {
restart();
} else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
} else if (k === 'ArrowLeft') {
e.preventDefault();
keyMove(-1, 0);
} else if (k === 'ArrowRight') {
e.preventDefault();
keyMove(1, 0);
} else if (k === 'ArrowUp') {
e.preventDefault();
keyMove(0, -1);
} else if (k === 'ArrowDown') {
e.preventDefault();
keyMove(0, 1);
} else if (keyHook !== null && keyHook(k)) {
e.preventDefault();
takeOver();
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
// 장면이 방향키·Enter·N·R 밖의 키를 받고 싶을 때 거는 갈고리 — 먹었으면 true
let keyHook: ((k: string) => boolean) | null = null;
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
ptrDown = true;
try { pad.setPointerCapture(e.pointerId); } catch (err) { ptrDown = true; }
const p = toCanvas(e);
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (!ptrDown) { return; }
const p = toCanvas(e);
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e: PointerEvent): void {
if (!ptrDown) { return; }
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 판 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// 누른 칸이 빈칸의 이웃이면 그 타일이 들어갈 빈칸의 방향(빈칸 기준), 아니면 -1
function dirOfCell(i: number): number {
const z = bd.indexOf(0);
for (let d = 0; d < 4; d += 1) { if (canStep(bd, BN, d) && z + DX[d] + DY[d] * BN === i) { return d; } }
return -1;
}
// 05 — 맨해튼 거리 합 H 를 길잡이로 깊이 우선 탐색을 하되, 수와 H 의 합이 한도(bound)를 넘으면 가지를 접고 한도를 한 단계씩 올려 다시 돈다(반복 심화 탐색). 노드 수가 NODE_CAP 에 닿으면 멈춘다고 표시하고, 해를 찾으면 찾은 수를 판에서 한 수씩 재생한다
const NODE_CAP = 4000;
const SEEDS_05 = [24, 2, 17, 5];
const SEEDS_4X4 = [5, 1, 8, 6];
const SHUFFLE_05 = 30;
const SHUFFLE_4X4 = 60;
const MOVE_GAP = 0.12;
const SEARCH_S = 1.5;
const LAP_S = 8.2;
const REST_S = 2.6;
const PX = 256;
const PW = 216;
let res: Ida = { path: [], nodes: 0, bounds: [], perBound: [], capHit: false };
let res4: Ida = { path: [], nodes: 0, bounds: [], perBound: [], capHit: false };
let h0 = 0;
let h04 = 0;
let t0 = 0.3;
let replayAt = 0;
let fourAt = 0;
let doneMoves = 0;
const STAT_SEARCH = '한도 {a}, 노드 {b}개 탐색';
const STAT_REPLAY = '찾은 수 {a}/{b} 재생 중';
const STAT_CAP = '4×4 판은 노드 {a}개에서 멈춤';
const SPK_FOUND = '반복 심화 탐색: 노드 {a}개, 해 길이 {b}수';
function phaseNow(): number { return BN === 4 ? 3 : (sceneT < replayAt ? 1 : 2); }
function toFour(): void {
const b = solvedBoard(4);
applyMoves(b, 4, walkMoves(4, SHUFFLE_4X4, SEEDS_4X4[lap % SEEDS_4X4.length]));
res4 = idaStar(b, 4, NODE_CAP);
h04 = sumH(b, 4);
setBoard(b, 4);
t0 = sceneT + 0.2;
}
function moveAt(d: number): () => void {
return function (): void {
if (stepBlank(d, 0.1)) { doneMoves += 1; }
};
}
function build(): void {
const b = solvedBoard(3);
applyMoves(b, 3, walkMoves(3, SHUFFLE_05, SEEDS_05[lap % SEEDS_05.length]));
res = idaStar(b, 3, NODE_CAP);
h0 = sumH(b, 3);
setBoard(b, 3);
t0 = 0.3;
doneMoves = 0;
replayAt = t0 + SEARCH_S + 0.1;
for (let k = 0; k < res.path.length; k += 1) { script.push({ t: replayAt + k * MOVE_GAP, f: moveAt(res.path[k]) }); }
fourAt = replayAt + res.path.length * MOVE_GAP + 0.6;
script.push({ t: fourAt, f: toFour });
}
function update(dt: number): void { if (dt < 0) { return; } }
function shownNodes(r: Ida): number {
return clamp((sceneT - t0) / SEARCH_S, 0, 1) * Math.min(r.nodes, NODE_CAP);
}
function currentBound(r: Ida, shown: number): number {
let cum = 0;
for (let i = 0; i < r.bounds.length; i += 1) {
cum += r.perBound[i];
if (shown < cum) { return r.bounds[i]; }
}
return r.bounds.length > 0 ? r.bounds[r.bounds.length - 1] : 0;
}
function draw(): void {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoardBase();
drawTiles(function (v: number): RGB { return tileHue(v, BN); });
const ph = phaseNow();
const r = ph === 3 ? res4 : res;
const shown = shownNodes(r);
const s = CS * BN;
panel(PX, BY - 5, PW, s + 10);
text('반복 심화 탐색', PX + 12, BY + 12, NEU, 15, 800, 'left');
text('H ' + (ph === 3 ? h04 : h0), PX + PW - 12, BY + 12, DIM, 11, 700, 'right');
// 노드 눈금 — 지금까지 센 노드 수가 NODE_CAP 의 얼마인지
text('NODE_CAP ' + NODE_CAP, PX + 12, BY + 36, DIM, 11, 700, 'left');
text(String(Math.round(shown)), PX + PW - 12, BY + 36, NEU, 12, 800, 'right');
box(PX + 12, BY + 46, PW - 24, 8, 4, [48, 44, 74], 1);
box(PX + 12, BY + 46, (PW - 24) * clamp(shown / NODE_CAP, 0, 1), 8, 4, r.capHit && shown >= Math.min(r.nodes, NODE_CAP) - 1 ? ROSE : MINT, 1);
// 한도별 줄 — 한도마다 센 노드 수
let maxPer = 1;
for (let i = 0; i < r.perBound.length; i += 1) { maxPer = Math.max(maxPer, r.perBound[i]); }
let cum = 0;
for (let i = 0; i < Math.min(r.bounds.length, 7); i += 1) {
const y = BY + 70 + i * 17;
const got = clamp(shown - cum, 0, r.perBound[i]);
cum += r.perBound[i];
text('bound ' + r.bounds[i], PX + 12, y + 7, got > 0 ? NEU : DIM, 10, 700, 'left');
box(PX + 70, y + 1, 94, 12, 4, [48, 44, 74], 1);
box(PX + 70, y + 1, 94 * got / maxPer, 12, 4, i === r.bounds.length - 1 && !r.capHit ? ROSE : SKY, 1);
if (got > 0) { text(String(Math.round(got)), PX + PW - 12, y + 7, NEU, 10, 800, 'right'); }
}
// 결과 — 해를 찾았으면 길이와 재생 진행, 못 찾고 멈췄으면 멈춤 표시
const done = sceneT - t0 >= SEARCH_S;
if (ph === 3) {
if (done) {
box(PX + 12, BY + 192, PW - 24, 22, 11, ROSE, 1);
text('NODE_CAP 도달, 멈춤', PX + PW / 2, BY + 203.5, INK, 11, 800, 'center');
}
} else if (done) {
text(fill('해 길이 {a}수, 노드 {b}개', String(res.path.length), String(res.nodes)), PX + 12, BY + 192, NEU, 11, 700, 'left');
box(PX + 12, BY + 202, PW - 24, 8, 4, [48, 44, 74], 1);
box(PX + 12, BY + 202, (PW - 24) * doneMoves / Math.max(1, res.path.length), 8, 4, MINT, 1);
}
hud('NODE_CAP', String(NODE_CAP), shown / NODE_CAP);
}
function statusText(): string {
const ph = phaseNow();
if (ph === 3) { return fill(STAT_CAP, String(Math.round(Math.min(res4.nodes, NODE_CAP)))); }
const shown = shownNodes(res);
if (sceneT - t0 < SEARCH_S) { return fill(STAT_SEARCH, String(currentBound(res, shown)), String(Math.round(shown))); }
return fill(STAT_REPLAY, String(doneMoves), String(res.path.length));
}
function modeId(): number { return modeBase(phaseNow()); }
function speakText(): string {
const head = phaseNow() === 3 ? fill(STAT_CAP, String(Math.round(Math.min(res4.nodes, NODE_CAP)))) : fill(SPK_FOUND, String(res.nodes), String(res.path.length));
return head + '. ' + speakBoard();
}
function onDir(d: number): void { stepBlank(d, 0.1); }
function onPtr(kind: number, x: number, y: number): void {
if (kind !== 0) { return; }
const d = dirOfCell(cellAt(x, y));
if (d >= 0) { onDir(d); }
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'슬라이딩 퍼즐 시연 무대 — 방향키로 빈칸 옆 타일 이동, 타일 누르기도 가능, Enter 또는 Space 로 일시정지, N 으로 한 걸음, R 로 처음부터'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'섞인 3×3 판에서 반복 심화 탐색이 맨해튼 거리 한도를 하나씩 올리며 돈다. 오른쪽 패널에 한도별 막대와 노드 수, 노드 총합이 NODE_CAP 4000 칸 중 얼마인지 보이는 눈금이 있고, 해를 찾으면 찾은 수를 판에서 한 수씩 재생한다. 마지막에는 4×4 판에서 같은 탐색이 노드 4000개에 닿아 멈췄다고 표시한다. 아래 눈금은 상수 이름과 지금 값을 보이고, 움직임 줄이기에서는 타일이 바로 자리를 바꾸는 판 한 장만 그려진다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'슬라이딩 퍼즐 장면'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}05 · 上限を引き上げながら最短手を探す反復深化探索 — 計測 累積面積 26.812% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function dfs(g, h, bound, last) {
nodes += 1;
if (nodes > cap) {
capHit = true;
return INF;
}
const f = g + h;
if (f > bound) {
return f;
}
if (h === 0) {
return -1;
}
let min = INF;
for (let d = 0; d < 4; d += 1) {
if (last >= 0 && d === (last ^ 1)) {
continue;
}
探索済みの盤を溜めずに深さ優先で進むので、最短手を求める流れを学ぶ例にも向きます。3×3の四つの盤の実測はHが10〜18、解は20〜26手、ノードは1,429〜2,800で、上限を上げるたびにノードは階段のように増えました(種2の盤は上限14〜24で3・8・57・313・1,261・1,158個)。
06一枚の絵をタイルに切って継ぎ目が見える盤
キャンバスにノイズで夕空・太陽・丘を一枚描き、TILE_N の4×4のマスに切って drawImage でタイルに写します。最後のマスは空けておき、タイルごとに SEAM_PX の2の暗い縁と上側のハイライトを載せるので、数字の代わりに絵で合わせるパズル画面になります。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="スライドパズルの舞台 — 矢印キーで空きマスの隣のタイルを動かす、タイルを押しても動く、EnterかSpaceで一時停止、Nで一歩、Rで最初から">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="ノイズで描いた夕焼けの風景の絵一枚が4×4のタイルに切られて混ざり、最後のマスは空いている。タイルごとに暗い2ピクセルの縁と上側のハイライトがあり、角に番号が書かれる。右のパネルには元の絵と、元の位置に来たタイルに印を付けた格子があり、すべて合うと切れ目が消えて絵が一枚につながる。下の目盛りは定数名と今の値を示し、動きを減らす設定ではタイルがすぐ入れ替わる盤一枚だけを描きます"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">スライドパズルの場面</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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;
}
// 무대 상자 — 눌러서 한 번 깨뜨리는 곳(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause {
padding: $sp-1 $sp-3;
border: 2px solid $subject-cream;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 스크린리더에만 읽히는 낭독 문장 — 모드가 바뀔 때만 쓴다
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-ink;
color: $subject-cream;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면에만 있고 aria-live 는 늘 off
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 100px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 글리치 없는 원문 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
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;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause {
padding: 4px 12px;
border: 2px solid #fff7e6;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 100px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function easeOut(k) { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const SKY = [150, 192, 244];
const SUN = [250, 218, 146];
const NEU = [236, 230, 250];
const BGC = [46, 42, 68];
const PANEL = [62, 57, 92];
const DIM = [196, 190, 224];
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function hsl(h, s, l) {
const a = s * Math.min(l, 1 - l);
const f = function (n) {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a, b) {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s, a, b, c) {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v) { return String(Math.round(v * 10) / 10); }
function group(v) {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
o += ',';
}
o += s.charAt(i);
}
return o;
}
// ── 퍼즐 모델 — 판은 번호 배열, 0 은 빈칸, 타일 v 의 제자리는 v-1 번 칸, 빈칸의 제자리는 맨 끝
// 빈칸이 움직이는 방향 d: 0 오른쪽 · 1 왼쪽 · 2 아래 · 3 위. 반대 방향은 d ^ 1
const DX = [1, -1, 0, 0];
const DY = [0, 0, 1, -1];
function solvedBoard(n) {
const b = [];
for (let i = 1; i < n * n; i += 1) {
b.push(i);
}
b.push(0);
return b;
}
function canStep(b, n, d) {
const z = b.indexOf(0);
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
return c >= 0 && c < n && r >= 0 && r < n;
}
function blankStep(b, n, d) {
if (!canStep(b, n, d)) {
return false;
}
const z = b.indexOf(0);
const t = z + DX[d] + DY[d] * n;
b[z] = b[t];
b[t] = 0;
return true;
}
// 역전 — 번호를 한 줄로 늘어놓고 앞 번호가 뒤 번호보다 큰 쌍의 수(빈칸은 세지 않는다)
function inversions(b) {
let c = 0;
for (let i = 0; i < b.length; i += 1) {
for (let j = i + 1; j < b.length; j += 1) {
if (b[i] !== 0 && b[j] !== 0 && b[i] > b[j]) {
c += 1;
}
}
}
return c;
}
// 빈칸이 아래서 몇 번째 줄인지(맨 아래 줄 = 1)
function blankRow(b, n) { return n - Math.floor(b.indexOf(0) / n); }
// 판정 합 — 짝수 폭은 역전 + 빈칸 줄, 홀수 폭은 역전만
function parity(b, n) { return n % 2 === 0 ? inversions(b) + blankRow(b, n) : inversions(b); }
function solvable(b, n) {
return n % 2 === 0 ? parity(b, n) % 2 === 1 : parity(b, n) % 2 === 0;
}
function manhattan(b, n, i) {
const v = b[i];
if (v === 0) {
return 0;
}
const h = v - 1;
return Math.abs((i % n) - (h % n)) + Math.abs(Math.floor(i / n) - Math.floor(h / n));
}
function sumH(b, n) {
let s = 0;
for (let i = 0; i < b.length; i += 1) {
s += manhattan(b, n, i);
}
return s;
}
function placedCount(b) {
let c = 0;
for (let i = 0; i < b.length - 1; i += 1) {
if (b[i] === i + 1) {
c += 1;
}
}
return c;
}
function isSolved(b) { return placedCount(b) === b.length - 1 && b[b.length - 1] === 0; }
// 歩いて混ぜる — 完成 판에서 시작해 빈칸을 steps 번 이웃 칸으로 옮긴다. 방금 온 칸으로 되돌아가는 수는 건너뛰고
// 어느 이웃으로 갈지는 정수 해시로 고른다. 반환은 빈칸이 움직인 방향의 목록
function walkMoves(n, steps, seed) {
const b = solvedBoard(n);
const out = [];
let noBacktrack = -1;
for (let s = 0; s < steps; s += 1) {
const opts = [];
for (let d = 0; d < 4; d += 1) {
if (d !== noBacktrack && canStep(b, n, d)) {
opts.push(d);
}
}
const d = opts[Math.floor(hash2(s, seed) * opts.length)];
blankStep(b, n, d);
out.push(d);
noBacktrack = d ^ 1;
}
return out;
}
function applyMoves(b, n, ms) {
for (let i = 0; i < ms.length; i += 1) {
blankStep(b, n, ms[i]);
}
}
function reverseMoves(ms) {
const o = [];
for (let i = ms.length - 1; i >= 0; i -= 1) {
o.push(ms[i] ^ 1);
}
return o;
}
// 줄째 밀기 — 누른 칸 i 가 빈칸과 같은 줄이면 사이 타일이 모두 한 칸씩 빈칸 쪽으로. 배열 교환으로 먼저 정하고
// 옮겨진 타일 번호를 빈칸에 가까운 순서로 돌려준다
function rowChain(b, n, i) {
const z = b.indexOf(0);
if (i === z) {
return [];
}
let step = 0;
if (Math.floor(i / n) === Math.floor(z / n)) {
step = i > z ? 1 : -1;
}
else if (i % n === z % n) {
step = i > z ? n : -n;
}
else {
return [];
}
const moved = [];
for (let p = z; p !== i; p += step) {
b[p] = b[p + step];
moved.push(b[p]);
}
b[i] = 0;
return moved;
}
function idaStar(start, n, cap) {
const b = start.slice();
const path = [];
let nodes = 0;
let capHit = false;
const INF = 9999;
let z = b.indexOf(0);
function dfs(g, h, bound, last) {
nodes += 1;
if (nodes > cap) {
capHit = true;
return INF;
}
const f = g + h;
if (f > bound) {
return f;
}
if (h === 0) {
return -1;
}
let min = INF;
for (let d = 0; d < 4; d += 1) {
if (last >= 0 && d === (last ^ 1)) {
continue;
}
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
if (c < 0 || c >= n || r < 0 || r >= n) {
continue;
}
const t = z + DX[d] + DY[d] * n;
const v = b[t];
const hv = Math.abs((t % n) - ((v - 1) % n)) + Math.abs(Math.floor(t / n) - Math.floor((v - 1) / n));
const hn = Math.abs((z % n) - ((v - 1) % n)) + Math.abs(Math.floor(z / n) - Math.floor((v - 1) / n));
b[z] = v;
b[t] = 0;
const oz = z;
z = t;
path.push(d);
const res = dfs(g + 1, h - hv + hn, bound, d);
if (res === -1) {
return -1;
}
path.pop();
z = oz;
b[t] = v;
b[z] = 0;
if (capHit) {
return INF;
}
if (res < min) {
min = res;
}
}
return min;
}
let bound = sumH(b, n);
const bounds = [];
const perBound = [];
while (bound < INF) {
const before = nodes;
bounds.push(bound);
const res = dfs(0, sumH(b, n), bound, -1);
perBound.push(nodes - before);
if (res === -1) {
return { path: path.slice(), nodes: nodes, bounds: bounds, perBound: perBound, capHit: false };
}
if (capHit || res >= INF) {
break;
}
bound = res;
}
return { path: [], nodes: nodes, bounds: bounds, perBound: perBound, capHit: capHit };
}
// ── 판 화면 — 타일마다 지금 칸 → 목표 칸 보간. 배열은 먼저 바뀌고 화면이 뒤따른다
const BX = 24;
const BY = 44;
const BSZ = 216;
let bd = [];
let BN = 4;
let CS = 54;
let tl = [];
let dragV = -1;
let dragCx = 0;
let dragCy = 0;
function setBoard(b, n) {
bd = b.slice();
BN = n;
CS = BSZ / n;
tl = [];
dragV = -1;
for (let v = 0; v < n * n; v += 1) {
const i = bd.indexOf(v);
const c = i % n;
const r = Math.floor(i / n);
tl.push({ fx: c, fy: r, tx: c, ty: r, t0: 0, du: 0 });
}
}
let still = false;
function tilePos(v) {
const t = tl[v];
if (v === dragV) {
return [dragCx, dragCy];
}
if (still || t.du <= 0) {
return [t.tx, t.ty];
}
const e = easeOut((sceneT - t.t0) / t.du);
return [lerp(t.fx, t.tx, e), lerp(t.fy, t.ty, e)];
}
// 배열이 바뀐 뒤 타일마다 목표 칸을 다시 읽는다 — 옮겨진 타일만 지금 화면 자리에서 출발한다
function retarget(delayOf, du) {
for (let v = 1; v < BN * BN; v += 1) {
const i = bd.indexOf(v);
const c = i % BN;
const r = Math.floor(i / BN);
const t = tl[v];
if (c !== t.tx || r !== t.ty) {
const p = tilePos(v);
t.fx = p[0];
t.fy = p[1];
t.tx = c;
t.ty = r;
t.t0 = sceneT + delayOf(v);
t.du = du;
}
}
}
function stepBlank(d, du) {
if (!blankStep(bd, BN, d)) {
return false;
}
retarget(function () { return 0; }, du);
return true;
}
function cellAt(x, y) {
const c = Math.floor((x - BX) / CS);
const r = Math.floor((y - BY) / CS);
if (c < 0 || c >= BN || r < 0 || r >= BN) {
return -1;
}
return r * BN + c;
}
// ── 그리기 도구
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function disc(x, y, r, c, a) {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, c, a, w) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s, x, y, c, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x, y, w, h) { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name, val, frac) {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// 판 바탕 — 받침과 빈 칸 홈
function drawBoardBase() {
const s = CS * BN;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < BN * BN; i += 1) {
box(BX + (i % BN) * CS + 2, BY + Math.floor(i / BN) * CS + 2, CS - 4, CS - 4, 8, [40, 36, 62], 1);
}
}
function tileHue(v, n) {
return hsl(190 + 160 * ((v - 1) / (n * n - 2)), 0.55, 0.8);
}
function paintTile(label, cx, cy, c, cs, ox, oy) {
const s = cs - 4;
const x = ox + cx * cs + 2;
const y = oy + cy * cs + 2;
box(x, y + 2, s, s, 8, [26, 23, 42], 0.5);
box(x, y, s, s, 8, c, 1);
box(x + 3, y + 2, s - 6, 3, 1.5, [255, 255, 255], 0.4);
text(label, x + s / 2, y + s / 2 + 1, INK, Math.max(9, Math.round(cs * 0.38)), 800, 'center');
}
function drawTiles(fillOf) {
for (let v = 1; v < BN * BN; v += 1) {
const p = tilePos(v);
paintTile(String(v), p[0], p[1], fillOf(v), CS, BX, BY);
}
}
// 글자판 없이 배열만 그리는 작은 판 — 비교 장면용
function drawMini(b, n, ox, oy, size) {
const cs = size / n;
box(ox - 3, oy - 3, size + 6, size + 6, 8, PANEL, 1);
for (let i = 0; i < n * n; i += 1) {
const v = b[i];
if (v === 0) {
box(ox + (i % n) * cs + 1, oy + Math.floor(i / n) * cs + 1, cs - 2, cs - 2, 3, [40, 36, 62], 1);
continue;
}
const x = ox + (i % n) * cs + 1;
const y = oy + Math.floor(i / n) * cs + 1;
box(x, y, cs - 2, cs - 2, 3, tileHue(v, n), 1);
if (cs >= 20) {
text(String(v), x + (cs - 2) / 2, y + (cs - 2) / 2 + 1, INK, Math.round(cs * 0.4), 800, 'center');
}
}
}
// ── 상태 줄·낭독 문장
const SPK_PLACE = '{a}×{a} の盤、タイル {b} 枚のうち {c} 枚が元の位置にある';
const SPK_DONE = '{a}×{a} の盤が完成、タイル {b} 枚すべてが元の位置にある';
function speakBoard() {
const t = BN * BN - 1;
return isSolved(bd) ? fill(SPK_DONE, String(BN), String(t)) : fill(SPK_PLACE, String(BN), String(t), String(placedCount(bd)));
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
}
function takeOver() {
if (manual) {
return;
}
manual = true;
wake();
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 맞은 타일 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode) {
if (isSolved(bd)) {
return 1000;
}
return manual ? 100 + placedCount(bd) : sceneMode;
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say() {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
function advance(dt) {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
}
update(dt);
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow() {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) {
say();
}
}
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest() {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak) {
if (paused || still) {
sceneT += 1;
}
update(0);
showNow();
if (speak) {
say();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
say();
}
function restart() {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// N 한 걸음 — 시간표의 다음 일 하나를 지금 치른다. 시간표가 끝났으면 처음부터 다시 짠 뒤 첫 일
function stepOnce() {
takeOver();
if (evAt >= script.length) {
lap += 1;
begin();
}
if (evAt < script.length) {
const ev = script[evAt];
evAt += 1;
if (sceneT < ev.t) {
sceneT = ev.t;
}
ev.f();
}
afterInput(true);
}
function keyMove(dx, dy) {
// 방향키는 타일이 가는 방향 — 그 타일은 빈칸의 반대쪽에 있다. 빈칸이 가는 방향으로 바꿔 부른다
let d = 0;
for (let k = 0; k < 4; k += 1) {
if (DX[k] === -dx && DY[k] === -dy) {
d = k;
}
}
takeOver();
onDir(d);
afterInput(true);
}
function toCanvas(e) {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 빈칸 이웃 타일 이동, Enter·Space = 一時停止, N = 한 걸음, R = 처음부터
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'r' || k === 'R') {
restart();
}
else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
}
else if (k === 'ArrowLeft') {
e.preventDefault();
keyMove(-1, 0);
}
else if (k === 'ArrowRight') {
e.preventDefault();
keyMove(1, 0);
}
else if (k === 'ArrowUp') {
e.preventDefault();
keyMove(0, -1);
}
else if (k === 'ArrowDown') {
e.preventDefault();
keyMove(0, 1);
}
else if (keyHook !== null && keyHook(k)) {
e.preventDefault();
takeOver();
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
// 장면이 방향키·Enter·N·R 밖의 키를 받고 싶을 때 거는 갈고리 — 먹었으면 true
let keyHook = null;
pad.addEventListener('pointerdown', function (e) {
takeOver();
ptrDown = true;
try {
pad.setPointerCapture(e.pointerId);
}
catch (err) {
ptrDown = true;
}
const p = toCanvas(e);
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (!ptrDown) {
return;
}
const p = toCanvas(e);
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e) {
if (!ptrDown) {
return;
}
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 판 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// 누른 칸이 빈칸의 이웃이면 그 타일이 들어갈 빈칸의 방향(빈칸 기준), 아니면 -1
function dirOfCell(i) {
const z = bd.indexOf(0);
for (let d = 0; d < 4; d += 1) {
if (canStep(bd, BN, d) && z + DX[d] + DY[d] * BN === i) {
return d;
}
}
return -1;
}
// 06 — 노이즈로 그린 그림 한 장을 먼저 만들고 TILE_N×TILE_N 칸으로 잘라(tileCut) drawImage 로 타일에 옮긴다. 마지막 칸은 비워 두고 타일마다 SEAM_PX 의 어두운 가장자리와 위쪽 하이라이트를 얹는다
const TILE_N = 4;
const SEAM_PX = 2;
const SHUFFLE_06 = 16;
const SEED_06 = 11;
const MOVE_GAP = 0.14;
const LAP_S = 6.6;
const REST_S = 2.4;
const PX = 256;
const PW = 216;
const picCv = document.createElement('canvas');
picCv.width = BSZ;
picCv.height = BSZ;
const picRaw = picCv.getContext('2d');
if (!picRaw) {
return;
}
const picCtx = picRaw;
let doneT = -1;
const STAT_PLACED = 'そろったタイル {a}/{b} 枚';
function mixc(a, b, k) {
const t = clamp(k, 0, 1);
return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)];
}
// 그림 — 복숭아색 노을 하늘, 해, 값 잡음으로 굴곡을 준 겹겹의 언덕. 외부 이미지는 쓰지 않는다
function paintPicture(seed) {
const img = picCtx.createImageData(BSZ, BSZ);
const d = img.data;
const skyTop = [176, 166, 238];
const skyLow = [255, 208, 186];
const hill1 = [168, 152, 214];
const hill2 = [140, 196, 190];
const hill3 = [104, 168, 160];
for (let y = 0; y < BSZ; y += 1) {
for (let x = 0; x < BSZ; x += 1) {
const u = x / BSZ;
const v = y / BSZ;
let c = mixc(skyTop, skyLow, v * 1.5 + (noise2(u * 5, v * 12, seed) - 0.5) * 0.35);
const dx = u - 0.66;
const dy = v - 0.32;
const rr = Math.sqrt(dx * dx + dy * dy);
c = mixc(c, SUN, Math.exp(-rr * rr * 38) * 0.75);
if (rr < 0.085) {
c = mixc(c, [255, 244, 190], 0.9);
}
const r1 = 0.6 + (noise2(u * 3.5, 0.5, seed + 1) - 0.5) * 0.3;
const r2 = 0.74 + (noise2(u * 5, 2.5, seed + 2) - 0.5) * 0.24;
const r3 = 0.88 + (noise2(u * 7, 4.5, seed + 3) - 0.5) * 0.16;
const grain = (noise2(u * 40, v * 40, seed + 4) - 0.5) * 18;
if (v > r3) {
c = mixc(hill3, hill2, 0.2);
}
else if (v > r2) {
c = mixc(hill2, hill3, (v - r2) * 2.2);
}
else if (v > r1) {
c = mixc(hill1, hill2, (v - r1) * 2.2);
}
const o = (y * BSZ + x) * 4;
d[o] = clamp(c[0] + grain, 0, 255);
d[o + 1] = clamp(c[1] + grain, 0, 255);
d[o + 2] = clamp(c[2] + grain, 0, 255);
d[o + 3] = 255;
}
}
picCtx.putImageData(img, 0, 0);
}
paintPicture(SEED_06);
function moveAt(d) {
return function () { stepBlank(d, 0.12); };
}
function build() {
doneT = -1;
const ms = walkMoves(TILE_N, SHUFFLE_06, 5 + lap);
const b = solvedBoard(TILE_N);
applyMoves(b, TILE_N, ms);
setBoard(b, TILE_N);
// 풀이 — 섞은 길을 거꾸로 되걷는다
const back = reverseMoves(ms);
for (let k = 0; k < back.length; k += 1) {
script.push({ t: 0.25 + k * MOVE_GAP, f: moveAt(back[k]) });
}
}
function update(dt) {
if (dt < 0) {
return;
}
if (isSolved(bd)) {
if (doneT < 0) {
doneT = sceneT;
}
}
else {
doneT = -1;
}
}
// 타일 한 장 — 그림의 제자리 조각을 drawImage 로 옮기고 심과 하이라이트를 얹는다. wk 가 커지면 심이 옅어진다
function tileCut(v, px, py, wk) {
const hc = (v - 1) % BN;
const hr = Math.floor((v - 1) / BN);
const sz = BSZ / BN;
const x = BX + px * CS + 1;
const y = BY + py * CS + 1;
const s = CS - 2;
ctx.save();
ctx.beginPath();
ctx.roundRect(x, y, s, s, lerp(6, 0, wk));
ctx.clip();
ctx.drawImage(picCv, hc * sz, hr * sz, sz, sz, x, y, s, s);
ctx.restore();
ctx.beginPath();
ctx.roundRect(x + SEAM_PX / 2, y + SEAM_PX / 2, s - SEAM_PX, s - SEAM_PX, lerp(6, 0, wk));
ctx.strokeStyle = rgba(INK, 0.55 * (1 - wk));
ctx.lineWidth = SEAM_PX;
ctx.stroke();
seg(x + 6, y + SEAM_PX + 1.5, x + s - 6, y + SEAM_PX + 1.5, [255, 255, 255], 0.45 * (1 - wk), 1.5);
box(x + 3, y + 3, 15, 11, 4, INK, 0.72 * (1 - wk));
text(String(v), x + 10.5, y + 9, PAPER, 8, 800, 'center');
}
function draw() {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoardBase();
const wk = doneT >= 0 ? clamp((sceneT - doneT) / 0.6, 0, 1) : 0;
for (let v = 1; v < BN * BN; v += 1) {
const p = tilePos(v);
tileCut(v, p[0], p[1], wk);
}
// 完成 — 비어 있던 마지막 칸이 채워지며 그림이 한 장으로 이어진다
if (wk > 0) {
const sz = BSZ / BN;
const x = BX + (BN - 1) * CS + 1;
const y = BY + (BN - 1) * CS + 1;
ctx.save();
ctx.globalAlpha = wk;
ctx.drawImage(picCv, (BN - 1) * sz, (BN - 1) * sz, sz, sz, x, y, CS - 2, CS - 2);
ctx.restore();
}
const s = CS * BN;
panel(PX, BY - 5, PW, s + 10);
const tx = PX + 12;
const ty = BY + 8;
const ts = 150;
ctx.save();
ctx.beginPath();
ctx.roundRect(tx, ty, ts, ts, 8);
ctx.clip();
ctx.drawImage(picCv, 0, 0, BSZ, BSZ, tx, ty, ts, ts);
for (let i = 0; i < BN * BN - 1; i += 1) {
if (bd[i] === i + 1) {
ctx.fillStyle = rgba(INK, 0.5);
ctx.fillRect(tx + (i % BN) * ts / BN, ty + Math.floor(i / BN) * ts / BN, ts / BN, ts / BN);
}
}
ctx.restore();
for (let i = 1; i < BN; i += 1) {
seg(tx + i * ts / BN, ty, tx + i * ts / BN, ty + ts, PAPER, 0.85, 1);
seg(tx, ty + i * ts / BN, tx + ts, ty + i * ts / BN, PAPER, 0.85, 1);
}
for (let i = 0; i < BN * BN - 1; i += 1) {
if (bd[i] === i + 1) {
const cx = tx + ((i % BN) + 0.5) * ts / BN;
const cy = ty + (Math.floor(i / BN) + 0.5) * ts / BN;
seg(cx - 4, cy, cx - 1, cy + 3, MINT, 1, 2.5);
seg(cx - 1, cy + 3, cx + 5, cy - 4, MINT, 1, 2.5);
}
}
const placed = placedCount(bd);
text(String(placed), PX + 188, BY + 40, NEU, 26, 800, 'center');
text('/ ' + (BN * BN - 1), PX + 188, BY + 62, DIM, 12, 700, 'center');
box(PX + 12, BY + 168, PW - 24, 8, 4, [48, 44, 74], 1);
box(PX + 12, BY + 168, (PW - 24) * placed / (BN * BN - 1), 8, 4, MINT, 1);
text('TILE_N ' + TILE_N, PX + 12, BY + 192, DIM, 11, 700, 'left');
text('SEAM_PX ' + SEAM_PX, PX + PW - 12, BY + 192, DIM, 11, 700, 'right');
hud('SEAM_PX', String(SEAM_PX), SEAM_PX / 6);
}
function statusText() { return fill(STAT_PLACED, String(placedCount(bd)), String(BN * BN - 1)); }
function modeId() { return modeBase(0); }
function speakText() { return speakBoard(); }
function onDir(d) { stepBlank(d, 0.12); }
function onPtr(kind, x, y) {
if (kind !== 0) {
return;
}
const d = dirOfCell(cellAt(x, y));
if (d >= 0) {
onDir(d);
}
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// ImageTileCut - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./ImageTileCut.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ImageTileCut() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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 easeOut(k: number): number { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const SKY: RGB = [150, 192, 244];
const SUN: RGB = [250, 218, 146];
const NEU: RGB = [236, 230, 250];
const BGC: RGB = [46, 42, 68];
const PANEL: RGB = [62, 57, 92];
const DIM: RGB = [196, 190, 224];
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function hsl(h: number, s: number, l: number): RGB {
const a = s * Math.min(l, 1 - l);
const f = function (n: number): number {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a: number, b: number): number {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s: string, a: string, b?: string, c?: string): string {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v: number): string { return String(Math.round(v * 10) / 10); }
function group(v: number): string {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { o += ','; }
o += s.charAt(i);
}
return o;
}
// ── 퍼즐 모델 — 판은 번호 배열, 0 은 빈칸, 타일 v 의 제자리는 v-1 번 칸, 빈칸의 제자리는 맨 끝
// 빈칸이 움직이는 방향 d: 0 오른쪽 · 1 왼쪽 · 2 아래 · 3 위. 반대 방향은 d ^ 1
const DX = [1, -1, 0, 0];
const DY = [0, 0, 1, -1];
function solvedBoard(n: number): number[] {
const b: number[] = [];
for (let i = 1; i < n * n; i += 1) { b.push(i); }
b.push(0);
return b;
}
function canStep(b: number[], n: number, d: number): boolean {
const z = b.indexOf(0);
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
return c >= 0 && c < n && r >= 0 && r < n;
}
function blankStep(b: number[], n: number, d: number): boolean {
if (!canStep(b, n, d)) { return false; }
const z = b.indexOf(0);
const t = z + DX[d] + DY[d] * n;
b[z] = b[t];
b[t] = 0;
return true;
}
// 역전 — 번호를 한 줄로 늘어놓고 앞 번호가 뒤 번호보다 큰 쌍의 수(빈칸은 세지 않는다)
function inversions(b: number[]): number {
let c = 0;
for (let i = 0; i < b.length; i += 1) {
for (let j = i + 1; j < b.length; j += 1) {
if (b[i] !== 0 && b[j] !== 0 && b[i] > b[j]) { c += 1; }
}
}
return c;
}
// 빈칸이 아래서 몇 번째 줄인지(맨 아래 줄 = 1)
function blankRow(b: number[], n: number): number { return n - Math.floor(b.indexOf(0) / n); }
// 판정 합 — 짝수 폭은 역전 + 빈칸 줄, 홀수 폭은 역전만
function parity(b: number[], n: number): number { return n % 2 === 0 ? inversions(b) + blankRow(b, n) : inversions(b); }
function solvable(b: number[], n: number): boolean {
return n % 2 === 0 ? parity(b, n) % 2 === 1 : parity(b, n) % 2 === 0;
}
function manhattan(b: number[], n: number, i: number): number {
const v = b[i];
if (v === 0) { return 0; }
const h = v - 1;
return Math.abs((i % n) - (h % n)) + Math.abs(Math.floor(i / n) - Math.floor(h / n));
}
function sumH(b: number[], n: number): number {
let s = 0;
for (let i = 0; i < b.length; i += 1) { s += manhattan(b, n, i); }
return s;
}
function placedCount(b: number[]): number {
let c = 0;
for (let i = 0; i < b.length - 1; i += 1) { if (b[i] === i + 1) { c += 1; } }
return c;
}
function isSolved(b: number[]): boolean { return placedCount(b) === b.length - 1 && b[b.length - 1] === 0; }
// 걸어서 섞기 — 완성 판에서 시작해 빈칸을 steps 번 이웃 칸으로 옮긴다. 방금 온 칸으로 되돌아가는 수는 건너뛰고
// 어느 이웃으로 갈지는 정수 해시로 고른다. 반환은 빈칸이 움직인 방향의 목록
function walkMoves(n: number, steps: number, seed: number): number[] {
const b = solvedBoard(n);
const out: number[] = [];
let noBacktrack = -1;
for (let s = 0; s < steps; s += 1) {
const opts: number[] = [];
for (let d = 0; d < 4; d += 1) { if (d !== noBacktrack && canStep(b, n, d)) { opts.push(d); } }
const d = opts[Math.floor(hash2(s, seed) * opts.length)];
blankStep(b, n, d);
out.push(d);
noBacktrack = d ^ 1;
}
return out;
}
function applyMoves(b: number[], n: number, ms: number[]): void {
for (let i = 0; i < ms.length; i += 1) { blankStep(b, n, ms[i]); }
}
function reverseMoves(ms: number[]): number[] {
const o: number[] = [];
for (let i = ms.length - 1; i >= 0; i -= 1) { o.push(ms[i] ^ 1); }
return o;
}
// 줄째 밀기 — 누른 칸 i 가 빈칸과 같은 줄이면 사이 타일이 모두 한 칸씩 빈칸 쪽으로. 배열 교환으로 먼저 정하고
// 옮겨진 타일 번호를 빈칸에 가까운 순서로 돌려준다
function rowChain(b: number[], n: number, i: number): number[] {
const z = b.indexOf(0);
if (i === z) { return []; }
let step = 0;
if (Math.floor(i / n) === Math.floor(z / n)) { step = i > z ? 1 : -1; } else if (i % n === z % n) { step = i > z ? n : -n; } else { return []; }
const moved: number[] = [];
for (let p = z; p !== i; p += step) {
b[p] = b[p + step];
moved.push(b[p]);
}
b[i] = 0;
return moved;
}
// 반복 깊이 탐색 — 맨해튼 거리 합 h 를 길잡이로 깊이 우선 탐색하고, 수와 h 의 합이 한도(bound)를 넘으면 가지를 접는다.
// 한 바퀴가 끝나면 한도를 넘긴 값 중 가장 작은 것으로 올려 다시 돈다. 노드 수가 cap 에 닿으면 멈춘다
type Ida = { path: number[]; nodes: number; bounds: number[]; perBound: number[]; capHit: boolean };
function idaStar(start: number[], n: number, cap: number): Ida {
const b = start.slice();
const path: number[] = [];
let nodes = 0;
let capHit = false;
const INF = 9999;
let z = b.indexOf(0);
function dfs(g: number, h: number, bound: number, last: number): number {
nodes += 1;
if (nodes > cap) { capHit = true; return INF; }
const f = g + h;
if (f > bound) { return f; }
if (h === 0) { return -1; }
let min = INF;
for (let d = 0; d < 4; d += 1) {
if (last >= 0 && d === (last ^ 1)) { continue; }
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
if (c < 0 || c >= n || r < 0 || r >= n) { continue; }
const t = z + DX[d] + DY[d] * n;
const v = b[t];
const hv = Math.abs((t % n) - ((v - 1) % n)) + Math.abs(Math.floor(t / n) - Math.floor((v - 1) / n));
const hn = Math.abs((z % n) - ((v - 1) % n)) + Math.abs(Math.floor(z / n) - Math.floor((v - 1) / n));
b[z] = v;
b[t] = 0;
const oz = z;
z = t;
path.push(d);
const res = dfs(g + 1, h - hv + hn, bound, d);
if (res === -1) { return -1; }
path.pop();
z = oz;
b[t] = v;
b[z] = 0;
if (capHit) { return INF; }
if (res < min) { min = res; }
}
return min;
}
let bound = sumH(b, n);
const bounds: number[] = [];
const perBound: number[] = [];
while (bound < INF) {
const before = nodes;
bounds.push(bound);
const res = dfs(0, sumH(b, n), bound, -1);
perBound.push(nodes - before);
if (res === -1) { return { path: path.slice(), nodes: nodes, bounds: bounds, perBound: perBound, capHit: false }; }
if (capHit || res >= INF) { break; }
bound = res;
}
return { path: [], nodes: nodes, bounds: bounds, perBound: perBound, capHit: capHit };
}
// ── 판 화면 — 타일마다 지금 칸 → 목표 칸 보간. 배열은 먼저 바뀌고 화면이 뒤따른다
const BX = 24;
const BY = 44;
const BSZ = 216;
let bd: number[] = [];
let BN = 4;
let CS = 54;
type Tl = { fx: number; fy: number; tx: number; ty: number; t0: number; du: number };
let tl: Tl[] = [];
let dragV = -1;
let dragCx = 0;
let dragCy = 0;
function setBoard(b: number[], n: number): void {
bd = b.slice();
BN = n;
CS = BSZ / n;
tl = [];
dragV = -1;
for (let v = 0; v < n * n; v += 1) {
const i = bd.indexOf(v);
const c = i % n;
const r = Math.floor(i / n);
tl.push({ fx: c, fy: r, tx: c, ty: r, t0: 0, du: 0 });
}
}
let still = false;
function tilePos(v: number): number[] {
const t = tl[v];
if (v === dragV) { return [dragCx, dragCy]; }
if (still || t.du <= 0) { return [t.tx, t.ty]; }
const e = easeOut((sceneT - t.t0) / t.du);
return [lerp(t.fx, t.tx, e), lerp(t.fy, t.ty, e)];
}
// 배열이 바뀐 뒤 타일마다 목표 칸을 다시 읽는다 — 옮겨진 타일만 지금 화면 자리에서 출발한다
function retarget(delayOf: (v: number) => number, du: number): void {
for (let v = 1; v < BN * BN; v += 1) {
const i = bd.indexOf(v);
const c = i % BN;
const r = Math.floor(i / BN);
const t = tl[v];
if (c !== t.tx || r !== t.ty) {
const p = tilePos(v);
t.fx = p[0];
t.fy = p[1];
t.tx = c;
t.ty = r;
t.t0 = sceneT + delayOf(v);
t.du = du;
}
}
}
function stepBlank(d: number, du: number): boolean {
if (!blankStep(bd, BN, d)) { return false; }
retarget(function (): number { return 0; }, du);
return true;
}
function cellAt(x: number, y: number): number {
const c = Math.floor((x - BX) / CS);
const r = Math.floor((y - BY) / CS);
if (c < 0 || c >= BN || r < 0 || r >= BN) { return -1; }
return r * BN + c;
}
// ── 그리기 도구
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, c: RGB, a: number, w: number): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x: number, y: number, w: number, h: number): void { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name: string, val: string, frac: number): void {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// 판 바탕 — 받침과 빈 칸 홈
function drawBoardBase(): void {
const s = CS * BN;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < BN * BN; i += 1) {
box(BX + (i % BN) * CS + 2, BY + Math.floor(i / BN) * CS + 2, CS - 4, CS - 4, 8, [40, 36, 62], 1);
}
}
function tileHue(v: number, n: number): RGB {
return hsl(190 + 160 * ((v - 1) / (n * n - 2)), 0.55, 0.8);
}
function paintTile(label: string, cx: number, cy: number, c: RGB, cs: number, ox: number, oy: number): void {
const s = cs - 4;
const x = ox + cx * cs + 2;
const y = oy + cy * cs + 2;
box(x, y + 2, s, s, 8, [26, 23, 42], 0.5);
box(x, y, s, s, 8, c, 1);
box(x + 3, y + 2, s - 6, 3, 1.5, [255, 255, 255], 0.4);
text(label, x + s / 2, y + s / 2 + 1, INK, Math.max(9, Math.round(cs * 0.38)), 800, 'center');
}
function drawTiles(fillOf: (v: number) => RGB): void {
for (let v = 1; v < BN * BN; v += 1) {
const p = tilePos(v);
paintTile(String(v), p[0], p[1], fillOf(v), CS, BX, BY);
}
}
// 글자판 없이 배열만 그리는 작은 판 — 비교 장면용
function drawMini(b: number[], n: number, ox: number, oy: number, size: number): void {
const cs = size / n;
box(ox - 3, oy - 3, size + 6, size + 6, 8, PANEL, 1);
for (let i = 0; i < n * n; i += 1) {
const v = b[i];
if (v === 0) { box(ox + (i % n) * cs + 1, oy + Math.floor(i / n) * cs + 1, cs - 2, cs - 2, 3, [40, 36, 62], 1); continue; }
const x = ox + (i % n) * cs + 1;
const y = oy + Math.floor(i / n) * cs + 1;
box(x, y, cs - 2, cs - 2, 3, tileHue(v, n), 1);
if (cs >= 20) { text(String(v), x + (cs - 2) / 2, y + (cs - 2) / 2 + 1, INK, Math.round(cs * 0.4), 800, 'center'); }
}
}
// ── 상태 줄·낭독 문장
const SPK_PLACE = '{a}×{a} 판, 타일 {b}장 중 {c}장이 제자리에 있다';
const SPK_DONE = '{a}×{a} 판 완성, 타일 {b}장이 모두 제자리에 있다';
function speakBoard(): string {
const t = BN * BN - 1;
return isSolved(bd) ? fill(SPK_DONE, String(BN), String(t)) : fill(SPK_PLACE, String(BN), String(t), String(placedCount(bd)));
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
}
function takeOver(): void {
if (manual) { return; }
manual = true;
wake();
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 맞은 타일 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode: number): number {
if (isSolved(bd)) { return 1000; }
return manual ? 100 + placedCount(bd) : sceneMode;
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say(): void {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
function advance(dt: number): void {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
}
update(dt);
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow(): void {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) { say(); }
}
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest(): void {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak: boolean): void {
if (paused || still) { sceneT += 1; }
update(0);
showNow();
if (speak) { say(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
say();
}
function restart(): void {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// N 한 걸음 — 시간표의 다음 일 하나를 지금 치른다. 시간표가 끝났으면 처음부터 다시 짠 뒤 첫 일
function stepOnce(): void {
takeOver();
if (evAt >= script.length) { lap += 1; begin(); }
if (evAt < script.length) {
const ev = script[evAt];
evAt += 1;
if (sceneT < ev.t) { sceneT = ev.t; }
ev.f();
}
afterInput(true);
}
function keyMove(dx: number, dy: number): void {
// 방향키는 타일이 가는 방향 — 그 타일은 빈칸의 반대쪽에 있다. 빈칸이 가는 방향으로 바꿔 부른다
let d = 0;
for (let k = 0; k < 4; k += 1) { if (DX[k] === -dx && DY[k] === -dy) { d = k; } }
takeOver();
onDir(d);
afterInput(true);
}
function toCanvas(e: PointerEvent): number[] {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 빈칸 이웃 타일 이동, Enter·Space = 일시정지, N = 한 걸음, R = 처음부터
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'r' || k === 'R') {
restart();
} else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
} else if (k === 'ArrowLeft') {
e.preventDefault();
keyMove(-1, 0);
} else if (k === 'ArrowRight') {
e.preventDefault();
keyMove(1, 0);
} else if (k === 'ArrowUp') {
e.preventDefault();
keyMove(0, -1);
} else if (k === 'ArrowDown') {
e.preventDefault();
keyMove(0, 1);
} else if (keyHook !== null && keyHook(k)) {
e.preventDefault();
takeOver();
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
// 장면이 방향키·Enter·N·R 밖의 키를 받고 싶을 때 거는 갈고리 — 먹었으면 true
let keyHook: ((k: string) => boolean) | null = null;
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
ptrDown = true;
try { pad.setPointerCapture(e.pointerId); } catch (err) { ptrDown = true; }
const p = toCanvas(e);
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (!ptrDown) { return; }
const p = toCanvas(e);
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e: PointerEvent): void {
if (!ptrDown) { return; }
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 판 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// 누른 칸이 빈칸의 이웃이면 그 타일이 들어갈 빈칸의 방향(빈칸 기준), 아니면 -1
function dirOfCell(i: number): number {
const z = bd.indexOf(0);
for (let d = 0; d < 4; d += 1) { if (canStep(bd, BN, d) && z + DX[d] + DY[d] * BN === i) { return d; } }
return -1;
}
// 06 — 노이즈로 그린 그림 한 장을 먼저 만들고 TILE_N×TILE_N 칸으로 잘라(tileCut) drawImage 로 타일에 옮긴다. 마지막 칸은 비워 두고 타일마다 SEAM_PX 의 어두운 가장자리와 위쪽 하이라이트를 얹는다
const TILE_N = 4;
const SEAM_PX = 2;
const SHUFFLE_06 = 16;
const SEED_06 = 11;
const MOVE_GAP = 0.14;
const LAP_S = 6.6;
const REST_S = 2.4;
const PX = 256;
const PW = 216;
const picCv: HTMLCanvasElement = document.createElement('canvas');
picCv.width = BSZ;
picCv.height = BSZ;
const picRaw = picCv.getContext('2d');
if (!picRaw) { return; }
const picCtx: CanvasRenderingContext2D = picRaw;
let doneT = -1;
const STAT_PLACED = '맞은 타일 {a}/{b}장';
function mixc(a: RGB, b: RGB, k: number): RGB {
const t = clamp(k, 0, 1);
return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)];
}
// 그림 — 복숭아색 노을 하늘, 해, 값 잡음으로 굴곡을 준 겹겹의 언덕. 외부 이미지는 쓰지 않는다
function paintPicture(seed: number): void {
const img = picCtx.createImageData(BSZ, BSZ);
const d = img.data;
const skyTop: RGB = [176, 166, 238];
const skyLow: RGB = [255, 208, 186];
const hill1: RGB = [168, 152, 214];
const hill2: RGB = [140, 196, 190];
const hill3: RGB = [104, 168, 160];
for (let y = 0; y < BSZ; y += 1) {
for (let x = 0; x < BSZ; x += 1) {
const u = x / BSZ;
const v = y / BSZ;
let c = mixc(skyTop, skyLow, v * 1.5 + (noise2(u * 5, v * 12, seed) - 0.5) * 0.35);
const dx = u - 0.66;
const dy = v - 0.32;
const rr = Math.sqrt(dx * dx + dy * dy);
c = mixc(c, SUN, Math.exp(-rr * rr * 38) * 0.75);
if (rr < 0.085) { c = mixc(c, [255, 244, 190], 0.9); }
const r1 = 0.6 + (noise2(u * 3.5, 0.5, seed + 1) - 0.5) * 0.3;
const r2 = 0.74 + (noise2(u * 5, 2.5, seed + 2) - 0.5) * 0.24;
const r3 = 0.88 + (noise2(u * 7, 4.5, seed + 3) - 0.5) * 0.16;
const grain = (noise2(u * 40, v * 40, seed + 4) - 0.5) * 18;
if (v > r3) { c = mixc(hill3, hill2, 0.2); } else if (v > r2) { c = mixc(hill2, hill3, (v - r2) * 2.2); } else if (v > r1) { c = mixc(hill1, hill2, (v - r1) * 2.2); }
const o = (y * BSZ + x) * 4;
d[o] = clamp(c[0] + grain, 0, 255);
d[o + 1] = clamp(c[1] + grain, 0, 255);
d[o + 2] = clamp(c[2] + grain, 0, 255);
d[o + 3] = 255;
}
}
picCtx.putImageData(img, 0, 0);
}
paintPicture(SEED_06);
function moveAt(d: number): () => void {
return function (): void { stepBlank(d, 0.12); };
}
function build(): void {
doneT = -1;
const ms = walkMoves(TILE_N, SHUFFLE_06, 5 + lap);
const b = solvedBoard(TILE_N);
applyMoves(b, TILE_N, ms);
setBoard(b, TILE_N);
// 풀이 — 섞은 길을 거꾸로 되걷는다
const back = reverseMoves(ms);
for (let k = 0; k < back.length; k += 1) { script.push({ t: 0.25 + k * MOVE_GAP, f: moveAt(back[k]) }); }
}
function update(dt: number): void {
if (dt < 0) { return; }
if (isSolved(bd)) { if (doneT < 0) { doneT = sceneT; } } else { doneT = -1; }
}
// 타일 한 장 — 그림의 제자리 조각을 drawImage 로 옮기고 심과 하이라이트를 얹는다. wk 가 커지면 심이 옅어진다
function tileCut(v: number, px: number, py: number, wk: number): void {
const hc = (v - 1) % BN;
const hr = Math.floor((v - 1) / BN);
const sz = BSZ / BN;
const x = BX + px * CS + 1;
const y = BY + py * CS + 1;
const s = CS - 2;
ctx.save();
ctx.beginPath();
ctx.roundRect(x, y, s, s, lerp(6, 0, wk));
ctx.clip();
ctx.drawImage(picCv, hc * sz, hr * sz, sz, sz, x, y, s, s);
ctx.restore();
ctx.beginPath();
ctx.roundRect(x + SEAM_PX / 2, y + SEAM_PX / 2, s - SEAM_PX, s - SEAM_PX, lerp(6, 0, wk));
ctx.strokeStyle = rgba(INK, 0.55 * (1 - wk));
ctx.lineWidth = SEAM_PX;
ctx.stroke();
seg(x + 6, y + SEAM_PX + 1.5, x + s - 6, y + SEAM_PX + 1.5, [255, 255, 255], 0.45 * (1 - wk), 1.5);
box(x + 3, y + 3, 15, 11, 4, INK, 0.72 * (1 - wk));
text(String(v), x + 10.5, y + 9, PAPER, 8, 800, 'center');
}
function draw(): void {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoardBase();
const wk = doneT >= 0 ? clamp((sceneT - doneT) / 0.6, 0, 1) : 0;
for (let v = 1; v < BN * BN; v += 1) {
const p = tilePos(v);
tileCut(v, p[0], p[1], wk);
}
// 완성 — 비어 있던 마지막 칸이 채워지며 그림이 한 장으로 이어진다
if (wk > 0) {
const sz = BSZ / BN;
const x = BX + (BN - 1) * CS + 1;
const y = BY + (BN - 1) * CS + 1;
ctx.save();
ctx.globalAlpha = wk;
ctx.drawImage(picCv, (BN - 1) * sz, (BN - 1) * sz, sz, sz, x, y, CS - 2, CS - 2);
ctx.restore();
}
const s = CS * BN;
panel(PX, BY - 5, PW, s + 10);
const tx = PX + 12;
const ty = BY + 8;
const ts = 150;
ctx.save();
ctx.beginPath();
ctx.roundRect(tx, ty, ts, ts, 8);
ctx.clip();
ctx.drawImage(picCv, 0, 0, BSZ, BSZ, tx, ty, ts, ts);
for (let i = 0; i < BN * BN - 1; i += 1) {
if (bd[i] === i + 1) {
ctx.fillStyle = rgba(INK, 0.5);
ctx.fillRect(tx + (i % BN) * ts / BN, ty + Math.floor(i / BN) * ts / BN, ts / BN, ts / BN);
}
}
ctx.restore();
for (let i = 1; i < BN; i += 1) {
seg(tx + i * ts / BN, ty, tx + i * ts / BN, ty + ts, PAPER, 0.85, 1);
seg(tx, ty + i * ts / BN, tx + ts, ty + i * ts / BN, PAPER, 0.85, 1);
}
for (let i = 0; i < BN * BN - 1; i += 1) {
if (bd[i] === i + 1) {
const cx = tx + ((i % BN) + 0.5) * ts / BN;
const cy = ty + (Math.floor(i / BN) + 0.5) * ts / BN;
seg(cx - 4, cy, cx - 1, cy + 3, MINT, 1, 2.5);
seg(cx - 1, cy + 3, cx + 5, cy - 4, MINT, 1, 2.5);
}
}
const placed = placedCount(bd);
text(String(placed), PX + 188, BY + 40, NEU, 26, 800, 'center');
text('/ ' + (BN * BN - 1), PX + 188, BY + 62, DIM, 12, 700, 'center');
box(PX + 12, BY + 168, PW - 24, 8, 4, [48, 44, 74], 1);
box(PX + 12, BY + 168, (PW - 24) * placed / (BN * BN - 1), 8, 4, MINT, 1);
text('TILE_N ' + TILE_N, PX + 12, BY + 192, DIM, 11, 700, 'left');
text('SEAM_PX ' + SEAM_PX, PX + PW - 12, BY + 192, DIM, 11, 700, 'right');
hud('SEAM_PX', String(SEAM_PX), SEAM_PX / 6);
}
function statusText(): string { return fill(STAT_PLACED, String(placedCount(bd)), String(BN * BN - 1)); }
function modeId(): number { return modeBase(0); }
function speakText(): string { return speakBoard(); }
function onDir(d: number): void { stepBlank(d, 0.12); }
function onPtr(kind: number, x: number, y: number): void {
if (kind !== 0) { return; }
const d = dirOfCell(cellAt(x, y));
if (d >= 0) { onDir(d); }
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'슬라이딩 퍼즐 시연 무대 — 방향키로 빈칸 옆 타일 이동, 타일 누르기도 가능, Enter 또는 Space 로 일시정지, N 으로 한 걸음, R 로 처음부터'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'노이즈로 그린 노을 풍경 그림 한 장이 4×4 타일로 잘려 섞여 있고 마지막 칸은 비어 있다. 타일마다 어두운 2픽셀 가장자리와 위쪽 하이라이트가 있고 모서리에 번호가 적힌다. 오른쪽 패널에는 원래 그림과 제자리에 온 타일이 표시된 격자가 있고, 모두 맞으면 잘린 선이 사라지며 그림이 한 장으로 이어진다. 아래 눈금은 상수 이름과 지금 값을 보이고, 움직임 줄이기에서는 타일이 바로 자리를 바꾸는 판 한 장만 그려진다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'슬라이딩 퍼즐 장면'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}06 · 一枚の絵をタイルに切って継ぎ目が見える盤 — 計測 累積面積 32.667% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function tileCut(v, px, py, wk) {
const hc = (v - 1) % BN;
const hr = Math.floor((v - 1) / BN);
const sz = BSZ / BN;
const x = BX + px * CS + 1;
const y = BY + py * CS + 1;
const s = CS - 2;
ctx.save();
ctx.beginPath();
ctx.roundRect(x, y, s, s, lerp(6, 0, wk));
ctx.clip();
ctx.drawImage(picCv, hc * sz, hr * sz, sz, sz, x, y, s, s);
ctx.restore();
そろえていくと絵が一枚現れ、完成後の0.6秒で継ぎ目が薄くなります。216×216の絵をマス54pxで切ると、タイル15枚・解く手16・完成2.37秒で、切り出しは MDN の drawImage の文書にある元の領域から先の領域へのコピーそのものです。
07ドラッグして離すとマスに吸いつくポインター
タイルを押してドラッグすると、空きマスの向きの一軸にだけ付いてきて、一マスの幅を超えないように切ります。離したとき移動量が SNAP_RATIO の0.5を超えれば空きマスに入り、届かなければ元に戻り、ポインターイベント一つでマウスとタッチが同時に使えます。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="スライドパズルの舞台 — タイルをドラッグして離す、矢印キーで空きマスの隣のタイルを動かす、EnterかSpaceで一時停止、Nで一歩、Rで最初から">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="4×4の盤でポインターの輪が空きマスの隣のタイルを引く。タイルは空きマスの向きの一軸にだけ付いてきて一マスの幅で切られ、ポインターとタイルを点線が結ぶ。右の目盛りは引いた量を0から1まで示し、SNAP_RATIOの0.5の位置に印がある。離したとき印を超えていれば空きマスに吸いつき、届かなければ元に戻る。直近の結果は点として積まれる。下の目盛りは定数名と今の値を示し、動きを減らす設定ではタイルがすぐ入れ替わる盤一枚だけを描きます"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">スライドパズルの場面</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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;
}
// 무대 상자 — 눌러서 한 번 깨뜨리는 곳(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause {
padding: $sp-1 $sp-3;
border: 2px solid $subject-cream;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 스크린리더에만 읽히는 낭독 문장 — 모드가 바뀔 때만 쓴다
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-ink;
color: $subject-cream;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면에만 있고 aria-live 는 늘 off
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 100px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 글리치 없는 원문 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
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;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause {
padding: 4px 12px;
border: 2px solid #fff7e6;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 100px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function easeOut(k) { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const SKY = [150, 192, 244];
const SUN = [250, 218, 146];
const NEU = [236, 230, 250];
const BGC = [46, 42, 68];
const PANEL = [62, 57, 92];
const DIM = [196, 190, 224];
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function hsl(h, s, l) {
const a = s * Math.min(l, 1 - l);
const f = function (n) {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a, b) {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s, a, b, c) {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v) { return String(Math.round(v * 10) / 10); }
function group(v) {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
o += ',';
}
o += s.charAt(i);
}
return o;
}
// ── 퍼즐 모델 — 판은 번호 배열, 0 은 빈칸, 타일 v 의 제자리는 v-1 번 칸, 빈칸의 제자리는 맨 끝
// 빈칸이 움직이는 방향 d: 0 오른쪽 · 1 왼쪽 · 2 아래 · 3 위. 반대 방향은 d ^ 1
const DX = [1, -1, 0, 0];
const DY = [0, 0, 1, -1];
function solvedBoard(n) {
const b = [];
for (let i = 1; i < n * n; i += 1) {
b.push(i);
}
b.push(0);
return b;
}
function canStep(b, n, d) {
const z = b.indexOf(0);
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
return c >= 0 && c < n && r >= 0 && r < n;
}
function blankStep(b, n, d) {
if (!canStep(b, n, d)) {
return false;
}
const z = b.indexOf(0);
const t = z + DX[d] + DY[d] * n;
b[z] = b[t];
b[t] = 0;
return true;
}
// 역전 — 번호를 한 줄로 늘어놓고 앞 번호가 뒤 번호보다 큰 쌍의 수(빈칸은 세지 않는다)
function inversions(b) {
let c = 0;
for (let i = 0; i < b.length; i += 1) {
for (let j = i + 1; j < b.length; j += 1) {
if (b[i] !== 0 && b[j] !== 0 && b[i] > b[j]) {
c += 1;
}
}
}
return c;
}
// 빈칸이 아래서 몇 번째 줄인지(맨 아래 줄 = 1)
function blankRow(b, n) { return n - Math.floor(b.indexOf(0) / n); }
// 판정 합 — 짝수 폭은 역전 + 빈칸 줄, 홀수 폭은 역전만
function parity(b, n) { return n % 2 === 0 ? inversions(b) + blankRow(b, n) : inversions(b); }
function solvable(b, n) {
return n % 2 === 0 ? parity(b, n) % 2 === 1 : parity(b, n) % 2 === 0;
}
function manhattan(b, n, i) {
const v = b[i];
if (v === 0) {
return 0;
}
const h = v - 1;
return Math.abs((i % n) - (h % n)) + Math.abs(Math.floor(i / n) - Math.floor(h / n));
}
function sumH(b, n) {
let s = 0;
for (let i = 0; i < b.length; i += 1) {
s += manhattan(b, n, i);
}
return s;
}
function placedCount(b) {
let c = 0;
for (let i = 0; i < b.length - 1; i += 1) {
if (b[i] === i + 1) {
c += 1;
}
}
return c;
}
function isSolved(b) { return placedCount(b) === b.length - 1 && b[b.length - 1] === 0; }
// 歩いて混ぜる — 完成 판에서 시작해 빈칸을 steps 번 이웃 칸으로 옮긴다. 방금 온 칸으로 되돌아가는 수는 건너뛰고
// 어느 이웃으로 갈지는 정수 해시로 고른다. 반환은 빈칸이 움직인 방향의 목록
function walkMoves(n, steps, seed) {
const b = solvedBoard(n);
const out = [];
let noBacktrack = -1;
for (let s = 0; s < steps; s += 1) {
const opts = [];
for (let d = 0; d < 4; d += 1) {
if (d !== noBacktrack && canStep(b, n, d)) {
opts.push(d);
}
}
const d = opts[Math.floor(hash2(s, seed) * opts.length)];
blankStep(b, n, d);
out.push(d);
noBacktrack = d ^ 1;
}
return out;
}
function applyMoves(b, n, ms) {
for (let i = 0; i < ms.length; i += 1) {
blankStep(b, n, ms[i]);
}
}
function reverseMoves(ms) {
const o = [];
for (let i = ms.length - 1; i >= 0; i -= 1) {
o.push(ms[i] ^ 1);
}
return o;
}
// 줄째 밀기 — 누른 칸 i 가 빈칸과 같은 줄이면 사이 타일이 모두 한 칸씩 빈칸 쪽으로. 배열 교환으로 먼저 정하고
// 옮겨진 타일 번호를 빈칸에 가까운 순서로 돌려준다
function rowChain(b, n, i) {
const z = b.indexOf(0);
if (i === z) {
return [];
}
let step = 0;
if (Math.floor(i / n) === Math.floor(z / n)) {
step = i > z ? 1 : -1;
}
else if (i % n === z % n) {
step = i > z ? n : -n;
}
else {
return [];
}
const moved = [];
for (let p = z; p !== i; p += step) {
b[p] = b[p + step];
moved.push(b[p]);
}
b[i] = 0;
return moved;
}
function idaStar(start, n, cap) {
const b = start.slice();
const path = [];
let nodes = 0;
let capHit = false;
const INF = 9999;
let z = b.indexOf(0);
function dfs(g, h, bound, last) {
nodes += 1;
if (nodes > cap) {
capHit = true;
return INF;
}
const f = g + h;
if (f > bound) {
return f;
}
if (h === 0) {
return -1;
}
let min = INF;
for (let d = 0; d < 4; d += 1) {
if (last >= 0 && d === (last ^ 1)) {
continue;
}
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
if (c < 0 || c >= n || r < 0 || r >= n) {
continue;
}
const t = z + DX[d] + DY[d] * n;
const v = b[t];
const hv = Math.abs((t % n) - ((v - 1) % n)) + Math.abs(Math.floor(t / n) - Math.floor((v - 1) / n));
const hn = Math.abs((z % n) - ((v - 1) % n)) + Math.abs(Math.floor(z / n) - Math.floor((v - 1) / n));
b[z] = v;
b[t] = 0;
const oz = z;
z = t;
path.push(d);
const res = dfs(g + 1, h - hv + hn, bound, d);
if (res === -1) {
return -1;
}
path.pop();
z = oz;
b[t] = v;
b[z] = 0;
if (capHit) {
return INF;
}
if (res < min) {
min = res;
}
}
return min;
}
let bound = sumH(b, n);
const bounds = [];
const perBound = [];
while (bound < INF) {
const before = nodes;
bounds.push(bound);
const res = dfs(0, sumH(b, n), bound, -1);
perBound.push(nodes - before);
if (res === -1) {
return { path: path.slice(), nodes: nodes, bounds: bounds, perBound: perBound, capHit: false };
}
if (capHit || res >= INF) {
break;
}
bound = res;
}
return { path: [], nodes: nodes, bounds: bounds, perBound: perBound, capHit: capHit };
}
// ── 판 화면 — 타일마다 지금 칸 → 목표 칸 보간. 배열은 먼저 바뀌고 화면이 뒤따른다
const BX = 24;
const BY = 44;
const BSZ = 216;
let bd = [];
let BN = 4;
let CS = 54;
let tl = [];
let dragV = -1;
let dragCx = 0;
let dragCy = 0;
function setBoard(b, n) {
bd = b.slice();
BN = n;
CS = BSZ / n;
tl = [];
dragV = -1;
for (let v = 0; v < n * n; v += 1) {
const i = bd.indexOf(v);
const c = i % n;
const r = Math.floor(i / n);
tl.push({ fx: c, fy: r, tx: c, ty: r, t0: 0, du: 0 });
}
}
let still = false;
function tilePos(v) {
const t = tl[v];
if (v === dragV) {
return [dragCx, dragCy];
}
if (still || t.du <= 0) {
return [t.tx, t.ty];
}
const e = easeOut((sceneT - t.t0) / t.du);
return [lerp(t.fx, t.tx, e), lerp(t.fy, t.ty, e)];
}
// 배열이 바뀐 뒤 타일마다 목표 칸을 다시 읽는다 — 옮겨진 타일만 지금 화면 자리에서 출발한다
function retarget(delayOf, du) {
for (let v = 1; v < BN * BN; v += 1) {
const i = bd.indexOf(v);
const c = i % BN;
const r = Math.floor(i / BN);
const t = tl[v];
if (c !== t.tx || r !== t.ty) {
const p = tilePos(v);
t.fx = p[0];
t.fy = p[1];
t.tx = c;
t.ty = r;
t.t0 = sceneT + delayOf(v);
t.du = du;
}
}
}
function stepBlank(d, du) {
if (!blankStep(bd, BN, d)) {
return false;
}
retarget(function () { return 0; }, du);
return true;
}
function cellAt(x, y) {
const c = Math.floor((x - BX) / CS);
const r = Math.floor((y - BY) / CS);
if (c < 0 || c >= BN || r < 0 || r >= BN) {
return -1;
}
return r * BN + c;
}
// ── 그리기 도구
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function disc(x, y, r, c, a) {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, c, a, w) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s, x, y, c, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x, y, w, h) { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name, val, frac) {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// 판 바탕 — 받침과 빈 칸 홈
function drawBoardBase() {
const s = CS * BN;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < BN * BN; i += 1) {
box(BX + (i % BN) * CS + 2, BY + Math.floor(i / BN) * CS + 2, CS - 4, CS - 4, 8, [40, 36, 62], 1);
}
}
function tileHue(v, n) {
return hsl(190 + 160 * ((v - 1) / (n * n - 2)), 0.55, 0.8);
}
function paintTile(label, cx, cy, c, cs, ox, oy) {
const s = cs - 4;
const x = ox + cx * cs + 2;
const y = oy + cy * cs + 2;
box(x, y + 2, s, s, 8, [26, 23, 42], 0.5);
box(x, y, s, s, 8, c, 1);
box(x + 3, y + 2, s - 6, 3, 1.5, [255, 255, 255], 0.4);
text(label, x + s / 2, y + s / 2 + 1, INK, Math.max(9, Math.round(cs * 0.38)), 800, 'center');
}
function drawTiles(fillOf) {
for (let v = 1; v < BN * BN; v += 1) {
const p = tilePos(v);
paintTile(String(v), p[0], p[1], fillOf(v), CS, BX, BY);
}
}
// 글자판 없이 배열만 그리는 작은 판 — 비교 장면용
function drawMini(b, n, ox, oy, size) {
const cs = size / n;
box(ox - 3, oy - 3, size + 6, size + 6, 8, PANEL, 1);
for (let i = 0; i < n * n; i += 1) {
const v = b[i];
if (v === 0) {
box(ox + (i % n) * cs + 1, oy + Math.floor(i / n) * cs + 1, cs - 2, cs - 2, 3, [40, 36, 62], 1);
continue;
}
const x = ox + (i % n) * cs + 1;
const y = oy + Math.floor(i / n) * cs + 1;
box(x, y, cs - 2, cs - 2, 3, tileHue(v, n), 1);
if (cs >= 20) {
text(String(v), x + (cs - 2) / 2, y + (cs - 2) / 2 + 1, INK, Math.round(cs * 0.4), 800, 'center');
}
}
}
// ── 상태 줄·낭독 문장
const SPK_PLACE = '{a}×{a} の盤、タイル {b} 枚のうち {c} 枚が元の位置にある';
const SPK_DONE = '{a}×{a} の盤が完成、タイル {b} 枚すべてが元の位置にある';
function speakBoard() {
const t = BN * BN - 1;
return isSolved(bd) ? fill(SPK_DONE, String(BN), String(t)) : fill(SPK_PLACE, String(BN), String(t), String(placedCount(bd)));
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
}
function takeOver() {
if (manual) {
return;
}
manual = true;
wake();
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 맞은 타일 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode) {
if (isSolved(bd)) {
return 1000;
}
return manual ? 100 + placedCount(bd) : sceneMode;
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say() {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
function advance(dt) {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
}
update(dt);
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow() {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) {
say();
}
}
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest() {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak) {
if (paused || still) {
sceneT += 1;
}
update(0);
showNow();
if (speak) {
say();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
say();
}
function restart() {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// N 한 걸음 — 시간표의 다음 일 하나를 지금 치른다. 시간표가 끝났으면 처음부터 다시 짠 뒤 첫 일
function stepOnce() {
takeOver();
if (evAt >= script.length) {
lap += 1;
begin();
}
if (evAt < script.length) {
const ev = script[evAt];
evAt += 1;
if (sceneT < ev.t) {
sceneT = ev.t;
}
ev.f();
}
afterInput(true);
}
function keyMove(dx, dy) {
// 방향키는 타일이 가는 방향 — 그 타일은 빈칸의 반대쪽에 있다. 빈칸이 가는 방향으로 바꿔 부른다
let d = 0;
for (let k = 0; k < 4; k += 1) {
if (DX[k] === -dx && DY[k] === -dy) {
d = k;
}
}
takeOver();
onDir(d);
afterInput(true);
}
function toCanvas(e) {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 빈칸 이웃 타일 이동, Enter·Space = 一時停止, N = 한 걸음, R = 처음부터
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'r' || k === 'R') {
restart();
}
else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
}
else if (k === 'ArrowLeft') {
e.preventDefault();
keyMove(-1, 0);
}
else if (k === 'ArrowRight') {
e.preventDefault();
keyMove(1, 0);
}
else if (k === 'ArrowUp') {
e.preventDefault();
keyMove(0, -1);
}
else if (k === 'ArrowDown') {
e.preventDefault();
keyMove(0, 1);
}
else if (keyHook !== null && keyHook(k)) {
e.preventDefault();
takeOver();
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
// 장면이 방향키·Enter·N·R 밖의 키를 받고 싶을 때 거는 갈고리 — 먹었으면 true
let keyHook = null;
pad.addEventListener('pointerdown', function (e) {
takeOver();
ptrDown = true;
try {
pad.setPointerCapture(e.pointerId);
}
catch (err) {
ptrDown = true;
}
const p = toCanvas(e);
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (!ptrDown) {
return;
}
const p = toCanvas(e);
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e) {
if (!ptrDown) {
return;
}
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 판 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// 누른 칸이 빈칸의 이웃이면 그 타일이 들어갈 빈칸의 방향(빈칸 기준), 아니면 -1
function dirOfCell(i) {
const z = bd.indexOf(0);
for (let d = 0; d < 4; d += 1) {
if (canStep(bd, BN, d) && z + DX[d] + DY[d] * BN === i) {
return d;
}
}
return -1;
}
// 07 — 타일을 누르고 끌면 빈칸 방향의 한 축으로만 따라오고 한 칸 폭을 넘지 못하게 자른다(clamp). 놓을 때 이동량이 SNAP_RATIO 이상이면 빈칸으로 들어가고 못 미치면 제자리로 돌아온다. 포인터 이벤트 하나로 마우스와 터치가 같이 된다
const TILE_N = 4;
const SNAP_RATIO = 0.5;
const RAW_MAX = 1.6;
const SETTLE_S = 0.14;
const DRAG_S = 0.36;
const HOLD_S = 0.12;
const CYCLE_S = 0.78;
const RAWS = [0.85, 0.3, 1.45, 0.62, 0.42, 1.2, 0.7, 0.25];
const SHUFFLE_07 = 24;
const LAP_S = 6.6;
const REST_S = 2.4;
const PX = 256;
const PW = 216;
let dragging = false;
let dragCell = -1;
let axX = 0;
let axY = 0;
let startX = 0;
let startY = 0;
let ptrX = 0;
let ptrY = 0;
let rawK = 0;
let lastRaw = -1;
let results = [];
let au = { on: false, cell: -1, raw: 0, wob: 0, t0: 0 };
const STAT_NONE = '空きマスの隣のタイルをドラッグして離す';
const STAT_DRAG_SNAP = 'ドラッグ中、移動量 {a}: 離すと空きマスに吸いつく';
const STAT_DRAG_BACK = 'ドラッグ中、移動量 {a}: 離すと元の位置に戻る';
const STAT_IDLE_SNAP = '最後に離した量 {a}: 空きマスに吸いついた';
const STAT_IDLE_BACK = '最後に離した量 {a}: 元の位置に戻った';
const SPK_SNAP = 'タイルが一マス幅の {a} だけ引かれて空きマスに吸いついた';
const SPK_BACK = 'タイルが一マス幅の {a} しか引かれず元の位置に戻った';
const V_SNAP = '吸着';
const V_BACK = '戻る';
function clampK() { return clamp(rawK, 0, 1); }
function dragBegin(i, x, y) {
const d = dirOfCell(i);
if (d < 0 || dragging) {
return;
}
const z = bd.indexOf(0);
dragging = true;
dragCell = i;
dragV = bd[i];
axX = (z % BN) - (i % BN);
axY = Math.floor(z / BN) - Math.floor(i / BN);
startX = x;
startY = y;
ptrX = x;
ptrY = y;
rawK = 0;
dragCx = i % BN;
dragCy = Math.floor(i / BN);
}
// 포인터가 어디에 있든 타일은 빈칸 방향 축으로 투영한 양만큼, 0 ~ 1 칸 안에서만 따라온다
function dragMove(x, y) {
if (!dragging) {
return;
}
ptrX = x;
ptrY = y;
rawK = ((x - startX) * axX + (y - startY) * axY) / CS;
const k = clampK();
dragCx = (dragCell % BN) + axX * k;
dragCy = Math.floor(dragCell / BN) + axY * k;
}
function dragEnd() {
if (!dragging) {
return;
}
dragging = false;
const v = dragV;
const k = clampK();
const fx = dragCx;
const fy = dragCy;
const ox = dragCell % BN;
const oy = Math.floor(dragCell / BN);
dragV = -1;
const t = tl[v];
// 놓는 순간 타일이 있던 자리(끌린 자리)를 출발점으로 삼는다
t.tx = fx;
t.ty = fy;
t.du = 0;
const snap = k >= SNAP_RATIO;
if (snap) {
blankStep(bd, BN, dirOfCell(dragCell));
retarget(function () { return 0; }, SETTLE_S);
}
else {
t.fx = fx;
t.fy = fy;
t.tx = ox;
t.ty = oy;
t.t0 = sceneT;
t.du = SETTLE_S;
}
lastRaw = rawK;
results.push(snap);
if (results.length > 8) {
results.shift();
}
}
function finishAuto() {
if (!au.on) {
return;
}
const c = tileCenter(au.cell);
dragMove(c[0] + axX * au.raw * CS, c[1] + axY * au.raw * CS);
dragEnd();
au.on = false;
}
function tileCenter(i) {
return [BX + ((i % BN) + 0.5) * CS, BY + (Math.floor(i / BN) + 0.5) * CS];
}
// 시연 — 빈칸의 이웃 중 하나를 해시로 골라 RAWS[k] 칸만큼 끌었다 놓는다. 옆으로도 흔들지만 축 밖 움직임은 잘려 나간다
function autoStart(k) {
finishAuto();
const z = bd.indexOf(0);
const nbs = [];
for (let d = 0; d < 4; d += 1) {
if (canStep(bd, BN, d)) {
nbs.push(z + DX[d] + DY[d] * BN);
}
}
const cell = nbs[Math.floor(hash2(k, 31 + lap) * nbs.length)];
const c = tileCenter(cell);
dragBegin(cell, c[0], c[1]);
au = { on: true, cell: cell, raw: RAWS[k % RAWS.length], wob: 0.35 * (hash2(k, 5) < 0.5 ? -1 : 1), t0: sceneT };
}
function autoAt(k) {
return function () { autoStart(k); };
}
function build() {
dragging = false;
dragV = -1;
au = { on: false, cell: -1, raw: 0, wob: 0, t0: 0 };
results = [];
lastRaw = -1;
rawK = 0;
const b = solvedBoard(TILE_N);
applyMoves(b, TILE_N, walkMoves(TILE_N, SHUFFLE_07, 13 + lap));
setBoard(b, TILE_N);
for (let k = 0; k < 8; k += 1) {
script.push({ t: 0.3 + k * CYCLE_S, f: autoAt(k) });
}
}
function update(dt) {
if (dt < 0 || !au.on) {
return;
}
const u = (sceneT - au.t0) / DRAG_S;
if (u >= 1 + HOLD_S / DRAG_S) {
finishAuto();
return;
}
const e = u < 1 ? (u < 0 ? 0 : u * u * (3 - 2 * u)) : 1;
const c = tileCenter(au.cell);
const px = -axY;
const py = axX;
const lat = au.wob * CS * Math.sin(clamp(u, 0, 1) * Math.PI * 1.5);
dragMove(c[0] + axX * au.raw * e * CS + px * lat, c[1] + axY * au.raw * e * CS + py * lat);
}
function draw() {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoardBase();
drawTiles(function (v) { return tileHue(v, BN); });
if (dragging) {
const p = tilePos(dragV);
const x = BX + p[0] * CS;
const y = BY + p[1] * CS;
ctx.beginPath();
ctx.roundRect(x + 1, y + 1, CS - 2, CS - 2, 9);
ctx.strokeStyle = rgba(PAPER, 1);
ctx.lineWidth = 3;
ctx.stroke();
// 포인터와 타일 중심을 잇는 점선 — 축 밖으로 벗어난 만큼이 잘려 나간 몫
const cx = x + CS / 2;
const cy = y + CS / 2;
const n = 8;
for (let i = 0; i < n; i += 2) {
seg(lerp(cx, ptrX, i / n), lerp(cy, ptrY, i / n), lerp(cx, ptrX, (i + 1) / n), lerp(cy, ptrY, (i + 1) / n), PAPER, 0.8, 2);
}
ctx.beginPath();
ctx.arc(ptrX, ptrY, 9, 0, TAU);
ctx.strokeStyle = rgba(ROSE, 1);
ctx.lineWidth = 3;
ctx.stroke();
disc(ptrX, ptrY, 3, ROSE, 1);
}
const s = CS * BN;
panel(PX, BY - 5, PW, s + 10);
const shownRaw = dragging ? rawK : (lastRaw >= 0 ? lastRaw : 0);
const shownK = clamp(shownRaw, 0, 1);
text('SNAP_RATIO', PX + 12, BY + 12, DIM, 11, 700, 'left');
text(String(SNAP_RATIO), PX + PW - 12, BY + 12, NEU, 15, 800, 'right');
text('raw', PX + 12, BY + 36, DIM, 11, 700, 'left');
text(String(Math.round(shownRaw * 100) / 100), PX + PW - 12, BY + 36, NEU, 15, 800, 'right');
text('clamp', PX + 12, BY + 58, DIM, 11, 700, 'left');
text(String(Math.round(shownK * 100) / 100), PX + PW - 12, BY + 58, NEU, 15, 800, 'right');
// 눈금 — 0 ~ RAW_MAX 칸. 1 칸 선에서 잘리고 SNAP_RATIO 위치에 붙는 기준 표시
const rx = PX + 14;
const rw = PW - 28;
const ry = BY + 76;
box(rx, ry, rw, 12, 6, [48, 44, 74], 1);
box(rx, ry, rw * shownK / RAW_MAX, 12, 6, shownK >= SNAP_RATIO ? MINT : SKY, 1);
seg(rx + rw * SNAP_RATIO / RAW_MAX, ry - 4, rx + rw * SNAP_RATIO / RAW_MAX, ry + 16, PAPER, 1, 2);
seg(rx + rw / RAW_MAX, ry - 4, rx + rw / RAW_MAX, ry + 16, ROSE, 1, 2);
text('0.5', rx + rw * SNAP_RATIO / RAW_MAX, ry + 24, DIM, 10, 700, 'center');
text('1', rx + rw / RAW_MAX, ry + 24, DIM, 10, 700, 'center');
if (dragging || lastRaw >= 0) {
disc(rx + rw * clamp(shownRaw, 0, RAW_MAX) / RAW_MAX, ry + 6, 5, ROSE, 1);
}
const okNow = shownK >= SNAP_RATIO;
box(PX + 12, BY + 118, PW - 24, 24, 12, lastRaw < 0 && !dragging ? [88, 82, 122] : (okNow ? MINT : SKY), 1);
text(lastRaw < 0 && !dragging ? '-' : (dragging ? '→ ' : '') + (okNow ? V_SNAP : V_BACK), PX + PW / 2, BY + 130, lastRaw < 0 && !dragging ? NEU : INK, 12, 800, 'center');
// 최근 놓은 결과 여덟 개 — 채운 점은 吸着, 빈 고리는 戻る
for (let i = 0; i < 8; i += 1) {
const x = PX + 22 + i * 24;
if (i < results.length) {
if (results[i]) {
disc(x, BY + 168, 8, MINT, 1);
}
else {
ctx.beginPath();
ctx.arc(x, BY + 168, 7, 0, TAU);
ctx.strokeStyle = rgba(SKY, 1);
ctx.lineWidth = 3;
ctx.stroke();
}
}
else {
disc(x, BY + 168, 4, [88, 82, 122], 1);
}
}
hud('SNAP_RATIO', String(SNAP_RATIO), SNAP_RATIO);
}
function statusText() {
if (dragging) {
return fill(clampK() >= SNAP_RATIO ? STAT_DRAG_SNAP : STAT_DRAG_BACK, r1(clampK() * 100) + '%');
}
if (lastRaw < 0) {
return STAT_NONE;
}
const k = clamp(lastRaw, 0, 1);
return fill(k >= SNAP_RATIO ? STAT_IDLE_SNAP : STAT_IDLE_BACK, r1(k * 100) + '%');
}
function modeId() { return modeBase(0); }
function speakText() {
if (lastRaw < 0) {
return speakBoard();
}
const k = clamp(lastRaw, 0, 1);
return fill(k >= SNAP_RATIO ? SPK_SNAP : SPK_BACK, r1(k * 100) + '%') + '. ' + speakBoard();
}
function onDir(d) { stepBlank(d, SETTLE_S); }
function onPtr(kind, x, y) {
if (kind === 0) {
if (au.on) {
finishAuto();
}
dragBegin(cellAt(x, y), x, y);
}
else if (kind === 1) {
dragMove(x, y);
}
else {
dragEnd();
}
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// DragClampSnap - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./DragClampSnap.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function DragClampSnap() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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 easeOut(k: number): number { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const SKY: RGB = [150, 192, 244];
const SUN: RGB = [250, 218, 146];
const NEU: RGB = [236, 230, 250];
const BGC: RGB = [46, 42, 68];
const PANEL: RGB = [62, 57, 92];
const DIM: RGB = [196, 190, 224];
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function hsl(h: number, s: number, l: number): RGB {
const a = s * Math.min(l, 1 - l);
const f = function (n: number): number {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a: number, b: number): number {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s: string, a: string, b?: string, c?: string): string {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v: number): string { return String(Math.round(v * 10) / 10); }
function group(v: number): string {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { o += ','; }
o += s.charAt(i);
}
return o;
}
// ── 퍼즐 모델 — 판은 번호 배열, 0 은 빈칸, 타일 v 의 제자리는 v-1 번 칸, 빈칸의 제자리는 맨 끝
// 빈칸이 움직이는 방향 d: 0 오른쪽 · 1 왼쪽 · 2 아래 · 3 위. 반대 방향은 d ^ 1
const DX = [1, -1, 0, 0];
const DY = [0, 0, 1, -1];
function solvedBoard(n: number): number[] {
const b: number[] = [];
for (let i = 1; i < n * n; i += 1) { b.push(i); }
b.push(0);
return b;
}
function canStep(b: number[], n: number, d: number): boolean {
const z = b.indexOf(0);
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
return c >= 0 && c < n && r >= 0 && r < n;
}
function blankStep(b: number[], n: number, d: number): boolean {
if (!canStep(b, n, d)) { return false; }
const z = b.indexOf(0);
const t = z + DX[d] + DY[d] * n;
b[z] = b[t];
b[t] = 0;
return true;
}
// 역전 — 번호를 한 줄로 늘어놓고 앞 번호가 뒤 번호보다 큰 쌍의 수(빈칸은 세지 않는다)
function inversions(b: number[]): number {
let c = 0;
for (let i = 0; i < b.length; i += 1) {
for (let j = i + 1; j < b.length; j += 1) {
if (b[i] !== 0 && b[j] !== 0 && b[i] > b[j]) { c += 1; }
}
}
return c;
}
// 빈칸이 아래서 몇 번째 줄인지(맨 아래 줄 = 1)
function blankRow(b: number[], n: number): number { return n - Math.floor(b.indexOf(0) / n); }
// 판정 합 — 짝수 폭은 역전 + 빈칸 줄, 홀수 폭은 역전만
function parity(b: number[], n: number): number { return n % 2 === 0 ? inversions(b) + blankRow(b, n) : inversions(b); }
function solvable(b: number[], n: number): boolean {
return n % 2 === 0 ? parity(b, n) % 2 === 1 : parity(b, n) % 2 === 0;
}
function manhattan(b: number[], n: number, i: number): number {
const v = b[i];
if (v === 0) { return 0; }
const h = v - 1;
return Math.abs((i % n) - (h % n)) + Math.abs(Math.floor(i / n) - Math.floor(h / n));
}
function sumH(b: number[], n: number): number {
let s = 0;
for (let i = 0; i < b.length; i += 1) { s += manhattan(b, n, i); }
return s;
}
function placedCount(b: number[]): number {
let c = 0;
for (let i = 0; i < b.length - 1; i += 1) { if (b[i] === i + 1) { c += 1; } }
return c;
}
function isSolved(b: number[]): boolean { return placedCount(b) === b.length - 1 && b[b.length - 1] === 0; }
// 걸어서 섞기 — 완성 판에서 시작해 빈칸을 steps 번 이웃 칸으로 옮긴다. 방금 온 칸으로 되돌아가는 수는 건너뛰고
// 어느 이웃으로 갈지는 정수 해시로 고른다. 반환은 빈칸이 움직인 방향의 목록
function walkMoves(n: number, steps: number, seed: number): number[] {
const b = solvedBoard(n);
const out: number[] = [];
let noBacktrack = -1;
for (let s = 0; s < steps; s += 1) {
const opts: number[] = [];
for (let d = 0; d < 4; d += 1) { if (d !== noBacktrack && canStep(b, n, d)) { opts.push(d); } }
const d = opts[Math.floor(hash2(s, seed) * opts.length)];
blankStep(b, n, d);
out.push(d);
noBacktrack = d ^ 1;
}
return out;
}
function applyMoves(b: number[], n: number, ms: number[]): void {
for (let i = 0; i < ms.length; i += 1) { blankStep(b, n, ms[i]); }
}
function reverseMoves(ms: number[]): number[] {
const o: number[] = [];
for (let i = ms.length - 1; i >= 0; i -= 1) { o.push(ms[i] ^ 1); }
return o;
}
// 줄째 밀기 — 누른 칸 i 가 빈칸과 같은 줄이면 사이 타일이 모두 한 칸씩 빈칸 쪽으로. 배열 교환으로 먼저 정하고
// 옮겨진 타일 번호를 빈칸에 가까운 순서로 돌려준다
function rowChain(b: number[], n: number, i: number): number[] {
const z = b.indexOf(0);
if (i === z) { return []; }
let step = 0;
if (Math.floor(i / n) === Math.floor(z / n)) { step = i > z ? 1 : -1; } else if (i % n === z % n) { step = i > z ? n : -n; } else { return []; }
const moved: number[] = [];
for (let p = z; p !== i; p += step) {
b[p] = b[p + step];
moved.push(b[p]);
}
b[i] = 0;
return moved;
}
// 반복 깊이 탐색 — 맨해튼 거리 합 h 를 길잡이로 깊이 우선 탐색하고, 수와 h 의 합이 한도(bound)를 넘으면 가지를 접는다.
// 한 바퀴가 끝나면 한도를 넘긴 값 중 가장 작은 것으로 올려 다시 돈다. 노드 수가 cap 에 닿으면 멈춘다
type Ida = { path: number[]; nodes: number; bounds: number[]; perBound: number[]; capHit: boolean };
function idaStar(start: number[], n: number, cap: number): Ida {
const b = start.slice();
const path: number[] = [];
let nodes = 0;
let capHit = false;
const INF = 9999;
let z = b.indexOf(0);
function dfs(g: number, h: number, bound: number, last: number): number {
nodes += 1;
if (nodes > cap) { capHit = true; return INF; }
const f = g + h;
if (f > bound) { return f; }
if (h === 0) { return -1; }
let min = INF;
for (let d = 0; d < 4; d += 1) {
if (last >= 0 && d === (last ^ 1)) { continue; }
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
if (c < 0 || c >= n || r < 0 || r >= n) { continue; }
const t = z + DX[d] + DY[d] * n;
const v = b[t];
const hv = Math.abs((t % n) - ((v - 1) % n)) + Math.abs(Math.floor(t / n) - Math.floor((v - 1) / n));
const hn = Math.abs((z % n) - ((v - 1) % n)) + Math.abs(Math.floor(z / n) - Math.floor((v - 1) / n));
b[z] = v;
b[t] = 0;
const oz = z;
z = t;
path.push(d);
const res = dfs(g + 1, h - hv + hn, bound, d);
if (res === -1) { return -1; }
path.pop();
z = oz;
b[t] = v;
b[z] = 0;
if (capHit) { return INF; }
if (res < min) { min = res; }
}
return min;
}
let bound = sumH(b, n);
const bounds: number[] = [];
const perBound: number[] = [];
while (bound < INF) {
const before = nodes;
bounds.push(bound);
const res = dfs(0, sumH(b, n), bound, -1);
perBound.push(nodes - before);
if (res === -1) { return { path: path.slice(), nodes: nodes, bounds: bounds, perBound: perBound, capHit: false }; }
if (capHit || res >= INF) { break; }
bound = res;
}
return { path: [], nodes: nodes, bounds: bounds, perBound: perBound, capHit: capHit };
}
// ── 판 화면 — 타일마다 지금 칸 → 목표 칸 보간. 배열은 먼저 바뀌고 화면이 뒤따른다
const BX = 24;
const BY = 44;
const BSZ = 216;
let bd: number[] = [];
let BN = 4;
let CS = 54;
type Tl = { fx: number; fy: number; tx: number; ty: number; t0: number; du: number };
let tl: Tl[] = [];
let dragV = -1;
let dragCx = 0;
let dragCy = 0;
function setBoard(b: number[], n: number): void {
bd = b.slice();
BN = n;
CS = BSZ / n;
tl = [];
dragV = -1;
for (let v = 0; v < n * n; v += 1) {
const i = bd.indexOf(v);
const c = i % n;
const r = Math.floor(i / n);
tl.push({ fx: c, fy: r, tx: c, ty: r, t0: 0, du: 0 });
}
}
let still = false;
function tilePos(v: number): number[] {
const t = tl[v];
if (v === dragV) { return [dragCx, dragCy]; }
if (still || t.du <= 0) { return [t.tx, t.ty]; }
const e = easeOut((sceneT - t.t0) / t.du);
return [lerp(t.fx, t.tx, e), lerp(t.fy, t.ty, e)];
}
// 배열이 바뀐 뒤 타일마다 목표 칸을 다시 읽는다 — 옮겨진 타일만 지금 화면 자리에서 출발한다
function retarget(delayOf: (v: number) => number, du: number): void {
for (let v = 1; v < BN * BN; v += 1) {
const i = bd.indexOf(v);
const c = i % BN;
const r = Math.floor(i / BN);
const t = tl[v];
if (c !== t.tx || r !== t.ty) {
const p = tilePos(v);
t.fx = p[0];
t.fy = p[1];
t.tx = c;
t.ty = r;
t.t0 = sceneT + delayOf(v);
t.du = du;
}
}
}
function stepBlank(d: number, du: number): boolean {
if (!blankStep(bd, BN, d)) { return false; }
retarget(function (): number { return 0; }, du);
return true;
}
function cellAt(x: number, y: number): number {
const c = Math.floor((x - BX) / CS);
const r = Math.floor((y - BY) / CS);
if (c < 0 || c >= BN || r < 0 || r >= BN) { return -1; }
return r * BN + c;
}
// ── 그리기 도구
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, c: RGB, a: number, w: number): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x: number, y: number, w: number, h: number): void { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name: string, val: string, frac: number): void {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// 판 바탕 — 받침과 빈 칸 홈
function drawBoardBase(): void {
const s = CS * BN;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < BN * BN; i += 1) {
box(BX + (i % BN) * CS + 2, BY + Math.floor(i / BN) * CS + 2, CS - 4, CS - 4, 8, [40, 36, 62], 1);
}
}
function tileHue(v: number, n: number): RGB {
return hsl(190 + 160 * ((v - 1) / (n * n - 2)), 0.55, 0.8);
}
function paintTile(label: string, cx: number, cy: number, c: RGB, cs: number, ox: number, oy: number): void {
const s = cs - 4;
const x = ox + cx * cs + 2;
const y = oy + cy * cs + 2;
box(x, y + 2, s, s, 8, [26, 23, 42], 0.5);
box(x, y, s, s, 8, c, 1);
box(x + 3, y + 2, s - 6, 3, 1.5, [255, 255, 255], 0.4);
text(label, x + s / 2, y + s / 2 + 1, INK, Math.max(9, Math.round(cs * 0.38)), 800, 'center');
}
function drawTiles(fillOf: (v: number) => RGB): void {
for (let v = 1; v < BN * BN; v += 1) {
const p = tilePos(v);
paintTile(String(v), p[0], p[1], fillOf(v), CS, BX, BY);
}
}
// 글자판 없이 배열만 그리는 작은 판 — 비교 장면용
function drawMini(b: number[], n: number, ox: number, oy: number, size: number): void {
const cs = size / n;
box(ox - 3, oy - 3, size + 6, size + 6, 8, PANEL, 1);
for (let i = 0; i < n * n; i += 1) {
const v = b[i];
if (v === 0) { box(ox + (i % n) * cs + 1, oy + Math.floor(i / n) * cs + 1, cs - 2, cs - 2, 3, [40, 36, 62], 1); continue; }
const x = ox + (i % n) * cs + 1;
const y = oy + Math.floor(i / n) * cs + 1;
box(x, y, cs - 2, cs - 2, 3, tileHue(v, n), 1);
if (cs >= 20) { text(String(v), x + (cs - 2) / 2, y + (cs - 2) / 2 + 1, INK, Math.round(cs * 0.4), 800, 'center'); }
}
}
// ── 상태 줄·낭독 문장
const SPK_PLACE = '{a}×{a} 판, 타일 {b}장 중 {c}장이 제자리에 있다';
const SPK_DONE = '{a}×{a} 판 완성, 타일 {b}장이 모두 제자리에 있다';
function speakBoard(): string {
const t = BN * BN - 1;
return isSolved(bd) ? fill(SPK_DONE, String(BN), String(t)) : fill(SPK_PLACE, String(BN), String(t), String(placedCount(bd)));
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
}
function takeOver(): void {
if (manual) { return; }
manual = true;
wake();
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 맞은 타일 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode: number): number {
if (isSolved(bd)) { return 1000; }
return manual ? 100 + placedCount(bd) : sceneMode;
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say(): void {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
function advance(dt: number): void {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
}
update(dt);
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow(): void {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) { say(); }
}
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest(): void {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak: boolean): void {
if (paused || still) { sceneT += 1; }
update(0);
showNow();
if (speak) { say(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
say();
}
function restart(): void {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// N 한 걸음 — 시간표의 다음 일 하나를 지금 치른다. 시간표가 끝났으면 처음부터 다시 짠 뒤 첫 일
function stepOnce(): void {
takeOver();
if (evAt >= script.length) { lap += 1; begin(); }
if (evAt < script.length) {
const ev = script[evAt];
evAt += 1;
if (sceneT < ev.t) { sceneT = ev.t; }
ev.f();
}
afterInput(true);
}
function keyMove(dx: number, dy: number): void {
// 방향키는 타일이 가는 방향 — 그 타일은 빈칸의 반대쪽에 있다. 빈칸이 가는 방향으로 바꿔 부른다
let d = 0;
for (let k = 0; k < 4; k += 1) { if (DX[k] === -dx && DY[k] === -dy) { d = k; } }
takeOver();
onDir(d);
afterInput(true);
}
function toCanvas(e: PointerEvent): number[] {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 빈칸 이웃 타일 이동, Enter·Space = 일시정지, N = 한 걸음, R = 처음부터
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'r' || k === 'R') {
restart();
} else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
} else if (k === 'ArrowLeft') {
e.preventDefault();
keyMove(-1, 0);
} else if (k === 'ArrowRight') {
e.preventDefault();
keyMove(1, 0);
} else if (k === 'ArrowUp') {
e.preventDefault();
keyMove(0, -1);
} else if (k === 'ArrowDown') {
e.preventDefault();
keyMove(0, 1);
} else if (keyHook !== null && keyHook(k)) {
e.preventDefault();
takeOver();
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
// 장면이 방향키·Enter·N·R 밖의 키를 받고 싶을 때 거는 갈고리 — 먹었으면 true
let keyHook: ((k: string) => boolean) | null = null;
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
ptrDown = true;
try { pad.setPointerCapture(e.pointerId); } catch (err) { ptrDown = true; }
const p = toCanvas(e);
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (!ptrDown) { return; }
const p = toCanvas(e);
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e: PointerEvent): void {
if (!ptrDown) { return; }
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 판 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// 누른 칸이 빈칸의 이웃이면 그 타일이 들어갈 빈칸의 방향(빈칸 기준), 아니면 -1
function dirOfCell(i: number): number {
const z = bd.indexOf(0);
for (let d = 0; d < 4; d += 1) { if (canStep(bd, BN, d) && z + DX[d] + DY[d] * BN === i) { return d; } }
return -1;
}
// 07 — 타일을 누르고 끌면 빈칸 방향의 한 축으로만 따라오고 한 칸 폭을 넘지 못하게 자른다(clamp). 놓을 때 이동량이 SNAP_RATIO 이상이면 빈칸으로 들어가고 못 미치면 제자리로 돌아온다. 포인터 이벤트 하나로 마우스와 터치가 같이 된다
const TILE_N = 4;
const SNAP_RATIO = 0.5;
const RAW_MAX = 1.6;
const SETTLE_S = 0.14;
const DRAG_S = 0.36;
const HOLD_S = 0.12;
const CYCLE_S = 0.78;
const RAWS = [0.85, 0.3, 1.45, 0.62, 0.42, 1.2, 0.7, 0.25];
const SHUFFLE_07 = 24;
const LAP_S = 6.6;
const REST_S = 2.4;
const PX = 256;
const PW = 216;
let dragging = false;
let dragCell = -1;
let axX = 0;
let axY = 0;
let startX = 0;
let startY = 0;
let ptrX = 0;
let ptrY = 0;
let rawK = 0;
let lastRaw = -1;
let results: boolean[] = [];
type Au = { on: boolean; cell: number; raw: number; wob: number; t0: number };
let au: Au = { on: false, cell: -1, raw: 0, wob: 0, t0: 0 };
const STAT_NONE = '빈칸 옆 타일을 끌어서 놓는다';
const STAT_DRAG_SNAP = '끄는 중, 끈 양 {a} — 놓으면 빈칸에 붙음';
const STAT_DRAG_BACK = '끄는 중, 끈 양 {a} — 놓으면 제자리로 돌아감';
const STAT_IDLE_SNAP = '마지막에 놓은 양 {a} — 빈칸에 붙었음';
const STAT_IDLE_BACK = '마지막에 놓은 양 {a} — 제자리로 돌아왔음';
const SPK_SNAP = '타일이 한 칸 폭의 {a} 만큼 끌려 가 빈칸에 붙었다';
const SPK_BACK = '타일이 한 칸 폭의 {a} 만 끌려 제자리로 돌아왔다';
const V_SNAP = '붙음';
const V_BACK = '되돌아감';
function clampK(): number { return clamp(rawK, 0, 1); }
function dragBegin(i: number, x: number, y: number): void {
const d = dirOfCell(i);
if (d < 0 || dragging) { return; }
const z = bd.indexOf(0);
dragging = true;
dragCell = i;
dragV = bd[i];
axX = (z % BN) - (i % BN);
axY = Math.floor(z / BN) - Math.floor(i / BN);
startX = x;
startY = y;
ptrX = x;
ptrY = y;
rawK = 0;
dragCx = i % BN;
dragCy = Math.floor(i / BN);
}
// 포인터가 어디에 있든 타일은 빈칸 방향 축으로 투영한 양만큼, 0 ~ 1 칸 안에서만 따라온다
function dragMove(x: number, y: number): void {
if (!dragging) { return; }
ptrX = x;
ptrY = y;
rawK = ((x - startX) * axX + (y - startY) * axY) / CS;
const k = clampK();
dragCx = (dragCell % BN) + axX * k;
dragCy = Math.floor(dragCell / BN) + axY * k;
}
function dragEnd(): void {
if (!dragging) { return; }
dragging = false;
const v = dragV;
const k = clampK();
const fx = dragCx;
const fy = dragCy;
const ox = dragCell % BN;
const oy = Math.floor(dragCell / BN);
dragV = -1;
const t = tl[v];
// 놓는 순간 타일이 있던 자리(끌린 자리)를 출발점으로 삼는다
t.tx = fx;
t.ty = fy;
t.du = 0;
const snap = k >= SNAP_RATIO;
if (snap) {
blankStep(bd, BN, dirOfCell(dragCell));
retarget(function (): number { return 0; }, SETTLE_S);
} else {
t.fx = fx;
t.fy = fy;
t.tx = ox;
t.ty = oy;
t.t0 = sceneT;
t.du = SETTLE_S;
}
lastRaw = rawK;
results.push(snap);
if (results.length > 8) { results.shift(); }
}
function finishAuto(): void {
if (!au.on) { return; }
const c = tileCenter(au.cell);
dragMove(c[0] + axX * au.raw * CS, c[1] + axY * au.raw * CS);
dragEnd();
au.on = false;
}
function tileCenter(i: number): number[] {
return [BX + ((i % BN) + 0.5) * CS, BY + (Math.floor(i / BN) + 0.5) * CS];
}
// 시연 — 빈칸의 이웃 중 하나를 해시로 골라 RAWS[k] 칸만큼 끌었다 놓는다. 옆으로도 흔들지만 축 밖 움직임은 잘려 나간다
function autoStart(k: number): void {
finishAuto();
const z = bd.indexOf(0);
const nbs: number[] = [];
for (let d = 0; d < 4; d += 1) { if (canStep(bd, BN, d)) { nbs.push(z + DX[d] + DY[d] * BN); } }
const cell = nbs[Math.floor(hash2(k, 31 + lap) * nbs.length)];
const c = tileCenter(cell);
dragBegin(cell, c[0], c[1]);
au = { on: true, cell: cell, raw: RAWS[k % RAWS.length], wob: 0.35 * (hash2(k, 5) < 0.5 ? -1 : 1), t0: sceneT };
}
function autoAt(k: number): () => void {
return function (): void { autoStart(k); };
}
function build(): void {
dragging = false;
dragV = -1;
au = { on: false, cell: -1, raw: 0, wob: 0, t0: 0 };
results = [];
lastRaw = -1;
rawK = 0;
const b = solvedBoard(TILE_N);
applyMoves(b, TILE_N, walkMoves(TILE_N, SHUFFLE_07, 13 + lap));
setBoard(b, TILE_N);
for (let k = 0; k < 8; k += 1) { script.push({ t: 0.3 + k * CYCLE_S, f: autoAt(k) }); }
}
function update(dt: number): void {
if (dt < 0 || !au.on) { return; }
const u = (sceneT - au.t0) / DRAG_S;
if (u >= 1 + HOLD_S / DRAG_S) {
finishAuto();
return;
}
const e = u < 1 ? (u < 0 ? 0 : u * u * (3 - 2 * u)) : 1;
const c = tileCenter(au.cell);
const px = -axY;
const py = axX;
const lat = au.wob * CS * Math.sin(clamp(u, 0, 1) * Math.PI * 1.5);
dragMove(c[0] + axX * au.raw * e * CS + px * lat, c[1] + axY * au.raw * e * CS + py * lat);
}
function draw(): void {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoardBase();
drawTiles(function (v: number): RGB { return tileHue(v, BN); });
if (dragging) {
const p = tilePos(dragV);
const x = BX + p[0] * CS;
const y = BY + p[1] * CS;
ctx.beginPath();
ctx.roundRect(x + 1, y + 1, CS - 2, CS - 2, 9);
ctx.strokeStyle = rgba(PAPER, 1);
ctx.lineWidth = 3;
ctx.stroke();
// 포인터와 타일 중심을 잇는 점선 — 축 밖으로 벗어난 만큼이 잘려 나간 몫
const cx = x + CS / 2;
const cy = y + CS / 2;
const n = 8;
for (let i = 0; i < n; i += 2) {
seg(lerp(cx, ptrX, i / n), lerp(cy, ptrY, i / n), lerp(cx, ptrX, (i + 1) / n), lerp(cy, ptrY, (i + 1) / n), PAPER, 0.8, 2);
}
ctx.beginPath();
ctx.arc(ptrX, ptrY, 9, 0, TAU);
ctx.strokeStyle = rgba(ROSE, 1);
ctx.lineWidth = 3;
ctx.stroke();
disc(ptrX, ptrY, 3, ROSE, 1);
}
const s = CS * BN;
panel(PX, BY - 5, PW, s + 10);
const shownRaw = dragging ? rawK : (lastRaw >= 0 ? lastRaw : 0);
const shownK = clamp(shownRaw, 0, 1);
text('SNAP_RATIO', PX + 12, BY + 12, DIM, 11, 700, 'left');
text(String(SNAP_RATIO), PX + PW - 12, BY + 12, NEU, 15, 800, 'right');
text('raw', PX + 12, BY + 36, DIM, 11, 700, 'left');
text(String(Math.round(shownRaw * 100) / 100), PX + PW - 12, BY + 36, NEU, 15, 800, 'right');
text('clamp', PX + 12, BY + 58, DIM, 11, 700, 'left');
text(String(Math.round(shownK * 100) / 100), PX + PW - 12, BY + 58, NEU, 15, 800, 'right');
// 눈금 — 0 ~ RAW_MAX 칸. 1 칸 선에서 잘리고 SNAP_RATIO 위치에 붙는 기준 표시
const rx = PX + 14;
const rw = PW - 28;
const ry = BY + 76;
box(rx, ry, rw, 12, 6, [48, 44, 74], 1);
box(rx, ry, rw * shownK / RAW_MAX, 12, 6, shownK >= SNAP_RATIO ? MINT : SKY, 1);
seg(rx + rw * SNAP_RATIO / RAW_MAX, ry - 4, rx + rw * SNAP_RATIO / RAW_MAX, ry + 16, PAPER, 1, 2);
seg(rx + rw / RAW_MAX, ry - 4, rx + rw / RAW_MAX, ry + 16, ROSE, 1, 2);
text('0.5', rx + rw * SNAP_RATIO / RAW_MAX, ry + 24, DIM, 10, 700, 'center');
text('1', rx + rw / RAW_MAX, ry + 24, DIM, 10, 700, 'center');
if (dragging || lastRaw >= 0) { disc(rx + rw * clamp(shownRaw, 0, RAW_MAX) / RAW_MAX, ry + 6, 5, ROSE, 1); }
const okNow = shownK >= SNAP_RATIO;
box(PX + 12, BY + 118, PW - 24, 24, 12, lastRaw < 0 && !dragging ? [88, 82, 122] : (okNow ? MINT : SKY), 1);
text(lastRaw < 0 && !dragging ? '-' : (dragging ? '→ ' : '') + (okNow ? V_SNAP : V_BACK), PX + PW / 2, BY + 130, lastRaw < 0 && !dragging ? NEU : INK, 12, 800, 'center');
// 최근 놓은 결과 여덟 개 — 채운 점은 붙음, 빈 고리는 되돌아감
for (let i = 0; i < 8; i += 1) {
const x = PX + 22 + i * 24;
if (i < results.length) {
if (results[i]) { disc(x, BY + 168, 8, MINT, 1); } else {
ctx.beginPath();
ctx.arc(x, BY + 168, 7, 0, TAU);
ctx.strokeStyle = rgba(SKY, 1);
ctx.lineWidth = 3;
ctx.stroke();
}
} else { disc(x, BY + 168, 4, [88, 82, 122], 1); }
}
hud('SNAP_RATIO', String(SNAP_RATIO), SNAP_RATIO);
}
function statusText(): string {
if (dragging) { return fill(clampK() >= SNAP_RATIO ? STAT_DRAG_SNAP : STAT_DRAG_BACK, r1(clampK() * 100) + '%'); }
if (lastRaw < 0) { return STAT_NONE; }
const k = clamp(lastRaw, 0, 1);
return fill(k >= SNAP_RATIO ? STAT_IDLE_SNAP : STAT_IDLE_BACK, r1(k * 100) + '%');
}
function modeId(): number { return modeBase(0); }
function speakText(): string {
if (lastRaw < 0) { return speakBoard(); }
const k = clamp(lastRaw, 0, 1);
return fill(k >= SNAP_RATIO ? SPK_SNAP : SPK_BACK, r1(k * 100) + '%') + '. ' + speakBoard();
}
function onDir(d: number): void { stepBlank(d, SETTLE_S); }
function onPtr(kind: number, x: number, y: number): void {
if (kind === 0) {
if (au.on) { finishAuto(); }
dragBegin(cellAt(x, y), x, y);
} else if (kind === 1) {
dragMove(x, y);
} else {
dragEnd();
}
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'슬라이딩 퍼즐 시연 무대 — 타일을 끌어서 놓기, 방향키로 빈칸 옆 타일 이동, Enter 또는 Space 로 일시정지, N 으로 한 걸음, R 로 처음부터'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'4×4 판에서 포인터 고리가 빈칸 옆 타일을 끈다. 타일은 빈칸 방향의 한 축으로만 따라오고 한 칸 폭에서 잘리며, 포인터와 타일을 잇는 점선이 보인다. 오른쪽 눈금은 끈 양을 0 부터 1 까지 보이고 SNAP_RATIO 0.5 위치에 표시가 있어, 놓을 때 표시를 넘으면 빈칸에 붙고 못 미치면 제자리로 돌아온다. 최근 놓은 결과는 점으로 쌓인다. 아래 눈금은 상수 이름과 지금 값을 보이고, 움직임 줄이기에서는 타일이 바로 자리를 바꾸는 판 한 장만 그려진다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'슬라이딩 퍼즐 장면'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}07 · ドラッグして離すとマスに吸いつくポインター — 計測 累積面積 12.728% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function dragMove(x, y) {
if (!dragging) {
return;
}
ptrX = x;
ptrY = y;
rawK = ((x - startX) * axX + (y - startY) * axY) / CS;
const k = clampK();
dragCx = (dragCell % BN) + axX * k;
dragCy = Math.floor(dragCell / BN) + axY * k;
}
ドラッグ幅を変えた八回の実測は吸着5・戻り3で、0.85・0.62・0.7マスは吸いつき、0.3・0.42・0.25マスは戻り、1.45・1.2マスは一マスで切られて1.00になってから吸いつきます。一回のドラッグは0.36秒の移動と0.12秒の保持で、入力は MDN の Pointer events の文書にある統合された入力の仕組みを使います。
08取り消しの記録と解法の再生
押した手をスタックに積み、取り消し一回で一手ずつ取り出します。解法を再生するときは、反対向きの手が隣り合っていたら二つを消して長さを縮め、REPLAY_MS の220ms間隔で一手ずつ見せ、縮めた手数と元の手数は数字で並べて書きます。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="スライドパズルの舞台 — 矢印キーで空きマスの隣のタイルを動かす、タイルを押しても動く、EnterかSpaceで一時停止、Nで一歩、Rで最初から">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="4×4の盤で押した手が右のパネルに方向矢印のチップとして積まれ、取り消しをするとチップが一つずつ抜けて盤が一手ずつ戻る。続いて反対向きの隣り合うチップの組が薄くなって消え、元の手数から縮めた手数が数字で出て、最初の盤に戻って縮めた手だけを220ミリ秒間隔で再生し同じ盤に着くかを確かめる。下の目盛りは定数名と今の値を示し、動きを減らす設定ではタイルがすぐ入れ替わる盤一枚だけを描きます"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">スライドパズルの場面</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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;
}
// 무대 상자 — 눌러서 한 번 깨뜨리는 곳(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause {
padding: $sp-1 $sp-3;
border: 2px solid $subject-cream;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 스크린리더에만 읽히는 낭독 문장 — 모드가 바뀔 때만 쓴다
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-ink;
color: $subject-cream;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면에만 있고 aria-live 는 늘 off
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 100px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 글리치 없는 원문 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
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;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause {
padding: 4px 12px;
border: 2px solid #fff7e6;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 100px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function easeOut(k) { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const SKY = [150, 192, 244];
const SUN = [250, 218, 146];
const NEU = [236, 230, 250];
const BGC = [46, 42, 68];
const PANEL = [62, 57, 92];
const DIM = [196, 190, 224];
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function hsl(h, s, l) {
const a = s * Math.min(l, 1 - l);
const f = function (n) {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a, b) {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s, a, b, c) {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v) { return String(Math.round(v * 10) / 10); }
function group(v) {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
o += ',';
}
o += s.charAt(i);
}
return o;
}
// ── 퍼즐 모델 — 판은 번호 배열, 0 은 빈칸, 타일 v 의 제자리는 v-1 번 칸, 빈칸의 제자리는 맨 끝
// 빈칸이 움직이는 방향 d: 0 오른쪽 · 1 왼쪽 · 2 아래 · 3 위. 반대 방향은 d ^ 1
const DX = [1, -1, 0, 0];
const DY = [0, 0, 1, -1];
function solvedBoard(n) {
const b = [];
for (let i = 1; i < n * n; i += 1) {
b.push(i);
}
b.push(0);
return b;
}
function canStep(b, n, d) {
const z = b.indexOf(0);
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
return c >= 0 && c < n && r >= 0 && r < n;
}
function blankStep(b, n, d) {
if (!canStep(b, n, d)) {
return false;
}
const z = b.indexOf(0);
const t = z + DX[d] + DY[d] * n;
b[z] = b[t];
b[t] = 0;
return true;
}
// 역전 — 번호를 한 줄로 늘어놓고 앞 번호가 뒤 번호보다 큰 쌍의 수(빈칸은 세지 않는다)
function inversions(b) {
let c = 0;
for (let i = 0; i < b.length; i += 1) {
for (let j = i + 1; j < b.length; j += 1) {
if (b[i] !== 0 && b[j] !== 0 && b[i] > b[j]) {
c += 1;
}
}
}
return c;
}
// 빈칸이 아래서 몇 번째 줄인지(맨 아래 줄 = 1)
function blankRow(b, n) { return n - Math.floor(b.indexOf(0) / n); }
// 판정 합 — 짝수 폭은 역전 + 빈칸 줄, 홀수 폭은 역전만
function parity(b, n) { return n % 2 === 0 ? inversions(b) + blankRow(b, n) : inversions(b); }
function solvable(b, n) {
return n % 2 === 0 ? parity(b, n) % 2 === 1 : parity(b, n) % 2 === 0;
}
function manhattan(b, n, i) {
const v = b[i];
if (v === 0) {
return 0;
}
const h = v - 1;
return Math.abs((i % n) - (h % n)) + Math.abs(Math.floor(i / n) - Math.floor(h / n));
}
function sumH(b, n) {
let s = 0;
for (let i = 0; i < b.length; i += 1) {
s += manhattan(b, n, i);
}
return s;
}
function placedCount(b) {
let c = 0;
for (let i = 0; i < b.length - 1; i += 1) {
if (b[i] === i + 1) {
c += 1;
}
}
return c;
}
function isSolved(b) { return placedCount(b) === b.length - 1 && b[b.length - 1] === 0; }
// 歩いて混ぜる — 完成 판에서 시작해 빈칸을 steps 번 이웃 칸으로 옮긴다. 방금 온 칸으로 되돌아가는 수는 건너뛰고
// 어느 이웃으로 갈지는 정수 해시로 고른다. 반환은 빈칸이 움직인 방향의 목록
function walkMoves(n, steps, seed) {
const b = solvedBoard(n);
const out = [];
let noBacktrack = -1;
for (let s = 0; s < steps; s += 1) {
const opts = [];
for (let d = 0; d < 4; d += 1) {
if (d !== noBacktrack && canStep(b, n, d)) {
opts.push(d);
}
}
const d = opts[Math.floor(hash2(s, seed) * opts.length)];
blankStep(b, n, d);
out.push(d);
noBacktrack = d ^ 1;
}
return out;
}
function applyMoves(b, n, ms) {
for (let i = 0; i < ms.length; i += 1) {
blankStep(b, n, ms[i]);
}
}
function reverseMoves(ms) {
const o = [];
for (let i = ms.length - 1; i >= 0; i -= 1) {
o.push(ms[i] ^ 1);
}
return o;
}
// 줄째 밀기 — 누른 칸 i 가 빈칸과 같은 줄이면 사이 타일이 모두 한 칸씩 빈칸 쪽으로. 배열 교환으로 먼저 정하고
// 옮겨진 타일 번호를 빈칸에 가까운 순서로 돌려준다
function rowChain(b, n, i) {
const z = b.indexOf(0);
if (i === z) {
return [];
}
let step = 0;
if (Math.floor(i / n) === Math.floor(z / n)) {
step = i > z ? 1 : -1;
}
else if (i % n === z % n) {
step = i > z ? n : -n;
}
else {
return [];
}
const moved = [];
for (let p = z; p !== i; p += step) {
b[p] = b[p + step];
moved.push(b[p]);
}
b[i] = 0;
return moved;
}
function idaStar(start, n, cap) {
const b = start.slice();
const path = [];
let nodes = 0;
let capHit = false;
const INF = 9999;
let z = b.indexOf(0);
function dfs(g, h, bound, last) {
nodes += 1;
if (nodes > cap) {
capHit = true;
return INF;
}
const f = g + h;
if (f > bound) {
return f;
}
if (h === 0) {
return -1;
}
let min = INF;
for (let d = 0; d < 4; d += 1) {
if (last >= 0 && d === (last ^ 1)) {
continue;
}
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
if (c < 0 || c >= n || r < 0 || r >= n) {
continue;
}
const t = z + DX[d] + DY[d] * n;
const v = b[t];
const hv = Math.abs((t % n) - ((v - 1) % n)) + Math.abs(Math.floor(t / n) - Math.floor((v - 1) / n));
const hn = Math.abs((z % n) - ((v - 1) % n)) + Math.abs(Math.floor(z / n) - Math.floor((v - 1) / n));
b[z] = v;
b[t] = 0;
const oz = z;
z = t;
path.push(d);
const res = dfs(g + 1, h - hv + hn, bound, d);
if (res === -1) {
return -1;
}
path.pop();
z = oz;
b[t] = v;
b[z] = 0;
if (capHit) {
return INF;
}
if (res < min) {
min = res;
}
}
return min;
}
let bound = sumH(b, n);
const bounds = [];
const perBound = [];
while (bound < INF) {
const before = nodes;
bounds.push(bound);
const res = dfs(0, sumH(b, n), bound, -1);
perBound.push(nodes - before);
if (res === -1) {
return { path: path.slice(), nodes: nodes, bounds: bounds, perBound: perBound, capHit: false };
}
if (capHit || res >= INF) {
break;
}
bound = res;
}
return { path: [], nodes: nodes, bounds: bounds, perBound: perBound, capHit: capHit };
}
// ── 판 화면 — 타일마다 지금 칸 → 목표 칸 보간. 배열은 먼저 바뀌고 화면이 뒤따른다
const BX = 24;
const BY = 44;
const BSZ = 216;
let bd = [];
let BN = 4;
let CS = 54;
let tl = [];
let dragV = -1;
let dragCx = 0;
let dragCy = 0;
function setBoard(b, n) {
bd = b.slice();
BN = n;
CS = BSZ / n;
tl = [];
dragV = -1;
for (let v = 0; v < n * n; v += 1) {
const i = bd.indexOf(v);
const c = i % n;
const r = Math.floor(i / n);
tl.push({ fx: c, fy: r, tx: c, ty: r, t0: 0, du: 0 });
}
}
let still = false;
function tilePos(v) {
const t = tl[v];
if (v === dragV) {
return [dragCx, dragCy];
}
if (still || t.du <= 0) {
return [t.tx, t.ty];
}
const e = easeOut((sceneT - t.t0) / t.du);
return [lerp(t.fx, t.tx, e), lerp(t.fy, t.ty, e)];
}
// 배열이 바뀐 뒤 타일마다 목표 칸을 다시 읽는다 — 옮겨진 타일만 지금 화면 자리에서 출발한다
function retarget(delayOf, du) {
for (let v = 1; v < BN * BN; v += 1) {
const i = bd.indexOf(v);
const c = i % BN;
const r = Math.floor(i / BN);
const t = tl[v];
if (c !== t.tx || r !== t.ty) {
const p = tilePos(v);
t.fx = p[0];
t.fy = p[1];
t.tx = c;
t.ty = r;
t.t0 = sceneT + delayOf(v);
t.du = du;
}
}
}
function stepBlank(d, du) {
if (!blankStep(bd, BN, d)) {
return false;
}
retarget(function () { return 0; }, du);
return true;
}
function cellAt(x, y) {
const c = Math.floor((x - BX) / CS);
const r = Math.floor((y - BY) / CS);
if (c < 0 || c >= BN || r < 0 || r >= BN) {
return -1;
}
return r * BN + c;
}
// ── 그리기 도구
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function disc(x, y, r, c, a) {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, c, a, w) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s, x, y, c, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x, y, w, h) { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name, val, frac) {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// 판 바탕 — 받침과 빈 칸 홈
function drawBoardBase() {
const s = CS * BN;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < BN * BN; i += 1) {
box(BX + (i % BN) * CS + 2, BY + Math.floor(i / BN) * CS + 2, CS - 4, CS - 4, 8, [40, 36, 62], 1);
}
}
function tileHue(v, n) {
return hsl(190 + 160 * ((v - 1) / (n * n - 2)), 0.55, 0.8);
}
function paintTile(label, cx, cy, c, cs, ox, oy) {
const s = cs - 4;
const x = ox + cx * cs + 2;
const y = oy + cy * cs + 2;
box(x, y + 2, s, s, 8, [26, 23, 42], 0.5);
box(x, y, s, s, 8, c, 1);
box(x + 3, y + 2, s - 6, 3, 1.5, [255, 255, 255], 0.4);
text(label, x + s / 2, y + s / 2 + 1, INK, Math.max(9, Math.round(cs * 0.38)), 800, 'center');
}
function drawTiles(fillOf) {
for (let v = 1; v < BN * BN; v += 1) {
const p = tilePos(v);
paintTile(String(v), p[0], p[1], fillOf(v), CS, BX, BY);
}
}
// 글자판 없이 배열만 그리는 작은 판 — 비교 장면용
function drawMini(b, n, ox, oy, size) {
const cs = size / n;
box(ox - 3, oy - 3, size + 6, size + 6, 8, PANEL, 1);
for (let i = 0; i < n * n; i += 1) {
const v = b[i];
if (v === 0) {
box(ox + (i % n) * cs + 1, oy + Math.floor(i / n) * cs + 1, cs - 2, cs - 2, 3, [40, 36, 62], 1);
continue;
}
const x = ox + (i % n) * cs + 1;
const y = oy + Math.floor(i / n) * cs + 1;
box(x, y, cs - 2, cs - 2, 3, tileHue(v, n), 1);
if (cs >= 20) {
text(String(v), x + (cs - 2) / 2, y + (cs - 2) / 2 + 1, INK, Math.round(cs * 0.4), 800, 'center');
}
}
}
// ── 상태 줄·낭독 문장
const SPK_PLACE = '{a}×{a} の盤、タイル {b} 枚のうち {c} 枚が元の位置にある';
const SPK_DONE = '{a}×{a} の盤が完成、タイル {b} 枚すべてが元の位置にある';
function speakBoard() {
const t = BN * BN - 1;
return isSolved(bd) ? fill(SPK_DONE, String(BN), String(t)) : fill(SPK_PLACE, String(BN), String(t), String(placedCount(bd)));
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
}
function takeOver() {
if (manual) {
return;
}
manual = true;
wake();
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 맞은 타일 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode) {
if (isSolved(bd)) {
return 1000;
}
return manual ? 100 + placedCount(bd) : sceneMode;
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say() {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
function advance(dt) {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
}
update(dt);
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow() {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) {
say();
}
}
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest() {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak) {
if (paused || still) {
sceneT += 1;
}
update(0);
showNow();
if (speak) {
say();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
say();
}
function restart() {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// N 한 걸음 — 시간표의 다음 일 하나를 지금 치른다. 시간표가 끝났으면 처음부터 다시 짠 뒤 첫 일
function stepOnce() {
takeOver();
if (evAt >= script.length) {
lap += 1;
begin();
}
if (evAt < script.length) {
const ev = script[evAt];
evAt += 1;
if (sceneT < ev.t) {
sceneT = ev.t;
}
ev.f();
}
afterInput(true);
}
function keyMove(dx, dy) {
// 방향키는 타일이 가는 방향 — 그 타일은 빈칸의 반대쪽에 있다. 빈칸이 가는 방향으로 바꿔 부른다
let d = 0;
for (let k = 0; k < 4; k += 1) {
if (DX[k] === -dx && DY[k] === -dy) {
d = k;
}
}
takeOver();
onDir(d);
afterInput(true);
}
function toCanvas(e) {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 빈칸 이웃 타일 이동, Enter·Space = 一時停止, N = 한 걸음, R = 처음부터
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'r' || k === 'R') {
restart();
}
else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
}
else if (k === 'ArrowLeft') {
e.preventDefault();
keyMove(-1, 0);
}
else if (k === 'ArrowRight') {
e.preventDefault();
keyMove(1, 0);
}
else if (k === 'ArrowUp') {
e.preventDefault();
keyMove(0, -1);
}
else if (k === 'ArrowDown') {
e.preventDefault();
keyMove(0, 1);
}
else if (keyHook !== null && keyHook(k)) {
e.preventDefault();
takeOver();
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
// 장면이 방향키·Enter·N·R 밖의 키를 받고 싶을 때 거는 갈고리 — 먹었으면 true
let keyHook = null;
pad.addEventListener('pointerdown', function (e) {
takeOver();
ptrDown = true;
try {
pad.setPointerCapture(e.pointerId);
}
catch (err) {
ptrDown = true;
}
const p = toCanvas(e);
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (!ptrDown) {
return;
}
const p = toCanvas(e);
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e) {
if (!ptrDown) {
return;
}
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 판 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// 누른 칸이 빈칸의 이웃이면 그 타일이 들어갈 빈칸의 방향(빈칸 기준), 아니면 -1
function dirOfCell(i) {
const z = bd.indexOf(0);
for (let d = 0; d < 4; d += 1) {
if (canStep(bd, BN, d) && z + DX[d] + DY[d] * BN === i) {
return d;
}
}
return -1;
}
// 08 — 누른 수를 스택에 쌓아 되돌리기(U·Z·Backspace) 한 번에 한 수씩 뺀다. 풀이를 재생할 때는 서로 반대 방향인 수가 이웃해 있으면 둘을 지워(cancelPair) 길이를 줄인 뒤 REPLAY_MS 간격으로 한 수씩 보인다. 줄인 수와 원래 수는 숫자로 같이 적힌다
const TILE_N = 4;
const REPLAY_MS = 220;
const PRESS_GAP = 0.13;
const SHUFFLE_08 = 24;
const PLAN_LEN = 14;
const SEEDS_08 = [18, 19, 20, 21];
const LAP_S = 7.6;
const REST_S = 3.6;
const PX = 256;
const PW = 216;
let origin = [];
let stack = [];
let reduced = [];
let marks = [];
let phase = 0;
let replayIdx = 0;
let targetB = [];
let same = false;
const STAT_STACK = '積まれた手 {a} 個';
const STAT_CUT = '元の {a} 手 → 縮めた {b} 手';
const STAT_PLAY = '縮めた手の再生 {a}/{b}';
const SPK_CUT = '反対向きの隣り合う手を消して {a} 手が {b} 手に縮んだ';
const SPK_SAME = '縮めた手で再生した盤は元の盤と同じ';
// 서로 반대 방향(d 와 d ^ 1)인 수가 이웃하면 둘을 지운다. 스택에 쌓으며 훑으면 겹쳐 지워지는 쌍까지 걸러진다
function cancelPair(ms) {
const out = [];
for (let i = 0; i < ms.length; i += 1) {
if (out.length > 0 && out[out.length - 1] === (ms[i] ^ 1)) {
out.pop();
}
else {
out.push(ms[i]);
}
}
return out;
}
// 어느 수가 지워졌는지 — 인덱스 스택으로 같은 훑기를 한 번 더
function cancelMarks(ms) {
const gone = [];
const idx = [];
for (let i = 0; i < ms.length; i += 1) {
gone.push(false);
if (idx.length > 0 && ms[idx[idx.length - 1]] === (ms[i] ^ 1)) {
gone[idx.pop()] = true;
gone[i] = true;
}
else {
idx.push(i);
}
}
return gone;
}
// 계획 — 평소엔 되돌아가지 않고 해시가 정한 확률로만 일부러 되돌아가는 수를 넣어 왕복이 생기게 한 14수(시작 판은 그대로 두고 복사본에서 합법인 수만 고른다)
function planMoves(seed) {
const b = origin.slice();
const out = [];
for (let k = 0; k < PLAN_LEN; k += 1) {
const last = out.length > 0 ? out[out.length - 1] : -1;
const opts = [];
for (let d = 0; d < 4; d += 1) {
if (d !== (last ^ 1) && canStep(b, TILE_N, d)) {
opts.push(d);
}
}
let d = opts[Math.floor(hash2(k, seed) * opts.length)];
if (last >= 0 && hash2(k, seed + 77) < 0.22 && canStep(b, TILE_N, last ^ 1)) {
d = last ^ 1;
}
blankStep(b, TILE_N, d);
out.push(d);
}
return out;
}
function press(d) {
if (stepBlank(d, 0.1)) {
stack.push(d);
}
}
function undoOne() {
if (stack.length === 0) {
return;
}
const d = stack.pop();
stepBlank(d ^ 1, 0.1);
}
function pressAt(d) { return function () { press(d); }; }
function showCut() {
phase = 2;
targetB = bd.slice();
reduced = cancelPair(stack);
marks = cancelMarks(stack);
}
function startReplay() {
phase = 3;
replayIdx = 0;
same = false;
setBoard(origin, TILE_N);
}
function playAt(k) {
return function () {
stepBlank(reduced[k], 0.15);
replayIdx = k + 1;
if (replayIdx === reduced.length) {
same = true;
for (let i = 0; i < bd.length; i += 1) {
if (bd[i] !== targetB[i]) {
same = false;
}
}
}
};
}
function build() {
phase = 0;
replayIdx = 0;
same = false;
stack = [];
reduced = [];
marks = [];
targetB = [];
origin = solvedBoard(TILE_N);
applyMoves(origin, TILE_N, walkMoves(TILE_N, SHUFFLE_08, 8 + lap));
setBoard(origin, TILE_N);
const plan = planMoves(SEEDS_08[lap % SEEDS_08.length]);
for (let k = 0; k < plan.length; k += 1) {
script.push({ t: 0.25 + k * PRESS_GAP, f: pressAt(plan[k]) });
}
for (let j = 0; j < 3; j += 1) {
script.push({ t: 2.25 + j * 0.28, f: function () { phase = 1; undoOne(); } });
}
script.push({ t: 3.3, f: showCut });
// 재생 길이는 줄인 수에 달려 있어 시간표는 지금 줄인 길이를 같은 방식으로 미리 잰다
const probe = plan.slice(0, plan.length - 3);
const rl = cancelPair(probe).length;
script.push({ t: 4.1, f: startReplay });
for (let k = 0; k < rl; k += 1) {
script.push({ t: 4.3 + k * REPLAY_MS / 1000, f: playAt(k) });
}
}
function update(dt) { if (dt < 0) {
return;
} }
function arrow(d, cx, cy, c, a) {
const dx = DX[d];
const dy = DY[d];
const px = -dy;
const py = dx;
ctx.beginPath();
ctx.moveTo(cx + dx * 4.5, cy + dy * 4.5);
ctx.lineTo(cx - dx * 3 + px * 3.5, cy - dy * 3 + py * 3.5);
ctx.lineTo(cx - dx * 3 - px * 3.5, cy - dy * 3 - py * 3.5);
ctx.closePath();
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function chipRow(ms, y, gone, hot) {
for (let i = 0; i < PLAN_LEN; i += 1) {
const x = PX + 12 + i * 13.5;
box(x, y, 12, 16, 4, [48, 44, 74], 1);
if (i < ms.length) {
const dead = gone !== null && gone[i];
const cur = i === hot;
const done = hot >= 0 && i < hot;
box(x, y, 12, 16, 4, dead ? [88, 82, 122] : (cur ? ROSE : (done ? MINT : SKY)), 1);
arrow(ms[i], x + 6, y + 8, dead ? [150, 144, 180] : INK, 1);
if (dead) {
seg(x + 2, y + 14, x + 10, y + 2, ROSE, 1, 1.5);
}
}
}
}
function draw() {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoardBase();
drawTiles(function (v) { return tileHue(v, BN); });
const s = CS * BN;
panel(PX, BY - 5, PW, s + 10);
text('stack', PX + 12, BY + 12, DIM, 11, 700, 'left');
text(String(stack.length), PX + PW - 12, BY + 12, NEU, 15, 800, 'right');
chipRow(stack, BY + 26, phase >= 2 ? marks : null, -1);
text('cancelPair', PX + 12, BY + 62, DIM, 11, 700, 'left');
if (phase >= 2) {
text(stack.length + ' → ' + reduced.length, PX + PW - 12, BY + 62, NEU, 15, 800, 'right');
chipRow(reduced, BY + 74, null, phase === 3 ? replayIdx : -1);
text('REPLAY_MS ' + REPLAY_MS, PX + 12, BY + 104, DIM, 11, 700, 'left');
}
// 두 판 비교 — 원래 수를 다 쌓은 판(왼쪽)과 줄인 수로 재생한 판(오른쪽)
if (phase >= 2) {
text('元の手順', PX + 12, BY + 126, DIM, 10, 700, 'left');
text('縮めた手順', PX + PW - 12, BY + 126, DIM, 10, 700, 'right');
drawMini(targetB, BN, PX + 22, BY + 140, 64);
drawMini(bd, BN, PX + PW - 86, BY + 140, 64);
const eq = phase === 3 && same;
text(eq ? '=' : '?', PX + PW / 2, BY + 172, eq ? MINT : DIM, 20, 800, 'center');
}
hud('REPLAY_MS', String(REPLAY_MS), REPLAY_MS / 400);
}
function statusText() {
if (phase === 3) {
return fill(STAT_PLAY, String(replayIdx), String(reduced.length));
}
if (phase === 2) {
return fill(STAT_CUT, String(stack.length), String(reduced.length));
}
return fill(STAT_STACK, String(stack.length));
}
function modeId() { return modeBase(phase); }
function speakText() {
if (phase >= 2) {
return fill(SPK_CUT, String(stack.length), String(reduced.length)) + (same ? '. ' + SPK_SAME : '') + '. ' + speakBoard();
}
return fill(STAT_STACK, String(stack.length)) + '. ' + speakBoard();
}
function onDir(d) { press(d); }
function onPtr(kind, x, y) {
if (kind !== 0) {
return;
}
const d = dirOfCell(cellAt(x, y));
if (d >= 0) {
onDir(d);
}
}
keyHook = function (k) {
if (k === 'u' || k === 'U' || k === 'z' || k === 'Z' || k === 'Backspace') {
undoOne();
return true;
}
return false;
};
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// UndoReplayCancel - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./UndoReplayCancel.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function UndoReplayCancel() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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 easeOut(k: number): number { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const SKY: RGB = [150, 192, 244];
const SUN: RGB = [250, 218, 146];
const NEU: RGB = [236, 230, 250];
const BGC: RGB = [46, 42, 68];
const PANEL: RGB = [62, 57, 92];
const DIM: RGB = [196, 190, 224];
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function hsl(h: number, s: number, l: number): RGB {
const a = s * Math.min(l, 1 - l);
const f = function (n: number): number {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a: number, b: number): number {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s: string, a: string, b?: string, c?: string): string {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v: number): string { return String(Math.round(v * 10) / 10); }
function group(v: number): string {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { o += ','; }
o += s.charAt(i);
}
return o;
}
// ── 퍼즐 모델 — 판은 번호 배열, 0 은 빈칸, 타일 v 의 제자리는 v-1 번 칸, 빈칸의 제자리는 맨 끝
// 빈칸이 움직이는 방향 d: 0 오른쪽 · 1 왼쪽 · 2 아래 · 3 위. 반대 방향은 d ^ 1
const DX = [1, -1, 0, 0];
const DY = [0, 0, 1, -1];
function solvedBoard(n: number): number[] {
const b: number[] = [];
for (let i = 1; i < n * n; i += 1) { b.push(i); }
b.push(0);
return b;
}
function canStep(b: number[], n: number, d: number): boolean {
const z = b.indexOf(0);
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
return c >= 0 && c < n && r >= 0 && r < n;
}
function blankStep(b: number[], n: number, d: number): boolean {
if (!canStep(b, n, d)) { return false; }
const z = b.indexOf(0);
const t = z + DX[d] + DY[d] * n;
b[z] = b[t];
b[t] = 0;
return true;
}
// 역전 — 번호를 한 줄로 늘어놓고 앞 번호가 뒤 번호보다 큰 쌍의 수(빈칸은 세지 않는다)
function inversions(b: number[]): number {
let c = 0;
for (let i = 0; i < b.length; i += 1) {
for (let j = i + 1; j < b.length; j += 1) {
if (b[i] !== 0 && b[j] !== 0 && b[i] > b[j]) { c += 1; }
}
}
return c;
}
// 빈칸이 아래서 몇 번째 줄인지(맨 아래 줄 = 1)
function blankRow(b: number[], n: number): number { return n - Math.floor(b.indexOf(0) / n); }
// 판정 합 — 짝수 폭은 역전 + 빈칸 줄, 홀수 폭은 역전만
function parity(b: number[], n: number): number { return n % 2 === 0 ? inversions(b) + blankRow(b, n) : inversions(b); }
function solvable(b: number[], n: number): boolean {
return n % 2 === 0 ? parity(b, n) % 2 === 1 : parity(b, n) % 2 === 0;
}
function manhattan(b: number[], n: number, i: number): number {
const v = b[i];
if (v === 0) { return 0; }
const h = v - 1;
return Math.abs((i % n) - (h % n)) + Math.abs(Math.floor(i / n) - Math.floor(h / n));
}
function sumH(b: number[], n: number): number {
let s = 0;
for (let i = 0; i < b.length; i += 1) { s += manhattan(b, n, i); }
return s;
}
function placedCount(b: number[]): number {
let c = 0;
for (let i = 0; i < b.length - 1; i += 1) { if (b[i] === i + 1) { c += 1; } }
return c;
}
function isSolved(b: number[]): boolean { return placedCount(b) === b.length - 1 && b[b.length - 1] === 0; }
// 걸어서 섞기 — 완성 판에서 시작해 빈칸을 steps 번 이웃 칸으로 옮긴다. 방금 온 칸으로 되돌아가는 수는 건너뛰고
// 어느 이웃으로 갈지는 정수 해시로 고른다. 반환은 빈칸이 움직인 방향의 목록
function walkMoves(n: number, steps: number, seed: number): number[] {
const b = solvedBoard(n);
const out: number[] = [];
let noBacktrack = -1;
for (let s = 0; s < steps; s += 1) {
const opts: number[] = [];
for (let d = 0; d < 4; d += 1) { if (d !== noBacktrack && canStep(b, n, d)) { opts.push(d); } }
const d = opts[Math.floor(hash2(s, seed) * opts.length)];
blankStep(b, n, d);
out.push(d);
noBacktrack = d ^ 1;
}
return out;
}
function applyMoves(b: number[], n: number, ms: number[]): void {
for (let i = 0; i < ms.length; i += 1) { blankStep(b, n, ms[i]); }
}
function reverseMoves(ms: number[]): number[] {
const o: number[] = [];
for (let i = ms.length - 1; i >= 0; i -= 1) { o.push(ms[i] ^ 1); }
return o;
}
// 줄째 밀기 — 누른 칸 i 가 빈칸과 같은 줄이면 사이 타일이 모두 한 칸씩 빈칸 쪽으로. 배열 교환으로 먼저 정하고
// 옮겨진 타일 번호를 빈칸에 가까운 순서로 돌려준다
function rowChain(b: number[], n: number, i: number): number[] {
const z = b.indexOf(0);
if (i === z) { return []; }
let step = 0;
if (Math.floor(i / n) === Math.floor(z / n)) { step = i > z ? 1 : -1; } else if (i % n === z % n) { step = i > z ? n : -n; } else { return []; }
const moved: number[] = [];
for (let p = z; p !== i; p += step) {
b[p] = b[p + step];
moved.push(b[p]);
}
b[i] = 0;
return moved;
}
// 반복 깊이 탐색 — 맨해튼 거리 합 h 를 길잡이로 깊이 우선 탐색하고, 수와 h 의 합이 한도(bound)를 넘으면 가지를 접는다.
// 한 바퀴가 끝나면 한도를 넘긴 값 중 가장 작은 것으로 올려 다시 돈다. 노드 수가 cap 에 닿으면 멈춘다
type Ida = { path: number[]; nodes: number; bounds: number[]; perBound: number[]; capHit: boolean };
function idaStar(start: number[], n: number, cap: number): Ida {
const b = start.slice();
const path: number[] = [];
let nodes = 0;
let capHit = false;
const INF = 9999;
let z = b.indexOf(0);
function dfs(g: number, h: number, bound: number, last: number): number {
nodes += 1;
if (nodes > cap) { capHit = true; return INF; }
const f = g + h;
if (f > bound) { return f; }
if (h === 0) { return -1; }
let min = INF;
for (let d = 0; d < 4; d += 1) {
if (last >= 0 && d === (last ^ 1)) { continue; }
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
if (c < 0 || c >= n || r < 0 || r >= n) { continue; }
const t = z + DX[d] + DY[d] * n;
const v = b[t];
const hv = Math.abs((t % n) - ((v - 1) % n)) + Math.abs(Math.floor(t / n) - Math.floor((v - 1) / n));
const hn = Math.abs((z % n) - ((v - 1) % n)) + Math.abs(Math.floor(z / n) - Math.floor((v - 1) / n));
b[z] = v;
b[t] = 0;
const oz = z;
z = t;
path.push(d);
const res = dfs(g + 1, h - hv + hn, bound, d);
if (res === -1) { return -1; }
path.pop();
z = oz;
b[t] = v;
b[z] = 0;
if (capHit) { return INF; }
if (res < min) { min = res; }
}
return min;
}
let bound = sumH(b, n);
const bounds: number[] = [];
const perBound: number[] = [];
while (bound < INF) {
const before = nodes;
bounds.push(bound);
const res = dfs(0, sumH(b, n), bound, -1);
perBound.push(nodes - before);
if (res === -1) { return { path: path.slice(), nodes: nodes, bounds: bounds, perBound: perBound, capHit: false }; }
if (capHit || res >= INF) { break; }
bound = res;
}
return { path: [], nodes: nodes, bounds: bounds, perBound: perBound, capHit: capHit };
}
// ── 판 화면 — 타일마다 지금 칸 → 목표 칸 보간. 배열은 먼저 바뀌고 화면이 뒤따른다
const BX = 24;
const BY = 44;
const BSZ = 216;
let bd: number[] = [];
let BN = 4;
let CS = 54;
type Tl = { fx: number; fy: number; tx: number; ty: number; t0: number; du: number };
let tl: Tl[] = [];
let dragV = -1;
let dragCx = 0;
let dragCy = 0;
function setBoard(b: number[], n: number): void {
bd = b.slice();
BN = n;
CS = BSZ / n;
tl = [];
dragV = -1;
for (let v = 0; v < n * n; v += 1) {
const i = bd.indexOf(v);
const c = i % n;
const r = Math.floor(i / n);
tl.push({ fx: c, fy: r, tx: c, ty: r, t0: 0, du: 0 });
}
}
let still = false;
function tilePos(v: number): number[] {
const t = tl[v];
if (v === dragV) { return [dragCx, dragCy]; }
if (still || t.du <= 0) { return [t.tx, t.ty]; }
const e = easeOut((sceneT - t.t0) / t.du);
return [lerp(t.fx, t.tx, e), lerp(t.fy, t.ty, e)];
}
// 배열이 바뀐 뒤 타일마다 목표 칸을 다시 읽는다 — 옮겨진 타일만 지금 화면 자리에서 출발한다
function retarget(delayOf: (v: number) => number, du: number): void {
for (let v = 1; v < BN * BN; v += 1) {
const i = bd.indexOf(v);
const c = i % BN;
const r = Math.floor(i / BN);
const t = tl[v];
if (c !== t.tx || r !== t.ty) {
const p = tilePos(v);
t.fx = p[0];
t.fy = p[1];
t.tx = c;
t.ty = r;
t.t0 = sceneT + delayOf(v);
t.du = du;
}
}
}
function stepBlank(d: number, du: number): boolean {
if (!blankStep(bd, BN, d)) { return false; }
retarget(function (): number { return 0; }, du);
return true;
}
function cellAt(x: number, y: number): number {
const c = Math.floor((x - BX) / CS);
const r = Math.floor((y - BY) / CS);
if (c < 0 || c >= BN || r < 0 || r >= BN) { return -1; }
return r * BN + c;
}
// ── 그리기 도구
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, c: RGB, a: number, w: number): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x: number, y: number, w: number, h: number): void { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name: string, val: string, frac: number): void {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// 판 바탕 — 받침과 빈 칸 홈
function drawBoardBase(): void {
const s = CS * BN;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < BN * BN; i += 1) {
box(BX + (i % BN) * CS + 2, BY + Math.floor(i / BN) * CS + 2, CS - 4, CS - 4, 8, [40, 36, 62], 1);
}
}
function tileHue(v: number, n: number): RGB {
return hsl(190 + 160 * ((v - 1) / (n * n - 2)), 0.55, 0.8);
}
function paintTile(label: string, cx: number, cy: number, c: RGB, cs: number, ox: number, oy: number): void {
const s = cs - 4;
const x = ox + cx * cs + 2;
const y = oy + cy * cs + 2;
box(x, y + 2, s, s, 8, [26, 23, 42], 0.5);
box(x, y, s, s, 8, c, 1);
box(x + 3, y + 2, s - 6, 3, 1.5, [255, 255, 255], 0.4);
text(label, x + s / 2, y + s / 2 + 1, INK, Math.max(9, Math.round(cs * 0.38)), 800, 'center');
}
function drawTiles(fillOf: (v: number) => RGB): void {
for (let v = 1; v < BN * BN; v += 1) {
const p = tilePos(v);
paintTile(String(v), p[0], p[1], fillOf(v), CS, BX, BY);
}
}
// 글자판 없이 배열만 그리는 작은 판 — 비교 장면용
function drawMini(b: number[], n: number, ox: number, oy: number, size: number): void {
const cs = size / n;
box(ox - 3, oy - 3, size + 6, size + 6, 8, PANEL, 1);
for (let i = 0; i < n * n; i += 1) {
const v = b[i];
if (v === 0) { box(ox + (i % n) * cs + 1, oy + Math.floor(i / n) * cs + 1, cs - 2, cs - 2, 3, [40, 36, 62], 1); continue; }
const x = ox + (i % n) * cs + 1;
const y = oy + Math.floor(i / n) * cs + 1;
box(x, y, cs - 2, cs - 2, 3, tileHue(v, n), 1);
if (cs >= 20) { text(String(v), x + (cs - 2) / 2, y + (cs - 2) / 2 + 1, INK, Math.round(cs * 0.4), 800, 'center'); }
}
}
// ── 상태 줄·낭독 문장
const SPK_PLACE = '{a}×{a} 판, 타일 {b}장 중 {c}장이 제자리에 있다';
const SPK_DONE = '{a}×{a} 판 완성, 타일 {b}장이 모두 제자리에 있다';
function speakBoard(): string {
const t = BN * BN - 1;
return isSolved(bd) ? fill(SPK_DONE, String(BN), String(t)) : fill(SPK_PLACE, String(BN), String(t), String(placedCount(bd)));
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
}
function takeOver(): void {
if (manual) { return; }
manual = true;
wake();
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 맞은 타일 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode: number): number {
if (isSolved(bd)) { return 1000; }
return manual ? 100 + placedCount(bd) : sceneMode;
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say(): void {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
function advance(dt: number): void {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
}
update(dt);
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow(): void {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) { say(); }
}
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest(): void {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak: boolean): void {
if (paused || still) { sceneT += 1; }
update(0);
showNow();
if (speak) { say(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
say();
}
function restart(): void {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// N 한 걸음 — 시간표의 다음 일 하나를 지금 치른다. 시간표가 끝났으면 처음부터 다시 짠 뒤 첫 일
function stepOnce(): void {
takeOver();
if (evAt >= script.length) { lap += 1; begin(); }
if (evAt < script.length) {
const ev = script[evAt];
evAt += 1;
if (sceneT < ev.t) { sceneT = ev.t; }
ev.f();
}
afterInput(true);
}
function keyMove(dx: number, dy: number): void {
// 방향키는 타일이 가는 방향 — 그 타일은 빈칸의 반대쪽에 있다. 빈칸이 가는 방향으로 바꿔 부른다
let d = 0;
for (let k = 0; k < 4; k += 1) { if (DX[k] === -dx && DY[k] === -dy) { d = k; } }
takeOver();
onDir(d);
afterInput(true);
}
function toCanvas(e: PointerEvent): number[] {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 빈칸 이웃 타일 이동, Enter·Space = 일시정지, N = 한 걸음, R = 처음부터
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'r' || k === 'R') {
restart();
} else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
} else if (k === 'ArrowLeft') {
e.preventDefault();
keyMove(-1, 0);
} else if (k === 'ArrowRight') {
e.preventDefault();
keyMove(1, 0);
} else if (k === 'ArrowUp') {
e.preventDefault();
keyMove(0, -1);
} else if (k === 'ArrowDown') {
e.preventDefault();
keyMove(0, 1);
} else if (keyHook !== null && keyHook(k)) {
e.preventDefault();
takeOver();
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
// 장면이 방향키·Enter·N·R 밖의 키를 받고 싶을 때 거는 갈고리 — 먹었으면 true
let keyHook: ((k: string) => boolean) | null = null;
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
ptrDown = true;
try { pad.setPointerCapture(e.pointerId); } catch (err) { ptrDown = true; }
const p = toCanvas(e);
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (!ptrDown) { return; }
const p = toCanvas(e);
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e: PointerEvent): void {
if (!ptrDown) { return; }
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 판 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// 누른 칸이 빈칸의 이웃이면 그 타일이 들어갈 빈칸의 방향(빈칸 기준), 아니면 -1
function dirOfCell(i: number): number {
const z = bd.indexOf(0);
for (let d = 0; d < 4; d += 1) { if (canStep(bd, BN, d) && z + DX[d] + DY[d] * BN === i) { return d; } }
return -1;
}
// 08 — 누른 수를 스택에 쌓아 되돌리기(U·Z·Backspace) 한 번에 한 수씩 뺀다. 풀이를 재생할 때는 서로 반대 방향인 수가 이웃해 있으면 둘을 지워(cancelPair) 길이를 줄인 뒤 REPLAY_MS 간격으로 한 수씩 보인다. 줄인 수와 원래 수는 숫자로 같이 적힌다
const TILE_N = 4;
const REPLAY_MS = 220;
const PRESS_GAP = 0.13;
const SHUFFLE_08 = 24;
const PLAN_LEN = 14;
const SEEDS_08 = [18, 19, 20, 21];
const LAP_S = 7.6;
const REST_S = 3.6;
const PX = 256;
const PW = 216;
let origin: number[] = [];
let stack: number[] = [];
let reduced: number[] = [];
let marks: boolean[] = [];
let phase = 0;
let replayIdx = 0;
let targetB: number[] = [];
let same = false;
const STAT_STACK = '쌓인 수 {a}개';
const STAT_CUT = '원래 {a}수 → 줄인 {b}수';
const STAT_PLAY = '줄인 수 재생 {a}/{b}';
const SPK_CUT = '반대 방향 이웃 수를 지워 {a}수가 {b}수로 줄었다';
const SPK_SAME = '줄인 수로 재생한 판이 원래 판과 같다';
// 서로 반대 방향(d 와 d ^ 1)인 수가 이웃하면 둘을 지운다. 스택에 쌓으며 훑으면 겹쳐 지워지는 쌍까지 걸러진다
function cancelPair(ms: number[]): number[] {
const out: number[] = [];
for (let i = 0; i < ms.length; i += 1) {
if (out.length > 0 && out[out.length - 1] === (ms[i] ^ 1)) { out.pop(); } else { out.push(ms[i]); }
}
return out;
}
// 어느 수가 지워졌는지 — 인덱스 스택으로 같은 훑기를 한 번 더
function cancelMarks(ms: number[]): boolean[] {
const gone: boolean[] = [];
const idx: number[] = [];
for (let i = 0; i < ms.length; i += 1) {
gone.push(false);
if (idx.length > 0 && ms[idx[idx.length - 1]] === (ms[i] ^ 1)) {
gone[idx.pop() as number] = true;
gone[i] = true;
} else { idx.push(i); }
}
return gone;
}
// 계획 — 평소엔 되돌아가지 않고 해시가 정한 확률로만 일부러 되돌아가는 수를 넣어 왕복이 생기게 한 14수(시작 판은 그대로 두고 복사본에서 합법인 수만 고른다)
function planMoves(seed: number): number[] {
const b = origin.slice();
const out: number[] = [];
for (let k = 0; k < PLAN_LEN; k += 1) {
const last = out.length > 0 ? out[out.length - 1] : -1;
const opts: number[] = [];
for (let d = 0; d < 4; d += 1) { if (d !== (last ^ 1) && canStep(b, TILE_N, d)) { opts.push(d); } }
let d = opts[Math.floor(hash2(k, seed) * opts.length)];
if (last >= 0 && hash2(k, seed + 77) < 0.22 && canStep(b, TILE_N, last ^ 1)) { d = last ^ 1; }
blankStep(b, TILE_N, d);
out.push(d);
}
return out;
}
function press(d: number): void {
if (stepBlank(d, 0.1)) { stack.push(d); }
}
function undoOne(): void {
if (stack.length === 0) { return; }
const d = stack.pop() as number;
stepBlank(d ^ 1, 0.1);
}
function pressAt(d: number): () => void { return function (): void { press(d); }; }
function showCut(): void {
phase = 2;
targetB = bd.slice();
reduced = cancelPair(stack);
marks = cancelMarks(stack);
}
function startReplay(): void {
phase = 3;
replayIdx = 0;
same = false;
setBoard(origin, TILE_N);
}
function playAt(k: number): () => void {
return function (): void {
stepBlank(reduced[k], 0.15);
replayIdx = k + 1;
if (replayIdx === reduced.length) {
same = true;
for (let i = 0; i < bd.length; i += 1) { if (bd[i] !== targetB[i]) { same = false; } }
}
};
}
function build(): void {
phase = 0;
replayIdx = 0;
same = false;
stack = [];
reduced = [];
marks = [];
targetB = [];
origin = solvedBoard(TILE_N);
applyMoves(origin, TILE_N, walkMoves(TILE_N, SHUFFLE_08, 8 + lap));
setBoard(origin, TILE_N);
const plan = planMoves(SEEDS_08[lap % SEEDS_08.length]);
for (let k = 0; k < plan.length; k += 1) { script.push({ t: 0.25 + k * PRESS_GAP, f: pressAt(plan[k]) }); }
for (let j = 0; j < 3; j += 1) { script.push({ t: 2.25 + j * 0.28, f: function (): void { phase = 1; undoOne(); } }); }
script.push({ t: 3.3, f: showCut });
// 재생 길이는 줄인 수에 달려 있어 시간표는 지금 줄인 길이를 같은 방식으로 미리 잰다
const probe = plan.slice(0, plan.length - 3);
const rl = cancelPair(probe).length;
script.push({ t: 4.1, f: startReplay });
for (let k = 0; k < rl; k += 1) { script.push({ t: 4.3 + k * REPLAY_MS / 1000, f: playAt(k) }); }
}
function update(dt: number): void { if (dt < 0) { return; } }
function arrow(d: number, cx: number, cy: number, c: RGB, a: number): void {
const dx = DX[d];
const dy = DY[d];
const px = -dy;
const py = dx;
ctx.beginPath();
ctx.moveTo(cx + dx * 4.5, cy + dy * 4.5);
ctx.lineTo(cx - dx * 3 + px * 3.5, cy - dy * 3 + py * 3.5);
ctx.lineTo(cx - dx * 3 - px * 3.5, cy - dy * 3 - py * 3.5);
ctx.closePath();
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function chipRow(ms: number[], y: number, gone: boolean[] | null, hot: number): void {
for (let i = 0; i < PLAN_LEN; i += 1) {
const x = PX + 12 + i * 13.5;
box(x, y, 12, 16, 4, [48, 44, 74], 1);
if (i < ms.length) {
const dead = gone !== null && gone[i];
const cur = i === hot;
const done = hot >= 0 && i < hot;
box(x, y, 12, 16, 4, dead ? [88, 82, 122] : (cur ? ROSE : (done ? MINT : SKY)), 1);
arrow(ms[i], x + 6, y + 8, dead ? [150, 144, 180] : INK, 1);
if (dead) { seg(x + 2, y + 14, x + 10, y + 2, ROSE, 1, 1.5); }
}
}
}
function draw(): void {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoardBase();
drawTiles(function (v: number): RGB { return tileHue(v, BN); });
const s = CS * BN;
panel(PX, BY - 5, PW, s + 10);
text('stack', PX + 12, BY + 12, DIM, 11, 700, 'left');
text(String(stack.length), PX + PW - 12, BY + 12, NEU, 15, 800, 'right');
chipRow(stack, BY + 26, phase >= 2 ? marks : null, -1);
text('cancelPair', PX + 12, BY + 62, DIM, 11, 700, 'left');
if (phase >= 2) {
text(stack.length + ' → ' + reduced.length, PX + PW - 12, BY + 62, NEU, 15, 800, 'right');
chipRow(reduced, BY + 74, null, phase === 3 ? replayIdx : -1);
text('REPLAY_MS ' + REPLAY_MS, PX + 12, BY + 104, DIM, 11, 700, 'left');
}
// 두 판 비교 — 원래 수를 다 쌓은 판(왼쪽)과 줄인 수로 재생한 판(오른쪽)
if (phase >= 2) {
text('원래 수열', PX + 12, BY + 126, DIM, 10, 700, 'left');
text('줄인 수열', PX + PW - 12, BY + 126, DIM, 10, 700, 'right');
drawMini(targetB, BN, PX + 22, BY + 140, 64);
drawMini(bd, BN, PX + PW - 86, BY + 140, 64);
const eq = phase === 3 && same;
text(eq ? '=' : '?', PX + PW / 2, BY + 172, eq ? MINT : DIM, 20, 800, 'center');
}
hud('REPLAY_MS', String(REPLAY_MS), REPLAY_MS / 400);
}
function statusText(): string {
if (phase === 3) { return fill(STAT_PLAY, String(replayIdx), String(reduced.length)); }
if (phase === 2) { return fill(STAT_CUT, String(stack.length), String(reduced.length)); }
return fill(STAT_STACK, String(stack.length));
}
function modeId(): number { return modeBase(phase); }
function speakText(): string {
if (phase >= 2) { return fill(SPK_CUT, String(stack.length), String(reduced.length)) + (same ? '. ' + SPK_SAME : '') + '. ' + speakBoard(); }
return fill(STAT_STACK, String(stack.length)) + '. ' + speakBoard();
}
function onDir(d: number): void { press(d); }
function onPtr(kind: number, x: number, y: number): void {
if (kind !== 0) { return; }
const d = dirOfCell(cellAt(x, y));
if (d >= 0) { onDir(d); }
}
keyHook = function (k: string): boolean {
if (k === 'u' || k === 'U' || k === 'z' || k === 'Z' || k === 'Backspace') {
undoOne();
return true;
}
return false;
};
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'슬라이딩 퍼즐 시연 무대 — 방향키로 빈칸 옆 타일 이동, 타일 누르기도 가능, Enter 또는 Space 로 일시정지, N 으로 한 걸음, R 로 처음부터'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'4×4 판에서 누른 수가 오른쪽 패널의 칩 줄에 방향 화살표로 쌓이고, 되돌리기를 하면 칩이 하나씩 빠지며 판이 한 수씩 돌아간다. 이어서 서로 반대 방향인 이웃 칩 쌍이 흐려지며 지워져 원래 수에서 줄인 수가 숫자로 나오고, 처음 판으로 돌아가 줄인 수만 220밀리초 간격으로 재생해 같은 판에 닿는지 확인한다. 아래 눈금은 상수 이름과 지금 값을 보이고, 움직임 줄이기에서는 타일이 바로 자리를 바꾸는 판 한 장만 그려진다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'슬라이딩 퍼즐 장면'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}08 · 取り消しの記録と解法の再生 — 計測 累積面積 14.764% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function cancelPair(ms) {
const out = [];
for (let i = 0; i < ms.length; i += 1) {
if (out.length > 0 && out[out.length - 1] === (ms[i] ^ 1)) {
out.pop();
}
else {
out.push(ms[i]);
}
}
return out;
}
間違えた手を戻し、解いた過程をもう一度見せる機能にそのまま使えます。四つの盤で14手のうち3回戻してスタックに11手を残し、縮めた手数は5・7・7・3(消えた組は3・2・2・4)で、最初の盤から縮めた手だけで再生した結果は11手をそのまま積んだ盤とマスごとに同じでした(最初の盤の再生は5手 × 220ms = 1.1秒)。
09盤の大きさごとの状態数と探索ノードの比較
2×2・3×3・4×4の盤で取りうる配置の数を、n×nの階乗を2で割った数として計算し、対数目盛の棒で見せます。同じ混ぜの種で05番の探索を回した結果のノード数を横に置きます。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="スライドパズルの舞台 — 矢印キーで空きマスの隣のタイルを動かす、タイルを押しても動く、EnterかSpaceで一時停止、Nで一歩、Rで最初から">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="2×2、3×3、4×4の三つの盤が横に並び、同じ混ぜの種で混ぜた盤を探索が見つけた手で同時に解いていく。盤の下には解ける配置の数(12、181,440、約10兆)を対数目盛りで描いた棒と05番の探索のノード数の棒があり、ノードが4000に届くと停止の印が付く。盤が一段大きくなるたびに配置数の棒が大きく跳ねる。下の目盛りは定数名と今の値を示し、動きを減らす設定ではタイルがすぐ入れ替わる盤一枚だけを描きます"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">スライドパズルの場面</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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;
}
// 무대 상자 — 눌러서 한 번 깨뜨리는 곳(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause {
padding: $sp-1 $sp-3;
border: 2px solid $subject-cream;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 스크린리더에만 읽히는 낭독 문장 — 모드가 바뀔 때만 쓴다
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-ink;
color: $subject-cream;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면에만 있고 aria-live 는 늘 off
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 100px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 글리치 없는 원문 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
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;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause {
padding: 4px 12px;
border: 2px solid #fff7e6;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 100px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function easeOut(k) { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const SKY = [150, 192, 244];
const SUN = [250, 218, 146];
const NEU = [236, 230, 250];
const BGC = [46, 42, 68];
const PANEL = [62, 57, 92];
const DIM = [196, 190, 224];
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function hsl(h, s, l) {
const a = s * Math.min(l, 1 - l);
const f = function (n) {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a, b) {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s, a, b, c) {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v) { return String(Math.round(v * 10) / 10); }
function group(v) {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
o += ',';
}
o += s.charAt(i);
}
return o;
}
// ── 퍼즐 모델 — 판은 번호 배열, 0 은 빈칸, 타일 v 의 제자리는 v-1 번 칸, 빈칸의 제자리는 맨 끝
// 빈칸이 움직이는 방향 d: 0 오른쪽 · 1 왼쪽 · 2 아래 · 3 위. 반대 방향은 d ^ 1
const DX = [1, -1, 0, 0];
const DY = [0, 0, 1, -1];
function solvedBoard(n) {
const b = [];
for (let i = 1; i < n * n; i += 1) {
b.push(i);
}
b.push(0);
return b;
}
function canStep(b, n, d) {
const z = b.indexOf(0);
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
return c >= 0 && c < n && r >= 0 && r < n;
}
function blankStep(b, n, d) {
if (!canStep(b, n, d)) {
return false;
}
const z = b.indexOf(0);
const t = z + DX[d] + DY[d] * n;
b[z] = b[t];
b[t] = 0;
return true;
}
// 역전 — 번호를 한 줄로 늘어놓고 앞 번호가 뒤 번호보다 큰 쌍의 수(빈칸은 세지 않는다)
function inversions(b) {
let c = 0;
for (let i = 0; i < b.length; i += 1) {
for (let j = i + 1; j < b.length; j += 1) {
if (b[i] !== 0 && b[j] !== 0 && b[i] > b[j]) {
c += 1;
}
}
}
return c;
}
// 빈칸이 아래서 몇 번째 줄인지(맨 아래 줄 = 1)
function blankRow(b, n) { return n - Math.floor(b.indexOf(0) / n); }
// 판정 합 — 짝수 폭은 역전 + 빈칸 줄, 홀수 폭은 역전만
function parity(b, n) { return n % 2 === 0 ? inversions(b) + blankRow(b, n) : inversions(b); }
function solvable(b, n) {
return n % 2 === 0 ? parity(b, n) % 2 === 1 : parity(b, n) % 2 === 0;
}
function manhattan(b, n, i) {
const v = b[i];
if (v === 0) {
return 0;
}
const h = v - 1;
return Math.abs((i % n) - (h % n)) + Math.abs(Math.floor(i / n) - Math.floor(h / n));
}
function sumH(b, n) {
let s = 0;
for (let i = 0; i < b.length; i += 1) {
s += manhattan(b, n, i);
}
return s;
}
function placedCount(b) {
let c = 0;
for (let i = 0; i < b.length - 1; i += 1) {
if (b[i] === i + 1) {
c += 1;
}
}
return c;
}
function isSolved(b) { return placedCount(b) === b.length - 1 && b[b.length - 1] === 0; }
// 歩いて混ぜる — 完成 판에서 시작해 빈칸을 steps 번 이웃 칸으로 옮긴다. 방금 온 칸으로 되돌아가는 수는 건너뛰고
// 어느 이웃으로 갈지는 정수 해시로 고른다. 반환은 빈칸이 움직인 방향의 목록
function walkMoves(n, steps, seed) {
const b = solvedBoard(n);
const out = [];
let noBacktrack = -1;
for (let s = 0; s < steps; s += 1) {
const opts = [];
for (let d = 0; d < 4; d += 1) {
if (d !== noBacktrack && canStep(b, n, d)) {
opts.push(d);
}
}
const d = opts[Math.floor(hash2(s, seed) * opts.length)];
blankStep(b, n, d);
out.push(d);
noBacktrack = d ^ 1;
}
return out;
}
function applyMoves(b, n, ms) {
for (let i = 0; i < ms.length; i += 1) {
blankStep(b, n, ms[i]);
}
}
function reverseMoves(ms) {
const o = [];
for (let i = ms.length - 1; i >= 0; i -= 1) {
o.push(ms[i] ^ 1);
}
return o;
}
// 줄째 밀기 — 누른 칸 i 가 빈칸과 같은 줄이면 사이 타일이 모두 한 칸씩 빈칸 쪽으로. 배열 교환으로 먼저 정하고
// 옮겨진 타일 번호를 빈칸에 가까운 순서로 돌려준다
function rowChain(b, n, i) {
const z = b.indexOf(0);
if (i === z) {
return [];
}
let step = 0;
if (Math.floor(i / n) === Math.floor(z / n)) {
step = i > z ? 1 : -1;
}
else if (i % n === z % n) {
step = i > z ? n : -n;
}
else {
return [];
}
const moved = [];
for (let p = z; p !== i; p += step) {
b[p] = b[p + step];
moved.push(b[p]);
}
b[i] = 0;
return moved;
}
function idaStar(start, n, cap) {
const b = start.slice();
const path = [];
let nodes = 0;
let capHit = false;
const INF = 9999;
let z = b.indexOf(0);
function dfs(g, h, bound, last) {
nodes += 1;
if (nodes > cap) {
capHit = true;
return INF;
}
const f = g + h;
if (f > bound) {
return f;
}
if (h === 0) {
return -1;
}
let min = INF;
for (let d = 0; d < 4; d += 1) {
if (last >= 0 && d === (last ^ 1)) {
continue;
}
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
if (c < 0 || c >= n || r < 0 || r >= n) {
continue;
}
const t = z + DX[d] + DY[d] * n;
const v = b[t];
const hv = Math.abs((t % n) - ((v - 1) % n)) + Math.abs(Math.floor(t / n) - Math.floor((v - 1) / n));
const hn = Math.abs((z % n) - ((v - 1) % n)) + Math.abs(Math.floor(z / n) - Math.floor((v - 1) / n));
b[z] = v;
b[t] = 0;
const oz = z;
z = t;
path.push(d);
const res = dfs(g + 1, h - hv + hn, bound, d);
if (res === -1) {
return -1;
}
path.pop();
z = oz;
b[t] = v;
b[z] = 0;
if (capHit) {
return INF;
}
if (res < min) {
min = res;
}
}
return min;
}
let bound = sumH(b, n);
const bounds = [];
const perBound = [];
while (bound < INF) {
const before = nodes;
bounds.push(bound);
const res = dfs(0, sumH(b, n), bound, -1);
perBound.push(nodes - before);
if (res === -1) {
return { path: path.slice(), nodes: nodes, bounds: bounds, perBound: perBound, capHit: false };
}
if (capHit || res >= INF) {
break;
}
bound = res;
}
return { path: [], nodes: nodes, bounds: bounds, perBound: perBound, capHit: capHit };
}
// ── 판 화면 — 타일마다 지금 칸 → 목표 칸 보간. 배열은 먼저 바뀌고 화면이 뒤따른다
const BX = 24;
const BY = 44;
const BSZ = 216;
let bd = [];
let BN = 4;
let CS = 54;
let tl = [];
let dragV = -1;
let dragCx = 0;
let dragCy = 0;
function setBoard(b, n) {
bd = b.slice();
BN = n;
CS = BSZ / n;
tl = [];
dragV = -1;
for (let v = 0; v < n * n; v += 1) {
const i = bd.indexOf(v);
const c = i % n;
const r = Math.floor(i / n);
tl.push({ fx: c, fy: r, tx: c, ty: r, t0: 0, du: 0 });
}
}
let still = false;
function tilePos(v) {
const t = tl[v];
if (v === dragV) {
return [dragCx, dragCy];
}
if (still || t.du <= 0) {
return [t.tx, t.ty];
}
const e = easeOut((sceneT - t.t0) / t.du);
return [lerp(t.fx, t.tx, e), lerp(t.fy, t.ty, e)];
}
// 배열이 바뀐 뒤 타일마다 목표 칸을 다시 읽는다 — 옮겨진 타일만 지금 화면 자리에서 출발한다
function retarget(delayOf, du) {
for (let v = 1; v < BN * BN; v += 1) {
const i = bd.indexOf(v);
const c = i % BN;
const r = Math.floor(i / BN);
const t = tl[v];
if (c !== t.tx || r !== t.ty) {
const p = tilePos(v);
t.fx = p[0];
t.fy = p[1];
t.tx = c;
t.ty = r;
t.t0 = sceneT + delayOf(v);
t.du = du;
}
}
}
function stepBlank(d, du) {
if (!blankStep(bd, BN, d)) {
return false;
}
retarget(function () { return 0; }, du);
return true;
}
function cellAt(x, y) {
const c = Math.floor((x - BX) / CS);
const r = Math.floor((y - BY) / CS);
if (c < 0 || c >= BN || r < 0 || r >= BN) {
return -1;
}
return r * BN + c;
}
// ── 그리기 도구
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function disc(x, y, r, c, a) {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, c, a, w) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s, x, y, c, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x, y, w, h) { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name, val, frac) {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// 판 바탕 — 받침과 빈 칸 홈
function drawBoardBase() {
const s = CS * BN;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < BN * BN; i += 1) {
box(BX + (i % BN) * CS + 2, BY + Math.floor(i / BN) * CS + 2, CS - 4, CS - 4, 8, [40, 36, 62], 1);
}
}
function tileHue(v, n) {
return hsl(190 + 160 * ((v - 1) / (n * n - 2)), 0.55, 0.8);
}
function paintTile(label, cx, cy, c, cs, ox, oy) {
const s = cs - 4;
const x = ox + cx * cs + 2;
const y = oy + cy * cs + 2;
box(x, y + 2, s, s, 8, [26, 23, 42], 0.5);
box(x, y, s, s, 8, c, 1);
box(x + 3, y + 2, s - 6, 3, 1.5, [255, 255, 255], 0.4);
text(label, x + s / 2, y + s / 2 + 1, INK, Math.max(9, Math.round(cs * 0.38)), 800, 'center');
}
function drawTiles(fillOf) {
for (let v = 1; v < BN * BN; v += 1) {
const p = tilePos(v);
paintTile(String(v), p[0], p[1], fillOf(v), CS, BX, BY);
}
}
// 글자판 없이 배열만 그리는 작은 판 — 비교 장면용
function drawMini(b, n, ox, oy, size) {
const cs = size / n;
box(ox - 3, oy - 3, size + 6, size + 6, 8, PANEL, 1);
for (let i = 0; i < n * n; i += 1) {
const v = b[i];
if (v === 0) {
box(ox + (i % n) * cs + 1, oy + Math.floor(i / n) * cs + 1, cs - 2, cs - 2, 3, [40, 36, 62], 1);
continue;
}
const x = ox + (i % n) * cs + 1;
const y = oy + Math.floor(i / n) * cs + 1;
box(x, y, cs - 2, cs - 2, 3, tileHue(v, n), 1);
if (cs >= 20) {
text(String(v), x + (cs - 2) / 2, y + (cs - 2) / 2 + 1, INK, Math.round(cs * 0.4), 800, 'center');
}
}
}
// ── 상태 줄·낭독 문장
const SPK_PLACE = '{a}×{a} の盤、タイル {b} 枚のうち {c} 枚が元の位置にある';
const SPK_DONE = '{a}×{a} の盤が完成、タイル {b} 枚すべてが元の位置にある';
function speakBoard() {
const t = BN * BN - 1;
return isSolved(bd) ? fill(SPK_DONE, String(BN), String(t)) : fill(SPK_PLACE, String(BN), String(t), String(placedCount(bd)));
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
}
function takeOver() {
if (manual) {
return;
}
manual = true;
wake();
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 맞은 타일 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode) {
if (isSolved(bd)) {
return 1000;
}
return manual ? 100 + placedCount(bd) : sceneMode;
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say() {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
function advance(dt) {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
}
update(dt);
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow() {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) {
say();
}
}
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest() {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak) {
if (paused || still) {
sceneT += 1;
}
update(0);
showNow();
if (speak) {
say();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
say();
}
function restart() {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// N 한 걸음 — 시간표의 다음 일 하나를 지금 치른다. 시간표가 끝났으면 처음부터 다시 짠 뒤 첫 일
function stepOnce() {
takeOver();
if (evAt >= script.length) {
lap += 1;
begin();
}
if (evAt < script.length) {
const ev = script[evAt];
evAt += 1;
if (sceneT < ev.t) {
sceneT = ev.t;
}
ev.f();
}
afterInput(true);
}
function keyMove(dx, dy) {
// 방향키는 타일이 가는 방향 — 그 타일은 빈칸의 반대쪽에 있다. 빈칸이 가는 방향으로 바꿔 부른다
let d = 0;
for (let k = 0; k < 4; k += 1) {
if (DX[k] === -dx && DY[k] === -dy) {
d = k;
}
}
takeOver();
onDir(d);
afterInput(true);
}
function toCanvas(e) {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 빈칸 이웃 타일 이동, Enter·Space = 一時停止, N = 한 걸음, R = 처음부터
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'r' || k === 'R') {
restart();
}
else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
}
else if (k === 'ArrowLeft') {
e.preventDefault();
keyMove(-1, 0);
}
else if (k === 'ArrowRight') {
e.preventDefault();
keyMove(1, 0);
}
else if (k === 'ArrowUp') {
e.preventDefault();
keyMove(0, -1);
}
else if (k === 'ArrowDown') {
e.preventDefault();
keyMove(0, 1);
}
else if (keyHook !== null && keyHook(k)) {
e.preventDefault();
takeOver();
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
// 장면이 방향키·Enter·N·R 밖의 키를 받고 싶을 때 거는 갈고리 — 먹었으면 true
let keyHook = null;
pad.addEventListener('pointerdown', function (e) {
takeOver();
ptrDown = true;
try {
pad.setPointerCapture(e.pointerId);
}
catch (err) {
ptrDown = true;
}
const p = toCanvas(e);
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (!ptrDown) {
return;
}
const p = toCanvas(e);
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e) {
if (!ptrDown) {
return;
}
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 판 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// 누른 칸이 빈칸의 이웃이면 그 타일이 들어갈 빈칸의 방향(빈칸 기준), 아니면 -1
function dirOfCell(i) {
const z = bd.indexOf(0);
for (let d = 0; d < 4; d += 1) {
if (canStep(bd, BN, d) && z + DX[d] + DY[d] * BN === i) {
return d;
}
}
return -1;
}
// 09 — 2×2·3×3·4×4 판에서 풀 수 있는 배치 수(stateCount = 칸 수 계승의 절반)를 로그 눈금 막대(logBar)로 보이고, 같은 섞기 씨앗으로 05번 탐색을 돌려 나온 노드 수를 옆에 둔다
const TILE_N = 4;
const NODE_CAP = 4000;
const SIZES = [2, 3, 4];
const SEEDS_09 = [8, 6, 7];
const SHUFFLE_09 = 30;
const MOVE_GAP = 0.14;
const LAP_S = 6.6;
const REST_S = 2.6;
const COLW = 148;
let mini = [];
let found = [];
let startH = [];
let stepAt = [0, 0, 0];
const STAT_NODES = '探索ノード: 2×2 {a}・3×3 {b}・4×4 {c}';
const SPK_SIZES = '解ける配置は2×2で12通り、3×3で181,440通り、4×4で約10兆通り';
// 풀 수 있는 배치 수 — 칸 수(n×n) 계승의 절반. 16 계승까지는 배정밀도로 정확하다
function stateCount(n) {
let f = 1;
for (let i = 2; i <= n * n; i += 1) {
f *= i;
}
return f / 2;
}
// 로그 눈금 막대 — 값의 상용로그를 상한 로그로 나눈 0 ~ 1
function logBar(v, top) {
return clamp(Math.log10(Math.max(1, v)) / Math.log10(top), 0, 1);
}
function fmtBig(v) {
if (v < 1000000) {
return group(v);
}
const e = Math.floor(Math.log10(v));
return (v / Math.pow(10, e)).toFixed(2) + 'e' + e;
}
function nodeText(i) {
return found[i].capHit ? NODE_CAP + '+' : String(found[i].nodes);
}
function moveMini(c, k) {
return function () {
const d = found[c].path[k];
if (c === 2) {
stepBlank(d, 0.1);
}
else {
blankStep(mini[c], SIZES[c], d);
}
stepAt[c] = k + 1;
};
}
function build() {
mini = [];
found = [];
startH = [];
stepAt = [0, 0, 0];
const seed = SEEDS_09[lap % SEEDS_09.length];
for (let c = 0; c < 3; c += 1) {
const n = SIZES[c];
const b = solvedBoard(n);
applyMoves(b, n, walkMoves(n, SHUFFLE_09, seed));
found.push(idaStar(b, n, NODE_CAP));
startH.push(sumH(b, n));
mini.push(b.slice());
}
// 4×4 판은 방향키·누르기와 같은 판(bd)을 쓴다
setBoard(mini[2], 4);
for (let c = 0; c < 3; c += 1) {
for (let k = 0; k < found[c].path.length; k += 1) {
script.push({ t: 0.4 + k * MOVE_GAP, f: moveMini(c, k) });
}
}
}
function update(dt) { if (dt < 0) {
return;
} }
function draw() {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
for (let c = 0; c < 3; c += 1) {
const n = SIZES[c];
const x0 = 12 + c * (COLW + 4);
const y0 = 34;
panel(x0, y0, COLW, 228);
text(n + '×' + n, x0 + 12, y0 + 14, NEU, 14, 800, 'left');
const total = found[c].path.length;
text(found[c].capHit ? '-' : stepAt[c] + ' / ' + total, x0 + COLW - 12, y0 + 14, DIM, 11, 700, 'right');
const size = 92;
drawMini(c === 2 ? bd : mini[c], n, x0 + (COLW - size) / 2, y0 + 30, size);
const g1 = easeOut((sceneT - (0.3 + c * 0.3)) / 0.7);
const sc = stateCount(n);
text('stateCount', x0 + 12, y0 + 142, DIM, 10, 700, 'left');
text(fmtBig(sc), x0 + COLW - 12, y0 + 142, NEU, 12, 800, 'right');
box(x0 + 12, y0 + 150, COLW - 24, 10, 5, [48, 44, 74], 1);
box(x0 + 12, y0 + 150, (COLW - 24) * logBar(sc, 1e14) * g1, 10, 5, SKY, 1);
const g2 = easeOut((sceneT - (0.9 + c * 0.3)) / 0.7);
text('nodes', x0 + 12, y0 + 176, DIM, 10, 700, 'left');
text(nodeText(c), x0 + COLW - 12, y0 + 176, found[c].capHit ? ROSE : NEU, 12, 800, 'right');
box(x0 + 12, y0 + 184, COLW - 24, 10, 5, [48, 44, 74], 1);
box(x0 + 12, y0 + 184, (COLW - 24) * logBar(Math.min(found[c].nodes, NODE_CAP), NODE_CAP) * g2, 10, 5, found[c].capHit ? ROSE : MINT, 1);
seg(x0 + COLW - 12, y0 + 181, x0 + COLW - 12, y0 + 197, PAPER, 0.9, 2);
if (found[c].capHit) {
box(x0 + 12, y0 + 202, COLW - 24, 18, 9, ROSE, g2);
text('NODE_CAPで停止', x0 + COLW / 2, y0 + 211.5, INK, 10, 800, 'center');
}
else {
text(fill('解 {a} 手', String(total)), x0 + 12, y0 + 211, NEU, 11, 700, 'left');
}
}
hud('TILE_N', '2 → 4', 1);
}
function statusText() { return fill(STAT_NODES, nodeText(0), nodeText(1), nodeText(2)); }
function modeId() { return modeBase(sceneT > 2 ? 1 : 0); }
function speakText() { return SPK_SIZES + '. ' + statusText() + '. ' + speakBoard(); }
function onDir(d) { stepBlank(d, 0.1); }
function onPtr(kind, x, y) {
if (kind !== 0) {
return;
}
const size = 92;
const ox = 12 + 2 * (COLW + 4) + (COLW - size) / 2;
const oy = 34 + 30;
const c = Math.floor((x - ox) / (size / 4));
const r = Math.floor((y - oy) / (size / 4));
if (c < 0 || c > 3 || r < 0 || r > 3) {
return;
}
const d = dirOfCell(r * 4 + c);
if (d >= 0) {
onDir(d);
}
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// SizeStateCountRamp - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./SizeStateCountRamp.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SizeStateCountRamp() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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 easeOut(k: number): number { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const SKY: RGB = [150, 192, 244];
const SUN: RGB = [250, 218, 146];
const NEU: RGB = [236, 230, 250];
const BGC: RGB = [46, 42, 68];
const PANEL: RGB = [62, 57, 92];
const DIM: RGB = [196, 190, 224];
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function hsl(h: number, s: number, l: number): RGB {
const a = s * Math.min(l, 1 - l);
const f = function (n: number): number {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a: number, b: number): number {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s: string, a: string, b?: string, c?: string): string {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v: number): string { return String(Math.round(v * 10) / 10); }
function group(v: number): string {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { o += ','; }
o += s.charAt(i);
}
return o;
}
// ── 퍼즐 모델 — 판은 번호 배열, 0 은 빈칸, 타일 v 의 제자리는 v-1 번 칸, 빈칸의 제자리는 맨 끝
// 빈칸이 움직이는 방향 d: 0 오른쪽 · 1 왼쪽 · 2 아래 · 3 위. 반대 방향은 d ^ 1
const DX = [1, -1, 0, 0];
const DY = [0, 0, 1, -1];
function solvedBoard(n: number): number[] {
const b: number[] = [];
for (let i = 1; i < n * n; i += 1) { b.push(i); }
b.push(0);
return b;
}
function canStep(b: number[], n: number, d: number): boolean {
const z = b.indexOf(0);
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
return c >= 0 && c < n && r >= 0 && r < n;
}
function blankStep(b: number[], n: number, d: number): boolean {
if (!canStep(b, n, d)) { return false; }
const z = b.indexOf(0);
const t = z + DX[d] + DY[d] * n;
b[z] = b[t];
b[t] = 0;
return true;
}
// 역전 — 번호를 한 줄로 늘어놓고 앞 번호가 뒤 번호보다 큰 쌍의 수(빈칸은 세지 않는다)
function inversions(b: number[]): number {
let c = 0;
for (let i = 0; i < b.length; i += 1) {
for (let j = i + 1; j < b.length; j += 1) {
if (b[i] !== 0 && b[j] !== 0 && b[i] > b[j]) { c += 1; }
}
}
return c;
}
// 빈칸이 아래서 몇 번째 줄인지(맨 아래 줄 = 1)
function blankRow(b: number[], n: number): number { return n - Math.floor(b.indexOf(0) / n); }
// 판정 합 — 짝수 폭은 역전 + 빈칸 줄, 홀수 폭은 역전만
function parity(b: number[], n: number): number { return n % 2 === 0 ? inversions(b) + blankRow(b, n) : inversions(b); }
function solvable(b: number[], n: number): boolean {
return n % 2 === 0 ? parity(b, n) % 2 === 1 : parity(b, n) % 2 === 0;
}
function manhattan(b: number[], n: number, i: number): number {
const v = b[i];
if (v === 0) { return 0; }
const h = v - 1;
return Math.abs((i % n) - (h % n)) + Math.abs(Math.floor(i / n) - Math.floor(h / n));
}
function sumH(b: number[], n: number): number {
let s = 0;
for (let i = 0; i < b.length; i += 1) { s += manhattan(b, n, i); }
return s;
}
function placedCount(b: number[]): number {
let c = 0;
for (let i = 0; i < b.length - 1; i += 1) { if (b[i] === i + 1) { c += 1; } }
return c;
}
function isSolved(b: number[]): boolean { return placedCount(b) === b.length - 1 && b[b.length - 1] === 0; }
// 걸어서 섞기 — 완성 판에서 시작해 빈칸을 steps 번 이웃 칸으로 옮긴다. 방금 온 칸으로 되돌아가는 수는 건너뛰고
// 어느 이웃으로 갈지는 정수 해시로 고른다. 반환은 빈칸이 움직인 방향의 목록
function walkMoves(n: number, steps: number, seed: number): number[] {
const b = solvedBoard(n);
const out: number[] = [];
let noBacktrack = -1;
for (let s = 0; s < steps; s += 1) {
const opts: number[] = [];
for (let d = 0; d < 4; d += 1) { if (d !== noBacktrack && canStep(b, n, d)) { opts.push(d); } }
const d = opts[Math.floor(hash2(s, seed) * opts.length)];
blankStep(b, n, d);
out.push(d);
noBacktrack = d ^ 1;
}
return out;
}
function applyMoves(b: number[], n: number, ms: number[]): void {
for (let i = 0; i < ms.length; i += 1) { blankStep(b, n, ms[i]); }
}
function reverseMoves(ms: number[]): number[] {
const o: number[] = [];
for (let i = ms.length - 1; i >= 0; i -= 1) { o.push(ms[i] ^ 1); }
return o;
}
// 줄째 밀기 — 누른 칸 i 가 빈칸과 같은 줄이면 사이 타일이 모두 한 칸씩 빈칸 쪽으로. 배열 교환으로 먼저 정하고
// 옮겨진 타일 번호를 빈칸에 가까운 순서로 돌려준다
function rowChain(b: number[], n: number, i: number): number[] {
const z = b.indexOf(0);
if (i === z) { return []; }
let step = 0;
if (Math.floor(i / n) === Math.floor(z / n)) { step = i > z ? 1 : -1; } else if (i % n === z % n) { step = i > z ? n : -n; } else { return []; }
const moved: number[] = [];
for (let p = z; p !== i; p += step) {
b[p] = b[p + step];
moved.push(b[p]);
}
b[i] = 0;
return moved;
}
// 반복 깊이 탐색 — 맨해튼 거리 합 h 를 길잡이로 깊이 우선 탐색하고, 수와 h 의 합이 한도(bound)를 넘으면 가지를 접는다.
// 한 바퀴가 끝나면 한도를 넘긴 값 중 가장 작은 것으로 올려 다시 돈다. 노드 수가 cap 에 닿으면 멈춘다
type Ida = { path: number[]; nodes: number; bounds: number[]; perBound: number[]; capHit: boolean };
function idaStar(start: number[], n: number, cap: number): Ida {
const b = start.slice();
const path: number[] = [];
let nodes = 0;
let capHit = false;
const INF = 9999;
let z = b.indexOf(0);
function dfs(g: number, h: number, bound: number, last: number): number {
nodes += 1;
if (nodes > cap) { capHit = true; return INF; }
const f = g + h;
if (f > bound) { return f; }
if (h === 0) { return -1; }
let min = INF;
for (let d = 0; d < 4; d += 1) {
if (last >= 0 && d === (last ^ 1)) { continue; }
const c = (z % n) + DX[d];
const r = Math.floor(z / n) + DY[d];
if (c < 0 || c >= n || r < 0 || r >= n) { continue; }
const t = z + DX[d] + DY[d] * n;
const v = b[t];
const hv = Math.abs((t % n) - ((v - 1) % n)) + Math.abs(Math.floor(t / n) - Math.floor((v - 1) / n));
const hn = Math.abs((z % n) - ((v - 1) % n)) + Math.abs(Math.floor(z / n) - Math.floor((v - 1) / n));
b[z] = v;
b[t] = 0;
const oz = z;
z = t;
path.push(d);
const res = dfs(g + 1, h - hv + hn, bound, d);
if (res === -1) { return -1; }
path.pop();
z = oz;
b[t] = v;
b[z] = 0;
if (capHit) { return INF; }
if (res < min) { min = res; }
}
return min;
}
let bound = sumH(b, n);
const bounds: number[] = [];
const perBound: number[] = [];
while (bound < INF) {
const before = nodes;
bounds.push(bound);
const res = dfs(0, sumH(b, n), bound, -1);
perBound.push(nodes - before);
if (res === -1) { return { path: path.slice(), nodes: nodes, bounds: bounds, perBound: perBound, capHit: false }; }
if (capHit || res >= INF) { break; }
bound = res;
}
return { path: [], nodes: nodes, bounds: bounds, perBound: perBound, capHit: capHit };
}
// ── 판 화면 — 타일마다 지금 칸 → 목표 칸 보간. 배열은 먼저 바뀌고 화면이 뒤따른다
const BX = 24;
const BY = 44;
const BSZ = 216;
let bd: number[] = [];
let BN = 4;
let CS = 54;
type Tl = { fx: number; fy: number; tx: number; ty: number; t0: number; du: number };
let tl: Tl[] = [];
let dragV = -1;
let dragCx = 0;
let dragCy = 0;
function setBoard(b: number[], n: number): void {
bd = b.slice();
BN = n;
CS = BSZ / n;
tl = [];
dragV = -1;
for (let v = 0; v < n * n; v += 1) {
const i = bd.indexOf(v);
const c = i % n;
const r = Math.floor(i / n);
tl.push({ fx: c, fy: r, tx: c, ty: r, t0: 0, du: 0 });
}
}
let still = false;
function tilePos(v: number): number[] {
const t = tl[v];
if (v === dragV) { return [dragCx, dragCy]; }
if (still || t.du <= 0) { return [t.tx, t.ty]; }
const e = easeOut((sceneT - t.t0) / t.du);
return [lerp(t.fx, t.tx, e), lerp(t.fy, t.ty, e)];
}
// 배열이 바뀐 뒤 타일마다 목표 칸을 다시 읽는다 — 옮겨진 타일만 지금 화면 자리에서 출발한다
function retarget(delayOf: (v: number) => number, du: number): void {
for (let v = 1; v < BN * BN; v += 1) {
const i = bd.indexOf(v);
const c = i % BN;
const r = Math.floor(i / BN);
const t = tl[v];
if (c !== t.tx || r !== t.ty) {
const p = tilePos(v);
t.fx = p[0];
t.fy = p[1];
t.tx = c;
t.ty = r;
t.t0 = sceneT + delayOf(v);
t.du = du;
}
}
}
function stepBlank(d: number, du: number): boolean {
if (!blankStep(bd, BN, d)) { return false; }
retarget(function (): number { return 0; }, du);
return true;
}
function cellAt(x: number, y: number): number {
const c = Math.floor((x - BX) / CS);
const r = Math.floor((y - BY) / CS);
if (c < 0 || c >= BN || r < 0 || r >= BN) { return -1; }
return r * BN + c;
}
// ── 그리기 도구
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, c: RGB, a: number, w: number): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x: number, y: number, w: number, h: number): void { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name: string, val: string, frac: number): void {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// 판 바탕 — 받침과 빈 칸 홈
function drawBoardBase(): void {
const s = CS * BN;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < BN * BN; i += 1) {
box(BX + (i % BN) * CS + 2, BY + Math.floor(i / BN) * CS + 2, CS - 4, CS - 4, 8, [40, 36, 62], 1);
}
}
function tileHue(v: number, n: number): RGB {
return hsl(190 + 160 * ((v - 1) / (n * n - 2)), 0.55, 0.8);
}
function paintTile(label: string, cx: number, cy: number, c: RGB, cs: number, ox: number, oy: number): void {
const s = cs - 4;
const x = ox + cx * cs + 2;
const y = oy + cy * cs + 2;
box(x, y + 2, s, s, 8, [26, 23, 42], 0.5);
box(x, y, s, s, 8, c, 1);
box(x + 3, y + 2, s - 6, 3, 1.5, [255, 255, 255], 0.4);
text(label, x + s / 2, y + s / 2 + 1, INK, Math.max(9, Math.round(cs * 0.38)), 800, 'center');
}
function drawTiles(fillOf: (v: number) => RGB): void {
for (let v = 1; v < BN * BN; v += 1) {
const p = tilePos(v);
paintTile(String(v), p[0], p[1], fillOf(v), CS, BX, BY);
}
}
// 글자판 없이 배열만 그리는 작은 판 — 비교 장면용
function drawMini(b: number[], n: number, ox: number, oy: number, size: number): void {
const cs = size / n;
box(ox - 3, oy - 3, size + 6, size + 6, 8, PANEL, 1);
for (let i = 0; i < n * n; i += 1) {
const v = b[i];
if (v === 0) { box(ox + (i % n) * cs + 1, oy + Math.floor(i / n) * cs + 1, cs - 2, cs - 2, 3, [40, 36, 62], 1); continue; }
const x = ox + (i % n) * cs + 1;
const y = oy + Math.floor(i / n) * cs + 1;
box(x, y, cs - 2, cs - 2, 3, tileHue(v, n), 1);
if (cs >= 20) { text(String(v), x + (cs - 2) / 2, y + (cs - 2) / 2 + 1, INK, Math.round(cs * 0.4), 800, 'center'); }
}
}
// ── 상태 줄·낭독 문장
const SPK_PLACE = '{a}×{a} 판, 타일 {b}장 중 {c}장이 제자리에 있다';
const SPK_DONE = '{a}×{a} 판 완성, 타일 {b}장이 모두 제자리에 있다';
function speakBoard(): string {
const t = BN * BN - 1;
return isSolved(bd) ? fill(SPK_DONE, String(BN), String(t)) : fill(SPK_PLACE, String(BN), String(t), String(placedCount(bd)));
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
}
function takeOver(): void {
if (manual) { return; }
manual = true;
wake();
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 맞은 타일 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode: number): number {
if (isSolved(bd)) { return 1000; }
return manual ? 100 + placedCount(bd) : sceneMode;
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say(): void {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
function advance(dt: number): void {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
}
update(dt);
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow(): void {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) { say(); }
}
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest(): void {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak: boolean): void {
if (paused || still) { sceneT += 1; }
update(0);
showNow();
if (speak) { say(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
say();
}
function restart(): void {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// N 한 걸음 — 시간표의 다음 일 하나를 지금 치른다. 시간표가 끝났으면 처음부터 다시 짠 뒤 첫 일
function stepOnce(): void {
takeOver();
if (evAt >= script.length) { lap += 1; begin(); }
if (evAt < script.length) {
const ev = script[evAt];
evAt += 1;
if (sceneT < ev.t) { sceneT = ev.t; }
ev.f();
}
afterInput(true);
}
function keyMove(dx: number, dy: number): void {
// 방향키는 타일이 가는 방향 — 그 타일은 빈칸의 반대쪽에 있다. 빈칸이 가는 방향으로 바꿔 부른다
let d = 0;
for (let k = 0; k < 4; k += 1) { if (DX[k] === -dx && DY[k] === -dy) { d = k; } }
takeOver();
onDir(d);
afterInput(true);
}
function toCanvas(e: PointerEvent): number[] {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 빈칸 이웃 타일 이동, Enter·Space = 일시정지, N = 한 걸음, R = 처음부터
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'r' || k === 'R') {
restart();
} else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
} else if (k === 'ArrowLeft') {
e.preventDefault();
keyMove(-1, 0);
} else if (k === 'ArrowRight') {
e.preventDefault();
keyMove(1, 0);
} else if (k === 'ArrowUp') {
e.preventDefault();
keyMove(0, -1);
} else if (k === 'ArrowDown') {
e.preventDefault();
keyMove(0, 1);
} else if (keyHook !== null && keyHook(k)) {
e.preventDefault();
takeOver();
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
// 장면이 방향키·Enter·N·R 밖의 키를 받고 싶을 때 거는 갈고리 — 먹었으면 true
let keyHook: ((k: string) => boolean) | null = null;
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
ptrDown = true;
try { pad.setPointerCapture(e.pointerId); } catch (err) { ptrDown = true; }
const p = toCanvas(e);
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (!ptrDown) { return; }
const p = toCanvas(e);
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e: PointerEvent): void {
if (!ptrDown) { return; }
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 판 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// 누른 칸이 빈칸의 이웃이면 그 타일이 들어갈 빈칸의 방향(빈칸 기준), 아니면 -1
function dirOfCell(i: number): number {
const z = bd.indexOf(0);
for (let d = 0; d < 4; d += 1) { if (canStep(bd, BN, d) && z + DX[d] + DY[d] * BN === i) { return d; } }
return -1;
}
// 09 — 2×2·3×3·4×4 판에서 풀 수 있는 배치 수(stateCount = 칸 수 계승의 절반)를 로그 눈금 막대(logBar)로 보이고, 같은 섞기 씨앗으로 05번 탐색을 돌려 나온 노드 수를 옆에 둔다
const TILE_N = 4;
const NODE_CAP = 4000;
const SIZES = [2, 3, 4];
const SEEDS_09 = [8, 6, 7];
const SHUFFLE_09 = 30;
const MOVE_GAP = 0.14;
const LAP_S = 6.6;
const REST_S = 2.6;
const COLW = 148;
let mini: number[][] = [];
let found: Ida[] = [];
let startH: number[] = [];
let stepAt: number[] = [0, 0, 0];
const STAT_NODES = '탐색 노드: 2×2 {a} · 3×3 {b} · 4×4 {c}';
const SPK_SIZES = '풀 수 있는 배치는 2×2 가 12개, 3×3 이 181,440개, 4×4 가 약 10조개';
// 풀 수 있는 배치 수 — 칸 수(n×n) 계승의 절반. 16 계승까지는 배정밀도로 정확하다
function stateCount(n: number): number {
let f = 1;
for (let i = 2; i <= n * n; i += 1) { f *= i; }
return f / 2;
}
// 로그 눈금 막대 — 값의 상용로그를 상한 로그로 나눈 0 ~ 1
function logBar(v: number, top: number): number {
return clamp(Math.log10(Math.max(1, v)) / Math.log10(top), 0, 1);
}
function fmtBig(v: number): string {
if (v < 1000000) { return group(v); }
const e = Math.floor(Math.log10(v));
return (v / Math.pow(10, e)).toFixed(2) + 'e' + e;
}
function nodeText(i: number): string {
return found[i].capHit ? NODE_CAP + '+' : String(found[i].nodes);
}
function moveMini(c: number, k: number): () => void {
return function (): void {
const d = found[c].path[k];
if (c === 2) { stepBlank(d, 0.1); } else { blankStep(mini[c], SIZES[c], d); }
stepAt[c] = k + 1;
};
}
function build(): void {
mini = [];
found = [];
startH = [];
stepAt = [0, 0, 0];
const seed = SEEDS_09[lap % SEEDS_09.length];
for (let c = 0; c < 3; c += 1) {
const n = SIZES[c];
const b = solvedBoard(n);
applyMoves(b, n, walkMoves(n, SHUFFLE_09, seed));
found.push(idaStar(b, n, NODE_CAP));
startH.push(sumH(b, n));
mini.push(b.slice());
}
// 4×4 판은 방향키·누르기와 같은 판(bd)을 쓴다
setBoard(mini[2], 4);
for (let c = 0; c < 3; c += 1) {
for (let k = 0; k < found[c].path.length; k += 1) { script.push({ t: 0.4 + k * MOVE_GAP, f: moveMini(c, k) }); }
}
}
function update(dt: number): void { if (dt < 0) { return; } }
function draw(): void {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
for (let c = 0; c < 3; c += 1) {
const n = SIZES[c];
const x0 = 12 + c * (COLW + 4);
const y0 = 34;
panel(x0, y0, COLW, 228);
text(n + '×' + n, x0 + 12, y0 + 14, NEU, 14, 800, 'left');
const total = found[c].path.length;
text(found[c].capHit ? '-' : stepAt[c] + ' / ' + total, x0 + COLW - 12, y0 + 14, DIM, 11, 700, 'right');
const size = 92;
drawMini(c === 2 ? bd : mini[c], n, x0 + (COLW - size) / 2, y0 + 30, size);
const g1 = easeOut((sceneT - (0.3 + c * 0.3)) / 0.7);
const sc = stateCount(n);
text('stateCount', x0 + 12, y0 + 142, DIM, 10, 700, 'left');
text(fmtBig(sc), x0 + COLW - 12, y0 + 142, NEU, 12, 800, 'right');
box(x0 + 12, y0 + 150, COLW - 24, 10, 5, [48, 44, 74], 1);
box(x0 + 12, y0 + 150, (COLW - 24) * logBar(sc, 1e14) * g1, 10, 5, SKY, 1);
const g2 = easeOut((sceneT - (0.9 + c * 0.3)) / 0.7);
text('nodes', x0 + 12, y0 + 176, DIM, 10, 700, 'left');
text(nodeText(c), x0 + COLW - 12, y0 + 176, found[c].capHit ? ROSE : NEU, 12, 800, 'right');
box(x0 + 12, y0 + 184, COLW - 24, 10, 5, [48, 44, 74], 1);
box(x0 + 12, y0 + 184, (COLW - 24) * logBar(Math.min(found[c].nodes, NODE_CAP), NODE_CAP) * g2, 10, 5, found[c].capHit ? ROSE : MINT, 1);
seg(x0 + COLW - 12, y0 + 181, x0 + COLW - 12, y0 + 197, PAPER, 0.9, 2);
if (found[c].capHit) {
box(x0 + 12, y0 + 202, COLW - 24, 18, 9, ROSE, g2);
text('NODE_CAP 멈춤', x0 + COLW / 2, y0 + 211.5, INK, 10, 800, 'center');
} else {
text(fill('해 {a}수', String(total)), x0 + 12, y0 + 211, NEU, 11, 700, 'left');
}
}
hud('TILE_N', '2 → 4', 1);
}
function statusText(): string { return fill(STAT_NODES, nodeText(0), nodeText(1), nodeText(2)); }
function modeId(): number { return modeBase(sceneT > 2 ? 1 : 0); }
function speakText(): string { return SPK_SIZES + '. ' + statusText() + '. ' + speakBoard(); }
function onDir(d: number): void { stepBlank(d, 0.1); }
function onPtr(kind: number, x: number, y: number): void {
if (kind !== 0) { return; }
const size = 92;
const ox = 12 + 2 * (COLW + 4) + (COLW - size) / 2;
const oy = 34 + 30;
const c = Math.floor((x - ox) / (size / 4));
const r = Math.floor((y - oy) / (size / 4));
if (c < 0 || c > 3 || r < 0 || r > 3) { return; }
const d = dirOfCell(r * 4 + c);
if (d >= 0) { onDir(d); }
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'슬라이딩 퍼즐 시연 무대 — 방향키로 빈칸 옆 타일 이동, 타일 누르기도 가능, Enter 또는 Space 로 일시정지, N 으로 한 걸음, R 로 처음부터'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'2×2, 3×3, 4×4 세 판이 가로로 나란히 있고 같은 섞기 씨앗으로 섞인 판을 탐색이 찾은 수로 동시에 풀어 간다. 판 아래에는 풀 수 있는 배치 수(12, 181,440, 약 10조)를 로그 눈금으로 그린 막대와 05번 탐색의 노드 수 막대가 있고, 노드가 4000에 닿으면 멈춤 표시가 붙는다. 판이 한 칸 커질 때마다 배치 수 막대가 크게 뛴다. 아래 눈금은 상수 이름과 지금 값을 보이고, 움직임 줄이기에서는 타일이 바로 자리를 바꾸는 판 한 장만 그려진다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'슬라이딩 퍼즐 장면'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}09 · 盤の大きさごとの状態数と探索ノードの比較 — 計測 累積面積 12.89% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function stateCount(n) {
let f = 1;
for (let i = 2; i <= n * n; i += 1) {
f *= i;
}
return f / 2;
}
// 対数目盛の棒 — 値の常用対数を上限の対数で割った0〜1
function logBar(v, top) {
return clamp(Math.log10(Math.max(1, v)) / Math.log10(top), 0, 1);
}
盤の大きさを決めるときに難しさがどう跳ねるかを見積もる目安です。配置数は2×2が12、3×3が181,440、4×4が約1.05e13で、12から181,440は15,120倍、181,440から1.05e13は約5,770万倍に跳ね、種8ではノード数が2×2で7個(解6手)・3×3で303個(14手)・4×4で3,600個(28手)と、盤が大きいほど探索も重くなります。
スライドパズルをコードで作ると、タイル一枚の動きはどんな値がいくつで決まるのでしょうか。
盤の配列と空きマスの向きが決めます
盤は番号の配列 bd(0が空きマス、タイル v の元の位置は v-1 番のマス)ひとつで、空きマスの向き d は0が右・1が左・2が下・3が上の四つ、反対向きは d ^ 1 です。一手は配列の二つのマスの入れ替えにすぎません。
| 値 | 意味 | 変える場所 |
|---|---|---|
bd |
盤の状態(0が空きマス) | rowChain・blankStep・walkMoves |
d |
空きマスの移動の向き | 方向キー・ドラッグ・探索 |
| H | マンハッタン距離の合計 | manhattan・sumH(03・05) |
一つの盤からいくつもの計算が生まれます
同じ bd に対して、inversions は判定を、manhattan は距離を、walkMoves は混ぜ方を計算します。場面が九つあっても配列を作る規則はひとつなので、別の場面の関数でも同じ配列をそのまま調べられます。
盤を番号の配列で持ち、空きマスの隣のタイルを選んで入れ替え、その結果をマスの座標に移して描く — スライドパズルの一手が処理される順番
配列が先で、画面があとから追います
押したりドラッグしたりすると配列の入れ替えが先に起き、画面のタイルは目標のマスへ SLIDE_MS か SETTLE_S の補間で追いかけます。補間の途中でもう一度押しても盤の状態はすでに決まっているのでずれません。drawImage で絵を写す06も同じ順番です。
上限ごとのノードの階段
05の探索は上限をHから始め、失敗するたびに受け取った最小の超過値へ引き上げます。上限ごとのノード数を perBound に積んで画面に行として見せるので、上限が一段上がるたびに探索がどれだけ膨らむかが数字で読めます。
スライドパズルのゲームのように実際の画面に付けるとき — ライブラリなしの作り方・タッチとマウスの入力・混ぜる時点
ライブラリなしの作り方
キャンバス2Dがあれば足ります。九つの場面はライブラリ・WebGL・CDNを使わず、ソース一つのファイルに計算の関数と場面のデモを一緒に入れました。「スライドパズル 無料」で探す人も、外部のサービスに頼らずこのページの上で動かして確かめられます。
タッチとマウスの入力
07は pointerdown・pointermove・pointerup・pointercancel と setPointerCapture でドラッグを処理し、舞台の箱に touch-action: none を付けるので、ドラッグ中にページが一緒にスクロールしません。ほかの場面は方向キーとタイルの押下で同じ配列の関数を呼びます。
混ぜる時点
盤を見せる前に walkMoves で歩いておけば、解けない盤が出る隙がありません。すでに受け取った盤なら、絵を描く前に solvable で先に判定します。「スライドパズル コツ」を一つだけ挙げるなら、この判定を画面より前に置くことです。
ジグソー編との違い
ジグソーパズルの編 はピースの形と隣のピースとの結合を扱い、この編は空きマスとの入れ替えの規則と解ける盤の判定を扱います。格子の規則を探索で解く流れが気になるなら、迷路の生成の編 も同じ計算の枠組みを使っています。
スライドパズルが壊れる落とし穴 — でたらめに混ぜて解けない盤ができること・探索が深くなりすぎて画面が止まること・ドラッグして離したときタイルがマスの間に残ること
でたらめに混ぜると解けない盤ができます
配列を無作為に並べ替えると、解ける盤は半分ほどです。ハッシュによる無作為な配置1,000盤のうち解けたのは490盤でした。解けない盤を除くには、02の判定を描く前に回すか、04のように規則どおりの手だけで混ぜます。二枚を入れ替えた盤は、その後に何手重ねても偶奇が戻りません。
探索が深くなりすぎると画面が止まります
4×4の盤を上限なしで探索するとノードは数十万を超え、上限を300,000にしても四つの盤のうち三つが終わりませんでした。画面が固まるのを防ぐのが NODE_CAP の4000で、届いたら「上限に到達」と示してやめます。実際のサービスでは上限を大きく取るか、ウェブワーカーで回します。
ドラッグして離すとタイルがマスの間に残ります
ドラッグを一マスの幅で切らないと、タイルが隣のマスの上に重なり、離した瞬間にどのマスへ付けるか曖昧になります。clampK で0〜1マスの中に切り、SNAP_RATIO で吸着か戻りかを一度に決めれば、タイルがマスの間で止まったままになりません。
この記事の九つの場面の全ソースと編集用のファイルは、下の箱のzipで受け取れます。開くための圧縮パスワードは mvvxprjw です。
盤の大きさとスライド時間と混ぜる手数を変えると感じはどれだけ変わるか — TILE_N・SLIDE_MS・SHUFFLE_STEPSを変えた違い
| 名前 | 変えた値 | 感じ |
|---|---|---|
| 盤をもっと大きく | 06 TILE_N 4 → 5 | TILE_N を4から5に上げるとタイルが15枚から24枚に増え、一枚が54pxから43.2pxに小さくなって絵がより細かく切られ継ぎ目が増え、下げると大きな断片が数枚になります。手数は同じなので、解く手16と完成2.37秒は変わりません |
| スライドをもっとゆっくり | 01 SLIDE_MS 140 → 400 | SLIDE_MS を140から400に伸ばすと三枚が動き終わるまでが200msから460msに延びる一方、STAGGER_MS の30msのずれは変わらないので、順に付いていく様子はひとかたまりにぼやけ、縮めるとずれが目立ちます |
| 混ぜる手数を少なく | 04 SHUFFLE_STEPS 60 → 12 | SHUFFLE_STEPS を60から12に減らすと距離の合計Hが30から12に下がり(元の位置にあるタイルは0枚から3枚)、解く手が短くなります。種41〜60の二十の盤の平均Hは12歩で10.6・60歩で24.7・300歩で38.4でした |
この三つの値は互いに干渉しません。TILE_N はピースの数と大きさだけ、SLIDE_MS は画面の補間だけ、SHUFFLE_STEPS は混ざり具合だけを変えるので、一度にひとつずつ変えて、どの値が感じを変えたのか見るのがよいでしょう。
動きを減らす設定ではタイルがすぐ入れ替わる盤一枚 — 場面名と今そろったタイルの数を文で読み上げるアクセシビリティ
方向キーでタイルを動かし、取り消しはUです
舞台の箱は九つの場面とも role="application" で tabindex 0 なのでフォーカスを受け取り、方向キーはその向きに動くタイルを動かし、Enter・Space・Pは一時停止、Nは一歩、Rは最初からです。08はU・Z・Backspaceで一手戻し、07はタイルをドラッグして離します。
読み上げはモードが変わるときだけです
画面の状態の行は aria-live="off" でデモ中は読まれず、role="status" の隠した段落に speakText() で場面名と今そろったタイルの数を文で書きます。書くのは操作した直後とモードが変わるときだけなので、自動のデモ中に読み上げが流れることはありません。
動きを減らす設定と隠れたタブ
prefers-reduced-motion が有効なときはループを回さず、時間表の停止の時刻の一枚だけを描くので、タイルは補間なしで目標のマスにすぐ収まります。タブが隠れたらループを止め、戻ったら積んだ時間を捨てて続きから動かします。この処理は MDN の prefers-reduced-motion の文書に沿っています。
// タブが隠れたら止め、戻ったら時計を合わせ直して始める — 隠れていた数秒が一フレームに集まらないように
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 設定が変わったら — 有効なら停止の場面一枚、無効なら再び動かす
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
スライドパズルのFAQ — スライドパズルの解き方・解けない盤ができる理由・ウェブに載せるときに必要なもの
スライドパズルの解き方をコードで手伝えますか
03のマンハッタン距離で元の位置から遠いタイルを選んで動かしていくとHが0に向かって下がり、行き詰まったら05の反復深化探索が最短手を探して一手ずつ見せます。Hは各タイルが元の位置まであと何マスかを足した目安で、0になれば完成です。
解けない盤はなぜできますか
二枚のタイルを入れ替えると逆転の数の偶奇が反転し、解けない側へ移ります。規則どおりの手だけではこの偶奇が変わらないので、無作為に混ぜた盤の半分ほどは解けない盤になり、02は判定の値を実時間で見せます。
ウェブに載せるときは何が必要ですか
静的なファイル一式で足ります。キャンバスは devicePixelRatio を2までしか掛けず、外部のフォントもサーバー呼び出しもないので、どの静的ホスティングにも載せられます。
出典の文書は MDN の requestAnimationFrame の項目です。









