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

지뢰찾기 만들기 9종 — 번져 열리는 칸부터 확률 지도 추론 힌트까지

첫 클릭이 지뢰면 그 판은 실패인데, 02번은 첫 칸을 연 뒤에 판을 짜고 05번은 숫자 두 개를 겹쳐 확실한 칸을 가려내며 06번은 확실한 칸이 없을 때 칸마다 확률을 칠한다. 지뢰찾기 만들기 9종은 판 상태를 계산하는 코드와 그 상태만 그리는 캔버스를 나눠 만든 데모다.

지뢰찾기는 규칙이 아니라 계산으로 이해한다 — 지뢰찾기 만들기는 칸 하나의 이웃 여덟 개를 세는 일에서 시작한다

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

아홉 장면은 같은 구조를 쓴다. 지뢰 배치·숫자·열림·깃발은 판 로직이 만든 값이고, 화면은 그 값과 장면 시계를 비교해 그리기만 하므로 보이는 것은 연출이 아니라 계산된 판이다. 라이브러리와 CDN 은 쓰지 않고 난수는 시드를 고정한 생성기 하나뿐이라, 같은 시드는 같은 판을 만든다.

판 로직과 캔버스를 나눈 이유

판은 newBoard 가 만든 배열 묶음이다. placeMines·calcNums·revealFrom·chordList·pairHint·probMap 은 이 배열만 읽고 쓰는 함수이고, drawCells 가 칸마다 열린 시각·터진 시각·깃발 시각을 장면 시계 sceneT 와 비교해 그린다. 아홉 파일 모두 같은 판 로직 함수를 담고 있어 화면을 바꿔도 지뢰찾기 규칙은 건드리지 않는다.

    function newBoard(cols, rows, count) {
        const b = { cols: cols, rows: rows, count: count, mine: [], num: [], st: [], openT: [], exT: [], flT: [], xT: [], placed: false };
        for (let i = 0; i < cols * rows; i += 1) {
            b.mine.push(false);
            b.num.push(0);
            b.st.push(0);
            b.openT.push(0);
            b.exT.push(-1);
            b.flT.push(-1);
            b.xT.push(-1);
        }
        return b;
    }

한 판은 시간표로 흘러간다

장면마다 script 에 (시각, 할 일) 쌍을 채워 두고 advance(dt) 가 시각이 지난 일을 한 번씩 부른다. 한 판 LAP_S 가 지나면 시드를 lap % 2 로 갈아 두 판을 번갈아 보이고, 첫 판은 START_S 만큼 앞선 지점에서 시작해 이미 진행 중인 장면이 첫 화면에 뜬다.

장면마다 얹은 계산 한 겹

01 은 거리 순서로 지연되는 번짐, 02 는 첫 클릭 둘레를 비워 지뢰를 옮기는 재배치, 03 은 이웃 수 스캔, 04 는 깃발 수 조건을 얹었다. 05 는 두 숫자의 닫힌 이웃 집합 비교, 06 은 배치 전수 계산과 조합 가중, 07 은 거리에 따른 폭발 시각과 충격파 장, 08 은 깃발 수에서 남은 지뢰를 되짚는 계량기, 09 는 선이 지나친 각도로 밝기를 감쇠시키는 소나를 얹었다.

지뢰찾기 만들기 9종 — 번져 열리는 연쇄 열기·판을 뒤에 짜는 안전 첫 수·이웃을 세는 숫자 스캔·한 번에 여는 코드 열기·조건을 겹치는 추론 힌트·확률을 칠하는 지도·연쇄 폭발 충격파·스프링 깃발 계량기·소나가 도는 히어로

아홉 장면을 렌더해서 얻은 면적·프레임·강도

