01A Row of Tiles Sliding Toward the Blank in a Chain
Pressing a tile in the same row as the blank moves every tile between them one cell toward the blank. Each tile takes SLIDE_MS (140) ms but starts STAGGER_MS (30) ms after the last, so they follow in turn, and the move is settled in the array first with the screen trailing behind.
Best forAn input where one press moves several tiles
SLIDE_MSSTAGGER_MSrowChain

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="Sliding puzzle stage: arrow keys move a tile beside the blank, pressing a tile works too, Enter or Space pauses, N takes one step, R restarts">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="A 4×4 sliding puzzle board sits on a dark plum background. Pressing a tile in the same row or column as the blank moves every tile between them one cell toward the blank over 140 milliseconds, each starting 30 milliseconds after the last. The right panel shows ten bars of tiles moved per press, the press count and tiles moved total, and for the last press when each tile starts and how long it moves. The gauge below shows the constant name and its current value, and under reduced motion only one board where the tiles swap places at once is drawn"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">Sliding puzzle scene</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">Pause</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; }
// Shuffle by walking — Done 판에서 시작해 빈칸을 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}x{a} board, {c} of {b} tiles are in place';
const SPK_DONE = '{a}x{a} board complete, all {b} tiles are in place';
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 = Pause, 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 = 'In the line just pressed, {a} tiles slid one cell each toward the blank';
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('Tiles moved per press', 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} presses, {b} tiles moved', 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} tiles moved at once, presses so far {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 A Row of Tiles Sliding Toward the Blank in a Chain — Measured area 27.141% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Sliding Puzzle: 9 Canvas Scenes, Parity to Search
The post this demo lives in
Sliding Puzzle: 9 Canvas Scenes, Parity to SearchWhy are some sliding puzzle boards unsolvable? 02 counts flipped pairs to sort them, 05 raises a Manhattan distance boun See all demos in this post
If you like this effect - similar demos
game06 Balls Pulled Toward the Pointer and Orbiting It9 Bouncing Ball Animation Patterns With Physics
game04 A Gridless Snake Whose Chain Body Is Dragged Behind the Head by Distance ConstraintsCSS Snake Game: 9 Boards That Find Their Own Path
game07 An Auto Player That Counts the Tiles Yet to Fall2048 Game JavaScript: 9 Boards from Merges to AI