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

슬라이딩 퍼즐 9종 — 풀 수 있는 판 가리기부터 최단 풀이 탐색까지

슬라이딩 퍼즐은 왜 어떤 판은 영원히 못 풀까 — 02번은 뒤집힌 쌍의 수로 풀 수 있는 판을 가르고, 05번은 맨해튼 거리 한도를 늘려 가며 최단 풀이를 찾고, 07번은 타일을 끌다 놓는 자리에서 칸에 붙인다.

슬라이딩 퍼즐은 번호 타일을 무작정 섞어 맞추는 게임이 아니라, 빈칸 하나와 이웃한 타일이 자리를 바꾸는 규칙 위에서 풀 수 있는 판과 없는 판이 갈리는 계산이다 — 슬라이딩 퍼즐 제작은 섞는 방법과 판정 값을 정하는 데서 시작한다

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

아홉 장면은 같은 판 배열 규칙을 쓰고 장면마다 값을 읽는 함수 하나씩만 다르다. 판을 번호 배열로 보면 퍼즐은 상태 하나이고, 누르고 끄는 화면은 그 상태를 칠하는 일 뿐이다.

판 상태는 실제 퍼즐 로직에서만 나온다

아홉 장면 어디에서도 판 배열을 흉내 낸 그림을 따로 그리지 않는다. bd 배열을 rowChain·walkMoves·idaStar 같은 실제 이동 함수로만 바꾸고, 화면은 바뀐 배열을 칸 좌표로 옮겨 그린다.

고정 걸음 1/60초와 정수 해시

경과 시간은 DT_MAX 0.05초에서 자르고 쌓아 STEP_S 1/60초 걸음으로 나눠 먹는다. 난수는 쓰지 않고 정수 해시 hash2(a, b) 로 섞기·장면 선택을 정해 같은 씨앗이면 같은 판, 같은 시연이 나온다. 탭이 숨으면 루프를 멈췄다가 돌아오면 쌓인 시간을 버린다.

        const order = [];
        for (let k = 0; k < list.length; k += 1) {
            order[list[k]] = k;
        }
        retarget(function (v) {
            const o = order[v];
            return (o ? o : 0) * STAGGER_MS / 1000;
        }, SLIDE_MS / 1000);
        recordPress(list, i);
    }

슬라이딩 퍼즐 9종 — 줄째 밀기·풀 수 있는 판 가리기·맨해튼 거리 열지도·걸어서 섞기·최단 풀이 탐색·그림 조각 맞추기·끌어서 붙이기·되돌리기와 풀이 재생·판 크기별 경우의 수

아홉 장면을 렌더해 잰 면적과 프레임

장면 누적면적 움직인 프레임 강도 평균
01 줄째 밀기 27.1% 23/23 97.9
02 판 가리기 23.4% 22/23 120.5
03 맨해튼 열지도 18.9% 23/23 154.4
04 걸어서 섞기 27.6% 23/23 119.9
05 풀이 탐색 26.8% 22/23 126.0
06 그림 조각 32.7% 21/23 106.1
07 끌어서 붙이기 12.7% 19/23 110.3
08 되돌리기·재생 14.8% 21/23 153.0
09 경우의 수 12.9% 21/23 143.8

누적면적이 가장 넓은 것은 06 의 32.7% 이고 가장 좁은 것은 07 의 12.7% 다. 움직인 프레임은 07 이 19/23 으로 가장 적고 세 장면이 23/23 이며, 강도 평균은 03 이 154.4 로 가장 높다.

01빈칸 쪽으로 줄째 밀리는 타일 체인

빈칸과 같은 줄에 있는 타일을 누르면 그 사이 타일이 모두 한 칸씩 빈칸 쪽으로 가는 입력 방식이라, 한 번의 누름으로 여러 장을 옮기고 싶을 때 쓴다. 타일마다 SLIDE_MS 140ms 로 움직이되 시작이 STAGGER_MS 30ms 씩 어긋나 세 장이 차례로 따라가고, 배열 교환이 먼저 끝나므로 누르는 즉시 판 상태는 정해져 있다.

