GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

온라인 직소 퍼즐 만들기 9종 — 곡선 조각과 자석 맞물림을 계산으로

온라인 직소 퍼즐 만들기는 그림 한 장을 혹과 홈이 달린 조각으로 잘라 화면에서 끌어 맞추게 하는 일이다. 퍼즐이 다 맞춰지는 순간은 판 위의 묶음이 하나로 줄어드는 순간이다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

사진을 네모로만 자르면 퍼즐이 아니다 — 직소 조각의 모양을 정하는 것은 이웃과 나눠 쓰는 테두리 곡선

네모 칸으로 자른 그림은 어느 조각을 어느 칸에 놓아도 모양으로는 막히지 않아서, 맞추는 사람이 그림만 보고 자리를 찾아야 한다. 직소 조각은 변마다 한쪽으로 솟은 혹이 있고 이웃 조각의 같은 변에 그 혹이 들어갈 홈이 파여 있어, 모양이 먼저 짝을 좁혀 준다. 그 짝이 화면에서 어긋나지 않으려면 두 조각이 비슷한 곡선을 하나씩 따로 그리지 않고 한 곡선을 함께 써야 한다.

변 하나를 다섯 마디 곡선으로 적기

이 데모의 안쪽 변 하나는 3차 곡선 다섯 마디(어깨 → 목 → 혹 위 → 목 → 어깨)다. tabEdge 가 변 방향을 u, 그 90도 쪽을 v 로 두고 변 길이 단위로 점을 적은 뒤 v 에 탭 부호 ±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 가 같은 꺾은선을 본다. 그리는 경로와 판정하는 경로가 다르면 눈에 보이는 혹 끝을 눌러도 옆 조각이 집히는데, 이 구성에서는 그 어긋남이 생기지 않는다.

온라인 직소 퍼즐 만들기 9종 — 곡선을 나눠 쓰는 조각 자르기·문턱 안 스프링 맞물림·대표 하나로 묶는 합치기·90도씩 돌려 맞추는 조각·겹치지 않게 펼치는 쟁반·테두리부터 푸는 자동 풀이·들어 올린 조각의 기울기와 그림자·완성 물결과 광택·스스로 맞춰지는 첫 화면 장면

01~04 는 판 위 조각 하나가 지키는 규칙(자르기·맞물림·묶기·돌리기)이고, 05~07 은 판 둘레와 손의 일(쟁반·자동 풀이·집어 들기), 08 은 완성 순간, 09 는 앞의 여덟 가지를 한 장면에 이어 붙인 첫 화면이다. 아홉 개 모두 캔버스 2D 하나에 내부 좌표 480×300 을 쓰고, 그림은 외부 사진이 아니라 sceneryColor(u, v) 가 하늘·해·구름·먼 산·언덕·들꽃을 화소마다 칠한 풍경을 숨은 캔버스에 한 번 그려 둔 것이다. 조각은 자기 경로로 잘라 그 그림을 붙이므로, 조각이 어디로 옮겨 가도 그림 속 제자리 무늬를 그대로 들고 다닌다.

480×300 캔버스에서 2초 동안 찍은 스물네 장의 결과

항목 누적면적 움직인 프레임 강도 평균
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 의 면적이 가장 넓은 것은 조각 열두 개가 한꺼번에 벌어졌다 돌아오기 때문으로 보이고, 05 만 19/23 인 것은 던지기 전 0.15초 동안 조각이 한 점에 모여 있고 겹침이 0 이 된 뒤 쟁반이 가라앉는 구간이 있기 때문으로 보인다. 08 의 강도가 가장 낮은 것은 판이 3px 안팎으로만 솟고 이음매 선이 옅어지는 변화가 대부분이기 때문으로 보인다.

01이웃과 곡선을 나눠 쓰는 조각 자르기

가족사진 퍼즐 선물 페이지의 조각 미리보기처럼 그림이 어떻게 잘리는지 먼저 보여 줄 때 쓰는 장면으로, 0.25초부터 안쪽 변 17개를 차례로 긋고(끝에 산호 점) 1.15초부터 조각마다 0.05초씩 늦게 판 가운데에서 바깥으로 벌어지며 떠오른다. 이웃 17쌍은 한쪽 사슬과 다른 쪽을 뒤집은 사슬이 점마다 차 0 이고 17쌍 모두 혹 하나에 홈 하나가 마주 보며, 벌어질 때 스프링(단단함 90·감쇠 11)이 모서리 조각을 최대 33.1px 옮기면서 목표보다 10.3% 더 나갔다 돌아온다. 오른쪽 4배 확대 두 칸 가운데 위는 사슬 하나를 함께 쓴 이음매, 아래는 조각마다 자기 모서리를 정수 화소로 따로 반올림한 이음매다.

이웃과 곡선을 나눠 쓰는 조각 자르기
새 창에서 열기

01 · 이웃과 곡선을 나눠 쓰는 조각 자르기 — 측정 누적면적 47.428% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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° 로 놓을 때만 붙는다. 붙은 뒤 남은 11.37px 를 스프링(단단함 160·감쇠 14)이 당겨 0.288초에 반대쪽으로 1.35px 넘쳤다가 0.417초부터 ±0.5px 띠 안에 머문다.