장면 누적면적 움직인 프레임 강도 평균
01 연쇄 열기 24.9% 16/23 31.3
02 안전 첫 수 34.6% 20/23 58.1
03 숫자 스캔 34.8% 23/23 71.3
04 코드 열기 6.3% 13/23 38.9
05 추론 힌트 20.1% 15/23 56.5
06 확률 지도 30.2% 14/23 50.6
07 충격파 63.4% 22/23 35.3
08 깃발 계량기 4.6% 22/23 78.1
09 소나 히어로 38.9% 23/23 48.4

화면 전체가 흔들리는 07 이 63.4% 로 가장 넓고, 숫자 하나가 바뀌는 계량기 위주의 08 이 4.6% 로 가장 좁은데 강도 평균은 78.1 로 가장 높다. 매 프레임 움직인 장면은 03 과 09 뿐이고, 04 는 13/23 이라 코드 열기를 기다리는 동안 변화가 없는 프레임이 많다.

01빈 칸을 열면 물결처럼 번지는 연쇄 열기

지뢰찾기 학습 사이트의 첫 판 화면에 두면 빈 칸 하나가 열릴 때 이웃 빈 칸이 거리 순서로 시차를 두고 열리고 숫자 칸에서 멈추는 번짐이 바로 보인다. revealFrom 은 거리가 가장 작은 칸부터 꺼내 openT = t0 + d × RIP_DELAY 로 열릴 시각을 적고, 열린 칸이 0 일 때만 이웃을 대기열에 넣는다. 첫 클릭은 0.5초에 57칸을 열고 가장 먼 칸이 거리 14 라서 번짐은 1.4초에 끝난다.

빈 칸을 열면 물결처럼 번지는 연쇄 열기
새 창에서 열기

01 · 빈 칸을 열면 물결처럼 번지는 연쇄 열기 — 측정 누적면적 24.889% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

RIP_DELAYrevealFromfloodSize
        const todo = src.slice();
        const out = [];
        while (todo.length > 0) {
            let k = 0;
            for (let j = 1; j < todo.length; j += 1) {
                if (todo[j].d < todo[k].d) {
                    k = j;
                }
            }
            const cur = todo.splice(k, 1)[0];
            if (b.st[cur.i] !== 0 || b.mine[cur.i]) {
                continue;
            }
            b.st[cur.i] = 1;
            b.openT[cur.i] = t0 + cur.d * delay;
            out.push(cur.i);
            if (b.num[cur.i] === 0) {
                for (const j of nbrs(b, cur.i)) {
                    if (b.st[j] === 0) {
                        todo.push({ i: j, d: cur.d + 1 });
                    }
                }
            }
        }
        return out;

02첫 수가 지뢰를 밟지 않게 판을 그 뒤에 짜는 안전 첫 수

첫 클릭에 지는 불공정을 없애는 규칙을 설명할 때는 판이 비어 있다가 첫 칸을 연 순간 짜인다는 점이 이 장면의 핵심이다. placeMines 는 시드로 모든 칸을 섞어 앞쪽 count 칸에 지뢰를 놓되, 첫 칸과 둘레 여덟 칸에 걸린 지뢰는 섞인 순서 뒤쪽 빈 칸으로 옮기고 [원래 칸, 새 칸] 목록을 돌려준다. 첫 클릭 칸 55 는 먼저 놓은 판에서 지뢰였고 구역 안 지뢰 3개가 옮겨지면 구역 안 지뢰는 0, 열린 칸은 77칸이다.

첫 수가 지뢰를 밟지 않게 판을 그 뒤에 짜는 안전 첫 수
새 창에서 열기

02 · 첫 수가 지뢰를 밟지 않게 판을 그 뒤에 짜는 안전 첫 수 — 측정 누적면적 34.646% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

placeMinesinZoneOPEN_DELAY
        if (safe >= 0) {
            zone[safe] = true;
            for (const j of nbrs(b, safe)) {
                zone[j] = true;
            }
        }
        const moves = [];
        let next = b.count;
        for (let i = 0; i < n; i += 1) {
            b.mine[i] = false;
        }
        for (let k = 0; k < b.count; k += 1) {
            const c = order[k];
            if (!zone[c]) {
                b.mine[c] = true;
                continue;
            }
            while (next < n && (zone[order[next]] || b.mine[order[next]])) {
                next += 1;
            }
            b.mine[order[next]] = true;
            moves.push([c, order[next]]);
            next += 1;
        }