빈칸 쪽으로 줄째 밀리는 타일 체인
새 창에서 열기

01 · 빈칸 쪽으로 줄째 밀리는 타일 체인 — 측정 누적면적 27.141% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SLIDE_MSSTAGGER_MSrowChain
    function rowChain(b, n, i) {
        const z = b.indexOf(0);
        if (i === z) {
            return [];
        }
        let step = 0;
        if (Math.floor(i / n) === Math.floor(z / n)) {
            step = i > z ? 1 : -1;
        }
        else if (i % n === z % n) {
            step = i > z ? n : -n;
        }
        else {
            return [];
        }
        const moved = [];
        for (let p = z; p !== i; p += step) {
            b[p] = b[p + step];
            moved.push(b[p]);
        }
        b[i] = 0;
        return moved;
    }

rowChain 이 돌려준 옮겨진 순서로 시작 시각을 어긋나게 물려 세 장이 차례로 따라가게 하고, 세 장을 밀면 마지막 타일이 끝나는 데 SLIDE_MS + 2 × STAGGER_MS = 200ms 다.

02뒤집힌 쌍의 수로 풀 수 있는 판 가리기

타일 번호를 한 줄로 늘어놓고 앞 번호가 뒤 번호보다 큰 쌍(역전)의 수를 세면, 4×4 판에서는 역전 수와 빈칸이 아래서 몇 번째 줄인지의 합이 홀수일 때만 풀 수 있다. 이 판정을 노드로 검증했는데, 3×3 완성 판에서 너비 우선 탐색으로 닿는 판 181,440개 전부가 solvable 참이고 두 타일만 맞바꾼 판에서 닿는 181,440개는 전부 거짓에 겹침이 0 이었다.

뒤집힌 쌍의 수로 풀 수 있는 판 가리기
새 창에서 열기

02 · 뒤집힌 쌍의 수로 풀 수 있는 판 가리기 — 측정 누적면적 23.449% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

inversionsparityblankRow
    function inversions(b) {
        let c = 0;
        for (let i = 0; i < b.length; i += 1) {
            for (let j = i + 1; j < b.length; j += 1) {
                if (b[i] !== 0 && b[j] !== 0 && b[i] > b[j]) {
                    c += 1;
                }
            }
        }
        return c;
    }
    // 빈칸이 아래서 몇 번째 줄인지(맨 아래 줄 = 1)
    function blankRow(b, n) { return n - Math.floor(b.indexOf(0) / n); }
    // 판정 합 — 짝수 폭은 역전 + 빈칸 줄, 홀수 폭은 역전만
    function parity(b, n) { return n % 2 === 0 ? inversions(b) + blankRow(b, n) : inversions(b); }
    function solvable(b, n) {
        return n % 2 === 0 ? parity(b, n) % 2 === 1 : parity(b, n) % 2 === 0;
    }

첫 판은 시작 역전 27 · 빈칸 줄 2 · 합 29(홀수)로 풀 수 있었고, 1.8초에 타일 5 와 9 를 맞바꾸자 합이 32(짝수)로 넘어가 이후 8수 동안 34·36·40 으로 움직여도 홀짝은 짝수 그대로였다.

03제자리까지 거리를 색으로 칠하는 맨해튼 열지도

타일마다 지금 칸과 제자리 칸의 가로·세로 칸 수를 더한 맨해튼 거리를 구해 색의 진하기로 칠하면, 어느 타일이 제자리에서 먼지 한눈에 읽힌다. 색은 0 의 연한 민트에서 3 의 노랑, 6 이상의 장미로 heatColor 가 거리 비율을 섞어 정하고 판 전체 거리 합 H 는 숫자로 같이 보인다.

제자리까지 거리를 색으로 칠하는 맨해튼 열지도
새 창에서 열기

03 · 제자리까지 거리를 색으로 칠하는 맨해튼 열지도 — 측정 누적면적 18.897% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