거리와 각도 문턱 안에서만 당겨지는 맞물림
새 창에서 열기

02 · 거리와 각도 문턱 안에서만 당겨지는 맞물림 — 측정 누적면적 17.341% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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 어긋나게 놓아 열한 번 합친다. 1.3초의 2+2 처럼 크기가 같으면 들고 온 묶음의 대표 5 가 남고, 1.7초(대표 3)·2.9초(대표 9)처럼 떨어진 곳에 새 묶음이 생겨도 큰 묶음과 만나면 작은 쪽 대표가 큰 쪽 대표 아래로 들어가 4.5초 마지막 합치기까지 대표 5 가 남는다. 끝난 뒤 오른쪽 4×3 가리키기 표는 12칸 모두 5 이고 어느 조각에서 대표까지도 많아야 한 칸이다.

붙은 조각을 대표 하나로 묶는 묶음 합치기
새 창에서 열기

03 · 붙은 조각을 대표 하나로 묶는 묶음 합치기 — 측정 누적면적 26.863% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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 에서 붙는다. 04 만 판정에 orientOk = upright(목표 각을 −π~π 로 접어 0 인가)를 끼우고, 붙을 때 아래 tryJoin 이 각 목표를 남은 차 da 만큼만 고쳐 한 바퀴 돌아 360° 가 된 조각이 0° 로 되감기지 않는다.

90도씩 돌려 맞추는 회전 조각
새 창에서 열기

04 · 90도씩 돌려 맞추는 회전 조각 — 측정 누적면적 19.883% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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초에 열두 조각을 세 무더기로 던진 뒤 0.5초부터 1초에 18회씩 모두 36회 반지름 24.8px 원끼리 겹친 깊이의 절반씩 서로 민다. 회차마다 남은 겹침 합이 574.6 → 116.1 → 50.1px 로 줄어 13회째에 0.5px 아래, 18회째부터 0 이 되고, 판 상자 안에 떨어졌던 두 조각도 가장 가까운 바깥 변으로 밀려난다. 끝난 뒤 가장 가까운 두 가운데 거리는 49.6px 로 반지름의 딱 두 배, 곧 두 원이 맞닿은 자리에서 멈춘다.

겹치지 않게 펼쳐지는 조각 쟁반
새 창에서 열기

05 · 겹치지 않게 펼쳐지는 조각 쟁반 — 측정 누적면적 24.217% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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단계 모두 맞는 조각이 나오고 테두리 모양 때문에 후보가 대부분 한두 개로 줄어든다. 후보가 둘 남은 첫 안쪽 칸 5 에서는 맞는 조각 0.137점과 다른 안쪽 조각 6.467점이 맞서, 판 아래 12칸 열지도가 그 점수를 색으로 보여 준다(진한 보라일수록 낮은 점수, 색 상한 3).

테두리부터 푸는 자동 풀이
새 창에서 열기

06 · 테두리부터 푸는 자동 풀이 — 측정 누적면적 28.913% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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.32 rad(18.3°)에서 자르는데 옮기는 동안 그 상한에 닿고, 그림자는 높이에 비례해 아래로 최대 18.1px 멀어지며 흐림이 21px 까지 번진다. 판 위를 도는 탐침 점은 네모 상자로 고른 조각(산호 점선)과 경로로 고른 조각(민트 윤곽)을 견주는데, 아래 pickByPoly 는 노드에서 셀 때 쓰는 계산판이고 화면에서는 같은 자리를 isPointInPath 로 묻는 pickAt 이 맡는다.

들어 올린 조각의 기울기와 그림자 높이
새 창에서 열기

07 · 들어 올린 조각의 기울기와 그림자 높이 — 측정 누적면적 36.252% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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초까지 한 번 지나간다. 바깥 곧은 변은 지우지 않아 판 테두리는 남고, 이음매를 지운 몫만큼 판 밑에 그림 한 장을 깔아 조각 경계를 메운다.

완성 순간 퍼지는 이음매 물결과 광택
새 창에서 열기

08 · 완성 순간 퍼지는 이음매 물결과 광택 — 측정 누적면적 26.381% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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초 간격이고 02 와 같은 스프링 값으로 칸에 앉아 남은 묶음이 12 에서 하나씩 줄며, 풀이는 12/12 맞아 5.233초에 마지막 조각 6 이 들어가면 08 과 같은 늦음·물결·광택이 그 조각에서 퍼진다. 7.4초가 지나면 다시 흩어져 처음으로 돌아간다.

첫 화면에서 스스로 맞춰지는 퍼즐 장면
새 창에서 열기

09 · 첫 화면에서 스스로 맞춰지는 퍼즐 장면 — 측정 누적면적 39.764% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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 });
        }
    }

직소퍼즐 온라인 판을 웹 화면에 직접 올리려면 끌어 놓기 말고 무엇이 더 필요할까?