03여덟 이웃의 지뢰를 세어 숫자를 채우는 스캔

숫자가 어떻게 만들어지는지 보여 주는 규칙 해설에는 스캔 틀이 칸을 한 개씩 훑으며 이웃을 세는 장면이 맞다. calcNums 가 nbrs 로 판 안의 이웃만 골라 지뢰를 세고, 장면은 bornT(i) = SCAN_START + i / SCAN_RATE 로 센 값이 떠오르는 시각을 정한다. 128칸을 초당 32칸으로 훑으면 4.3초에 끝나고 지뢰가 아닌 106칸의 숫자는 0 이 28칸, 1 이 43칸, 2 가 24칸, 3 이 7칸, 4 가 4칸이다.

여덟 이웃의 지뢰를 세어 숫자를 채우는 스캔
새 창에서 열기

03 · 여덟 이웃의 지뢰를 세어 숫자를 채우는 스캔 — 측정 누적면적 34.759% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SCAN_RATEnbrscalcNums
    function calcNums(b) {
        for (let i = 0; i < b.cols * b.rows; i += 1) {
            let n = 0;
            if (!b.mine[i]) {
                for (const j of nbrs(b, i)) {
                    if (b.mine[j]) {
                        n += 1;
                    }
                }
            }
            b.num[i] = n;
        }
    }

04숫자와 깃발 수가 같으면 이웃을 한 번에 여는 코드 열기

숙련자용 단축 조작을 소개하는 가이드에서는 깃발 수와 숫자가 같을 때만 이웃이 열린다는 조건이 핵심이다. chordList 는 열린 숫자 칸이면서 숫자가 0 이 아니고 flagsAround 가 숫자와 같을 때만 닫힌 이웃 목록을 돌려주고, doChord 는 그 목록에 지뢰가 섞여 있으면 exT 로 터뜨리며 틀린 깃발 칸에 가위표를 켠다. 첫 장면은 깃발 두 개를 0.9초와 1.35초에 꽂고 2.15초에 이웃을 열며, 틀린 깃발 장면은 6.6초의 코드 열기로 지뢰를 터뜨리는데 이때 열린 칸은 61칸이다.

숫자와 깃발 수가 같으면 이웃을 한 번에 여는 코드 열기
새 창에서 열기

04 · 숫자와 깃발 수가 같으면 이웃을 한 번에 여는 코드 열기 — 측정 누적면적 6.338% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

CHORD_DELAYchordListflagsAround
    function chordList(b, i) {
        if (b.st[i] !== 1 || b.num[i] === 0 || flagsAround(b, i) !== b.num[i]) {
            return [];
        }
        const out = [];
        for (const j of nbrs(b, i)) {
            if (b.st[j] === 0) {
                out.push(j);
            }
        }
        return out;
    }

05이웃 숫자의 조건을 겹쳐 확실한 칸을 찾아내는 추론 힌트

막힌 판에서 다음 수를 알려 주는 힌트 기능에는 숫자 칸 둘의 조건을 겹쳐 보는 이 방식이 맞다. consOf 가 열린 숫자마다 닫힌 이웃 u 와 남은 지뢰 r 을 모으면, pairHint 는 한 숫자의 u 가 다른 숫자의 u 에 통째로 들어 있을 때 차집합의 지뢰 수 r_B − r_A 로 안전·지뢰를 가른다. 16×8 판 150개에서 나온 힌트 3,536개를 실제 지뢰 배치와 대조해 어긋난 것이 0건이었고, 시연 첫 판의 힌트 6개는 전부 두 숫자 힌트다.

