javascript ジグソーパズル 作り方 9種 — 曲線のピースと吸着
javascript ジグソーパズル 作り方の出発点は、完成をどう見分けるかです。ジグソーが完成するのは、盤の上のまとまりが一つになった瞬間です。01番は隣り合う二つのピースが一本の曲線を裏返して分け合い、すき間をなくします。
01 jig-shared-curve-tab-cut
02 jig-snap-threshold-spring
03 jig-group-root-merge
04 jig-quarter-turn-spring-rotate
05 jig-tray-scatter-overlap-relax
06 jig-edge-first-auto-solver
07 jig-lift-tilt-shadow-height
08 jig-complete-seam-wave-gloss
09 jig-hero-self-assemble-scene
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 隣と曲線を分け合うピースの切り出し
- 02 距離と角度のしきい値の内側でだけ引き寄せる吸着
- 03 つながったピースを代表一つにまとめる合体
- 04 90度ずつ回して合わせる回転ピース
- 05 重ならずに広がるピースのトレイ
- 06 縁から解いていく自動ソルバー
- 07 持ち上げたピースの傾きと影の高さ
- 08 完成の瞬間に広がる継ぎ目の波と光沢
- 09 ファーストビューでひとりでに組み上がるパズルの場面
写真を四角に切るだけではジグソーになりません — ピースの形を決めるのは隣と分け合う縁の曲線です
ただの四角に切った絵は、形だけ見ればどのピースもどのマスにも入るので、遊ぶ人の手がかりは絵柄しか残りません。ジグソーのピースは内側の辺ごとにこぶが外へ張り出し、その辺の向かいのピースには同じ形のくぼみがあるので、色より先に輪郭が相手を絞り込みます。ジグソーパズル 自作 カットを画面の上でやるなら、二つのピースがそれぞれ似た曲線を描くのではなく、一本の曲線を一緒に使うことが、髪の毛ほどのすき間も出さずにはめるための条件になります。
一本の辺を五つの曲線区間で書く
このデモの内側の辺は、肩・首・こぶの頂上・首・肩と続く3次曲線の五区間です。tabEdge は辺に沿った向きを u、それに直角な向きを v として辺の長さを単位に書き、v にタブの符号(+1 か −1)を掛けるので、符号一つでこぶが張り出すかくぼみがへこむかが決まります。こぶの半径は TAB_SIZE 0.2 の半分に最大12%の揺らぎ(KNOB_JITTER)、首の幅はその半径の0.62倍(NECK_RATIO)、こぶの中心は辺の真ん中から最大0.06ずれ(EDGE_WOBBLE)、どれも種486の乱数の流れ一本から辺の順に取り出すので、読み込み直しても同じ盤ができます。外周はまっすぐな一区間(flatEdge)なので、角のピースはまっすぐな辺が二本、縁のピースは一本になります。
画面の輪郭は曲線ではなく折れ線
曲線は点を置くためだけに使われます。chainInto が区間ごとに12等分(CURVE_STEPS)して lineTo でつなぐので、絵を切り抜く clip、クリックがどのピースに当たったかを問う isPointInPath、計算側の pointInPoly の三つが同じ折れ線を見ます。描いた輪郭と当たりの輪郭がずれていると、見えているこぶの先を押したのに隣のピースをつかんでしまいますが、この作りならそのずれが入り込む余地がありません。
javascript ジグソーパズル 作り方 9種 — 曲線を分け合うピースの切り出し・しきい値の内側で働くばねの吸着・代表一つにまとめる合体・90度ずつ回して合わせるピース・重ならずに広がるトレイ・縁から解く自動ソルバー・持ち上げたピースの傾きと影・完成の波と光沢・ひとりでに組み上がるファーストビューの場面
01〜04はピース一つが盤の上で従う規則(切る・吸着する・まとめる・回す)、05〜07は盤のまわりと手の動き(トレイ・ソルバー・持ち上げ)、08は完成の瞬間で、09は前の八つを一つのオープニング場面につなぎます。九つとも内部座標480×300の2Dキャンバス一枚で動き、絵は外から読み込んだ写真ではありません。sceneryColor(u, v) が空・太陽・雲・遠い山・近い丘・野の花をピクセルごとに隠れたキャンバスへ一度だけ描き、ピースはその絵を自分の輪郭で切り抜くので、どこへ運ばれても元のマスの景色を持ち歩きます。
480×300のキャンバスで2秒間に24枚を撮った値
| 項目 | 累積面積 | 動いたフレーム | 強度の平均 |
|---|---|---|---|
| 01 曲線を分け合う切り出し | 47.4% | 23/23 | 50.1 |
| 02 二つのしきい値で働くばねの吸着 | 17.3% | 23/23 | 86.5 |
| 03 代表一つにまとめる合体 | 26.9% | 23/23 | 66.5 |
| 04 90度ずつ回すピース | 19.9% | 23/23 | 79.8 |
| 05 重ならずに広がるトレイ | 24.2% | 19/23 | 62.2 |
| 06 縁から解く自動ソルバー | 28.9% | 23/23 | 68.2 |
| 07 持ち上げたピースの傾き | 36.3% | 23/23 | 84.0 |
| 08 完成の波と光沢 | 26.4% | 23/23 | 35.0 |
| 09 ひとりでに組み上がるファーストビュー | 39.8% | 23/23 | 59.1 |
01の面積がいちばん広いのは、12個のピースがすべて外へ広がってからまた集まるためと考えられます。05だけが19/23なのは、最初の0.15秒はピースが一点に積まれたままで、重なりが0になったあとはトレイが止まるためと考えられます。08の強度がいちばん低いのは、盤が浮くのが3pxほどで、変化の大半が継ぎ目の線が薄れていくことだからと考えられます。
01隣と曲線を分け合うピースの切り出し
家族写真パズルのギフトページで、遊ぶ前にどう切れるかを見せるプレビューに向く場面で、0.25秒から内側の辺17本が先端にコーラル色の点をつけて一本ずつ引かれ、1.15秒からは各ピースが0.05秒ずつ遅れて盤の中心から外へ離れて浮きます。隣り合う17組はどれも片方のチェーンがもう片方を裏返したものと点ごとの差0で一致してこぶ一つとくぼみ一つが向き合い、広がりのばね(硬さ90・減衰11)は角のピースを最大33.1px運んで目標を10.3%行き過ぎてから戻ります。右の4倍拡大のうち上段は一本を共有した継ぎ目、下段はピースごとに自分の角を整数ピクセルへ丸めて描いた継ぎ目です。

<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([]);
}
// 01 — 이웃과 곡선을 나눠 쓰는 조각 자르기. 판 4×3, 조각 한 변 55.5px(소수 자리가 있어야 「조각마다 따로 반올림」 의 틈이 보인다).
// 0.25초부터 内側 변을 한 줄씩 긋고(CUT_S 동안), LIFT_AT_S 부터 조각이 판 가운데에서 바깥쪽으로 SPREAD_GAIN 만큼 벌어지며 떠오른다.
// RETURN_AT_S 에 제자리로 돌아와 다시 한 장이 된다
const COLS = 4;
const ROWS = 3;
const PIECE = 55.5;
const BOARD_X = 33.5;
const BOARD_Y = 52.25;
const CUT_AT_S = 0.25;
const CUT_S = 0.85;
const LIFT_AT_S = 1.15;
const LIFT_STAGGER_S = 0.05;
const SPREAD_GAIN = 0.3;
const SPREAD_STIFFNESS = 90;
const SPREAD_DAMPING = 11;
const RETURN_AT_S = 2.9;
const LOOP_S = 3.9;
const STATUS_CUT = '内側の辺 {e}本のうち {k}本を引いています';
const STATUS_LIFT = 'ピース {n}個・内側の辺 {e}本 — 辺ごとにこぶ一つと合うくぼみ一つ';
// 조각마다 자기 왼쪽 위 모서리를 정수 화소로 반올림해 따로 그리는 비교판 — 그 반올림 몫(px)
function roundShift(v) { return Math.round(v) - v; }
// 内側 변 목록 — 위 변(줄 > 0)과 왼 변(칸 > 0)만 모으면 변 하나를 한 번씩 센다
function innerEdges() {
const out = [];
for (const d of puzzle.defs) {
if (d.row > 0) {
out.push(d.sides[0].pts);
}
if (d.col > 0) {
out.push(d.sides[3].pts);
}
}
return out;
}
// 이음매가 벌어진 폭 — 오른쪽(아래) 조각의 반올림 몫 − 왼쪽(위) 조각의 반올림 몫. 양수면 틈, 음수면 겹침
function seamOpening(a, b) {
if (a.row === b.row) {
return roundShift(b.hx - PIECE / 2) - roundShift(a.hx - PIECE / 2);
}
return roundShift(b.hy - PIECE / 2) - roundShift(a.hy - PIECE / 2);
}
let edges01 = [];
let cutProgress = 0;
let showWhole = true;
function reset() {
newBoard(COLS, ROWS, PIECE, BOARD_X, BOARD_Y, PUZZLE_SEED);
edges01 = innerEdges();
cutProgress = 0;
showWhole = true;
}
function spreadTarget(i) {
const d = puzzle.defs[i];
const cx = BOARD_X + COLS * PIECE / 2;
const cy = BOARD_Y + ROWS * PIECE / 2;
return [d.hx + (d.hx - cx) * SPREAD_GAIN, d.hy + (d.hy - cy) * SPREAD_GAIN];
}
function update(dt) {
cutProgress = clamp((sceneT - CUT_AT_S) / CUT_S, 0, 1);
showWhole = sceneT < LIFT_AT_S;
for (let i = 0; i < pieces.length; i += 1) {
const p = pieces[i];
const out = sceneT >= LIFT_AT_S + i * LIFT_STAGGER_S && sceneT < RETURN_AT_S;
const d = puzzle.defs[i];
const s = out ? spreadTarget(i) : [d.hx, d.hy];
p.tx = s[0];
p.ty = s[1];
p.th = out ? 0.6 : 0;
}
stepWorld(dt, SPREAD_STIFFNESS, SPREAD_DAMPING);
simStatus = showWhole ? fmt(STATUS_CUT, { e: edges01.length, k: Math.round(cutProgress * edges01.length) }) : fmt(STATUS_LIFT, { n: pieces.length, e: edges01.length });
}
function restPose() {
assembleAll();
cutProgress = 1;
showWhole = false;
simStatus = fmt(STATUS_LIFT, { n: pieces.length, e: edges01.length });
}
// 01 화면 — 왼쪽은 판(자르기 → 벌어지기), 오른쪽 두 칸은 같은 이음매를 4배로 키운 흐린 비교.
// 위 칸 = 두 조각이 사슬 하나를 함께 씀(틈 0), 아래 칸 = 조각마다 자기 모서리를 정수 화소로 반올림해 따로 그림(반 픽셀 틈)
const ZOOM_01 = 4;
const PANEL_X = 296;
const PANEL_W = 170;
const PANEL_H = 88;
const PANEL_Y = [28, 140];
const LABEL_SHARED = '曲線を一本共有 — すき間 0px';
const LABEL_ROUNDED = 'ピースごとに丸めた — すき間 {g}px';
const LABEL_ZOOM = '同じ継ぎ目を4倍に拡大';
const flatEdges01 = [];
// 모든 内側 이음매 중 가장 넓게 벌어진 폭
function widestGap() {
let g = 0;
for (const d of puzzle.defs) {
if (d.col > 0) {
g = Math.max(g, seamOpening(puzzle.defs[d.id - 1], d));
}
if (d.row > 0) {
g = Math.max(g, seamOpening(puzzle.defs[d.id - COLS], d));
}
}
return g;
}
function drawCutLines() {
if (flatEdges01.length !== edges01.length) {
flatEdges01.length = 0;
for (const e of edges01) {
flatEdges01.push(flattenChain(e, 8));
}
}
const n = flatEdges01.length;
flatEdges01.forEach(function (f, k) {
const part = clamp(cutProgress * n - k, 0, 1);
if (part <= 0) {
return;
}
const last = Math.max(2, Math.round(part * (f.length / 2 - 1)) * 2);
ctx.beginPath();
ctx.moveTo(f[0], f[1]);
for (let q = 2; q <= last; q += 2) {
ctx.lineTo(f[q], f[q + 1]);
}
ctx.lineWidth = 1.6;
ctx.strokeStyle = rgba(INK, 0.8);
ctx.stroke();
if (part < 1) {
disc(f[last], f[last + 1], 3.2, CORAL_DEEP, 1);
}
});
}
function drawSeamPanel(y, rounded) {
const left = puzzle.defs[COLS + 1];
const right = puzzle.defs[COLS + 2];
const fx = BOARD_X + 2 * PIECE;
const fy = BOARD_Y + PIECE * (1.2 + 0.6 * (0.5 - 0.5 * Math.cos(sceneT * TAU / LOOP_S)));
ctx.save();
roundRectPath(PANEL_X, y, PANEL_W, PANEL_H, 10);
ctx.fillStyle = rgba(CREAM, 1);
ctx.fill();
ctx.clip();
ctx.translate(PANEL_X + PANEL_W / 2, y + PANEL_H / 2);
ctx.scale(ZOOM_01, ZOOM_01);
ctx.translate(-fx, -fy);
for (const d of [left, right]) {
const sx = rounded ? roundShift(d.hx - PIECE / 2) : 0;
const sy = rounded ? roundShift(d.hy - PIECE / 2) : 0;
ctx.save();
ctx.translate(d.hx + sx, d.hy + sy);
ctx.save();
ctx.clip(paths[d.id]);
ctx.drawImage(pic, BOARD_X - d.hx, BOARD_Y - d.hy, COLS * PIECE, ROWS * PIECE);
ctx.restore();
ctx.lineWidth = 1.2 / ZOOM_01;
ctx.strokeStyle = rgba(INK, 0.5);
ctx.stroke(sidePaths[d.id][d.id === left.id ? 1 : 3]);
ctx.restore();
}
ctx.restore();
roundRectPath(PANEL_X, y, PANEL_W, PANEL_H, 10);
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.55);
ctx.stroke();
pill(rounded ? fmt(LABEL_ROUNDED, { g: widestGap().toFixed(1) }) : LABEL_SHARED, PANEL_X + PANEL_W / 2, y + PANEL_H + 11, rounded ? CORAL_DEEP : MINT_DEEP, CREAM, 1, 10);
}
function draw() {
clearStage(PAPER);
drawBoardFrame(1);
if (showWhole && !handsOn) {
ctx.drawImage(pic, BOARD_X, BOARD_Y, COLS * PIECE, ROWS * PIECE);
drawCutLines();
}
else {
drawAllPieces(function (i) { void i; return PLAIN; });
}
drawSelection();
label(LABEL_ZOOM, PANEL_X + PANEL_W / 2, 17, INK, 1, 11, 700, 'center');
drawSeamPanel(PANEL_Y[0], false);
drawSeamPanel(PANEL_Y[1], true);
}
// 상태 줄 — 바뀔 때만 글자를 고친다
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();
};
})();// SharedCurvePieceCutter - 같은 항목 vanilla 판 index.html 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./SharedCurvePieceCutter.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SharedCurvePieceCutter() {
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([]);
}
// 01 — 이웃과 곡선을 나눠 쓰는 조각 자르기. 판 4×3, 조각 한 변 55.5px(소수 자리가 있어야 「조각마다 따로 반올림」 의 틈이 보인다).
// 0.25초부터 안쪽 변을 한 줄씩 긋고(CUT_S 동안), LIFT_AT_S 부터 조각이 판 가운데에서 바깥쪽으로 SPREAD_GAIN 만큼 벌어지며 떠오른다.
// RETURN_AT_S 에 제자리로 돌아와 다시 한 장이 된다
const COLS = 4;
const ROWS = 3;
const PIECE = 55.5;
const BOARD_X = 33.5;
const BOARD_Y = 52.25;
const CUT_AT_S = 0.25;
const CUT_S = 0.85;
const LIFT_AT_S = 1.15;
const LIFT_STAGGER_S = 0.05;
const SPREAD_GAIN = 0.3;
const SPREAD_STIFFNESS = 90;
const SPREAD_DAMPING = 11;
const RETURN_AT_S = 2.9;
const LOOP_S = 3.9;
const STATUS_CUT = '안쪽 변 {e}개 중 {k}개를 긋는 중';
const STATUS_LIFT = '조각 {n}개 · 안쪽 변 {e}개 — 변마다 혹 하나와 맞는 홈 하나';
// 조각마다 자기 왼쪽 위 모서리를 정수 화소로 반올림해 따로 그리는 비교판 — 그 반올림 몫(px)
function roundShift(v: number): number { return Math.round(v) - v; }
// 안쪽 변 목록 — 위 변(줄 > 0)과 왼 변(칸 > 0)만 모으면 변 하나를 한 번씩 센다
function innerEdges(): number[][] {
const out: number[][] = [];
for (const d of puzzle.defs) {
if (d.row > 0) { out.push(d.sides[0].pts); }
if (d.col > 0) { out.push(d.sides[3].pts); }
}
return out;
}
// 이음매가 벌어진 폭 — 오른쪽(아래) 조각의 반올림 몫 − 왼쪽(위) 조각의 반올림 몫. 양수면 틈, 음수면 겹침
function seamOpening(a: PieceDef, b: PieceDef): number {
if (a.row === b.row) { return roundShift(b.hx - PIECE / 2) - roundShift(a.hx - PIECE / 2); }
return roundShift(b.hy - PIECE / 2) - roundShift(a.hy - PIECE / 2);
}
let edges01: number[][] = [];
let cutProgress = 0;
let showWhole = true;
function reset(): void {
newBoard(COLS, ROWS, PIECE, BOARD_X, BOARD_Y, PUZZLE_SEED);
edges01 = innerEdges();
cutProgress = 0;
showWhole = true;
}
function spreadTarget(i: number): [number, number] {
const d = puzzle.defs[i];
const cx = BOARD_X + COLS * PIECE / 2;
const cy = BOARD_Y + ROWS * PIECE / 2;
return [d.hx + (d.hx - cx) * SPREAD_GAIN, d.hy + (d.hy - cy) * SPREAD_GAIN];
}
function update(dt: number): void {
cutProgress = clamp((sceneT - CUT_AT_S) / CUT_S, 0, 1);
showWhole = sceneT < LIFT_AT_S;
for (let i = 0; i < pieces.length; i += 1) {
const p = pieces[i];
const out = sceneT >= LIFT_AT_S + i * LIFT_STAGGER_S && sceneT < RETURN_AT_S;
const d = puzzle.defs[i];
const s = out ? spreadTarget(i) : [d.hx, d.hy];
p.tx = s[0];
p.ty = s[1];
p.th = out ? 0.6 : 0;
}
stepWorld(dt, SPREAD_STIFFNESS, SPREAD_DAMPING);
simStatus = showWhole ? fmt(STATUS_CUT, { e: edges01.length, k: Math.round(cutProgress * edges01.length) }) : fmt(STATUS_LIFT, { n: pieces.length, e: edges01.length });
}
function restPose(): void {
assembleAll();
cutProgress = 1;
showWhole = false;
simStatus = fmt(STATUS_LIFT, { n: pieces.length, e: edges01.length });
}
// 01 화면 — 왼쪽은 판(자르기 → 벌어지기), 오른쪽 두 칸은 같은 이음매를 4배로 키운 흐린 비교.
// 위 칸 = 두 조각이 사슬 하나를 함께 씀(틈 0), 아래 칸 = 조각마다 자기 모서리를 정수 화소로 반올림해 따로 그림(반 픽셀 틈)
const ZOOM_01 = 4;
const PANEL_X = 296;
const PANEL_W = 170;
const PANEL_H = 88;
const PANEL_Y = [28, 140];
const LABEL_SHARED = '곡선 하나를 함께 씀 — 틈 0px';
const LABEL_ROUNDED = '조각마다 따로 반올림 — 틈 {g}px';
const LABEL_ZOOM = '같은 이음매 4배 확대';
const flatEdges01: number[][] = [];
// 모든 안쪽 이음매 중 가장 넓게 벌어진 폭
function widestGap(): number {
let g = 0;
for (const d of puzzle.defs) {
if (d.col > 0) { g = Math.max(g, seamOpening(puzzle.defs[d.id - 1], d)); }
if (d.row > 0) { g = Math.max(g, seamOpening(puzzle.defs[d.id - COLS], d)); }
}
return g;
}
function drawCutLines(): void {
if (flatEdges01.length !== edges01.length) {
flatEdges01.length = 0;
for (const e of edges01) { flatEdges01.push(flattenChain(e, 8)); }
}
const n = flatEdges01.length;
flatEdges01.forEach(function (f: number[], k: number): void {
const part = clamp(cutProgress * n - k, 0, 1);
if (part <= 0) { return; }
const last = Math.max(2, Math.round(part * (f.length / 2 - 1)) * 2);
ctx.beginPath();
ctx.moveTo(f[0], f[1]);
for (let q = 2; q <= last; q += 2) { ctx.lineTo(f[q], f[q + 1]); }
ctx.lineWidth = 1.6;
ctx.strokeStyle = rgba(INK, 0.8);
ctx.stroke();
if (part < 1) { disc(f[last], f[last + 1], 3.2, CORAL_DEEP, 1); }
});
}
function drawSeamPanel(y: number, rounded: boolean): void {
const left = puzzle.defs[COLS + 1];
const right = puzzle.defs[COLS + 2];
const fx = BOARD_X + 2 * PIECE;
const fy = BOARD_Y + PIECE * (1.2 + 0.6 * (0.5 - 0.5 * Math.cos(sceneT * TAU / LOOP_S)));
ctx.save();
roundRectPath(PANEL_X, y, PANEL_W, PANEL_H, 10);
ctx.fillStyle = rgba(CREAM, 1);
ctx.fill();
ctx.clip();
ctx.translate(PANEL_X + PANEL_W / 2, y + PANEL_H / 2);
ctx.scale(ZOOM_01, ZOOM_01);
ctx.translate(-fx, -fy);
for (const d of [left, right]) {
const sx = rounded ? roundShift(d.hx - PIECE / 2) : 0;
const sy = rounded ? roundShift(d.hy - PIECE / 2) : 0;
ctx.save();
ctx.translate(d.hx + sx, d.hy + sy);
ctx.save();
ctx.clip(paths[d.id]);
ctx.drawImage(pic, BOARD_X - d.hx, BOARD_Y - d.hy, COLS * PIECE, ROWS * PIECE);
ctx.restore();
ctx.lineWidth = 1.2 / ZOOM_01;
ctx.strokeStyle = rgba(INK, 0.5);
ctx.stroke(sidePaths[d.id][d.id === left.id ? 1 : 3]);
ctx.restore();
}
ctx.restore();
roundRectPath(PANEL_X, y, PANEL_W, PANEL_H, 10);
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.55);
ctx.stroke();
pill(rounded ? fmt(LABEL_ROUNDED, { g: widestGap().toFixed(1) }) : LABEL_SHARED, PANEL_X + PANEL_W / 2, y + PANEL_H + 11, rounded ? CORAL_DEEP : MINT_DEEP, CREAM, 1, 10);
}
function draw(): void {
clearStage(PAPER);
drawBoardFrame(1);
if (showWhole && !handsOn) {
ctx.drawImage(pic, BOARD_X, BOARD_Y, COLS * PIECE, ROWS * PIECE);
drawCutLines();
} else {
drawAllPieces(function (i: number): PieceLook { void i; return PLAIN; });
}
drawSelection();
label(LABEL_ZOOM, PANEL_X + PANEL_W / 2, 17, INK, 1, 11, 700, 'center');
drawSeamPanel(PANEL_Y[0], false);
drawSeamPanel(PANEL_Y[1], true);
}
// 상태 줄 — 바뀔 때만 글자를 고친다
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>
);
}01 · 隣と曲線を分け合うピースの切り出し — 計測 累積面積 47.428% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// チェーンを裏返す — 点の順番だけを逆にする。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;
}
02距離と角度のしきい値の内側でだけ引き寄せる吸着
子ども向け学習サイトの絵合わせのように、雑に置いてもはまってほしいけれど違う場所でははまってほしくない画面の吸着で、まねた手が右中央のピースを三回置きます。0.7秒は距離6.4pxでも角度の差18°が角度のしきい値8°の外、1.3秒は差0°でも距離36.77pxが距離のしきい値14pxの外なので、どちらも「しきい値の外」の札とともにその場に残り、1.854秒の11.4px・3°だけが「吸着」になります。つながったあとはばね(硬さ160・減衰14)が残りの11.37pxを引き寄せ、0.288秒に1.35px行き過ぎて、0.417秒からは±0.5pxの帯の内側にとどまります。