끌어 놓기는 조각을 옮기는 데까지만 한다. 판이 되려면 놓은 조각이 맞는 이웃 옆인지 묻는 판정(02·04), 붙은 조각을 덩어리로 끄는 묶음(03), 집을 조각을 그림 모양대로 고르는 판정(07), 처음에 조각을 판 밖으로 펼쳐 두는 쟁반(05)이 더 붙어야 한다. 이 넷이 빠지면 조각은 움직여도 퍼즐은 끝나지 않는다.

이벤트 페이지와 학습 자료에 붙일 때

이벤트 페이지라면 응모 버튼 앞에 3×3 판(02·04 의 크기) 하나를 두고 완성되는 순간 08 의 물결로 다음 단계를 여는 식이 무난하다. 학습 자료에서는 지도나 그림 단원을 4×3 판으로 자르고, 막힌 학생에게 06 의 열지도를 힌트로 띄우면 왜 그 조각인지까지 보인다. 브랜드 체험 페이지에서 제품 그림으로 직소퍼즐 만들기를 넣는다면 첫 화면은 09 처럼 스스로 맞춰지게 두고, 사람이 판을 만지는 순간부터 자동 시연을 멈추는 지금 구조를 그대로 쓰면 된다.

완성까지 몇 번 합쳐야 하는가

조각이 n 개면 묶음이 하나가 될 때까지 합치기는 정확히 n − 1 번이다. 상태 줄이 「이은 자리 j/n」 의 분모를 조각 수가 아니라 pieces.length - 1 로 쓰는 까닭이고, 12조각 판은 11 이 차면 끝난다. 03 의 열한 번 합치기가 이 수와 같다.

끌어 놓기 부품 편과 무엇이 다른가

드래그 앤 드롭 구현 9가지의 01 격자 자석은 끌던 요소가 가까운 칸 가운데로 끌리고, 04 는 이웃 중간점을 지나면 자리를 맞바꾼다. 이 편의 02 는 칸이 아니라 맞는 이웃 조각과의 상대 자리와 각도 두 문턱을 보고, 붙은 뒤에는 03 처럼 묶음째 움직인다는 점에서 드래그 퍼즐에 필요한 몫이 다르다. 가벼운 퍼즐 놀이 하나는 웹 미니게임 만들기 9종의 01 로고 퍼즐 맞추기도 다룬다.

이웃은 같은 곡선을 뒤집어 쓰고, 맞물림은 거리와 각도 두 문턱으로, 묶음은 대표 조각 하나로 — 퍼즐 판 아래의 셈

한 변을 한 번만 만들고 뒤집어 붙이기

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√단단함) 하나로 넘침을 미리 셈할 수 있다. 처음 속도 0 에서 계단 하나를 따라갈 때의 넘침 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 의 열한 번이 끝난 뒤 가장 깊은 칸이 1 이었다. 05 의 relaxOnce 는 조각 쌍을 모두 견주니 한 회에 12조각이면 66쌍이고, 1초에 18회라 조각 수가 늘면 쌍의 수가 조각 수의 제곱에 가깝게 불어난다.

틈이 벌어지는 이음매·엉뚱하게 집히는 조각·고해상도 화면에서 흐려지는 테두리 — 퍼즐 판이 깨지는 세 자리

조각마다 따로 반올림한 반 픽셀 틈

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% 다. 탐침 점의 산호 점선이 민트 윤곽과 다른 조각을 가리킬 때가 바로 그 순간이다.

배율을 빠뜨리면 흐려지는 혹 테두리

배율 2 화면에서 캔버스 내부 크기에 배율을 곱하면 960×600, 빠뜨리면 480×300 이라 캔버스 화소 하나가 기기 화소 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.417 → 0.388초

탭을 키우면 맞물린 모양은 또렷해지지만 모서리 근처에서 두 혹이 5.82px 까지 다가와 조각 끝이 붐빈다. 자석 거리를 28px 로 늘려도 두 번째 놓기의 36.77px 는 여전히 밖이라 결과가 같고, 문턱 끝에서 놓을 때 넘침은 거리가 두 배라 1.6px 에서 3.26px 로 같은 11.9% 비율을 지킨다. 단단함만 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 을 누르면 시연용 turnPiece 를 거치지 않고 그 조각의 목표 각만 90도 늘어, 각도 스프링이 그쪽으로 돌린다. 장면마다 진짜 button 인 일시정지가 있어 누르면 aria-pressed 가 true 가 되고, 탭이 숨으면 멈췄다가 돌아오면 이어 간다. 움직임 줄이기 설정에서는 루프를 멈추고 조각이 모두 제자리에 맞춰진 완성 판 한 장만 그려 0.4초와 1.2초의 두 장 화소 차가 아홉 장면 모두 0 이었고, 08·09 는 완성 10초 뒤 장면으로 두어 이음매는 지워지고 물결·광택은 지나간 판이다. 이 설정에서도 키 입력은 스프링을 목표에 바로 앉혀 한 번에 그린다.

온라인 직소 퍼즐 만들기 FAQ — 조각 수·사진 바꾸기·휴대폰 터치

판의 조각 수를 늘리려면 어디를 고치나요

각 항목 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 끌면 그 묶음 전체가 함께 옮겨졌다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.