이웃 숫자의 조건을 겹쳐 확실한 칸을 찾아내는 추론 힌트
새 창에서 열기

05 · 이웃 숫자의 조건을 겹쳐 확실한 칸을 찾아내는 추론 힌트 — 측정 누적면적 20.097% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

pairHintsubsetOfHINT_SHOW
    function pairHint(cs) {
        for (const B of cs) {
            if (B.r === 0 || B.r === B.u.length) {
                continue;
            }
            for (const A of cs) {
                if (A.c === B.c || A.u.length >= B.u.length || !subsetOf(A.u, B.u)) {
                    continue;
                }
                const diff = B.u.filter(function (v) { return A.u.indexOf(v) < 0; });
                const rd = B.r - A.r;
                if (rd === 0) {
                    return { kind: 0, cells: diff, a: A.c, b: B.c };
                }
                if (rd === diff.length) {
                    return { kind: 1, cells: diff, a: A.c, b: B.c };
                }
            }
        }
        return null;
    }

06확실한 칸이 없을 때 칸마다 지뢰 확률을 색 농도로 칠하는 확률 지도

운이 갈리는 구간에서 가장 안전한 수를 고르는 해설에는 칸마다 확률을 색 농도로 칠한 지도가 맞다. probMap 은 열린 숫자와 맞닿은 경계 칸의 배치를 재귀로 모두 세고, 바깥 칸은 남은 지뢰마다 comb(바깥 칸, 남은 지뢰) 가중치로 나눠 가지며 탐색 노드가 PROB_NODE_CAP 을 넘으면 멈춘다. 6×4 판 25개를 배치까지 직접 세어 비교한 최대 차이는 0 이고, 시연에서 가장 옅은 칸은 라운드 1 이 13.8%, 라운드 2 가 13.9%, 라운드 3 이 0% 다.

확실한 칸이 없을 때 칸마다 지뢰 확률을 색 농도로 칠하는 확률 지도
새 창에서 열기

06 · 확실한 칸이 없을 때 칸마다 지뢰 확률을 색 농도로 칠하는 확률 지도 — 측정 누적면적 30.206% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

probMapcombPROB_NODE_CAP
        const M = b.count - flags;
        let total = 0;
        let innerMines = 0;
        const pf = [];
        for (let k = 0; k < F.length; k += 1) {
            pf.push(0);
        }
        for (let m = 0; m <= F.length; m += 1) {
            if (sol[m] === 0) {
                continue;
            }
            const w = comb(inner.length, M - m);
            if (w === 0) {
                continue;
            }
            total += w * sol[m];
            innerMines += w * sol[m] * (M - m);
            for (let k = 0; k < F.length; k += 1) {
                pf[k] += w * cm[m][k];
            }
        }

07지뢰를 밟으면 판 전체의 지뢰가 연쇄로 터지는 충격파

패배 연출이 필요한 게임 결과 화면에서는 밟은 칸에서 퍼지는 원이 닿는 순서로 나머지 지뢰가 터지는 장면이 잘 맞는다. startChain 이 지뢰마다 exT = sceneT + 거리 / RING_SPEED 로 터질 시각을 적고, 충격파 장은 exp(−((d − r) / WAVE_W)²) 에 시간 감쇠를 곱해 칸을 바깥으로 PUSH 만큼 밀었다 되돌린다. 지뢰 18개 중 시작 지뢰에서 가장 먼 것은 424px 거리라 마지막 폭발은 0.3 + 424/170 으로 2.79초에 일어난다.

지뢰를 밟으면 판 전체의 지뢰가 연쇄로 터지는 충격파
새 창에서 열기

07 · 지뢰를 밟으면 판 전체의 지뢰가 연쇄로 터지는 충격파 — 측정 누적면적 63.435% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