<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 に同梱 · パスワードを見る
// まとまり 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;
}
03つながったピースを代表一つにまとめる合体
チーム紹介ページの集合写真パズルのように、つなぐたびに育つ塊を一つとして引きずる画面向けで、0.5秒から0.4秒ごとにピースを一つずつ正しい位置から6pxずらして置き、合わせて11回合体させます。1.3秒の2+2のように大きさが同じときは運んできたまとまりの代表(ピース5)が残り、1.7秒(代表3)と2.9秒(代表9)に別の場所で始まったまとまりも大きいほうと出会うと代表5の下に入るので、最後の4.5秒まで代表5が生き残ります。終わったあと右の4×3の表は12マスすべてが5を指していて、代表まで2段以上離れたピースは一つもありません。

<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([]);
}
// 03 — 붙은 조각을 대표 하나로 묶는 묶음 합치기. 판 4×3 조각이 흩어져 있고, 흉내 낸 손이 MERGE_PLAN 순서로
// 묶음 하나를 들어 이웃 묶음 옆 바른 자리 근처(CARRY_MISS 만큼 어긋나게)에 놓는다. 놓으면 02 와 같은 문턱 판정으로 붙고,
// 두 묶음 중 조각 수가 적은 쪽 대표가 많은 쪽 대표 아래로 들어간다(같으면 들고 온 묶음의 대표가 남는다). 이음매는 SEAM_FADE_S 동안 SEAM_MIN 까지 옅어진다
const COLS = 4;
const ROWS = 3;
const PIECE = 46;
const BOARD_X = 58;
const BOARD_Y = 66;
const SCATTER_GAIN = 0.55;
const SCATTER_JITTER = 10;
// [옮길 조각, 옆에 붙을 조각] — 두 조각이 속한 묶음끼리 합쳐진다
const MERGE_PLAN = [[1, 0], [5, 4], [4, 0], [3, 2], [2, 1], [6, 5], [9, 8], [8, 4], [7, 6], [10, 9], [11, 10]];
const MERGE_START_S = 0.2;
const MERGE_EVERY_S = 0.4;
const CARRY_S = 0.3;
const CARRY_MISS = 6;
const SEAM_FADE_S = 0.5;
const SEAM_MIN = 0.12;
const LOOP_S = 5.6;
const STATUS_MERGE = '{a}個のまとまりが {b}個のまとまりの代表 {r} の下へ・残りのまとまり {g}';
let lastMerge = [];
function reset() {
newBoard(COLS, ROWS, PIECE, BOARD_X, BOARD_Y, PUZZLE_SEED);
const rng = makeRng(PUZZLE_SEED + 3);
const cx = BOARD_X + COLS * PIECE / 2;
const cy = BOARD_Y + ROWS * PIECE / 2;
for (let i = 0; i < pieces.length; i += 1) {
const d = puzzle.defs[i];
placeAt(i, d.hx + (d.hx - cx) * SCATTER_GAIN + spread(rng) * SCATTER_JITTER, d.hy + (d.hy - cy) * SCATTER_GAIN + spread(rng) * SCATTER_JITTER, 0);
}
resetCarries();
MERGE_PLAN.forEach(function (m, k) {
const t0 = MERGE_START_S + k * MERGE_EVERY_S;
const ang = k * 2.4;
planCarryNext(m[0], m[1], t0, t0 + CARRY_S, Math.cos(ang) * CARRY_MISS, Math.sin(ang) * CARRY_MISS);
});
lastMerge = [];
onRelease = function (i) {
const c = bestSnap(i);
if (!c.ok) {
return;
}
const a = groupSize[findRoot(c.m)];
const b = groupSize[findRoot(c.n)];
tryJoin(i);
lastMerge = [Math.min(a, b), Math.max(a, b), findRoot(c.n)];
};
simStatus = fmt(STATUS_FREE, { g: groupCount(), j: joinsMade(), n: pieces.length - 1 });
}
function update(dt) {
runCarries(sceneT);
for (const p of pieces) {
p.th = 0;
}
if (carrying >= 0) {
for (const k of membersOf(carrying)) {
pieces[k].th = 0.7;
}
}
stepWorld(dt, SNAP_STIFFNESS, SNAP_DAMPING);
if (lastMerge.length) {
simStatus = groupCount() === 1 ? fmt(STATUS_DONE, { n: pieces.length }) : fmt(STATUS_MERGE, { a: lastMerge[0], b: lastMerge[1], r: lastMerge[2], g: groupCount() });
}
}
function restPose() {
assembleAll();
resetCarries();
simStatus = fmt(STATUS_DONE, { n: pieces.length });
}
// 03 화면 — 조각 가운데 점은 묶음 색(대표 번호로 정함), 대표 조각 점에는 진한 고리. 오른쪽은 대표 가리키기 표:
// 퍼즐과 같은 4×3 칸에 「조각 번호 → 가리키는 조각 번호」, 대표 칸은 진한 테두리. 위에는 残りのまとまり 수
const GROUP_COLORS = [LAVENDER, MINT, BUTTER, CORAL, SKY, PINK];
const TABLE_X = 310;
const TABLE_Y = 92;
const CELL_W = 36;
const CELL_H = 34;
const LABEL_LEFT = '残りのまとまり';
const LABEL_TABLE = 'ピース → 指しているピース';
const LABEL_ROOT = '太枠のマス = 代表';
seamAlpha = function (i, s) {
const t = seamJoinedAt[i * 4 + s];
if (t < 0) {
return 1;
}
return Math.max(SEAM_MIN, 1 - (sceneT - t) / SEAM_FADE_S);
};
function groupColor(i) { return GROUP_COLORS[findRoot(i) % GROUP_COLORS.length]; }
function drawTable() {
const g = groupCount();
label(LABEL_LEFT, TABLE_X, 30, INK, 1, 12, 700, 'left');
label(String(g), TABLE_X + 4, 62, VIOLET_DEEP, 1, 34, 800, 'left');
label(LABEL_TABLE, TABLE_X, TABLE_Y - 12, INK, 1, 10, 700, 'left');
for (let i = 0; i < pieces.length; i += 1) {
const d = puzzle.defs[i];
const x = TABLE_X + d.col * (CELL_W + 4);
const y = TABLE_Y + d.row * (CELL_H + 4);
const root = parent[i] === i;
fillRound(x, y, CELL_W, CELL_H, 6, groupColor(i), 1);
if (root) {
roundRectPath(x, y, CELL_W, CELL_H, 6);
ctx.lineWidth = 2.4;
ctx.strokeStyle = rgba(INK, 1);
ctx.stroke();
}
label(String(i), x + 5, y + 9, INK, 0.75, 9, 700, 'left');
label(root ? '●' : '→' + parent[i], x + CELL_W / 2 + 3, y + CELL_H / 2 + 3, INK, 1, root ? 11 : 12, 800, 'center');
}
label(LABEL_ROOT, TABLE_X, TABLE_Y + 3 * (CELL_H + 4) + 8, INK, 1, 10, 700, 'left');
}
function draw() {
clearStage(PAPER);
drawAllPieces(function (i) { void i; return PLAIN; });
for (let i = 0; i < pieces.length; i += 1) {
const p = pieces[i];
const root = findRoot(i) === i;
disc(p.x, p.y, root ? 6.5 : 4.5, groupColor(i), 1);
ring(p.x, p.y, root ? 6.5 : 4.5, root ? 2.4 : 1.2, INK, root ? 1 : 0.7);
}
drawSelection();
drawTable();
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();
};
})();// GroupRootMerge - 같은 항목 vanilla 판 index.html 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./GroupRootMerge.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function GroupRootMerge() {
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([]);
}
// 03 — 붙은 조각을 대표 하나로 묶는 묶음 합치기. 판 4×3 조각이 흩어져 있고, 흉내 낸 손이 MERGE_PLAN 순서로
// 묶음 하나를 들어 이웃 묶음 옆 바른 자리 근처(CARRY_MISS 만큼 어긋나게)에 놓는다. 놓으면 02 와 같은 문턱 판정으로 붙고,
// 두 묶음 중 조각 수가 적은 쪽 대표가 많은 쪽 대표 아래로 들어간다(같으면 들고 온 묶음의 대표가 남는다). 이음매는 SEAM_FADE_S 동안 SEAM_MIN 까지 옅어진다
const COLS = 4;
const ROWS = 3;
const PIECE = 46;
const BOARD_X = 58;
const BOARD_Y = 66;
const SCATTER_GAIN = 0.55;
const SCATTER_JITTER = 10;
// [옮길 조각, 옆에 붙을 조각] — 두 조각이 속한 묶음끼리 합쳐진다
const MERGE_PLAN: number[][] = [[1, 0], [5, 4], [4, 0], [3, 2], [2, 1], [6, 5], [9, 8], [8, 4], [7, 6], [10, 9], [11, 10]];
const MERGE_START_S = 0.2;
const MERGE_EVERY_S = 0.4;
const CARRY_S = 0.3;
const CARRY_MISS = 6;
const SEAM_FADE_S = 0.5;
const SEAM_MIN = 0.12;
const LOOP_S = 5.6;
const STATUS_MERGE = '{a}개 묶음이 {b}개 묶음 대표 {r} 아래로 · 남은 묶음 {g}';
let lastMerge: number[] = [];
function reset(): void {
newBoard(COLS, ROWS, PIECE, BOARD_X, BOARD_Y, PUZZLE_SEED);
const rng = makeRng(PUZZLE_SEED + 3);
const cx = BOARD_X + COLS * PIECE / 2;
const cy = BOARD_Y + ROWS * PIECE / 2;
for (let i = 0; i < pieces.length; i += 1) {
const d = puzzle.defs[i];
placeAt(i, d.hx + (d.hx - cx) * SCATTER_GAIN + spread(rng) * SCATTER_JITTER, d.hy + (d.hy - cy) * SCATTER_GAIN + spread(rng) * SCATTER_JITTER, 0);
}
resetCarries();
MERGE_PLAN.forEach(function (m: number[], k: number): void {
const t0 = MERGE_START_S + k * MERGE_EVERY_S;
const ang = k * 2.4;
planCarryNext(m[0], m[1], t0, t0 + CARRY_S, Math.cos(ang) * CARRY_MISS, Math.sin(ang) * CARRY_MISS);
});
lastMerge = [];
onRelease = function (i: number): void {
const c = bestSnap(i);
if (!c.ok) { return; }
const a = groupSize[findRoot(c.m)];
const b = groupSize[findRoot(c.n)];
tryJoin(i);
lastMerge = [Math.min(a, b), Math.max(a, b), findRoot(c.n)];
};
simStatus = fmt(STATUS_FREE, { g: groupCount(), j: joinsMade(), n: pieces.length - 1 });
}
function update(dt: number): void {
runCarries(sceneT);
for (const p of pieces) { p.th = 0; }
if (carrying >= 0) { for (const k of membersOf(carrying)) { pieces[k].th = 0.7; } }
stepWorld(dt, SNAP_STIFFNESS, SNAP_DAMPING);
if (lastMerge.length) {
simStatus = groupCount() === 1 ? fmt(STATUS_DONE, { n: pieces.length }) : fmt(STATUS_MERGE, { a: lastMerge[0], b: lastMerge[1], r: lastMerge[2], g: groupCount() });
}
}
function restPose(): void {
assembleAll();
resetCarries();
simStatus = fmt(STATUS_DONE, { n: pieces.length });
}
// 03 화면 — 조각 가운데 점은 묶음 색(대표 번호로 정함), 대표 조각 점에는 진한 고리. 오른쪽은 대표 가리키기 표:
// 퍼즐과 같은 4×3 칸에 「조각 번호 → 가리키는 조각 번호」, 대표 칸은 진한 테두리. 위에는 남은 묶음 수
const GROUP_COLORS: RGB[] = [LAVENDER, MINT, BUTTER, CORAL, SKY, PINK];
const TABLE_X = 310;
const TABLE_Y = 92;
const CELL_W = 36;
const CELL_H = 34;
const LABEL_LEFT = '남은 묶음';
const LABEL_TABLE = '조각 → 가리키는 조각';
const LABEL_ROOT = '진한 칸 = 대표';
seamAlpha = function (i: number, s: number): number {
const t = seamJoinedAt[i * 4 + s];
if (t < 0) { return 1; }
return Math.max(SEAM_MIN, 1 - (sceneT - t) / SEAM_FADE_S);
};
function groupColor(i: number): RGB { return GROUP_COLORS[findRoot(i) % GROUP_COLORS.length]; }
function drawTable(): void {
const g = groupCount();
label(LABEL_LEFT, TABLE_X, 30, INK, 1, 12, 700, 'left');
label(String(g), TABLE_X + 4, 62, VIOLET_DEEP, 1, 34, 800, 'left');
label(LABEL_TABLE, TABLE_X, TABLE_Y - 12, INK, 1, 10, 700, 'left');
for (let i = 0; i < pieces.length; i += 1) {
const d = puzzle.defs[i];
const x = TABLE_X + d.col * (CELL_W + 4);
const y = TABLE_Y + d.row * (CELL_H + 4);
const root = parent[i] === i;
fillRound(x, y, CELL_W, CELL_H, 6, groupColor(i), 1);
if (root) {
roundRectPath(x, y, CELL_W, CELL_H, 6);
ctx.lineWidth = 2.4;
ctx.strokeStyle = rgba(INK, 1);
ctx.stroke();
}
label(String(i), x + 5, y + 9, INK, 0.75, 9, 700, 'left');
label(root ? '●' : '→' + parent[i], x + CELL_W / 2 + 3, y + CELL_H / 2 + 3, INK, 1, root ? 11 : 12, 800, 'center');
}
label(LABEL_ROOT, TABLE_X, TABLE_Y + 3 * (CELL_H + 4) + 8, INK, 1, 10, 700, 'left');
}
function draw(): void {
clearStage(PAPER);
drawAllPieces(function (i: number): PieceLook { void i; return PLAIN; });
for (let i = 0; i < pieces.length; i += 1) {
const p = pieces[i];
const root = findRoot(i) === i;
disc(p.x, p.y, root ? 6.5 : 4.5, groupColor(i), 1);
ring(p.x, p.y, root ? 6.5 : 4.5, root ? 2.4 : 1.2, INK, root ? 1 : 0.7);
}
drawSelection();
drawTable();
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>
);
}03 · つながったピースを代表一つにまとめる合体 — 計測 累積面積 26.863% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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;
}
0490度ずつ回して合わせる回転ピース
クイズアプリの上級モードのように向きまで合わせる画面の場面で、押すたびに目標の角度が90°増え、角度のばね(硬さ180・減衰11)は一度押してから0.25秒後に21.5°行き過ぎ、0.637秒からは±1°の内側に収まります。1.35秒にピース5は90°回ったまま正しい位置から5px、つまり距離のしきい値14pxの内側に置かれますが、札は「判定クローズ」のままその場に残り、まっすぐ立ってから2.5秒に同じ5pxで置いたときにはじめてはまります。判定に orientOk = upright(目標の角度を−π〜πに折りたたんで0かどうか)が入るのは04だけで、つながるときは下の tryJoin が目標の角度を残りの差 da だけ直すので、360°まで一周したピースが0°へ巻き戻りません。