manhattanheatsumH
    function manhattan(b, n, i) {
        const v = b[i];
        if (v === 0) {
            return 0;
        }
        const h = v - 1;
        return Math.abs((i % n) - (h % n)) + Math.abs(Math.floor(i / n) - Math.floor(h / n));
    }

네 판을 재면 한 수마다 H 의 변화는 정확히 ±1 이고 끝에서 0 이 된다 — 시드 7 판은 H 22 에서 시작해 최단 26수 · 노드 700 으로 풀렸고(오른 수 2 · 내린 수 24), 시드 18 판은 H 18 · 20수 · 노드 236 으로 가장 짧았다.

04완성 판에서 걸어 나가는 섞기

완성 판에서 빈칸을 SHUFFLE_STEPS 60번 이웃 칸으로 옮기는 섞기라, 규칙대로 걸어 나온 판이라 판정이 풀 수 있음으로 나온다. 방금 온 칸으로 되돌아가는 수는 건너뛰고 어느 이웃으로 갈지는 정수 해시로 고른다.

완성 판에서 걸어 나가는 섞기
새 창에서 열기

04 · 완성 판에서 걸어 나가는 섞기 — 측정 누적면적 27.604% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SHUFFLE_STEPShashnoBacktrack
    function walkMoves(n, steps, seed) {
        const b = solvedBoard(n);
        const out = [];
        let noBacktrack = -1;
        for (let s = 0; s < steps; s += 1) {
            const opts = [];
            for (let d = 0; d < 4; d += 1) {
                if (d !== noBacktrack && canStep(b, n, d)) {
                    opts.push(d);
                }
            }
            const d = opts[Math.floor(hash2(s, seed) * opts.length)];
            blankStep(b, n, d);
            out.push(d);
            noBacktrack = d ^ 1;
        }
        return out;
    }

같은 씨앗 41 의 60걸음을 매 걸음 검사하면 solvable 참이 60/60 이고 처음 10걸음은 H 가 1 부터 10 까지 한 걸음에 1씩 올라 60걸음에서 H 30 · 제자리 타일 0장이며, 대비로 위 줄의 해시 무작위 배치 1,000개 중 풀 수 있는 판은 490개(49.0%)다.

05한도를 늘려 가며 최단 풀이를 찾는 반복 심화 탐색

맨해튼 거리 합 H 를 길잡이로 깊이 우선 탐색을 하되, 지금까지 수와 H 의 합이 한도를 넘으면 가지를 접고 한도를 한 단계씩 올려 다시 도는 방식으로, 노드 수가 NODE_CAP 4000 에 닿으면 멈춘다고 표시한다.

한도를 늘려 가며 최단 풀이를 찾는 반복 심화 탐색
새 창에서 열기

05 · 한도를 늘려 가며 최단 풀이를 찾는 반복 심화 탐색 — 측정 누적면적 26.812% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