RING_SPEEDWAVE_SPEEDPUSH
            const r = age * WAVE_SPEED;
            const amp = Math.exp(-age * 1.6);
            for (let i = 0; i < COLS * ROWS; i += 1) {
                const ddx = cellCx(i) - cellCx(s);
                const ddy = cellCy(i) - cellCy(s);
                const d = Math.hypot(ddx, ddy);
                if (d < 1) {
                    continue;
                }
                const q = (d - r) / WAVE_W;
                const e = Math.exp(-q * q) * amp;
                dxs[i] += ddx / d * e * PUSH;
                dys[i] += ddy / d * e * PUSH;
                glow[i] += e;
            }

08깃발을 꽂을 때 스프링으로 박히고 남은 지뢰 수가 굴러 줄어드는 깃발 계량기

모바일 게임 상단 HUD 에 쓰는 카운터에는 깃발을 꽂을 때마다 숫자가 굴러 줄어드는 계량기가 맞다. remain() 은 MINES − countSt(B, 2) 로 남은 지뢰 수를 구하고, 값이 바뀌면 mT 에 시각을 적어 0.2초 동안 옛 숫자를 아래로 밀며 새 숫자를 위에서 내려 pad3 로 부호와 세 자리를 맞춘다. 깃발은 올바른 것 14개와 틀린 것 6개를 합쳐 20개이고 마지막 깃발은 4.48초에 꽂히며, 계량기는 18 에서 −2 까지 내려갔다가 틀린 깃발을 걷어 내면 4 로 돌아온다.

깃발을 꽂을 때 스프링으로 박히고 남은 지뢰 수가 굴러 줄어드는 깃발 계량기
새 창에서 열기

08 · 깃발을 꽂을 때 스프링으로 박히고 남은 지뢰 수가 굴러 줄어드는 깃발 계량기 — 측정 누적면적 4.63% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FLAG_GAPpad3remain
        ctx.save();
        ctx.beginPath();
        ctx.rect(bx, 41, 104, 26);
        ctx.clip();
        const p = ease((sceneT - mT) / 0.2);
        text(pad3(mPrev), W / 2, 54 + p * 18, INK, 1 - p, 20, 800, 'center');
        text(pad3(mNow), W / 2, 54 - (1 - p) * 18, INK, p, 20, 800, 'center');
        ctx.restore();

09소나 선이 돌며 칸 아래의 숫자를 드러내는 지뢰밭 히어로

게임·보안 서비스 랜딩의 첫 화면에는 소나 선이 지나간 칸만 숫자가 떠오르는 어두운 판이 맞다. lagOf 가 선이 칸 중심 방향을 지나친 각도를 구하면 칸 밝기와 숫자 불투명도는 exp(−lag / FADE_ANGLE) 로, 지뢰의 점멸은 exp(−lag / BLINK_ANGLE) 로 정해진다. OMEGA 가 1.15 라서 선 한 바퀴는 5.46초이고 한 판 LAP_S 는 두 바퀴인 10.93초다.

소나 선이 돌며 칸 아래의 숫자를 드러내는 지뢰밭 히어로
새 창에서 열기

09 · 소나 선이 돌며 칸 아래의 숫자를 드러내는 지뢰밭 히어로 — 측정 누적면적 38.867% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

OMEGAFADE_ANGLElagOf
            const lag = lagOf(i, a);
            let k = Math.exp(-lag / FADE_ANGLE);
            const pa = sceneT - pingT[i];
            if (pa >= 0 && pa < 2) {
                k = Math.max(k, Math.exp(-pa / 0.7));
            }
            if (k < 0.04) {
                k = 0;
            }
            if (k > 0.3) {
                lit += 1;
            }

지뢰찾기 게임 원리를 코드로 옮기면 칸은 몇 가지 상태를 가져야 할까?

칸이 가진 값은 세 가지 상태와 네 개의 시각이다

칸 하나는 지뢰 여부와 이웃 수, 닫힘·열림·깃발 세 상태, 그리고 일이 일어난 시각 넷으로 나뉜다. 시각을 상태와 따로 두면 번짐처럼 열림이 거리만큼 늦게 일어나는 장면도 상태는 바로 바꾸고 화면만 늦출 수 있다.