<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="90度ずつ回ったピースを正しい向きに立ててはじめて隣にはめられるパズル盤。R キーで選んだピースを回せます"></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([]);
}
// 04 — 90도씩 돌려 맞추는 회전 조각. 판 3×3 중 LOOSE 넷이 TURNS_AT_START × 90도 돌아간 채 오른쪽에 놓여 있다.
// 누를 때마다 목표 각이 90도 늘고, 각은 각도 스프링 θ'' = −ROT_STIFFNESS·(θ − θ*) − ROT_DAMPING·θ' 로 넘쳤다 돌아온다.
// 목표 각이 360도의 배수(바로 섬)일 때만 02 의 吸着 판정이 열린다 — 돌아간 채 놓으면 문턱 안이어도 그 자리에 머문다
const COLS = 3;
const ROWS = 3;
const PIECE = 56;
const BOARD_X = 40;
const BOARD_Y = 50;
const LOOSE = [2, 5, 8, 7];
const TURNS_AT_START = [3, 1, 2, 1];
const LOOSE_AT = [[300, 64], [386, 68], [306, 146], [394, 150]];
// 누르기 일정표 [초, 조각] 과 옮기기 일정표 [시작 초, 끝 초, 조각, 바른 자리에서 x 어긋남, y 어긋남]
const TURN_PLAN = [[0.25, 2], [1.45, 5], [1.7, 5], [1.95, 5], [2.75, 8], [3.0, 8], [3.8, 7], [4.05, 7], [4.3, 7]];
const CARRY_PLAN = [[0.55, 0.9, 2, 5, 4], [1.0, 1.35, 5, 4, 3], [2.2, 2.5, 5, 3, -4], [3.25, 3.6, 8, -4, 5], [4.5, 4.85, 7, 5, -3]];
const LOOP_S = 5.8;
const STATUS_TURN = '90度回した — 目標 {a}°';
const STATUS_LOCKED = '{a}°回っているので吸着の判定は閉じたまま — その場に置く';
const STATUS_SNAP4 = '正しい向き — 判定が開く・つないだ所 {j}/{n}';
// 바로 섰는가 — 목표 각이 한 바퀴의 배수
function upright(i) { return Math.abs(wrapAngle(pieces[i].ta)) < 1e-3; }
let turnTrace = [];
let tracePiece = -1;
let traceFrom = 0;
let lastTurnAt = -1;
function reset() {
newBoard(COLS, ROWS, PIECE, BOARD_X, BOARD_Y, PUZZLE_SEED);
orientOk = upright;
const fixed = [];
for (let i = 0; i < pieces.length; i += 1) {
if (LOOSE.indexOf(i) < 0) {
fixed.push(i);
}
}
for (const i of fixed) {
if (i !== fixed[0]) {
joinGroups(fixed[0], i);
}
}
markSeams();
const turns = [];
LOOSE.forEach(function (i, k) {
placeAt(i, LOOSE_AT[k][0], LOOSE_AT[k][1], TURNS_AT_START[k] * Math.PI / 2);
turns.push(TURNS_AT_START[k]);
});
resetCarries();
// 놓을 때의 각 = 그 시각까지 누른 횟수만큼 돈 각
for (const c of CARRY_PLAN) {
const k = LOOSE.indexOf(c[2]);
let n = turns[k];
for (const t of TURN_PLAN) {
if (t[1] === c[2] && t[0] < c[0]) {
n += 1;
}
}
const d = puzzle.defs[c[2]];
planCarry(c[2], c[0], c[1], d.hx + c[3], d.hy + c[4], n * Math.PI / 2);
}
turnTrace = [];
tracePiece = -1;
lastTurnAt = -1;
onRelease = function (i) {
const c = tryJoin(i);
if (c.ok) {
simStatus = fmt(STATUS_SNAP4, { j: joinsMade(), n: pieces.length - 1 });
}
else if (!upright(i)) {
simStatus = fmt(STATUS_LOCKED, { a: ((Math.round(deg(pieces[i].ta)) % 360) + 360) % 360 });
}
else {
freeStatus();
}
};
}
function turnPiece(i) {
pieces[i].ta += Math.PI / 2;
if (tracePiece !== i) {
turnTrace = [];
traceFrom = sceneT;
}
tracePiece = i;
lastTurnAt = sceneT;
simStatus = fmt(STATUS_TURN, { a: Math.round(deg(pieces[i].ta)) % 360 });
}
function update(dt) {
const before = sceneT - dt;
for (const t of TURN_PLAN) {
if (t[0] > before && t[0] <= sceneT) {
turnPiece(t[1]);
}
}
runCarries(sceneT);
// 누르기 0.15초 전부터 0.1초 뒤까지 손이 그 조각 위로 간다(0.12초에 걸쳐 다가가고, 누르기 ±0.06초 동안 눌림)
for (const t of TURN_PLAN) {
if (sceneT > t[0] - 0.15 && sceneT < t[0] + 0.1 && carrying < 0) {
const p = pieces[t[1]];
const k = smooth((sceneT - (t[0] - 0.15)) / 0.12);
handX = lerp(handX, p.x + 6, k);
handY = lerp(handY, p.y + 4, k);
handDown = Math.abs(sceneT - t[0]) < 0.06;
}
}
for (const p of pieces) {
p.th = 0;
}
if (carrying >= 0) {
pieces[carrying].th = 0.8;
}
stepWorld(dt, SNAP_STIFFNESS, SNAP_DAMPING);
if (tracePiece >= 0) {
turnTrace.push([sceneT - traceFrom, deg(pieces[tracePiece].a - pieces[tracePiece].ta)]);
}
if (turnTrace.length > 400) {
turnTrace.shift();
}
}
function restPose() {
assembleAll();
resetCarries();
simStatus = fmt(STATUS_DONE, { n: pieces.length });
}
// 04 화면 — 떨어진 조각마다 위쪽을 가리키는 작은 세모(조각과 함께 돈다)와 지금 각, 判定オープン/닫힘 이름표.
// 오른쪽 아래 그래프는 마지막으로 누른 조각의 「いまの角度 − 目標の角度」 — 누를 때마다 −90도에서 출발해 0 을 지나쳤다 돌아온다
allowTurn = true;
const PLOT_X = 296;
const PLOT_Y = 212;
const PLOT_W = 170;
const PLOT_H = 48;
const PLOT_S = 1.2;
const PLOT_DEG = 100;
const LABEL_OPEN = '判定オープン';
const LABEL_SHUT = '判定クローズ';
const LABEL_PLOT = 'いまの角度 − 目標の角度';
function drawUpMark(i) {
const p = pieces[i];
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.a);
ctx.beginPath();
ctx.moveTo(0, -PIECE * 0.3);
ctx.lineTo(-6, -PIECE * 0.3 + 9);
ctx.lineTo(6, -PIECE * 0.3 + 9);
ctx.closePath();
ctx.fillStyle = rgba(INK, 0.85);
ctx.fill();
ctx.restore();
}
function drawTurnPlot() {
fillRound(PLOT_X, PLOT_Y, PLOT_W, PLOT_H, 8, CREAM, 1);
roundRectPath(PLOT_X, PLOT_Y, PLOT_W, PLOT_H, 8);
ctx.lineWidth = 1.2;
ctx.strokeStyle = rgba(INK, 0.4);
ctx.stroke();
const y0 = PLOT_Y + PLOT_H * 0.45;
const sy = (PLOT_H * 0.4) / PLOT_DEG;
line(PLOT_X + 8, y0, PLOT_X + PLOT_W - 8, y0, 1, INK, 0.35);
label(LABEL_PLOT, PLOT_X + 8, PLOT_Y + PLOT_H - 8, INK, 1, 9, 700, 'left');
ctx.beginPath();
let started = false;
for (const s of turnTrace) {
if (s[0] > PLOT_S) {
break;
}
const x = PLOT_X + 8 + (s[0] / PLOT_S) * (PLOT_W - 16);
const y = y0 - clamp(s[1], -PLOT_DEG, PLOT_DEG) * sy;
if (!started) {
ctx.moveTo(x, y);
started = true;
}
else {
ctx.lineTo(x, y);
}
}
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(VIOLET_DEEP, 1);
ctx.stroke();
}
function draw() {
clearStage(PAPER);
drawBoardFrame(1);
drawAllPieces(function (i) { void i; return PLAIN; });
for (const i of LOOSE) {
if (groupSize[findRoot(i)] > 1) {
continue;
}
const p = pieces[i];
drawUpMark(i);
const open = upright(i);
const a = ((Math.round(deg(p.a)) % 360) + 360) % 360;
pill(a + '°', p.x, p.y + 6, CREAM, INK, 0.92, 10);
pill(open ? LABEL_OPEN : LABEL_SHUT, p.x, p.y + PIECE * 0.5 + 14, open ? MINT_DEEP : CORAL_DEEP, CREAM, 1, 9);
}
drawSelection();
drawTurnPlot();
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();
};
})();// QuarterTurnPieces - 같은 항목 vanilla 판 index.html 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./QuarterTurnPieces.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function QuarterTurnPieces() {
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([]);
}
// 04 — 90도씩 돌려 맞추는 회전 조각. 판 3×3 중 LOOSE 넷이 TURNS_AT_START × 90도 돌아간 채 오른쪽에 놓여 있다.
// 누를 때마다 목표 각이 90도 늘고, 각은 각도 스프링 θ'' = −ROT_STIFFNESS·(θ − θ*) − ROT_DAMPING·θ' 로 넘쳤다 돌아온다.
// 목표 각이 360도의 배수(바로 섬)일 때만 02 의 맞물림 판정이 열린다 — 돌아간 채 놓으면 문턱 안이어도 그 자리에 머문다
const COLS = 3;
const ROWS = 3;
const PIECE = 56;
const BOARD_X = 40;
const BOARD_Y = 50;
const LOOSE: number[] = [2, 5, 8, 7];
const TURNS_AT_START: number[] = [3, 1, 2, 1];
const LOOSE_AT: number[][] = [[300, 64], [386, 68], [306, 146], [394, 150]];
// 누르기 일정표 [초, 조각] 과 옮기기 일정표 [시작 초, 끝 초, 조각, 바른 자리에서 x 어긋남, y 어긋남]
const TURN_PLAN: number[][] = [[0.25, 2], [1.45, 5], [1.7, 5], [1.95, 5], [2.75, 8], [3.0, 8], [3.8, 7], [4.05, 7], [4.3, 7]];
const CARRY_PLAN: number[][] = [[0.55, 0.9, 2, 5, 4], [1.0, 1.35, 5, 4, 3], [2.2, 2.5, 5, 3, -4], [3.25, 3.6, 8, -4, 5], [4.5, 4.85, 7, 5, -3]];
const LOOP_S = 5.8;
const STATUS_TURN = '90도 돌림 — 목표 {a}°';
const STATUS_LOCKED = '{a}° 돌아가 있어 맞물림 판정 닫힘 — 그 자리에 둠';
const STATUS_SNAP4 = '바로 섬 — 판정 열림 · 이은 자리 {j}/{n}';
// 바로 섰는가 — 목표 각이 한 바퀴의 배수
function upright(i: number): boolean { return Math.abs(wrapAngle(pieces[i].ta)) < 1e-3; }
let turnTrace: number[][] = [];
let tracePiece = -1;
let traceFrom = 0;
let lastTurnAt = -1;
function reset(): void {
newBoard(COLS, ROWS, PIECE, BOARD_X, BOARD_Y, PUZZLE_SEED);
orientOk = upright;
const fixed: number[] = [];
for (let i = 0; i < pieces.length; i += 1) { if (LOOSE.indexOf(i) < 0) { fixed.push(i); } }
for (const i of fixed) { if (i !== fixed[0]) { joinGroups(fixed[0], i); } }
markSeams();
const turns: number[] = [];
LOOSE.forEach(function (i: number, k: number): void {
placeAt(i, LOOSE_AT[k][0], LOOSE_AT[k][1], TURNS_AT_START[k] * Math.PI / 2);
turns.push(TURNS_AT_START[k]);
});
resetCarries();
// 놓을 때의 각 = 그 시각까지 누른 횟수만큼 돈 각
for (const c of CARRY_PLAN) {
const k = LOOSE.indexOf(c[2]);
let n = turns[k];
for (const t of TURN_PLAN) { if (t[1] === c[2] && t[0] < c[0]) { n += 1; } }
const d = puzzle.defs[c[2]];
planCarry(c[2], c[0], c[1], d.hx + c[3], d.hy + c[4], n * Math.PI / 2);
}
turnTrace = [];
tracePiece = -1;
lastTurnAt = -1;
onRelease = function (i: number): void {
const c = tryJoin(i);
if (c.ok) { simStatus = fmt(STATUS_SNAP4, { j: joinsMade(), n: pieces.length - 1 }); }
else if (!upright(i)) { simStatus = fmt(STATUS_LOCKED, { a: ((Math.round(deg(pieces[i].ta)) % 360) + 360) % 360 }); }
else { freeStatus(); }
};
}
function turnPiece(i: number): void {
pieces[i].ta += Math.PI / 2;
if (tracePiece !== i) { turnTrace = []; traceFrom = sceneT; }
tracePiece = i;
lastTurnAt = sceneT;
simStatus = fmt(STATUS_TURN, { a: Math.round(deg(pieces[i].ta)) % 360 });
}
function update(dt: number): void {
const before = sceneT - dt;
for (const t of TURN_PLAN) { if (t[0] > before && t[0] <= sceneT) { turnPiece(t[1]); } }
runCarries(sceneT);
// 누르기 0.15초 전부터 0.1초 뒤까지 손이 그 조각 위로 간다(0.12초에 걸쳐 다가가고, 누르기 ±0.06초 동안 눌림)
for (const t of TURN_PLAN) {
if (sceneT > t[0] - 0.15 && sceneT < t[0] + 0.1 && carrying < 0) {
const p = pieces[t[1]];
const k = smooth((sceneT - (t[0] - 0.15)) / 0.12);
handX = lerp(handX, p.x + 6, k);
handY = lerp(handY, p.y + 4, k);
handDown = Math.abs(sceneT - t[0]) < 0.06;
}
}
for (const p of pieces) { p.th = 0; }
if (carrying >= 0) { pieces[carrying].th = 0.8; }
stepWorld(dt, SNAP_STIFFNESS, SNAP_DAMPING);
if (tracePiece >= 0) { turnTrace.push([sceneT - traceFrom, deg(pieces[tracePiece].a - pieces[tracePiece].ta)]); }
if (turnTrace.length > 400) { turnTrace.shift(); }
}
function restPose(): void {
assembleAll();
resetCarries();
simStatus = fmt(STATUS_DONE, { n: pieces.length });
}
// 04 화면 — 떨어진 조각마다 위쪽을 가리키는 작은 세모(조각과 함께 돈다)와 지금 각, 판정 열림/닫힘 이름표.
// 오른쪽 아래 그래프는 마지막으로 누른 조각의 「지금 각 − 목표 각」 — 누를 때마다 −90도에서 출발해 0 을 지나쳤다 돌아온다
allowTurn = true;
const PLOT_X = 296;
const PLOT_Y = 212;
const PLOT_W = 170;
const PLOT_H = 48;
const PLOT_S = 1.2;
const PLOT_DEG = 100;
const LABEL_OPEN = '판정 열림';
const LABEL_SHUT = '판정 닫힘';
const LABEL_PLOT = '지금 각 − 목표 각';
function drawUpMark(i: number): void {
const p = pieces[i];
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.a);
ctx.beginPath();
ctx.moveTo(0, -PIECE * 0.3);
ctx.lineTo(-6, -PIECE * 0.3 + 9);
ctx.lineTo(6, -PIECE * 0.3 + 9);
ctx.closePath();
ctx.fillStyle = rgba(INK, 0.85);
ctx.fill();
ctx.restore();
}
function drawTurnPlot(): void {
fillRound(PLOT_X, PLOT_Y, PLOT_W, PLOT_H, 8, CREAM, 1);
roundRectPath(PLOT_X, PLOT_Y, PLOT_W, PLOT_H, 8);
ctx.lineWidth = 1.2;
ctx.strokeStyle = rgba(INK, 0.4);
ctx.stroke();
const y0 = PLOT_Y + PLOT_H * 0.45;
const sy = (PLOT_H * 0.4) / PLOT_DEG;
line(PLOT_X + 8, y0, PLOT_X + PLOT_W - 8, y0, 1, INK, 0.35);
label(LABEL_PLOT, PLOT_X + 8, PLOT_Y + PLOT_H - 8, INK, 1, 9, 700, 'left');
ctx.beginPath();
let started = false;
for (const s of turnTrace) {
if (s[0] > PLOT_S) { break; }
const x = PLOT_X + 8 + (s[0] / PLOT_S) * (PLOT_W - 16);
const y = y0 - clamp(s[1], -PLOT_DEG, PLOT_DEG) * sy;
if (!started) { ctx.moveTo(x, y); started = true; } else { ctx.lineTo(x, y); }
}
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(VIOLET_DEEP, 1);
ctx.stroke();
}
function draw(): void {
clearStage(PAPER);
drawBoardFrame(1);
drawAllPieces(function (i: number): PieceLook { void i; return PLAIN; });
for (const i of LOOSE) {
if (groupSize[findRoot(i)] > 1) { continue; }
const p = pieces[i];
drawUpMark(i);
const open = upright(i);
const a = ((Math.round(deg(p.a)) % 360) + 360) % 360;
pill(a + '°', p.x, p.y + 6, CREAM, INK, 0.92, 10);
pill(open ? LABEL_OPEN : LABEL_SHUT, p.x, p.y + PIECE * 0.5 + 14, open ? MINT_DEEP : CORAL_DEEP, CREAM, 1, 9);
}
drawSelection();
drawTurnPlot();
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={'90도씩 돌아간 조각을 바로 세운 뒤에야 이웃에 붙일 수 있는 퍼즐 판. R 키로 고른 조각을 돌릴 수 있다'}></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>
);
}04 · 90度ずつ回して合わせる回転ピース — 計測 累積面積 19.883% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 放したとき — しきい値の内側ならまとまりごと目標を正しい位置へ移してつなぐ。外側ならその場にとどまる
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;
}
05重ならずに広がるピースのトレイ
オンライン展示ページのピース選びトレイのように、散らばったピースに盤もほかのピースも隠してほしくない画面向けで、0.15秒に12個を三つの山へ投げ、0.5秒から1秒に18回、全部で36回、半径24.8pxの円どうしが重なった深さの半分ずつ押し合います。回ごとの重なりの合計は574.6→116.1→50.1pxと減って13回目に0.5pxを下回り、18回目からは0になり、盤の箱の中に落ちていた2個はいちばん近い外側の辺へ押し出されます。終わったときにいちばん近い二つの中心の間は49.6px、ちょうど半径の2倍なので、円どうしはぴったり接したところで止まります。