NODE_CAPboundidaStar
        function dfs(g, h, bound, last) {
            nodes += 1;
            if (nodes > cap) {
                capHit = true;
                return INF;
            }
            const f = g + h;
            if (f > bound) {
                return f;
            }
            if (h === 0) {
                return -1;
            }
            let min = INF;
            for (let d = 0; d < 4; d += 1) {
                if (last >= 0 && d === (last ^ 1)) {
                    continue;
                }

메모리를 크게 쓰지 않고 최단 풀이를 구하는 방식이라 알고리즘 학습 예제로도 붙이기 좋다. 3×3 네 판의 실측은 H 10~18 · 해 20~26수 · 노드 1,429~2,800 이었고 한도를 올릴 때마다 노드가 계단처럼 불었다(시드 2 판은 한도 14~24 에 3·8·57·313·1,261·1,158 개).

06한 장의 그림을 타일로 잘라 심이 보이는 판

캔버스에 노이즈 그라디언트로 노을 하늘·해·언덕 한 장을 먼저 그리고 TILE_N 4×4 칸으로 잘라 drawImage 로 타일에 옮기는데, 마지막 칸은 비워 두고 타일마다 SEAM_PX 2 의 어두운 가장자리와 위쪽 하이라이트를 얹어 숫자 대신 그림으로 맞추는 퍼즐 화면이 된다.

한 장의 그림을 타일로 잘라 심이 보이는 판
새 창에서 열기

06 · 한 장의 그림을 타일로 잘라 심이 보이는 판 — 측정 누적면적 32.667% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

drawImageSEAM_PXtileCut
    function tileCut(v, px, py, wk) {
        const hc = (v - 1) % BN;
        const hr = Math.floor((v - 1) / BN);
        const sz = BSZ / BN;
        const x = BX + px * CS + 1;
        const y = BY + py * CS + 1;
        const s = CS - 2;
        ctx.save();
        ctx.beginPath();
        ctx.roundRect(x, y, s, s, lerp(6, 0, wk));
        ctx.clip();
        ctx.drawImage(picCv, hc * sz, hr * sz, sz, sz, x, y, s, s);
        ctx.restore();

맞춰 가면 그림 한 장이 드러나고 완성 뒤 0.6초 동안 심이 옅어지는데, 216×216 그림을 칸 54px 로 잘면 타일 15장 · 푸는 길 16수 · 완성 2.37초다. 그림 조각 자르기는 MDN 의 drawImage 문서가 다루는 원본 영역→대상 영역 복사 그대로다.

07끌다 놓으면 칸에 붙는 포인터 흡착

타일을 누르고 끌면 빈칸 방향의 한 축으로만 따라오고 한 칸 폭을 넘지 못하게 자르며, 놓을 때 이동량이 SNAP_RATIO 0.5 를 넘으면 빈칸으로 들어가고 못 미치면 제자리로 돌아온다. 포인터 이벤트 하나로 마우스와 터치가 같이 돼 손가락으로 끌어 놓는 조작을 구현하는 데 쓴다.

끌다 놓으면 칸에 붙는 포인터 흡착
새 창에서 열기

07 · 끌다 놓으면 칸에 붙는 포인터 흡착 — 측정 누적면적 12.728% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SNAP_RATIOpointerclamp
    function dragMove(x, y) {
        if (!dragging) {
            return;
        }
        ptrX = x;
        ptrY = y;
        rawK = ((x - startX) * axX + (y - startY) * axY) / CS;
        const k = clampK();
        dragCx = (dragCell % BN) + axX * k;
        dragCy = Math.floor(dragCell / BN) + axY * k;
    }

끌기 범위 실측 여덟 번은 붙음 5 · 돌아감 3 으로, 0.85·0.62·0.7 칸은 붙고 0.3·0.42·0.25 칸은 돌아가며 1.45·1.2 칸은 한 칸에서 잘려 1.00 이 된 뒤 붙는다(한 번의 끌기는 0.36초 끌기와 0.12초 홀드, 포인터 입력은 MDN 의 Pointer events 문서가 다루는 통합 입력 모델).

08되돌리기 기록과 풀이 재생

누른 수를 스택에 쌓아 되돌리기 한 번에 한 수씩 빼고, 풀이를 재생할 때는 서로 반대 방향인 수가 이웃해 있으면 둘을 지워 길이를 줄인 뒤 REPLAY_MS 220ms 간격으로 한 수씩 보이며 줄인 수와 원래 수는 숫자로 같이 적힌다.

되돌리기 기록과 풀이 재생
새 창에서 열기

08 · 되돌리기 기록과 풀이 재생 — 측정 누적면적 14.764% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

REPLAY_MSstackcancelPair
    function cancelPair(ms) {
        const out = [];
        for (let i = 0; i < ms.length; i += 1) {
            if (out.length > 0 && out[out.length - 1] === (ms[i] ^ 1)) {
                out.pop();
            }
            else {
                out.push(ms[i]);
            }
        }
        return out;
    }

실수한 수를 되돌리고 푼 과정을 다시 보여 주는 기능에 그대로 쓸 수 있다. 네 판에서 14수 중 3번 되돌려 스택 11수를 남겼고 줄인 수는 5·7·7·3(지워진 쌍 3·2·2·4)이며 처음 판에서 줄인 수로만 재생한 결과는 11수를 그대로 쌓은 판과 칸마다 같았다(첫 판 재생 5수 × 220ms = 1.1초).

09판 크기별 경우의 수와 탐색 노드 비교

2×2·3×3·4×4 판의 가능한 배치 수를 풀 수 있는 절반으로 계산해 로그 눈금 막대로 보이고, 같은 섞기 씨앗으로 05번 탐색을 돌려 나온 노드 수를 옆에 둔다.

판 크기별 경우의 수와 탐색 노드 비교
새 창에서 열기

09 · 판 크기별 경우의 수와 탐색 노드 비교 — 측정 누적면적 12.89% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

TILE_NstateCountlogBar
    function stateCount(n) {
        let f = 1;
        for (let i = 2; i <= n * n; i += 1) {
            f *= i;
        }
        return f / 2;
    }
    // 로그 눈금 막대 — 값의 상용로그를 상한 로그로 나눈 0 ~ 1
    function logBar(v, top) {
        return clamp(Math.log10(Math.max(1, v)) / Math.log10(top), 0, 1);
    }

판 크기를 정할 때 난이도가 어떻게 뛰는지 가늠하는 눈금이다. 배치 수는 2×2 가 12, 3×3 이 181,440, 4×4 가 약 1.05e13 — 12 → 181,440 은 15,120배, 181,440 → 1.05e13 은 약 5,770만 배 뛰고, 시드 8 에서 노드 수는 2×2 7개(해 6수) · 3×3 303개(14수) · 4×4 3,600개(28수)로 판이 커질수록 탐색도 같이 무거워진다.

슬라이딩 퍼즐을 코드로 만들면 타일 하나의 움직임은 어떤 값 몇 개로 정해질까?

판 배열과 빈칸 방향이 전부다

판은 번호 배열 bd(0 = 빈칸, 타일 v 의 제자리는 v−1 번 칸) 하나이고, 빈칸 방향 d 는 0 오른쪽 · 1 왼쪽 · 2 아래 · 3 위 넷이며 반대 방향은 d ^ 1 이다. 한 수는 배열의 두 칸 맞바꿈 그 이상이 아니다.

값 뜻 바꾸는 곳
bd 판 상태(0 = 빈칸) rowChain·blankStep·walkMoves
d 빈칸 이동 방향 방향키·끌기·탐색
H 맨해튼 거리 합 manhattan·sumH(03·05)

하나의 판에서 여러 계산이 파생된다

같은 bd 를 놓고 inversions 는 판정을, manhattan 은 거리를, walkMoves 는 섞기를 계산한다. 장면이 아홉 개여도 배열을 만드는 규칙은 하나라, 어느 장면의 판이든 다른 장면의 함수로 그대로 검사할 수 있다.

판을 번호 배열로 두고, 빈칸 옆 타일을 골라 자리를 바꾸고, 그 결과를 칸 좌표로 옮겨 그리는 — 슬라이딩 퍼즐 한 수가 처리되는 순서

배열이 먼저, 화면은 뒤따른다

누름이나 끌기가 들어오면 배열 교환이 먼저 일어나고, 화면의 타일은 목표 칸으로 SLIDE_MS 나 SETTLE_S 보간으로 따라간다. 그래서 보간 도중에 다시 눌러도 판 상태는 이미 정해져 있어 어긋나지 않는다. drawImage 로 그림을 옮기는 06 도 같은 순서다.

한도별 노드 계단

05 의 탐색은 한도를 H 에서 시작해 실패할 때마다 받은 최소 초과값으로 올린다. 한도별 노드 수를 perBound 에 쌓아 화면에 줄로 보이므로, 한도가 한 단계 오를 때 탐색이 얼마나 불어나는지 숫자로 읽힌다.

슬라이딩 퍼즐 게임처럼 실제 화면에 붙일 때 — 라이브러리 없이 만들기·터치와 마우스 입력·섞는 시점

라이브러리 없이 만들기

캔버스 2D 만 있으면 된다. 아홉 장면은 라이브러리·WebGL·CDN 을 쓰지 않고, 소스 한 파일에 계산 함수와 장면 시연을 같이 담았다.

터치와 마우스 입력

07 은 pointerdown·pointermove·pointerup·pointercancel 과 setPointerCapture 로 끌기를 처리하고 무대 상자에 touch-action: none 을 걸어 끌기 중 페이지가 같이 스크롤되지 않게 한다. 나머지 장면은 방향키와 타일 누르기로 같은 배열 함수를 부른다.

섞는 시점

판을 보여 주기 전에 walkMoves 로 걸어 두면 풀 수 없는 판이 나올 틈이 없다. 이미 받아 놓은 판이라면 그림을 그리기 전에 solvable 로 먼저 판정해야 한다.

직소 편과 다른 점

직소 퍼즐 편은 조각 모양과 이웃 조각 병합을 다루고, 이 편은 빈칸과의 자리 바꿈 규칙과 풀 수 있는 판 판정을 다룬다. 격자 규칙을 탐색으로 푸는 흐름이 궁금하면 미로 만들기 편도 같은 계산 틀을 쓴다.

슬라이딩 퍼즐이 깨지는 함정 — 아무렇게나 섞어 풀 수 없는 판이 나오는 일·탐색이 너무 깊어져 화면이 멈추는 일·끌다 놓을 때 타일이 칸 사이에 걸리는 일

아무렇게나 섞으면 풀 수 없는 판이 나온다

배열을 무작위로 뒤섞으면 절반 안팎만 풀린다 — 해시 무작위 배치 1,000개 중 풀 수 있는 판은 490개였다. 풀 수 없는 판을 거르려면 02 의 판정을 그리기 전에 돌리거나, 아예 04 처럼 합법 이동으로만 섞는다. 두 타일을 맞바꾼 판은 그 뒤로 몇 수를 더해도 홀짝이 돌아오지 않는다.

탐색이 너무 깊어지면 화면이 멈춘다

4×4 판을 한도 없이 탐색하면 노드가 수십만을 넘는다 — 상한 300,000 에서도 네 판 중 셋이 못 끝냈다. 화면이 얼어붙는 이 일을 막는 것이 NODE_CAP 4000 이고, 닿으면 "한도 도달"로 표시하고 그만둔다. 실제 서비스에서는 한도를 크게 두거나 웹 워커로 돌린다.

끌다 놓으면 타일이 칸 사이에 걸린다

끌기를 한 칸 폭으로 자르지 않으면 타일이 이웃 칸 위로 겹치고, 놓는 순간 어느 칸에 붙일지 애매해진다. clampK 로 0~1 칸 안에 자르고 SNAP_RATIO 로 붙일지 돌릴지를 한 번에 정하면 타일이 칸 사이에 남는 일이 없다.

이 글의 아홉 장면 전체 소스와 편집용 파일은 아래 상자의 zip 으로 받고, 여는 데 쓰는 압축 비밀번호는 mvvxprjw 이다.

판 크기와 슬라이드 시간과 섞는 걸음 수를 바꾸면 느낌은 얼마나 달라질까 — TILE_N·SLIDE_MS·SHUFFLE_STEPS 를 바꿔 본 차이

이름 바꾼 값 느낌
판을 더 크게 06 TILE_N 4 → 5 TILE_N 을 4 에서 5 로 올리면 타일이 15장에서 24장으로 늘고 한 장이 54px 에서 43.2px 로 작아져 그림이 더 잘게 잘리며 심이 늘고, 낮추면 큰 조각 몇 장이 된다. 걸음 수는 그대로라 푸는 길 16수와 완성 2.37초는 변하지 않는다
슬라이드를 더 느리게 01 SLIDE_MS 140 → 400 SLIDE_MS 를 140 에서 400 으로 늘리면 세 장이 모두 끝나는 데 200ms 에서 460ms 로 늘지만 STAGGER_MS 30ms 어긋남은 그대로라 차례로 따라가는 모습이 한 덩어리로 흐려지고, 줄이면 어긋남이 도드라진다
섞는 걸음을 짧게 04 SHUFFLE_STEPS 60 → 12 SHUFFLE_STEPS 를 60 에서 12 로 줄이면 거리 합 H 가 30 에서 12 로 줄어(제자리 타일 0장 → 3장) 풀이가 짧아지고, 늘리면 판이 더 뒤섞여 H 가 오른다. 시드 41~60 스무 판의 평균 H 는 12걸음 10.6 · 60걸음 24.7 · 300걸음 38.4 다

이 세 값은 서로 간섭하지 않는다. TILE_N 은 조각 수와 크기만, SLIDE_MS 는 화면 보간만, SHUFFLE_STEPS 는 섞인 정도만 바꾸니 한 번에 하나씩 바꿔 어느 값이 느낌을 바꿨는지 보는 게 좋다.

움직임 줄이기에서는 타일이 바로 자리를 바꾸는 판 한 장 — 장면 이름과 지금 맞은 타일 수를 문장으로 읽어 주는 접근성

방향키로 타일을 옮기고 되돌리기는 U 로

무대 상자는 아홉 장면 모두 role="application" 에 tabindex 0 이라 포커스를 받고, 방향키는 그 방향으로 가는 타일을 옮기며 Enter·Space·P 는 일시정지, N 은 한 걸음, R 은 처음부터다. 08 은 U·Z·Backspace 가 한 수 되돌리고, 07 은 타일을 끌어서 놓는다.

낭독은 모드가 바뀔 때만

화면 상태 줄은 aria-live="off" 로 시연 중에는 읽히지 않고, role="status" 숨김 문단에 speakText() 로 장면 이름과 지금 맞은 타일 수를 문장으로 쓰되 손댄 직후와 모드가 바뀔 때만 쓴다. 자동 시연 중 낭독이 흐르지 않는다는 뜻이다.

움직임 줄이기와 숨은 탭

prefers-reduced-motion 이 켜져 있으면 루프 없이 시간표의 정지 시각 한 장만 그린다 — 타일이 보간 없이 목표 칸에 바로 앉는 판이다. 탭이 숨으면 루프를 멈추고 돌아오면 쌓인 시간을 버려 이어 간다. 이 처리는 MDN 의 prefers-reduced-motion 문서를 따른다.

    // 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
    document.addEventListener('visibilitychange', function () {
        if (document.hidden) {
            stopLoop();
        }
        else if (!paused && !still) {
            startLoop();
        }
    }, { signal: off.signal });
    // 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
    reduceQuery.addEventListener('change', function () {
        still = reduceQuery.matches;
        if (still) {
            showRest();
        }
        else {
            lap = 0;
            play();
        }
    }, { signal: off.signal });

슬라이딩 퍼즐 FAQ — 슬라이딩 퍼즐 푸는 법·풀 수 없는 판이 생기는 이유·웹에 올릴 때 필요한 것

슬라이딩 퍼즐 푸는 법을 코드로 도와줄 수 있나요

03 의 맨해튼 거리로 지금 제자리에서 먼 타일을 골라 옮기다 보면 H 가 0 을 향해 내려가고, 막히면 05 의 반복 심화 탐색이 최단 풀이를 찾아 한 수씩 보여 준다. H 는 판이 제자리에서 얼마나 먼지 보여 주는 숫자다.

풀 수 없는 판은 왜 생기나요

두 타일을 맞바꾸면 역전 수의 홀짝이 뒤집혀 풀 수 없는 쪽으로 넘어간다. 합법 이동만으로는 이 홀짝이 바뀌지 않으니, 무작위로 뒤섞인 판의 절반 가까이가 못 푸는 판이다 — 02 가 이 슬라이딩 퍼즐 공식 같은 판정 값을 실시간으로 보여 준다.

웹에 올릴 때는 무엇이 필요한가요

정적 파일 한 벌로 충분하다. 캔버스는 devicePixelRatio 를 2 까지만 곱하고 외부 글꼴·서버 호출이 없어 어느 정적 호스팅에도 올라간다.

출처 문서는 MDN 의 requestAnimationFrame 항목이다.

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

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