GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

javascript ジグソーパズル 作り方 9種 — 曲線のピースと吸着

javascript ジグソーパズル 作り方の出発点は、完成をどう見分けるかです。ジグソーが完成するのは、盤の上のまとまりが一つになった瞬間です。01番は隣り合う二つのピースが一本の曲線を裏返して分け合い、すき間をなくします。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

写真を四角に切るだけではジグソーになりません — ピースの形を決めるのは隣と分け合う縁の曲線です

ただの四角に切った絵は、形だけ見ればどのピースもどのマスにも入るので、遊ぶ人の手がかりは絵柄しか残りません。ジグソーのピースは内側の辺ごとにこぶが外へ張り出し、その辺の向かいのピースには同じ形のくぼみがあるので、色より先に輪郭が相手を絞り込みます。ジグソーパズル 自作 カットを画面の上でやるなら、二つのピースがそれぞれ似た曲線を描くのではなく、一本の曲線を一緒に使うことが、髪の毛ほどのすき間も出さずにはめるための条件になります。

一本の辺を五つの曲線区間で書く

このデモの内側の辺は、肩・首・こぶの頂上・首・肩と続く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倍拡大のうち上段は一本を共有した継ぎ目、下段はピースごとに自分の角を整数ピクセルへ丸めて描いた継ぎ目です。

隣と曲線を分け合うピースの切り出し
新しいタブで開く

01 · 隣と曲線を分け合うピースの切り出し — 計測 累積面積 47.428% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

TAB_SIZEEDGE_WOBBLESPREAD_GAIN
    // チェーンを裏返す — 点の順番だけを逆にする。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の帯の内側にとどまります。

距離と角度のしきい値の内側でだけ引き寄せる吸着
新しいタブで開く

02 · 距離と角度のしきい値の内側でだけ引き寄せる吸着 — 計測 累積面積 17.341% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SNAP_RADIUSSNAP_ANGLESNAP_STIFFNESS
    // まとまり 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段以上離れたピースは一つもありません。

つながったピースを代表一つにまとめる合体
新しいタブで開く

03 · つながったピースを代表一つにまとめる合体 — 計測 累積面積 26.863% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MERGE_PLANSEAM_FADE_SSEAM_MIN
    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°へ巻き戻りません。

90度ずつ回して合わせる回転ピース
新しいタブで開く

04 · 90度ずつ回して合わせる回転ピース — 計測 累積面積 19.883% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ROT_STIFFNESSROT_DAMPINGTURNS_AT_START
    // 放したとき — しきい値の内側ならまとまりごと目標を正しい位置へ移してつなぐ。外側ならその場にとどまる
    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倍なので、円どうしはぴったり接したところで止まります。

重ならずに広がるピースのトレイ
新しいタブで開く

05 · 重ならずに広がるピースのトレイ — 計測 累積面積 24.217% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

TRAY_RRELAX_PER_STRAY_MARGIN
        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)。

縁から解いていく自動ソルバー
新しいタブで開く

06 · 縁から解いていく自動ソルバー — 計測 累積面積 28.913% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

COLOR_WEIGHTTAB_MISMATCH_COSTCOLOR_INSET
        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 に問います。

持ち上げたピースの傾きと影の高さ
新しいタブで開く

07 · 持ち上げたピースの傾きと影の高さ — 計測 累積面積 36.252% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

TILT_GAINTILT_MAXSHADOW_OFFSET
    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)の濃さで敷かれ、ピースの境目を埋めます。

完成の瞬間に広がる継ぎ目の波と光沢
新しいタブで開く

08 · 完成の瞬間に広がる継ぎ目の波と光沢 — 計測 累積面積 26.381% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SEAM_SPEEDWAVE_DECAYGLOSS_S
    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秒を過ぎるとピースはまた散らばって最初に戻ります。

ファーストビューでひとりでに組み上がるパズルの場面
新しいタブで開く

09 · ファーストビューでひとりでに組み上がるパズルの場面 — 計測 累積面積 39.764% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STEP_SRELAX_ITERSLIFT_LEAD_S
    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引きずるとそのまとまり全体が一緒に動きました。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。