<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([]);
}
// 05 — 겹치지 않게 펼쳐지는 조각 쟁반. 판 4×3 조각이 THROW_AT_S 에 판 가운데에서 시드로 뽑은 자리(두 무더기 + 판 안 몇 개)로 던져지고,
// RELAX_AT_S 부터 1초에 RELAX_PER_S 번씩 relaxOnce 를 되풀이한다: 겹친 쌍은 겹친 깊이의 절반씩 서로 밀고, 판 상자 안 조각은 가장 가까운 바깥 변으로.
// 조각은 반지름 TRAY_R 원으로 본다. 위치는 목표를 스프링(TRAY_STIFFNESS·TRAY_DAMPING)으로 따라가 미끄러지듯 퍼진다
const COLS = 4;
const ROWS = 3;
const PIECE = 40;
const BOARD_X = 160;
const BOARD_Y = 84;
const TRAY_R = PIECE * 0.62;
const THROW_AT_S = 0.15;
const RELAX_AT_S = 0.5;
const RELAX_PER_S = 18;
const RELAX_ITERS = 36;
const TRAY_STIFFNESS = 70;
const TRAY_DAMPING = 13;
// 던질 자리 — [가운데 x, 가운데 y, 퍼짐 px, 조각 수] 무더기 셋
const HEAPS = [[86, 118, 22, 5], [398, 196, 22, 5], [236, 150, 30, 2]];
const LOOP_S = 3.6;
const STATUS_RELAX = '押し出し {k}回目・残りの重なり {o}px';
const STATUS_SPREAD = '重なり 0 — ピース {n}個が盤のまわりに広がった';
let relaxDone = 0;
let overlapLog = [];
let throwSpots = [];
function reset() {
newBoard(COLS, ROWS, PIECE, BOARD_X, BOARD_Y, PUZZLE_SEED);
const rng = makeRng(PUZZLE_SEED + 5);
throwSpots = [];
for (const hp of HEAPS) {
for (let k = 0; k < hp[3]; k += 1) {
throwSpots.push([hp[0] + spread(rng) * hp[2], hp[1] + spread(rng) * hp[2]]);
}
}
for (let i = 0; i < pieces.length; i += 1) {
placeAt(i, BOARD_X + COLS * PIECE / 2, BOARD_Y + ROWS * PIECE / 2, 0);
}
relaxDone = 0;
overlapLog = [];
simStatus = fmt(STATUS_RELAX, { k: 0, o: 0 });
}
function allIds() { return pieces.map(function (p, i) { void p; return i; }); }
function update(dt) {
if (sceneT >= THROW_AT_S && sceneT - dt < THROW_AT_S) {
throwSpots.forEach(function (s, i) { pieces[i].tx = s[0]; pieces[i].ty = s[1]; });
}
const want = clamp(Math.floor((sceneT - RELAX_AT_S) * RELAX_PER_S) + 1, 0, RELAX_ITERS);
while (relaxDone < want) {
overlapLog.push(relaxOnce(allIds(), TRAY_R, BOARD_X, BOARD_Y, BOARD_X + COLS * PIECE, BOARD_Y + ROWS * PIECE));
relaxDone += 1;
}
stepWorld(dt, TRAY_STIFFNESS, TRAY_DAMPING);
const last = overlapLog.length ? overlapLog[overlapLog.length - 1] : 0;
simStatus = relaxDone > 0 && last < 0.5 ? fmt(STATUS_SPREAD, { n: pieces.length }) : fmt(STATUS_RELAX, { k: relaxDone, o: Math.round(last) });
}
function restPose() {
assembleAll();
simStatus = fmt(STATUS_DONE, { n: pieces.length });
}
// 05 화면 — 가운데 빈 판 위에 화소 배율 비교 두 칸(같은 이음매를 배율 2 화면 기준으로 키움): 왼쪽은 캔버스 내부 크기에
// 배율을 곱한 판(이음매 둘레 16px 를 화소 32×32 로 그림), 오른쪽은 곱하지 않은 판(16×16 을 늘림 — 흐림). 그 아래는 밀어내기 횟수별 남은 겹침 그래프.
// 조각 둘레의 옅은 원은 겹침 판정에 쓰는 반지름 TRAY_R
const INSET_R = 16;
const INSET_BOX = 62;
const INSET_Y = 92;
const INSET_X = [172, 246];
const SPARK_Y = 186;
const LABEL_SHARP = '倍率を掛けた';
const LABEL_SOFT = '倍率が抜けた';
const LABEL_INSET = '倍率2の画面・同じ所を拡大';
const LABEL_SPARK = '押し出しごとの残りの重なり';
const sharpCv = document.createElement('canvas');
const softCv = document.createElement('canvas');
let insetKey = '';
// 이음매에서 혹이 가장 높이 솟은 점 — 조각 5 의 오른쪽 변
function knobPoint() {
const d = puzzle.defs[COLS + 1];
const f = flattenChain(d.sides[1].pts, 8);
let best = 0;
let bx = f[0];
let by = f[1];
for (let k = 0; k < f.length; k += 2) {
const off = Math.abs(f[k] - (BOARD_X + (d.col + 1) * PIECE));
if (off > best) {
best = off;
bx = f[k];
by = f[k + 1];
}
}
return [bx, by];
}
// 이음매 둘레 INSET_R 정사각을 scale 배 화소로 숨은 캔버스에 그린다 — 두 조각을 각자 잘라 붙이고 윤곽선까지
function renderPatch(target, scale) {
const g = target.getContext('2d');
if (!g) {
return;
}
target.width = INSET_R * scale;
target.height = INSET_R * scale;
const k = knobPoint();
g.setTransform(scale, 0, 0, scale, 0, 0);
g.fillStyle = rgba(PAPER, 1);
g.fillRect(0, 0, INSET_R, INSET_R);
g.translate(INSET_R / 2 - k[0], INSET_R / 2 - k[1]);
for (const id of [COLS + 1, COLS + 2]) {
const d = puzzle.defs[id];
g.save();
g.translate(d.hx, d.hy);
g.save();
g.clip(paths[id]);
g.drawImage(pic, BOARD_X - d.hx, BOARD_Y - d.hy, COLS * PIECE, ROWS * PIECE);
g.restore();
g.lineWidth = 1.3;
g.strokeStyle = rgba(INK, 0.62);
g.stroke(paths[id]);
g.restore();
}
}
function drawInsets() {
if (insetKey !== picKey) {
insetKey = picKey;
renderPatch(sharpCv, 2);
renderPatch(softCv, 1);
}
label(LABEL_INSET, BOARD_X + COLS * PIECE / 2, INSET_Y - 9, INK, 1, 10, 700, 'center');
[sharpCv, softCv].forEach(function (c, k) {
const x = INSET_X[k];
ctx.save();
ctx.imageSmoothingEnabled = true;
ctx.drawImage(c, x, INSET_Y, INSET_BOX, INSET_BOX);
ctx.restore();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(k === 0 ? MINT_DEEP : CORAL_DEEP, 1);
ctx.strokeRect(x, INSET_Y, INSET_BOX, INSET_BOX);
pill(k === 0 ? LABEL_SHARP : LABEL_SOFT, x + INSET_BOX / 2, INSET_Y + INSET_BOX + 10, k === 0 ? MINT_DEEP : CORAL_DEEP, CREAM, 1, 9);
});
}
function drawSpark() {
const x0 = BOARD_X + 10;
const w = COLS * PIECE - 20;
const top = Math.max(1, overlapLog.length ? Math.max.apply(null, overlapLog) : 1);
label(LABEL_SPARK, x0, SPARK_Y, INK_SOFT, 1, 9, 700, 'left');
if (overlapLog.length < 2) {
return;
}
ctx.beginPath();
overlapLog.forEach(function (o, k) {
const x = x0 + (k / (RELAX_ITERS - 1)) * w;
const y = SPARK_Y + 14 - (o / top) * 10;
if (k === 0) {
ctx.moveTo(x, y);
}
else {
ctx.lineTo(x, y);
}
});
ctx.lineWidth = 1.8;
ctx.strokeStyle = rgba(VIOLET_DEEP, 1);
ctx.stroke();
}
function draw() {
clearStage(PAPER);
drawBoardFrame(1);
drawInsets();
drawSpark();
if (!handsOn && relaxDone > 0 && relaxDone < RELAX_ITERS) {
for (const p of pieces) {
ring(p.tx, p.ty, TRAY_R, 1, VIOLET_DEEP, 0.35);
}
}
drawAllPieces(function (i) { void i; return PLAIN; });
drawSelection();
}
// 상태 줄 — 바뀔 때만 글자를 고친다
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();
};
})();// OverlapRelaxTray - 같은 항목 vanilla 판 index.html 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./OverlapRelaxTray.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function OverlapRelaxTray() {
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([]);
}
// 05 — 겹치지 않게 펼쳐지는 조각 쟁반. 판 4×3 조각이 THROW_AT_S 에 판 가운데에서 시드로 뽑은 자리(두 무더기 + 판 안 몇 개)로 던져지고,
// RELAX_AT_S 부터 1초에 RELAX_PER_S 번씩 relaxOnce 를 되풀이한다: 겹친 쌍은 겹친 깊이의 절반씩 서로 밀고, 판 상자 안 조각은 가장 가까운 바깥 변으로.
// 조각은 반지름 TRAY_R 원으로 본다. 위치는 목표를 스프링(TRAY_STIFFNESS·TRAY_DAMPING)으로 따라가 미끄러지듯 퍼진다
const COLS = 4;
const ROWS = 3;
const PIECE = 40;
const BOARD_X = 160;
const BOARD_Y = 84;
const TRAY_R = PIECE * 0.62;
const THROW_AT_S = 0.15;
const RELAX_AT_S = 0.5;
const RELAX_PER_S = 18;
const RELAX_ITERS = 36;
const TRAY_STIFFNESS = 70;
const TRAY_DAMPING = 13;
// 던질 자리 — [가운데 x, 가운데 y, 퍼짐 px, 조각 수] 무더기 셋
const HEAPS: number[][] = [[86, 118, 22, 5], [398, 196, 22, 5], [236, 150, 30, 2]];
const LOOP_S = 3.6;
const STATUS_RELAX = '밀어내기 {k}번째 · 남은 겹침 {o}px';
const STATUS_SPREAD = '겹침 0 — 조각 {n}개가 판 둘레에 펼쳐짐';
let relaxDone = 0;
let overlapLog: number[] = [];
let throwSpots: number[][] = [];
function reset(): void {
newBoard(COLS, ROWS, PIECE, BOARD_X, BOARD_Y, PUZZLE_SEED);
const rng = makeRng(PUZZLE_SEED + 5);
throwSpots = [];
for (const hp of HEAPS) {
for (let k = 0; k < hp[3]; k += 1) { throwSpots.push([hp[0] + spread(rng) * hp[2], hp[1] + spread(rng) * hp[2]]); }
}
for (let i = 0; i < pieces.length; i += 1) { placeAt(i, BOARD_X + COLS * PIECE / 2, BOARD_Y + ROWS * PIECE / 2, 0); }
relaxDone = 0;
overlapLog = [];
simStatus = fmt(STATUS_RELAX, { k: 0, o: 0 });
}
function allIds(): number[] { return pieces.map(function (p: Piece, i: number): number { void p; return i; }); }
function update(dt: number): void {
if (sceneT >= THROW_AT_S && sceneT - dt < THROW_AT_S) {
throwSpots.forEach(function (s: number[], i: number): void { pieces[i].tx = s[0]; pieces[i].ty = s[1]; });
}
const want = clamp(Math.floor((sceneT - RELAX_AT_S) * RELAX_PER_S) + 1, 0, RELAX_ITERS);
while (relaxDone < want) {
overlapLog.push(relaxOnce(allIds(), TRAY_R, BOARD_X, BOARD_Y, BOARD_X + COLS * PIECE, BOARD_Y + ROWS * PIECE));
relaxDone += 1;
}
stepWorld(dt, TRAY_STIFFNESS, TRAY_DAMPING);
const last = overlapLog.length ? overlapLog[overlapLog.length - 1] : 0;
simStatus = relaxDone > 0 && last < 0.5 ? fmt(STATUS_SPREAD, { n: pieces.length }) : fmt(STATUS_RELAX, { k: relaxDone, o: Math.round(last) });
}
function restPose(): void {
assembleAll();
simStatus = fmt(STATUS_DONE, { n: pieces.length });
}
// 05 화면 — 가운데 빈 판 위에 화소 배율 비교 두 칸(같은 이음매를 배율 2 화면 기준으로 키움): 왼쪽은 캔버스 내부 크기에
// 배율을 곱한 판(이음매 둘레 16px 를 화소 32×32 로 그림), 오른쪽은 곱하지 않은 판(16×16 을 늘림 — 흐림). 그 아래는 밀어내기 횟수별 남은 겹침 그래프.
// 조각 둘레의 옅은 원은 겹침 판정에 쓰는 반지름 TRAY_R
const INSET_R = 16;
const INSET_BOX = 62;
const INSET_Y = 92;
const INSET_X: number[] = [172, 246];
const SPARK_Y = 186;
const LABEL_SHARP = '배율 곱함';
const LABEL_SOFT = '배율 빠짐';
const LABEL_INSET = '배율 2 화면 · 같은 자리 확대';
const LABEL_SPARK = '밀어내기마다 남은 겹침';
const sharpCv: HTMLCanvasElement = document.createElement('canvas');
const softCv: HTMLCanvasElement = document.createElement('canvas');
let insetKey = '';
// 이음매에서 혹이 가장 높이 솟은 점 — 조각 5 의 오른쪽 변
function knobPoint(): [number, number] {
const d = puzzle.defs[COLS + 1];
const f = flattenChain(d.sides[1].pts, 8);
let best = 0;
let bx = f[0];
let by = f[1];
for (let k = 0; k < f.length; k += 2) {
const off = Math.abs(f[k] - (BOARD_X + (d.col + 1) * PIECE));
if (off > best) { best = off; bx = f[k]; by = f[k + 1]; }
}
return [bx, by];
}
// 이음매 둘레 INSET_R 정사각을 scale 배 화소로 숨은 캔버스에 그린다 — 두 조각을 각자 잘라 붙이고 윤곽선까지
function renderPatch(target: HTMLCanvasElement, scale: number): void {
const g = target.getContext('2d');
if (!g) { return; }
target.width = INSET_R * scale;
target.height = INSET_R * scale;
const k = knobPoint();
g.setTransform(scale, 0, 0, scale, 0, 0);
g.fillStyle = rgba(PAPER, 1);
g.fillRect(0, 0, INSET_R, INSET_R);
g.translate(INSET_R / 2 - k[0], INSET_R / 2 - k[1]);
for (const id of [COLS + 1, COLS + 2]) {
const d = puzzle.defs[id];
g.save();
g.translate(d.hx, d.hy);
g.save();
g.clip(paths[id]);
g.drawImage(pic, BOARD_X - d.hx, BOARD_Y - d.hy, COLS * PIECE, ROWS * PIECE);
g.restore();
g.lineWidth = 1.3;
g.strokeStyle = rgba(INK, 0.62);
g.stroke(paths[id]);
g.restore();
}
}
function drawInsets(): void {
if (insetKey !== picKey) {
insetKey = picKey;
renderPatch(sharpCv, 2);
renderPatch(softCv, 1);
}
label(LABEL_INSET, BOARD_X + COLS * PIECE / 2, INSET_Y - 9, INK, 1, 10, 700, 'center');
[sharpCv, softCv].forEach(function (c: HTMLCanvasElement, k: number): void {
const x = INSET_X[k];
ctx.save();
ctx.imageSmoothingEnabled = true;
ctx.drawImage(c, x, INSET_Y, INSET_BOX, INSET_BOX);
ctx.restore();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(k === 0 ? MINT_DEEP : CORAL_DEEP, 1);
ctx.strokeRect(x, INSET_Y, INSET_BOX, INSET_BOX);
pill(k === 0 ? LABEL_SHARP : LABEL_SOFT, x + INSET_BOX / 2, INSET_Y + INSET_BOX + 10, k === 0 ? MINT_DEEP : CORAL_DEEP, CREAM, 1, 9);
});
}
function drawSpark(): void {
const x0 = BOARD_X + 10;
const w = COLS * PIECE - 20;
const top = Math.max(1, overlapLog.length ? Math.max.apply(null, overlapLog) : 1);
label(LABEL_SPARK, x0, SPARK_Y, INK_SOFT, 1, 9, 700, 'left');
if (overlapLog.length < 2) { return; }
ctx.beginPath();
overlapLog.forEach(function (o: number, k: number): void {
const x = x0 + (k / (RELAX_ITERS - 1)) * w;
const y = SPARK_Y + 14 - (o / top) * 10;
if (k === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); }
});
ctx.lineWidth = 1.8;
ctx.strokeStyle = rgba(VIOLET_DEEP, 1);
ctx.stroke();
}
function draw(): void {
clearStage(PAPER);
drawBoardFrame(1);
drawInsets();
drawSpark();
if (!handsOn && relaxDone > 0 && relaxDone < RELAX_ITERS) {
for (const p of pieces) { ring(p.tx, p.ty, TRAY_R, 1, VIOLET_DEEP, 0.35); }
}
drawAllPieces(function (i: number): PieceLook { void i; return PLAIN; });
drawSelection();
}
// 상태 줄 — 바뀔 때만 글자를 고친다
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>
);
}05 · 重ならずに広がるピースのトレイ — 計測 累積面積 24.217% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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;
}
}
06縁から解いていく自動ソルバー
パズルアプリのヒント画面のように、次のピースがどこへ行くのか、なぜそこなのかを見せたい場面向けで、まっすぐな辺の数でピースを角4・縁6・内側2に分け、額縁を時計回りに埋めてから真ん中を埋めます。マスごとに、置かれた隣一つにつきタブが向き合わなければ3を足し、辺の2.5px内側で読んだ9点の色の差の平均÷255×4も足して、いちばん低い点数を選ぶと12段階すべてで正しいピースが選ばれ、縁の形のおかげで候補はほとんどが1〜2個に絞られます。候補が二つ残る最初の内側のマス(5)では正しいピースが0.137点、もう一つの内側のピースが6.467点で、盤の下にある12マスの色の地図がその点数を色で見せます(濃い紫ほど低く、上限は3)。