값 뜻 바꾸는 함수
mine 지뢰 여부 placeMines
num 이웃 지뢰 수 calcNums
st 0·1·2 닫힘·열림·깃발 revealFrom·applyHint
openT·exT·flT·xT 열림·폭발·깃발·가위표가 일어난 시각 revealFrom·startChain·applyHint·doChord

지뢰찾기 게임 사이트에 붙일 때

지뢰찾기 게임 사이트에 넣는다면 05 의 findHint(b) 는 판 하나를 받아 힌트 하나를 돌려주는 함수라 버튼 하나에 이을 수 있다. 판 로직이 화면을 모르기 때문에 캔버스를 DOM 칸으로 바꿔도 같은 함수를 그대로 쓴다.

게임 만들기 과제로 낼 때

코딩 과제라면 03 의 calcNums 를 첫 과제, 01 의 revealFrom 을 둘째 과제로 나누면 이웃 세기와 번져 열기가 서로 다른 문제로 풀린다. 지뢰찾기 게임 공략을 다루는 글을 쓸 때도 05 의 부분집합 비교와 06 의 확률 지도가 설명 그림이 된다.

판을 배열로 만들고, 첫 클릭 뒤에 지뢰를 놓고, 이웃을 세고, 빈 칸에서 번져 열기까지 — 지뢰찾기 한 판이 만들어지는 순서

판을 만들고 열고 힌트를 세는 순서

05 의 build 가 이 순서를 한 번에 보여 준다. 새 판을 만들고, 중앙 칸을 안전 칸으로 placeMines 하고, revealFrom 으로 번지게 하고, 판 복사본에서 findHint 와 applyHint 를 여섯 번 돌려 힌트가 이어지는지 본 뒤 조건에 맞는 시드를 고른다.

            B = newBoard(COLS, ROWS, MINES);
            placeMines(B, base + k, center);
            revealFrom(B, [{ i: center, d: 0 }], -9, 0);
            const c = cloneBoard(B);
            let pairs = 0;
            let mineHints = 0;
            let total = 0;
            for (let s = 0; s < 6; s += 1) {
                const h = findHint(c);
                if (!h) {
                    break;
                }
                total += 1;
                if (h.b >= 0) {
                    pairs += 1;
                }
                if (h.kind === 1) {
                    mineHints += 1;
                }
                applyHint(c, h, -9, 0);
            }
            if (total >= 6 && pairs >= 4 && mineHints >= 1 && countSt(B, 1) >= 30) {
                break;
            }
        }

시연에 맞는 판을 고르는 탐색

시드가 좋은 판을 주지 않을 때를 대비해 장면마다 후보를 훑는다. 01 은 SEED + k 에서 floodSize 가 MIN_FLOOD 44 이상인 판을, 05 는 위 조건에 맞는 판을 120번까지, 06 은 열린 칸이 28~64칸인 판을 600번까지 찾는다.

지뢰찾기가 깨지는 순간 — 첫 클릭이 지뢰인 판·번져 열기가 판 가장자리를 넘어 읽는 칸·깃발 수가 남은 지뢰 수와 어긋나는 계량기

첫 클릭이 지뢰인 판

02 는 지뢰를 먼저 놓은 판과 첫 클릭 뒤에 짠 판을 나란히 보여 주는데, 시간표의 폭발은 1.0초라 첫 판이 시작하는 2.2초보다 앞이다. 그래서 실패판 대비는 두 번째 판부터 보이고, 첫 판에서는 이미 안전 구역이 표시되는 단계가 먼저 눈에 들어온다.

번져 열기가 판 가장자리를 넘어 읽는 칸

