슬라이딩 퍼즐 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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 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);
}
슬라이딩 퍼즐 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 으로 가장 적고 세 장면이 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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번 이웃 칸으로 옮기는 섞기라, 규칙대로 걸어 나온 판이라 판정이 풀 수 있음으로 나온다. 방금 온 칸으로 되돌아가는 수는 건너뛰고 어느 이웃으로 갈지는 정수 해시로 고른다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 부터 10 까지 한 걸음에 1씩 올라 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 판의 가능한 배치 수를 풀 수 있는 절반으로 계산해 로그 눈금 막대로 보이고, 같은 섞기 씨앗으로 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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장) 풀이가 짧아지고, 늘리면 판이 더 뒤섞여 H 가 오른다. 시드 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 는 판이 제자리에서 얼마나 먼지 보여 주는 숫자다.
풀 수 없는 판은 왜 생기나요
두 타일을 맞바꾸면 역전 수의 홀짝이 뒤집혀 풀 수 없는 쪽으로 넘어간다. 합법 이동만으로는 이 홀짝이 바뀌지 않으니, 무작위로 뒤섞인 판의 절반 가까이가 못 푸는 판이다 — 02 가 이 슬라이딩 퍼즐 공식 같은 판정 값을 실시간으로 보여 준다.
웹에 올릴 때는 무엇이 필요한가요
정적 파일 한 벌로 충분하다. 캔버스는 devicePixelRatio 를 2 까지만 곱하고 외부 글꼴·서버 호출이 없어 어느 정적 호스팅에도 올라간다.
출처 문서는 MDN 의 requestAnimationFrame 항목이다.









