02距離と角度のしきい値の内側でだけ引き寄せる吸着
まねた手がピースを正しい隣の近くまで運ぶと、はまる位置までの距離と角度の差がどちらもしきい値の内側のときだけ、ばねが残りの距離を引き寄せて少し行き過ぎてから収まり、しきい値の外で放されたピースはその場にとどまります。
使いどころ子ども向け学習サイトの絵合わせ
SNAP_RADIUSSNAP_ANGLESNAP_STIFFNESS

<main class="stage">
<div class="jz is-demo" id="jz">
<canvas class="jz__canvas" data-role="canvas" width="480" height="300" role="img" tabindex="0" aria-label="ピースを正しい隣のそばへ運ぶと、距離と角度がしきい値の内側のときだけばねで引き寄せるパズル盤。矢印キーでピースを動かせます"></canvas>
<p class="jz__status" data-role="status" aria-live="off">ピースを運んでいます</p>
<button class="jz__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.jz {
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-cream;
}
// 캔버스 — 끌기를 손가락으로도 받도록 브라우저 기본 끌기(스크롤·확대)를 이 위에서만 끈다
.jz__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
touch-action: none;
cursor: grab;
border-radius: $r-card;
}
// 초점 — 키보드로 들어오면 캔버스 안쪽에 진한 테두리. 고른 조각은 캔버스가 점선으로 따로 그린다
.jz__canvas:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.jz__pause {
position: absolute;
right: $sp-3;
bottom: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
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;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.jz__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.jz__pause:hover {
transform: translateY(-2px);
}
.jz__pause:active {
transform: translateY(1px);
}
.jz__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 남은 묶음 수·이은 자리 수를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.jz__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
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) {
.jz__pause,
.jz__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
// 동작 줄이기 — 판은 스크립트가 완성 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.jz__pause { transition: none !important; }
.jz__pause:hover,
.jz__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);
}
.jz {
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: #fff7e6;
}
.jz__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
touch-action: none;
cursor: grab;
border-radius: 16px;
}
.jz__canvas:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.jz__pause {
position: absolute;
right: 12px;
bottom: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
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);
}
.jz__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.jz__pause:hover {
transform: translateY(-2px);
}
.jz__pause:active {
transform: translateY(1px);
}
.jz__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.jz__status {
position: absolute;
left: 12px;
bottom: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
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) {
.jz__pause,
.jz__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.jz__pause {
transition: none !important;
}
.jz__pause:hover,
.jz__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("jz");
if (!root) return;
// 장면 시계(초)와 상태 줄 글자 — 장면 계산이 고치고, 화면 쪽이 읽어 그린다
let sceneT = 0;
let simStatus = '';
// 판 좌표 — 캔버스 안은 늘 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 smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 남은 거리의 같은 비율만큼 다가간다
function follow(rate, dt) { return 1 - Math.exp(-rate * dt); }
function deg(rad) { return rad * 180 / Math.PI; }
// 각을 −π ~ π 로 접는다 — 두 각의 차를 잴 때 한 바퀴 차이를 없앤다
function wrapAngle(a) { let x = a % TAU; if (x > Math.PI) {
x -= TAU;
} if (x < -Math.PI) {
x += TAU;
} return x; }
// 시드 고정 난수 — 같은 시드면 같은 시연이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// −1 ~ 1 사이 값
function spread(rng) { return rng() * 2 - 1; }
function makeSpring(x) { return { x: x, v: 0, to: x }; }
const SPRING_SUBSTEP = 1 / 240;
function stepSpring(s, stiffness, damping, dt) {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * (s.to - s.x) - damping * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
function snapSpring(s, x) { s.x = x; s.v = 0; s.to = x; }
// ── 값 노이즈 ────────────────────────────────────────────────
// 정수 격자점마다 시드로 0~1 값을 하나씩 정해 두고, 그 사이는 부드러운 계단(3x² − 2x³)으로 섞는다.
// 여러 배율(옥타브)을 반씩 줄인 세기로 더하면 산등성이·구름 같은 결이 나온다
function hash2(ix, iy, seed) {
let h = Math.imul(ix, 374761393) ^ Math.imul(iy, 668265263) ^ Math.imul(seed, 1442695041);
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function valueNoise(x, y, seed) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const sx = fx * fx * (3 - 2 * fx);
const sy = fy * fy * (3 - 2 * fy);
const a = hash2(ix, iy, seed);
const b = hash2(ix + 1, iy, seed);
const c = hash2(ix, iy + 1, seed);
const d = hash2(ix + 1, iy + 1, seed);
return lerp(lerp(a, b, sx), lerp(c, d, sx), sy);
}
function fbm(x, y, seed, octaves) {
let amp = 0.5;
let freq = 1;
let sum = 0;
let norm = 0;
for (let k = 0; k < octaves; k += 1) {
sum += amp * valueNoise(x * freq, y * freq, seed + k * 17);
norm += amp;
amp *= 0.5;
freq *= 2;
}
return sum / norm;
}
// 파스텔 팔레트 — 크림 바탕 위 라벤더·민트·버터·산호. 테두리·글자는 진한 쪽으로 명도 차를 둔다
const PAPER = [251, 247, 238];
const PAPER_DIM = [238, 230, 214];
const INK = [44, 38, 58];
const INK_SOFT = [112, 104, 126];
const CREAM = [255, 251, 242];
const LAVENDER = [196, 182, 238];
const LAVENDER_DIM = [160, 146, 214];
const MINT = [164, 218, 196];
const MINT_DIM = [118, 184, 158];
const BUTTER = [246, 224, 150];
const CORAL = [244, 170, 152];
const SKY = [170, 206, 238];
const PINK = [244, 190, 212];
const MINT_DEEP = [36, 116, 90];
const CORAL_DEEP = [178, 66, 52];
const VIOLET_DEEP = [84, 58, 160];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
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) + ')';
}
// ── 코드로 칠한 풍경 ─────────────────────────────────────────
// 판 안의 비율 좌표 (u, v) 0~1 하나에 색 하나. 하늘 → 해 → 구름 → 먼 산 → 가까운 언덕 → 들꽃 순으로 덮어 칠한다.
// 사진이 아니라 같은 페이지에서 계산한 그림이라 getImageData 로 다시 읽어도 막히지 않는다
const SCENE_SEED = 27;
const SUN_U = 0.74;
const SUN_V = 0.24;
const SUN_R = 0.085;
function sceneryColor(u, v) {
let c = mix(SKY, CREAM, smooth(v / 0.56));
const ds = Math.hypot(u - SUN_U, (v - SUN_V) * 0.8);
c = mix(c, BUTTER, clamp(1 - ds / (SUN_R * 2.6), 0, 1) * 0.55);
if (ds < SUN_R) {
c = mix(BUTTER, CREAM, 0.25);
}
const cloud = fbm(u * 5, v * 10, SCENE_SEED + 3, 4);
if (v < 0.5) {
c = mix(c, CREAM, smooth((cloud - 0.56) / 0.14) * 0.9 * (1 - v / 0.5));
}
const ridgeFar = 0.42 - 0.17 * fbm(u * 2.4, 0.5, SCENE_SEED + 5, 3);
if (v > ridgeFar) {
const shade = fbm(u * 9, v * 9, SCENE_SEED + 7, 3);
c = mix(LAVENDER, LAVENDER_DIM, clamp((v - ridgeFar) * 2.4 + (shade - 0.5) * 0.7, 0, 1));
}
const ridgeNear = 0.63 - 0.15 * fbm(u * 3.2 + 4, 1.5, SCENE_SEED + 9, 3);
if (v > ridgeNear) {
const shade = fbm(u * 12, v * 12, SCENE_SEED + 11, 3);
c = mix(MINT, MINT_DIM, clamp((v - ridgeNear) * 1.8 + (shade - 0.5) * 0.9, 0, 1));
}
if (v > 0.8) {
const f = valueNoise(u * 58, v * 58, SCENE_SEED + 13);
if (f > 0.9) {
c = f > 0.95 ? PINK : BUTTER;
}
}
return c;
}
// ── 조각 자르기 — 이웃과 나눠 쓰는 곡선 ───────────────────────
// 칸 사이 변 하나를 3차 곡선 다섯 마디로 만든다: 어깨 → 목 → 혹 위 → 목 → 어깨.
// 변 시작점 A 에서 끝점 B 로 가는 방향을 u(0~1), 그 방향을 90도 돌린 쪽을 v 로 두고 (u, v) 를 변 길이 L 단위로 적는다.
// v 에 탭 부호(+1/−1)를 곱해 혹이 어느 이웃 쪽으로 나오는지 정한다
const PUZZLE_SEED = 486;
// 혹의 지름 = 변 길이 × TAB_SIZE, 혹의 높이 = 지름 × TAB_HEIGHT
const TAB_SIZE = 0.2;
const TAB_HEIGHT = 1.0;
// 목 폭 = 혹 반지름 × NECK_RATIO — 1 보다 작아야 혹이 목보다 넓어 빠지지 않는다
const NECK_RATIO = 0.62;
// 흔들림 — 혹 자리(변 가운데에서 ± 변 길이 몫), 혹 크기 ± 몫
const EDGE_WOBBLE = 0.06;
const KNOB_JITTER = 0.12;
// 3차 곡선 사슬 — [x0, y0, c1x, c1y, c2x, c2y, x1, y1, c1x, …]. 마디 끝점이 다음 마디 시작점이다
function tabEdge(ax, ay, bx, by, sign, rng, tabSize) {
const dx = bx - ax;
const dy = by - ay;
const c = 0.5 + spread(rng) * EDGE_WOBBLE;
const kr = tabSize / 2 * (1 + spread(rng) * KNOB_JITTER);
const nw = kr * NECK_RATIO;
const h = tabSize * TAB_HEIGHT * (1 + spread(rng) * KNOB_JITTER);
const j = spread(rng) * EDGE_WOBBLE * 0.5;
const left = c - nw;
const right = 1 - c - nw;
const uv = [
0, 0,
left * 0.35, j, left * 0.75, -j, c - nw, 0,
c - nw * 0.55, h * 0.28, c - kr * 1.12, h * 0.38, c - kr, h * 0.66,
c - kr * 0.9, h * 1.12, c + kr * 0.9, h * 1.12, c + kr, h * 0.66,
c + kr * 1.12, h * 0.38, c + nw * 0.55, h * 0.28, c + nw, 0,
c + nw + right * 0.25, j, c + nw + right * 0.65, -j, 1, 0
];
const out = [];
for (let k = 0; k < uv.length; k += 2) {
const u = uv[k];
const v = uv[k + 1] * sign;
// 방향 (dx, dy) 를 화면 좌표에서 90도 돌린 쪽 = (−dy, dx)
out.push(ax + u * dx - v * dy, ay + u * dy + v * dx);
}
return out;
}
// 바깥 테두리 — 곧은 변 한 마디(조절점을 1/3·2/3 자리에)
function flatEdge(ax, ay, bx, by) {
return [ax, ay, lerp(ax, bx, 1 / 3), lerp(ay, by, 1 / 3), lerp(ax, bx, 2 / 3), lerp(ay, by, 2 / 3), bx, by];
}
// 사슬 뒤집기 — 점 순서만 거꾸로. 3차 곡선 한 마디 (p0, c1, c2, p1) 는 (p1, c2, c1, p0) 로 같은 곡선이라 어긋남이 0 이다
function reverseChain(pts) {
const out = [];
for (let k = pts.length - 2; k >= 0; k -= 2) {
out.push(pts[k], pts[k + 1]);
}
return out;
}
// 조각 순서 = 위 → 오른쪽 → 아래 → 왼쪽, 시계 방향. 위 변과 오른쪽 변은 사슬을 그대로, 아래·왼쪽 변은 뒤집어 쓴다.
// 가로 변은 왼→오, 세로 변은 위→아래로 한 번만 만든다. 그래서 같은 변을 한쪽은 그대로, 한쪽은 뒤집어 쓰고,
// 혹이 한쪽엔 바깥(+), 한쪽엔 안(−)이 된다 — 그대로 쓰는 조각의 탭 부호 = −sign, 뒤집어 쓰는 조각 = +sign
function makePuzzle(cols, rows, size, bx, by, seed, tabSize) {
const rng = makeRng(seed);
const hEdges = [];
const hSign = [];
const vEdges = [];
const vSign = [];
const signs = [];
for (let r = 0; r <= rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const ax = bx + c * size;
const ay = by + r * size;
if (r === 0 || r === rows) {
hEdges.push(flatEdge(ax, ay, ax + size, ay));
hSign.push(0);
continue;
}
const s = rng() < 0.5 ? 1 : -1;
hEdges.push(tabEdge(ax, ay, ax + size, ay, s, rng, tabSize));
hSign.push(s);
signs.push(s);
}
}
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c <= cols; c += 1) {
const ax = bx + c * size;
const ay = by + r * size;
if (c === 0 || c === cols) {
vEdges.push(flatEdge(ax, ay, ax, ay + size));
vSign.push(0);
continue;
}
const s = rng() < 0.5 ? 1 : -1;
vEdges.push(tabEdge(ax, ay, ax, ay + size, s, rng, tabSize));
vSign.push(s);
signs.push(s);
}
}
const defs = [];
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const top = r * cols + c;
const bottom = (r + 1) * cols + c;
const left = r * (cols + 1) + c;
const right = r * (cols + 1) + c + 1;
const sides = [
{ pts: hEdges[top], tab: -hSign[top] },
{ pts: vEdges[right], tab: -vSign[right] },
{ pts: reverseChain(hEdges[bottom]), tab: hSign[bottom] },
{ pts: reverseChain(vEdges[left]), tab: vSign[left] }
];
const hx = bx + (c + 0.5) * size;
const hy = by + (r + 0.5) * size;
let flat = 0;
const local = [];
for (const sd of sides) {
if (sd.tab === 0) {
flat += 1;
}
const l = [];
for (let k = 0; k < sd.pts.length; k += 2) {
l.push(sd.pts[k] - hx, sd.pts[k + 1] - hy);
}
local.push(l);
}
defs.push({ id: r * cols + c, col: c, row: r, hx: hx, hy: hy, sides: sides, flat: flat, local: local });
}
}
return { cols: cols, rows: rows, size: size, bx: bx, by: by, defs: defs, signs: signs };
}
// 3차 곡선 한 마디의 점 — t 0~1
function cubicAt(p, k, t) {
const u = 1 - t;
const a = u * u * u;
const b = 3 * u * u * t;
const c = 3 * u * t * t;
const d = t * t * t;
return [a * p[k] + b * p[k + 2] + c * p[k + 4] + d * p[k + 6], a * p[k + 1] + b * p[k + 3] + c * p[k + 5] + d * p[k + 7]];
}
// 사슬을 꺾은선으로 — 마디마다 steps 등분
function flattenChain(pts, steps) {
const out = [pts[0], pts[1]];
for (let k = 0; k + 7 < pts.length; k += 6) {
for (let s = 1; s <= steps; s += 1) {
const p = cubicAt(pts, k, s / steps);
out.push(p[0], p[1]);
}
}
return out;
}
// 조각 하나의 닫힌 꺾은선(조각 가운데 기준) — 네 변을 이어 붙인다
function piecePolygon(d, steps) {
const out = [];
for (const l of d.local) {
const f = flattenChain(l, steps);
for (let k = out.length ? 2 : 0; k < f.length; k += 2) {
out.push(f[k], f[k + 1]);
}
}
return out;
}
// 점이 닫힌 꺾은선 안에 있는가 — 오른쪽으로 쏜 반직선이 변을 몇 번 건너는지(홀수면 안)
function pointInPoly(px, py, poly) {
let inside = false;
const n = poly.length;
for (let i = 0, j = n - 2; i < n; j = i, i += 2) {
const xi = poly[i];
const yi = poly[i + 1];
const xj = poly[j];
const yj = poly[j + 1];
if ((yi > py) !== (yj > py) && px < (xj - xi) * (py - yi) / (yj - yi) + xi) {
inside = !inside;
}
}
return inside;
}
// 꺾은선의 네모 상자 [왼, 위, 오른, 아래]
function polyBox(poly) {
let x0 = Infinity;
let y0 = Infinity;
let x1 = -Infinity;
let y1 = -Infinity;
for (let k = 0; k < poly.length; k += 2) {
x0 = Math.min(x0, poly[k]);
x1 = Math.max(x1, poly[k]);
y0 = Math.min(y0, poly[k + 1]);
y1 = Math.max(y1, poly[k + 1]);
}
return [x0, y0, x1, y1];
}
// ── 대표 하나로 묶기 ─────────────────────────────────────────
// parent[i] 가 자기 자신이면 그 조각이 묶음의 대표. 대표를 찾으며 지나는 칸은 한 칸 건너(할아버지)를 가리키게 고쳐
// 다음 찾기가 짧아진다(경로 줄이기). 두 묶음을 합칠 땐 조각 수가 적은 쪽 대표를 많은 쪽 대표 아래로 넣는다(크기 기준)
let parent = [];
let groupSize = [];
function resetGroups(n) {
parent = [];
groupSize = [];
for (let i = 0; i < n; i += 1) {
parent.push(i);
groupSize.push(1);
}
}
function findRoot(i) {
let x = i;
while (parent[x] !== x) {
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}
function joinGroups(a, b) {
let ra = findRoot(a);
let rb = findRoot(b);
if (ra === rb) {
return ra;
}
if (groupSize[ra] < groupSize[rb]) {
const t = ra;
ra = rb;
rb = t;
}
parent[rb] = ra;
groupSize[ra] += groupSize[rb];
return ra;
}
function groupCount() {
let n = 0;
for (let i = 0; i < parent.length; i += 1) {
if (parent[i] === i) {
n += 1;
}
}
return n;
}
function membersOf(i) {
const r = findRoot(i);
const out = [];
for (let k = 0; k < parent.length; k += 1) {
if (findRoot(k) === r) {
out.push(k);
}
}
return out;
}
let puzzle = makePuzzle(1, 1, 10, 0, 0, 1, TAB_SIZE);
let pieces = [];
// 그리는 순서 — 뒤에 있을수록 위에 그린다
let zOrder = [];
// 마지막 이음이 생긴 장면 시각 — 묶음이 하나가 되면 정해진다(아직이면 −1)
let completeAt = -1;
// 이음 시각 — 조각 i 의 변 s(위·오른·아래·왼)가 이웃과 묶인 장면 시각, 아직이면 −1
let seamJoinedAt = [];
function setupWorld(pz) {
puzzle = pz;
pieces = [];
zOrder = [];
seamJoinedAt = [];
for (const d of pz.defs) {
pieces.push({ x: d.hx, y: d.hy, vx: 0, vy: 0, tx: d.hx, ty: d.hy, a: 0, va: 0, ta: 0, h: 0, vh: 0, th: 0, mvx: 0, mvy: 0, lx: d.hx, ly: d.hy });
zOrder.push(d.id);
seamJoinedAt.push(-1, -1, -1, -1);
}
resetGroups(pz.defs.length);
completeAt = -1;
}
// 퍼즐을 새로 만든 직후 부르는 일 — 화면 쪽이 그림 칠하기·경로 만들기를 끼워 넣는다(계산만 하는 곳에선 아무 일 없음)
let onBoardReady = function () { return; };
function newBoard(cols, rows, size, bx, by, seed) {
setupWorld(makePuzzle(cols, rows, size, bx, by, seed, TAB_SIZE));
onBoardReady();
}
function placeAt(i, x, y, a) {
const p = pieces[i];
p.x = x;
p.y = y;
p.tx = x;
p.ty = y;
p.vx = 0;
p.vy = 0;
p.lx = x;
p.ly = y;
p.a = a;
p.ta = a;
p.va = 0;
}
function raise(i) {
const members = membersOf(i);
zOrder = zOrder.filter(function (k) { return members.indexOf(k) < 0; }).concat(members);
}
// 묶음 째 옮기기 — 조각 i 의 목표가 (x, y) 가 되도록 같은 묶음 전부를 같은 만큼
function moveGroupTo(i, x, y, instant) {
const p = pieces[i];
const dx = x - p.tx;
const dy = y - p.ty;
for (const k of membersOf(i)) {
const q = pieces[k];
q.tx += dx;
q.ty += dy;
if (instant) {
q.x = q.tx;
q.y = q.ty;
q.vx = 0;
q.vy = 0;
}
}
}
// 이웃 — 격자에서 위·오른·아래·왼 칸의 조각 번호, 없으면 −1
function neighborOf(i, side) {
const d = puzzle.defs[i];
const c = d.col + (side === 1 ? 1 : (side === 3 ? -1 : 0));
const r = d.row + (side === 2 ? 1 : (side === 0 ? -1 : 0));
if (c < 0 || r < 0 || c >= puzzle.cols || r >= puzzle.rows) {
return -1;
}
return r * puzzle.cols + c;
}
// ── 吸着 — 거리와 각도 두 문턱 ─────────────────────────────
// 조각 m 이 이웃 n 옆에 바르게 앉을 자리 = n 의 목표 + (m 의 판 자리 − n 의 판 자리). 그 자리까지 거리 d 가 SNAP_RADIUS 안이고
// 두 조각 각의 차가 SNAP_ANGLE 안일 때만 묶음 전체의 목표를 그만큼 옮긴다 — 나머지 거리는 스프링이 당겨 살짝 넘쳤다 앉는다
const SNAP_RADIUS = 14;
const SNAP_ANGLE = 8 * Math.PI / 180;
const SNAP_STIFFNESS = 160;
const SNAP_DAMPING = 14;
// 방향 판정 — 기본은 늘 열림. 04 처럼 조각이 돌아가는 항목은 「바로 섰는가」 를 넣어 바꾼다
let orientOk = function (i) { void i; return true; };
// 묶음 i 의 조각들과, 다른 묶음에 있는 진짜 이웃 사이의 가장 가까운 吸着 후보
function bestSnap(i) {
let best = { m: -1, n: -1, d: Infinity, da: 0, ok: false, gx: 0, gy: 0 };
const root = findRoot(i);
for (const m of membersOf(i)) {
for (let s = 0; s < 4; s += 1) {
const n = neighborOf(m, s);
if (n < 0 || findRoot(n) === root) {
continue;
}
const dm = puzzle.defs[m];
const dn = puzzle.defs[n];
const gx = pieces[n].tx + (dm.hx - dn.hx);
const gy = pieces[n].ty + (dm.hy - dn.hy);
const d = Math.hypot(pieces[m].tx - gx, pieces[m].ty - gy);
const da = wrapAngle(pieces[m].ta - pieces[n].ta);
if (d < best.d) {
best = { m: m, n: n, d: d, da: da, ok: d < SNAP_RADIUS && Math.abs(da) < SNAP_ANGLE && orientOk(m) && orientOk(n), gx: gx, gy: gy };
}
}
}
return best;
}
// 놓았을 때 — 문턱 안이면 묶음 째 목표를 맞는 자리로 옮기고 묶는다. 문턱 밖이면 그 자리에 머문다
function tryJoin(i) {
const s = bestSnap(i);
if (!s.ok) {
return s;
}
const dx = s.gx - pieces[s.m].tx;
const dy = s.gy - pieces[s.m].ty;
// 각은 남은 차(da)만큼만 고친다 — 한 바퀴(360도) 돌아 바로 선 조각이 0 으로 되감기지 않게
for (const k of membersOf(s.m)) {
pieces[k].tx += dx;
pieces[k].ty += dy;
pieces[k].ta -= s.da;
}
joinGroups(s.m, s.n);
markSeams();
return s;
}
// 같은 묶음이 된 이웃 사이 변에 이음 시각을 적는다 — 이음매 선은 그 시각부터 옅어진다
function markSeams() {
for (let i = 0; i < pieces.length; i += 1) {
for (let s = 0; s < 4; s += 1) {
const n = neighborOf(i, s);
if (n >= 0 && seamJoinedAt[i * 4 + s] < 0 && findRoot(n) === findRoot(i)) {
seamJoinedAt[i * 4 + s] = sceneT;
}
}
}
if (completeAt < 0 && groupCount() === 1) {
completeAt = sceneT;
}
}
// 이은 자리 수 = 조각 수 − 묶음 수 (묶음이 하나면 조각 수 − 1)
function joinsMade() { return pieces.length - groupCount(); }
// ── 한 걸음 ──────────────────────────────────────────────────
// 위치·각·높이 세 스프링을 1/240초 잔걸음으로. 다듬은 속도는 걸음 앞뒤 위치 차를 VEL_SMOOTH 비율로 따라간다
const ROT_STIFFNESS = 180;
const ROT_DAMPING = 11;
const LIFT_STIFFNESS = 260;
const LIFT_DAMPING = 15;
const VEL_SMOOTH = 10;
// 들어 올린 높이 1 일 때 그림자가 떨어지는 거리(px)와 흐림(px) — 높이에 비례해 줄고 는다
const SHADOW_OFFSET = 14;
const SHADOW_BLUR = 16;
function stepWorld(dt, stiffness, damping) {
const k = follow(VEL_SMOOTH, dt);
for (const p of pieces) {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
p.vx += (stiffness * (p.tx - p.x) - damping * p.vx) * h;
p.vy += (stiffness * (p.ty - p.y) - damping * p.vy) * h;
p.x += p.vx * h;
p.y += p.vy * h;
p.va += (ROT_STIFFNESS * (p.ta - p.a) - ROT_DAMPING * p.va) * h;
p.a += p.va * h;
p.vh += (LIFT_STIFFNESS * (p.th - p.h) - LIFT_DAMPING * p.vh) * h;
p.h += p.vh * h;
left -= h;
}
// 지난 걸음 끝 위치(lx, ly)와 견준다 — 손이 옮긴 몫과 스프링이 옮긴 몫이 둘 다 들어간다
if (dt > 0) {
p.mvx += ((p.x - p.lx) / dt - p.mvx) * k;
p.mvy += ((p.y - p.ly) / dt - p.mvy) * k;
}
p.lx = p.x;
p.ly = p.y;
}
}
// 스프링을 모두 목표에 앉힌다 — 동작 줄이기에서 입력을 받으면 보간 없이 바로 그 자리
function settleNow() {
for (const p of pieces) {
p.x = p.tx;
p.y = p.ty;
p.vx = 0;
p.vy = 0;
p.a = p.ta;
p.va = 0;
p.h = p.th;
p.vh = 0;
p.mvx = 0;
p.mvy = 0;
p.lx = p.x;
p.ly = p.y;
}
}
// 완성 판 — 모든 조각이 제자리에 있고 하나로 묶인 상태
function assembleAll() {
for (let i = 0; i < pieces.length; i += 1) {
const d = puzzle.defs[i];
placeAt(i, d.hx, d.hy, 0);
pieces[i].h = 0;
pieces[i].th = 0;
pieces[i].vh = 0;
}
for (let i = 1; i < pieces.length; i += 1) {
joinGroups(0, i);
}
markSeams();
}
let carries = [];
let handX = W + 40;
let handY = H * 0.6;
let handDown = false;
let carrying = -1;
const HAND_ARC = 26;
function planCarry(i, t0, t1, x, y, a) {
carries.push({ i: i, t0: t0, t1: t1, x: x, y: y, a: a, anchor: -1, ox: 0, oy: 0, started: false, done: false, sx: 0, sy: 0, sa: 0 });
}
function planCarryNext(i, anchor, t0, t1, ox, oy) {
carries.push({ i: i, t0: t0, t1: t1, x: 0, y: 0, a: 0, anchor: anchor, ox: ox, oy: oy, started: false, done: false, sx: 0, sy: 0, sa: 0 });
}
// 들고 가기 완화 — 앞뒤가 느린 5차 곡선(6t⁵ − 15t⁴ + 10t³)
function easeCarry(t) { const c = clamp(t, 0, 1); return c * c * c * (c * (c * 6 - 15) + 10); }
// 놓는 순간 부르는 일 — 기본은 吸着 판정. 항목이 바꿔 끼운다
let onRelease = function (i) { tryJoin(i); };
let onPick = function (i) { void i; };
function runCarries(t) {
carrying = -1;
handDown = false;
let lastEnd = null;
let next = null;
for (const c of carries) {
if (c.done) {
lastEnd = c;
continue;
}
if (t < c.t0) {
if (!next) {
next = c;
}
continue;
}
if (!c.started) {
c.started = true;
const p = pieces[c.i];
c.sx = p.tx;
c.sy = p.ty;
c.sa = p.ta;
if (c.anchor >= 0) {
const dm = puzzle.defs[c.i];
const dn = puzzle.defs[c.anchor];
c.x = pieces[c.anchor].tx + dm.hx - dn.hx + c.ox;
c.y = pieces[c.anchor].ty + dm.hy - dn.hy + c.oy;
c.a = pieces[c.anchor].ta;
}
raise(c.i);
onPick(c.i);
}
const k = (t - c.t0) / Math.max(1e-6, c.t1 - c.t0);
const e = easeCarry(k);
const arc = Math.sin(Math.PI * clamp(k, 0, 1)) * HAND_ARC;
if (k >= 1) {
moveGroupTo(c.i, c.x, c.y, true);
pieces[c.i].ta = c.a;
c.done = true;
onRelease(c.i);
lastEnd = c;
continue;
}
moveGroupTo(c.i, lerp(c.sx, c.x, e), lerp(c.sy, c.y, e) - arc, true);
pieces[c.i].ta = lerp(c.sa, c.a, e);
carrying = c.i;
handDown = true;
handX = pieces[c.i].x + 6;
handY = pieces[c.i].y + 4;
return;
}
// 들고 있지 않을 때 — 지난 놓은 자리에서 다음 집을 조각 쪽으로 손이 옮겨 간다
if (next) {
const from = lastEnd ? [pieces[lastEnd.i].x + 6, pieces[lastEnd.i].y + 4] : [W + 40, H * 0.6];
const t0 = lastEnd ? lastEnd.t1 : 0;
const k = easeCarry((t - t0) / Math.max(1e-6, next.t0 - t0));
const target = [pieces[next.i].x + 6, pieces[next.i].y + 4];
handX = lerp(from[0], target[0], k);
handY = lerp(from[1], target[1], k);
}
else if (lastEnd) {
handX = lerp(handX, W + 40, 0.04);
handY = lerp(handY, H * 0.7, 0.04);
}
}
function resetCarries() { carries = []; carrying = -1; handDown = false; handX = W + 40; handY = H * 0.6; }
// ── 쟁반 — 겹친 깊이만큼 밀어내기 ────────────────────────────
// 조각을 반지름 TRAY_R 원으로 보고, 겹친 쌍마다 가운데를 잇는 방향으로 겹친 깊이의 절반씩 서로 민다.
// 판 상자(여백 TRAY_MARGIN 포함) 안에 들어온 원은 가장 가까운 판 바깥 변으로 투영하고, 가운데가 캔버스 가장자리에서 반지름의 0.7 배 안쪽에 머물게 자른다.
// 한 번에 다 풀리지 않으므로 여러 번 되풀이한다 — 되풀이할수록 남은 겹침 합이 준다
const TRAY_MARGIN = 6;
function relaxOnce(ids, radius, bx0, by0, bx1, by1) {
let overlap = 0;
for (let a = 0; a < ids.length; a += 1) {
for (let b = a + 1; b < ids.length; b += 1) {
const p = pieces[ids[a]];
const q = pieces[ids[b]];
let dx = q.tx - p.tx;
let dy = q.ty - p.ty;
let d = Math.hypot(dx, dy);
const depth = radius * 2 - d;
if (depth <= 0) {
continue;
}
overlap += depth;
if (d < 1e-6) {
dx = 1;
dy = 0;
d = 1;
}
const push = depth / 2 / d;
p.tx -= dx * push;
p.ty -= dy * push;
q.tx += dx * push;
q.ty += dy * push;
}
}
for (const i of ids) {
const p = pieces[i];
const x0 = bx0 - radius - TRAY_MARGIN;
const x1 = bx1 + radius + TRAY_MARGIN;
const y0 = by0 - radius - TRAY_MARGIN;
const y1 = by1 + radius + TRAY_MARGIN;
if (p.tx > x0 && p.tx < x1 && p.ty > y0 && p.ty < y1) {
// 판 안 — 네 변 중 가장 가까운 쪽으로 밀어낸다
const dl = p.tx - x0;
const dr = x1 - p.tx;
const du = p.ty - y0;
const dd = y1 - p.ty;
const m = Math.min(dl, dr, du, dd);
overlap += m;
if (m === dl) {
p.tx = x0;
}
else if (m === dr) {
p.tx = x1;
}
else if (m === du) {
p.ty = y0;
}
else {
p.ty = y1;
}
}
p.tx = clamp(p.tx, radius * 0.7, W - radius * 0.7);
p.ty = clamp(p.ty, radius * 0.7, H - radius * 0.7);
}
return overlap;
}
// ── 자동 풀이 — 곧은 변 수와 변 색 차로 점수 ─────────────────
// 곧은 변 2개 = 角, 1개 = 辺, 0개 = 内側. 칸 하나에 들어갈 후보 점수 = 이미 놓인 이웃마다
// (탭이 마주 보지 않으면 TAB_MISMATCH_COST) + (두 변을 따라 안쪽으로 COLOR_INSET 들어간 점의 색 차 평균 / 255 × COLOR_WEIGHT).
// 곧은 변 자리가 칸의 바깥 테두리와 다르면 그 칸에는 넣지 않는다. 점수가 가장 낮은 조각을 고른다
const COLOR_SAMPLES = 9;
const COLOR_INSET = 2.5;
const COLOR_WEIGHT = 4;
const TAB_MISMATCH_COST = 3;
// 칸 순서 — 바깥 테두리를 시계 방향으로 한 바퀴 돈 뒤 안쪽을 줄 순서로
function frameFirstOrder(cols, rows) {
const out = [];
for (let c = 0; c < cols; c += 1) {
out.push(c);
}
for (let r = 1; r < rows; r += 1) {
out.push(r * cols + cols - 1);
}
for (let c = cols - 2; c >= 0; c -= 1) {
out.push((rows - 1) * cols + c);
}
for (let r = rows - 2; r >= 1; r -= 1) {
out.push(r * cols);
}
for (let r = 1; r < rows - 1; r += 1) {
for (let c = 1; c < cols - 1; c += 1) {
out.push(r * cols + c);
}
}
return out;
}
function pieceKind(d) { return d.flat; }
// 칸 slot 의 바깥 테두리 패턴 — 위·오른·아래·왼 중 판 끝인 변이 곧은 변이어야 한다
function fitsBorder(d, slot, cols, rows) {
const c = slot % cols;
const r = Math.floor(slot / cols);
const edge = [r === 0, c === cols - 1, r === rows - 1, c === 0];
for (let s = 0; s < 4; s += 1) {
if ((d.sides[s].tab === 0) !== edge[s]) {
return false;
}
}
return true;
}
// 조각 d 의 변 side 를 따라 안쪽으로 들어간 점들의 색 — 조각 그림은 원래 판 자리(hx, hy)에서 잘라 온 것이다
function sideColors(d, side, size, sample) {
const out = [];
const half = size / 2;
for (let k = 0; k < COLOR_SAMPLES; k += 1) {
const u = (k + 0.5) / COLOR_SAMPLES - 0.5;
let x = 0;
let y = 0;
if (side === 0) {
x = u * size;
y = -half + COLOR_INSET;
}
if (side === 1) {
x = half - COLOR_INSET;
y = u * size;
}
if (side === 2) {
x = u * size;
y = half - COLOR_INSET;
}
if (side === 3) {
x = -half + COLOR_INSET;
y = u * size;
}
out.push(sample(d.hx + x, d.hy + y));
}
return out;
}
function colorGap(a, b) {
let s = 0;
for (let k = 0; k < a.length; k += 1) {
s += (Math.abs(a[k][0] - b[k][0]) + Math.abs(a[k][1] - b[k][1]) + Math.abs(a[k][2] - b[k][2])) / 3;
}
return s / a.length;
}
// 칸 slot 에 조각 cand 를 넣을 때의 점수. placedAt[slot'] = 그 칸에 놓인 조각 번호(없으면 −1)
function slotScore(cand, slot, placedAt, sample) {
const pz = puzzle;
const d = pz.defs[cand];
if (!fitsBorder(d, slot, pz.cols, pz.rows)) {
return Infinity;
}
const c = slot % pz.cols;
const r = Math.floor(slot / pz.cols);
let score = 0;
const dirs = [[0, -1], [1, 0], [0, 1], [-1, 0]];
for (let s = 0; s < 4; s += 1) {
const nc = c + dirs[s][0];
const nr = r + dirs[s][1];
if (nc < 0 || nr < 0 || nc >= pz.cols || nr >= pz.rows) {
continue;
}
const q = placedAt[nr * pz.cols + nc];
if (q < 0) {
continue;
}
const facing = (s + 2) % 4;
const qd = pz.defs[q];
if (d.sides[s].tab + qd.sides[facing].tab !== 0 || d.sides[s].tab === 0) {
score += TAB_MISMATCH_COST;
}
score += colorGap(sideColors(d, s, pz.size, sample), sideColors(qd, facing, pz.size, sample)) / 255 * COLOR_WEIGHT;
}
return score;
}
// 식으로 읽기 — 그림을 칠하는 식을 배율 1 화소 가운데에서 바로 부르고 0~255 정수로 자른다(DOM 없이 셀 때).
// 화면에서는 칠한 그림을 getImageData 로 읽는 함수로 바뀐다(dom 쪽)
function formulaSampler() {
const pz = puzzle;
const w = Math.round(pz.cols * pz.size);
const h = Math.round(pz.rows * pz.size);
return function (x, y) {
const px = clamp(Math.floor(x - pz.bx), 0, w - 1);
const py = clamp(Math.floor(y - pz.by), 0, h - 1);
const c = sceneryColor((px + 0.5) / w, (py + 0.5) / h);
return [Math.round(c[0]), Math.round(c[1]), Math.round(c[2])];
};
}
let makeSampler = formulaSampler;
// 테두리부터 — 칸 순서대로 남은 조각 중 점수가 가장 낮은 것을 놓는다. 단계마다 후보 점수 표도 남긴다(열지도용)
function solveFrameFirst(sample) {
const pz = puzzle;
const n = pz.defs.length;
const placedAt = [];
const used = [];
for (let k = 0; k < n; k += 1) {
placedAt.push(-1);
used.push(false);
}
const steps = [];
for (const slot of frameFirstOrder(pz.cols, pz.rows)) {
const scores = [];
let best = -1;
let bestScore = Infinity;
for (let k = 0; k < n; k += 1) {
const sc = used[k] ? Infinity : slotScore(k, slot, placedAt, sample);
scores.push(sc);
if (sc < bestScore) {
bestScore = sc;
best = k;
}
}
if (best < 0) {
break;
}
used[best] = true;
placedAt[slot] = best;
steps.push({ slot: slot, piece: best, scores: scores });
}
return steps;
}
// 사람이 처음 판을 만진 뒤 — 흉내 낸 손을 멈추고 끌기·키 입력만 받는다
let handsOn = false;
function fmt(s, vals) {
let out = s;
for (const k of Object.keys(vals)) {
out = out.split('{' + k + '}').join(String(vals[k]));
}
return out;
}
// 사람이 쓰는 동안의 상태 줄 — 残りのまとまり 수와 이은 자리 수
const STATUS_FREE = '残りのまとまり {g}・つないだ所 {j}/{n}';
const STATUS_DONE = '完成 — ピース {n}個が一つのまとまり';
function freeStatus() {
simStatus = groupCount() === 1 ? fmt(STATUS_DONE, { n: pieces.length }) : fmt(STATUS_FREE, { g: groupCount(), j: joinsMade(), n: pieces.length - 1 });
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 화소 배율 — 캔버스 내부 크기에 곱한다. 2 에서 자른다(배율 3 기기에서 화소가 2.25배로 늘지 않게)
let dpr = 1;
function fitCanvas() {
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);
}
// ── 그림과 조각 경로 ─────────────────────────────────────────
// 풍경은 판 크기 × 배율의 숨은 캔버스에 화소 하나씩 한 번만 칠해 두고, 조각마다 자기 경로로 잘라(clip) 그 위치에 붙인다
const pic = document.createElement('canvas');
const picCtx = pic.getContext('2d');
let picDpr = 0;
let picKey = '';
function paintPicture() {
const pz = puzzle;
const key = pz.cols + 'x' + pz.rows + '@' + pz.size + '/' + dpr;
if (!picCtx || key === picKey) {
return;
}
picKey = key;
picDpr = dpr;
const w = Math.max(1, Math.round(pz.cols * pz.size * dpr));
const h = Math.max(1, Math.round(pz.rows * pz.size * dpr));
pic.width = w;
pic.height = h;
const img = picCtx.createImageData(w, h);
for (let y = 0; y < h; y += 1) {
for (let x = 0; x < w; x += 1) {
const c = sceneryColor((x + 0.5) / w, (y + 0.5) / h);
const k = (y * w + x) * 4;
img.data[k] = c[0];
img.data[k + 1] = c[1];
img.data[k + 2] = c[2];
img.data[k + 3] = 255;
}
}
picCtx.putImageData(img, 0, 0);
}
// 칠한 그림을 getImageData 로 다시 읽어 색 하나를 돌려주는 함수 — 같은 페이지에서 그린 그림이라 막히지 않는다.
// 프레임마다 읽지 않고 그림을 새로 칠했을 때 한 번만 읽는다
function pictureSampler() {
const pz = puzzle;
const data = picCtx ? picCtx.getImageData(0, 0, pic.width, pic.height).data : new Uint8ClampedArray(4);
const w = pic.width;
const h = pic.height;
return function (x, y) {
const px = clamp(Math.floor((x - pz.bx) * picDpr), 0, w - 1);
const py = clamp(Math.floor((y - pz.by) * picDpr), 0, h - 1);
const k = (py * w + px) * 4;
return [data[k], data[k + 1], data[k + 2]];
};
}
// 조각 경로 — 조각 가운데 기준. 네 변 사슬을 이어 하나의 닫힌 경로로, 변마다 따로 그을 경로도 함께
let paths = [];
let sidePaths = [];
// 3차 곡선 사슬을 마디마다 CURVE_STEPS 등분한 꺾은선으로 잇는다 — 계산 쪽 pointInPoly 와 화면의 isPointInPath 가 같은 모양을 본다
const CURVE_STEPS = 12;
function chainInto(p, l, move) {
const f = flattenChain(l, CURVE_STEPS);
if (move) {
p.moveTo(f[0], f[1]);
}
for (let k = 2; k < f.length; k += 2) {
p.lineTo(f[k], f[k + 1]);
}
}
function buildPaths() {
paths = [];
sidePaths = [];
for (const d of puzzle.defs) {
const p = new Path2D();
d.local.forEach(function (l, s) { chainInto(p, l, s === 0); });
p.closePath();
paths.push(p);
sidePaths.push(d.local.map(function (l) { const q = new Path2D(); chainInto(q, l, true); return q; }));
}
}
// 판·그림·경로를 지금 퍼즐에 맞게 — 항목의 reset 이 퍼즐을 새로 만들면 부른다
function prepareBoard() {
paintPicture();
buildPaths();
}
onBoardReady = prepareBoard;
makeSampler = pictureSampler;
// 이음매 선 투명도 — 항목이 바꿔 끼운다. 기본은 늘 진하게
let seamAlpha = function (i, s) { void i; void s; return 1; };
const PLAIN = { dx: 0, dy: 0, scale: 1, skewX: 0, skewY: 0, alpha: 1, shade: 1 };
// 들어 올린 높이 h 에 비례하는 그림자 — 오프셋 SHADOW_OFFSET·흐림 SHADOW_BLUR(core)를 h 배. 그림자 값은 변환을 안 타므로 배율을 직접 곱한다
function drawPiece(i, look) {
const p = pieces[i];
const d = puzzle.defs[i];
const pz = puzzle;
const h = Math.max(0, p.h);
ctx.save();
ctx.globalAlpha = look.alpha;
ctx.translate(p.x + look.dx, p.y + look.dy);
ctx.rotate(p.a);
ctx.transform(1, look.skewY, look.skewX, 1, 0, 0);
ctx.scale(look.scale, look.scale);
ctx.save();
ctx.shadowColor = rgba(INK, (0.22 + 0.1 * clamp(h, 0, 1)) * look.shade);
ctx.shadowBlur = (2 + SHADOW_BLUR * h) * dpr;
ctx.shadowOffsetX = (1 + SHADOW_OFFSET * 0.45 * h) * dpr;
ctx.shadowOffsetY = (1.5 + SHADOW_OFFSET * h) * dpr;
ctx.fillStyle = rgba(PAPER_DIM, 1);
ctx.fill(paths[i]);
ctx.restore();
ctx.save();
ctx.clip(paths[i]);
ctx.drawImage(pic, pz.bx - d.hx, pz.by - d.hy, pz.cols * pz.size, pz.rows * pz.size);
ctx.restore();
ctx.lineJoin = 'round';
for (let s = 0; s < 4; s += 1) {
const a = seamAlpha(i, s);
if (a <= 0.01) {
continue;
}
ctx.lineWidth = 1.3;
ctx.strokeStyle = rgba(INK, 0.62 * a);
ctx.stroke(sidePaths[i][s]);
}
ctx.restore();
}
function drawAllPieces(lookOf) {
for (const i of zOrder) {
drawPiece(i, lookOf(i));
}
}
// 판 자리 — 빈 판의 점선 테두리와 옅은 바탕
function drawBoardFrame(a) {
const pz = puzzle;
const w = pz.cols * pz.size;
const h = pz.rows * pz.size;
fillRound(pz.bx - 4, pz.by - 4, w + 8, h + 8, 6, PAPER_DIM, 0.75 * a);
ctx.setLineDash([5, 5]);
ctx.lineWidth = 1.4;
ctx.strokeStyle = rgba(INK_SOFT, 0.7 * a);
ctx.strokeRect(pz.bx, pz.by, w, h);
ctx.setLineDash([]);
}
function clearStage(c) {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function setFont(px, weight) { ctx.font = weight + ' ' + px.toFixed(2) + 'px ' + FONT_STACK; }
function label(s, x, y, c, a, px, weight, align) {
setFont(px, weight);
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
function roundRectPath(x, y, w, h, r) {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
function fillRound(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
roundRectPath(x, y, w, h, r);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function line(x0, y0, x1, y1, width, c, a) {
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
// 작은 알약 이름표
function pill(s, x, y, bg, fg, a, px) {
setFont(px, 700);
const w = ctx.measureText(s).width + px * 1.2;
fillRound(x - w / 2, y - px * 0.85, w, px * 1.7, px * 0.85, bg, a);
label(s, x, y + 0.5, fg, a, px, 700, 'center');
}
// 흉내 낸 손 — 둥근 손끝과 짧은 화살. 누르는 중이면 손끝 둘레에 고리
function drawHand(x, y, down) {
if (x > W + 20 || handsOn) {
return;
}
ctx.save();
ctx.translate(x, y);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, 17);
ctx.lineTo(4.5, 13);
ctx.lineTo(8, 20);
ctx.lineTo(11, 18.5);
ctx.lineTo(7.5, 11.8);
ctx.lineTo(13, 11.5);
ctx.closePath();
ctx.fillStyle = rgba(CREAM, 1);
ctx.fill();
ctx.lineWidth = 1.6;
ctx.strokeStyle = rgba(INK, 1);
ctx.stroke();
if (down) {
ring(0, 0, 7, 2, VIOLET_DEEP, 0.8);
}
ctx.restore();
}
// ── 고르기 — 경로 판정 ───────────────────────────────────────
// 위에 그린 조각부터, 점을 조각 자기 좌표로 되돌린 뒤 isPointInPath 로 경로 안인지 묻는다(변환은 단위 행렬로 두고)
function toLocal(i, x, y) {
const p = pieces[i];
const c = Math.cos(-p.a);
const s = Math.sin(-p.a);
const dx = x - p.x;
const dy = y - p.y;
return [dx * c - dy * s, dx * s + dy * c];
}
function insidePiece(i, x, y) {
const l = toLocal(i, x, y);
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
const hit = ctx.isPointInPath(paths[i], l[0], l[1]);
ctx.restore();
return hit;
}
function pickAt(x, y) {
for (let k = zOrder.length - 1; k >= 0; k -= 1) {
if (insidePiece(zOrder[k], x, y)) {
return zOrder[k];
}
}
return -1;
}
// ── 사람 손 — 끌기와 키 ──────────────────────────────────────
// 끌기는 포인터 이벤트(마우스·펜·손가락 한 갈래). 키보드는 캔버스에 초점이 있을 때만:
// Enter·Space 다음 묶음 고르기 · 화살표 4px(Shift 16px) 옮기기 · R 90도 돌리기(돌리는 항목만) · Escape 고르기 풀기
let dragging = -1;
let dragOx = 0;
let dragOy = 0;
let selected = -1;
let focused = false;
// 돌리기를 받는 항목인가 — 04 만 true 로 바꾼다
let allowTurn = false;
let onTurn = function (i) { void i; };
function eventPoint(e) {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / Math.max(1, r.width), (e.clientY - r.top) * H / Math.max(1, r.height)];
}
// 입력 뒤 — 동작 줄이기면 스프링 없이 바로 그 자리에. 루프가 멈춰 있으면(동작 줄이기·一時停止) 상태 줄과 화면을 한 번 바로 고친다
function afterInput() {
if (still) {
settleNow();
}
if (still || paused) {
freeStatus();
draw();
pushStatus();
}
}
function nextGroup() {
const roots = [];
for (let k = zOrder.length - 1; k >= 0; k -= 1) {
const r = findRoot(zOrder[k]);
if (roots.indexOf(r) < 0) {
roots.push(r);
}
}
if (!roots.length) {
return -1;
}
const cur = selected >= 0 ? roots.indexOf(findRoot(selected)) : -1;
return roots[(cur + 1) % roots.length];
}
function bindInput(signal) {
cv.addEventListener('pointerdown', function (e) {
const pt = eventPoint(e);
const i = pickAt(pt[0], pt[1]);
if (i < 0) {
return;
}
takeOver();
dragging = i;
selected = i;
dragOx = pieces[i].tx - pt[0];
dragOy = pieces[i].ty - pt[1];
raise(i);
cv.setPointerCapture(e.pointerId);
onPick(i);
afterInput();
}, { signal: signal });
cv.addEventListener('pointermove', function (e) {
if (dragging < 0) {
return;
}
const pt = eventPoint(e);
moveGroupTo(dragging, pt[0] + dragOx, pt[1] + dragOy, true);
if (still || paused) {
draw();
}
}, { signal: signal });
const end = function () {
if (dragging < 0) {
return;
}
const i = dragging;
dragging = -1;
onRelease(i);
afterInput();
};
cv.addEventListener('pointerup', end, { signal: signal });
cv.addEventListener('pointercancel', end, { signal: signal });
cv.addEventListener('focus', function () { focused = true; if (still || paused) {
draw();
} }, { signal: signal });
cv.addEventListener('blur', function () { focused = false; if (still || paused) {
draw();
} }, { signal: signal });
cv.addEventListener('keydown', function (e) {
const step = e.shiftKey ? 16 : 4;
let dx = 0;
let dy = 0;
if (e.key === 'ArrowLeft') {
dx = -step;
}
else if (e.key === 'ArrowRight') {
dx = step;
}
else if (e.key === 'ArrowUp') {
dy = -step;
}
else if (e.key === 'ArrowDown') {
dy = step;
}
else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
takeOver();
selected = nextGroup();
if (selected >= 0) {
raise(selected);
}
afterInput();
return;
}
else if (e.key === 'Escape') {
selected = -1;
afterInput();
return;
}
else if ((e.key === 'r' || e.key === 'R') && allowTurn && selected >= 0 && groupSize[findRoot(selected)] === 1) {
e.preventDefault();
takeOver();
pieces[selected].ta += Math.PI / 2;
onTurn(selected);
afterInput();
return;
}
else {
return;
}
e.preventDefault();
takeOver();
if (selected < 0) {
selected = nextGroup();
}
if (selected < 0) {
return;
}
moveGroupTo(selected, pieces[selected].tx + dx, pieces[selected].ty + dy, true);
onRelease(selected);
afterInput();
}, { signal: signal });
}
// 사람이 쓰는 동안 — 끄는 묶음은 들어 올리고 나머지는 내려놓는다. 스프링은 吸着 값 그대로
function freeStep(dt) {
const lifted = dragging >= 0 ? findRoot(dragging) : -1;
for (let i = 0; i < pieces.length; i += 1) {
pieces[i].th = findRoot(i) === lifted ? 1 : 0;
}
stepWorld(dt, SNAP_STIFFNESS, SNAP_DAMPING);
freeStatus();
}
// 고른 묶음 — 캔버스에 초점이 있을 때 점선으로 둘러 보인다
function drawSelection() {
if (!focused || selected < 0) {
return;
}
for (const k of membersOf(selected)) {
const p = pieces[k];
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.a);
ctx.setLineDash([4, 3]);
ctx.lineWidth = 2.4;
ctx.strokeStyle = rgba(VIOLET_DEEP, 1);
ctx.stroke(paths[k]);
ctx.restore();
}
ctx.setLineDash([]);
}
// 02 — 거리와 각도 문턱 안에서만 당겨지는 吸着. 판 3×3 중 오른쪽 가운데 조각(MOVER) 하나만 떨어져 있고 나머지 여덟은 한 묶음.
// 흉내 낸 손이 세 번 가져다 놓는다: ① 거의 붙였지만 18도 돌아감 → 각도 しきい値の外 ② 바로 섰지만 약 37px 떨어짐 → 거리 しきい値の外
// ③ 약 11px·3도 → 두 문턱 안이라 스프링(SNAP_STIFFNESS·SNAP_DAMPING)이 남은 거리를 당겨 살짝 넘쳤다 앉는다
const COLS = 3;
const ROWS = 3;
const PIECE = 62;
const BOARD_X = 48;
const BOARD_Y = 46;
const MOVER = 5;
const TRAY_X = 396;
const TRAY_Y = 196;
// 시도 셋 — [시작 초, 끝 초, 바른 자리에서 x 어긋남, y 어긋남, 각(도)]
const ATTEMPTS = [[0.2, 0.7, 5, -4, 18], [0.85, 1.3, 34, 14, 0], [1.45, 1.85, 9, 7, 3]];
const LOOP_S = 3.4;
const STATUS_MOVE = '距離 {d}px・角度の差 {a}°';
const STATUS_MISS = 'しきい値の外 — 距離 {d}px・角度の差 {a}°・その場に置く';
const STATUS_SNAP = '吸着 — 距離 {d}px・角度の差 {a}°・ばねが引き寄せる';
// 마지막으로 놓은 순간의 판정과 시각, 맞물린 뒤 바른 자리까지의 부호 있는 거리 기록
let lastCheck = { m: -1, n: -1, d: 0, da: 0, ok: false, gx: 0, gy: 0 };
let lastCheckAt = -1;
let snapTrace = [];
let snapDir = [1, 0];
function slotX() { return puzzle.defs[MOVER].hx; }
function slotY() { return puzzle.defs[MOVER].hy; }
function reset() {
newBoard(COLS, ROWS, PIECE, BOARD_X, BOARD_Y, PUZZLE_SEED);
for (let i = 0; i < pieces.length; i += 1) {
if (i !== MOVER && i !== 0) {
joinGroups(0, i);
}
}
markSeams();
placeAt(MOVER, TRAY_X, TRAY_Y, -0.5);
resetCarries();
for (const a of ATTEMPTS) {
planCarry(MOVER, a[0], a[1], slotX() + a[2], slotY() + a[3], a[4] * Math.PI / 180);
}
lastCheck = { m: -1, n: -1, d: 0, da: 0, ok: false, gx: 0, gy: 0 };
lastCheckAt = -1;
snapTrace = [];
onRelease = function (i) {
const c = tryJoin(i);
lastCheck = c;
lastCheckAt = sceneT;
if (c.ok) {
const dx = pieces[i].x - pieces[i].tx;
const dy = pieces[i].y - pieces[i].ty;
const len = Math.max(1e-6, Math.hypot(dx, dy));
snapDir = [dx / len, dy / len];
snapTrace = [];
}
};
}
// 바른 자리까지의 부호 있는 거리 — 놓은 쪽을 +, 지나쳐 반대쪽으로 넘어가면 −
function signedOffset() {
const p = pieces[MOVER];
return (p.x - slotX()) * snapDir[0] + (p.y - slotY()) * snapDir[1];
}
function update(dt) {
runCarries(sceneT);
for (const p of pieces) {
p.th = 0;
}
if (carrying >= 0) {
pieces[carrying].th = 1;
}
stepWorld(dt, SNAP_STIFFNESS, SNAP_DAMPING);
if (lastCheck.ok && sceneT - lastCheckAt < 1.4) {
snapTrace.push([sceneT - lastCheckAt, signedOffset()]);
}
const live = bestSnap(MOVER);
if (carrying >= 0) {
simStatus = fmt(STATUS_MOVE, { d: Math.round(live.d), a: Math.round(Math.abs(deg(live.da))) });
}
else if (lastCheckAt >= 0) {
simStatus = fmt(lastCheck.ok ? STATUS_SNAP : STATUS_MISS, { d: Math.round(lastCheck.d), a: Math.round(Math.abs(deg(lastCheck.da))) });
}
}
function restPose() {
assembleAll();
resetCarries();
simStatus = fmt(STATUS_DONE, { n: pieces.length });
}
// 02 화면 — 빈 자리의 점선 윤곽과 SNAP_RADIUS 원, 조각 가운데에서 바른 자리까지의 선, 놓은 순간의 판정 이름표,
// 오른쪽 위에는 맞물린 뒤 바른 자리까지 남은 거리(부호 있음)를 시간에 따라 그린 작은 그래프 — 0 아래로 내려간 만큼이 넘침
const PLOT_X = 300;
const PLOT_Y = 24;
const PLOT_W = 166;
const PLOT_H = 92;
const PLOT_S = 1.4;
const PLOT_PX = 14;
const LABEL_PLOT = '吸着後の残り距離 (px)';
const LABEL_MISS = 'しきい値の外';
const LABEL_SNAP = '吸着';
const LABEL_RADIUS = 'SNAP_RADIUS {r}px · SNAP_ANGLE {a}°';
function drawSlotGhost() {
ctx.save();
ctx.translate(slotX(), slotY());
ctx.setLineDash([4, 4]);
ctx.lineWidth = 1.4;
ctx.strokeStyle = rgba(INK_SOFT, 0.9);
ctx.fillStyle = rgba(PAPER_DIM, 1);
ctx.fill(paths[MOVER]);
ctx.stroke(paths[MOVER]);
ctx.restore();
ctx.setLineDash([]);
}
function drawPlot() {
fillRound(PLOT_X, PLOT_Y, PLOT_W, PLOT_H, 10, CREAM, 1);
roundRectPath(PLOT_X, PLOT_Y, PLOT_W, PLOT_H, 10);
ctx.lineWidth = 1.2;
ctx.strokeStyle = rgba(INK, 0.4);
ctx.stroke();
const y0 = PLOT_Y + PLOT_H * 0.42;
const sy = (PLOT_H * 0.4) / PLOT_PX;
line(PLOT_X + 10, y0, PLOT_X + PLOT_W - 10, y0, 1, INK, 0.35);
label(LABEL_PLOT, PLOT_X + 10, PLOT_Y + PLOT_H - 10, INK, 1, 10, 700, 'left');
if (snapTrace.length < 2) {
return;
}
ctx.beginPath();
snapTrace.forEach(function (s, k) {
const x = PLOT_X + 10 + (s[0] / PLOT_S) * (PLOT_W - 20);
const y = y0 - clamp(s[1], -PLOT_PX, PLOT_PX) * sy;
if (k === 0) {
ctx.moveTo(x, y);
}
else {
ctx.lineTo(x, y);
}
});
ctx.lineWidth = 2.2;
ctx.strokeStyle = rgba(VIOLET_DEEP, 1);
ctx.stroke();
let low = 0;
let lowAt = 0;
for (const s of snapTrace) {
if (s[1] < low) {
low = s[1];
lowAt = s[0];
}
}
if (low < -0.3) {
const x = PLOT_X + 10 + (lowAt / PLOT_S) * (PLOT_W - 20);
disc(x, y0 - low * sy, 3.5, CORAL_DEEP, 1);
label(low.toFixed(1), x + 6, y0 - low * sy + 1, CORAL_DEEP, 1, 10, 800, 'left');
}
}
function draw() {
clearStage(PAPER);
drawBoardFrame(1);
if (!handsOn) {
drawSlotGhost();
}
const live = bestSnap(handsOn && dragging >= 0 ? dragging : MOVER);
const inside = live.d < SNAP_RADIUS;
if (live.m >= 0) {
disc(live.gx, live.gy, SNAP_RADIUS, inside ? MINT : CORAL, 0.45);
ring(live.gx, live.gy, SNAP_RADIUS, 1.6, inside ? MINT_DEEP : CORAL_DEEP, 0.9);
}
drawAllPieces(function (i) { void i; return PLAIN; });
const p = pieces[live.m >= 0 ? live.m : MOVER];
if (live.m >= 0 && live.d > 1.5) {
ctx.setLineDash([3, 3]);
line(p.x, p.y, live.gx, live.gy, 1.6, INK, 0.8);
ctx.setLineDash([]);
disc(p.x, p.y, 3, INK, 1);
// 조각이 돌아간 각 — 위쪽 기준선과 조각의 위 방향 사이 부채꼴
if (Math.abs(p.a) > 0.01) {
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.arc(p.x, p.y, 20, -Math.PI / 2, -Math.PI / 2 + p.a, p.a < 0);
ctx.closePath();
ctx.fillStyle = rgba(Math.abs(live.da) < SNAP_ANGLE ? MINT : CORAL, 0.75);
ctx.fill();
line(p.x, p.y, p.x, p.y - 24, 1.2, INK, 0.7);
}
label(Math.round(live.d) + 'px · ' + Math.round(Math.abs(deg(live.da))) + '°', (p.x + live.gx) / 2 + 8, (p.y + live.gy) / 2 - 10, INK, 1, 11, 800, 'left');
}
drawSelection();
if (!handsOn && lastCheckAt >= 0 && sceneT - lastCheckAt < 0.9) {
pill(lastCheck.ok ? LABEL_SNAP : LABEL_MISS, slotX(), BOARD_Y - 16, lastCheck.ok ? MINT_DEEP : CORAL_DEEP, CREAM, 1, 11);
}
pill(fmt(LABEL_RADIUS, { r: SNAP_RADIUS, a: Math.round(deg(SNAP_ANGLE)) }), BOARD_X + COLS * PIECE / 2, BOARD_Y + ROWS * PIECE + 22, INK, CREAM, 1, 10);
drawPlot();
drawHand(handX, handY, handDown);
}
// 상태 줄 — 바뀔 때만 글자를 고친다
let shownStatus = '';
function pushStatus() {
if (!statusEl || simStatus === shownStatus) {
return;
}
shownStatus = simStatus;
statusEl.textContent = simStatus;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 판을 처음 만지면 — 흉내 낸 손을 멈추고, 지금 놓인 그대로 사람에게 넘긴다(시연 되감기도 멈춘다)
function takeOver() {
wake();
if (handsOn) {
return;
}
handsOn = true;
carrying = -1;
handDown = false;
for (const p of pieces) {
p.th = 0;
}
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 장면 시계를 dt 만큼 — 시연 중에는 한 바퀴(LOOP_S)를 넘으면 처음 배치로 되돌려 다시 시연한다
function advance(dt) {
sceneT += dt;
if (handsOn) {
freeStep(dt);
return;
}
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
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;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 — 루프를 돌리지 않고 완성 판 한 장만 그린다. 사람이 이미 만졌으면 그 판을 그대로 둔다
function showRest() {
stopLoop();
if (!handsOn) {
sceneT = 0;
reset();
restPose();
}
settleNow();
draw();
pushStatus();
}
function play() {
if (!handsOn) {
sceneT = 0;
reset();
}
draw();
pushStatus();
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { 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 {
play();
}
}, { signal: off.signal });
// 창 크기나 화소 배율이 바뀌면 — 캔버스 내부 크기와 그림을 새 배율로 다시
window.addEventListener('resize', function () {
fitCanvas();
prepareBoard();
draw();
}, { signal: off.signal });
bindInput(off.signal);
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// ThresholdSnapSpring - 같은 항목 vanilla 판 index.html 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./ThresholdSnapSpring.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ThresholdSnapSpring() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 장면 시계(초)와 상태 줄 글자 — 장면 계산이 고치고, 화면 쪽이 읽어 그린다
let sceneT = 0;
let simStatus = '';
// 판 좌표 — 캔버스 안은 늘 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 smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 남은 거리의 같은 비율만큼 다가간다
function follow(rate: number, dt: number): number { return 1 - Math.exp(-rate * dt); }
function deg(rad: number): number { return rad * 180 / Math.PI; }
// 각을 −π ~ π 로 접는다 — 두 각의 차를 잴 때 한 바퀴 차이를 없앤다
function wrapAngle(a: number): number { let x = a % TAU; if (x > Math.PI) { x -= TAU; } if (x < -Math.PI) { x += TAU; } return x; }
// 시드 고정 난수 — 같은 시드면 같은 시연이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// −1 ~ 1 사이 값
function spread(rng: () => number): number { return rng() * 2 - 1; }
// ── 감쇠 스프링 ──────────────────────────────────────────────
// 가속 = 단단함 × (목표 − 위치) − 감쇠 × 속도. 속도를 먼저 고친 뒤 위치를 옮기는 적분을 1/240초 잔걸음으로 나눠 돌린다
type Spring = { x: number; v: number; to: number };
function makeSpring(x: number): Spring { return { x: x, v: 0, to: x }; }
const SPRING_SUBSTEP = 1 / 240;
function stepSpring(s: Spring, stiffness: number, damping: number, dt: number): void {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * (s.to - s.x) - damping * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
function snapSpring(s: Spring, x: number): void { s.x = x; s.v = 0; s.to = x; }
// ── 값 노이즈 ────────────────────────────────────────────────
// 정수 격자점마다 시드로 0~1 값을 하나씩 정해 두고, 그 사이는 부드러운 계단(3x² − 2x³)으로 섞는다.
// 여러 배율(옥타브)을 반씩 줄인 세기로 더하면 산등성이·구름 같은 결이 나온다
function hash2(ix: number, iy: number, seed: number): number {
let h = Math.imul(ix, 374761393) ^ Math.imul(iy, 668265263) ^ Math.imul(seed, 1442695041);
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function valueNoise(x: number, y: number, seed: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const sx = fx * fx * (3 - 2 * fx);
const sy = fy * fy * (3 - 2 * fy);
const a = hash2(ix, iy, seed);
const b = hash2(ix + 1, iy, seed);
const c = hash2(ix, iy + 1, seed);
const d = hash2(ix + 1, iy + 1, seed);
return lerp(lerp(a, b, sx), lerp(c, d, sx), sy);
}
function fbm(x: number, y: number, seed: number, octaves: number): number {
let amp = 0.5;
let freq = 1;
let sum = 0;
let norm = 0;
for (let k = 0; k < octaves; k += 1) {
sum += amp * valueNoise(x * freq, y * freq, seed + k * 17);
norm += amp;
amp *= 0.5;
freq *= 2;
}
return sum / norm;
}
// 파스텔 팔레트 — 크림 바탕 위 라벤더·민트·버터·산호. 테두리·글자는 진한 쪽으로 명도 차를 둔다
const PAPER: RGB = [251, 247, 238];
const PAPER_DIM: RGB = [238, 230, 214];
const INK: RGB = [44, 38, 58];
const INK_SOFT: RGB = [112, 104, 126];
const CREAM: RGB = [255, 251, 242];
const LAVENDER: RGB = [196, 182, 238];
const LAVENDER_DIM: RGB = [160, 146, 214];
const MINT: RGB = [164, 218, 196];
const MINT_DIM: RGB = [118, 184, 158];
const BUTTER: RGB = [246, 224, 150];
const CORAL: RGB = [244, 170, 152];
const SKY: RGB = [170, 206, 238];
const PINK: RGB = [244, 190, 212];
const MINT_DEEP: RGB = [36, 116, 90];
const CORAL_DEEP: RGB = [178, 66, 52];
const VIOLET_DEEP: RGB = [84, 58, 160];
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
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) + ')';
}
// ── 코드로 칠한 풍경 ─────────────────────────────────────────
// 판 안의 비율 좌표 (u, v) 0~1 하나에 색 하나. 하늘 → 해 → 구름 → 먼 산 → 가까운 언덕 → 들꽃 순으로 덮어 칠한다.
// 사진이 아니라 같은 페이지에서 계산한 그림이라 getImageData 로 다시 읽어도 막히지 않는다
const SCENE_SEED = 27;
const SUN_U = 0.74;
const SUN_V = 0.24;
const SUN_R = 0.085;
function sceneryColor(u: number, v: number): RGB {
let c = mix(SKY, CREAM, smooth(v / 0.56));
const ds = Math.hypot(u - SUN_U, (v - SUN_V) * 0.8);
c = mix(c, BUTTER, clamp(1 - ds / (SUN_R * 2.6), 0, 1) * 0.55);
if (ds < SUN_R) { c = mix(BUTTER, CREAM, 0.25); }
const cloud = fbm(u * 5, v * 10, SCENE_SEED + 3, 4);
if (v < 0.5) { c = mix(c, CREAM, smooth((cloud - 0.56) / 0.14) * 0.9 * (1 - v / 0.5)); }
const ridgeFar = 0.42 - 0.17 * fbm(u * 2.4, 0.5, SCENE_SEED + 5, 3);
if (v > ridgeFar) {
const shade = fbm(u * 9, v * 9, SCENE_SEED + 7, 3);
c = mix(LAVENDER, LAVENDER_DIM, clamp((v - ridgeFar) * 2.4 + (shade - 0.5) * 0.7, 0, 1));
}
const ridgeNear = 0.63 - 0.15 * fbm(u * 3.2 + 4, 1.5, SCENE_SEED + 9, 3);
if (v > ridgeNear) {
const shade = fbm(u * 12, v * 12, SCENE_SEED + 11, 3);
c = mix(MINT, MINT_DIM, clamp((v - ridgeNear) * 1.8 + (shade - 0.5) * 0.9, 0, 1));
}
if (v > 0.8) {
const f = valueNoise(u * 58, v * 58, SCENE_SEED + 13);
if (f > 0.9) { c = f > 0.95 ? PINK : BUTTER; }
}
return c;
}
// ── 조각 자르기 — 이웃과 나눠 쓰는 곡선 ───────────────────────
// 칸 사이 변 하나를 3차 곡선 다섯 마디로 만든다: 어깨 → 목 → 혹 위 → 목 → 어깨.
// 변 시작점 A 에서 끝점 B 로 가는 방향을 u(0~1), 그 방향을 90도 돌린 쪽을 v 로 두고 (u, v) 를 변 길이 L 단위로 적는다.
// v 에 탭 부호(+1/−1)를 곱해 혹이 어느 이웃 쪽으로 나오는지 정한다
const PUZZLE_SEED = 486;
// 혹의 지름 = 변 길이 × TAB_SIZE, 혹의 높이 = 지름 × TAB_HEIGHT
const TAB_SIZE = 0.2;
const TAB_HEIGHT = 1.0;
// 목 폭 = 혹 반지름 × NECK_RATIO — 1 보다 작아야 혹이 목보다 넓어 빠지지 않는다
const NECK_RATIO = 0.62;
// 흔들림 — 혹 자리(변 가운데에서 ± 변 길이 몫), 혹 크기 ± 몫
const EDGE_WOBBLE = 0.06;
const KNOB_JITTER = 0.12;
// 3차 곡선 사슬 — [x0, y0, c1x, c1y, c2x, c2y, x1, y1, c1x, …]. 마디 끝점이 다음 마디 시작점이다
function tabEdge(ax: number, ay: number, bx: number, by: number, sign: number, rng: () => number, tabSize: number): number[] {
const dx = bx - ax;
const dy = by - ay;
const c = 0.5 + spread(rng) * EDGE_WOBBLE;
const kr = tabSize / 2 * (1 + spread(rng) * KNOB_JITTER);
const nw = kr * NECK_RATIO;
const h = tabSize * TAB_HEIGHT * (1 + spread(rng) * KNOB_JITTER);
const j = spread(rng) * EDGE_WOBBLE * 0.5;
const left = c - nw;
const right = 1 - c - nw;
const uv = [
0, 0,
left * 0.35, j, left * 0.75, -j, c - nw, 0,
c - nw * 0.55, h * 0.28, c - kr * 1.12, h * 0.38, c - kr, h * 0.66,
c - kr * 0.9, h * 1.12, c + kr * 0.9, h * 1.12, c + kr, h * 0.66,
c + kr * 1.12, h * 0.38, c + nw * 0.55, h * 0.28, c + nw, 0,
c + nw + right * 0.25, j, c + nw + right * 0.65, -j, 1, 0
];
const out: number[] = [];
for (let k = 0; k < uv.length; k += 2) {
const u = uv[k];
const v = uv[k + 1] * sign;
// 방향 (dx, dy) 를 화면 좌표에서 90도 돌린 쪽 = (−dy, dx)
out.push(ax + u * dx - v * dy, ay + u * dy + v * dx);
}
return out;
}
// 바깥 테두리 — 곧은 변 한 마디(조절점을 1/3·2/3 자리에)
function flatEdge(ax: number, ay: number, bx: number, by: number): number[] {
return [ax, ay, lerp(ax, bx, 1 / 3), lerp(ay, by, 1 / 3), lerp(ax, bx, 2 / 3), lerp(ay, by, 2 / 3), bx, by];
}
// 사슬 뒤집기 — 점 순서만 거꾸로. 3차 곡선 한 마디 (p0, c1, c2, p1) 는 (p1, c2, c1, p0) 로 같은 곡선이라 어긋남이 0 이다
function reverseChain(pts: number[]): number[] {
const out: number[] = [];
for (let k = pts.length - 2; k >= 0; k -= 2) { out.push(pts[k], pts[k + 1]); }
return out;
}
// 변 하나 = 판 좌표 사슬 + 부호. 조각 쪽에서 본 탭 부호 tab: +1 혹(바깥으로), −1 홈(안으로), 0 곧은 변
type Side = { pts: number[]; tab: number };
type PieceDef = { id: number; col: number; row: number; hx: number; hy: number; sides: Side[]; flat: number; local: number[][] };
type Puzzle = { cols: number; rows: number; size: number; bx: number; by: number; defs: PieceDef[]; signs: number[] };
// 조각 순서 = 위 → 오른쪽 → 아래 → 왼쪽, 시계 방향. 위 변과 오른쪽 변은 사슬을 그대로, 아래·왼쪽 변은 뒤집어 쓴다.
// 가로 변은 왼→오, 세로 변은 위→아래로 한 번만 만든다. 그래서 같은 변을 한쪽은 그대로, 한쪽은 뒤집어 쓰고,
// 혹이 한쪽엔 바깥(+), 한쪽엔 안(−)이 된다 — 그대로 쓰는 조각의 탭 부호 = −sign, 뒤집어 쓰는 조각 = +sign
function makePuzzle(cols: number, rows: number, size: number, bx: number, by: number, seed: number, tabSize: number): Puzzle {
const rng = makeRng(seed);
const hEdges: number[][] = [];
const hSign: number[] = [];
const vEdges: number[][] = [];
const vSign: number[] = [];
const signs: number[] = [];
for (let r = 0; r <= rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const ax = bx + c * size;
const ay = by + r * size;
if (r === 0 || r === rows) { hEdges.push(flatEdge(ax, ay, ax + size, ay)); hSign.push(0); continue; }
const s = rng() < 0.5 ? 1 : -1;
hEdges.push(tabEdge(ax, ay, ax + size, ay, s, rng, tabSize));
hSign.push(s);
signs.push(s);
}
}
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c <= cols; c += 1) {
const ax = bx + c * size;
const ay = by + r * size;
if (c === 0 || c === cols) { vEdges.push(flatEdge(ax, ay, ax, ay + size)); vSign.push(0); continue; }
const s = rng() < 0.5 ? 1 : -1;
vEdges.push(tabEdge(ax, ay, ax, ay + size, s, rng, tabSize));
vSign.push(s);
signs.push(s);
}
}
const defs: PieceDef[] = [];
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const top = r * cols + c;
const bottom = (r + 1) * cols + c;
const left = r * (cols + 1) + c;
const right = r * (cols + 1) + c + 1;
const sides: Side[] = [
{ pts: hEdges[top], tab: -hSign[top] },
{ pts: vEdges[right], tab: -vSign[right] },
{ pts: reverseChain(hEdges[bottom]), tab: hSign[bottom] },
{ pts: reverseChain(vEdges[left]), tab: vSign[left] }
];
const hx = bx + (c + 0.5) * size;
const hy = by + (r + 0.5) * size;
let flat = 0;
const local: number[][] = [];
for (const sd of sides) {
if (sd.tab === 0) { flat += 1; }
const l: number[] = [];
for (let k = 0; k < sd.pts.length; k += 2) { l.push(sd.pts[k] - hx, sd.pts[k + 1] - hy); }
local.push(l);
}
defs.push({ id: r * cols + c, col: c, row: r, hx: hx, hy: hy, sides: sides, flat: flat, local: local });
}
}
return { cols: cols, rows: rows, size: size, bx: bx, by: by, defs: defs, signs: signs };
}
// 3차 곡선 한 마디의 점 — t 0~1
function cubicAt(p: number[], k: number, t: number): [number, number] {
const u = 1 - t;
const a = u * u * u;
const b = 3 * u * u * t;
const c = 3 * u * t * t;
const d = t * t * t;
return [a * p[k] + b * p[k + 2] + c * p[k + 4] + d * p[k + 6], a * p[k + 1] + b * p[k + 3] + c * p[k + 5] + d * p[k + 7]];
}
// 사슬을 꺾은선으로 — 마디마다 steps 등분
function flattenChain(pts: number[], steps: number): number[] {
const out: number[] = [pts[0], pts[1]];
for (let k = 0; k + 7 < pts.length; k += 6) {
for (let s = 1; s <= steps; s += 1) {
const p = cubicAt(pts, k, s / steps);
out.push(p[0], p[1]);
}
}
return out;
}
// 조각 하나의 닫힌 꺾은선(조각 가운데 기준) — 네 변을 이어 붙인다
function piecePolygon(d: PieceDef, steps: number): number[] {
const out: number[] = [];
for (const l of d.local) {
const f = flattenChain(l, steps);
for (let k = out.length ? 2 : 0; k < f.length; k += 2) { out.push(f[k], f[k + 1]); }
}
return out;
}
// 점이 닫힌 꺾은선 안에 있는가 — 오른쪽으로 쏜 반직선이 변을 몇 번 건너는지(홀수면 안)
function pointInPoly(px: number, py: number, poly: number[]): boolean {
let inside = false;
const n = poly.length;
for (let i = 0, j = n - 2; i < n; j = i, i += 2) {
const xi = poly[i];
const yi = poly[i + 1];
const xj = poly[j];
const yj = poly[j + 1];
if ((yi > py) !== (yj > py) && px < (xj - xi) * (py - yi) / (yj - yi) + xi) { inside = !inside; }
}
return inside;
}
// 꺾은선의 네모 상자 [왼, 위, 오른, 아래]
function polyBox(poly: number[]): number[] {
let x0 = Infinity;
let y0 = Infinity;
let x1 = -Infinity;
let y1 = -Infinity;
for (let k = 0; k < poly.length; k += 2) {
x0 = Math.min(x0, poly[k]); x1 = Math.max(x1, poly[k]);
y0 = Math.min(y0, poly[k + 1]); y1 = Math.max(y1, poly[k + 1]);
}
return [x0, y0, x1, y1];
}
// ── 대표 하나로 묶기 ─────────────────────────────────────────
// parent[i] 가 자기 자신이면 그 조각이 묶음의 대표. 대표를 찾으며 지나는 칸은 한 칸 건너(할아버지)를 가리키게 고쳐
// 다음 찾기가 짧아진다(경로 줄이기). 두 묶음을 합칠 땐 조각 수가 적은 쪽 대표를 많은 쪽 대표 아래로 넣는다(크기 기준)
let parent: number[] = [];
let groupSize: number[] = [];
function resetGroups(n: number): void {
parent = [];
groupSize = [];
for (let i = 0; i < n; i += 1) { parent.push(i); groupSize.push(1); }
}
function findRoot(i: number): number {
let x = i;
while (parent[x] !== x) {
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}
function joinGroups(a: number, b: number): number {
let ra = findRoot(a);
let rb = findRoot(b);
if (ra === rb) { return ra; }
if (groupSize[ra] < groupSize[rb]) { const t = ra; ra = rb; rb = t; }
parent[rb] = ra;
groupSize[ra] += groupSize[rb];
return ra;
}
function groupCount(): number {
let n = 0;
for (let i = 0; i < parent.length; i += 1) { if (parent[i] === i) { n += 1; } }
return n;
}
function membersOf(i: number): number[] {
const r = findRoot(i);
const out: number[] = [];
for (let k = 0; k < parent.length; k += 1) { if (findRoot(k) === r) { out.push(k); } }
return out;
}
// ── 판 위의 조각들 ───────────────────────────────────────────
// 위치 x, y 는 조각 가운데. tx, ty 는 스프링이 끌고 가는 목표. 손에 들린 조각은 목표와 위치를 함께 옮겨 스프링이 일하지 않는다.
// a 는 각(라디안), h 는 들어 올린 높이(0 바닥 ~ 1), mvx·mvy 는 속도를 지수 평균으로 다듬은 값
type Piece = { x: number; y: number; vx: number; vy: number; tx: number; ty: number; a: number; va: number; ta: number; h: number; vh: number; th: number; mvx: number; mvy: number; lx: number; ly: number };
let puzzle: Puzzle = makePuzzle(1, 1, 10, 0, 0, 1, TAB_SIZE);
let pieces: Piece[] = [];
// 그리는 순서 — 뒤에 있을수록 위에 그린다
let zOrder: number[] = [];
// 마지막 이음이 생긴 장면 시각 — 묶음이 하나가 되면 정해진다(아직이면 −1)
let completeAt = -1;
// 이음 시각 — 조각 i 의 변 s(위·오른·아래·왼)가 이웃과 묶인 장면 시각, 아직이면 −1
let seamJoinedAt: number[] = [];
function setupWorld(pz: Puzzle): void {
puzzle = pz;
pieces = [];
zOrder = [];
seamJoinedAt = [];
for (const d of pz.defs) {
pieces.push({ x: d.hx, y: d.hy, vx: 0, vy: 0, tx: d.hx, ty: d.hy, a: 0, va: 0, ta: 0, h: 0, vh: 0, th: 0, mvx: 0, mvy: 0, lx: d.hx, ly: d.hy });
zOrder.push(d.id);
seamJoinedAt.push(-1, -1, -1, -1);
}
resetGroups(pz.defs.length);
completeAt = -1;
}
// 퍼즐을 새로 만든 직후 부르는 일 — 화면 쪽이 그림 칠하기·경로 만들기를 끼워 넣는다(계산만 하는 곳에선 아무 일 없음)
let onBoardReady: () => void = function (): void { return; };
function newBoard(cols: number, rows: number, size: number, bx: number, by: number, seed: number): void {
setupWorld(makePuzzle(cols, rows, size, bx, by, seed, TAB_SIZE));
onBoardReady();
}
function placeAt(i: number, x: number, y: number, a: number): void {
const p = pieces[i];
p.x = x; p.y = y; p.tx = x; p.ty = y; p.vx = 0; p.vy = 0; p.lx = x; p.ly = y;
p.a = a; p.ta = a; p.va = 0;
}
function raise(i: number): void {
const members = membersOf(i);
zOrder = zOrder.filter(function (k: number): boolean { return members.indexOf(k) < 0; }).concat(members);
}
// 묶음 째 옮기기 — 조각 i 의 목표가 (x, y) 가 되도록 같은 묶음 전부를 같은 만큼
function moveGroupTo(i: number, x: number, y: number, instant: boolean): void {
const p = pieces[i];
const dx = x - p.tx;
const dy = y - p.ty;
for (const k of membersOf(i)) {
const q = pieces[k];
q.tx += dx; q.ty += dy;
if (instant) { q.x = q.tx; q.y = q.ty; q.vx = 0; q.vy = 0; }
}
}
// 이웃 — 격자에서 위·오른·아래·왼 칸의 조각 번호, 없으면 −1
function neighborOf(i: number, side: number): number {
const d = puzzle.defs[i];
const c = d.col + (side === 1 ? 1 : (side === 3 ? -1 : 0));
const r = d.row + (side === 2 ? 1 : (side === 0 ? -1 : 0));
if (c < 0 || r < 0 || c >= puzzle.cols || r >= puzzle.rows) { return -1; }
return r * puzzle.cols + c;
}
// ── 맞물림 — 거리와 각도 두 문턱 ─────────────────────────────
// 조각 m 이 이웃 n 옆에 바르게 앉을 자리 = n 의 목표 + (m 의 판 자리 − n 의 판 자리). 그 자리까지 거리 d 가 SNAP_RADIUS 안이고
// 두 조각 각의 차가 SNAP_ANGLE 안일 때만 묶음 전체의 목표를 그만큼 옮긴다 — 나머지 거리는 스프링이 당겨 살짝 넘쳤다 앉는다
const SNAP_RADIUS = 14;
const SNAP_ANGLE = 8 * Math.PI / 180;
const SNAP_STIFFNESS = 160;
const SNAP_DAMPING = 14;
type SnapCheck = { m: number; n: number; d: number; da: number; ok: boolean; gx: number; gy: number };
// 방향 판정 — 기본은 늘 열림. 04 처럼 조각이 돌아가는 항목은 「바로 섰는가」 를 넣어 바꾼다
let orientOk: (i: number) => boolean = function (i: number): boolean { void i; return true; };
// 묶음 i 의 조각들과, 다른 묶음에 있는 진짜 이웃 사이의 가장 가까운 맞물림 후보
function bestSnap(i: number): SnapCheck {
let best: SnapCheck = { m: -1, n: -1, d: Infinity, da: 0, ok: false, gx: 0, gy: 0 };
const root = findRoot(i);
for (const m of membersOf(i)) {
for (let s = 0; s < 4; s += 1) {
const n = neighborOf(m, s);
if (n < 0 || findRoot(n) === root) { continue; }
const dm = puzzle.defs[m];
const dn = puzzle.defs[n];
const gx = pieces[n].tx + (dm.hx - dn.hx);
const gy = pieces[n].ty + (dm.hy - dn.hy);
const d = Math.hypot(pieces[m].tx - gx, pieces[m].ty - gy);
const da = wrapAngle(pieces[m].ta - pieces[n].ta);
if (d < best.d) {
best = { m: m, n: n, d: d, da: da, ok: d < SNAP_RADIUS && Math.abs(da) < SNAP_ANGLE && orientOk(m) && orientOk(n), gx: gx, gy: gy };
}
}
}
return best;
}
// 놓았을 때 — 문턱 안이면 묶음 째 목표를 맞는 자리로 옮기고 묶는다. 문턱 밖이면 그 자리에 머문다
function tryJoin(i: number): SnapCheck {
const s = bestSnap(i);
if (!s.ok) { return s; }
const dx = s.gx - pieces[s.m].tx;
const dy = s.gy - pieces[s.m].ty;
// 각은 남은 차(da)만큼만 고친다 — 한 바퀴(360도) 돌아 바로 선 조각이 0 으로 되감기지 않게
for (const k of membersOf(s.m)) {
pieces[k].tx += dx;
pieces[k].ty += dy;
pieces[k].ta -= s.da;
}
joinGroups(s.m, s.n);
markSeams();
return s;
}
// 같은 묶음이 된 이웃 사이 변에 이음 시각을 적는다 — 이음매 선은 그 시각부터 옅어진다
function markSeams(): void {
for (let i = 0; i < pieces.length; i += 1) {
for (let s = 0; s < 4; s += 1) {
const n = neighborOf(i, s);
if (n >= 0 && seamJoinedAt[i * 4 + s] < 0 && findRoot(n) === findRoot(i)) { seamJoinedAt[i * 4 + s] = sceneT; }
}
}
if (completeAt < 0 && groupCount() === 1) { completeAt = sceneT; }
}
// 이은 자리 수 = 조각 수 − 묶음 수 (묶음이 하나면 조각 수 − 1)
function joinsMade(): number { return pieces.length - groupCount(); }
// ── 한 걸음 ──────────────────────────────────────────────────
// 위치·각·높이 세 스프링을 1/240초 잔걸음으로. 다듬은 속도는 걸음 앞뒤 위치 차를 VEL_SMOOTH 비율로 따라간다
const ROT_STIFFNESS = 180;
const ROT_DAMPING = 11;
const LIFT_STIFFNESS = 260;
const LIFT_DAMPING = 15;
const VEL_SMOOTH = 10;
// 들어 올린 높이 1 일 때 그림자가 떨어지는 거리(px)와 흐림(px) — 높이에 비례해 줄고 는다
const SHADOW_OFFSET = 14;
const SHADOW_BLUR = 16;
function stepWorld(dt: number, stiffness: number, damping: number): void {
const k = follow(VEL_SMOOTH, dt);
for (const p of pieces) {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
p.vx += (stiffness * (p.tx - p.x) - damping * p.vx) * h;
p.vy += (stiffness * (p.ty - p.y) - damping * p.vy) * h;
p.x += p.vx * h;
p.y += p.vy * h;
p.va += (ROT_STIFFNESS * (p.ta - p.a) - ROT_DAMPING * p.va) * h;
p.a += p.va * h;
p.vh += (LIFT_STIFFNESS * (p.th - p.h) - LIFT_DAMPING * p.vh) * h;
p.h += p.vh * h;
left -= h;
}
// 지난 걸음 끝 위치(lx, ly)와 견준다 — 손이 옮긴 몫과 스프링이 옮긴 몫이 둘 다 들어간다
if (dt > 0) {
p.mvx += ((p.x - p.lx) / dt - p.mvx) * k;
p.mvy += ((p.y - p.ly) / dt - p.mvy) * k;
}
p.lx = p.x;
p.ly = p.y;
}
}
// 스프링을 모두 목표에 앉힌다 — 동작 줄이기에서 입력을 받으면 보간 없이 바로 그 자리
function settleNow(): void {
for (const p of pieces) {
p.x = p.tx; p.y = p.ty; p.vx = 0; p.vy = 0;
p.a = p.ta; p.va = 0; p.h = p.th; p.vh = 0; p.mvx = 0; p.mvy = 0; p.lx = p.x; p.ly = p.y;
}
}
// 완성 판 — 모든 조각이 제자리에 있고 하나로 묶인 상태
function assembleAll(): void {
for (let i = 0; i < pieces.length; i += 1) {
const d = puzzle.defs[i];
placeAt(i, d.hx, d.hy, 0);
pieces[i].h = 0; pieces[i].th = 0; pieces[i].vh = 0;
}
for (let i = 1; i < pieces.length; i += 1) { joinGroups(0, i); }
markSeams();
}
// ── 흉내 낸 손 ───────────────────────────────────────────────
// 시연 일정표 — 조각 i 를 t0 ~ t1 동안 (x, y, a) 까지 들고 가 놓는다. anchor 가 0 이상이면 출발 순간에
// 그 조각 옆 바른 자리 + (ox, oy) 로 목적지를 정한다(묶음이 움직였어도 거기로). 손 경로는 완화 곡선 + 위로 볼록한 호
type Carry = { i: number; t0: number; t1: number; x: number; y: number; a: number; anchor: number; ox: number; oy: number; started: boolean; done: boolean; sx: number; sy: number; sa: number };
let carries: Carry[] = [];
let handX = W + 40;
let handY = H * 0.6;
let handDown = false;
let carrying = -1;
const HAND_ARC = 26;
function planCarry(i: number, t0: number, t1: number, x: number, y: number, a: number): void {
carries.push({ i: i, t0: t0, t1: t1, x: x, y: y, a: a, anchor: -1, ox: 0, oy: 0, started: false, done: false, sx: 0, sy: 0, sa: 0 });
}
function planCarryNext(i: number, anchor: number, t0: number, t1: number, ox: number, oy: number): void {
carries.push({ i: i, t0: t0, t1: t1, x: 0, y: 0, a: 0, anchor: anchor, ox: ox, oy: oy, started: false, done: false, sx: 0, sy: 0, sa: 0 });
}
// 들고 가기 완화 — 앞뒤가 느린 5차 곡선(6t⁵ − 15t⁴ + 10t³)
function easeCarry(t: number): number { const c = clamp(t, 0, 1); return c * c * c * (c * (c * 6 - 15) + 10); }
// 놓는 순간 부르는 일 — 기본은 맞물림 판정. 항목이 바꿔 끼운다
let onRelease: (i: number) => void = function (i: number): void { tryJoin(i); };
let onPick: (i: number) => void = function (i: number): void { void i; };
function runCarries(t: number): void {
carrying = -1;
handDown = false;
let lastEnd: Carry | null = null;
let next: Carry | null = null;
for (const c of carries) {
if (c.done) { lastEnd = c; continue; }
if (t < c.t0) { if (!next) { next = c; } continue; }
if (!c.started) {
c.started = true;
const p = pieces[c.i];
c.sx = p.tx; c.sy = p.ty; c.sa = p.ta;
if (c.anchor >= 0) {
const dm = puzzle.defs[c.i];
const dn = puzzle.defs[c.anchor];
c.x = pieces[c.anchor].tx + dm.hx - dn.hx + c.ox;
c.y = pieces[c.anchor].ty + dm.hy - dn.hy + c.oy;
c.a = pieces[c.anchor].ta;
}
raise(c.i);
onPick(c.i);
}
const k = (t - c.t0) / Math.max(1e-6, c.t1 - c.t0);
const e = easeCarry(k);
const arc = Math.sin(Math.PI * clamp(k, 0, 1)) * HAND_ARC;
if (k >= 1) {
moveGroupTo(c.i, c.x, c.y, true);
pieces[c.i].ta = c.a;
c.done = true;
onRelease(c.i);
lastEnd = c;
continue;
}
moveGroupTo(c.i, lerp(c.sx, c.x, e), lerp(c.sy, c.y, e) - arc, true);
pieces[c.i].ta = lerp(c.sa, c.a, e);
carrying = c.i;
handDown = true;
handX = pieces[c.i].x + 6;
handY = pieces[c.i].y + 4;
return;
}
// 들고 있지 않을 때 — 지난 놓은 자리에서 다음 집을 조각 쪽으로 손이 옮겨 간다
if (next) {
const from = lastEnd ? [pieces[lastEnd.i].x + 6, pieces[lastEnd.i].y + 4] : [W + 40, H * 0.6];
const t0 = lastEnd ? lastEnd.t1 : 0;
const k = easeCarry((t - t0) / Math.max(1e-6, next.t0 - t0));
const target = [pieces[next.i].x + 6, pieces[next.i].y + 4];
handX = lerp(from[0], target[0], k);
handY = lerp(from[1], target[1], k);
} else if (lastEnd) {
handX = lerp(handX, W + 40, 0.04);
handY = lerp(handY, H * 0.7, 0.04);
}
}
function resetCarries(): void { carries = []; carrying = -1; handDown = false; handX = W + 40; handY = H * 0.6; }
// ── 쟁반 — 겹친 깊이만큼 밀어내기 ────────────────────────────
// 조각을 반지름 TRAY_R 원으로 보고, 겹친 쌍마다 가운데를 잇는 방향으로 겹친 깊이의 절반씩 서로 민다.
// 판 상자(여백 TRAY_MARGIN 포함) 안에 들어온 원은 가장 가까운 판 바깥 변으로 투영하고, 가운데가 캔버스 가장자리에서 반지름의 0.7 배 안쪽에 머물게 자른다.
// 한 번에 다 풀리지 않으므로 여러 번 되풀이한다 — 되풀이할수록 남은 겹침 합이 준다
const TRAY_MARGIN = 6;
function relaxOnce(ids: number[], radius: number, bx0: number, by0: number, bx1: number, by1: number): number {
let overlap = 0;
for (let a = 0; a < ids.length; a += 1) {
for (let b = a + 1; b < ids.length; b += 1) {
const p = pieces[ids[a]];
const q = pieces[ids[b]];
let dx = q.tx - p.tx;
let dy = q.ty - p.ty;
let d = Math.hypot(dx, dy);
const depth = radius * 2 - d;
if (depth <= 0) { continue; }
overlap += depth;
if (d < 1e-6) { dx = 1; dy = 0; d = 1; }
const push = depth / 2 / d;
p.tx -= dx * push; p.ty -= dy * push;
q.tx += dx * push; q.ty += dy * push;
}
}
for (const i of ids) {
const p = pieces[i];
const x0 = bx0 - radius - TRAY_MARGIN;
const x1 = bx1 + radius + TRAY_MARGIN;
const y0 = by0 - radius - TRAY_MARGIN;
const y1 = by1 + radius + TRAY_MARGIN;
if (p.tx > x0 && p.tx < x1 && p.ty > y0 && p.ty < y1) {
// 판 안 — 네 변 중 가장 가까운 쪽으로 밀어낸다
const dl = p.tx - x0;
const dr = x1 - p.tx;
const du = p.ty - y0;
const dd = y1 - p.ty;
const m = Math.min(dl, dr, du, dd);
overlap += m;
if (m === dl) { p.tx = x0; } else if (m === dr) { p.tx = x1; } else if (m === du) { p.ty = y0; } else { p.ty = y1; }
}
p.tx = clamp(p.tx, radius * 0.7, W - radius * 0.7);
p.ty = clamp(p.ty, radius * 0.7, H - radius * 0.7);
}
return overlap;
}
// ── 자동 풀이 — 곧은 변 수와 변 색 차로 점수 ─────────────────
// 곧은 변 2개 = 모서리, 1개 = 가장자리, 0개 = 안쪽. 칸 하나에 들어갈 후보 점수 = 이미 놓인 이웃마다
// (탭이 마주 보지 않으면 TAB_MISMATCH_COST) + (두 변을 따라 안쪽으로 COLOR_INSET 들어간 점의 색 차 평균 / 255 × COLOR_WEIGHT).
// 곧은 변 자리가 칸의 바깥 테두리와 다르면 그 칸에는 넣지 않는다. 점수가 가장 낮은 조각을 고른다
const COLOR_SAMPLES = 9;
const COLOR_INSET = 2.5;
const COLOR_WEIGHT = 4;
const TAB_MISMATCH_COST = 3;
type Sampler = (x: number, y: number) => RGB;
// 칸 순서 — 바깥 테두리를 시계 방향으로 한 바퀴 돈 뒤 안쪽을 줄 순서로
function frameFirstOrder(cols: number, rows: number): number[] {
const out: number[] = [];
for (let c = 0; c < cols; c += 1) { out.push(c); }
for (let r = 1; r < rows; r += 1) { out.push(r * cols + cols - 1); }
for (let c = cols - 2; c >= 0; c -= 1) { out.push((rows - 1) * cols + c); }
for (let r = rows - 2; r >= 1; r -= 1) { out.push(r * cols); }
for (let r = 1; r < rows - 1; r += 1) { for (let c = 1; c < cols - 1; c += 1) { out.push(r * cols + c); } }
return out;
}
function pieceKind(d: PieceDef): number { return d.flat; }
// 칸 slot 의 바깥 테두리 패턴 — 위·오른·아래·왼 중 판 끝인 변이 곧은 변이어야 한다
function fitsBorder(d: PieceDef, slot: number, cols: number, rows: number): boolean {
const c = slot % cols;
const r = Math.floor(slot / cols);
const edge = [r === 0, c === cols - 1, r === rows - 1, c === 0];
for (let s = 0; s < 4; s += 1) { if ((d.sides[s].tab === 0) !== edge[s]) { return false; } }
return true;
}
// 조각 d 의 변 side 를 따라 안쪽으로 들어간 점들의 색 — 조각 그림은 원래 판 자리(hx, hy)에서 잘라 온 것이다
function sideColors(d: PieceDef, side: number, size: number, sample: Sampler): RGB[] {
const out: RGB[] = [];
const half = size / 2;
for (let k = 0; k < COLOR_SAMPLES; k += 1) {
const u = (k + 0.5) / COLOR_SAMPLES - 0.5;
let x = 0;
let y = 0;
if (side === 0) { x = u * size; y = -half + COLOR_INSET; }
if (side === 1) { x = half - COLOR_INSET; y = u * size; }
if (side === 2) { x = u * size; y = half - COLOR_INSET; }
if (side === 3) { x = -half + COLOR_INSET; y = u * size; }
out.push(sample(d.hx + x, d.hy + y));
}
return out;
}
function colorGap(a: RGB[], b: RGB[]): number {
let s = 0;
for (let k = 0; k < a.length; k += 1) { s += (Math.abs(a[k][0] - b[k][0]) + Math.abs(a[k][1] - b[k][1]) + Math.abs(a[k][2] - b[k][2])) / 3; }
return s / a.length;
}
// 칸 slot 에 조각 cand 를 넣을 때의 점수. placedAt[slot'] = 그 칸에 놓인 조각 번호(없으면 −1)
function slotScore(cand: number, slot: number, placedAt: number[], sample: Sampler): number {
const pz = puzzle;
const d = pz.defs[cand];
if (!fitsBorder(d, slot, pz.cols, pz.rows)) { return Infinity; }
const c = slot % pz.cols;
const r = Math.floor(slot / pz.cols);
let score = 0;
const dirs = [[0, -1], [1, 0], [0, 1], [-1, 0]];
for (let s = 0; s < 4; s += 1) {
const nc = c + dirs[s][0];
const nr = r + dirs[s][1];
if (nc < 0 || nr < 0 || nc >= pz.cols || nr >= pz.rows) { continue; }
const q = placedAt[nr * pz.cols + nc];
if (q < 0) { continue; }
const facing = (s + 2) % 4;
const qd = pz.defs[q];
if (d.sides[s].tab + qd.sides[facing].tab !== 0 || d.sides[s].tab === 0) { score += TAB_MISMATCH_COST; }
score += colorGap(sideColors(d, s, pz.size, sample), sideColors(qd, facing, pz.size, sample)) / 255 * COLOR_WEIGHT;
}
return score;
}
// 식으로 읽기 — 그림을 칠하는 식을 배율 1 화소 가운데에서 바로 부르고 0~255 정수로 자른다(DOM 없이 셀 때).
// 화면에서는 칠한 그림을 getImageData 로 읽는 함수로 바뀐다(dom 쪽)
function formulaSampler(): Sampler {
const pz = puzzle;
const w = Math.round(pz.cols * pz.size);
const h = Math.round(pz.rows * pz.size);
return function (x: number, y: number): RGB {
const px = clamp(Math.floor(x - pz.bx), 0, w - 1);
const py = clamp(Math.floor(y - pz.by), 0, h - 1);
const c = sceneryColor((px + 0.5) / w, (py + 0.5) / h);
return [Math.round(c[0]), Math.round(c[1]), Math.round(c[2])];
};
}
let makeSampler: () => Sampler = formulaSampler;
type SolveStep = { slot: number; piece: number; scores: number[] };
// 테두리부터 — 칸 순서대로 남은 조각 중 점수가 가장 낮은 것을 놓는다. 단계마다 후보 점수 표도 남긴다(열지도용)
function solveFrameFirst(sample: Sampler): SolveStep[] {
const pz = puzzle;
const n = pz.defs.length;
const placedAt: number[] = [];
const used: boolean[] = [];
for (let k = 0; k < n; k += 1) { placedAt.push(-1); used.push(false); }
const steps: SolveStep[] = [];
for (const slot of frameFirstOrder(pz.cols, pz.rows)) {
const scores: number[] = [];
let best = -1;
let bestScore = Infinity;
for (let k = 0; k < n; k += 1) {
const sc = used[k] ? Infinity : slotScore(k, slot, placedAt, sample);
scores.push(sc);
if (sc < bestScore) { bestScore = sc; best = k; }
}
if (best < 0) { break; }
used[best] = true;
placedAt[slot] = best;
steps.push({ slot: slot, piece: best, scores: scores });
}
return steps;
}
// 사람이 처음 판을 만진 뒤 — 흉내 낸 손을 멈추고 끌기·키 입력만 받는다
let handsOn = false;
function fmt(s: string, vals: { [key: string]: string | number }): string {
let out = s;
for (const k of Object.keys(vals)) { out = out.split('{' + k + '}').join(String(vals[k])); }
return out;
}
// 사람이 쓰는 동안의 상태 줄 — 남은 묶음 수와 이은 자리 수
const STATUS_FREE = '남은 묶음 {g} · 이은 자리 {j}/{n}';
const STATUS_DONE = '완성 — 조각 {n}개가 한 묶음';
function freeStatus(): void {
simStatus = groupCount() === 1 ? fmt(STATUS_DONE, { n: pieces.length }) : fmt(STATUS_FREE, { g: groupCount(), j: joinsMade(), n: pieces.length - 1 });
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 화소 배율 — 캔버스 내부 크기에 곱한다. 2 에서 자른다(배율 3 기기에서 화소가 2.25배로 늘지 않게)
let dpr = 1;
function fitCanvas(): void {
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);
}
// ── 그림과 조각 경로 ─────────────────────────────────────────
// 풍경은 판 크기 × 배율의 숨은 캔버스에 화소 하나씩 한 번만 칠해 두고, 조각마다 자기 경로로 잘라(clip) 그 위치에 붙인다
const pic: HTMLCanvasElement = document.createElement('canvas');
const picCtx = pic.getContext('2d');
let picDpr = 0;
let picKey = '';
function paintPicture(): void {
const pz = puzzle;
const key = pz.cols + 'x' + pz.rows + '@' + pz.size + '/' + dpr;
if (!picCtx || key === picKey) { return; }
picKey = key;
picDpr = dpr;
const w = Math.max(1, Math.round(pz.cols * pz.size * dpr));
const h = Math.max(1, Math.round(pz.rows * pz.size * dpr));
pic.width = w;
pic.height = h;
const img = picCtx.createImageData(w, h);
for (let y = 0; y < h; y += 1) {
for (let x = 0; x < w; x += 1) {
const c = sceneryColor((x + 0.5) / w, (y + 0.5) / h);
const k = (y * w + x) * 4;
img.data[k] = c[0];
img.data[k + 1] = c[1];
img.data[k + 2] = c[2];
img.data[k + 3] = 255;
}
}
picCtx.putImageData(img, 0, 0);
}
// 칠한 그림을 getImageData 로 다시 읽어 색 하나를 돌려주는 함수 — 같은 페이지에서 그린 그림이라 막히지 않는다.
// 프레임마다 읽지 않고 그림을 새로 칠했을 때 한 번만 읽는다
function pictureSampler(): Sampler {
const pz = puzzle;
const data = picCtx ? picCtx.getImageData(0, 0, pic.width, pic.height).data : new Uint8ClampedArray(4);
const w = pic.width;
const h = pic.height;
return function (x: number, y: number): RGB {
const px = clamp(Math.floor((x - pz.bx) * picDpr), 0, w - 1);
const py = clamp(Math.floor((y - pz.by) * picDpr), 0, h - 1);
const k = (py * w + px) * 4;
return [data[k], data[k + 1], data[k + 2]];
};
}
// 조각 경로 — 조각 가운데 기준. 네 변 사슬을 이어 하나의 닫힌 경로로, 변마다 따로 그을 경로도 함께
let paths: Path2D[] = [];
let sidePaths: Path2D[][] = [];
// 3차 곡선 사슬을 마디마다 CURVE_STEPS 등분한 꺾은선으로 잇는다 — 계산 쪽 pointInPoly 와 화면의 isPointInPath 가 같은 모양을 본다
const CURVE_STEPS = 12;
function chainInto(p: Path2D, l: number[], move: boolean): void {
const f = flattenChain(l, CURVE_STEPS);
if (move) { p.moveTo(f[0], f[1]); }
for (let k = 2; k < f.length; k += 2) { p.lineTo(f[k], f[k + 1]); }
}
function buildPaths(): void {
paths = [];
sidePaths = [];
for (const d of puzzle.defs) {
const p = new Path2D();
d.local.forEach(function (l: number[], s: number): void { chainInto(p, l, s === 0); });
p.closePath();
paths.push(p);
sidePaths.push(d.local.map(function (l: number[]): Path2D { const q = new Path2D(); chainInto(q, l, true); return q; }));
}
}
// 판·그림·경로를 지금 퍼즐에 맞게 — 항목의 reset 이 퍼즐을 새로 만들면 부른다
function prepareBoard(): void {
paintPicture();
buildPaths();
}
onBoardReady = prepareBoard;
makeSampler = pictureSampler;
// 이음매 선 투명도 — 항목이 바꿔 끼운다. 기본은 늘 진하게
let seamAlpha: (i: number, s: number) => number = function (i: number, s: number): number { void i; void s; return 1; };
// 조각 하나의 겉모습 — 옮김(dx, dy)·배율·기울임(skewX, skewY)·투명도·그림자 세기(shade, 0 이면 그림자 없음)
type PieceLook = { dx: number; dy: number; scale: number; skewX: number; skewY: number; alpha: number; shade: number };
const PLAIN: PieceLook = { dx: 0, dy: 0, scale: 1, skewX: 0, skewY: 0, alpha: 1, shade: 1 };
// 들어 올린 높이 h 에 비례하는 그림자 — 오프셋 SHADOW_OFFSET·흐림 SHADOW_BLUR(core)를 h 배. 그림자 값은 변환을 안 타므로 배율을 직접 곱한다
function drawPiece(i: number, look: PieceLook): void {
const p = pieces[i];
const d = puzzle.defs[i];
const pz = puzzle;
const h = Math.max(0, p.h);
ctx.save();
ctx.globalAlpha = look.alpha;
ctx.translate(p.x + look.dx, p.y + look.dy);
ctx.rotate(p.a);
ctx.transform(1, look.skewY, look.skewX, 1, 0, 0);
ctx.scale(look.scale, look.scale);
ctx.save();
ctx.shadowColor = rgba(INK, (0.22 + 0.1 * clamp(h, 0, 1)) * look.shade);
ctx.shadowBlur = (2 + SHADOW_BLUR * h) * dpr;
ctx.shadowOffsetX = (1 + SHADOW_OFFSET * 0.45 * h) * dpr;
ctx.shadowOffsetY = (1.5 + SHADOW_OFFSET * h) * dpr;
ctx.fillStyle = rgba(PAPER_DIM, 1);
ctx.fill(paths[i]);
ctx.restore();
ctx.save();
ctx.clip(paths[i]);
ctx.drawImage(pic, pz.bx - d.hx, pz.by - d.hy, pz.cols * pz.size, pz.rows * pz.size);
ctx.restore();
ctx.lineJoin = 'round';
for (let s = 0; s < 4; s += 1) {
const a = seamAlpha(i, s);
if (a <= 0.01) { continue; }
ctx.lineWidth = 1.3;
ctx.strokeStyle = rgba(INK, 0.62 * a);
ctx.stroke(sidePaths[i][s]);
}
ctx.restore();
}
function drawAllPieces(lookOf: (i: number) => PieceLook): void {
for (const i of zOrder) { drawPiece(i, lookOf(i)); }
}
// 판 자리 — 빈 판의 점선 테두리와 옅은 바탕
function drawBoardFrame(a: number): void {
const pz = puzzle;
const w = pz.cols * pz.size;
const h = pz.rows * pz.size;
fillRound(pz.bx - 4, pz.by - 4, w + 8, h + 8, 6, PAPER_DIM, 0.75 * a);
ctx.setLineDash([5, 5]);
ctx.lineWidth = 1.4;
ctx.strokeStyle = rgba(INK_SOFT, 0.7 * a);
ctx.strokeRect(pz.bx, pz.by, w, h);
ctx.setLineDash([]);
}
function clearStage(c: RGB): void {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function setFont(px: number, weight: number): void { ctx.font = weight + ' ' + px.toFixed(2) + 'px ' + FONT_STACK; }
function label(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
setFont(px, weight);
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
function roundRectPath(x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
function fillRound(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; }
roundRectPath(x, y, w, h, r);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function line(x0: number, y0: number, x1: number, y1: number, width: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
// 작은 알약 이름표
function pill(s: string, x: number, y: number, bg: RGB, fg: RGB, a: number, px: number): void {
setFont(px, 700);
const w = ctx.measureText(s).width + px * 1.2;
fillRound(x - w / 2, y - px * 0.85, w, px * 1.7, px * 0.85, bg, a);
label(s, x, y + 0.5, fg, a, px, 700, 'center');
}
// 흉내 낸 손 — 둥근 손끝과 짧은 화살. 누르는 중이면 손끝 둘레에 고리
function drawHand(x: number, y: number, down: boolean): void {
if (x > W + 20 || handsOn) { return; }
ctx.save();
ctx.translate(x, y);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, 17);
ctx.lineTo(4.5, 13);
ctx.lineTo(8, 20);
ctx.lineTo(11, 18.5);
ctx.lineTo(7.5, 11.8);
ctx.lineTo(13, 11.5);
ctx.closePath();
ctx.fillStyle = rgba(CREAM, 1);
ctx.fill();
ctx.lineWidth = 1.6;
ctx.strokeStyle = rgba(INK, 1);
ctx.stroke();
if (down) { ring(0, 0, 7, 2, VIOLET_DEEP, 0.8); }
ctx.restore();
}
// ── 고르기 — 경로 판정 ───────────────────────────────────────
// 위에 그린 조각부터, 점을 조각 자기 좌표로 되돌린 뒤 isPointInPath 로 경로 안인지 묻는다(변환은 단위 행렬로 두고)
function toLocal(i: number, x: number, y: number): [number, number] {
const p = pieces[i];
const c = Math.cos(-p.a);
const s = Math.sin(-p.a);
const dx = x - p.x;
const dy = y - p.y;
return [dx * c - dy * s, dx * s + dy * c];
}
function insidePiece(i: number, x: number, y: number): boolean {
const l = toLocal(i, x, y);
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
const hit = ctx.isPointInPath(paths[i], l[0], l[1]);
ctx.restore();
return hit;
}
function pickAt(x: number, y: number): number {
for (let k = zOrder.length - 1; k >= 0; k -= 1) { if (insidePiece(zOrder[k], x, y)) { return zOrder[k]; } }
return -1;
}
// ── 사람 손 — 끌기와 키 ──────────────────────────────────────
// 끌기는 포인터 이벤트(마우스·펜·손가락 한 갈래). 키보드는 캔버스에 초점이 있을 때만:
// Enter·Space 다음 묶음 고르기 · 화살표 4px(Shift 16px) 옮기기 · R 90도 돌리기(돌리는 항목만) · Escape 고르기 풀기
let dragging = -1;
let dragOx = 0;
let dragOy = 0;
let selected = -1;
let focused = false;
// 돌리기를 받는 항목인가 — 04 만 true 로 바꾼다
let allowTurn = false;
let onTurn: (i: number) => void = function (i: number): void { void i; };
function eventPoint(e: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / Math.max(1, r.width), (e.clientY - r.top) * H / Math.max(1, r.height)];
}
// 입력 뒤 — 동작 줄이기면 스프링 없이 바로 그 자리에. 루프가 멈춰 있으면(동작 줄이기·일시정지) 상태 줄과 화면을 한 번 바로 고친다
function afterInput(): void {
if (still) { settleNow(); }
if (still || paused) { freeStatus(); draw(); pushStatus(); }
}
function nextGroup(): number {
const roots: number[] = [];
for (let k = zOrder.length - 1; k >= 0; k -= 1) {
const r = findRoot(zOrder[k]);
if (roots.indexOf(r) < 0) { roots.push(r); }
}
if (!roots.length) { return -1; }
const cur = selected >= 0 ? roots.indexOf(findRoot(selected)) : -1;
return roots[(cur + 1) % roots.length];
}
function bindInput(signal: AbortSignal): void {
cv.addEventListener('pointerdown', function (e: PointerEvent): void {
const pt = eventPoint(e);
const i = pickAt(pt[0], pt[1]);
if (i < 0) { return; }
takeOver();
dragging = i;
selected = i;
dragOx = pieces[i].tx - pt[0];
dragOy = pieces[i].ty - pt[1];
raise(i);
cv.setPointerCapture(e.pointerId);
onPick(i);
afterInput();
}, { signal: signal });
cv.addEventListener('pointermove', function (e: PointerEvent): void {
if (dragging < 0) { return; }
const pt = eventPoint(e);
moveGroupTo(dragging, pt[0] + dragOx, pt[1] + dragOy, true);
if (still || paused) { draw(); }
}, { signal: signal });
const end = function (): void {
if (dragging < 0) { return; }
const i = dragging;
dragging = -1;
onRelease(i);
afterInput();
};
cv.addEventListener('pointerup', end, { signal: signal });
cv.addEventListener('pointercancel', end, { signal: signal });
cv.addEventListener('focus', function (): void { focused = true; if (still || paused) { draw(); } }, { signal: signal });
cv.addEventListener('blur', function (): void { focused = false; if (still || paused) { draw(); } }, { signal: signal });
cv.addEventListener('keydown', function (e: KeyboardEvent): void {
const step = e.shiftKey ? 16 : 4;
let dx = 0;
let dy = 0;
if (e.key === 'ArrowLeft') { dx = -step; }
else if (e.key === 'ArrowRight') { dx = step; }
else if (e.key === 'ArrowUp') { dy = -step; }
else if (e.key === 'ArrowDown') { dy = step; }
else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
takeOver();
selected = nextGroup();
if (selected >= 0) { raise(selected); }
afterInput();
return;
} else if (e.key === 'Escape') {
selected = -1;
afterInput();
return;
} else if ((e.key === 'r' || e.key === 'R') && allowTurn && selected >= 0 && groupSize[findRoot(selected)] === 1) {
e.preventDefault();
takeOver();
pieces[selected].ta += Math.PI / 2;
onTurn(selected);
afterInput();
return;
} else { return; }
e.preventDefault();
takeOver();
if (selected < 0) { selected = nextGroup(); }
if (selected < 0) { return; }
moveGroupTo(selected, pieces[selected].tx + dx, pieces[selected].ty + dy, true);
onRelease(selected);
afterInput();
}, { signal: signal });
}
// 사람이 쓰는 동안 — 끄는 묶음은 들어 올리고 나머지는 내려놓는다. 스프링은 맞물림 값 그대로
function freeStep(dt: number): void {
const lifted = dragging >= 0 ? findRoot(dragging) : -1;
for (let i = 0; i < pieces.length; i += 1) { pieces[i].th = findRoot(i) === lifted ? 1 : 0; }
stepWorld(dt, SNAP_STIFFNESS, SNAP_DAMPING);
freeStatus();
}
// 고른 묶음 — 캔버스에 초점이 있을 때 점선으로 둘러 보인다
function drawSelection(): void {
if (!focused || selected < 0) { return; }
for (const k of membersOf(selected)) {
const p = pieces[k];
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.a);
ctx.setLineDash([4, 3]);
ctx.lineWidth = 2.4;
ctx.strokeStyle = rgba(VIOLET_DEEP, 1);
ctx.stroke(paths[k]);
ctx.restore();
}
ctx.setLineDash([]);
}
// 02 — 거리와 각도 문턱 안에서만 당겨지는 맞물림. 판 3×3 중 오른쪽 가운데 조각(MOVER) 하나만 떨어져 있고 나머지 여덟은 한 묶음.
// 흉내 낸 손이 세 번 가져다 놓는다: ① 거의 붙였지만 18도 돌아감 → 각도 문턱 밖 ② 바로 섰지만 약 37px 떨어짐 → 거리 문턱 밖
// ③ 약 11px·3도 → 두 문턱 안이라 스프링(SNAP_STIFFNESS·SNAP_DAMPING)이 남은 거리를 당겨 살짝 넘쳤다 앉는다
const COLS = 3;
const ROWS = 3;
const PIECE = 62;
const BOARD_X = 48;
const BOARD_Y = 46;
const MOVER = 5;
const TRAY_X = 396;
const TRAY_Y = 196;
// 시도 셋 — [시작 초, 끝 초, 바른 자리에서 x 어긋남, y 어긋남, 각(도)]
const ATTEMPTS: number[][] = [[0.2, 0.7, 5, -4, 18], [0.85, 1.3, 34, 14, 0], [1.45, 1.85, 9, 7, 3]];
const LOOP_S = 3.4;
const STATUS_MOVE = '거리 {d}px · 각도 차 {a}°';
const STATUS_MISS = '문턱 밖 — 거리 {d}px · 각도 차 {a}° · 그 자리에 둠';
const STATUS_SNAP = '맞물림 — 거리 {d}px · 각도 차 {a}° · 스프링이 당김';
// 마지막으로 놓은 순간의 판정과 시각, 맞물린 뒤 바른 자리까지의 부호 있는 거리 기록
let lastCheck: SnapCheck = { m: -1, n: -1, d: 0, da: 0, ok: false, gx: 0, gy: 0 };
let lastCheckAt = -1;
let snapTrace: number[][] = [];
let snapDir: [number, number] = [1, 0];
function slotX(): number { return puzzle.defs[MOVER].hx; }
function slotY(): number { return puzzle.defs[MOVER].hy; }
function reset(): void {
newBoard(COLS, ROWS, PIECE, BOARD_X, BOARD_Y, PUZZLE_SEED);
for (let i = 0; i < pieces.length; i += 1) { if (i !== MOVER && i !== 0) { joinGroups(0, i); } }
markSeams();
placeAt(MOVER, TRAY_X, TRAY_Y, -0.5);
resetCarries();
for (const a of ATTEMPTS) { planCarry(MOVER, a[0], a[1], slotX() + a[2], slotY() + a[3], a[4] * Math.PI / 180); }
lastCheck = { m: -1, n: -1, d: 0, da: 0, ok: false, gx: 0, gy: 0 };
lastCheckAt = -1;
snapTrace = [];
onRelease = function (i: number): void {
const c = tryJoin(i);
lastCheck = c;
lastCheckAt = sceneT;
if (c.ok) {
const dx = pieces[i].x - pieces[i].tx;
const dy = pieces[i].y - pieces[i].ty;
const len = Math.max(1e-6, Math.hypot(dx, dy));
snapDir = [dx / len, dy / len];
snapTrace = [];
}
};
}
// 바른 자리까지의 부호 있는 거리 — 놓은 쪽을 +, 지나쳐 반대쪽으로 넘어가면 −
function signedOffset(): number {
const p = pieces[MOVER];
return (p.x - slotX()) * snapDir[0] + (p.y - slotY()) * snapDir[1];
}
function update(dt: number): void {
runCarries(sceneT);
for (const p of pieces) { p.th = 0; }
if (carrying >= 0) { pieces[carrying].th = 1; }
stepWorld(dt, SNAP_STIFFNESS, SNAP_DAMPING);
if (lastCheck.ok && sceneT - lastCheckAt < 1.4) { snapTrace.push([sceneT - lastCheckAt, signedOffset()]); }
const live = bestSnap(MOVER);
if (carrying >= 0) {
simStatus = fmt(STATUS_MOVE, { d: Math.round(live.d), a: Math.round(Math.abs(deg(live.da))) });
} else if (lastCheckAt >= 0) {
simStatus = fmt(lastCheck.ok ? STATUS_SNAP : STATUS_MISS, { d: Math.round(lastCheck.d), a: Math.round(Math.abs(deg(lastCheck.da))) });
}
}
function restPose(): void {
assembleAll();
resetCarries();
simStatus = fmt(STATUS_DONE, { n: pieces.length });
}
// 02 화면 — 빈 자리의 점선 윤곽과 SNAP_RADIUS 원, 조각 가운데에서 바른 자리까지의 선, 놓은 순간의 판정 이름표,
// 오른쪽 위에는 맞물린 뒤 바른 자리까지 남은 거리(부호 있음)를 시간에 따라 그린 작은 그래프 — 0 아래로 내려간 만큼이 넘침
const PLOT_X = 300;
const PLOT_Y = 24;
const PLOT_W = 166;
const PLOT_H = 92;
const PLOT_S = 1.4;
const PLOT_PX = 14;
const LABEL_PLOT = '맞물린 뒤 남은 거리(px)';
const LABEL_MISS = '문턱 밖';
const LABEL_SNAP = '맞물림';
const LABEL_RADIUS = 'SNAP_RADIUS {r}px · SNAP_ANGLE {a}°';
function drawSlotGhost(): void {
ctx.save();
ctx.translate(slotX(), slotY());
ctx.setLineDash([4, 4]);
ctx.lineWidth = 1.4;
ctx.strokeStyle = rgba(INK_SOFT, 0.9);
ctx.fillStyle = rgba(PAPER_DIM, 1);
ctx.fill(paths[MOVER]);
ctx.stroke(paths[MOVER]);
ctx.restore();
ctx.setLineDash([]);
}
function drawPlot(): void {
fillRound(PLOT_X, PLOT_Y, PLOT_W, PLOT_H, 10, CREAM, 1);
roundRectPath(PLOT_X, PLOT_Y, PLOT_W, PLOT_H, 10);
ctx.lineWidth = 1.2;
ctx.strokeStyle = rgba(INK, 0.4);
ctx.stroke();
const y0 = PLOT_Y + PLOT_H * 0.42;
const sy = (PLOT_H * 0.4) / PLOT_PX;
line(PLOT_X + 10, y0, PLOT_X + PLOT_W - 10, y0, 1, INK, 0.35);
label(LABEL_PLOT, PLOT_X + 10, PLOT_Y + PLOT_H - 10, INK, 1, 10, 700, 'left');
if (snapTrace.length < 2) { return; }
ctx.beginPath();
snapTrace.forEach(function (s: number[], k: number): void {
const x = PLOT_X + 10 + (s[0] / PLOT_S) * (PLOT_W - 20);
const y = y0 - clamp(s[1], -PLOT_PX, PLOT_PX) * sy;
if (k === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); }
});
ctx.lineWidth = 2.2;
ctx.strokeStyle = rgba(VIOLET_DEEP, 1);
ctx.stroke();
let low = 0;
let lowAt = 0;
for (const s of snapTrace) { if (s[1] < low) { low = s[1]; lowAt = s[0]; } }
if (low < -0.3) {
const x = PLOT_X + 10 + (lowAt / PLOT_S) * (PLOT_W - 20);
disc(x, y0 - low * sy, 3.5, CORAL_DEEP, 1);
label(low.toFixed(1), x + 6, y0 - low * sy + 1, CORAL_DEEP, 1, 10, 800, 'left');
}
}
function draw(): void {
clearStage(PAPER);
drawBoardFrame(1);
if (!handsOn) { drawSlotGhost(); }
const live = bestSnap(handsOn && dragging >= 0 ? dragging : MOVER);
const inside = live.d < SNAP_RADIUS;
if (live.m >= 0) {
disc(live.gx, live.gy, SNAP_RADIUS, inside ? MINT : CORAL, 0.45);
ring(live.gx, live.gy, SNAP_RADIUS, 1.6, inside ? MINT_DEEP : CORAL_DEEP, 0.9);
}
drawAllPieces(function (i: number): PieceLook { void i; return PLAIN; });
const p = pieces[live.m >= 0 ? live.m : MOVER];
if (live.m >= 0 && live.d > 1.5) {
ctx.setLineDash([3, 3]);
line(p.x, p.y, live.gx, live.gy, 1.6, INK, 0.8);
ctx.setLineDash([]);
disc(p.x, p.y, 3, INK, 1);
// 조각이 돌아간 각 — 위쪽 기준선과 조각의 위 방향 사이 부채꼴
if (Math.abs(p.a) > 0.01) {
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.arc(p.x, p.y, 20, -Math.PI / 2, -Math.PI / 2 + p.a, p.a < 0);
ctx.closePath();
ctx.fillStyle = rgba(Math.abs(live.da) < SNAP_ANGLE ? MINT : CORAL, 0.75);
ctx.fill();
line(p.x, p.y, p.x, p.y - 24, 1.2, INK, 0.7);
}
label(Math.round(live.d) + 'px · ' + Math.round(Math.abs(deg(live.da))) + '°', (p.x + live.gx) / 2 + 8, (p.y + live.gy) / 2 - 10, INK, 1, 11, 800, 'left');
}
drawSelection();
if (!handsOn && lastCheckAt >= 0 && sceneT - lastCheckAt < 0.9) {
pill(lastCheck.ok ? LABEL_SNAP : LABEL_MISS, slotX(), BOARD_Y - 16, lastCheck.ok ? MINT_DEEP : CORAL_DEEP, CREAM, 1, 11);
}
pill(fmt(LABEL_RADIUS, { r: SNAP_RADIUS, a: Math.round(deg(SNAP_ANGLE)) }), BOARD_X + COLS * PIECE / 2, BOARD_Y + ROWS * PIECE + 22, INK, CREAM, 1, 10);
drawPlot();
drawHand(handX, handY, handDown);
}
// 상태 줄 — 바뀔 때만 글자를 고친다
let shownStatus = '';
function pushStatus(): void {
if (!statusEl || simStatus === shownStatus) { return; }
shownStatus = simStatus;
statusEl.textContent = simStatus;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 판을 처음 만지면 — 흉내 낸 손을 멈추고, 지금 놓인 그대로 사람에게 넘긴다(시연 되감기도 멈춘다)
function takeOver(): void {
wake();
if (handsOn) { return; }
handsOn = true;
carrying = -1;
handDown = false;
for (const p of pieces) { p.th = 0; }
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 장면 시계를 dt 만큼 — 시연 중에는 한 바퀴(LOOP_S)를 넘으면 처음 배치로 되돌려 다시 시연한다
function advance(dt: number): void {
sceneT += dt;
if (handsOn) { freeStep(dt); return; }
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
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;
if (dt > 0) { advance(dt); }
draw();
pushStatus();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 — 루프를 돌리지 않고 완성 판 한 장만 그린다. 사람이 이미 만졌으면 그 판을 그대로 둔다
function showRest(): void {
stopLoop();
if (!handsOn) {
sceneT = 0;
reset();
restPose();
}
settleNow();
draw();
pushStatus();
}
function play(): void {
if (!handsOn) {
sceneT = 0;
reset();
}
draw();
pushStatus();
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { 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 { play(); }
}, { signal: off.signal });
// 창 크기나 화소 배율이 바뀌면 — 캔버스 내부 크기와 그림을 새 배율로 다시
window.addEventListener('resize', function (): void {
fitCanvas();
prepareBoard();
draw();
}, { signal: off.signal });
bindInput(off.signal);
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("jz") + " is-demo"} ref={rootRef}>
<canvas className={cx("jz__canvas")} data-role="canvas" width={480} height={300} role="img" tabIndex={0} aria-label={'조각 하나를 맞는 이웃 옆으로 옮기면 거리와 각도가 문턱 안일 때만 스프링으로 당겨 붙는 퍼즐 판. 화살표 키로 조각을 옮길 수 있다'}></canvas>
<p className={cx("jz__status")} data-role="status" aria-live="off">{'조각을 옮기는 중'}</p>
<button className={cx("jz__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}02 距離と角度のしきい値の内側でだけ引き寄せる吸着 — 計測 累積面積 17.341% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · javascript ジグソーパズル 作り方 9種 — 曲線のピースと吸着
このデモが属する記事
javascript ジグソーパズル 作り方 9種 — 曲線のピースと吸着javascript ジグソーパズル 作り方の出発点は、完成をどう見分けるかです。ジグソーが完成するのは、盤の上のまとまりが一つになった瞬間です。01番は隣り合う二つのピースが一本の曲線を裏返して分け合い、すき間をなくします。02番は距離と この記事のデモをすべて見る