번져 열기는 이웃 여덟 칸을 읽는데, 가장자리 칸이 i + 1 만 더해 이웃을 찾으면 오른쪽 끝 칸의 이웃이 다음 줄 왼쪽 끝 칸이 된다. 이 편은 경계 검사를 nbrs 한 곳에만 두어 숫자 세기·번짐·힌트가 같은 이웃 목록을 쓴다.

    function nbrs(b, i) {
        const x = i % b.cols;
        const y = Math.floor(i / b.cols);
        const out = [];
        for (let dy = -1; dy <= 1; dy += 1) {
            for (let dx = -1; dx <= 1; dx += 1) {
                if (dx === 0 && dy === 0) {
                    continue;
                }
                const nx = x + dx;
                const ny = y + dy;
                if (nx >= 0 && nx < b.cols && ny >= 0 && ny < b.rows) {
                    out.push(ny * b.cols + nx);
                }
            }
        }
        return out;
    }

깃발 수가 남은 지뢰 수와 어긋나는 계량기

08 은 깃발을 지뢰 수 18개보다 많은 20개까지 꽂아 계량기가 −2 로 내려가게 한다. pad3 가 부호를 붙여 세 자리로 맞추므로 −2 는 -002 로 보이고, 깃발 수는 정답 여부를 모르는 값이라 틀린 깃발 6개는 확인 단계의 가위표로만 드러난다.

확실한 칸이 없는 판은 드물다

06 은 중앙 칸을 열어 번진 판 600개 중 닫힌 칸에 확률 0% 와 100% 가 하나도 없는 판이 19개뿐이었고 모두 열린 칸이 9~18칸이었다. 그래서 solveOut 으로 확실한 칸을 모두 처리한 열린 칸 28~64칸의 판을 쓴다.

장면 아홉 개의 완성본 소스와 편집용 파일을 묶은 zip 은 아래에서 받으며, 압축 비밀번호는 vh7z44k3 이다.

판 크기와 지뢰 밀도를 바꾸면 추론 힌트가 닿는 칸은 얼마나 달라질까 — 열 수·지뢰 수·시드를 바꿔 본 차이

이름 바꾼 값 느낌
판을 크게 01 COLS×ROWS 16×8 → 30×16 (MINES 16 → 60) 밀도 12.5% 를 지키면 첫 번짐이 57칸에서 121칸으로 늘지만 가장 먼 칸 거리는 14 에서 16 이라 번지는 시간은 1.4초에서 1.6초다. 칸 크기가 28px 에서 15px 로 줄어 숫자 글자는 17px 에서 9px 가 된다
지뢰를 빽빽하게 02 MINES 22 → 40 밀도가 17% 에서 31% 가 되면 옮길 지뢰가 3개에서 4개로 늘고 안전 첫 수 뒤 열린 칸은 77칸에서 9칸으로 줄어든다. 구역 둘레 이웃에 지뢰가 닿아 숫자 0 이 가운데 한 칸뿐이라 번짐이 거리 1 에서 멈춘다
번짐을 느리게 01 RIP_DELAY 0.1 → 0.25 가장 먼 칸이 열리는 시각이 1.4초에서 3.5초로 늦어져 번짐은 4.0초에 끝나고 3.9초의 둘째 클릭과 잠깐 겹친다. 칸이 눌리는 시간 0.28초는 그대로라 칸 하나의 움직임은 같다

값을 바꾸기 전에 볼 것

추론 힌트는 열린 숫자 칸에서만 나오므로, 열린 칸이 77에서 9로 줄면 힌트의 재료도 그만큼 줄어든다. 판을 키울 때는 칸 크기 28px 에서 15px 로 줄어 눌러 맞추기 어려워지니 터치 화면에는 판 크기보다 칸 크기를 먼저 정하는 쪽이 낫다.

움직임 줄이기에서는 열린 판 한 장 — 칸 상태를 문장으로 읽어 주고 키보드로 칸을 옮기는 접근성

방향키로 칸을 옮기고 열고 깃발을 꽂는다