<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([]);
}
// 06 — 테두리부터 푸는 자동 풀이. 판 4×3 조각이 오른쪽 쟁반에 섞여 있고, reset 에서 solveFrameFirst 가 칸 순서(바깥 테두리를
// 시계 방향 → 内側)대로 남은 조각마다 slotScore 를 매겨 가장 낮은 조각을 고른다. 그림 색은 칠한 그림을 한 번 다시 읽어(makeSampler) 쓴다.
// 흉내 낸 손이 STEP_S 간격으로 고른 조각을 그 칸에 놓고, 놓으면 판 칸에 앉힌 뒤 이미 놓인 이웃과 묶는다
const COLS = 4;
const ROWS = 3;
const PIECE = 46;
const BOARD_X = 34;
const BOARD_Y = 52;
const TRAY_X0 = 272;
const TRAY_Y0 = 44;
const TRAY_DX = 66;
const TRAY_DY = 62;
const TRAY_JITTER = 6;
const SOLVE_START_S = 0.25;
const STEP_S = 0.42;
const CARRY_S = 0.32;
const LOOP_S = 6.6;
const KIND_NAMES = ['内側', '辺', '角'];
const STATUS_SOLVE = '{k}のマス {s}番目・候補 {c}個のうち最低点 {v}';
let steps06 = [];
function stepIndexAt(t) { return clamp(Math.floor((t - SOLVE_START_S) / STEP_S), 0, steps06.length - 1); }
function slotHome(slot) { return [BOARD_X + (slot % COLS + 0.5) * PIECE, BOARD_Y + (Math.floor(slot / COLS) + 0.5) * PIECE]; }
function reset() {
newBoard(COLS, ROWS, PIECE, BOARD_X, BOARD_Y, PUZZLE_SEED);
const rng = makeRng(PUZZLE_SEED + 6);
// 쟁반 순서 — 시드로 섞기(뒤에서부터 앞의 아무 칸과 맞바꿈)
const order = allPieceIds();
for (let k = order.length - 1; k > 0; k -= 1) {
const j = Math.floor(rng() * (k + 1));
const t = order[k];
order[k] = order[j];
order[j] = t;
}
order.forEach(function (i, k) {
placeAt(i, TRAY_X0 + (k % 3) * TRAY_DX + spread(rng) * TRAY_JITTER, TRAY_Y0 + Math.floor(k / 3) * TRAY_DY + spread(rng) * TRAY_JITTER, 0);
});
steps06 = solveFrameFirst(makeSampler());
resetCarries();
steps06.forEach(function (s, k) {
const t0 = SOLVE_START_S + k * STEP_S;
const h = slotHome(s.slot);
planCarry(s.piece, t0, t0 + CARRY_S, h[0] + 4, h[1] - 3, 0);
});
onRelease = function (i) {
// 판 칸이 붙잡는다 — 칸 가운데까지 거리가 문턱 안이면 그 칸으로, 그다음 이미 놓인 이웃과 묶는다
const s = steps06.filter(function (x) { return x.piece === i; })[0];
const h = slotHome(s ? s.slot : i);
if (Math.hypot(pieces[i].tx - h[0], pieces[i].ty - h[1]) < SNAP_RADIUS) {
pieces[i].tx = h[0];
pieces[i].ty = h[1];
}
for (let n = 0; n < 4; n += 1) {
tryJoin(i);
}
};
statusFor06(0);
}
function allPieceIds() { return pieces.map(function (p, i) { void p; return i; }); }
function statusFor06(k) {
const s = steps06[k];
if (!s) {
return;
}
const finite = s.scores.filter(function (v) { return v < Infinity; });
simStatus = fmt(STATUS_SOLVE, { k: KIND_NAMES[pieceKind(puzzle.defs[s.piece])], s: k + 1, c: finite.length, v: s.scores[s.piece].toFixed(2) });
}
function update(dt) {
runCarries(sceneT);
for (const p of pieces) {
p.th = 0;
}
if (carrying >= 0) {
pieces[carrying].th = 0.8;
}
stepWorld(dt, SNAP_STIFFNESS, SNAP_DAMPING);
if (sceneT >= SOLVE_START_S) {
statusFor06(stepIndexAt(sceneT));
}
if (completeAt >= 0) {
simStatus = fmt(STATUS_DONE, { n: pieces.length });
}
}
function restPose() {
assembleAll();
resetCarries();
simStatus = fmt(STATUS_DONE, { n: pieces.length });
}
// 06 화면 — 지금 채울 칸은 버터색, 쟁반 조각 가운데 점은 곧은 변 수로 가른 종류(角·辺·内側).
// 판 아래 열지도 = 지금 칸에 대한 조각 12개의 점수(진할수록 낮은 점수 = 잘 맞음, 빈칸 = 테두리 모양이 안 맞거나 이미 씀), 고른 조각에 고리
const KIND_COLORS = [PINK, SKY, BUTTER];
const HEAT_X = 34;
const HEAT_Y = 222;
const HEAT_CELL = 13;
const HEAT_GAP = 2;
const HEAT_TOP = 3;
const LABEL_HEAT = 'いまのマスの候補点数(濃いほどよく合う)';
function drawHeat(k) {
const s = steps06[k];
label(LABEL_HEAT, HEAT_X, HEAT_Y - 10, INK, 1, 10, 700, 'left');
if (!s) {
return;
}
s.scores.forEach(function (v, i) {
const x = HEAT_X + i * (HEAT_CELL + HEAT_GAP);
if (v === Infinity) {
fillRound(x, HEAT_Y, HEAT_CELL, HEAT_CELL, 3, PAPER_DIM, 1);
line(x + 3, HEAT_Y + HEAT_CELL - 3, x + HEAT_CELL - 3, HEAT_Y + 3, 1, INK_SOFT, 0.7);
}
else {
fillRound(x, HEAT_Y, HEAT_CELL, HEAT_CELL, 3, mix(VIOLET_DEEP, LAVENDER, clamp(v / HEAT_TOP, 0, 1)), 1);
}
if (i === s.piece) {
roundRectPath(x - 2, HEAT_Y - 2, HEAT_CELL + 4, HEAT_CELL + 4, 4);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(CORAL_DEEP, 1);
ctx.stroke();
}
});
let lx = HEAT_X;
KIND_NAMES.forEach(function (n, kind) {
disc(lx + 5, HEAT_Y + 28, 4.5, KIND_COLORS[kind], 1);
ring(lx + 5, HEAT_Y + 28, 4.5, 1, INK, 0.8);
label(n, lx + 13, HEAT_Y + 28, INK, 1, 10, 700, 'left');
setFont(10, 700);
lx += 22 + ctx.measureText(n).width;
});
}
function draw() {
clearStage(PAPER);
drawBoardFrame(1);
const k = stepIndexAt(sceneT);
const s = steps06[k];
if (s && !handsOn && completeAt < 0) {
const h = slotHome(s.slot);
fillRound(h[0] - PIECE / 2, h[1] - PIECE / 2, PIECE, PIECE, 4, BUTTER, 0.9);
}
drawAllPieces(function (i) { void i; return PLAIN; });
for (let i = 0; i < pieces.length; i += 1) {
if (groupSize[findRoot(i)] > 1 || (completeAt >= 0)) {
continue;
}
const p = pieces[i];
const kind = pieceKind(puzzle.defs[i]);
disc(p.x, p.y, 5, KIND_COLORS[kind], 1);
ring(p.x, p.y, 5, 1.2, INK, 0.85);
}
drawSelection();
drawHeat(handsOn ? -1 : k);
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();
};
})();// EdgeFirstSolver - 같은 항목 vanilla 판 index.html 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./EdgeFirstSolver.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function EdgeFirstSolver() {
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([]);
}
// 06 — 테두리부터 푸는 자동 풀이. 판 4×3 조각이 오른쪽 쟁반에 섞여 있고, reset 에서 solveFrameFirst 가 칸 순서(바깥 테두리를
// 시계 방향 → 안쪽)대로 남은 조각마다 slotScore 를 매겨 가장 낮은 조각을 고른다. 그림 색은 칠한 그림을 한 번 다시 읽어(makeSampler) 쓴다.
// 흉내 낸 손이 STEP_S 간격으로 고른 조각을 그 칸에 놓고, 놓으면 판 칸에 앉힌 뒤 이미 놓인 이웃과 묶는다
const COLS = 4;
const ROWS = 3;
const PIECE = 46;
const BOARD_X = 34;
const BOARD_Y = 52;
const TRAY_X0 = 272;
const TRAY_Y0 = 44;
const TRAY_DX = 66;
const TRAY_DY = 62;
const TRAY_JITTER = 6;
const SOLVE_START_S = 0.25;
const STEP_S = 0.42;
const CARRY_S = 0.32;
const LOOP_S = 6.6;
const KIND_NAMES: string[] = ['안쪽', '가장자리', '모서리'];
const STATUS_SOLVE = '{k} 칸 {s}번째 · 후보 {c}개 중 최저 점수 {v}';
let steps06: SolveStep[] = [];
function stepIndexAt(t: number): number { return clamp(Math.floor((t - SOLVE_START_S) / STEP_S), 0, steps06.length - 1); }
function slotHome(slot: number): [number, number] { return [BOARD_X + (slot % COLS + 0.5) * PIECE, BOARD_Y + (Math.floor(slot / COLS) + 0.5) * PIECE]; }
function reset(): void {
newBoard(COLS, ROWS, PIECE, BOARD_X, BOARD_Y, PUZZLE_SEED);
const rng = makeRng(PUZZLE_SEED + 6);
// 쟁반 순서 — 시드로 섞기(뒤에서부터 앞의 아무 칸과 맞바꿈)
const order = allPieceIds();
for (let k = order.length - 1; k > 0; k -= 1) {
const j = Math.floor(rng() * (k + 1));
const t = order[k]; order[k] = order[j]; order[j] = t;
}
order.forEach(function (i: number, k: number): void {
placeAt(i, TRAY_X0 + (k % 3) * TRAY_DX + spread(rng) * TRAY_JITTER, TRAY_Y0 + Math.floor(k / 3) * TRAY_DY + spread(rng) * TRAY_JITTER, 0);
});
steps06 = solveFrameFirst(makeSampler());
resetCarries();
steps06.forEach(function (s: SolveStep, k: number): void {
const t0 = SOLVE_START_S + k * STEP_S;
const h = slotHome(s.slot);
planCarry(s.piece, t0, t0 + CARRY_S, h[0] + 4, h[1] - 3, 0);
});
onRelease = function (i: number): void {
// 판 칸이 붙잡는다 — 칸 가운데까지 거리가 문턱 안이면 그 칸으로, 그다음 이미 놓인 이웃과 묶는다
const s = steps06.filter(function (x: SolveStep): boolean { return x.piece === i; })[0];
const h = slotHome(s ? s.slot : i);
if (Math.hypot(pieces[i].tx - h[0], pieces[i].ty - h[1]) < SNAP_RADIUS) { pieces[i].tx = h[0]; pieces[i].ty = h[1]; }
for (let n = 0; n < 4; n += 1) { tryJoin(i); }
};
statusFor06(0);
}
function allPieceIds(): number[] { return pieces.map(function (p: Piece, i: number): number { void p; return i; }); }
function statusFor06(k: number): void {
const s = steps06[k];
if (!s) { return; }
const finite = s.scores.filter(function (v: number): boolean { return v < Infinity; });
simStatus = fmt(STATUS_SOLVE, { k: KIND_NAMES[pieceKind(puzzle.defs[s.piece])], s: k + 1, c: finite.length, v: s.scores[s.piece].toFixed(2) });
}
function update(dt: number): void {
runCarries(sceneT);
for (const p of pieces) { p.th = 0; }
if (carrying >= 0) { pieces[carrying].th = 0.8; }
stepWorld(dt, SNAP_STIFFNESS, SNAP_DAMPING);
if (sceneT >= SOLVE_START_S) { statusFor06(stepIndexAt(sceneT)); }
if (completeAt >= 0) { simStatus = fmt(STATUS_DONE, { n: pieces.length }); }
}
function restPose(): void {
assembleAll();
resetCarries();
simStatus = fmt(STATUS_DONE, { n: pieces.length });
}
// 06 화면 — 지금 채울 칸은 버터색, 쟁반 조각 가운데 점은 곧은 변 수로 가른 종류(모서리·가장자리·안쪽).
// 판 아래 열지도 = 지금 칸에 대한 조각 12개의 점수(진할수록 낮은 점수 = 잘 맞음, 빈칸 = 테두리 모양이 안 맞거나 이미 씀), 고른 조각에 고리
const KIND_COLORS: RGB[] = [PINK, SKY, BUTTER];
const HEAT_X = 34;
const HEAT_Y = 222;
const HEAT_CELL = 13;
const HEAT_GAP = 2;
const HEAT_TOP = 3;
const LABEL_HEAT = '지금 칸의 후보 점수(진할수록 잘 맞음)';
function drawHeat(k: number): void {
const s = steps06[k];
label(LABEL_HEAT, HEAT_X, HEAT_Y - 10, INK, 1, 10, 700, 'left');
if (!s) { return; }
s.scores.forEach(function (v: number, i: number): void {
const x = HEAT_X + i * (HEAT_CELL + HEAT_GAP);
if (v === Infinity) {
fillRound(x, HEAT_Y, HEAT_CELL, HEAT_CELL, 3, PAPER_DIM, 1);
line(x + 3, HEAT_Y + HEAT_CELL - 3, x + HEAT_CELL - 3, HEAT_Y + 3, 1, INK_SOFT, 0.7);
} else {
fillRound(x, HEAT_Y, HEAT_CELL, HEAT_CELL, 3, mix(VIOLET_DEEP, LAVENDER, clamp(v / HEAT_TOP, 0, 1)), 1);
}
if (i === s.piece) {
roundRectPath(x - 2, HEAT_Y - 2, HEAT_CELL + 4, HEAT_CELL + 4, 4);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(CORAL_DEEP, 1);
ctx.stroke();
}
});
let lx = HEAT_X;
KIND_NAMES.forEach(function (n: string, kind: number): void {
disc(lx + 5, HEAT_Y + 28, 4.5, KIND_COLORS[kind], 1);
ring(lx + 5, HEAT_Y + 28, 4.5, 1, INK, 0.8);
label(n, lx + 13, HEAT_Y + 28, INK, 1, 10, 700, 'left');
setFont(10, 700);
lx += 22 + ctx.measureText(n).width;
});
}
function draw(): void {
clearStage(PAPER);
drawBoardFrame(1);
const k = stepIndexAt(sceneT);
const s = steps06[k];
if (s && !handsOn && completeAt < 0) {
const h = slotHome(s.slot);
fillRound(h[0] - PIECE / 2, h[1] - PIECE / 2, PIECE, PIECE, 4, BUTTER, 0.9);
}
drawAllPieces(function (i: number): PieceLook { void i; return PLAIN; });
for (let i = 0; i < pieces.length; i += 1) {
if (groupSize[findRoot(i)] > 1 || (completeAt >= 0)) { continue; }
const p = pieces[i];
const kind = pieceKind(puzzle.defs[i]);
disc(p.x, p.y, 5, KIND_COLORS[kind], 1);
ring(p.x, p.y, 5, 1.2, INK, 0.85);
}
drawSelection();
drawHeat(handsOn ? -1 : k);
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>
);
}06 · 縁から解いていく自動ソルバー — 計測 累積面積 28.913% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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;
07持ち上げたピースの傾きと影の高さ
ボードゲーム紹介ページでピースを持ち上げる手ごたえを出したい場面で、つまむ0.15秒前から高さの目標が1になり、高さのばねは1.186まで行き過ぎてから、0.5秒からは±2%の内側にとどまります。傾きはならした横の速さ×0.0007で、0.32rad(18.3°)で切られて運ぶあいだにその上限へ届き、影は下へ最大18.1pxずれて、高さがいちばん上のときにぼかしが21pxまで広がります。盤を回る探り点は四角で選んだピース(コーラルの点線)と輪郭で選んだピース(ミント)を比べていて、下の pickByPoly は数え上げに使う計算用の写し、画面の pickAt は同じ点を isPointInPath に問います。

<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([]);
}
// 07 — 들어 올린 조각의 기울기와 그림자 높이. 판 4×3 중 LOOSE 넷이 오른쪽에 있고, 흉내 낸 손이 하나씩 집어 제자리로 옮긴다.
// 집기 LIFT_LEAD_S 전부터 높이 목표 1 → 높이 스프링(LIFT_STIFFNESS·LIFT_DAMPING)이 넘쳤다 앉고, 놓으면 목표 0.
// 기울기 = 다듬은 속도(VEL_SMOOTH 지수 평균) × TILT_GAIN, ±TILT_MAX 에서 자름. 그림자 아래 오프셋 = 1.5 + SHADOW_OFFSET × 높이, 흐림 = 2 + SHADOW_BLUR × 높이(core 값, dom 의 drawPiece 가 그림)
const COLS = 4;
const ROWS = 3;
const PIECE = 50;
const BOARD_X = 24;
const BOARD_Y = 60;
const LOOSE = [3, 7, 11, 10];
const LOOSE_AT = [[312, 70], [404, 96], [318, 178], [410, 206]];
// [시작 초, 끝 초] — LOOSE 순서대로
const CARRY_PLAN = [[0.3, 0.8], [0.95, 1.45], [1.6, 2.1], [2.25, 2.75]];
const LIFT_LEAD_S = 0.15;
const TILT_GAIN = 0.0007;
const TILT_MAX = 0.32;
const LIFT_SCALE = 0.06;
const LOOP_S = 3.6;
const STATUS_LIFT = '高さ {h}・傾き {t}°・影 {o}px';
const STATUS_REST = '四角の判定の誤り {r}% — 経路の判定は 0%';
// 상자 판정 — 위에 그린 조각부터, 조각 꺾은선(화면 경로와 같은 마디당 12 등분)의 네모 상자 안이면 그 조각. 탭이 이웃 상자에 걸쳐 엉뚱한 조각이 집힌다
let polys07 = [];
let boxes07 = [];
function buildPolys() {
polys07 = puzzle.defs.map(function (d) { return piecePolygon(d, 12); });
boxes07 = polys07.map(polyBox);
}
function pickByBox(x, y) {
for (let k = zOrder.length - 1; k >= 0; k -= 1) {
const i = zOrder[k];
const b = boxes07[i];
const p = pieces[i];
if (x >= p.x + b[0] && x <= p.x + b[2] && y >= p.y + b[1] && y <= p.y + b[3]) {
return i;
}
}
return -1;
}
// 경로 판정(계산판) — 같은 꺾은선 안인지 반직선으로. 화면은 isPointInPath 로 같은 것을 묻는다
function pickByPoly(x, y) {
for (let k = zOrder.length - 1; k >= 0; k -= 1) {
const i = zOrder[k];
if (pointInPoly(x - pieces[i].x, y - pieces[i].y, polys07[i])) {
return i;
}
}
return -1;
}
// 경로 판정 함수 — 계산만 할 땐 꺾은선 판정, 화면에서는 isPointInPath 판정으로 바뀐다(07 화면 쪽)
let pathPick = pickByPoly;
// 완성 판 위 step 간격 점마다 두 판정을 견준다 — 조각 안의 점 중 상자 판정이 다른 조각을 고른 몫
function boxMissRatio(pick, step) {
let inside = 0;
let wrong = 0;
for (let y = BOARD_Y + step / 2; y < BOARD_Y + ROWS * PIECE; y += step) {
for (let x = BOARD_X + step / 2; x < BOARD_X + COLS * PIECE; x += step) {
const truth = pick(x, y);
if (truth < 0) {
continue;
}
inside += 1;
if (pickByBox(x, y) !== truth) {
wrong += 1;
}
}
}
return [wrong, inside];
}
function tiltOf(i) { return clamp(pieces[i].mvx * TILT_GAIN, -TILT_MAX, TILT_MAX); }
let missRatio = 0;
function reset() {
newBoard(COLS, ROWS, PIECE, BOARD_X, BOARD_Y, PUZZLE_SEED);
buildPolys();
const fixed = [];
for (let i = 0; i < pieces.length; i += 1) {
if (LOOSE.indexOf(i) < 0) {
fixed.push(i);
}
}
const m = boxMissRatio(pathPick, 2);
missRatio = m[0] / Math.max(1, m[1]);
for (const i of fixed) {
if (i !== fixed[0]) {
joinGroups(fixed[0], i);
}
}
markSeams();
LOOSE.forEach(function (i, k) { placeAt(i, LOOSE_AT[k][0], LOOSE_AT[k][1], 0); });
resetCarries();
LOOSE.forEach(function (i, k) {
const d = puzzle.defs[i];
planCarry(i, CARRY_PLAN[k][0], CARRY_PLAN[k][1], d.hx + 4, d.hy - 3, 0);
});
simStatus = fmt(STATUS_REST, { r: (missRatio * 100).toFixed(1) });
}
function update(dt) {
runCarries(sceneT);
LOOSE.forEach(function (i, k) {
pieces[i].th = sceneT >= CARRY_PLAN[k][0] - LIFT_LEAD_S && sceneT < CARRY_PLAN[k][1] ? 1 : 0;
});
stepWorld(dt, SNAP_STIFFNESS, SNAP_DAMPING);
const up = LOOSE.filter(function (i) { return pieces[i].h > 0.05; })[0];
if (up !== undefined) {
const p = pieces[up];
simStatus = fmt(STATUS_LIFT, { h: p.h.toFixed(2), t: Math.round(deg(tiltOf(up))), o: Math.round(1.5 + SHADOW_OFFSET * p.h) });
}
else {
simStatus = fmt(STATUS_REST, { r: (missRatio * 100).toFixed(1) });
}
}
function restPose() {
assembleAll();
resetCarries();
simStatus = fmt(STATUS_DONE, { n: pieces.length });
}
// 07 화면 — 들린 조각은 높이만큼 커지고 그림자가 멀어지며, 다듬은 속도만큼 나아가는 쪽으로 기운다(가로 기울임 skewX).
// 판 위를 도는 탐침 점은 두 판정을 매 프레임 견준다: 산호 점선 = 네모 상자 판정이 고른 조각의 상자, 민트 윤곽 = isPointInPath 가 고른 조각
pathPick = pickAt;
const PROBE_X = 102;
const PROBE_Y = 112;
const PROBE_AX = 66;
const PROBE_AY = 40;
const LABEL_RATIO = '完成盤で四角の判定が誤る割合 {r}%';
const LABEL_DIFF = '四角 {a}・経路 {b}';
const LABEL_SAME = '同じピース {a}';
function probePoint() {
return [PROBE_X + PROBE_AX * Math.sin(sceneT * 1.9), PROBE_Y + PROBE_AY * Math.sin(sceneT * 2.7 + 0.6)];
}
function drawProbe() {
const q = probePoint();
const byBox = pickByBox(q[0], q[1]);
const byPath = pickAt(q[0], q[1]);
if (byBox >= 0) {
const b = boxes07[byBox];
const p = pieces[byBox];
ctx.setLineDash([4, 3]);
ctx.lineWidth = 1.8;
ctx.strokeStyle = rgba(CORAL_DEEP, 1);
ctx.strokeRect(p.x + b[0], p.y + b[1], b[2] - b[0], b[3] - b[1]);
ctx.setLineDash([]);
}
if (byPath >= 0) {
const p = pieces[byPath];
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.a);
ctx.lineWidth = 2.6;
ctx.strokeStyle = rgba(MINT_DEEP, 1);
ctx.stroke(paths[byPath]);
ctx.restore();
}
line(q[0] - 7, q[1], q[0] + 7, q[1], 2, INK, 1);
line(q[0], q[1] - 7, q[0], q[1] + 7, 2, INK, 1);
disc(q[0], q[1], 2.5, CREAM, 1);
const same = byBox === byPath;
pill(same ? fmt(LABEL_SAME, { a: byPath }) : fmt(LABEL_DIFF, { a: byBox, b: byPath }), q[0], q[1] - 16, same ? INK : CORAL_DEEP, CREAM, 1, 10);
}
function lookOf07(i) {
const p = pieces[i];
if (p.h < 0.02) {
return PLAIN;
}
return { dx: 0, dy: -4 * p.h, scale: 1 + LIFT_SCALE * p.h, skewX: tiltOf(i), skewY: clamp(p.mvy * TILT_GAIN * 0.5, -TILT_MAX, TILT_MAX) * 0.5, alpha: 1, shade: 1 };
}
function draw() {
clearStage(PAPER);
drawBoardFrame(1);
drawAllPieces(lookOf07);
drawSelection();
if (!handsOn) {
drawProbe();
}
pill(fmt(LABEL_RATIO, { r: (missRatio * 100).toFixed(1) }), BOARD_X + COLS * PIECE / 2, BOARD_Y - 26, INK, CREAM, 1, 10);
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();
};
})();// LiftTiltShadow - 같은 항목 vanilla 판 index.html 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./LiftTiltShadow.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function LiftTiltShadow() {
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([]);
}
// 07 — 들어 올린 조각의 기울기와 그림자 높이. 판 4×3 중 LOOSE 넷이 오른쪽에 있고, 흉내 낸 손이 하나씩 집어 제자리로 옮긴다.
// 집기 LIFT_LEAD_S 전부터 높이 목표 1 → 높이 스프링(LIFT_STIFFNESS·LIFT_DAMPING)이 넘쳤다 앉고, 놓으면 목표 0.
// 기울기 = 다듬은 속도(VEL_SMOOTH 지수 평균) × TILT_GAIN, ±TILT_MAX 에서 자름. 그림자 아래 오프셋 = 1.5 + SHADOW_OFFSET × 높이, 흐림 = 2 + SHADOW_BLUR × 높이(core 값, dom 의 drawPiece 가 그림)
const COLS = 4;
const ROWS = 3;
const PIECE = 50;
const BOARD_X = 24;
const BOARD_Y = 60;
const LOOSE: number[] = [3, 7, 11, 10];
const LOOSE_AT: number[][] = [[312, 70], [404, 96], [318, 178], [410, 206]];
// [시작 초, 끝 초] — LOOSE 순서대로
const CARRY_PLAN: number[][] = [[0.3, 0.8], [0.95, 1.45], [1.6, 2.1], [2.25, 2.75]];
const LIFT_LEAD_S = 0.15;
const TILT_GAIN = 0.0007;
const TILT_MAX = 0.32;
const LIFT_SCALE = 0.06;
const LOOP_S = 3.6;
const STATUS_LIFT = '높이 {h} · 기울기 {t}° · 그림자 {o}px';
const STATUS_REST = '상자 판정 오판 {r}% — 경로 판정은 0%';
// 상자 판정 — 위에 그린 조각부터, 조각 꺾은선(화면 경로와 같은 마디당 12 등분)의 네모 상자 안이면 그 조각. 탭이 이웃 상자에 걸쳐 엉뚱한 조각이 집힌다
let polys07: number[][] = [];
let boxes07: number[][] = [];
function buildPolys(): void {
polys07 = puzzle.defs.map(function (d: PieceDef): number[] { return piecePolygon(d, 12); });
boxes07 = polys07.map(polyBox);
}
function pickByBox(x: number, y: number): number {
for (let k = zOrder.length - 1; k >= 0; k -= 1) {
const i = zOrder[k];
const b = boxes07[i];
const p = pieces[i];
if (x >= p.x + b[0] && x <= p.x + b[2] && y >= p.y + b[1] && y <= p.y + b[3]) { return i; }
}
return -1;
}
// 경로 판정(계산판) — 같은 꺾은선 안인지 반직선으로. 화면은 isPointInPath 로 같은 것을 묻는다
function pickByPoly(x: number, y: number): number {
for (let k = zOrder.length - 1; k >= 0; k -= 1) {
const i = zOrder[k];
if (pointInPoly(x - pieces[i].x, y - pieces[i].y, polys07[i])) { return i; }
}
return -1;
}
// 경로 판정 함수 — 계산만 할 땐 꺾은선 판정, 화면에서는 isPointInPath 판정으로 바뀐다(07 화면 쪽)
let pathPick: (x: number, y: number) => number = pickByPoly;
// 완성 판 위 step 간격 점마다 두 판정을 견준다 — 조각 안의 점 중 상자 판정이 다른 조각을 고른 몫
function boxMissRatio(pick: (x: number, y: number) => number, step: number): number[] {
let inside = 0;
let wrong = 0;
for (let y = BOARD_Y + step / 2; y < BOARD_Y + ROWS * PIECE; y += step) {
for (let x = BOARD_X + step / 2; x < BOARD_X + COLS * PIECE; x += step) {
const truth = pick(x, y);
if (truth < 0) { continue; }
inside += 1;
if (pickByBox(x, y) !== truth) { wrong += 1; }
}
}
return [wrong, inside];
}
function tiltOf(i: number): number { return clamp(pieces[i].mvx * TILT_GAIN, -TILT_MAX, TILT_MAX); }
let missRatio = 0;
function reset(): void {
newBoard(COLS, ROWS, PIECE, BOARD_X, BOARD_Y, PUZZLE_SEED);
buildPolys();
const fixed: number[] = [];
for (let i = 0; i < pieces.length; i += 1) { if (LOOSE.indexOf(i) < 0) { fixed.push(i); } }
const m = boxMissRatio(pathPick, 2);
missRatio = m[0] / Math.max(1, m[1]);
for (const i of fixed) { if (i !== fixed[0]) { joinGroups(fixed[0], i); } }
markSeams();
LOOSE.forEach(function (i: number, k: number): void { placeAt(i, LOOSE_AT[k][0], LOOSE_AT[k][1], 0); });
resetCarries();
LOOSE.forEach(function (i: number, k: number): void {
const d = puzzle.defs[i];
planCarry(i, CARRY_PLAN[k][0], CARRY_PLAN[k][1], d.hx + 4, d.hy - 3, 0);
});
simStatus = fmt(STATUS_REST, { r: (missRatio * 100).toFixed(1) });
}
function update(dt: number): void {
runCarries(sceneT);
LOOSE.forEach(function (i: number, k: number): void {
pieces[i].th = sceneT >= CARRY_PLAN[k][0] - LIFT_LEAD_S && sceneT < CARRY_PLAN[k][1] ? 1 : 0;
});
stepWorld(dt, SNAP_STIFFNESS, SNAP_DAMPING);
const up = LOOSE.filter(function (i: number): boolean { return pieces[i].h > 0.05; })[0];
if (up !== undefined) {
const p = pieces[up];
simStatus = fmt(STATUS_LIFT, { h: p.h.toFixed(2), t: Math.round(deg(tiltOf(up))), o: Math.round(1.5 + SHADOW_OFFSET * p.h) });
} else {
simStatus = fmt(STATUS_REST, { r: (missRatio * 100).toFixed(1) });
}
}
function restPose(): void {
assembleAll();
resetCarries();
simStatus = fmt(STATUS_DONE, { n: pieces.length });
}
// 07 화면 — 들린 조각은 높이만큼 커지고 그림자가 멀어지며, 다듬은 속도만큼 나아가는 쪽으로 기운다(가로 기울임 skewX).
// 판 위를 도는 탐침 점은 두 판정을 매 프레임 견준다: 산호 점선 = 네모 상자 판정이 고른 조각의 상자, 민트 윤곽 = isPointInPath 가 고른 조각
pathPick = pickAt;
const PROBE_X = 102;
const PROBE_Y = 112;
const PROBE_AX = 66;
const PROBE_AY = 40;
const LABEL_RATIO = '완성 판 기준 상자 판정 오판 {r}%';
const LABEL_DIFF = '상자 {a} · 경로 {b}';
const LABEL_SAME = '같은 조각 {a}';
function probePoint(): [number, number] {
return [PROBE_X + PROBE_AX * Math.sin(sceneT * 1.9), PROBE_Y + PROBE_AY * Math.sin(sceneT * 2.7 + 0.6)];
}
function drawProbe(): void {
const q = probePoint();
const byBox = pickByBox(q[0], q[1]);
const byPath = pickAt(q[0], q[1]);
if (byBox >= 0) {
const b = boxes07[byBox];
const p = pieces[byBox];
ctx.setLineDash([4, 3]);
ctx.lineWidth = 1.8;
ctx.strokeStyle = rgba(CORAL_DEEP, 1);
ctx.strokeRect(p.x + b[0], p.y + b[1], b[2] - b[0], b[3] - b[1]);
ctx.setLineDash([]);
}
if (byPath >= 0) {
const p = pieces[byPath];
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.a);
ctx.lineWidth = 2.6;
ctx.strokeStyle = rgba(MINT_DEEP, 1);
ctx.stroke(paths[byPath]);
ctx.restore();
}
line(q[0] - 7, q[1], q[0] + 7, q[1], 2, INK, 1);
line(q[0], q[1] - 7, q[0], q[1] + 7, 2, INK, 1);
disc(q[0], q[1], 2.5, CREAM, 1);
const same = byBox === byPath;
pill(same ? fmt(LABEL_SAME, { a: byPath }) : fmt(LABEL_DIFF, { a: byBox, b: byPath }), q[0], q[1] - 16, same ? INK : CORAL_DEEP, CREAM, 1, 10);
}
function lookOf07(i: number): PieceLook {
const p = pieces[i];
if (p.h < 0.02) { return PLAIN; }
return { dx: 0, dy: -4 * p.h, scale: 1 + LIFT_SCALE * p.h, skewX: tiltOf(i), skewY: clamp(p.mvy * TILT_GAIN * 0.5, -TILT_MAX, TILT_MAX) * 0.5, alpha: 1, shade: 1 };
}
function draw(): void {
clearStage(PAPER);
drawBoardFrame(1);
drawAllPieces(lookOf07);
drawSelection();
if (!handsOn) { drawProbe(); }
pill(fmt(LABEL_RATIO, { r: (missRatio * 100).toFixed(1) }), BOARD_X + COLS * PIECE / 2, BOARD_Y - 26, INK, CREAM, 1, 10);
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>
);
}07 · 持ち上げたピースの傾きと影の高さ — 計測 累積面積 36.252% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function pickByBox(x, y) {
for (let k = zOrder.length - 1; k >= 0; k -= 1) {
const i = zOrder[k];
const b = boxes07[i];
const p = pieces[i];
if (x >= p.x + b[0] && x <= p.x + b[2] && y >= p.y + b[1] && y <= p.y + b[3]) {
return i;
}
}
return -1;
}
// 経路の判定(計算用) — 同じ折れ線の内側かどうかを半直線で調べる。画面では isPointInPath で同じことを問う
function pickByPoly(x, y) {
for (let k = zOrder.length - 1; k >= 0; k -= 1) {
const i = zOrder[k];
if (pointInPoly(x - pieces[i].x, y - pieces[i].y, polys07[i])) {
return i;
}
}
return -1;
}
08完成の瞬間に広がる継ぎ目の波と光沢
パズル完成のお祝い画面のように最後の一枚を一目で知らせたい場面で、最後のピースは0.554秒にはまり、各ピースの内側の継ぎ目は最後のピースの位置から離れた距離÷260px/s(0〜0.61秒)だけ遅れてから0.25秒で消えるので、1.414秒にはすべての継ぎ目がなくなります。同じ遅れに沿って盤は e^(−3.2τ) × sin(9τ) の形で最高3.04pxまで浮いて−1.0pxまで沈み、光沢の帯が1.104秒から1.704秒に一度だけ横切ります。外周のまっすぐな辺は消さないので額縁は残り、継ぎ目が消えていくにつれて盤の下に絵一枚が(消したピース数÷20)の濃さで敷かれ、ピースの境目を埋めます。

