03A Scan That Counts the Eight Neighbors to Fill In the Numbers
A scan frame passes over cells one by one counting the mines among eight neighbors, and each count floats up and settles as a number. Each number has its own color so the distribution reads at a glance.
Best forRule explainer showing how the numbers are made
SCAN_RATEnbrscalcNums

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="Minesweeper stage: arrow keys move between cells, Enter opens, F flags, P pauses, N steps once, R restarts">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="A 3 by 3 scan frame passes cell by cell over a 16 by 8 board holding 22 mines, counting the mines among the eight neighbors, and each count floats up and settles as a number. Mine neighbors get a pink ring and each number tints its cell a different color"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">Minesweeper demo</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">Pause</button>
<button class="vj__step" data-role="step" type="button">Step</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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;
}
// 무대 상자 — 여기서 시작한 터치만 칸 누르기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
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;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
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) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step: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);
}
.vj {
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;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
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);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
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) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 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 ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 판
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 닫힌 칸은 라벤더, 열린 칸은 크림, 지뢰·깃발은 분홍
const BG_TOP = [248, 244, 252];
const BG_BOT = [228, 234, 250];
const TILE = [196, 186, 240];
const TILE_HI = [226, 219, 250];
const TILE_LO = [160, 150, 214];
const OPEN = [252, 249, 244];
const OPEN_BED = [226, 220, 236];
const WATER = [168, 206, 244];
const ROSE = [240, 150, 176];
const ROSE_DEEP = [190, 52, 86];
const MINT = [150, 214, 190];
const BUTTER = [250, 224, 140];
const LAV = [188, 172, 236];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
// 숫자 색 — 칸 숫자는 색이 기능이라 숫자마다 다르게. 크림 바탕 위에서 4.5:1 이상
const NUM_COL = [INK, [30, 78, 196], [22, 112, 60], [186, 40, 60], [88, 50, 160], [150, 70, 10], [10, 110, 120], [40, 36, 54], [90, 90, 100]];
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) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
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;
}
// 이웃 칸 — 판 가장자리를 넘는 칸은 넣지 않는다(경계 검사가 여기 한 곳)
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;
}
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;
}
}
// 지뢰 배치 — 모든 칸을 시드로 섞어 앞에서 count 칸에 놓는다. safe 가 있으면 그 칸과 이웃 여덟 칸의 지뢰는
// 섞인 순서 뒤쪽의 빈 칸으로 옮긴다. 옮긴 목록 [원래 칸, 새 칸] 을 돌려준다
function placeMines(b, seed, safe) {
const n = b.cols * b.rows;
const rng = seeded(seed);
const order = [];
for (let i = 0; i < n; i += 1) {
order.push(i);
}
for (let i = n - 1; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = order[i];
order[i] = order[j];
order[j] = t;
}
const zone = [];
for (let i = 0; i < n; i += 1) {
zone.push(false);
}
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;
}
b.placed = true;
calcNums(b);
return moves;
}
// 번져 열기 — src 의 칸에서 시작해 숫자 0 인 칸의 이웃으로 번진다. 칸마다 거리 d 를 세어 openT = t0 + d·delay.
// 깃발 칸과 지뢰 칸은 열지 않는다. 새로 연 칸 목록을 연 순서대로 돌려준다
function revealFrom(b, src, t0, delay) {
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;
}
// 열지 않고 번짐 크기만 센다 — 시작 칸에서 닿는 닫힌 칸 수
function floodSize(b, i) {
if (b.st[i] !== 0 || b.mine[i]) {
return 0;
}
const seen = [];
for (let k = 0; k < b.cols * b.rows; k += 1) {
seen.push(false);
}
const q = [i];
seen[i] = true;
let n = 0;
while (q.length > 0) {
const c = q.pop();
n += 1;
if (b.num[c] !== 0) {
continue;
}
for (const j of nbrs(b, c)) {
if (!seen[j] && b.st[j] === 0 && !b.mine[j]) {
seen[j] = true;
q.push(j);
}
}
}
return n;
}
function flagsAround(b, i) {
let n = 0;
for (const j of nbrs(b, i)) {
if (b.st[j] === 2) {
n += 1;
}
}
return n;
}
// 코드 열기 — 열린 숫자 칸 둘레의 깃발 수가 숫자와 같을 때만 남은 닫힌 이웃 목록을 준다
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;
}
function countSt(b, s) {
let n = 0;
for (let i = 0; i < b.cols * b.rows; i += 1) {
if (b.st[i] === s) {
n += 1;
}
}
return n;
}
// 눈에 열려 있는 칸 수 — 열기가 예약돼 시각이 아직 안 된 칸은 세지 않는다
function countOpen(b) {
let n = 0;
for (let i = 0; i < b.cols * b.rows; i += 1) {
if (b.st[i] === 1 && sceneT >= b.openT[i]) {
n += 1;
}
}
return n;
}
function countEx(b, t) {
let n = 0;
for (let i = 0; i < b.cols * b.rows; i += 1) {
if (b.exT[i] >= 0 && b.exT[i] <= t) {
n += 1;
}
}
return n;
}
function cloneBoard(b) {
const c = newBoard(b.cols, b.rows, b.count);
for (let i = 0; i < b.cols * b.rows; i += 1) {
c.mine[i] = b.mine[i];
c.num[i] = b.num[i];
c.st[i] = b.st[i];
c.openT[i] = b.openT[i];
}
c.placed = b.placed;
return c;
}
function consOf(b) {
const out = [];
for (let i = 0; i < b.cols * b.rows; i += 1) {
if (b.st[i] !== 1 || b.num[i] === 0) {
continue;
}
const u = [];
for (const j of nbrs(b, i)) {
if (b.st[j] === 0) {
u.push(j);
}
}
if (u.length > 0) {
out.push({ c: i, u: u, r: b.num[i] - flagsAround(b, i) });
}
}
return out;
}
function subsetOf(x, y) {
for (const v of x) {
if (y.indexOf(v) < 0) {
return false;
}
}
return true;
}
// 두 조건을 겹친다 — A 의 닫힌 이웃이 B 의 것에 통째로 들어 있으면, B 에만 있는 칸들의 지뢰 수는 r_B − r_A 다.
// 0 이면 전부 안전, 칸 수와 같으면 전부 지뢰. 한 숫자만으로 이미 정해지는 경우는 두 숫자 힌트로 세지 않는다
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;
}
function singleHint(cs) {
for (const A of cs) {
if (A.r === 0) {
return { kind: 0, cells: A.u.slice(), a: A.c, b: -1 };
}
if (A.r === A.u.length) {
return { kind: 1, cells: A.u.slice(), a: A.c, b: -1 };
}
}
return null;
}
function findHint(b) {
const cs = consOf(b);
const p = pairHint(cs);
return p ? p : singleHint(cs);
}
function applyHint(b, h, t0, delay) {
if (h.kind === 1) {
for (const c of h.cells) {
b.st[c] = 2;
b.flT[c] = t0;
}
}
else {
const src = [];
for (const c of h.cells) {
src.push({ i: c, d: 0 });
}
revealFrom(b, src, t0, delay);
}
}
// ── 확률 ─────────────────────────────────────────────────
function comb(n, k) {
if (k < 0 || k > n) {
return 0;
}
let r = 1;
for (let i = 1; i <= k; i += 1) {
r = r * (n - k + i) / i;
}
return r;
}
// 모든 닫힌 칸의 지뢰 확률(−1 은 닫힌 칸이 아님). 경계 칸(열린 숫자와 맞닿은 닫힌 칸)은 조건을 모두 만족하는 배치를
// 하나씩 세고, 경계 밖 칸은 남은 지뢰를 경계 배치마다 C(바깥 칸, Mines left)가지로 나눠 가진다
const PROB_NODE_CAP = 300000;
let probNodes = 0;
function probMap(b) {
const n = b.cols * b.rows;
const out = [];
const fi = [];
for (let i = 0; i < n; i += 1) {
out.push(-1);
fi.push(-1);
}
const F = [];
const cs = consOf(b);
for (const c of cs) {
for (const j of c.u) {
if (fi[j] < 0) {
fi[j] = F.length;
F.push(j);
}
}
}
const cc = [];
for (let k = 0; k < F.length; k += 1) {
cc.push([]);
}
cs.forEach(function (c, ci) { for (const j of c.u) {
cc[fi[j]].push(ci);
} });
const got = [];
const left = [];
for (const c of cs) {
got.push(0);
left.push(c.u.length);
}
const asg = [];
for (let k = 0; k < F.length; k += 1) {
asg.push(0);
}
const sol = [];
const cm = [];
for (let m = 0; m <= F.length; m += 1) {
sol.push(0);
const row = [];
for (let k = 0; k < F.length; k += 1) {
row.push(0);
}
cm.push(row);
}
probNodes = 0;
function rec(k, mines) {
probNodes += 1;
if (probNodes > PROB_NODE_CAP) {
return;
}
if (k === F.length) {
sol[mines] += 1;
for (let f = 0; f < F.length; f += 1) {
if (asg[f] === 1) {
cm[mines][f] += 1;
}
}
return;
}
for (let v = 0; v <= 1; v += 1) {
let ok = true;
for (const ci of cc[k]) {
got[ci] += v;
left[ci] -= 1;
if (got[ci] > cs[ci].r || got[ci] + left[ci] < cs[ci].r) {
ok = false;
}
}
if (ok) {
asg[k] = v;
rec(k + 1, mines + v);
}
for (const ci of cc[k]) {
got[ci] -= v;
left[ci] += 1;
}
}
}
rec(0, 0);
let hidden = 0;
let flags = 0;
const inner = [];
for (let i = 0; i < n; i += 1) {
if (b.st[i] === 0) {
hidden += 1;
if (fi[i] < 0) {
inner.push(i);
}
}
if (b.st[i] === 2) {
flags += 1;
}
}
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];
}
}
const fallback = hidden > 0 ? clamp(M / hidden, 0, 1) : 0;
for (let i = 0; i < n; i += 1) {
if (b.st[i] !== 0) {
continue;
}
if (total <= 0 || probNodes > PROB_NODE_CAP) {
out[i] = fallback;
}
else if (fi[i] >= 0) {
out[i] = pf[fi[i]] / total;
}
else {
out[i] = innerMines / total / inner.length;
}
}
return out;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
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);
}
function skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
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 seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 판 자리 — 열·행에 맞춰 칸 크기 gs 와 왼쪽 위 (gx, gy) 를 정한다. 위·아래 여백은 상태 줄·계량기 자리
let gx = 0;
let gy = 0;
let gs = 28;
let gCols = 15;
let gRows = 8;
function fitGeo(cols, rows, top, bottom) {
gCols = cols;
gRows = rows;
gs = Math.min(Math.floor((W - 24) / cols), Math.floor((H - top - bottom) / rows), 32);
gx = (W - gs * cols) / 2;
gy = top + (H - top - bottom - gs * rows) / 2;
}
function cellX(i) { return gx + (i % gCols) * gs; }
function cellY(i) { return gy + Math.floor(i / gCols) * gs; }
function cellCx(i) { return cellX(i) + gs / 2; }
function cellCy(i) { return cellY(i) + gs / 2; }
function mineGlyph(x, y, r, c, a) {
for (let k = 0; k < 8; k += 1) {
const an = k * TAU / 8;
seg(x + Math.cos(an) * r * 0.55, y + Math.sin(an) * r * 0.55, x + Math.cos(an) * r * 1.12, y + Math.sin(an) * r * 1.12, Math.max(1.4, r * 0.24), c, a);
}
disc(x, y, r * 0.78, c, a);
disc(x - r * 0.26, y - r * 0.26, r * 0.22, [255, 255, 255], a * 0.8);
}
function flagGlyph(x, y, s, a) {
seg(x - s * 0.1, y - s * 0.34, x - s * 0.1, y + s * 0.34, Math.max(1.6, s * 0.08), INK, a);
ctx.beginPath();
ctx.moveTo(x - s * 0.1, y - s * 0.34);
ctx.lineTo(x + s * 0.3, y - s * 0.14);
ctx.lineTo(x - s * 0.1, y + s * 0.06);
ctx.closePath();
ctx.fillStyle = rgba(ROSE_DEEP, a);
ctx.fill();
box(x - s * 0.3, y + s * 0.32, s * 0.4, s * 0.1, s * 0.05, INK, a);
}
function numGlyph(n, x, y, s, a) {
if (n <= 0) {
return;
}
text(String(n), x, y + 0.5, NUM_COL[n], a, Math.round(s * 0.62), 800, 'center');
}
function hiddenTile(x, y, s, a) {
box(x + 1, y + 2, s - 2, s - 2, 4, TILE_LO, a);
box(x + 1, y + 1, s - 2, s - 3, 4, TILE, a);
box(x + 3, y + 2, s - 6, 2, 1, TILE_HI, a * 0.9);
}
// 판 그리기 — 상태(st·openT·exT·flT·xT)를 칸으로 옮길 뿐 규칙은 건드리지 않는다.
// off 는 칸을 밀어 놓는 값(충격파), tint 는 칸 위에 덮는 색, nums 가 거짓이면 열린 칸 숫자를 그리지 않는다
const OPEN_S = 0.28;
function drawCells(b, off, tint, nums) {
for (let i = 0; i < b.cols * b.rows; i += 1) {
let x = cellX(i);
let y = cellY(i);
if (off) {
const o = off(i);
x += o[0];
y += o[1];
}
const open = b.st[i] === 1 && sceneT >= b.openT[i];
const p = b.st[i] === 1 ? clamp((sceneT - b.openT[i]) / OPEN_S, 0, 1) : 0;
const ex = b.exT[i] >= 0 && sceneT >= b.exT[i];
const cx = x + gs / 2;
const cy = y + gs / 2;
if (open) {
// 눌렸다 올라오는 칸 — 닫힌 색에서 크림색으로 바뀌며 폭이 최대 16% 줄었다 돌아온다
const sc = 1 - 0.16 * Math.sin(p * Math.PI);
const w = (gs - 2) * sc;
box(x + 1, y + 1, gs - 2, gs - 2, 4, OPEN_BED, 1);
box(cx - w / 2, cy - w / 2, w, w, 4, mix(TILE, OPEN, ease(p)), 1);
if (nums && !b.mine[i]) {
numGlyph(b.num[i], cx, cy, gs, clamp((p - 0.4) / 0.6, 0, 1));
}
}
else if (ex) {
const k = clamp((sceneT - b.exT[i]) / 0.2, 0, 1);
box(x + 1, y + 1, gs - 2, gs - 2, 4, OPEN_BED, 1);
disc(cx, cy, gs * 0.62, [96, 70, 92], 0.22 * k);
mineGlyph(cx, cy, gs * (0.22 + 0.12 * ease(k)), INK, 1);
}
else {
hiddenTile(x, y, gs, 1);
}
if (b.st[i] === 2 && b.flT[i] >= 0 && sceneT >= b.flT[i]) {
const q = sceneT - b.flT[i];
const dy = -30 * Math.exp(-9 * q) * Math.cos(15 * q);
disc(cx, cy + gs * 0.36, gs * 0.2, INK, 0.16 * clamp(q * 8, 0, 1));
flagGlyph(cx, cy + dy, gs, clamp(q * 8, 0, 1));
}
if (tint) {
const tc = tint(i);
if (tc) {
box(x + 1, y + 1, gs - 2, gs - 2, 4, [tc[0], tc[1], tc[2]], tc[3]);
}
}
if (b.xT[i] >= 0 && sceneT >= b.xT[i]) {
const k = clamp((sceneT - b.xT[i]) / 0.2, 0, 1);
const r = gs * 0.3 * ease(k);
seg(cx - r, cy - r, cx + r, cy + r, 3, ROSE_DEEP, 1);
seg(cx - r, cy + r, cx + r, cy - r, 3, ROSE_DEEP, 1);
}
}
}
// 사람이 칸을 직접 누를 때 쓰는 기본 동작 — 장면마다 쓰는 규칙(첫 클릭 안전·번져 열기·코드 열기·지뢰 터짐)은 위 판 로직 그대로다
function loseBoard(b, i) {
const ix = i % b.cols;
const iy = Math.floor(i / b.cols);
for (let j = 0; j < b.cols * b.rows; j += 1) {
if (!b.mine[j] || b.exT[j] >= 0) {
continue;
}
const d = Math.hypot((j % b.cols) - ix, Math.floor(j / b.cols) - iy);
b.exT[j] = sceneT + d * 0.05;
}
b.exT[i] = sceneT;
}
function manualAct(b, seed, i, delay) {
if (b.st[i] === 2) {
return;
}
if (!b.placed) {
placeMines(b, seed, i);
}
if (b.st[i] === 1) {
const list = chordList(b, i);
const src = [];
list.forEach(function (c, k) {
if (b.mine[c]) {
loseBoard(b, c);
}
else {
src.push({ i: c, d: k });
}
});
revealFrom(b, src, sceneT, delay);
return;
}
if (b.mine[i]) {
loseBoard(b, i);
return;
}
revealFrom(b, [{ i: i, d: 0 }], sceneT, delay);
}
function manualFlag(b, i) {
if (b.st[i] === 0) {
b.st[i] = 2;
b.flT[i] = sceneT;
}
else if (b.st[i] === 2) {
b.st[i] = 0;
b.flT[i] = -1;
}
}
// 정지 장면 한 장 — 열린 판을 보여 주는 시각(초)과 첫 판이 앞서 시작하는 시각(초)은 장면 상수 START_S·REST_S
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
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();
// 사람이 직접 하는 중이면 시간표는 멈추고 애니메이션만 흐른다
let manual = false;
let curOn = false;
let kx = 0;
let ky = 0;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
function takeOver() {
if (manual) {
return;
}
manual = true;
onTake();
wake();
if (!curOn) {
curOn = true;
kx = Math.floor(gCols / 2);
ky = Math.floor(gRows / 2);
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 판이 이미 진행 중인 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
const DT_MAX = 0.05;
const SETTLE_FRAMES = 8;
const SETTLE_DT = 0.04;
function drawCursor() {
if (!curOn) {
return;
}
const i = ky * gCols + kx;
outline(cellX(i) + 1, cellY(i) + 1, gs - 2, gs - 2, 5, 3, INK, 1);
outline(cellX(i) - 1, cellY(i) - 1, gs + 2, gs + 2, 6, 1.5, PAPER, 0.9);
}
function showNow() {
draw();
drawCursor();
pushStatus(statusText());
checkDone();
}
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);
}
showNow();
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();
lap = 0;
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
manual = false;
doneSaid = false;
lap += 1;
begin();
for (let k = 0; k < SETTLE_FRAMES; k += 1) {
update(SETTLE_DT);
}
showNow();
}
// Step — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (manual || evAt >= script.length) {
restart();
return;
}
sceneT = Math.max(sceneT, script[evAt].t);
advance(0);
for (let k = 0; k < SETTLE_FRAMES; k += 1) {
sceneT += SETTLE_DT;
update(SETTLE_DT);
}
showNow();
}
function moveCur(dx, dy) {
kx = clamp(kx + dx, 0, gCols - 1);
ky = clamp(ky + dy, 0, gRows - 1);
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 칸 이동, Enter·Space 는 칸 열기, F 는 깃발, P 는 Pause, N 은 Step, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
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();
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 누른 칸 열기, 오른쪽 버튼은 깃발
function cellFromEvent(e) {
const rect = cv.getBoundingClientRect();
const px = (e.clientX - rect.left) / rect.width * W;
const py = (e.clientY - rect.top) / rect.height * H;
const cx = Math.floor((px - gx) / gs);
const cy = Math.floor((py - gy) / gs);
if (cx < 0 || cx >= gCols || cy < 0 || cy >= gRows) {
return -1;
}
return cy * gCols + cx;
}
pad.addEventListener('click', function (e) {
const c = cellFromEvent(e);
if (c < 0) {
return;
}
takeOver();
kx = c % gCols;
ky = Math.floor(c / gCols);
onAct(c);
showNow();
}, { signal: off.signal });
pad.addEventListener('contextmenu', function (e) {
const c = cellFromEvent(e);
if (c < 0) {
return;
}
e.preventDefault();
takeOver();
kx = c % gCols;
ky = Math.floor(c / gCols);
onFlag(c);
showNow();
}, { 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 {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 03 여덟 이웃의 지뢰를 세어 숫자를 채우는 스캔 ────────────────────────────
const COLS = 16;
const ROWS = 8;
const MINES = 22;
const SEED = 4943;
// 스캔 속도(칸/초) — 가로로 훑으며 한 칸에 1/SCAN_RATE 초를 쓴다
const SCAN_RATE = 32;
const SCAN_START = 0.3;
const LAP_S = 6.6;
const START_S = 0.0;
const REST_S = 5.8;
let B = newBoard(COLS, ROWS, MINES);
let probed = [];
function build() {
fitGeo(COLS, ROWS, 44, 14);
B = newBoard(COLS, ROWS, MINES);
placeMines(B, SEED + (lap % 2) * 29, -1);
probed = [];
for (let i = 0; i < COLS * ROWS; i += 1) {
probed.push(false);
}
}
function update(dt) { if (dt < 0) {
return;
} }
// 지금 스캔 틀이 서 있는 칸(−1 은 아직 시작 전, 칸 수 이상이면 끝)
function scanAt() { return Math.floor((sceneT - SCAN_START) * SCAN_RATE); }
function bornT(i) { return SCAN_START + i / SCAN_RATE; }
function draw() {
skyFill(BG_TOP, BG_BOT);
const n = COLS * ROWS;
const cur = scanAt();
for (let i = 0; i < n; i += 1) {
const x = cellX(i);
const y = cellY(i);
const age = manual ? (probed[i] ? 1 : -1) : sceneT - bornT(i);
box(x + 1, y + 1, gs - 2, gs - 2, 4, OPEN_BED, 1);
box(x + 2, y + 2, gs - 4, gs - 4, 3, OPEN, 1);
if (B.mine[i]) {
mineGlyph(cellCx(i), cellCy(i), gs * 0.22, INK, 0.55);
}
if (age < 0) {
continue;
}
// 센 값이 위에서 떠올라 자리를 잡는다 — 숫자 색이 칸에 옅게 번져 분포가 보인다
const k = ease(age / 0.3);
if (!B.mine[i] && B.num[i] > 0) {
const c = NUM_COL[B.num[i]];
box(x + 2, y + 2, gs - 4, gs - 4, 3, c, 0.17 * k);
numGlyph(B.num[i], cellCx(i), cellCy(i) - (1 - k) * gs * 0.45, gs * (1 + 0.35 * (1 - k)), k);
}
else if (!B.mine[i]) {
box(x + 2, y + 2, gs - 4, gs - 4, 3, MINT, 0.22 * k);
}
if (age < 0.5 && !B.mine[i]) {
box(x + 2, y + 2, gs - 4, gs - 4, 3, BUTTER, 0.55 * (1 - age / 0.5));
}
}
if (!manual && cur >= 0 && cur < n) {
const x = cellX(cur);
const y = cellY(cur);
// 스캔 틀 — 센 칸을 가운데 둔 3×3. 틀 안의 지뢰 이웃은 분홍 고리로 불려 나온다
outline(x - gs + 1, y - gs + 1, gs * 3 - 2, gs * 3 - 2, 6, 2.5, INK, 0.9);
for (const j of nbrs(B, cur)) {
if (B.mine[j]) {
ring(cellCx(j), cellCy(j), gs * 0.4, 2.5, ROSE_DEEP, 0.95);
}
}
outline(x + 1, y + 1, gs - 2, gs - 2, 4, 2, WATER, 1);
}
}
function statusText() {
let seen = 0;
if (manual) {
for (let i = 0; i < probed.length; i += 1) {
if (probed[i]) {
seen += 1;
}
}
}
else {
seen = clamp(scanAt() + 1, 0, COLS * ROWS);
}
const s = String(seen);
if (!manual && seen >= COLS * ROWS) {
return 'Scan done - {s} cells - {m} mines'.replace('{s}', s).replace('{m}', String(MINES));
}
return 'Scan {s}/{t} cells'.replace('{s}', s).replace('{t}', String(COLS * ROWS));
}
// 직접 하면 눌린 칸 하나를 바로 센다
function onAct(i) { probed[i] = true; }
function onFlag(i) { probed[i] = true; }
// 직접 하는 순간 — 스캔이 지나온 칸은 그대로 남기고 틀은 거둔다
function onTake() {
for (let i = 0; i < COLS * ROWS; i += 1) {
probed[i] = i <= scanAt();
}
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// NeighborCountScan - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./NeighborCountScan.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function NeighborCountScan() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 판
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 닫힌 칸은 라벤더, 열린 칸은 크림, 지뢰·깃발은 분홍
const BG_TOP: RGB = [248, 244, 252];
const BG_BOT: RGB = [228, 234, 250];
const TILE: RGB = [196, 186, 240];
const TILE_HI: RGB = [226, 219, 250];
const TILE_LO: RGB = [160, 150, 214];
const OPEN: RGB = [252, 249, 244];
const OPEN_BED: RGB = [226, 220, 236];
const WATER: RGB = [168, 206, 244];
const ROSE: RGB = [240, 150, 176];
const ROSE_DEEP: RGB = [190, 52, 86];
const MINT: RGB = [150, 214, 190];
const BUTTER: RGB = [250, 224, 140];
const LAV: RGB = [188, 172, 236];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
// 숫자 색 — 칸 숫자는 색이 기능이라 숫자마다 다르게. 크림 바탕 위에서 4.5:1 이상
const NUM_COL: RGB[] = [INK, [30, 78, 196], [22, 112, 60], [186, 40, 60], [88, 50, 160], [150, 70, 10], [10, 110, 120], [40, 36, 54], [90, 90, 100]];
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) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// ── 판 로직 ───────────────────────────────────────────────
// st: 0 닫힘 · 1 열림 · 2 깃발. openT 는 그 칸이 눈에 열리는 시각, exT 는 지뢰가 터지는 시각(없으면 −1),
// flT 는 깃발이 내려꽂히는 시각, xT 는 틀린 깃발에 ✕ 가 켜지는 시각
type Board = {
cols: number; rows: number; count: number;
mine: boolean[]; num: number[]; st: number[];
openT: number[]; exT: number[]; flT: number[]; xT: number[];
placed: boolean;
};
function newBoard(cols: number, rows: number, count: number): Board {
const b: Board = { 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;
}
// 이웃 칸 — 판 가장자리를 넘는 칸은 넣지 않는다(경계 검사가 여기 한 곳)
function nbrs(b: Board, i: number): number[] {
const x = i % b.cols;
const y = Math.floor(i / b.cols);
const out: number[] = [];
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;
}
function calcNums(b: Board): void {
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;
}
}
// 지뢰 배치 — 모든 칸을 시드로 섞어 앞에서 count 칸에 놓는다. safe 가 있으면 그 칸과 이웃 여덟 칸의 지뢰는
// 섞인 순서 뒤쪽의 빈 칸으로 옮긴다. 옮긴 목록 [원래 칸, 새 칸] 을 돌려준다
function placeMines(b: Board, seed: number, safe: number): number[][] {
const n = b.cols * b.rows;
const rng = seeded(seed);
const order: number[] = [];
for (let i = 0; i < n; i += 1) { order.push(i); }
for (let i = n - 1; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = order[i]; order[i] = order[j]; order[j] = t;
}
const zone: boolean[] = [];
for (let i = 0; i < n; i += 1) { zone.push(false); }
if (safe >= 0) {
zone[safe] = true;
for (const j of nbrs(b, safe)) { zone[j] = true; }
}
const moves: number[][] = [];
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;
}
b.placed = true;
calcNums(b);
return moves;
}
// 번져 열기 — src 의 칸에서 시작해 숫자 0 인 칸의 이웃으로 번진다. 칸마다 거리 d 를 세어 openT = t0 + d·delay.
// 깃발 칸과 지뢰 칸은 열지 않는다. 새로 연 칸 목록을 연 순서대로 돌려준다
function revealFrom(b: Board, src: Array<{ i: number; d: number }>, t0: number, delay: number): number[] {
const todo = src.slice();
const out: number[] = [];
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;
}
// 열지 않고 번짐 크기만 센다 — 시작 칸에서 닿는 닫힌 칸 수
function floodSize(b: Board, i: number): number {
if (b.st[i] !== 0 || b.mine[i]) { return 0; }
const seen: boolean[] = [];
for (let k = 0; k < b.cols * b.rows; k += 1) { seen.push(false); }
const q: number[] = [i];
seen[i] = true;
let n = 0;
while (q.length > 0) {
const c = q.pop() as number;
n += 1;
if (b.num[c] !== 0) { continue; }
for (const j of nbrs(b, c)) {
if (!seen[j] && b.st[j] === 0 && !b.mine[j]) { seen[j] = true; q.push(j); }
}
}
return n;
}
function flagsAround(b: Board, i: number): number {
let n = 0;
for (const j of nbrs(b, i)) { if (b.st[j] === 2) { n += 1; } }
return n;
}
// 코드 열기 — 열린 숫자 칸 둘레의 깃발 수가 숫자와 같을 때만 남은 닫힌 이웃 목록을 준다
function chordList(b: Board, i: number): number[] {
if (b.st[i] !== 1 || b.num[i] === 0 || flagsAround(b, i) !== b.num[i]) { return []; }
const out: number[] = [];
for (const j of nbrs(b, i)) { if (b.st[j] === 0) { out.push(j); } }
return out;
}
function countSt(b: Board, s: number): number {
let n = 0;
for (let i = 0; i < b.cols * b.rows; i += 1) { if (b.st[i] === s) { n += 1; } }
return n;
}
// 눈에 열려 있는 칸 수 — 열기가 예약돼 시각이 아직 안 된 칸은 세지 않는다
function countOpen(b: Board): number {
let n = 0;
for (let i = 0; i < b.cols * b.rows; i += 1) { if (b.st[i] === 1 && sceneT >= b.openT[i]) { n += 1; } }
return n;
}
function countEx(b: Board, t: number): number {
let n = 0;
for (let i = 0; i < b.cols * b.rows; i += 1) { if (b.exT[i] >= 0 && b.exT[i] <= t) { n += 1; } }
return n;
}
function cloneBoard(b: Board): Board {
const c = newBoard(b.cols, b.rows, b.count);
for (let i = 0; i < b.cols * b.rows; i += 1) {
c.mine[i] = b.mine[i]; c.num[i] = b.num[i]; c.st[i] = b.st[i]; c.openT[i] = b.openT[i];
}
c.placed = b.placed;
return c;
}
// ── 추론 ─────────────────────────────────────────────────
// 조건 하나 = 열린 숫자 칸 하나. u 는 그 칸의 닫힌(깃발 아닌) 이웃, r 은 남은 지뢰 수(숫자 − 둘레 깃발)
type Cons = { c: number; u: number[]; r: number };
function consOf(b: Board): Cons[] {
const out: Cons[] = [];
for (let i = 0; i < b.cols * b.rows; i += 1) {
if (b.st[i] !== 1 || b.num[i] === 0) { continue; }
const u: number[] = [];
for (const j of nbrs(b, i)) { if (b.st[j] === 0) { u.push(j); } }
if (u.length > 0) { out.push({ c: i, u: u, r: b.num[i] - flagsAround(b, i) }); }
}
return out;
}
// 확실한 칸 한 묶음 — kind 0 은 안전, 1 은 지뢰. a·b 는 근거가 된 숫자 칸(하나뿐이면 b = −1)
type Hint = { kind: number; cells: number[]; a: number; b: number };
function subsetOf(x: number[], y: number[]): boolean {
for (const v of x) { if (y.indexOf(v) < 0) { return false; } }
return true;
}
// 두 조건을 겹친다 — A 의 닫힌 이웃이 B 의 것에 통째로 들어 있으면, B 에만 있는 칸들의 지뢰 수는 r_B − r_A 다.
// 0 이면 전부 안전, 칸 수와 같으면 전부 지뢰. 한 숫자만으로 이미 정해지는 경우는 두 숫자 힌트로 세지 않는다
function pairHint(cs: Cons[]): Hint | null {
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: number): boolean { 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;
}
function singleHint(cs: Cons[]): Hint | null {
for (const A of cs) {
if (A.r === 0) { return { kind: 0, cells: A.u.slice(), a: A.c, b: -1 }; }
if (A.r === A.u.length) { return { kind: 1, cells: A.u.slice(), a: A.c, b: -1 }; }
}
return null;
}
function findHint(b: Board): Hint | null {
const cs = consOf(b);
const p = pairHint(cs);
return p ? p : singleHint(cs);
}
function applyHint(b: Board, h: Hint, t0: number, delay: number): void {
if (h.kind === 1) {
for (const c of h.cells) { b.st[c] = 2; b.flT[c] = t0; }
} else {
const src: Array<{ i: number; d: number }> = [];
for (const c of h.cells) { src.push({ i: c, d: 0 }); }
revealFrom(b, src, t0, delay);
}
}
// ── 확률 ─────────────────────────────────────────────────
function comb(n: number, k: number): number {
if (k < 0 || k > n) { return 0; }
let r = 1;
for (let i = 1; i <= k; i += 1) { r = r * (n - k + i) / i; }
return r;
}
// 모든 닫힌 칸의 지뢰 확률(−1 은 닫힌 칸이 아님). 경계 칸(열린 숫자와 맞닿은 닫힌 칸)은 조건을 모두 만족하는 배치를
// 하나씩 세고, 경계 밖 칸은 남은 지뢰를 경계 배치마다 C(바깥 칸, 남은 지뢰)가지로 나눠 가진다
const PROB_NODE_CAP = 300000;
let probNodes = 0;
function probMap(b: Board): number[] {
const n = b.cols * b.rows;
const out: number[] = [];
const fi: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(-1); fi.push(-1); }
const F: number[] = [];
const cs = consOf(b);
for (const c of cs) {
for (const j of c.u) { if (fi[j] < 0) { fi[j] = F.length; F.push(j); } }
}
const cc: number[][] = [];
for (let k = 0; k < F.length; k += 1) { cc.push([]); }
cs.forEach(function (c: Cons, ci: number): void { for (const j of c.u) { cc[fi[j]].push(ci); } });
const got: number[] = [];
const left: number[] = [];
for (const c of cs) { got.push(0); left.push(c.u.length); }
const asg: number[] = [];
for (let k = 0; k < F.length; k += 1) { asg.push(0); }
const sol: number[] = [];
const cm: number[][] = [];
for (let m = 0; m <= F.length; m += 1) {
sol.push(0);
const row: number[] = [];
for (let k = 0; k < F.length; k += 1) { row.push(0); }
cm.push(row);
}
probNodes = 0;
function rec(k: number, mines: number): void {
probNodes += 1;
if (probNodes > PROB_NODE_CAP) { return; }
if (k === F.length) {
sol[mines] += 1;
for (let f = 0; f < F.length; f += 1) { if (asg[f] === 1) { cm[mines][f] += 1; } }
return;
}
for (let v = 0; v <= 1; v += 1) {
let ok = true;
for (const ci of cc[k]) {
got[ci] += v;
left[ci] -= 1;
if (got[ci] > cs[ci].r || got[ci] + left[ci] < cs[ci].r) { ok = false; }
}
if (ok) { asg[k] = v; rec(k + 1, mines + v); }
for (const ci of cc[k]) { got[ci] -= v; left[ci] += 1; }
}
}
rec(0, 0);
let hidden = 0;
let flags = 0;
const inner: number[] = [];
for (let i = 0; i < n; i += 1) {
if (b.st[i] === 0) { hidden += 1; if (fi[i] < 0) { inner.push(i); } }
if (b.st[i] === 2) { flags += 1; }
}
const M = b.count - flags;
let total = 0;
let innerMines = 0;
const pf: number[] = [];
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]; }
}
const fallback = hidden > 0 ? clamp(M / hidden, 0, 1) : 0;
for (let i = 0; i < n; i += 1) {
if (b.st[i] !== 0) { continue; }
if (total <= 0 || probNodes > PROB_NODE_CAP) { out[i] = fallback; } else if (fi[i] >= 0) { out[i] = pf[fi[i]] / total; } else { out[i] = innerMines / total / inner.length; }
}
return out;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
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);
}
function skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
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 seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(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; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 판 자리 — 열·행에 맞춰 칸 크기 gs 와 왼쪽 위 (gx, gy) 를 정한다. 위·아래 여백은 상태 줄·계량기 자리
let gx = 0;
let gy = 0;
let gs = 28;
let gCols = 15;
let gRows = 8;
function fitGeo(cols: number, rows: number, top: number, bottom: number): void {
gCols = cols;
gRows = rows;
gs = Math.min(Math.floor((W - 24) / cols), Math.floor((H - top - bottom) / rows), 32);
gx = (W - gs * cols) / 2;
gy = top + (H - top - bottom - gs * rows) / 2;
}
function cellX(i: number): number { return gx + (i % gCols) * gs; }
function cellY(i: number): number { return gy + Math.floor(i / gCols) * gs; }
function cellCx(i: number): number { return cellX(i) + gs / 2; }
function cellCy(i: number): number { return cellY(i) + gs / 2; }
function mineGlyph(x: number, y: number, r: number, c: RGB, a: number): void {
for (let k = 0; k < 8; k += 1) {
const an = k * TAU / 8;
seg(x + Math.cos(an) * r * 0.55, y + Math.sin(an) * r * 0.55, x + Math.cos(an) * r * 1.12, y + Math.sin(an) * r * 1.12, Math.max(1.4, r * 0.24), c, a);
}
disc(x, y, r * 0.78, c, a);
disc(x - r * 0.26, y - r * 0.26, r * 0.22, [255, 255, 255], a * 0.8);
}
function flagGlyph(x: number, y: number, s: number, a: number): void {
seg(x - s * 0.1, y - s * 0.34, x - s * 0.1, y + s * 0.34, Math.max(1.6, s * 0.08), INK, a);
ctx.beginPath();
ctx.moveTo(x - s * 0.1, y - s * 0.34);
ctx.lineTo(x + s * 0.3, y - s * 0.14);
ctx.lineTo(x - s * 0.1, y + s * 0.06);
ctx.closePath();
ctx.fillStyle = rgba(ROSE_DEEP, a);
ctx.fill();
box(x - s * 0.3, y + s * 0.32, s * 0.4, s * 0.1, s * 0.05, INK, a);
}
function numGlyph(n: number, x: number, y: number, s: number, a: number): void {
if (n <= 0) { return; }
text(String(n), x, y + 0.5, NUM_COL[n], a, Math.round(s * 0.62), 800, 'center');
}
function hiddenTile(x: number, y: number, s: number, a: number): void {
box(x + 1, y + 2, s - 2, s - 2, 4, TILE_LO, a);
box(x + 1, y + 1, s - 2, s - 3, 4, TILE, a);
box(x + 3, y + 2, s - 6, 2, 1, TILE_HI, a * 0.9);
}
// 판 그리기 — 상태(st·openT·exT·flT·xT)를 칸으로 옮길 뿐 규칙은 건드리지 않는다.
// off 는 칸을 밀어 놓는 값(충격파), tint 는 칸 위에 덮는 색, nums 가 거짓이면 열린 칸 숫자를 그리지 않는다
const OPEN_S = 0.28;
function drawCells(b: Board, off: ((i: number) => number[]) | null, tint: ((i: number) => RGBA | null) | null, nums: boolean): void {
for (let i = 0; i < b.cols * b.rows; i += 1) {
let x = cellX(i);
let y = cellY(i);
if (off) { const o = off(i); x += o[0]; y += o[1]; }
const open = b.st[i] === 1 && sceneT >= b.openT[i];
const p = b.st[i] === 1 ? clamp((sceneT - b.openT[i]) / OPEN_S, 0, 1) : 0;
const ex = b.exT[i] >= 0 && sceneT >= b.exT[i];
const cx = x + gs / 2;
const cy = y + gs / 2;
if (open) {
// 눌렸다 올라오는 칸 — 닫힌 색에서 크림색으로 바뀌며 폭이 최대 16% 줄었다 돌아온다
const sc = 1 - 0.16 * Math.sin(p * Math.PI);
const w = (gs - 2) * sc;
box(x + 1, y + 1, gs - 2, gs - 2, 4, OPEN_BED, 1);
box(cx - w / 2, cy - w / 2, w, w, 4, mix(TILE, OPEN, ease(p)), 1);
if (nums && !b.mine[i]) { numGlyph(b.num[i], cx, cy, gs, clamp((p - 0.4) / 0.6, 0, 1)); }
} else if (ex) {
const k = clamp((sceneT - b.exT[i]) / 0.2, 0, 1);
box(x + 1, y + 1, gs - 2, gs - 2, 4, OPEN_BED, 1);
disc(cx, cy, gs * 0.62, [96, 70, 92], 0.22 * k);
mineGlyph(cx, cy, gs * (0.22 + 0.12 * ease(k)), INK, 1);
} else {
hiddenTile(x, y, gs, 1);
}
if (b.st[i] === 2 && b.flT[i] >= 0 && sceneT >= b.flT[i]) {
const q = sceneT - b.flT[i];
const dy = -30 * Math.exp(-9 * q) * Math.cos(15 * q);
disc(cx, cy + gs * 0.36, gs * 0.2, INK, 0.16 * clamp(q * 8, 0, 1));
flagGlyph(cx, cy + dy, gs, clamp(q * 8, 0, 1));
}
if (tint) {
const tc = tint(i);
if (tc) { box(x + 1, y + 1, gs - 2, gs - 2, 4, [tc[0], tc[1], tc[2]], tc[3]); }
}
if (b.xT[i] >= 0 && sceneT >= b.xT[i]) {
const k = clamp((sceneT - b.xT[i]) / 0.2, 0, 1);
const r = gs * 0.3 * ease(k);
seg(cx - r, cy - r, cx + r, cy + r, 3, ROSE_DEEP, 1);
seg(cx - r, cy + r, cx + r, cy - r, 3, ROSE_DEEP, 1);
}
}
}
// 사람이 칸을 직접 누를 때 쓰는 기본 동작 — 장면마다 쓰는 규칙(첫 클릭 안전·번져 열기·코드 열기·지뢰 터짐)은 위 판 로직 그대로다
function loseBoard(b: Board, i: number): void {
const ix = i % b.cols;
const iy = Math.floor(i / b.cols);
for (let j = 0; j < b.cols * b.rows; j += 1) {
if (!b.mine[j] || b.exT[j] >= 0) { continue; }
const d = Math.hypot((j % b.cols) - ix, Math.floor(j / b.cols) - iy);
b.exT[j] = sceneT + d * 0.05;
}
b.exT[i] = sceneT;
}
function manualAct(b: Board, seed: number, i: number, delay: number): void {
if (b.st[i] === 2) { return; }
if (!b.placed) { placeMines(b, seed, i); }
if (b.st[i] === 1) {
const list = chordList(b, i);
const src: Array<{ i: number; d: number }> = [];
list.forEach(function (c: number, k: number): void {
if (b.mine[c]) { loseBoard(b, c); } else { src.push({ i: c, d: k }); }
});
revealFrom(b, src, sceneT, delay);
return;
}
if (b.mine[i]) { loseBoard(b, i); return; }
revealFrom(b, [{ i: i, d: 0 }], sceneT, delay);
}
function manualFlag(b: Board, i: number): void {
if (b.st[i] === 0) { b.st[i] = 2; b.flT[i] = sceneT; } else if (b.st[i] === 2) { b.st[i] = 0; b.flT[i] = -1; }
}
// 정지 장면 한 장 — 열린 판을 보여 주는 시각(초)과 첫 판이 앞서 시작하는 시각(초)은 장면 상수 START_S·REST_S
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
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();
// 사람이 직접 하는 중이면 시간표는 멈추고 애니메이션만 흐른다
let manual = false;
let curOn = false;
let kx = 0;
let ky = 0;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
function takeOver(): void {
if (manual) { return; }
manual = true;
onTake();
wake();
if (!curOn) { curOn = true; kx = Math.floor(gCols / 2); ky = Math.floor(gRows / 2); }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 판이 이미 진행 중인 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) { runTo(START_S); }
}
const DT_MAX = 0.05;
const SETTLE_FRAMES = 8;
const SETTLE_DT = 0.04;
function drawCursor(): void {
if (!curOn) { return; }
const i = ky * gCols + kx;
outline(cellX(i) + 1, cellY(i) + 1, gs - 2, gs - 2, 5, 3, INK, 1);
outline(cellX(i) - 1, cellY(i) - 1, gs + 2, gs + 2, 6, 1.5, PAPER, 0.9);
}
function showNow(): void {
draw();
drawCursor();
pushStatus(statusText());
checkDone();
}
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); }
showNow();
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();
lap = 0;
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
manual = false;
doneSaid = false;
lap += 1;
begin();
for (let k = 0; k < SETTLE_FRAMES; k += 1) { update(SETTLE_DT); }
showNow();
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (manual || evAt >= script.length) {
restart();
return;
}
sceneT = Math.max(sceneT, script[evAt].t);
advance(0);
for (let k = 0; k < SETTLE_FRAMES; k += 1) { sceneT += SETTLE_DT; update(SETTLE_DT); }
showNow();
}
function moveCur(dx: number, dy: number): void {
kx = clamp(kx + dx, 0, gCols - 1);
ky = clamp(ky + dy, 0, gRows - 1);
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 칸 이동, Enter·Space 는 칸 열기, F 는 깃발, P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
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();
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 누른 칸 열기, 오른쪽 버튼은 깃발
function cellFromEvent(e: MouseEvent): number {
const rect = cv.getBoundingClientRect();
const px = (e.clientX - rect.left) / rect.width * W;
const py = (e.clientY - rect.top) / rect.height * H;
const cx = Math.floor((px - gx) / gs);
const cy = Math.floor((py - gy) / gs);
if (cx < 0 || cx >= gCols || cy < 0 || cy >= gRows) { return -1; }
return cy * gCols + cx;
}
pad.addEventListener('click', function (e: MouseEvent): void {
const c = cellFromEvent(e);
if (c < 0) { return; }
takeOver();
kx = c % gCols;
ky = Math.floor(c / gCols);
onAct(c);
showNow();
}, { signal: off.signal });
pad.addEventListener('contextmenu', function (e: MouseEvent): void {
const c = cellFromEvent(e);
if (c < 0) { return; }
e.preventDefault();
takeOver();
kx = c % gCols;
ky = Math.floor(c / gCols);
onFlag(c);
showNow();
}, { 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 { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 03 여덟 이웃의 지뢰를 세어 숫자를 채우는 스캔 ────────────────────────────
const COLS = 16;
const ROWS = 8;
const MINES = 22;
const SEED = 4943;
// 스캔 속도(칸/초) — 가로로 훑으며 한 칸에 1/SCAN_RATE 초를 쓴다
const SCAN_RATE = 32;
const SCAN_START = 0.3;
const LAP_S = 6.6;
const START_S = 0.0;
const REST_S = 5.8;
let B: Board = newBoard(COLS, ROWS, MINES);
let probed: boolean[] = [];
function build(): void {
fitGeo(COLS, ROWS, 44, 14);
B = newBoard(COLS, ROWS, MINES);
placeMines(B, SEED + (lap % 2) * 29, -1);
probed = [];
for (let i = 0; i < COLS * ROWS; i += 1) { probed.push(false); }
}
function update(dt: number): void { if (dt < 0) { return; } }
// 지금 스캔 틀이 서 있는 칸(−1 은 아직 시작 전, 칸 수 이상이면 끝)
function scanAt(): number { return Math.floor((sceneT - SCAN_START) * SCAN_RATE); }
function bornT(i: number): number { return SCAN_START + i / SCAN_RATE; }
function draw(): void {
skyFill(BG_TOP, BG_BOT);
const n = COLS * ROWS;
const cur = scanAt();
for (let i = 0; i < n; i += 1) {
const x = cellX(i);
const y = cellY(i);
const age = manual ? (probed[i] ? 1 : -1) : sceneT - bornT(i);
box(x + 1, y + 1, gs - 2, gs - 2, 4, OPEN_BED, 1);
box(x + 2, y + 2, gs - 4, gs - 4, 3, OPEN, 1);
if (B.mine[i]) { mineGlyph(cellCx(i), cellCy(i), gs * 0.22, INK, 0.55); }
if (age < 0) { continue; }
// 센 값이 위에서 떠올라 자리를 잡는다 — 숫자 색이 칸에 옅게 번져 분포가 보인다
const k = ease(age / 0.3);
if (!B.mine[i] && B.num[i] > 0) {
const c = NUM_COL[B.num[i]];
box(x + 2, y + 2, gs - 4, gs - 4, 3, c, 0.17 * k);
numGlyph(B.num[i], cellCx(i), cellCy(i) - (1 - k) * gs * 0.45, gs * (1 + 0.35 * (1 - k)), k);
} else if (!B.mine[i]) {
box(x + 2, y + 2, gs - 4, gs - 4, 3, MINT, 0.22 * k);
}
if (age < 0.5 && !B.mine[i]) { box(x + 2, y + 2, gs - 4, gs - 4, 3, BUTTER, 0.55 * (1 - age / 0.5)); }
}
if (!manual && cur >= 0 && cur < n) {
const x = cellX(cur);
const y = cellY(cur);
// 스캔 틀 — 센 칸을 가운데 둔 3×3. 틀 안의 지뢰 이웃은 분홍 고리로 불려 나온다
outline(x - gs + 1, y - gs + 1, gs * 3 - 2, gs * 3 - 2, 6, 2.5, INK, 0.9);
for (const j of nbrs(B, cur)) { if (B.mine[j]) { ring(cellCx(j), cellCy(j), gs * 0.4, 2.5, ROSE_DEEP, 0.95); } }
outline(x + 1, y + 1, gs - 2, gs - 2, 4, 2, WATER, 1);
}
}
function statusText(): string {
let seen = 0;
if (manual) { for (let i = 0; i < probed.length; i += 1) { if (probed[i]) { seen += 1; } } } else { seen = clamp(scanAt() + 1, 0, COLS * ROWS); }
const s = String(seen);
if (!manual && seen >= COLS * ROWS) { return '스캔 완료 — {s}칸 · 지뢰 {m}개'.replace('{s}', s).replace('{m}', String(MINES)); }
return '스캔 {s}/{t}칸'.replace('{s}', s).replace('{t}', String(COLS * ROWS));
}
// 직접 하면 눌린 칸 하나를 바로 센다
function onAct(i: number): void { probed[i] = true; }
function onFlag(i: number): void { probed[i] = true; }
// 직접 하는 순간 — 스캔이 지나온 칸은 그대로 남기고 틀은 거둔다
function onTake(): void {
for (let i = 0; i < COLS * ROWS; i += 1) { probed[i] = i <= scanAt(); }
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'지뢰찾기 시연 무대 — 방향키로 칸 이동, Enter 로 열기, F 로 깃발, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'지뢰 22개가 놓인 16×8 판을 3×3 스캔 틀이 한 칸씩 훑으며 여덟 이웃의 지뢰를 세고, 센 값이 칸 위로 떠올라 숫자로 자리 잡는다. 지뢰 이웃은 분홍 고리로 표시되고 숫자마다 칸에 번지는 색이 다르다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'지뢰찾기 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}03 A Scan That Counts the Eight Neighbors to Fill In the Numbers — Measured area 34.759% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Minesweeper JavaScript: 9 Minefield Scenes
The post this demo lives in
Minesweeper JavaScript: 9 Minefield ScenesA first click on a mine ruins the board, so 02 lays mines only after the reveal, 05 overlaps two numbers to find the cer See all demos in this post
If you like this effect - similar demos
game07 An Auto Player That Counts the Tiles Yet to Fall2048 Game JavaScript: 9 Boards from Merges to AI
game07 A Race Meter That Pits Four Sorts on the Same Array by Comparisons and MovesSorting Algorithm Visualizer: 9 Sort Scenes
game01 Paddle That Trails the Dragged PositionBrick Breaker Game JavaScript — 9 Physics Patterns