아홉 장면 모두 무대 상자가 role="application" 이고, 포커스를 둔 채 방향키를 누르면 자동 시연이 멈추고 직접 하기로 넘어간다. Enter·Space 는 그 칸 열기(열린 숫자면 코드 열기), F 는 깃발이고, P 는 일시정지, N 은 시간표의 다음 일 하나, R 은 다시 시작이다.

        if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowUp' || k === 'ArrowDown') {
            e.preventDefault();
            takeOver();
            moveCur(k === 'ArrowLeft' ? -1 : (k === 'ArrowRight' ? 1 : 0), k === 'ArrowUp' ? -1 : (k === 'ArrowDown' ? 1 : 0));
            showNow();
        }
        else if (k === 'Enter' || k === ' ') {
            e.preventDefault();
            takeOver();
            onAct(ky * gCols + kx);
            showNow();
        }
        else if (k === 'f' || k === 'F') {
            e.preventDefault();
            takeOver();
            onFlag(ky * gCols + kx);
            showNow();
        }

상태 줄은 시연 중에는 조용하다

상태 줄은 시연 중 aria-live="off" 라 일이 벌어질 때마다 읽히지 않고, 직접 하는 순간 polite 로 바뀐다. 한 판이 끝나기 0.6초 전에는 role="status" 숨김 문단에 현재 상태 문장을 한 번만 쓴다.

    // 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
    let doneSaid = false;
    function checkDone() {
        if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
            doneSaid = true;
            doneP.textContent = statusText();
        }
    }

움직임 줄이기와 숨은 탭

prefers-reduced-motion 이 켜져 있으면 루프 없이 showRest 가 REST_S 초 지점까지 시간표를 돌려 열린 판 한 장만 그린다. 탭이 숨으면 루프를 멈추고 돌아오면 첫 프레임의 dt 를 버려서 숨은 시간이 한꺼번에 쏟아지지 않는다.

    function showRest() {
        stopLoop();
        lap = 0;
        sceneT = 0;
        evAt = 0;
        script = [];
        build();
        runTo(REST_S);
        showNow();

숫자 색 8종은 크림 #fcf9f4 바탕에서 대비가 5.7:1 에서 14.3:1 사이다.

지뢰찾기 게임 만들기 FAQ — 라이브러리 없이 만들기·숫자 색 고르기·확실한 칸이 없는 판의 처리

지뢰찾기 게임 만들기에 라이브러리가 필요한가요

캔버스 2D 만 있으면 되고, 이 편의 아홉 장면은 라이브러리·WebGL·CDN 을 하나도 쓰지 않는다. 난수는 시드를 고정한 생성기로 만들어 같은 입력이 같은 판을 만들고, 판 로직 함수는 DOM 을 모르므로 따로 떼어 노드에서 돌려 볼 수 있다.

숫자 색은 어떻게 골라야 읽히나요

숫자마다 색이 달라야 분포가 한눈에 읽히지만 색만으로 구분하면 안 되므로, 이 편은 숫자 글자를 같이 그리고 8종 모두 바탕과의 대비를 5.7:1 이상으로 맞췄다. 바탕이 바뀌면 숫자 색의 대비도 다시 재야 한다.

확실한 칸이 없으면 어떻게 하나요

그때는 06 처럼 확률을 계산해 가장 옅은 칸을 고르면 된다. 이 편의 라운드 3 에서는 가장 옅은 칸이 0% 라서, 열고 나면 확실히 안전한 칸이 생긴다.

참고로 MDN 의 requestAnimationFrame 문서와 Canvas 2D 문서, 움직임 줄이기 설정은 prefers-reduced-motion 문서와 라이브 영역 문서가 기준이다. 퍼즐을 맞추는 규칙은 직소 퍼즐 편을, 칸을 합치는 규칙은 2048 게임 편을, 격자 위에서 길을 찾는 장면은 미로 생성기 편을 이어서 보면 된다.

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

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