<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([]);
}
// 08 — 완성 순간 퍼지는 이음매 물결과 광택. 판 5×4 중 LAST 하나만 오른쪽에 있고, 흉내 낸 손이 넣으면 묶음이 하나가 된다(completeAt).
// 조각 i 의 지연 = 마지막 조각 가운데에서 떨어진 거리 / SEAM_SPEED. 지연이 지난 조각부터 内側 이음매가 SEAM_FADE_S 동안 지워지고,
// 판 높이는 τ = 지금 − completeAt − 지연 으로 WAVE_AMP·e^(−WAVE_DECAY·τ)·sin(WAVE_FREQ·τ) 만큼 솟았다 가라앉는다. GLOSS_AT_S 뒤 광택 띠가 GLOSS_S 동안 한 번 지나간다
const COLS = 5;
const ROWS = 4;
const PIECE = 44;
const BOARD_X = 110;
const BOARD_Y = 52;
const LAST = 8;
const LAST_AT = [412, 150];
const CARRY_AT_S = 0.15;
const CARRY_S = 0.4;
const SEAM_SPEED = 260;
const SEAM_FADE_S = 0.25;
const WAVE_AMP = 5;
const WAVE_DECAY = 3.2;
const WAVE_FREQ = 9;
const GLOSS_AT_S = 0.55;
const GLOSS_S = 0.6;
const LOOP_S = 3.2;
const STATUS_LAST = '最後のピース — 残りのまとまり {g}';
const STATUS_WIPE = '完成 — ピース {k}/{n}個の継ぎ目を消した';
function delayOf(i) {
const a = puzzle.defs[i];
const b = puzzle.defs[LAST];
return Math.hypot(a.hx - b.hx, a.hy - b.hy) / SEAM_SPEED;
}
function sinceDone(i) { return completeAt < 0 ? -1 : sceneT - completeAt - delayOf(i); }
function waveOf(i) {
const tau = sinceDone(i);
if (tau <= 0) {
return 0;
}
return Math.exp(-WAVE_DECAY * tau) * Math.sin(WAVE_FREQ * tau);
}
// 内側 이음매가 남은 몫(1 진함 ~ 0 지움). 판 바깥 테두리(곧은 변)는 지우지 않는다
function seamLeft(i, s) {
if (puzzle.defs[i].sides[s].tab === 0) {
return 1;
}
const tau = sinceDone(i);
if (tau < 0) {
return 1;
}
return 1 - clamp(tau / SEAM_FADE_S, 0, 1);
}
function wipedCount() {
let n = 0;
for (let i = 0; i < pieces.length; i += 1) {
if (sinceDone(i) >= SEAM_FADE_S) {
n += 1;
}
}
return n;
}
function reset() {
newBoard(COLS, ROWS, PIECE, BOARD_X, BOARD_Y, PUZZLE_SEED);
for (let i = 0; i < pieces.length; i += 1) {
if (i !== LAST && i !== 0) {
joinGroups(0, i);
}
}
markSeams();
placeAt(LAST, LAST_AT[0], LAST_AT[1], 0);
resetCarries();
const d = puzzle.defs[LAST];
planCarry(LAST, CARRY_AT_S, CARRY_AT_S + CARRY_S, d.hx + 5, d.hy - 4, 0);
simStatus = fmt(STATUS_LAST, { g: groupCount() });
}
function update(dt) {
runCarries(sceneT);
pieces[LAST].th = carrying === LAST ? 0.8 : 0;
stepWorld(dt, SNAP_STIFFNESS, SNAP_DAMPING);
simStatus = completeAt < 0 ? fmt(STATUS_LAST, { g: groupCount() }) : fmt(STATUS_WIPE, { k: wipedCount(), n: pieces.length });
}
function restPose() {
assembleAll();
resetCarries();
// 완성 판 — 완성(0초)에서 10초 지난 장면으로 둔다: 이음매는 다 지웠고 물결·광택은 이미 지나갔다
completeAt = 0;
sceneT = 10;
simStatus = fmt(STATUS_DONE, { n: pieces.length });
}
// 08 화면 — 조각마다 물결 값만큼 위로 뜨고 밝아진다. 이음매 선은 seamLeft 몫만큼, 완성 뒤에는 그림자도 끈다(조각 사이 어두운 줄이 남지 않게).
// 광택 띠 = 판 상자 안에서 비스듬한 크림색 띠가 왼쪽에서 오른쪽으로 한 번. 보라 고리 = 마지막 조각에서 (지금까지 흐른 시간 × SEAM_SPEED) 만큼 퍼진 지연 기준선, 판 아래 이름표 = 지연 식
const GLOSS_WIDTH = 46;
const LABEL_DELAY = '遅れ = 距離 ÷ {v}px/s';
seamAlpha = seamLeft;
function lookOf08(i) {
const w = waveOf(i);
return { dx: 0, dy: -WAVE_AMP * w, scale: 1, skewX: 0, skewY: 0, alpha: 1, shade: completeAt < 0 ? 1 : clamp(1 - sinceDone(i) / SEAM_FADE_S, 0, 1) };
}
function drawGlow() {
for (let i = 0; i < pieces.length; i += 1) {
const w = waveOf(i);
if (Math.abs(w) < 0.02) {
continue;
}
const p = pieces[i];
ctx.save();
ctx.translate(p.x, p.y - WAVE_AMP * w);
ctx.fillStyle = rgba(w > 0 ? CREAM : INK, Math.abs(w) * (w > 0 ? 0.45 : 0.12));
ctx.fill(paths[i]);
ctx.restore();
}
}
function drawGloss() {
if (completeAt < 0) {
return;
}
const k = (sceneT - completeAt - GLOSS_AT_S) / GLOSS_S;
if (k <= 0 || k >= 1) {
return;
}
const w = COLS * PIECE;
const h = ROWS * PIECE;
const run = w + h + GLOSS_WIDTH * 2;
const at = -GLOSS_WIDTH + easeCarry(k) * run;
ctx.save();
ctx.beginPath();
ctx.rect(BOARD_X, BOARD_Y - WAVE_AMP, w, h + WAVE_AMP * 2);
ctx.clip();
const g = ctx.createLinearGradient(BOARD_X + at - GLOSS_WIDTH, BOARD_Y, BOARD_X + at, BOARD_Y + GLOSS_WIDTH);
g.addColorStop(0, rgba(CREAM, 0));
g.addColorStop(0.5, rgba(CREAM, 0.75));
g.addColorStop(1, rgba(CREAM, 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(BOARD_X + at - GLOSS_WIDTH, BOARD_Y - WAVE_AMP);
ctx.lineTo(BOARD_X + at + GLOSS_WIDTH, BOARD_Y - WAVE_AMP);
ctx.lineTo(BOARD_X + at + GLOSS_WIDTH - h - WAVE_AMP * 2, BOARD_Y + h + WAVE_AMP);
ctx.lineTo(BOARD_X + at - GLOSS_WIDTH - h - WAVE_AMP * 2, BOARD_Y + h + WAVE_AMP);
ctx.closePath();
ctx.fill();
ctx.restore();
}
function drawDelayRings() {
if (completeAt < 0) {
return;
}
const d = puzzle.defs[LAST];
const r = (sceneT - completeAt) * SEAM_SPEED;
const maxR = Math.hypot(COLS * PIECE, ROWS * PIECE);
if (r > maxR) {
return;
}
ctx.save();
ctx.beginPath();
ctx.rect(BOARD_X, BOARD_Y - WAVE_AMP, COLS * PIECE, ROWS * PIECE + WAVE_AMP * 2);
ctx.clip();
ring(d.hx, d.hy, r, 2.5, VIOLET_DEEP, 0.55 * (1 - r / maxR));
ctx.restore();
}
function draw() {
clearStage(PAPER);
drawBoardFrame(1);
// 완성 뒤 판 밑에 그림 한 장을 깐다 — 조각을 따로 잘라 붙이면 경계 화소가 두 번 반투명으로 덮여 바탕이 실선처럼 비치므로,
// 이음매를 지운 몫만큼 같은 그림으로 그 틈을 메운다
if (completeAt >= 0) {
ctx.save();
ctx.globalAlpha = wipedCount() / pieces.length;
ctx.drawImage(pic, BOARD_X, BOARD_Y, COLS * PIECE, ROWS * PIECE);
ctx.restore();
}
drawAllPieces(lookOf08);
drawGlow();
drawDelayRings();
drawGloss();
drawSelection();
pill(fmt(LABEL_DELAY, { v: SEAM_SPEED }), BOARD_X + COLS * PIECE / 2, BOARD_Y + ROWS * PIECE + 20, INK, CREAM, 1, 10);
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();
};
})();// CompletionSeamWave - 같은 항목 vanilla 판 index.html 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./CompletionSeamWave.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function CompletionSeamWave() {
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([]);
}
// 08 — 완성 순간 퍼지는 이음매 물결과 광택. 판 5×4 중 LAST 하나만 오른쪽에 있고, 흉내 낸 손이 넣으면 묶음이 하나가 된다(completeAt).
// 조각 i 의 지연 = 마지막 조각 가운데에서 떨어진 거리 / SEAM_SPEED. 지연이 지난 조각부터 안쪽 이음매가 SEAM_FADE_S 동안 지워지고,
// 판 높이는 τ = 지금 − completeAt − 지연 으로 WAVE_AMP·e^(−WAVE_DECAY·τ)·sin(WAVE_FREQ·τ) 만큼 솟았다 가라앉는다. GLOSS_AT_S 뒤 광택 띠가 GLOSS_S 동안 한 번 지나간다
const COLS = 5;
const ROWS = 4;
const PIECE = 44;
const BOARD_X = 110;
const BOARD_Y = 52;
const LAST = 8;
const LAST_AT: number[] = [412, 150];
const CARRY_AT_S = 0.15;
const CARRY_S = 0.4;
const SEAM_SPEED = 260;
const SEAM_FADE_S = 0.25;
const WAVE_AMP = 5;
const WAVE_DECAY = 3.2;
const WAVE_FREQ = 9;
const GLOSS_AT_S = 0.55;
const GLOSS_S = 0.6;
const LOOP_S = 3.2;
const STATUS_LAST = '마지막 조각 — 남은 묶음 {g}';
const STATUS_WIPE = '완성 — 조각 {k}/{n}개의 이음매를 지움';
function delayOf(i: number): number {
const a = puzzle.defs[i];
const b = puzzle.defs[LAST];
return Math.hypot(a.hx - b.hx, a.hy - b.hy) / SEAM_SPEED;
}
function sinceDone(i: number): number { return completeAt < 0 ? -1 : sceneT - completeAt - delayOf(i); }
function waveOf(i: number): number {
const tau = sinceDone(i);
if (tau <= 0) { return 0; }
return Math.exp(-WAVE_DECAY * tau) * Math.sin(WAVE_FREQ * tau);
}
// 안쪽 이음매가 남은 몫(1 진함 ~ 0 지움). 판 바깥 테두리(곧은 변)는 지우지 않는다
function seamLeft(i: number, s: number): number {
if (puzzle.defs[i].sides[s].tab === 0) { return 1; }
const tau = sinceDone(i);
if (tau < 0) { return 1; }
return 1 - clamp(tau / SEAM_FADE_S, 0, 1);
}
function wipedCount(): number {
let n = 0;
for (let i = 0; i < pieces.length; i += 1) { if (sinceDone(i) >= SEAM_FADE_S) { n += 1; } }
return n;
}
function reset(): void {
newBoard(COLS, ROWS, PIECE, BOARD_X, BOARD_Y, PUZZLE_SEED);
for (let i = 0; i < pieces.length; i += 1) { if (i !== LAST && i !== 0) { joinGroups(0, i); } }
markSeams();
placeAt(LAST, LAST_AT[0], LAST_AT[1], 0);
resetCarries();
const d = puzzle.defs[LAST];
planCarry(LAST, CARRY_AT_S, CARRY_AT_S + CARRY_S, d.hx + 5, d.hy - 4, 0);
simStatus = fmt(STATUS_LAST, { g: groupCount() });
}
function update(dt: number): void {
runCarries(sceneT);
pieces[LAST].th = carrying === LAST ? 0.8 : 0;
stepWorld(dt, SNAP_STIFFNESS, SNAP_DAMPING);
simStatus = completeAt < 0 ? fmt(STATUS_LAST, { g: groupCount() }) : fmt(STATUS_WIPE, { k: wipedCount(), n: pieces.length });
}
function restPose(): void {
assembleAll();
resetCarries();
// 완성 판 — 완성(0초)에서 10초 지난 장면으로 둔다: 이음매는 다 지웠고 물결·광택은 이미 지나갔다
completeAt = 0;
sceneT = 10;
simStatus = fmt(STATUS_DONE, { n: pieces.length });
}
// 08 화면 — 조각마다 물결 값만큼 위로 뜨고 밝아진다. 이음매 선은 seamLeft 몫만큼, 완성 뒤에는 그림자도 끈다(조각 사이 어두운 줄이 남지 않게).
// 광택 띠 = 판 상자 안에서 비스듬한 크림색 띠가 왼쪽에서 오른쪽으로 한 번. 보라 고리 = 마지막 조각에서 (지금까지 흐른 시간 × SEAM_SPEED) 만큼 퍼진 지연 기준선, 판 아래 이름표 = 지연 식
const GLOSS_WIDTH = 46;
const LABEL_DELAY = '지연 = 거리 ÷ {v}px/s';
seamAlpha = seamLeft;
function lookOf08(i: number): PieceLook {
const w = waveOf(i);
return { dx: 0, dy: -WAVE_AMP * w, scale: 1, skewX: 0, skewY: 0, alpha: 1, shade: completeAt < 0 ? 1 : clamp(1 - sinceDone(i) / SEAM_FADE_S, 0, 1) };
}
function drawGlow(): void {
for (let i = 0; i < pieces.length; i += 1) {
const w = waveOf(i);
if (Math.abs(w) < 0.02) { continue; }
const p = pieces[i];
ctx.save();
ctx.translate(p.x, p.y - WAVE_AMP * w);
ctx.fillStyle = rgba(w > 0 ? CREAM : INK, Math.abs(w) * (w > 0 ? 0.45 : 0.12));
ctx.fill(paths[i]);
ctx.restore();
}
}
function drawGloss(): void {
if (completeAt < 0) { return; }
const k = (sceneT - completeAt - GLOSS_AT_S) / GLOSS_S;
if (k <= 0 || k >= 1) { return; }
const w = COLS * PIECE;
const h = ROWS * PIECE;
const run = w + h + GLOSS_WIDTH * 2;
const at = -GLOSS_WIDTH + easeCarry(k) * run;
ctx.save();
ctx.beginPath();
ctx.rect(BOARD_X, BOARD_Y - WAVE_AMP, w, h + WAVE_AMP * 2);
ctx.clip();
const g = ctx.createLinearGradient(BOARD_X + at - GLOSS_WIDTH, BOARD_Y, BOARD_X + at, BOARD_Y + GLOSS_WIDTH);
g.addColorStop(0, rgba(CREAM, 0));
g.addColorStop(0.5, rgba(CREAM, 0.75));
g.addColorStop(1, rgba(CREAM, 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(BOARD_X + at - GLOSS_WIDTH, BOARD_Y - WAVE_AMP);
ctx.lineTo(BOARD_X + at + GLOSS_WIDTH, BOARD_Y - WAVE_AMP);
ctx.lineTo(BOARD_X + at + GLOSS_WIDTH - h - WAVE_AMP * 2, BOARD_Y + h + WAVE_AMP);
ctx.lineTo(BOARD_X + at - GLOSS_WIDTH - h - WAVE_AMP * 2, BOARD_Y + h + WAVE_AMP);
ctx.closePath();
ctx.fill();
ctx.restore();
}
function drawDelayRings(): void {
if (completeAt < 0) { return; }
const d = puzzle.defs[LAST];
const r = (sceneT - completeAt) * SEAM_SPEED;
const maxR = Math.hypot(COLS * PIECE, ROWS * PIECE);
if (r > maxR) { return; }
ctx.save();
ctx.beginPath();
ctx.rect(BOARD_X, BOARD_Y - WAVE_AMP, COLS * PIECE, ROWS * PIECE + WAVE_AMP * 2);
ctx.clip();
ring(d.hx, d.hy, r, 2.5, VIOLET_DEEP, 0.55 * (1 - r / maxR));
ctx.restore();
}
function draw(): void {
clearStage(PAPER);
drawBoardFrame(1);
// 완성 뒤 판 밑에 그림 한 장을 깐다 — 조각을 따로 잘라 붙이면 경계 화소가 두 번 반투명으로 덮여 바탕이 실선처럼 비치므로,
// 이음매를 지운 몫만큼 같은 그림으로 그 틈을 메운다
if (completeAt >= 0) {
ctx.save();
ctx.globalAlpha = wipedCount() / pieces.length;
ctx.drawImage(pic, BOARD_X, BOARD_Y, COLS * PIECE, ROWS * PIECE);
ctx.restore();
}
drawAllPieces(lookOf08);
drawGlow();
drawDelayRings();
drawGloss();
drawSelection();
pill(fmt(LABEL_DELAY, { v: SEAM_SPEED }), BOARD_X + COLS * PIECE / 2, BOARD_Y + ROWS * PIECE + 20, INK, CREAM, 1, 10);
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>
);
}08 · 完成の瞬間に広がる継ぎ目の波と光沢 — 計測 累積面積 26.381% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function delayOf(i) {
const a = puzzle.defs[i];
const b = puzzle.defs[LAST];
return Math.hypot(a.hx - b.hx, a.hy - b.hy) / SEAM_SPEED;
}
function sinceDone(i) { return completeAt < 0 ? -1 : sceneT - completeAt - delayOf(i); }
function waveOf(i) {
const tau = sinceDone(i);
if (tau <= 0) {
return 0;
}
return Math.exp(-WAVE_DECAY * tau) * Math.sin(WAVE_FREQ * tau);
}
// 内側の継ぎ目が残っている割合(1 濃い ~ 0 消えた)。盤の外周(まっすぐな辺)は消さない
function seamLeft(i, s) {
if (puzzle.defs[i].sides[s].tab === 0) {
return 1;
}
const tau = sinceDone(i);
if (tau < 0) {
return 1;
}
return 1 - clamp(tau / SEAM_FADE_S, 0, 1);
}
09ファーストビューでひとりでに組み上がるパズルの場面
写真プリントサービスのランディングページのファーストビューで、だれも触れないうちにパズルが組み上がっていく場面で、0.1秒に山を投げ、0.2秒からはまだ盤へ行っていないピースどうしだけが押し合い、0.75秒からは06の順番で0.38秒ごとに一つずつ持ち上げて07の傾きで運びます。1.05秒から0.38秒おきに置かれ、最初の角はマスに座るだけで、そのあとは置くたびにまとまりの数が11、10と一つずつ減っていきます。解く順番は12/12正しく、最後のピース6が5.233秒に収まるとそこから08の遅れ・波・光沢が広がり、7.4秒を過ぎるとピースはまた散らばって最初に戻ります。

<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([]);
}
// 09 — 첫 화면에서 스스로 맞춰지는 퍼즐 장면. 05 → 06 → 07 → 02 → 03 → 08 을 한 판에 잇는다:
// THROW_AT_S 에 판 가운데 무더기에서 시드로 뽑은 자리로 던지고 RELAX_PER_S 로 relaxOnce(05), solveFrameFirst 순서(06)대로
// STEP_S 마다 한 조각씩 들어 올려(07 높이·기울기) 판 칸으로 옮기고 스프링으로 앉혀(02) 이웃과 묶는다(03).
// 묶음이 하나가 되면 마지막 조각에서 떨어진 거리만큼 늦게 이음매를 지우고 물결·광택(08). LOOP_S 에 다시 던진다
const COLS = 4;
const ROWS = 3;
const PIECE = 46;
const BOARD_X = 148;
const BOARD_Y = 72;
const TRAY_R = PIECE * 0.62;
const THROW_AT_S = 0.1;
const THROW_SPREAD = 150;
const RELAX_AT_S = 0.2;
const RELAX_PER_S = 36;
const RELAX_ITERS = 24;
const SOLVE_START_S = 0.75;
const STEP_S = 0.38;
const CARRY_S = 0.3;
const LIFT_LEAD_S = 0.1;
const TILT_GAIN = 0.0007;
const TILT_MAX = 0.32;
const LIFT_SCALE = 0.06;
const SEAM_SPEED = 260;
const SEAM_FADE_S = 0.25;
const WAVE_AMP = 5;
const WAVE_DECAY = 3.2;
const WAVE_FREQ = 9;
const GLOSS_AT_S = 0.55;
const GLOSS_S = 0.6;
const LOOP_S = 7.4;
const STATUS_SPREAD9 = 'ピース {n}個を広げています';
const STATUS_PLACE = '縁から組んでいます・残りのまとまり {g}・つないだ所 {j}/{n}';
let steps09 = [];
let relaxDone9 = 0;
let lastPiece = 0;
let throw9 = [];
function slotHome9(slot) { return [BOARD_X + (slot % COLS + 0.5) * PIECE, BOARD_Y + (Math.floor(slot / COLS) + 0.5) * PIECE]; }
function ids9() { return pieces.map(function (p, i) { void p; return i; }); }
function reset() {
newBoard(COLS, ROWS, PIECE, BOARD_X, BOARD_Y, PUZZLE_SEED);
const rng = makeRng(PUZZLE_SEED + 9);
throw9 = [];
for (let i = 0; i < pieces.length; i += 1) {
placeAt(i, W / 2 + spread(rng) * 8, H / 2 + spread(rng) * 8, 0);
throw9.push([W / 2 + spread(rng) * THROW_SPREAD, H / 2 + spread(rng) * THROW_SPREAD * 0.6]);
}
steps09 = solveFrameFirst(makeSampler());
lastPiece = steps09.length ? steps09[steps09.length - 1].piece : 0;
relaxDone9 = 0;
resetCarries();
steps09.forEach(function (s, k) {
const t0 = SOLVE_START_S + k * STEP_S;
const h = slotHome9(s.slot);
planCarry(s.piece, t0, t0 + CARRY_S, h[0] + 4, h[1] - 3, 0);
});
onRelease = function (i) {
const s = steps09.filter(function (x) { return x.piece === i; })[0];
const h = slotHome9(s ? s.slot : i);
if (Math.hypot(pieces[i].tx - h[0], pieces[i].ty - h[1]) < SNAP_RADIUS) {
pieces[i].tx = h[0];
pieces[i].ty = h[1];
}
for (let n = 0; n < 4; n += 1) {
tryJoin(i);
}
};
simStatus = fmt(STATUS_SPREAD9, { n: pieces.length });
}
function isPlaced(i) {
for (const c of carries) {
if (c.i === i && c.started) {
return true;
}
}
return false;
}
function delayOf9(i) {
const a = puzzle.defs[i];
const b = puzzle.defs[lastPiece];
return Math.hypot(a.hx - b.hx, a.hy - b.hy) / SEAM_SPEED;
}
function sinceDone9(i) { return completeAt < 0 ? -1 : sceneT - completeAt - delayOf9(i); }
function waveOf9(i) {
const tau = sinceDone9(i);
return tau <= 0 ? 0 : Math.exp(-WAVE_DECAY * tau) * Math.sin(WAVE_FREQ * tau);
}
function tiltOf9(i) { return clamp(pieces[i].mvx * TILT_GAIN, -TILT_MAX, TILT_MAX); }
function update(dt) {
if (sceneT >= THROW_AT_S && sceneT - dt < THROW_AT_S) {
throw9.forEach(function (t, i) { pieces[i].tx = t[0]; pieces[i].ty = t[1]; });
}
const want = clamp(Math.floor((sceneT - RELAX_AT_S) * RELAX_PER_S) + 1, 0, RELAX_ITERS);
while (relaxDone9 < want) {
// 아직 판으로 안 간 조각끼리만 쟁반에서 밀어낸다
relaxOnce(ids9().filter(function (i) { return !isPlaced(i); }), TRAY_R, BOARD_X, BOARD_Y, BOARD_X + COLS * PIECE, BOARD_Y + ROWS * PIECE);
relaxDone9 += 1;
}
runCarries(sceneT);
steps09.forEach(function (s, k) {
const t0 = SOLVE_START_S + k * STEP_S;
pieces[s.piece].th = sceneT >= t0 - LIFT_LEAD_S && sceneT < t0 + CARRY_S ? 1 : 0;
});
stepWorld(dt, SNAP_STIFFNESS, SNAP_DAMPING);
if (completeAt >= 0) {
simStatus = fmt(STATUS_DONE, { n: pieces.length });
}
else if (sceneT >= SOLVE_START_S) {
simStatus = fmt(STATUS_PLACE, { g: groupCount(), j: joinsMade(), n: pieces.length - 1 });
}
}
function restPose() {
assembleAll();
resetCarries();
// 완성 판 — 완성(0초)에서 10초 지난 장면으로 둔다: 이음매는 다 지웠고 물결·광택은 이미 지나갔다
completeAt = 0;
sceneT = 10;
simStatus = fmt(STATUS_DONE, { n: pieces.length });
}
// 09 화면 — 들린 조각은 07 처럼 높이만큼 커지고 기울며 그림자가 멀어진다. 완성 뒤에는 08 처럼 판 밑에 그림을 깔고
// 이음매를 지운 몫만큼 메운 뒤, 조각마다 물결만큼 뜨고 밝아지며 광택 띠가 한 번 지나간다. 판 위 이름표 = 残りのまとまり 수
const GLOSS_WIDTH = 46;
const LABEL_GROUPS = '残りのまとまり {g}';
seamAlpha = function (i, s) {
if (puzzle.defs[i].sides[s].tab === 0) {
return 1;
}
const tau = sinceDone9(i);
return tau < 0 ? 1 : 1 - clamp(tau / SEAM_FADE_S, 0, 1);
};
function wiped9() {
let n = 0;
for (let i = 0; i < pieces.length; i += 1) {
if (sinceDone9(i) >= SEAM_FADE_S) {
n += 1;
}
}
return n;
}
function lookOf09(i) {
const p = pieces[i];
const w = waveOf9(i);
const done = completeAt >= 0;
if (p.h < 0.02) {
return { dx: 0, dy: -WAVE_AMP * w, scale: 1, skewX: 0, skewY: 0, alpha: 1, shade: done ? clamp(1 - sinceDone9(i) / SEAM_FADE_S, 0, 1) : 1 };
}
return { dx: 0, dy: -4 * p.h, scale: 1 + LIFT_SCALE * p.h, skewX: tiltOf9(i), skewY: 0, alpha: 1, shade: 1 };
}
function drawGlow9() {
for (let i = 0; i < pieces.length; i += 1) {
const w = waveOf9(i);
if (Math.abs(w) < 0.02) {
continue;
}
const p = pieces[i];
ctx.save();
ctx.translate(p.x, p.y - WAVE_AMP * w);
ctx.fillStyle = rgba(w > 0 ? CREAM : INK, Math.abs(w) * (w > 0 ? 0.45 : 0.12));
ctx.fill(paths[i]);
ctx.restore();
}
}
function drawGloss9() {
if (completeAt < 0) {
return;
}
const k = (sceneT - completeAt - GLOSS_AT_S) / GLOSS_S;
if (k <= 0 || k >= 1) {
return;
}
const w = COLS * PIECE;
const h = ROWS * PIECE;
const at = -GLOSS_WIDTH + easeCarry(k) * (w + h + GLOSS_WIDTH * 2);
ctx.save();
ctx.beginPath();
ctx.rect(BOARD_X, BOARD_Y - WAVE_AMP, w, h + WAVE_AMP * 2);
ctx.clip();
const g = ctx.createLinearGradient(BOARD_X + at - GLOSS_WIDTH, BOARD_Y, BOARD_X + at, BOARD_Y + GLOSS_WIDTH);
g.addColorStop(0, rgba(CREAM, 0));
g.addColorStop(0.5, rgba(CREAM, 0.75));
g.addColorStop(1, rgba(CREAM, 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(BOARD_X + at - GLOSS_WIDTH, BOARD_Y - WAVE_AMP);
ctx.lineTo(BOARD_X + at + GLOSS_WIDTH, BOARD_Y - WAVE_AMP);
ctx.lineTo(BOARD_X + at + GLOSS_WIDTH - h - WAVE_AMP * 2, BOARD_Y + h + WAVE_AMP);
ctx.lineTo(BOARD_X + at - GLOSS_WIDTH - h - WAVE_AMP * 2, BOARD_Y + h + WAVE_AMP);
ctx.closePath();
ctx.fill();
ctx.restore();
}
function draw() {
clearStage(PAPER);
drawBoardFrame(1);
if (completeAt >= 0) {
ctx.save();
ctx.globalAlpha = wiped9() / pieces.length;
ctx.drawImage(pic, BOARD_X, BOARD_Y, COLS * PIECE, ROWS * PIECE);
ctx.restore();
}
drawAllPieces(lookOf09);
drawGlow9();
drawGloss9();
drawSelection();
pill(fmt(LABEL_GROUPS, { g: groupCount() }), BOARD_X + COLS * PIECE / 2, BOARD_Y - 22, INK, CREAM, 1, 11);
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();
};
})();// SelfAssemblingPuzzleHero - 같은 항목 vanilla 판 index.html 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./SelfAssemblingPuzzleHero.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SelfAssemblingPuzzleHero() {
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([]);
}
// 09 — 첫 화면에서 스스로 맞춰지는 퍼즐 장면. 05 → 06 → 07 → 02 → 03 → 08 을 한 판에 잇는다:
// THROW_AT_S 에 판 가운데 무더기에서 시드로 뽑은 자리로 던지고 RELAX_PER_S 로 relaxOnce(05), solveFrameFirst 순서(06)대로
// STEP_S 마다 한 조각씩 들어 올려(07 높이·기울기) 판 칸으로 옮기고 스프링으로 앉혀(02) 이웃과 묶는다(03).
// 묶음이 하나가 되면 마지막 조각에서 떨어진 거리만큼 늦게 이음매를 지우고 물결·광택(08). LOOP_S 에 다시 던진다
const COLS = 4;
const ROWS = 3;
const PIECE = 46;
const BOARD_X = 148;
const BOARD_Y = 72;
const TRAY_R = PIECE * 0.62;
const THROW_AT_S = 0.1;
const THROW_SPREAD = 150;
const RELAX_AT_S = 0.2;
const RELAX_PER_S = 36;
const RELAX_ITERS = 24;
const SOLVE_START_S = 0.75;
const STEP_S = 0.38;
const CARRY_S = 0.3;
const LIFT_LEAD_S = 0.1;
const TILT_GAIN = 0.0007;
const TILT_MAX = 0.32;
const LIFT_SCALE = 0.06;
const SEAM_SPEED = 260;
const SEAM_FADE_S = 0.25;
const WAVE_AMP = 5;
const WAVE_DECAY = 3.2;
const WAVE_FREQ = 9;
const GLOSS_AT_S = 0.55;
const GLOSS_S = 0.6;
const LOOP_S = 7.4;
const STATUS_SPREAD9 = '조각 {n}개를 펼치는 중';
const STATUS_PLACE = '테두리부터 맞추는 중 · 남은 묶음 {g} · 이은 자리 {j}/{n}';
let steps09: SolveStep[] = [];
let relaxDone9 = 0;
let lastPiece = 0;
let throw9: number[][] = [];
function slotHome9(slot: number): [number, number] { return [BOARD_X + (slot % COLS + 0.5) * PIECE, BOARD_Y + (Math.floor(slot / COLS) + 0.5) * PIECE]; }
function ids9(): number[] { return pieces.map(function (p: Piece, i: number): number { void p; return i; }); }
function reset(): void {
newBoard(COLS, ROWS, PIECE, BOARD_X, BOARD_Y, PUZZLE_SEED);
const rng = makeRng(PUZZLE_SEED + 9);
throw9 = [];
for (let i = 0; i < pieces.length; i += 1) {
placeAt(i, W / 2 + spread(rng) * 8, H / 2 + spread(rng) * 8, 0);
throw9.push([W / 2 + spread(rng) * THROW_SPREAD, H / 2 + spread(rng) * THROW_SPREAD * 0.6]);
}
steps09 = solveFrameFirst(makeSampler());
lastPiece = steps09.length ? steps09[steps09.length - 1].piece : 0;
relaxDone9 = 0;
resetCarries();
steps09.forEach(function (s: SolveStep, k: number): void {
const t0 = SOLVE_START_S + k * STEP_S;
const h = slotHome9(s.slot);
planCarry(s.piece, t0, t0 + CARRY_S, h[0] + 4, h[1] - 3, 0);
});
onRelease = function (i: number): void {
const s = steps09.filter(function (x: SolveStep): boolean { return x.piece === i; })[0];
const h = slotHome9(s ? s.slot : i);
if (Math.hypot(pieces[i].tx - h[0], pieces[i].ty - h[1]) < SNAP_RADIUS) { pieces[i].tx = h[0]; pieces[i].ty = h[1]; }
for (let n = 0; n < 4; n += 1) { tryJoin(i); }
};
simStatus = fmt(STATUS_SPREAD9, { n: pieces.length });
}
function isPlaced(i: number): boolean {
for (const c of carries) { if (c.i === i && c.started) { return true; } }
return false;
}
function delayOf9(i: number): number {
const a = puzzle.defs[i];
const b = puzzle.defs[lastPiece];
return Math.hypot(a.hx - b.hx, a.hy - b.hy) / SEAM_SPEED;
}
function sinceDone9(i: number): number { return completeAt < 0 ? -1 : sceneT - completeAt - delayOf9(i); }
function waveOf9(i: number): number {
const tau = sinceDone9(i);
return tau <= 0 ? 0 : Math.exp(-WAVE_DECAY * tau) * Math.sin(WAVE_FREQ * tau);
}
function tiltOf9(i: number): number { return clamp(pieces[i].mvx * TILT_GAIN, -TILT_MAX, TILT_MAX); }
function update(dt: number): void {
if (sceneT >= THROW_AT_S && sceneT - dt < THROW_AT_S) {
throw9.forEach(function (t: number[], i: number): void { pieces[i].tx = t[0]; pieces[i].ty = t[1]; });
}
const want = clamp(Math.floor((sceneT - RELAX_AT_S) * RELAX_PER_S) + 1, 0, RELAX_ITERS);
while (relaxDone9 < want) {
// 아직 판으로 안 간 조각끼리만 쟁반에서 밀어낸다
relaxOnce(ids9().filter(function (i: number): boolean { return !isPlaced(i); }), TRAY_R, BOARD_X, BOARD_Y, BOARD_X + COLS * PIECE, BOARD_Y + ROWS * PIECE);
relaxDone9 += 1;
}
runCarries(sceneT);
steps09.forEach(function (s: SolveStep, k: number): void {
const t0 = SOLVE_START_S + k * STEP_S;
pieces[s.piece].th = sceneT >= t0 - LIFT_LEAD_S && sceneT < t0 + CARRY_S ? 1 : 0;
});
stepWorld(dt, SNAP_STIFFNESS, SNAP_DAMPING);
if (completeAt >= 0) { simStatus = fmt(STATUS_DONE, { n: pieces.length }); }
else if (sceneT >= SOLVE_START_S) { simStatus = fmt(STATUS_PLACE, { g: groupCount(), j: joinsMade(), n: pieces.length - 1 }); }
}
function restPose(): void {
assembleAll();
resetCarries();
// 완성 판 — 완성(0초)에서 10초 지난 장면으로 둔다: 이음매는 다 지웠고 물결·광택은 이미 지나갔다
completeAt = 0;
sceneT = 10;
simStatus = fmt(STATUS_DONE, { n: pieces.length });
}
// 09 화면 — 들린 조각은 07 처럼 높이만큼 커지고 기울며 그림자가 멀어진다. 완성 뒤에는 08 처럼 판 밑에 그림을 깔고
// 이음매를 지운 몫만큼 메운 뒤, 조각마다 물결만큼 뜨고 밝아지며 광택 띠가 한 번 지나간다. 판 위 이름표 = 남은 묶음 수
const GLOSS_WIDTH = 46;
const LABEL_GROUPS = '남은 묶음 {g}';
seamAlpha = function (i: number, s: number): number {
if (puzzle.defs[i].sides[s].tab === 0) { return 1; }
const tau = sinceDone9(i);
return tau < 0 ? 1 : 1 - clamp(tau / SEAM_FADE_S, 0, 1);
};
function wiped9(): number {
let n = 0;
for (let i = 0; i < pieces.length; i += 1) { if (sinceDone9(i) >= SEAM_FADE_S) { n += 1; } }
return n;
}
function lookOf09(i: number): PieceLook {
const p = pieces[i];
const w = waveOf9(i);
const done = completeAt >= 0;
if (p.h < 0.02) { return { dx: 0, dy: -WAVE_AMP * w, scale: 1, skewX: 0, skewY: 0, alpha: 1, shade: done ? clamp(1 - sinceDone9(i) / SEAM_FADE_S, 0, 1) : 1 }; }
return { dx: 0, dy: -4 * p.h, scale: 1 + LIFT_SCALE * p.h, skewX: tiltOf9(i), skewY: 0, alpha: 1, shade: 1 };
}
function drawGlow9(): void {
for (let i = 0; i < pieces.length; i += 1) {
const w = waveOf9(i);
if (Math.abs(w) < 0.02) { continue; }
const p = pieces[i];
ctx.save();
ctx.translate(p.x, p.y - WAVE_AMP * w);
ctx.fillStyle = rgba(w > 0 ? CREAM : INK, Math.abs(w) * (w > 0 ? 0.45 : 0.12));
ctx.fill(paths[i]);
ctx.restore();
}
}
function drawGloss9(): void {
if (completeAt < 0) { return; }
const k = (sceneT - completeAt - GLOSS_AT_S) / GLOSS_S;
if (k <= 0 || k >= 1) { return; }
const w = COLS * PIECE;
const h = ROWS * PIECE;
const at = -GLOSS_WIDTH + easeCarry(k) * (w + h + GLOSS_WIDTH * 2);
ctx.save();
ctx.beginPath();
ctx.rect(BOARD_X, BOARD_Y - WAVE_AMP, w, h + WAVE_AMP * 2);
ctx.clip();
const g = ctx.createLinearGradient(BOARD_X + at - GLOSS_WIDTH, BOARD_Y, BOARD_X + at, BOARD_Y + GLOSS_WIDTH);
g.addColorStop(0, rgba(CREAM, 0));
g.addColorStop(0.5, rgba(CREAM, 0.75));
g.addColorStop(1, rgba(CREAM, 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(BOARD_X + at - GLOSS_WIDTH, BOARD_Y - WAVE_AMP);
ctx.lineTo(BOARD_X + at + GLOSS_WIDTH, BOARD_Y - WAVE_AMP);
ctx.lineTo(BOARD_X + at + GLOSS_WIDTH - h - WAVE_AMP * 2, BOARD_Y + h + WAVE_AMP);
ctx.lineTo(BOARD_X + at - GLOSS_WIDTH - h - WAVE_AMP * 2, BOARD_Y + h + WAVE_AMP);
ctx.closePath();
ctx.fill();
ctx.restore();
}
function draw(): void {
clearStage(PAPER);
drawBoardFrame(1);
if (completeAt >= 0) {
ctx.save();
ctx.globalAlpha = wiped9() / pieces.length;
ctx.drawImage(pic, BOARD_X, BOARD_Y, COLS * PIECE, ROWS * PIECE);
ctx.restore();
}
drawAllPieces(lookOf09);
drawGlow9();
drawGloss9();
drawSelection();
pill(fmt(LABEL_GROUPS, { g: groupCount() }), BOARD_X + COLS * PIECE / 2, BOARD_Y - 22, INK, CREAM, 1, 11);
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>
);
}09 · ファーストビューでひとりでに組み上がるパズルの場面 — 計測 累積面積 39.764% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function update(dt) {
if (sceneT >= THROW_AT_S && sceneT - dt < THROW_AT_S) {
throw9.forEach(function (t, i) { pieces[i].tx = t[0]; pieces[i].ty = t[1]; });
}
const want = clamp(Math.floor((sceneT - RELAX_AT_S) * RELAX_PER_S) + 1, 0, RELAX_ITERS);
while (relaxDone9 < want) {
// まだ盤へ行っていないピースどうしだけをトレイで押し離す
relaxOnce(ids9().filter(function (i) { return !isPlaced(i); }), TRAY_R, BOARD_X, BOARD_Y, BOARD_X + COLS * PIECE, BOARD_Y + ROWS * PIECE);
relaxDone9 += 1;
}
runCarries(sceneT);
steps09.forEach(function (s, k) {
const t0 = SOLVE_START_S + k * STEP_S;
pieces[s.piece].th = sceneT >= t0 - LIFT_LEAD_S && sceneT < t0 + CARRY_S ? 1 : 0;
});
stepWorld(dt, SNAP_STIFFNESS, SNAP_DAMPING);
if (completeAt >= 0) {
simStatus = fmt(STATUS_DONE, { n: pieces.length });
}
else if (sceneT >= SOLVE_START_S) {
simStatus = fmt(STATUS_PLACE, { g: groupCount(), j: joinsMade(), n: pieces.length - 1 });
}
}
ジグソーパズル 作り方 手順をWebに置きかえると、ドラッグのほかに何が要るのでしょうか?
ドラッグはピースを動かすだけです。盤にはさらに、放したピースが本当の隣のそばにあるかを問う判定(02・04)、つながったピースを一つの塊として引きずるまとまり(03)、絵の輪郭どおりに選ぶ当たり判定(07)、始まりにピースを盤の外へ広げるトレイ(05)が要ります。四つのうちどれかが欠けると、ピースは動いてもパズルは終わりません。
イベントページや授業に組み込むなら
イベントページなら、応募ボタンの手前に3×3の盤(02・04と同じ大きさ)を置き、完成したら08の波で次の段階を開く形が無理のない使い方です。授業なら地図や図解を4×3の盤に切り、行き詰まった生徒に06の色の地図を見せると、そのピースがなぜそこに入るのかまでヒントが説明してくれます。商品のイラストで動く盤を見せたいブランドページなら、09のようにオープニングを自動で進め、人が盤に触れた瞬間に自動の再生が止まる今の作りをそのまま残すとよいでしょう。
盤が完成するまでに何回合体するか
ピースが n 個なら、まとまりが一つになるまでの合体はちょうど n − 1 回です。だから状態の行「残りのまとまり {g}・つないだ所 {j}/{n}」の分母はピース数ではなく pieces.length - 1 で、12個の盤はつないだ数が11に届いたところで完成します。03の11回の合体はこの数と一致します。
ドラッグ部品の記事との違い
ドラッグ アンド ドロップ 実装 9パターンの01はドラッグしたものをいちばん近いセルの中心へ吸い寄せ、04は隣の中点を越えると入れ替わります。ここでの02はセルではなく相手になる隣のピースに対する位置と角度のずれを見ていて、つながったあとは03のように塊ごと動くので、そこがドラッグのパズルに上乗せされる部分です。もっと軽いパズル遊びから始めたいなら、HTML CSS JS ゲーム 9種のロゴ当てパズルも入り口になります。
隣どうしは同じ曲線を裏返して使い、吸着は距離と角度の二つのしきい値で、まとまりは代表のピース一つで決めます — パズル盤の下にある計算
辺は一度だけ作って隣のために裏返す
makePuzzle は横の辺を左から右へ、縦の辺を上から下へ、それぞれ一度だけ作ります。ピースは上と右の辺をそのまま、下と左の辺を点の順番だけを逆にする reverseChain を通して使い、そのまま使うピースのタブの符号は −sign、裏返して使うピースは +sign なので、片側のこぶはもう片側のくぼみになります。オリジナル ジグソーパズル 作り方をコードで書くと、九つのデモの裏にあるのはこれだけで、種が一つと辺を一巡する処理が一つあれば、形の上ではまるように作られた十二の輪郭ができあがります。
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] }
];
3次曲線の一区間 (p0, c1, c2, p1) は (p1, c2, c1, p0) と読んでも同じ曲線をなぞるので、隣どうしの境目は一つの点の並びを逆向きに歩いているだけです。
減衰比一つで行き過ぎを読む
stepWorld が回すばねはどれも同じ小刻みの更新(速度 += (硬さ × 残りの距離 − 減衰 × 速度) × h、位置 += 速度 × h、h = 1/240秒)で動くので、減衰 ÷ (2√硬さ) で出る減衰比が分かれば行き過ぎを見積もれます。その比を静止から動き出すステップ応答の行き過ぎ e^(−ζπ/√(1−ζ²)) に入れて計算すると、02(ζ 0.553)は12.4%(実測11.9%)、04(0.41)は24.4%(実測 21.5° ÷ 90° = 23.9%)、07の高さ(0.465)は19.2%(実測18.6%)、01の広がり(0.58)は10.7%(実測10.3%)です。括弧の外は式で計算した値、括弧の中は関数を直接呼び出して測った値です。式はどれも実測を少し上回っていて、1/240秒刻みの積分が連続の式よりわずかに強く減衰させるためと考えられます。
まとまりの検索と重なりの押し出しにかかる手間
findRoot は代表までのぼる途中で、通ったマスを二段上のマスに向け直すので、次の検索は短くなります。joinGroups は小さいまとまりを大きいほうの下に入れるので木が浅いまま保たれ、03の11回の合体のあとでもいちばん深いマスは代表から一段でした。05の relaxOnce はピースのあらゆる組を比べ、12個なら1回に66組、それを1秒に18回なので、盤が大きくなると組の数はピース数のほぼ2乗で増えていきます。
すき間のあく継ぎ目・別のピースをつかむ当たり判定・高解像度の画面でぼやける縁 — パズル盤が壊れる三つの場所
ピースごとに丸めると半ピクセルのすき間
01の盤はピースの一辺が55.5pxで、盤の左上が (33.5, 52.25) にあるので、ピースの角はピクセルの途中に落ちます。各ピースが自分の左上の角を roundShift(v) = Math.round(v) − v で整数ピクセルへずらして別々に描くと、ピースは−0.25〜+0.5pxのばらばらな量だけずれ、内側の辺17本のうち7本に0.5pxのすき間があき、10本は−0.5〜−0.25px重なり、ぴったり合う辺は一本もありません。曲線を一度だけ作って片側はそのまま、もう片側は裏返して使えば、17組すべてで差は0です。まったく同じ経路で接する二つのピースでも、それぞれを別々に clip して貼ると境目のピクセルが半透明で二度塗られて背景が透けて線のように見えるので、08と09は完成のあと盤の下に絵を一枚敷いています。
四角で選ぶと隣のピースをつかむ
こぶが外へ張り出したピースを囲む四角の面積は2,502.5〜4,201.8px²で、2,500px²のマスの最大1.68倍あるため、隣どうしの四角は重なります。07の完成した盤で上に描いたピースから順に四角で選ぶと、1px間隔の30,000点のうち3,990点(13.3%)、2px間隔では14.0%で指の下とは別のピースが選ばれ、輪郭で選べば外れは0%です。探り点の札が「同じピース a」から「四角 a・経路 b」に変わり、コーラルの点線とミントの輪郭が別々のピースに乗るたびに、その外れの一回を見ていることになります。
倍率を抜くとこぶの縁がぼやける
倍率2の画面では、キャンバスの内部サイズに倍率を掛ければ960×600、抜けば480×300になり、キャンバスの1ピクセルが端末のピクセル2×2を覆います。05の真ん中の二つのパネル「倍率を掛けた」「倍率が抜けた」はピース5の右のこぶの先のまわり16pxを両方の方法で描いていて、覆われ方が0.1〜0.9のぼやけたピクセルは倍率ありの54個から倍率なしの158個へ、2.93倍に増えます。この盤の fitCanvas は devicePixelRatio を掛けたうえで2で切っています。
// ピクセル比 — キャンバスの内部サイズに掛ける。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);
}
倍率の上限やタブの大きさを自分で変えてみたいときは、比較パネルと九つの場面のフォルダーをダウンロードし、解凍するときのパスワード欄に 4kz7bnan と打ち込んでください。単純に計算すると、倍率の上限を3に上げれば内部のキャンバスは480×300 × 3 = 1440×900になり、隠れた絵をピクセルごとに描く paintPicture の仕事もピクセル数に比例して、倍率2のときの (3 ÷ 2)² = 2.25倍になります。
タブを大きくするとピースはどう変わるのでしょうか — タブの大きさ・吸着の距離・ばねの硬さの三つを比べます
| 名前 | 変えた値 | 変わること |
|---|---|---|
| タブを大きく | 01 TAB_SIZE 0.2 → 0.3 |
こぶの高さ 19.8 → 29.7%、幅 19.3 → 28.9%(辺の長さに対する割合)、一つのピースで隣り合う二辺のこぶ・くぼみがいちばん近づく距離 16.12 → 5.82px |
| 吸着の距離を広く | 02 SNAP_RADIUS 14 → 28 |
三回の置き方の結果は同じ、しきい値の端で置いたときの行き過ぎ 1.6 → 3.26px |
| ばねを硬く | 02 SNAP_STIFFNESS 160 → 400 |
減衰比 0.553 → 0.35、行き過ぎ 1.35px(11.9%)→ 3.46px(30.5%)、0をまたぐ回数 5 → 9、±0.5pxの帯にとどまり始める時刻 0.417 → 0.388秒 |
タブを大きくするとかみ合う形は読み取りやすくなりますが、角の近くでは二つのこぶが5.82pxまで近づき、ピースの先が混み合います。吸着の距離を28pxにしても二回目の36.77pxは外のままなので結果は変わらず、しきい値の端で置くと出発点が2倍遠くなるぶん、行き過ぎは同じ11.9%の割合のまま1.6pxから3.26pxになります。硬さだけを400に上げると減衰比は 14 ÷ (2√400) = 0.35 に下がり、上の式で計算すると30.9%、関数を直接呼び出して測った値は30.5%でした。0.553を保つには減衰も √(400 ÷ 160) ≈ 1.58倍、およそ22.1まで上げる必要があります(デモで測った値ではなく計算です)。
目で追わなくても進み具合が伝わるように — はまったピース数を知らせる文・一時停止ボタン・動きを減らす設定のアクセシビリティ
絵の代わりに読まれる文
九つのキャンバスはどれも role="img" と、場面を一文で説明する aria-label、tabindex="0" を持っています。場面の下の状態の行は自動の再生中は aria-live="off" で黙っていて、人が最初に入力すると polite に切り替わり、そこからは文が変わるたびに「残りのまとまり g・つないだ所 j/n」を、まとまりが一つになると「完成 — ピース n個が一つのまとまり」を読み上げます。
キーボードと一時停止
キャンバスにフォーカスがあるとき、Enter か Space で次のまとまりを選び、矢印キーで4px(Shift を押すと16px)動かすたびに置いたときの判定が走り、Escape で手放します。04だけは、離れたピース一つを選んだ状態で R を押すと目標の角度が90°増え、角度のばねがピースを回します。場面ごとに一時停止の本物の button があって aria-pressed を true にし、タブが隠れているあいだはループが止まって、戻ると再開します。動きを減らす設定ではループが止まってすべてのピースがはまった完成の盤が一枚描かれるので、0.4秒と1.2秒の二枚のキャプチャは九つの場面すべてでピクセルの差が0でした。08と09は完成から10秒後の盤を見せるので、継ぎ目は消えていて波と光沢はもう通り過ぎています。この設定のもとでキー入力をすると、ばねは目標にそのまま座り、一度だけ描き直されます。
javascript ジグソーパズルのよくある質問 — ピース数・写真の差し替え・スマートフォンのタッチ
盤のピース数はどこで変えますか?
各項目の index.html にある COLS・ROWS・PIECE の行です。盤の幅 COLS × PIECE と高さ ROWS × PIECE は480×300のキャンバスに収める必要があり、05と09のトレイの半径 TRAY_R は PIECE * 0.62 なので、ピースに合わせて小さくなります。ピースを増やすと06のソルバーは候補が二つ以上残る内側のマスに多く出会い、色の点数が決め手になる場面が増えると考えられます。
ジグソーパズル 自作 アプリのように、手持ちの写真でピースを作れますか?
作れますが、条件が一つあります。ピースが見ているのは隠れたキャンバス pic だけなので、paintPicture のピクセル塗りを、写真を盤の大きさ × 倍率で描く drawImage に置きかえれば、01〜05・07・08はそのまま動きます。06と09は色の点数をつけるためにそのキャンバスを getImageData で読むので、別のオリジンの写真を許可なしに描くとキャンバスが汚染されて読み取りがセキュリティエラーで止まります。同じオリジンの写真か、許可つきで配信される写真を使ってください。
スマートフォンで指でピースを動かせますか?
動かせます。マウス・ペン・指はどれも pointerdown・pointermove・pointerup・pointercancel の一組のハンドラーを通り、setPointerCapture が指がキャンバスの外へ出てもピースをつかんだままにし、キャンバスだけにかけた touch-action: none が盤の上で引きずるあいだページのスクロールを止めます。九つの場面すべてで、ピースをつかんで60px引きずるとそのまとまり全体が一緒に動きました。









