Minesweeper JavaScript: 9 Minefield Scenes
A first click on a mine ruins the board, so 02 lays mines only after the reveal, 05 overlaps two numbers to find the certain cells, and 06 paints each
Minesweeper Is Understood Through Counting, Not Rules: Building Minesweeper in JavaScript Starts With Counting the Eight Neighbors of One Cell
01 msw-flood-open-ripple
02 msw-first-click-safe-seed
03 msw-neighbor-count-scan
04 msw-chord-open-neighbors
05 msw-constraint-solver-hint
06 msw-probability-heat-map
07 msw-mine-chain-shockwave
08 msw-flag-stamp-counter
09 msw-hero-sonar-minefield
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 A Flood Open That Spreads Like a Ripple From One Empty Cell
- 02 A Safe First Move That Lays the Mines After the First Reveal
- 03 A Scan That Counts the Eight Neighbors to Fill In the Numbers
- 04 A Chord Open That Reveals All Neighbors When Flags Match the Number
- 05 A Deduction Hint That Overlaps Neighbor Constraints to Find Certain Cells
- 06 A Probability Map That Paints Each Cell's Mine Chance as Color Depth When Nothing Is Certain
- 07 A Shockwave Where a Stepped Mine Sets Off Every Mine on the Board in Sequence
- 08 A Flag Counter Where Flags Stamp In on a Spring and the Remaining-Mine Count Rolls Down
- 09 A Minefield Hero Where a Sonar Sweep Reveals the Numbers Under the Cells
The nine scenes share one structure. Mine placement, numbers, opened cells and flags are values the board logic computes, and the screen only compares them with a scene clock, so what you watch is a computed board, not a staged effect. No library or CDN is used and the one random source is a seeded generator, so the same seed builds the same board.
Why the board logic and the canvas are split
The board is a bundle of arrays built by newBoard. placeMines, calcNums, revealFrom, chordList, pairHint and probMap only read and write those arrays, while drawCells compares each cell's open, blast and flag times with the scene clock sceneT. Each of the nine files carries the same board functions, so changing the look leaves the minesweeper rules untouched.
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;
}
A round runs on a timetable
Each scene fills script with (time, action) pairs, and advance(dt) calls each action once its time has passed. When a round of LAP_S seconds ends, the seed switches by lap % 2 so two boards alternate, and the first round starts START_S seconds ahead so the first frame already shows a scene in progress.
The layer of calculation each scene adds
01 delays the spread by distance, 02 clears the area around the first click and relocates mines, 03 counts neighbors in a scan, and 04 adds a flag-count condition. 05 compares the closed-neighbor sets of two numbers, 06 enumerates arrangements with combination weights, 07 ties blast time to distance and drives a shock field, 08 recovers the remaining mines from the flag count, and 09 fades brightness by the angle a sonar line has passed.
Minesweeper JavaScript: 9 Scenes — a Flood Open That Spreads, a Safe First Move That Builds the Board Late, a Neighbor-Count Scan, a Chord Open, a Deduction Hint That Overlaps Constraints, a Probability Map, a Chain Shockwave, a Spring Flag Counter and a Sonar Hero
What nine renders measured for area, frames and intensity
The rows below come from the last full render of the nine demos.
| Scene | Cumulative area | Moving frames | Mean intensity |
|---|---|---|---|
| 01 Flood open | 24.9% | 16/23 | 31.3 |
| 02 Safe first move | 34.6% | 20/23 | 58.1 |
| 03 Number scan | 34.8% | 23/23 | 71.3 |
| 04 Chord open | 6.3% | 13/23 | 38.9 |
| 05 Deduction hint | 20.1% | 15/23 | 56.5 |
| 06 Probability map | 30.2% | 14/23 | 50.6 |
| 07 Shockwave | 63.4% | 22/23 | 35.3 |
| 08 Flag counter | 4.6% | 22/23 | 78.1 |
| 09 Sonar hero | 38.9% | 23/23 | 48.4 |
07 shakes the whole board and covers the most area at 63.4%, while 08 changes little more than a digit, covers 4.6% and still has the highest mean intensity at 78.1. Only 03 and 09 moved in every frame, and 04 moved in 13 of 23 because the board waits between flags and the chord.
01A Flood Open That Spreads Like a Ripple From One Empty Cell
On the first board of a minesweeper learning site, one empty cell opens and its empty neighbors follow in order of distance, stopping at numbered cells. revealFrom pulls the nearest cell first and writes openT = t0 + d × RIP_DELAY, queuing neighbors only when the opened cell is 0. The first click at 0.5s opens 57 cells and the farthest sits at distance 14, so the spread ends at 1.4s.

<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="On a 16 by 8 board, opening one empty cell with number 0 opens its empty neighbors in order of distance, 0.1 seconds later per step, so the board opens like a ripple and the spread stops at numbered cells. Opening cells press in and rise back while a mint ring spreads from the start cell. Three clicks open two spreads and one numbered cell"></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 });
// ── 01 빈 칸에서 물결처럼 번지는 연쇄 열기 ───────────────────────────
// 판 크기와 지뢰 수 — 밀도 16/128 = 12.5%
const COLS = 16;
const ROWS = 8;
const MINES = 16;
// 번짐 한 칸 거리마다 늘어나는 지연(초) — 거리가 d 인 칸은 d × RIP_DELAY 초 뒤에 열린다
const RIP_DELAY = 0.1;
const SEED = 4941;
const LAP_S = 9.6;
const START_S = 0.25;
const REST_S = 5.2;
// 첫 번짐은 이 크기 이상으로 퍼지는 빈 칸에서 시작하도록 시드를 고른다
const MIN_FLOOD = 44;
let B = newBoard(COLS, ROWS, MINES);
let waves = [];
// 닫힌 빈 칸(숫자 0) 가운데 번짐이 가장 큰 칸
function bestZero(b) {
let best = -1;
let bs = 0;
for (let i = 0; i < b.cols * b.rows; i += 1) {
if (b.st[i] !== 0 || b.mine[i] || b.num[i] !== 0) {
continue;
}
const s = floodSize(b, i);
if (s > bs) {
bs = s;
best = i;
}
}
return best;
}
// 열린 칸과 맞닿은 닫힌 숫자 칸 하나 — 번짐이 멈추는 자리라 혼자만 열린다
function edgeNumber(b) {
for (let i = 0; i < b.cols * b.rows; i += 1) {
if (b.st[i] !== 0 || b.mine[i] || b.num[i] === 0) {
continue;
}
for (const j of nbrs(b, i)) {
if (b.st[j] === 1) {
return i;
}
}
}
return -1;
}
function openAt(i) {
if (i < 0) {
return;
}
revealFrom(B, [{ i: i, d: 0 }], sceneT, RIP_DELAY);
waves.push({ t: sceneT, i: i });
}
function build() {
fitGeo(COLS, ROWS, 44, 14);
waves = [];
const base = SEED + (lap % 2) * 131;
for (let k = 0; k < 80; k += 1) {
B = newBoard(COLS, ROWS, MINES);
placeMines(B, base + k, -1);
const z = bestZero(B);
if (z >= 0 && floodSize(B, z) >= MIN_FLOOD) {
break;
}
}
script.push({ t: 0.5, f: function () { openAt(bestZero(B)); } });
script.push({ t: 3.9, f: function () {
const z = bestZero(B);
openAt(z >= 0 && floodSize(B, z) >= 3 ? z : edgeNumber(B));
} });
script.push({ t: 6.6, f: function () { openAt(edgeNumber(B)); } });
}
function update(dt) { if (dt < 0) {
return;
} }
function tintOf(i) {
if (B.st[i] !== 1) {
return null;
}
const p = (sceneT - B.openT[i]) / OPEN_S;
if (p <= 0 || p >= 1) {
return null;
}
return [MINT[0], MINT[1], MINT[2], 0.55 * (1 - p)];
}
function draw() {
skyFill(BG_TOP, BG_BOT);
drawCells(B, null, tintOf, true);
ctx.save();
ctx.beginPath();
ctx.rect(gx, gy, gs * COLS, gs * ROWS);
ctx.clip();
for (const w of waves) {
const age = sceneT - w.t;
if (age < 0 || age > 2.4) {
continue;
}
const r = age / RIP_DELAY * gs;
ring(cellCx(w.i), cellCy(w.i), r, 3, MINT, 0.7 * (1 - age / 2.4));
ring(cellCx(w.i), cellCy(w.i), Math.max(0, r - gs * 2.2), 2, WATER, 0.5 * (1 - age / 2.4));
}
ctx.restore();
}
function statusText() {
const o = String(countOpen(B));
if (sceneT >= LAP_S - 0.6 && !manual) {
return 'Flood open done - {o} cells open - {m} mines'.replace('{o}', o).replace('{m}', String(MINES));
}
return '{o} cells open - {m} mines'.replace('{o}', o).replace('{m}', String(MINES));
}
function onAct(i) {
manualAct(B, SEED, i, RIP_DELAY);
waves.push({ t: sceneT, i: i });
}
function onFlag(i) { manualFlag(B, i); }
function onTake() { return; }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// FloodOpenRipple - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./FloodOpenRipple.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function FloodOpenRipple() {
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 });
// ── 01 빈 칸에서 물결처럼 번지는 연쇄 열기 ───────────────────────────
// 판 크기와 지뢰 수 — 밀도 16/128 = 12.5%
const COLS = 16;
const ROWS = 8;
const MINES = 16;
// 번짐 한 칸 거리마다 늘어나는 지연(초) — 거리가 d 인 칸은 d × RIP_DELAY 초 뒤에 열린다
const RIP_DELAY = 0.1;
const SEED = 4941;
const LAP_S = 9.6;
const START_S = 0.25;
const REST_S = 5.2;
// 첫 번짐은 이 크기 이상으로 퍼지는 빈 칸에서 시작하도록 시드를 고른다
const MIN_FLOOD = 44;
let B: Board = newBoard(COLS, ROWS, MINES);
let waves: Array<{ t: number; i: number }> = [];
// 닫힌 빈 칸(숫자 0) 가운데 번짐이 가장 큰 칸
function bestZero(b: Board): number {
let best = -1;
let bs = 0;
for (let i = 0; i < b.cols * b.rows; i += 1) {
if (b.st[i] !== 0 || b.mine[i] || b.num[i] !== 0) { continue; }
const s = floodSize(b, i);
if (s > bs) { bs = s; best = i; }
}
return best;
}
// 열린 칸과 맞닿은 닫힌 숫자 칸 하나 — 번짐이 멈추는 자리라 혼자만 열린다
function edgeNumber(b: Board): number {
for (let i = 0; i < b.cols * b.rows; i += 1) {
if (b.st[i] !== 0 || b.mine[i] || b.num[i] === 0) { continue; }
for (const j of nbrs(b, i)) { if (b.st[j] === 1) { return i; } }
}
return -1;
}
function openAt(i: number): void {
if (i < 0) { return; }
revealFrom(B, [{ i: i, d: 0 }], sceneT, RIP_DELAY);
waves.push({ t: sceneT, i: i });
}
function build(): void {
fitGeo(COLS, ROWS, 44, 14);
waves = [];
const base = SEED + (lap % 2) * 131;
for (let k = 0; k < 80; k += 1) {
B = newBoard(COLS, ROWS, MINES);
placeMines(B, base + k, -1);
const z = bestZero(B);
if (z >= 0 && floodSize(B, z) >= MIN_FLOOD) { break; }
}
script.push({ t: 0.5, f: function (): void { openAt(bestZero(B)); } });
script.push({ t: 3.9, f: function (): void {
const z = bestZero(B);
openAt(z >= 0 && floodSize(B, z) >= 3 ? z : edgeNumber(B));
} });
script.push({ t: 6.6, f: function (): void { openAt(edgeNumber(B)); } });
}
function update(dt: number): void { if (dt < 0) { return; } }
function tintOf(i: number): RGBA | null {
if (B.st[i] !== 1) { return null; }
const p = (sceneT - B.openT[i]) / OPEN_S;
if (p <= 0 || p >= 1) { return null; }
return [MINT[0], MINT[1], MINT[2], 0.55 * (1 - p)];
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
drawCells(B, null, tintOf, true);
ctx.save();
ctx.beginPath();
ctx.rect(gx, gy, gs * COLS, gs * ROWS);
ctx.clip();
for (const w of waves) {
const age = sceneT - w.t;
if (age < 0 || age > 2.4) { continue; }
const r = age / RIP_DELAY * gs;
ring(cellCx(w.i), cellCy(w.i), r, 3, MINT, 0.7 * (1 - age / 2.4));
ring(cellCx(w.i), cellCy(w.i), Math.max(0, r - gs * 2.2), 2, WATER, 0.5 * (1 - age / 2.4));
}
ctx.restore();
}
function statusText(): string {
const o = String(countOpen(B));
if (sceneT >= LAP_S - 0.6 && !manual) { return '연쇄 열기 완료 — 열림 {o}칸 · 지뢰 {m}개'.replace('{o}', o).replace('{m}', String(MINES)); }
return '열림 {o}칸 · 지뢰 {m}개'.replace('{o}', o).replace('{m}', String(MINES));
}
function onAct(i: number): void {
manualAct(B, SEED, i, RIP_DELAY);
waves.push({ t: sceneT, i: i });
}
function onFlag(i: number): void { manualFlag(B, i); }
function onTake(): void { return; }
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={'16×8 판에서 숫자 0 인 빈 칸 하나를 열면 이웃 빈 칸이 거리 순서대로 0.1초씩 늦게 열려 물결처럼 번지고, 숫자 칸에서 번짐이 멈춘다. 열리는 칸은 눌렸다 올라오며 시작 칸에서 민트색 고리가 퍼진다. 세 번 눌러 번짐 두 번과 숫자 칸 하나가 열린다'}></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>
);
}01 · A Flood Open That Spreads Like a Ripple From One Empty Cell — Measured area 24.889% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
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;
02A Safe First Move That Lays the Mines After the First Reveal
To explain how a first-click loss is removed, the point of this scene is that the board stays empty until the first cell opens. placeMines shuffles every cell with the seed and puts mines on the first count cells, then moves any mine in the first cell or its eight neighbors to a free cell later in the shuffled order and returns the [from, to] pairs. Cell 55 was a mine on the naive board, and once 3 mines move out of the zone it holds none and 77 cells open.

<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="First a board with mines placed up front, where the first click steps on a mine and fails. Then the same seed with the board left empty: the first cell and its eight neighbors become a no-mine zone, mines inside move to free cells, the numbers fill in and the board opens as a safe first move"></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 });
// ── 02 첫 수가 지뢰를 밟지 않게 판을 그 뒤에 짜는 안전 첫 수 ──────────────────
const COLS = 16;
const ROWS = 8;
const MINES = 22;
const SEED = 4942;
const LAP_S = 8.0;
const START_S = 2.2;
const REST_S = 6.5;
// 안전 구역 — 첫 칸과 이웃 여덟 칸(3×3). 열기 번짐 지연(초)
const OPEN_DELAY = 0.09;
// 장면 단계 — 0 Mines placed first · 1 첫 클릭이 지뢰 · 2 새 판 · 3 안전 구역 · 4 지뢰 옮기기 · 5 숫자 채우고 열기
let mode = 0;
let modeT = 0;
let B = newBoard(COLS, ROWS, MINES);
let naive = newBoard(COLS, ROWS, MINES);
let safe = newBoard(COLS, ROWS, MINES);
let moves = [];
let A = 0;
let clickT = -9;
function setMode(m) { mode = m; modeT = sceneT; }
function build() {
fitGeo(COLS, ROWS, 44, 22);
B = newBoard(COLS, ROWS, MINES);
naive = newBoard(COLS, ROWS, MINES);
safe = newBoard(COLS, ROWS, MINES);
const seed = SEED + (lap % 2) * 17;
placeMines(naive, seed, -1);
// 첫 클릭 칸 A — 먼저 놓은 판에서 가운데쯤에 있고 이웃 지뢰가 둘 이상인 지뢰(옮겨야 할 지뢰가 여럿 생긴다). 그 둘레 3×3 이 판 안에 다 들어온다
A = -1;
for (let i = 0; i < COLS * ROWS && A < 0; i += 1) {
const x = i % COLS;
const y = Math.floor(i / COLS);
if (naive.mine[i] && x >= 3 && x <= COLS - 4 && y >= 2 && y <= ROWS - 3 && nbrs(naive, i).filter(function (j) { return naive.mine[j]; }).length >= 2) {
A = i;
}
}
for (let i = 0; i < COLS * ROWS && A < 0; i += 1) {
if (naive.mine[i] && i % COLS >= 1 && i % COLS <= COLS - 2 && i >= COLS && i < COLS * (ROWS - 1)) {
A = i;
}
}
moves = placeMines(safe, seed, A);
mode = 0;
modeT = 0;
clickT = -9;
script.push({ t: 0.7, f: function () { clickT = sceneT; } });
script.push({ t: 1.0, f: function () { setMode(1); B.exT[A] = sceneT; } });
script.push({ t: 2.0, f: function () { setMode(2); B.exT[A] = -1; } });
script.push({ t: 2.2, f: function () { clickT = sceneT; } });
script.push({ t: 2.4, f: function () { setMode(3); } });
script.push({ t: 2.9, f: function () { setMode(4); } });
script.push({ t: 3.7, f: function () {
setMode(5);
B.mine = safe.mine.slice();
B.num = safe.num.slice();
B.placed = true;
revealFrom(B, [{ i: A, d: 0 }], sceneT, OPEN_DELAY);
} });
}
function update(dt) { if (dt < 0) {
return;
} }
function inZone(i) { return i === A || nbrs(B, A).indexOf(i) >= 0; }
function tintOf(i) {
if (mode === 3 || mode === 4) {
if (!inZone(i)) {
return null;
}
const k = mode === 3 ? ease((sceneT - modeT) / 0.3) : 1;
return [MINT[0], MINT[1], MINT[2], 0.55 * k * (0.75 + 0.25 * Math.sin((sceneT - modeT) * 9))];
}
if (mode === 1 && i === A) {
return [ROSE[0], ROSE[1], ROSE[2], 0.5];
}
return null;
}
// 숨은 지뢰를 비춰 보는 점 — 닫힌 칸 위에 어둡게 찍는다
function dot(i, a) { disc(cellCx(i), cellCy(i), gs * 0.2, INK, a); }
function draw() {
skyFill(BG_TOP, BG_BOT);
drawCells(B, null, tintOf, true);
const n = COLS * ROWS;
if (mode <= 1) {
for (let i = 0; i < n; i += 1) {
if (naive.mine[i]) {
dot(i, 0.45 * ease((sceneT - 0.1) / 0.4));
}
}
}
else if (mode === 3) {
for (let i = 0; i < n; i += 1) {
if (naive.mine[i]) {
dot(i, 0.45);
}
}
}
else if (mode === 4) {
// 안전 구역 안의 지뢰는 섞인 순서 뒤쪽 빈 칸으로 옮겨 가고, 나머지는 그대로 남는다
const k = ease((sceneT - modeT) / 0.7);
const fromList = [];
for (const m of moves) {
fromList.push(m[0]);
}
for (let i = 0; i < n; i += 1) {
if (naive.mine[i] && fromList.indexOf(i) < 0) {
dot(i, 0.45);
}
}
for (const m of moves) {
const x = lerp(cellCx(m[0]), cellCx(m[1]), k);
const y = lerp(cellCy(m[0]), cellCy(m[1]), k) - Math.sin(k * Math.PI) * gs * 1.1;
disc(x, y, gs * 0.2, ROSE_DEEP, 0.9);
}
}
else if (mode === 5) {
const k = 1 - ease((sceneT - modeT) / 0.6);
for (let i = 0; i < n; i += 1) {
if (safe.mine[i] && B.st[i] === 0) {
dot(i, 0.45 * k);
}
}
}
// 첫 클릭 표시 — 눌린 칸에서 퍼지는 고리
const age = sceneT - clickT;
if (age >= 0 && age < 0.7) {
ring(cellCx(A), cellCy(A), gs * (0.3 + age * 2.2), 3, INK, 0.8 * (1 - age / 0.7));
}
let cap = '';
if (mode <= 1) {
cap = 'Mines placed first';
}
else if (mode === 2) {
cap = 'The board is empty';
}
else if (mode === 3) {
cap = 'No mines on the first cell or its 8 neighbors';
}
else if (mode === 4) {
cap = 'Mines in the zone move to free cells';
}
else {
cap = 'Then fill in the numbers and open';
}
tag(cap, W / 2, H - 14, mode <= 1 ? ROSE : MINT, INK, 12, 'center');
}
function statusText() {
if (mode <= 1 && sceneT >= 1.0) {
return 'First click hit a mine - board failed';
}
if (mode >= 5) {
const o = String(countOpen(B));
return (sceneT >= LAP_S - 0.6 && !manual ? 'Safe first move done - {o} cells open - {m} mines' : '{o} cells open - {m} mines').replace('{o}', o).replace('{m}', String(MINES));
}
return '{m} mines - waiting for the first click'.replace('{m}', String(MINES));
}
function onAct(i) {
// 직접 하면 판이 아직 비어 있어도 그 칸 둘레가 안전 구역이다
if (!B.placed) {
A = i;
clickT = sceneT;
mode = 5;
modeT = sceneT;
placeMines(B, SEED, i);
safe = cloneBoard(B);
}
manualAct(B, SEED, i, OPEN_DELAY);
}
function onFlag(i) { manualFlag(B, i); }
function onTake() { return; }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// FirstClickSafeSeed - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./FirstClickSafeSeed.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function FirstClickSafeSeed() {
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 });
// ── 02 첫 수가 지뢰를 밟지 않게 판을 그 뒤에 짜는 안전 첫 수 ──────────────────
const COLS = 16;
const ROWS = 8;
const MINES = 22;
const SEED = 4942;
const LAP_S = 8.0;
const START_S = 2.2;
const REST_S = 6.5;
// 안전 구역 — 첫 칸과 이웃 여덟 칸(3×3). 열기 번짐 지연(초)
const OPEN_DELAY = 0.09;
// 장면 단계 — 0 지뢰를 먼저 놓은 판 · 1 첫 클릭이 지뢰 · 2 새 판 · 3 안전 구역 · 4 지뢰 옮기기 · 5 숫자 채우고 열기
let mode = 0;
let modeT = 0;
let B: Board = newBoard(COLS, ROWS, MINES);
let naive: Board = newBoard(COLS, ROWS, MINES);
let safe: Board = newBoard(COLS, ROWS, MINES);
let moves: number[][] = [];
let A = 0;
let clickT = -9;
function setMode(m: number): void { mode = m; modeT = sceneT; }
function build(): void {
fitGeo(COLS, ROWS, 44, 22);
B = newBoard(COLS, ROWS, MINES);
naive = newBoard(COLS, ROWS, MINES);
safe = newBoard(COLS, ROWS, MINES);
const seed = SEED + (lap % 2) * 17;
placeMines(naive, seed, -1);
// 첫 클릭 칸 A — 먼저 놓은 판에서 가운데쯤에 있고 이웃 지뢰가 둘 이상인 지뢰(옮겨야 할 지뢰가 여럿 생긴다). 그 둘레 3×3 이 판 안에 다 들어온다
A = -1;
for (let i = 0; i < COLS * ROWS && A < 0; i += 1) {
const x = i % COLS;
const y = Math.floor(i / COLS);
if (naive.mine[i] && x >= 3 && x <= COLS - 4 && y >= 2 && y <= ROWS - 3 && nbrs(naive, i).filter(function (j: number): boolean { return naive.mine[j]; }).length >= 2) { A = i; }
}
for (let i = 0; i < COLS * ROWS && A < 0; i += 1) { if (naive.mine[i] && i % COLS >= 1 && i % COLS <= COLS - 2 && i >= COLS && i < COLS * (ROWS - 1)) { A = i; } }
moves = placeMines(safe, seed, A);
mode = 0;
modeT = 0;
clickT = -9;
script.push({ t: 0.7, f: function (): void { clickT = sceneT; } });
script.push({ t: 1.0, f: function (): void { setMode(1); B.exT[A] = sceneT; } });
script.push({ t: 2.0, f: function (): void { setMode(2); B.exT[A] = -1; } });
script.push({ t: 2.2, f: function (): void { clickT = sceneT; } });
script.push({ t: 2.4, f: function (): void { setMode(3); } });
script.push({ t: 2.9, f: function (): void { setMode(4); } });
script.push({ t: 3.7, f: function (): void {
setMode(5);
B.mine = safe.mine.slice();
B.num = safe.num.slice();
B.placed = true;
revealFrom(B, [{ i: A, d: 0 }], sceneT, OPEN_DELAY);
} });
}
function update(dt: number): void { if (dt < 0) { return; } }
function inZone(i: number): boolean { return i === A || nbrs(B, A).indexOf(i) >= 0; }
function tintOf(i: number): RGBA | null {
if (mode === 3 || mode === 4) {
if (!inZone(i)) { return null; }
const k = mode === 3 ? ease((sceneT - modeT) / 0.3) : 1;
return [MINT[0], MINT[1], MINT[2], 0.55 * k * (0.75 + 0.25 * Math.sin((sceneT - modeT) * 9))];
}
if (mode === 1 && i === A) { return [ROSE[0], ROSE[1], ROSE[2], 0.5]; }
return null;
}
// 숨은 지뢰를 비춰 보는 점 — 닫힌 칸 위에 어둡게 찍는다
function dot(i: number, a: number): void { disc(cellCx(i), cellCy(i), gs * 0.2, INK, a); }
function draw(): void {
skyFill(BG_TOP, BG_BOT);
drawCells(B, null, tintOf, true);
const n = COLS * ROWS;
if (mode <= 1) {
for (let i = 0; i < n; i += 1) { if (naive.mine[i]) { dot(i, 0.45 * ease((sceneT - 0.1) / 0.4)); } }
} else if (mode === 3) {
for (let i = 0; i < n; i += 1) { if (naive.mine[i]) { dot(i, 0.45); } }
} else if (mode === 4) {
// 안전 구역 안의 지뢰는 섞인 순서 뒤쪽 빈 칸으로 옮겨 가고, 나머지는 그대로 남는다
const k = ease((sceneT - modeT) / 0.7);
const fromList: number[] = [];
for (const m of moves) { fromList.push(m[0]); }
for (let i = 0; i < n; i += 1) { if (naive.mine[i] && fromList.indexOf(i) < 0) { dot(i, 0.45); } }
for (const m of moves) {
const x = lerp(cellCx(m[0]), cellCx(m[1]), k);
const y = lerp(cellCy(m[0]), cellCy(m[1]), k) - Math.sin(k * Math.PI) * gs * 1.1;
disc(x, y, gs * 0.2, ROSE_DEEP, 0.9);
}
} else if (mode === 5) {
const k = 1 - ease((sceneT - modeT) / 0.6);
for (let i = 0; i < n; i += 1) { if (safe.mine[i] && B.st[i] === 0) { dot(i, 0.45 * k); } }
}
// 첫 클릭 표시 — 눌린 칸에서 퍼지는 고리
const age = sceneT - clickT;
if (age >= 0 && age < 0.7) { ring(cellCx(A), cellCy(A), gs * (0.3 + age * 2.2), 3, INK, 0.8 * (1 - age / 0.7)); }
let cap = '';
if (mode <= 1) { cap = '지뢰를 먼저 놓은 판'; } else if (mode === 2) { cap = '판이 비어 있다'; } else if (mode === 3) { cap = '첫 칸과 이웃 8칸은 지뢰 금지'; } else if (mode === 4) { cap = '구역 안 지뢰는 빈 칸으로 이사'; } else { cap = '그 뒤에 숫자를 채우고 연다'; }
tag(cap, W / 2, H - 14, mode <= 1 ? ROSE : MINT, INK, 12, 'center');
}
function statusText(): string {
if (mode <= 1 && sceneT >= 1.0) { return '첫 클릭이 지뢰 — 판 실패'; }
if (mode >= 5) {
const o = String(countOpen(B));
return (sceneT >= LAP_S - 0.6 && !manual ? '안전 첫 수 완료 — 열림 {o}칸 · 지뢰 {m}개' : '열림 {o}칸 · 지뢰 {m}개').replace('{o}', o).replace('{m}', String(MINES));
}
return '지뢰 {m}개 · 첫 클릭을 기다리는 중'.replace('{m}', String(MINES));
}
function onAct(i: number): void {
// 직접 하면 판이 아직 비어 있어도 그 칸 둘레가 안전 구역이다
if (!B.placed) { A = i; clickT = sceneT; mode = 5; modeT = sceneT; placeMines(B, SEED, i); safe = cloneBoard(B); }
manualAct(B, SEED, i, OPEN_DELAY);
}
function onFlag(i: number): void { manualFlag(B, i); }
function onTake(): void { return; }
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={'지뢰를 먼저 놓은 판에서 첫 클릭이 지뢰를 밟아 실패하는 장면과, 같은 시드로 판을 비워 두었다가 첫 칸과 이웃 여덟 칸을 지뢰 금지 구역으로 잡고 그 안의 지뢰를 빈 칸으로 옮긴 뒤 숫자를 채워 여는 안전 첫 수를 이어서 보여 준다'}></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>
);
}02 · A Safe First Move That Lays the Mines After the First Reveal — Measured area 34.646% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
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;
}
03A Scan That Counts the Eight Neighbors to Fill In the Numbers
A rule explainer that shows how numbers are made fits a scan frame sweeping cell by cell. calcNums uses nbrs to pick the neighbors inside the board and count their mines, and the scene sets when each count floats up with bornT(i) = SCAN_START + i / SCAN_RATE. Scanning 128 cells at 32 per second ends at 4.3s, and the 106 non-mine cells hold 28 zeros, 43 ones, 24 twos, 7 threes and 4 fours.

<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 · See the password
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;
}
}
04A Chord Open That Reveals All Neighbors When Flags Match the Number
In a guide to the expert shortcut, the key condition is that neighbors open only when the flag count equals the number. chordList returns the closed neighbors only for an opened, nonzero number whose flagsAround equals it, and doChord blows any mine in that list through exT and lights a cross on a wrong flag. The first round plants two flags at 0.9s and 1.35s and opens the neighbors at 2.15s, while the wrong-flag round chords at 6.6s into a mine with 61 cells open.

<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 flag drops onto each mine neighbor of an open numbered cell, and when the flag count equals the number the remaining closed neighbors open at once. Then a second number with a flag on the wrong cell follows the same rule, opens a mine and blows it, and a cross lights on the wrong flag"></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 });
// ── 04 숫자와 깃발 수가 같으면 이웃을 한 번에 여는 코드 열기 ──────────────────
const COLS = 14;
const ROWS = 8;
const MINES = 20;
const SEED = 4944;
const LAP_S = 9.8;
const START_S = 1.2;
const REST_S = 8.8;
// 코드 열기로 열리는 이웃이 한 칸씩 늦어지는 시간(초) — 순서는 위에서 아래, 왼쪽에서 오른쪽
const CHORD_DELAY = 0.07;
// 깃발 한 개가 내려오는 간격(초)
const FLAG_GAP = 0.45;
let B = newBoard(COLS, ROWS, MINES);
let C = -1;
let C2 = -1;
let wrongAt = -1;
let focus = -1;
let focusT = -9;
let chordT = -9;
let chordFrom = -1;
let wrong = false;
function bestZero(b) {
let best = -1;
let bs = 0;
for (let i = 0; i < b.cols * b.rows; i += 1) {
if (b.st[i] !== 0 || b.mine[i] || b.num[i] !== 0) {
continue;
}
const s = floodSize(b, i);
if (s > bs) {
bs = s;
best = i;
}
}
return best;
}
function mineNbrs(b, i) { return nbrs(b, i).filter(function (j) { return b.mine[j]; }); }
function hiddenNbrs(b, i) { return nbrs(b, i).filter(function (j) { return b.st[j] === 0; }); }
// 코드 열기를 미리 돌려 본다 — 깃발을 다 꽂은 복사판에서 이웃이 몇 칸 열리는지
function chordGain(b, i) {
const c = cloneBoard(b);
for (const j of mineNbrs(c, i)) {
c.st[j] = 2;
}
const list = chordList(c, i);
const src = [];
for (const j of list) {
src.push({ i: j, d: 0 });
}
return revealFrom(c, src, 0, 0).length;
}
function cheb(a, b) {
return Math.max(Math.abs((a % COLS) - (b % COLS)), Math.abs(Math.floor(a / COLS) - Math.floor(b / COLS)));
}
function build() {
fitGeo(COLS, ROWS, 44, 14);
const base = SEED + (lap % 2) * 37;
C = -1;
C2 = -1;
for (let k = 0; k < 120 && (C < 0 || C2 < 0); k += 1) {
B = newBoard(COLS, ROWS, MINES);
placeMines(B, base + k, -1);
const z = bestZero(B);
if (z < 0 || floodSize(B, z) < 14) {
continue;
}
revealFrom(B, [{ i: z, d: 0 }], -9, 0);
C = -1;
C2 = -1;
let bg = 5;
for (let i = 0; i < COLS * ROWS; i += 1) {
if (B.st[i] !== 1 || B.num[i] < 2 || B.num[i] > 4) {
continue;
}
if (hiddenNbrs(B, i).length < B.num[i] + 3) {
continue;
}
const g = chordGain(B, i);
if (g > bg) {
bg = g;
C = i;
}
}
if (C < 0) {
continue;
}
for (let i = 0; i < COLS * ROWS; i += 1) {
if (B.st[i] === 1 && B.num[i] === 1 && hiddenNbrs(B, i).length >= 3 && cheb(i, C) >= 4) {
C2 = i;
break;
}
}
}
wrong = false;
focus = -1;
focusT = -9;
chordT = -9;
chordFrom = -1;
wrongAt = -1;
if (C < 0 || C2 < 0) {
return;
}
const fl = mineNbrs(B, C);
script.push({ t: 0.3, f: function () { focus = C; focusT = sceneT; } });
fl.forEach(function (j, k) {
script.push({ t: 0.9 + FLAG_GAP * k, f: function () { B.st[j] = 2; B.flT[j] = sceneT; } });
});
const tc = 0.9 + FLAG_GAP * fl.length + 0.35;
script.push({ t: tc, f: function () { doChord(C); } });
script.push({ t: 5.2, f: function () { focus = C2; focusT = sceneT; } });
script.push({ t: 5.8, f: function () {
const safeN = hiddenNbrs(B, C2).filter(function (j) { return !B.mine[j]; });
wrongAt = safeN[0];
B.st[wrongAt] = 2;
B.flT[wrongAt] = sceneT;
} });
script.push({ t: 6.6, f: function () { doChord(C2); } });
}
// 코드 열기 — 깃발 수가 숫자와 같을 때만 닫힌 이웃을 모두 연다. 깃발이 틀렸으면 그 안에 지뢰가 섞여 터진다
function doChord(i) {
const list = chordList(B, i);
const src = [];
chordT = sceneT;
chordFrom = i;
list.forEach(function (c, k) {
if (B.mine[c]) {
B.exT[c] = sceneT + k * CHORD_DELAY;
wrong = true;
if (wrongAt >= 0) {
B.xT[wrongAt] = sceneT + 0.3;
}
}
else {
src.push({ i: c, d: k });
}
});
revealFrom(B, src, sceneT, CHORD_DELAY);
}
function update(dt) { if (dt < 0) {
return;
} }
function tintOf(i) {
if (focus < 0 || manual) {
return null;
}
if (i === focus) {
return [BUTTER[0], BUTTER[1], BUTTER[2], 0.6];
}
if (B.st[i] === 0 && nbrs(B, focus).indexOf(i) >= 0 && chordFrom !== focus) {
return [BUTTER[0], BUTTER[1], BUTTER[2], 0.4 * ease((sceneT - focusT) / 0.3)];
}
return null;
}
function draw() {
skyFill(BG_TOP, BG_BOT);
drawCells(B, null, tintOf, true);
if (focus >= 0 && !manual) {
const k = ease((sceneT - focusT) / 0.3);
outline(cellX(focus) - gs + 1, cellY(focus) - gs + 1, gs * 3 - 2, gs * 3 - 2, 6, 2.5, INK, 0.85 * k);
}
const age = sceneT - chordT;
if (chordFrom >= 0 && age >= 0 && age < 0.8) {
ring(cellCx(chordFrom), cellCy(chordFrom), gs * (0.5 + age * 4), 3, wrong && chordFrom === C2 ? ROSE_DEEP : MINT, 0.8 * (1 - age / 0.8));
}
if (focus >= 0 && !manual) {
const n = B.num[focus];
const f = flagsAround(B, focus);
tag('{f}/{n}'.replace('{f}', String(f)).replace('{n}', String(n)), cellCx(focus), cellY(focus) - gs * 1.25 < 40 ? cellY(focus) + gs * 2.1 : cellY(focus) - gs * 1.25, f === n ? MINT : BUTTER, INK, 11, 'center');
}
}
function statusText() {
const o = String(countOpen(B));
if (wrong && !manual) {
return 'Wrong flag opened a mine - {o} cells open'.replace('{o}', o);
}
if (!manual && focus >= 0) {
const f = String(flagsAround(B, focus));
if (sceneT >= LAP_S - 0.6) {
return 'Chord open done - {o} cells open'.replace('{o}', o);
}
return 'Chord open - number {n} - {f} flags'.replace('{n}', String(B.num[focus])).replace('{f}', f);
}
return '{o} cells open - {f} flags'.replace('{o}', o).replace('{f}', String(countSt(B, 2)));
}
function onAct(i) { manualAct(B, SEED, i, CHORD_DELAY); }
function onFlag(i) { manualFlag(B, i); }
function onTake() { focus = -1; }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// ChordOpenNeighbors - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./ChordOpenNeighbors.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ChordOpenNeighbors() {
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 });
// ── 04 숫자와 깃발 수가 같으면 이웃을 한 번에 여는 코드 열기 ──────────────────
const COLS = 14;
const ROWS = 8;
const MINES = 20;
const SEED = 4944;
const LAP_S = 9.8;
const START_S = 1.2;
const REST_S = 8.8;
// 코드 열기로 열리는 이웃이 한 칸씩 늦어지는 시간(초) — 순서는 위에서 아래, 왼쪽에서 오른쪽
const CHORD_DELAY = 0.07;
// 깃발 한 개가 내려오는 간격(초)
const FLAG_GAP = 0.45;
let B: Board = newBoard(COLS, ROWS, MINES);
let C = -1;
let C2 = -1;
let wrongAt = -1;
let focus = -1;
let focusT = -9;
let chordT = -9;
let chordFrom = -1;
let wrong = false;
function bestZero(b: Board): number {
let best = -1;
let bs = 0;
for (let i = 0; i < b.cols * b.rows; i += 1) {
if (b.st[i] !== 0 || b.mine[i] || b.num[i] !== 0) { continue; }
const s = floodSize(b, i);
if (s > bs) { bs = s; best = i; }
}
return best;
}
function mineNbrs(b: Board, i: number): number[] { return nbrs(b, i).filter(function (j: number): boolean { return b.mine[j]; }); }
function hiddenNbrs(b: Board, i: number): number[] { return nbrs(b, i).filter(function (j: number): boolean { return b.st[j] === 0; }); }
// 코드 열기를 미리 돌려 본다 — 깃발을 다 꽂은 복사판에서 이웃이 몇 칸 열리는지
function chordGain(b: Board, i: number): number {
const c = cloneBoard(b);
for (const j of mineNbrs(c, i)) { c.st[j] = 2; }
const list = chordList(c, i);
const src: Array<{ i: number; d: number }> = [];
for (const j of list) { src.push({ i: j, d: 0 }); }
return revealFrom(c, src, 0, 0).length;
}
function cheb(a: number, b: number): number {
return Math.max(Math.abs((a % COLS) - (b % COLS)), Math.abs(Math.floor(a / COLS) - Math.floor(b / COLS)));
}
function build(): void {
fitGeo(COLS, ROWS, 44, 14);
const base = SEED + (lap % 2) * 37;
C = -1;
C2 = -1;
for (let k = 0; k < 120 && (C < 0 || C2 < 0); k += 1) {
B = newBoard(COLS, ROWS, MINES);
placeMines(B, base + k, -1);
const z = bestZero(B);
if (z < 0 || floodSize(B, z) < 14) { continue; }
revealFrom(B, [{ i: z, d: 0 }], -9, 0);
C = -1;
C2 = -1;
let bg = 5;
for (let i = 0; i < COLS * ROWS; i += 1) {
if (B.st[i] !== 1 || B.num[i] < 2 || B.num[i] > 4) { continue; }
if (hiddenNbrs(B, i).length < B.num[i] + 3) { continue; }
const g = chordGain(B, i);
if (g > bg) { bg = g; C = i; }
}
if (C < 0) { continue; }
for (let i = 0; i < COLS * ROWS; i += 1) {
if (B.st[i] === 1 && B.num[i] === 1 && hiddenNbrs(B, i).length >= 3 && cheb(i, C) >= 4) { C2 = i; break; }
}
}
wrong = false;
focus = -1;
focusT = -9;
chordT = -9;
chordFrom = -1;
wrongAt = -1;
if (C < 0 || C2 < 0) { return; }
const fl = mineNbrs(B, C);
script.push({ t: 0.3, f: function (): void { focus = C; focusT = sceneT; } });
fl.forEach(function (j: number, k: number): void {
script.push({ t: 0.9 + FLAG_GAP * k, f: function (): void { B.st[j] = 2; B.flT[j] = sceneT; } });
});
const tc = 0.9 + FLAG_GAP * fl.length + 0.35;
script.push({ t: tc, f: function (): void { doChord(C); } });
script.push({ t: 5.2, f: function (): void { focus = C2; focusT = sceneT; } });
script.push({ t: 5.8, f: function (): void {
const safeN = hiddenNbrs(B, C2).filter(function (j: number): boolean { return !B.mine[j]; });
wrongAt = safeN[0];
B.st[wrongAt] = 2;
B.flT[wrongAt] = sceneT;
} });
script.push({ t: 6.6, f: function (): void { doChord(C2); } });
}
// 코드 열기 — 깃발 수가 숫자와 같을 때만 닫힌 이웃을 모두 연다. 깃발이 틀렸으면 그 안에 지뢰가 섞여 터진다
function doChord(i: number): void {
const list = chordList(B, i);
const src: Array<{ i: number; d: number }> = [];
chordT = sceneT;
chordFrom = i;
list.forEach(function (c: number, k: number): void {
if (B.mine[c]) {
B.exT[c] = sceneT + k * CHORD_DELAY;
wrong = true;
if (wrongAt >= 0) { B.xT[wrongAt] = sceneT + 0.3; }
} else {
src.push({ i: c, d: k });
}
});
revealFrom(B, src, sceneT, CHORD_DELAY);
}
function update(dt: number): void { if (dt < 0) { return; } }
function tintOf(i: number): RGBA | null {
if (focus < 0 || manual) { return null; }
if (i === focus) { return [BUTTER[0], BUTTER[1], BUTTER[2], 0.6]; }
if (B.st[i] === 0 && nbrs(B, focus).indexOf(i) >= 0 && chordFrom !== focus) { return [BUTTER[0], BUTTER[1], BUTTER[2], 0.4 * ease((sceneT - focusT) / 0.3)]; }
return null;
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
drawCells(B, null, tintOf, true);
if (focus >= 0 && !manual) {
const k = ease((sceneT - focusT) / 0.3);
outline(cellX(focus) - gs + 1, cellY(focus) - gs + 1, gs * 3 - 2, gs * 3 - 2, 6, 2.5, INK, 0.85 * k);
}
const age = sceneT - chordT;
if (chordFrom >= 0 && age >= 0 && age < 0.8) {
ring(cellCx(chordFrom), cellCy(chordFrom), gs * (0.5 + age * 4), 3, wrong && chordFrom === C2 ? ROSE_DEEP : MINT, 0.8 * (1 - age / 0.8));
}
if (focus >= 0 && !manual) {
const n = B.num[focus];
const f = flagsAround(B, focus);
tag('{f}/{n}'.replace('{f}', String(f)).replace('{n}', String(n)), cellCx(focus), cellY(focus) - gs * 1.25 < 40 ? cellY(focus) + gs * 2.1 : cellY(focus) - gs * 1.25, f === n ? MINT : BUTTER, INK, 11, 'center');
}
}
function statusText(): string {
const o = String(countOpen(B));
if (wrong && !manual) { return '깃발이 틀려 지뢰를 열었다 — 열린 칸 {o}칸'.replace('{o}', o); }
if (!manual && focus >= 0) {
const f = String(flagsAround(B, focus));
if (sceneT >= LAP_S - 0.6) { return '코드 열기 완료 — 열린 칸 {o}칸'.replace('{o}', o); }
return '코드 열기 — 숫자 {n} · 깃발 {f}개'.replace('{n}', String(B.num[focus])).replace('{f}', f);
}
return '열린 칸 {o}칸 · 깃발 {f}개'.replace('{o}', o).replace('{f}', String(countSt(B, 2)));
}
function onAct(i: number): void { manualAct(B, SEED, i, CHORD_DELAY); }
function onFlag(i: number): void { manualFlag(B, i); }
function onTake(): void { focus = -1; }
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={'열린 숫자 칸의 지뢰 이웃에 깃발이 하나씩 내려와 깃발 수가 숫자와 같아지면 남은 닫힌 이웃이 한꺼번에 열리고, 이어서 깃발을 틀린 칸에 꽂은 두 번째 숫자에서는 같은 규칙으로 지뢰까지 열려 터지며 틀린 깃발에 가위표가 켜진다'}></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>
);
}04 · A Chord Open That Reveals All Neighbors When Flags Match the Number — Measured area 6.338% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
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;
}
05A Deduction Hint That Overlaps Neighbor Constraints to Find Certain Cells
A hint feature that names the next move on a stuck board suits this overlap of two numbers. consOf collects each opened number's closed neighbors u and remaining mines r, and when one number's u sits wholly inside another's, pairHint settles safe or mine by the difference r_B − r_A. Hints from 150 boards of 16×8 (3,536 in all) were checked against the real mine layouts with 0 mismatches, and all 6 hints in the first demo round are two-number hints.

<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="In an opened board, the closed neighbor sets of two numbered cells are compared, and when one sits wholly inside the other the mine count of the leftover cells comes from a subtraction. A line runs from the two source numbers to the certain cells, safe cells open and mine cells get a flag, and six such deductions follow in a row"></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 });
// ── 05 이웃 조건을 겹쳐 확실한 칸을 가려내는 추론 힌트 ────────────────────────
const COLS = 16;
const ROWS = 8;
const MINES = 22;
const SEED = 4945;
const LAP_S = 10.8;
const START_S = 0.0;
const REST_S = 6.0;
// 번져 열기 지연(초). 힌트 하나는 FIND→APPLY 사이 0.9초 동안 근거를 보여 준 뒤 적용한다
const OPEN_DELAY = 0.07;
const HINT_GAP = 1.4;
const HINT_SHOW = 0.9;
const HINT_FIRST = 1.9;
let B = newBoard(COLS, ROWS, MINES);
let hint = null;
let hintT = -9;
let hr = [0, 0];
let nHint = 0;
let center = 0;
function build() {
fitGeo(COLS, ROWS, 44, 14);
center = 3 * COLS + 8;
const base = SEED + (lap % 2) * 53;
for (let k = 0; k < 120; k += 1) {
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;
}
}
// 시연은 판을 처음부터 열어 보이게 — 첫 칸을 연 판을 닫아 두었다가 0.2초에 연다
B.st = B.st.map(function () { return 0; });
hint = null;
nHint = 0;
script.push({ t: 0.2, f: function () { revealFrom(B, [{ i: center, d: 0 }], sceneT, OPEN_DELAY); } });
for (let k = 0; k < 6; k += 1) {
script.push({ t: HINT_FIRST + HINT_GAP * k, f: find });
script.push({ t: HINT_FIRST + HINT_GAP * k + HINT_SHOW, f: apply });
}
}
function find() {
const h = findHint(B);
hint = h;
hintT = sceneT;
if (!h) {
return;
}
const cs = consOf(B);
for (const c of cs) {
if (c.c === h.a) {
hr[0] = c.r;
}
if (c.c === h.b) {
hr[1] = c.r;
}
}
}
function apply() {
if (!hint) {
return;
}
applyHint(B, hint, sceneT, OPEN_DELAY);
nHint += 1;
hint = null;
}
function update(dt) { if (dt < 0) {
return;
} }
function unknownOf(i) { return nbrs(B, i).filter(function (j) { return B.st[j] === 0; }); }
function tintOf(i) {
if (!hint || manual) {
return null;
}
const k = ease((sceneT - hintT) / 0.3);
if (hint.cells.indexOf(i) >= 0) {
const done = ease((sceneT - hintT - 0.5) / 0.3);
const c = hint.kind === 0 ? MINT : ROSE;
return [c[0], c[1], c[2], 0.35 * k + 0.5 * done];
}
if (unknownOf(hint.a).indexOf(i) >= 0) {
return [BUTTER[0], BUTTER[1], BUTTER[2], 0.7 * k];
}
if (hint.b >= 0 && unknownOf(hint.b).indexOf(i) >= 0) {
return [LAV[0], LAV[1], LAV[2], 0.6 * k];
}
return null;
}
function draw() {
skyFill(BG_TOP, BG_BOT);
drawCells(B, null, tintOf, true);
if (!hint || manual) {
return;
}
const age = sceneT - hintT;
const srcs = hint.b >= 0 ? [hint.a, hint.b] : [hint.a];
for (const s of srcs) {
outline(cellX(s) + 1, cellY(s) + 1, gs - 2, gs - 2, 4, 3, INK, 0.9 * ease(age / 0.25));
}
// 근거 숫자에서 확실한 칸으로 이어지는 선 — 0.3초에서 0.6초 사이에 그어진다
const lk = ease((age - 0.3) / 0.3);
const shown = hint.cells.slice(0, 4);
for (const s of srcs) {
for (const t of shown) {
seg(cellCx(s), cellCy(s), lerp(cellCx(s), cellCx(t), lk), lerp(cellCy(s), cellCy(t), lk), 2, INK, 0.7);
}
}
if (hint.b >= 0 && lk > 0.5) {
const t = hint.cells[0];
const ty = cellCy(t) - gs * 1.05 < 40 ? cellCy(t) + gs * 1.05 : cellCy(t) - gs * 1.05;
tag('{b} − {a} = {d}'.replace('{b}', String(hr[1])).replace('{a}', String(hr[0])).replace('{d}', String(hr[1] - hr[0])), cellCx(t), ty, hint.kind === 0 ? MINT : ROSE, INK, 11, 'center');
}
}
function statusText() {
const o = String(countOpen(B));
const f = String(countSt(B, 2));
if (!manual && hint) {
return (hint.kind === 0 ? 'Deduction {k} - {c} safe cells' : 'Deduction {k} - {c} mine cells').replace('{k}', String(nHint + 1)).replace('{c}', String(hint.cells.length));
}
if (!manual && sceneT >= LAP_S - 0.6) {
return 'Deduction done - {k} hints - {f} flags'.replace('{k}', String(nHint)).replace('{f}', f);
}
return '{o} cells open - {f} flags'.replace('{o}', o).replace('{f}', f);
}
function onAct(i) { manualAct(B, SEED, i, OPEN_DELAY); }
function onFlag(i) { manualFlag(B, i); }
function onTake() { hint = null; }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// ConstraintSolverHint - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./ConstraintSolverHint.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ConstraintSolverHint() {
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 });
// ── 05 이웃 조건을 겹쳐 확실한 칸을 가려내는 추론 힌트 ────────────────────────
const COLS = 16;
const ROWS = 8;
const MINES = 22;
const SEED = 4945;
const LAP_S = 10.8;
const START_S = 0.0;
const REST_S = 6.0;
// 번져 열기 지연(초). 힌트 하나는 FIND→APPLY 사이 0.9초 동안 근거를 보여 준 뒤 적용한다
const OPEN_DELAY = 0.07;
const HINT_GAP = 1.4;
const HINT_SHOW = 0.9;
const HINT_FIRST = 1.9;
let B: Board = newBoard(COLS, ROWS, MINES);
let hint: Hint | null = null;
let hintT = -9;
let hr: number[] = [0, 0];
let nHint = 0;
let center = 0;
function build(): void {
fitGeo(COLS, ROWS, 44, 14);
center = 3 * COLS + 8;
const base = SEED + (lap % 2) * 53;
for (let k = 0; k < 120; k += 1) {
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; }
}
// 시연은 판을 처음부터 열어 보이게 — 첫 칸을 연 판을 닫아 두었다가 0.2초에 연다
B.st = B.st.map(function (): number { return 0; });
hint = null;
nHint = 0;
script.push({ t: 0.2, f: function (): void { revealFrom(B, [{ i: center, d: 0 }], sceneT, OPEN_DELAY); } });
for (let k = 0; k < 6; k += 1) {
script.push({ t: HINT_FIRST + HINT_GAP * k, f: find });
script.push({ t: HINT_FIRST + HINT_GAP * k + HINT_SHOW, f: apply });
}
}
function find(): void {
const h = findHint(B);
hint = h;
hintT = sceneT;
if (!h) { return; }
const cs = consOf(B);
for (const c of cs) {
if (c.c === h.a) { hr[0] = c.r; }
if (c.c === h.b) { hr[1] = c.r; }
}
}
function apply(): void {
if (!hint) { return; }
applyHint(B, hint, sceneT, OPEN_DELAY);
nHint += 1;
hint = null;
}
function update(dt: number): void { if (dt < 0) { return; } }
function unknownOf(i: number): number[] { return nbrs(B, i).filter(function (j: number): boolean { return B.st[j] === 0; }); }
function tintOf(i: number): RGBA | null {
if (!hint || manual) { return null; }
const k = ease((sceneT - hintT) / 0.3);
if (hint.cells.indexOf(i) >= 0) {
const done = ease((sceneT - hintT - 0.5) / 0.3);
const c = hint.kind === 0 ? MINT : ROSE;
return [c[0], c[1], c[2], 0.35 * k + 0.5 * done];
}
if (unknownOf(hint.a).indexOf(i) >= 0) { return [BUTTER[0], BUTTER[1], BUTTER[2], 0.7 * k]; }
if (hint.b >= 0 && unknownOf(hint.b).indexOf(i) >= 0) { return [LAV[0], LAV[1], LAV[2], 0.6 * k]; }
return null;
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
drawCells(B, null, tintOf, true);
if (!hint || manual) { return; }
const age = sceneT - hintT;
const srcs = hint.b >= 0 ? [hint.a, hint.b] : [hint.a];
for (const s of srcs) {
outline(cellX(s) + 1, cellY(s) + 1, gs - 2, gs - 2, 4, 3, INK, 0.9 * ease(age / 0.25));
}
// 근거 숫자에서 확실한 칸으로 이어지는 선 — 0.3초에서 0.6초 사이에 그어진다
const lk = ease((age - 0.3) / 0.3);
const shown = hint.cells.slice(0, 4);
for (const s of srcs) {
for (const t of shown) {
seg(cellCx(s), cellCy(s), lerp(cellCx(s), cellCx(t), lk), lerp(cellCy(s), cellCy(t), lk), 2, INK, 0.7);
}
}
if (hint.b >= 0 && lk > 0.5) {
const t = hint.cells[0];
const ty = cellCy(t) - gs * 1.05 < 40 ? cellCy(t) + gs * 1.05 : cellCy(t) - gs * 1.05;
tag('{b} − {a} = {d}'.replace('{b}', String(hr[1])).replace('{a}', String(hr[0])).replace('{d}', String(hr[1] - hr[0])), cellCx(t), ty, hint.kind === 0 ? MINT : ROSE, INK, 11, 'center');
}
}
function statusText(): string {
const o = String(countOpen(B));
const f = String(countSt(B, 2));
if (!manual && hint) {
return (hint.kind === 0 ? '추론 {k}번째 — 안전한 칸 {c}개' : '추론 {k}번째 — 지뢰인 칸 {c}개').replace('{k}', String(nHint + 1)).replace('{c}', String(hint.cells.length));
}
if (!manual && sceneT >= LAP_S - 0.6) { return '추론 완료 — 힌트 {k}개 · 깃발 {f}개'.replace('{k}', String(nHint)).replace('{f}', f); }
return '열림 {o}칸 · 깃발 {f}개'.replace('{o}', o).replace('{f}', f);
}
function onAct(i: number): void { manualAct(B, SEED, i, OPEN_DELAY); }
function onFlag(i: number): void { manualFlag(B, i); }
function onTake(): void { hint = null; }
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={'열린 판에서 두 숫자 칸의 닫힌 이웃 집합을 견줘 한쪽이 다른 쪽에 통째로 들어 있으면 나머지 칸의 지뢰 수를 뺄셈으로 구해, 두 근거 숫자에서 확실한 칸까지 선을 긋고 안전한 칸은 열고 지뢰인 칸에는 깃발을 꽂는 추론이 여섯 번 이어진다'}></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>
);
}05 · A Deduction Hint That Overlaps Neighbor Constraints to Find Certain Cells — Measured area 20.097% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
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;
}
06A Probability Map That Paints Each Cell's Mine Chance as Color Depth When Nothing Is Certain
An explainer on picking the safest move where luck decides fits a map that paints each cell's odds as color depth. probMap enumerates by recursion every arrangement of the border cells touching opened numbers, weights the outside cells by comb(outside cells, remaining mines), and stops when the search nodes pass PROB_NODE_CAP. Comparing 25 boards of 6×4 against a direct count of arrangements gave a maximum difference of 0, and the palest cell in the demo is 13.8% in round 1, 13.9% in round 2 and 0% in round 3.

<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="On a board with no certain cell, every layout that satisfies all open number conditions is counted to get the mine chance of each closed cell, painted pale mint when low and deep pink when high, then a ring circles the palest cell and it is opened, for three rounds, and after an opening some cells fall to 0 and show up as certain safe cells"></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 });
// ── 06 확실한 칸이 없을 때 칸마다 지뢰 확률을 칠하는 확률 지도 ──────────────────
const COLS = 16;
const ROWS = 8;
const MINES = 24;
const SEED = 4946;
const ROUNDS = 3;
// 한 라운드(확률 계산 → 가장 안전한 칸 고르기 → 열기)의 길이(초)
const ROUND_S = 3.4;
const LAP_S = 10.8;
const START_S = 0.0;
const REST_S = 7.2;
const OPEN_DELAY = 0.07;
let B = newBoard(COLS, ROWS, MINES);
let prob = [];
let heatT = -9;
let heatOn = false;
let pick = -1;
let ringT = -9;
let nRound = 0;
let offT = -9;
let pickP = 0;
function isFront(i) {
if (B.st[i] !== 0) {
return false;
}
for (const j of nbrs(B, i)) {
if (B.st[j] === 1 && B.num[j] > 0) {
return true;
}
}
return false;
}
// 가장 옅은 칸 — 확률이 제일 낮은 닫힌 칸(같으면 앞쪽 칸)
function palest(p) {
let best = -1;
for (let i = 0; i < p.length; i += 1) {
if (p[i] >= 0 && (best < 0 || p[i] < p[best] - 1e-9)) {
best = i;
}
}
return best;
}
// 닫힌 칸 어디에도 확률 0%·100% 가 없는 판 — 어떤 추론으로도 확실한 칸이 없다
function noCertain(b) {
const p = probMap(b);
if (probNodes > PROB_NODE_CAP) {
return false;
}
for (const v of p) {
if (v >= 0 && (v < 0.02 || v > 0.98)) {
return false;
}
}
return true;
}
// 확실한 칸(확률 0% 는 열고 100% 는 깃발)을 더 없을 때까지 처리한다 — 사람이 추론으로 갈 수 있는 데까지 간 판
function solveOut(b) {
for (let s = 0; s < 80; s += 1) {
const p = probMap(b);
if (probNodes > PROB_NODE_CAP) {
return false;
}
let did = false;
for (let i = 0; i < p.length; i += 1) {
if (p[i] < 0 || b.st[i] !== 0) {
continue;
}
if (p[i] < 0.001) {
revealFrom(b, [{ i: i, d: 0 }], -9, 0);
did = true;
}
else if (p[i] > 0.999) {
b.st[i] = 2;
b.flT[i] = -9;
did = true;
}
}
if (!did) {
return true;
}
}
return false;
}
function build() {
fitGeo(COLS, ROWS, 44, 14);
const center = 3 * COLS + 8;
const base = SEED + (lap % 2) * 71;
for (let k = 0; k < 600; k += 1) {
B = newBoard(COLS, ROWS, MINES);
placeMines(B, base + k, center);
revealFrom(B, [{ i: center, d: 0 }], -9, 0);
if (!solveOut(B) || countSt(B, 1) < 28 || countSt(B, 1) > 64 || !noCertain(B)) {
continue;
}
// 세 라운드가 모두 안전한 칸을 고르고 계산이 상한 안에 끝나는 판만 쓴다
const c = cloneBoard(B);
let ok = true;
for (let r = 0; r < ROUNDS && ok; r += 1) {
const p = probMap(c);
const q = palest(p);
// 첫 라운드는 가장 옅은 칸도 확률이 0 이 아니어야 "확실한 칸이 없다" 가 참이다. 뒤 라운드는 0% 칸이 생길 수 있다
if (probNodes > PROB_NODE_CAP || q < 0 || c.mine[q] || (r === 0 && p[q] < 0.05)) {
ok = false;
break;
}
revealFrom(c, [{ i: q, d: 0 }], -9, 0);
}
if (ok) {
break;
}
}
prob = [];
heatOn = false;
pick = -1;
nRound = 0;
for (let r = 0; r < ROUNDS; r += 1) {
const t0 = 0.4 + ROUND_S * r;
script.push({ t: t0, f: function () {
prob = probMap(B);
pick = palest(prob);
pickP = pick >= 0 ? prob[pick] : 0;
heatT = sceneT;
heatOn = true;
ringT = -9;
} });
script.push({ t: t0 + 1.9, f: function () { ringT = sceneT; } });
script.push({ t: t0 + 2.7, f: function () {
if (pick >= 0) {
revealFrom(B, [{ i: pick, d: 0 }], sceneT, OPEN_DELAY);
}
heatOn = false;
offT = sceneT;
nRound = r + 1;
} });
}
}
function update(dt) { if (dt < 0) {
return;
} }
function heatColor(p) { return mix([208, 238, 224], [226, 98, 138], Math.pow(clamp(p, 0, 1), 0.8)); }
function tintOf(i) {
if (B.st[i] !== 0 || prob.length === 0) {
return null;
}
const age = sceneT - heatT;
let k = 0;
if (heatOn) {
k = ease((age - (i % COLS) * 0.035) / 0.5);
}
else {
k = 1 - ease((sceneT - offT) / 0.3);
}
if (manual) {
k = 0;
}
if (k <= 0 || prob[i] < 0) {
return null;
}
const c = heatColor(prob[i]);
return [c[0], c[1], c[2], 0.9 * k];
}
function draw() {
skyFill(BG_TOP, BG_BOT);
drawCells(B, null, tintOf, true);
if (!heatOn || manual) {
return;
}
const age = sceneT - heatT;
for (let i = 0; i < COLS * ROWS; i += 1) {
if (!isFront(i) || prob[i] < 0) {
continue;
}
const k = ease((age - (i % COLS) * 0.035 - 0.2) / 0.4);
text(String(Math.round(prob[i] * 100)), cellCx(i), cellCy(i) + 0.5, INK, k, Math.round(gs * 0.4), 800, 'center');
}
const ra = sceneT - ringT;
if (pick >= 0 && ringT >= 0 && ra >= 0) {
const k = ease(ra / 0.25);
ring(cellCx(pick), cellCy(pick), gs * (0.62 + 0.08 * Math.sin(ra * 9)), 3.5, INK, k);
tag('{p}%'.replace('{p}', String(Math.round(pickP * 100))), cellCx(pick), cellCy(pick) - gs * 1.1 < 40 ? cellCy(pick) + gs * 1.1 : cellCy(pick) - gs * 1.1, MINT, INK, 11, 'center');
}
}
function statusText() {
const o = String(countOpen(B));
if (!manual && heatOn && pick >= 0 && pickP < 0.005) {
return 'Certain safe cell found - chance 0%';
}
if (!manual && heatOn && pick >= 0) {
return 'No certain cell - palest cell {p}%'.replace('{p}', String(Math.round(pickP * 100)));
}
if (!manual && sceneT >= LAP_S - 0.6) {
return 'Probability map done - {o} cells open - {m} mines'.replace('{o}', o).replace('{m}', String(MINES));
}
return '{o} cells open - {m} mines'.replace('{o}', o).replace('{m}', String(MINES));
}
function onAct(i) { manualAct(B, SEED, i, OPEN_DELAY); }
function onFlag(i) { manualFlag(B, i); }
function onTake() { heatOn = false; }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// ProbabilityHeatMap - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./ProbabilityHeatMap.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ProbabilityHeatMap() {
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 });
// ── 06 확실한 칸이 없을 때 칸마다 지뢰 확률을 칠하는 확률 지도 ──────────────────
const COLS = 16;
const ROWS = 8;
const MINES = 24;
const SEED = 4946;
const ROUNDS = 3;
// 한 라운드(확률 계산 → 가장 안전한 칸 고르기 → 열기)의 길이(초)
const ROUND_S = 3.4;
const LAP_S = 10.8;
const START_S = 0.0;
const REST_S = 7.2;
const OPEN_DELAY = 0.07;
let B: Board = newBoard(COLS, ROWS, MINES);
let prob: number[] = [];
let heatT = -9;
let heatOn = false;
let pick = -1;
let ringT = -9;
let nRound = 0;
let offT = -9;
let pickP = 0;
function isFront(i: number): boolean {
if (B.st[i] !== 0) { return false; }
for (const j of nbrs(B, i)) { if (B.st[j] === 1 && B.num[j] > 0) { return true; } }
return false;
}
// 가장 옅은 칸 — 확률이 제일 낮은 닫힌 칸(같으면 앞쪽 칸)
function palest(p: number[]): number {
let best = -1;
for (let i = 0; i < p.length; i += 1) {
if (p[i] >= 0 && (best < 0 || p[i] < p[best] - 1e-9)) { best = i; }
}
return best;
}
// 닫힌 칸 어디에도 확률 0%·100% 가 없는 판 — 어떤 추론으로도 확실한 칸이 없다
function noCertain(b: Board): boolean {
const p = probMap(b);
if (probNodes > PROB_NODE_CAP) { return false; }
for (const v of p) { if (v >= 0 && (v < 0.02 || v > 0.98)) { return false; } }
return true;
}
// 확실한 칸(확률 0% 는 열고 100% 는 깃발)을 더 없을 때까지 처리한다 — 사람이 추론으로 갈 수 있는 데까지 간 판
function solveOut(b: Board): boolean {
for (let s = 0; s < 80; s += 1) {
const p = probMap(b);
if (probNodes > PROB_NODE_CAP) { return false; }
let did = false;
for (let i = 0; i < p.length; i += 1) {
if (p[i] < 0 || b.st[i] !== 0) { continue; }
if (p[i] < 0.001) { revealFrom(b, [{ i: i, d: 0 }], -9, 0); did = true; } else if (p[i] > 0.999) { b.st[i] = 2; b.flT[i] = -9; did = true; }
}
if (!did) { return true; }
}
return false;
}
function build(): void {
fitGeo(COLS, ROWS, 44, 14);
const center = 3 * COLS + 8;
const base = SEED + (lap % 2) * 71;
for (let k = 0; k < 600; k += 1) {
B = newBoard(COLS, ROWS, MINES);
placeMines(B, base + k, center);
revealFrom(B, [{ i: center, d: 0 }], -9, 0);
if (!solveOut(B) || countSt(B, 1) < 28 || countSt(B, 1) > 64 || !noCertain(B)) { continue; }
// 세 라운드가 모두 안전한 칸을 고르고 계산이 상한 안에 끝나는 판만 쓴다
const c = cloneBoard(B);
let ok = true;
for (let r = 0; r < ROUNDS && ok; r += 1) {
const p = probMap(c);
const q = palest(p);
// 첫 라운드는 가장 옅은 칸도 확률이 0 이 아니어야 "확실한 칸이 없다" 가 참이다. 뒤 라운드는 0% 칸이 생길 수 있다
if (probNodes > PROB_NODE_CAP || q < 0 || c.mine[q] || (r === 0 && p[q] < 0.05)) { ok = false; break; }
revealFrom(c, [{ i: q, d: 0 }], -9, 0);
}
if (ok) { break; }
}
prob = [];
heatOn = false;
pick = -1;
nRound = 0;
for (let r = 0; r < ROUNDS; r += 1) {
const t0 = 0.4 + ROUND_S * r;
script.push({ t: t0, f: function (): void {
prob = probMap(B);
pick = palest(prob);
pickP = pick >= 0 ? prob[pick] : 0;
heatT = sceneT;
heatOn = true;
ringT = -9;
} });
script.push({ t: t0 + 1.9, f: function (): void { ringT = sceneT; } });
script.push({ t: t0 + 2.7, f: function (): void {
if (pick >= 0) { revealFrom(B, [{ i: pick, d: 0 }], sceneT, OPEN_DELAY); }
heatOn = false;
offT = sceneT;
nRound = r + 1;
} });
}
}
function update(dt: number): void { if (dt < 0) { return; } }
function heatColor(p: number): RGB { return mix([208, 238, 224], [226, 98, 138], Math.pow(clamp(p, 0, 1), 0.8)); }
function tintOf(i: number): RGBA | null {
if (B.st[i] !== 0 || prob.length === 0) { return null; }
const age = sceneT - heatT;
let k = 0;
if (heatOn) { k = ease((age - (i % COLS) * 0.035) / 0.5); } else { k = 1 - ease((sceneT - offT) / 0.3); }
if (manual) { k = 0; }
if (k <= 0 || prob[i] < 0) { return null; }
const c = heatColor(prob[i]);
return [c[0], c[1], c[2], 0.9 * k];
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
drawCells(B, null, tintOf, true);
if (!heatOn || manual) { return; }
const age = sceneT - heatT;
for (let i = 0; i < COLS * ROWS; i += 1) {
if (!isFront(i) || prob[i] < 0) { continue; }
const k = ease((age - (i % COLS) * 0.035 - 0.2) / 0.4);
text(String(Math.round(prob[i] * 100)), cellCx(i), cellCy(i) + 0.5, INK, k, Math.round(gs * 0.4), 800, 'center');
}
const ra = sceneT - ringT;
if (pick >= 0 && ringT >= 0 && ra >= 0) {
const k = ease(ra / 0.25);
ring(cellCx(pick), cellCy(pick), gs * (0.62 + 0.08 * Math.sin(ra * 9)), 3.5, INK, k);
tag('{p}%'.replace('{p}', String(Math.round(pickP * 100))), cellCx(pick), cellCy(pick) - gs * 1.1 < 40 ? cellCy(pick) + gs * 1.1 : cellCy(pick) - gs * 1.1, MINT, INK, 11, 'center');
}
}
function statusText(): string {
const o = String(countOpen(B));
if (!manual && heatOn && pick >= 0 && pickP < 0.005) { return '확실히 안전한 칸 생김 — 확률 0%'; }
if (!manual && heatOn && pick >= 0) { return '확실한 칸 없음 — 가장 옅은 칸 {p}%'.replace('{p}', String(Math.round(pickP * 100))); }
if (!manual && sceneT >= LAP_S - 0.6) { return '확률 지도 완료 — 열림 {o}칸 · 지뢰 {m}개'.replace('{o}', o).replace('{m}', String(MINES)); }
return '열림 {o}칸 · 지뢰 {m}개'.replace('{o}', o).replace('{m}', String(MINES));
}
function onAct(i: number): void { manualAct(B, SEED, i, OPEN_DELAY); }
function onFlag(i: number): void { manualFlag(B, i); }
function onTake(): void { heatOn = false; }
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={'확실한 칸이 없는 판에서 열린 숫자 조건을 모두 만족하는 배치를 세어 닫힌 칸마다 지뢰 확률을 구하고, 확률이 낮으면 옅은 민트, 높으면 진한 분홍으로 칠한 뒤 가장 옅은 칸에 고리를 두르고 열어 보는 일이 세 라운드 이어지며, 열고 나면 확률이 0 이 된 칸이 확실히 안전한 칸으로 드러난다'}></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>
);
}06 · A Probability Map That Paints Each Cell's Mine Chance as Color Depth When Nothing Is Certain — Measured area 30.206% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
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];
}
}
07A Shockwave Where a Stepped Mine Sets Off Every Mine on the Board in Sequence
A game result screen that needs a defeat effect works best with mines going off in the order a ring reaches them. startChain writes each mine's blast time as exT = sceneT + distance / RING_SPEED, and the shock field multiplies exp(−((d − r) / WAVE_W)²) by a time decay to push each cell outward by PUSH and back. The farthest of the 18 mines is 424px from the start, so the last blast comes at 0.3 + 424/170 = 2.79s.

<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="From the stepped mine a pink ring spreads, the other mines on the board go off in the order the ring reaches them, each blast pushes cells outward and back with its own shockwave, and a scorch mark stays. The chain runs once more from a mine on the far side"></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 });
// ── 07 밟은 지뢰가 판의 모든 지뢰를 차례로 터뜨리는 연쇄 충격파 ─────────────────
const COLS = 16;
const ROWS = 8;
const MINES = 18;
const SEED = 4947;
// 첫 고리가 퍼지는 속도(px/초) — 지뢰는 고리가 닿는 시각에 터진다
const RING_SPEED = 170;
// 터진 지뢰마다 새로 퍼지는 충격파 속도(px/초)·두께(px)·칸을 미는 세기(px)
const WAVE_SPEED = 260;
const WAVE_W = 26;
const PUSH = 8;
const LAP_S = 9.8;
const START_S = 0.0;
const REST_S = 3.4;
let B = newBoard(COLS, ROWS, MINES);
let P = -1;
let chainT = -9;
let dxs = [];
let dys = [];
let glow = [];
function distPx(a, b) { return Math.hypot(cellCx(a) - cellCx(b), cellCy(a) - cellCy(b)); }
function startChain(p) {
P = p;
chainT = sceneT;
for (let m = 0; m < COLS * ROWS; m += 1) {
if (B.mine[m] && B.exT[m] < 0) {
B.exT[m] = sceneT + distPx(p, m) / RING_SPEED;
}
}
B.exT[p] = sceneT;
}
// 가장 먼 지뢰 — 두 번째 연쇄를 반대편에서 시작한다
function farMine(from) {
let best = -1;
for (let m = 0; m < COLS * ROWS; m += 1) {
if (B.mine[m] && (best < 0 || distPx(from, m) > distPx(from, best))) {
best = m;
}
}
return best;
}
function build() {
fitGeo(COLS, ROWS, 44, 14);
B = newBoard(COLS, ROWS, MINES);
placeMines(B, SEED + (lap % 2) * 11, -1);
dxs = [];
dys = [];
glow = [];
for (let i = 0; i < COLS * ROWS; i += 1) {
dxs.push(0);
dys.push(0);
glow.push(0);
}
P = -1;
chainT = -9;
let first = -1;
for (let m = 0; m < COLS * ROWS && first < 0; m += 1) {
if (B.mine[m] && m % COLS > 4 && m % COLS < 11) {
first = m;
}
}
if (first < 0) {
first = B.mine.indexOf(true);
}
script.push({ t: 0.3, f: function () { startChain(first); } });
script.push({ t: 4.6, f: function () {
for (let i = 0; i < COLS * ROWS; i += 1) {
B.exT[i] = -1;
}
P = -1;
} });
script.push({ t: 5.0, f: function () { startChain(farMine(first)); } });
}
// 충격파 장 — 터진 지뢰마다 퍼지는 고리가 칸을 바깥 방향으로 밀었다 되돌린다
function update(dt) {
if (dt < 0) {
return;
}
for (let i = 0; i < COLS * ROWS; i += 1) {
dxs[i] = 0;
dys[i] = 0;
glow[i] = 0;
}
for (let s = 0; s < COLS * ROWS; s += 1) {
if (B.exT[s] < 0 || sceneT < B.exT[s]) {
continue;
}
const age = sceneT - B.exT[s];
if (age > 2.4) {
continue;
}
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;
}
}
}
function offOf(i) { return [dxs[i], dys[i]]; }
function tintOf(i) {
const g = clamp(glow[i] * 0.55, 0, 0.55);
if (g < 0.03 || B.exT[i] >= 0 && sceneT >= B.exT[i]) {
return null;
}
return [BUTTER[0], BUTTER[1], BUTTER[2], g];
}
function draw() {
skyFill(BG_TOP, BG_BOT);
drawCells(B, offOf, tintOf, true);
if (P >= 0) {
const age = sceneT - chainT;
if (age >= 0 && age < 3) {
const r = age * RING_SPEED;
ring(cellCx(P), cellCy(P), r, 3.5, ROSE_DEEP, 0.75 * (1 - age / 3));
ring(cellCx(P), cellCy(P), Math.max(0, r - 34), 2.5, ROSE, 0.6 * (1 - age / 3));
}
}
}
function statusText() {
const k = String(countEx(B, sceneT));
if (!manual && sceneT >= LAP_S - 0.6) {
return 'Chain blast done - {m} mines'.replace('{m}', String(MINES));
}
return '{k}/{m} mines blown'.replace('{k}', k).replace('{m}', String(MINES));
}
// 직접 하면 지뢰를 누른 순간 연쇄가 시작되고, 지뢰가 아니면 그 칸의 숫자만 열린다
function onAct(i) {
if (B.st[i] === 2) {
return;
}
if (B.mine[i]) {
if (P < 0) {
startChain(i);
}
}
else {
revealFrom(B, [{ i: i, d: 0 }], sceneT, 0.1);
}
}
function onFlag(i) { manualFlag(B, i); }
function onTake() { return; }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// MineChainShockwave - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./MineChainShockwave.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function MineChainShockwave() {
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 });
// ── 07 밟은 지뢰가 판의 모든 지뢰를 차례로 터뜨리는 연쇄 충격파 ─────────────────
const COLS = 16;
const ROWS = 8;
const MINES = 18;
const SEED = 4947;
// 첫 고리가 퍼지는 속도(px/초) — 지뢰는 고리가 닿는 시각에 터진다
const RING_SPEED = 170;
// 터진 지뢰마다 새로 퍼지는 충격파 속도(px/초)·두께(px)·칸을 미는 세기(px)
const WAVE_SPEED = 260;
const WAVE_W = 26;
const PUSH = 8;
const LAP_S = 9.8;
const START_S = 0.0;
const REST_S = 3.4;
let B: Board = newBoard(COLS, ROWS, MINES);
let P = -1;
let chainT = -9;
let dxs: number[] = [];
let dys: number[] = [];
let glow: number[] = [];
function distPx(a: number, b: number): number { return Math.hypot(cellCx(a) - cellCx(b), cellCy(a) - cellCy(b)); }
function startChain(p: number): void {
P = p;
chainT = sceneT;
for (let m = 0; m < COLS * ROWS; m += 1) {
if (B.mine[m] && B.exT[m] < 0) { B.exT[m] = sceneT + distPx(p, m) / RING_SPEED; }
}
B.exT[p] = sceneT;
}
// 가장 먼 지뢰 — 두 번째 연쇄를 반대편에서 시작한다
function farMine(from: number): number {
let best = -1;
for (let m = 0; m < COLS * ROWS; m += 1) {
if (B.mine[m] && (best < 0 || distPx(from, m) > distPx(from, best))) { best = m; }
}
return best;
}
function build(): void {
fitGeo(COLS, ROWS, 44, 14);
B = newBoard(COLS, ROWS, MINES);
placeMines(B, SEED + (lap % 2) * 11, -1);
dxs = [];
dys = [];
glow = [];
for (let i = 0; i < COLS * ROWS; i += 1) { dxs.push(0); dys.push(0); glow.push(0); }
P = -1;
chainT = -9;
let first = -1;
for (let m = 0; m < COLS * ROWS && first < 0; m += 1) { if (B.mine[m] && m % COLS > 4 && m % COLS < 11) { first = m; } }
if (first < 0) { first = B.mine.indexOf(true); }
script.push({ t: 0.3, f: function (): void { startChain(first); } });
script.push({ t: 4.6, f: function (): void {
for (let i = 0; i < COLS * ROWS; i += 1) { B.exT[i] = -1; }
P = -1;
} });
script.push({ t: 5.0, f: function (): void { startChain(farMine(first)); } });
}
// 충격파 장 — 터진 지뢰마다 퍼지는 고리가 칸을 바깥 방향으로 밀었다 되돌린다
function update(dt: number): void {
if (dt < 0) { return; }
for (let i = 0; i < COLS * ROWS; i += 1) { dxs[i] = 0; dys[i] = 0; glow[i] = 0; }
for (let s = 0; s < COLS * ROWS; s += 1) {
if (B.exT[s] < 0 || sceneT < B.exT[s]) { continue; }
const age = sceneT - B.exT[s];
if (age > 2.4) { continue; }
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;
}
}
}
function offOf(i: number): number[] { return [dxs[i], dys[i]]; }
function tintOf(i: number): RGBA | null {
const g = clamp(glow[i] * 0.55, 0, 0.55);
if (g < 0.03 || B.exT[i] >= 0 && sceneT >= B.exT[i]) { return null; }
return [BUTTER[0], BUTTER[1], BUTTER[2], g];
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
drawCells(B, offOf, tintOf, true);
if (P >= 0) {
const age = sceneT - chainT;
if (age >= 0 && age < 3) {
const r = age * RING_SPEED;
ring(cellCx(P), cellCy(P), r, 3.5, ROSE_DEEP, 0.75 * (1 - age / 3));
ring(cellCx(P), cellCy(P), Math.max(0, r - 34), 2.5, ROSE, 0.6 * (1 - age / 3));
}
}
}
function statusText(): string {
const k = String(countEx(B, sceneT));
if (!manual && sceneT >= LAP_S - 0.6) { return '연쇄 폭발 완료 — 지뢰 {m}개'.replace('{m}', String(MINES)); }
return '터진 지뢰 {k}/{m}개'.replace('{k}', k).replace('{m}', String(MINES));
}
// 직접 하면 지뢰를 누른 순간 연쇄가 시작되고, 지뢰가 아니면 그 칸의 숫자만 열린다
function onAct(i: number): void {
if (B.st[i] === 2) { return; }
if (B.mine[i]) {
if (P < 0) { startChain(i); }
} else {
revealFrom(B, [{ i: i, d: 0 }], sceneT, 0.1);
}
}
function onFlag(i: number): void { manualFlag(B, i); }
function onTake(): void { return; }
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={'밟은 지뢰에서 분홍 고리가 퍼지며 고리가 닿는 순서대로 판의 다른 지뢰가 터지고, 터진 지뢰마다 충격파가 칸을 바깥 방향으로 밀었다 되돌리며 그을음 자국이 남는다. 반대편 지뢰에서 한 번 더 이어진다'}></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>
);
}07 · A Shockwave Where a Stepped Mine Sets Off Every Mine on the Board in Sequence — Measured area 63.435% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
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;
}
08A Flag Counter Where Flags Stamp In on a Spring and the Remaining-Mine Count Rolls Down
A counter in a mobile game HUD fits a meter that rolls down as each flag lands. remain() returns MINES − countSt(B, 2), and when the value changes it writes mT, then for 0.2s pushes the old digits down while the new ones drop in, with pad3 fixing the sign and three digits. The scene plants 20 flags, 14 right and 6 wrong, the last at 4.48s, and the meter runs from 18 down to −2 and comes back to 4 once the wrong flags are lifted.

<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="Flags drop in on a spring and the remaining-mine meter above the board rolls down one step at a time. When 14 right flags and 6 wrong flags are mixed in, the meter passes 0 into negative numbers, a cross lights on each wrong flag at the check, and removing them rolls the meter back up"></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 });
// ── 08 깃발이 스프링으로 꽂히고 Mines left 수가 굴러 내려가는 깃발 계량기 ─────────────
const COLS = 15;
const ROWS = 7;
const MINES = 18;
const SEED = 4948;
// 깃발 하나가 꽂히는 간격(초) — 올바른 깃발·틀린 깃발이 섞여 꽂힌다
const FLAG_GAP = 0.22;
// 틀린 깃발을 끼워 넣는 순번과 맨 끝에 덧붙이는 틀린 깃발 수
const WRONG_AT = [4, 8, 11, 15];
const EXTRA_WRONG = 2;
const LAP_S = 9.6;
const START_S = 0.0;
const REST_S = 6.4;
let B = newBoard(COLS, ROWS, MINES);
let plan = [];
let wrongSet = [];
let checked = false;
// 계량기 — 지금 값과 직전 값, 바뀐 시각(굴러 내려가는 연출용)
let mNow = MINES;
let mPrev = MINES;
let mT = -9;
function remain() { return MINES - countSt(B, 2); }
function build() {
fitGeo(COLS, ROWS, 64, 12);
B = newBoard(COLS, ROWS, MINES);
const seed = SEED + (lap % 2) * 19;
placeMines(B, seed, -1);
const rng = seeded(seed + 5);
const mines = [];
const safe = [];
for (let i = 0; i < COLS * ROWS; i += 1) {
(B.mine[i] ? mines : safe).push(i);
}
// 올바른 깃발 14개(지뢰 14곳)와 틀린 깃발 4+2개(지뢰 아닌 칸)를 시드로 섞는다
const nCorrect = MINES - WRONG_AT.length;
for (let i = mines.length - 1; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = mines[i];
mines[i] = mines[j];
mines[j] = t;
}
for (let i = safe.length - 1; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = safe[i];
safe[i] = safe[j];
safe[j] = t;
}
plan = [];
wrongSet = [];
let ci = 0;
let wi = 0;
for (let k = 0; ci < nCorrect || wi < WRONG_AT.length; k += 1) {
if (WRONG_AT.indexOf(k) >= 0 && wi < WRONG_AT.length) {
plan.push(safe[wi]);
wrongSet.push(safe[wi]);
wi += 1;
}
else if (ci < nCorrect) {
plan.push(mines[ci]);
ci += 1;
}
}
for (let e = 0; e < EXTRA_WRONG; e += 1) {
plan.push(safe[wi]);
wrongSet.push(safe[wi]);
wi += 1;
}
checked = false;
mNow = MINES;
mPrev = MINES;
mT = -9;
plan.forEach(function (c, k) {
script.push({ t: 0.3 + FLAG_GAP * k, f: function () { B.st[c] = 2; B.flT[c] = sceneT; } });
});
const tEnd = 0.3 + FLAG_GAP * plan.length;
script.push({ t: tEnd + 0.6, f: function () {
checked = true;
wrongSet.forEach(function (c, k) { B.xT[c] = sceneT + k * 0.08; });
} });
script.push({ t: tEnd + 2.3, f: function () {
wrongSet.forEach(function (c, k) {
script.push({ t: sceneT + k * 0.12, f: function () { B.st[c] = 0; B.flT[c] = -1; B.xT[c] = -1; } });
});
} });
}
function update(dt) {
if (dt < 0) {
return;
}
const v = remain();
if (v !== mNow) {
mPrev = mNow;
mNow = v;
mT = sceneT;
}
}
function pad3(v) { return (v < 0 ? '-' : '') + ('00' + String(Math.abs(v))).slice(-3); }
function stampRing(i) {
const age = sceneT - B.flT[i];
if (B.st[i] !== 2 || B.flT[i] < 0 || age < 0 || age > 0.4) {
return;
}
ring(cellCx(i), cellCy(i), gs * (0.3 + age * 2.2), 2.5, LAV, 0.8 * (1 - age / 0.4));
}
function draw() {
skyFill(BG_TOP, BG_BOT);
drawCells(B, null, null, true);
for (let i = 0; i < COLS * ROWS; i += 1) {
stampRing(i);
}
// Mines left 계량기 — 판 위. 0 이 되면 민트, 0 밑으로 내려가 음수가 되면 분홍
const bg = mNow < 0 ? ROSE : (mNow === 0 ? MINT : BUTTER);
const bx = W / 2 - 52;
box(bx, 41, 104, 26, 9, bg, 1);
text('Mines left', bx - 8, 54, INK, 1, 11, 700, 'right');
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();
if (checked && !manual) {
const right = plan.length - wrongSet.length;
tag('Right flags {c}/{m}'.replace('{c}', String(right)).replace('{m}', String(MINES)), W - 12, 54, ROSE, INK, 11, 'right');
}
}
function statusText() {
const f = countSt(B, 2);
if (!manual && checked) {
const wr = wrongSet.filter(function (c) { return B.st[c] === 2; }).length;
if (wr > 0) {
return '{f} flags - {w} wrong'.replace('{f}', String(f)).replace('{w}', String(wr));
}
return 'Flag check done - {r} mines left'.replace('{r}', String(remain()));
}
return '{r} mines left - {f} flags'.replace('{r}', String(remain())).replace('{f}', String(f));
}
function onAct(i) { manualAct(B, SEED, i, 0.08); }
function onFlag(i) { manualFlag(B, i); }
function onTake() { checked = false; }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// FlagStampCounter - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./FlagStampCounter.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function FlagStampCounter() {
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 });
// ── 08 깃발이 스프링으로 꽂히고 남은 지뢰 수가 굴러 내려가는 깃발 계량기 ─────────────
const COLS = 15;
const ROWS = 7;
const MINES = 18;
const SEED = 4948;
// 깃발 하나가 꽂히는 간격(초) — 올바른 깃발·틀린 깃발이 섞여 꽂힌다
const FLAG_GAP = 0.22;
// 틀린 깃발을 끼워 넣는 순번과 맨 끝에 덧붙이는 틀린 깃발 수
const WRONG_AT = [4, 8, 11, 15];
const EXTRA_WRONG = 2;
const LAP_S = 9.6;
const START_S = 0.0;
const REST_S = 6.4;
let B: Board = newBoard(COLS, ROWS, MINES);
let plan: number[] = [];
let wrongSet: number[] = [];
let checked = false;
// 계량기 — 지금 값과 직전 값, 바뀐 시각(굴러 내려가는 연출용)
let mNow = MINES;
let mPrev = MINES;
let mT = -9;
function remain(): number { return MINES - countSt(B, 2); }
function build(): void {
fitGeo(COLS, ROWS, 64, 12);
B = newBoard(COLS, ROWS, MINES);
const seed = SEED + (lap % 2) * 19;
placeMines(B, seed, -1);
const rng = seeded(seed + 5);
const mines: number[] = [];
const safe: number[] = [];
for (let i = 0; i < COLS * ROWS; i += 1) { (B.mine[i] ? mines : safe).push(i); }
// 올바른 깃발 14개(지뢰 14곳)와 틀린 깃발 4+2개(지뢰 아닌 칸)를 시드로 섞는다
const nCorrect = MINES - WRONG_AT.length;
for (let i = mines.length - 1; i > 0; i -= 1) { const j = Math.floor(rng() * (i + 1)); const t = mines[i]; mines[i] = mines[j]; mines[j] = t; }
for (let i = safe.length - 1; i > 0; i -= 1) { const j = Math.floor(rng() * (i + 1)); const t = safe[i]; safe[i] = safe[j]; safe[j] = t; }
plan = [];
wrongSet = [];
let ci = 0;
let wi = 0;
for (let k = 0; ci < nCorrect || wi < WRONG_AT.length; k += 1) {
if (WRONG_AT.indexOf(k) >= 0 && wi < WRONG_AT.length) { plan.push(safe[wi]); wrongSet.push(safe[wi]); wi += 1; } else if (ci < nCorrect) { plan.push(mines[ci]); ci += 1; }
}
for (let e = 0; e < EXTRA_WRONG; e += 1) { plan.push(safe[wi]); wrongSet.push(safe[wi]); wi += 1; }
checked = false;
mNow = MINES;
mPrev = MINES;
mT = -9;
plan.forEach(function (c: number, k: number): void {
script.push({ t: 0.3 + FLAG_GAP * k, f: function (): void { B.st[c] = 2; B.flT[c] = sceneT; } });
});
const tEnd = 0.3 + FLAG_GAP * plan.length;
script.push({ t: tEnd + 0.6, f: function (): void {
checked = true;
wrongSet.forEach(function (c: number, k: number): void { B.xT[c] = sceneT + k * 0.08; });
} });
script.push({ t: tEnd + 2.3, f: function (): void {
wrongSet.forEach(function (c: number, k: number): void {
script.push({ t: sceneT + k * 0.12, f: function (): void { B.st[c] = 0; B.flT[c] = -1; B.xT[c] = -1; } });
});
} });
}
function update(dt: number): void {
if (dt < 0) { return; }
const v = remain();
if (v !== mNow) { mPrev = mNow; mNow = v; mT = sceneT; }
}
function pad3(v: number): string { return (v < 0 ? '-' : '') + ('00' + String(Math.abs(v))).slice(-3); }
function stampRing(i: number): void {
const age = sceneT - B.flT[i];
if (B.st[i] !== 2 || B.flT[i] < 0 || age < 0 || age > 0.4) { return; }
ring(cellCx(i), cellCy(i), gs * (0.3 + age * 2.2), 2.5, LAV, 0.8 * (1 - age / 0.4));
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
drawCells(B, null, null, true);
for (let i = 0; i < COLS * ROWS; i += 1) { stampRing(i); }
// 남은 지뢰 계량기 — 판 위. 0 이 되면 민트, 0 밑으로 내려가 음수가 되면 분홍
const bg = mNow < 0 ? ROSE : (mNow === 0 ? MINT : BUTTER);
const bx = W / 2 - 52;
box(bx, 41, 104, 26, 9, bg, 1);
text('남은 지뢰', bx - 8, 54, INK, 1, 11, 700, 'right');
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();
if (checked && !manual) {
const right = plan.length - wrongSet.length;
tag('정답 깃발 {c}/{m}'.replace('{c}', String(right)).replace('{m}', String(MINES)), W - 12, 54, ROSE, INK, 11, 'right');
}
}
function statusText(): string {
const f = countSt(B, 2);
if (!manual && checked) {
const wr = wrongSet.filter(function (c: number): boolean { return B.st[c] === 2; }).length;
if (wr > 0) { return '깃발 {f}개 · 틀린 깃발 {w}개'.replace('{f}', String(f)).replace('{w}', String(wr)); }
return '깃발 확인 완료 — 남은 지뢰 {r}개'.replace('{r}', String(remain()));
}
return '남은 지뢰 {r}개 · 깃발 {f}개'.replace('{r}', String(remain())).replace('{f}', String(f));
}
function onAct(i: number): void { manualAct(B, SEED, i, 0.08); }
function onFlag(i: number): void { manualFlag(B, i); }
function onTake(): void { checked = false; }
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={'깃발이 위에서 스프링으로 내려 꽂히고 판 위 남은 지뢰 계량기가 한 단계씩 굴러 내려간다. 올바른 깃발 14개와 틀린 깃발 6개가 섞여 꽂히면 계량기가 0 을 지나 음수로 내려가고, 확인 때 틀린 깃발에 가위표가 켜지며 걷어 내면 계량기가 다시 올라간다'}></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>
);
}08 · A Flag Counter Where Flags Stamp In on a Spring and the Remaining-Mine Count Rolls Down — Measured area 4.63% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
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();
09A Minefield Hero Where a Sonar Sweep Reveals the Numbers Under the Cells
A first screen for a game or security landing page suits a dark board where only the cells the sonar line has passed show their numbers. lagOf finds the angle by which the line has passed a cell's center direction, then exp(−lag / FADE_ANGLE) sets cell brightness and number opacity while exp(−lag / BLINK_ANGLE) sets a mine's blink. With OMEGA at 1.15 one sweep takes 5.46s, and a round LAP_S is two sweeps, 10.93s.

<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="Over a dark minefield a sonar line turns about the center of the board and lights the numbers of cells it has passed, which sink back slowly. Mine cells blink pink briefly when the line touches them. Two turns make one round"></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 });
// ── 09 소나가 칸 밑의 숫자를 비추는 지뢰밭 히어로 ───────────────────────────────
const COLS = 16;
const ROWS = 8;
const MINES = 20;
const SEED = 4949;
// 소나 선이 도는 각속도(rad/초)와 처음 각도 — 두 바퀴가 한 판
const OMEGA = 1.15;
const START_ANGLE = -0.6;
// 선이 지나간 뒤 숫자가 가라앉는 속도 — 지난 각도(rad)에 대한 지수 감쇠의 폭
const FADE_ANGLE = 0.95;
// 지뢰가 선에 닿았을 때 깜빡이는 각도 폭(rad)
const BLINK_ANGLE = 0.16;
const LAP_S = 2 * TAU / OMEGA;
const START_S = 0.0;
const REST_S = 2.2;
const NIGHT_TOP = [30, 36, 72];
const NIGHT_BOT = [56, 64, 116];
const DARK_TILE = [66, 76, 128];
const DARK_HI = [96, 108, 160];
const DARK_LO = [40, 46, 92];
const LIT_TILE = [218, 226, 248];
let B = newBoard(COLS, ROWS, MINES);
let pingT = [];
let lit = 0;
function build() {
fitGeo(COLS, ROWS, 44, 14);
B = newBoard(COLS, ROWS, MINES);
placeMines(B, SEED + (lap % 2) * 23, -1);
pingT = [];
for (let i = 0; i < COLS * ROWS; i += 1) {
pingT.push(-9);
}
}
function update(dt) { if (dt < 0) {
return;
} }
function sweepAngle() { return START_ANGLE + sceneT * OMEGA; }
// 칸이 선을 지나친 각도 — 0 이면 선이 지금 닿은 칸, 커질수록 오래전에 지나간 칸
function lagOf(i, a) {
const ang = Math.atan2(cellCy(i) - (gy + gs * ROWS / 2), cellCx(i) - (gx + gs * COLS / 2));
let d = (a - ang) % TAU;
if (d < 0) {
d += TAU;
}
return d;
}
function draw() {
skyFill(NIGHT_TOP, NIGHT_BOT);
const a = sweepAngle();
const ox = gx + gs * COLS / 2;
const oy = gy + gs * ROWS / 2;
lit = 0;
for (let i = 0; i < COLS * ROWS; i += 1) {
const x = cellX(i);
const y = cellY(i);
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;
}
box(x + 1, y + 2, gs - 2, gs - 2, 4, DARK_LO, 1);
box(x + 1, y + 1, gs - 2, gs - 3, 4, mix(DARK_TILE, LIT_TILE, k * 0.85), 1);
box(x + 3, y + 2, gs - 6, 2, 1, mix(DARK_HI, [255, 255, 255], k * 0.6), 0.8);
if (B.mine[i]) {
const blink = Math.exp(-lag / BLINK_ANGLE) + (pa >= 0 && pa < 0.6 ? 1 - pa / 0.6 : 0);
if (blink > 0.05) {
disc(cellCx(i), cellCy(i), gs * (0.16 + 0.14 * Math.min(1, blink)), ROSE, Math.min(1, blink));
ring(cellCx(i), cellCy(i), gs * (0.3 + 0.18 * (1 - Math.min(1, blink))), 2, ROSE, 0.7 * Math.min(1, blink));
}
}
else if (k > 0.05) {
numGlyph(B.num[i], cellCx(i), cellCy(i), gs, k);
}
if (B.st[i] === 2 && B.flT[i] >= 0) {
flagGlyph(cellCx(i), cellCy(i) + clamp(-26 * Math.exp(-9 * (sceneT - B.flT[i])), -26, 0), gs, 1);
}
}
// 소나 부채꼴 — 선 뒤로 12 조각이 점점 옅어진다. 판 밖으로 나가는 부분은 자른다
ctx.save();
ctx.beginPath();
ctx.rect(gx, gy, gs * COLS, gs * ROWS);
ctx.clip();
const R = 340;
for (let s = 0; s < 12; s += 1) {
ctx.beginPath();
ctx.moveTo(ox, oy);
ctx.arc(ox, oy, R, a - (s + 1) * 0.07, a - s * 0.07);
ctx.closePath();
ctx.fillStyle = rgba(MINT, 0.3 * (1 - s / 12));
ctx.fill();
}
seg(ox, oy, ox + Math.cos(a) * R, oy + Math.sin(a) * R, 2.5, MINT, 0.95);
ctx.restore();
disc(ox, oy, 5, MINT, 1);
ring(ox, oy, 10 + 6 * ((sceneT * 1.5) % 1), 2, MINT, 0.6 * (1 - ((sceneT * 1.5) % 1)));
}
function statusText() {
const r = String(Math.floor(sceneT * OMEGA / TAU) + 1 + lap * 2);
if (!manual && sceneT >= LAP_S - 0.6) {
return 'Sonar patrol done - {m} mines'.replace('{m}', String(MINES));
}
return 'Sonar lap {r} - {n} cells showing numbers'.replace('{r}', r).replace('{n}', String(lit));
}
// 직접 하면 누른 칸에 소나 신호를 한 번 보낸다
function onAct(i) { pingT[i] = sceneT; }
function onFlag(i) { manualFlag(B, i); }
function onTake() { return; }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// HeroSonarMinefield - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./HeroSonarMinefield.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function HeroSonarMinefield() {
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 });
// ── 09 소나가 칸 밑의 숫자를 비추는 지뢰밭 히어로 ───────────────────────────────
const COLS = 16;
const ROWS = 8;
const MINES = 20;
const SEED = 4949;
// 소나 선이 도는 각속도(rad/초)와 처음 각도 — 두 바퀴가 한 판
const OMEGA = 1.15;
const START_ANGLE = -0.6;
// 선이 지나간 뒤 숫자가 가라앉는 속도 — 지난 각도(rad)에 대한 지수 감쇠의 폭
const FADE_ANGLE = 0.95;
// 지뢰가 선에 닿았을 때 깜빡이는 각도 폭(rad)
const BLINK_ANGLE = 0.16;
const LAP_S = 2 * TAU / OMEGA;
const START_S = 0.0;
const REST_S = 2.2;
const NIGHT_TOP: RGB = [30, 36, 72];
const NIGHT_BOT: RGB = [56, 64, 116];
const DARK_TILE: RGB = [66, 76, 128];
const DARK_HI: RGB = [96, 108, 160];
const DARK_LO: RGB = [40, 46, 92];
const LIT_TILE: RGB = [218, 226, 248];
let B: Board = newBoard(COLS, ROWS, MINES);
let pingT: number[] = [];
let lit = 0;
function build(): void {
fitGeo(COLS, ROWS, 44, 14);
B = newBoard(COLS, ROWS, MINES);
placeMines(B, SEED + (lap % 2) * 23, -1);
pingT = [];
for (let i = 0; i < COLS * ROWS; i += 1) { pingT.push(-9); }
}
function update(dt: number): void { if (dt < 0) { return; } }
function sweepAngle(): number { return START_ANGLE + sceneT * OMEGA; }
// 칸이 선을 지나친 각도 — 0 이면 선이 지금 닿은 칸, 커질수록 오래전에 지나간 칸
function lagOf(i: number, a: number): number {
const ang = Math.atan2(cellCy(i) - (gy + gs * ROWS / 2), cellCx(i) - (gx + gs * COLS / 2));
let d = (a - ang) % TAU;
if (d < 0) { d += TAU; }
return d;
}
function draw(): void {
skyFill(NIGHT_TOP, NIGHT_BOT);
const a = sweepAngle();
const ox = gx + gs * COLS / 2;
const oy = gy + gs * ROWS / 2;
lit = 0;
for (let i = 0; i < COLS * ROWS; i += 1) {
const x = cellX(i);
const y = cellY(i);
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; }
box(x + 1, y + 2, gs - 2, gs - 2, 4, DARK_LO, 1);
box(x + 1, y + 1, gs - 2, gs - 3, 4, mix(DARK_TILE, LIT_TILE, k * 0.85), 1);
box(x + 3, y + 2, gs - 6, 2, 1, mix(DARK_HI, [255, 255, 255], k * 0.6), 0.8);
if (B.mine[i]) {
const blink = Math.exp(-lag / BLINK_ANGLE) + (pa >= 0 && pa < 0.6 ? 1 - pa / 0.6 : 0);
if (blink > 0.05) {
disc(cellCx(i), cellCy(i), gs * (0.16 + 0.14 * Math.min(1, blink)), ROSE, Math.min(1, blink));
ring(cellCx(i), cellCy(i), gs * (0.3 + 0.18 * (1 - Math.min(1, blink))), 2, ROSE, 0.7 * Math.min(1, blink));
}
} else if (k > 0.05) {
numGlyph(B.num[i], cellCx(i), cellCy(i), gs, k);
}
if (B.st[i] === 2 && B.flT[i] >= 0) { flagGlyph(cellCx(i), cellCy(i) + clamp(-26 * Math.exp(-9 * (sceneT - B.flT[i])), -26, 0), gs, 1); }
}
// 소나 부채꼴 — 선 뒤로 12 조각이 점점 옅어진다. 판 밖으로 나가는 부분은 자른다
ctx.save();
ctx.beginPath();
ctx.rect(gx, gy, gs * COLS, gs * ROWS);
ctx.clip();
const R = 340;
for (let s = 0; s < 12; s += 1) {
ctx.beginPath();
ctx.moveTo(ox, oy);
ctx.arc(ox, oy, R, a - (s + 1) * 0.07, a - s * 0.07);
ctx.closePath();
ctx.fillStyle = rgba(MINT, 0.3 * (1 - s / 12));
ctx.fill();
}
seg(ox, oy, ox + Math.cos(a) * R, oy + Math.sin(a) * R, 2.5, MINT, 0.95);
ctx.restore();
disc(ox, oy, 5, MINT, 1);
ring(ox, oy, 10 + 6 * ((sceneT * 1.5) % 1), 2, MINT, 0.6 * (1 - ((sceneT * 1.5) % 1)));
}
function statusText(): string {
const r = String(Math.floor(sceneT * OMEGA / TAU) + 1 + lap * 2);
if (!manual && sceneT >= LAP_S - 0.6) { return '소나 순찰 완료 — 지뢰 {m}개'.replace('{m}', String(MINES)); }
return '소나 {r}바퀴째 · 숫자가 보이는 칸 {n}칸'.replace('{r}', r).replace('{n}', String(lit));
}
// 직접 하면 누른 칸에 소나 신호를 한 번 보낸다
function onAct(i: number): void { pingT[i] = sceneT; }
function onFlag(i: number): void { manualFlag(B, i); }
function onTake(): void { return; }
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={'어두운 지뢰밭 위에서 소나 선이 판 가운데를 축으로 돌며 지나간 칸의 숫자를 밝히고 숫자는 천천히 가라앉는다. 지뢰 칸은 선이 닿을 때 분홍으로 짧게 깜빡인다. 두 바퀴가 한 판이다'}></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>
);
}09 · A Minefield Hero Where a Sonar Sweep Reveals the Numbers Under the Cells — Measured area 38.867% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
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;
}
When You Turn the Minesweeper Game Principle Into Code, How Many States Does One Cell Need?
A cell holds three states and four timestamps
One cell splits into whether it is a mine, its neighbor count, three states (closed, open, flagged) and four times at which things happened. Keeping the times apart from the state lets a scene such as the spread change the state at once and delay only what is drawn.
| Value | Meaning | Changed by |
|---|---|---|
mine |
Whether the cell is a mine | placeMines |
num |
Neighboring mine count | calcNums |
st 0, 1, 2 |
Closed, open, flagged | revealFrom, applyHint |
openT, exT, flT, xT |
Times of opening, blast, flag and cross | revealFrom, startChain, applyHint, doChord |
Putting it into a minesweeper site
For a minesweeper site, 05's findHint(b) takes one board and returns one hint, so a single button can call it. The board logic does not know the screen, so swapping the canvas for DOM cells keeps the same function as it is.
Using it as a game-making exercise
If you write a minesweeper javascript tutorial or set a coding exercise, give 03's calcNums as the first task and 01's revealFrom as the second, so counting neighbors and spreading open become separate problems. Anyone looking for minesweeper javascript code to lift can start with 05's subset comparison and 06's probability map, which also work as diagrams in a strategy write-up.
Make the Board an Array, Place Mines After the First Click, Count Neighbors and Spread Open From Empty Cells: The Order in Which One Minesweeper Board Gets Built
The order of building, opening and counting hints
05's build shows the whole order at once. It makes a new board, calls placeMines with the center cell as the safe cell, spreads with revealFrom, runs findHint and applyHint six times on a copy to see whether the hints chain, and then picks a seed that meets the condition.
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;
}
}
The search that picks a board worth showing
A seed does not promise a good board, so each scene searches candidates. 01 takes the first SEED + k whose floodSize reaches MIN_FLOOD 44, 05 tries up to 120 times for a board meeting the condition above, and 06 tries up to 600 times for a board with 28 to 64 opened cells.
Where Minesweeper Breaks: a Board Whose First Click Is a Mine, a Spread That Reads Cells Past the Board Edge and a Meter Whose Flag Count Drifts From the Remaining Mines
A board whose first click is a mine
02 shows the board with mines laid first beside the board built after the first click, but its blast sits at 1.0s in the timetable, before the first round starts at 2.2s. So the failure contrast shows from the second round on, and the first round opens on the step that marks the safe zone.
A spread that reads cells past the board edge
A spread reads eight neighbors, and a border cell that finds neighbors by adding i + 1 makes the right-edge cell neighbor the left-edge cell of the next row. This build keeps the edge check in nbrs alone, so number counting, the spread and the hints all use the same neighbor list.
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;
}
A meter whose flag count drifts from the remaining mines
08 plants 20 flags against 18 mines, so the meter reaches −2, a depth that EXTRA_WRONG (2) sets. pad3 adds the sign and pads to three digits, so −2 reads -002, and because a flag count cannot tell right from wrong, the 6 wrong flags show only through the crosses of the check step.
A board with no certain cell is rare
Among 600 boards spread from the center cell in 06, only 19 had no 0% or 100% cell among the closed cells, and all of them had 9 to 18 opened cells. So the scene runs solveOut to clear every certain cell and uses boards with 28 to 64 opened cells.
The complete source of the nine scenes and the editable files are in the zip below, and the archive password is vh7z44k3.
Change the Board Size and Mine Density and How Far Does the Deduction Hint Reach? Columns, Rows, Mine Count and Seed Compared
| Name | Changed value | Feel |
|---|---|---|
| Bigger board | 01 COLS×ROWS 16×8 → 30×16 (MINES 16 → 60) |
Holding density at 12.5% takes the first spread from 57 to 121 cells, yet the farthest cell is 16 steps away instead of 14, so the spread lasts 1.6s versus 1.4s. The cell shrinks from 28px to 15px and the number glyph from 17px to 9px |
| Denser mines | 02 MINES 22 → 40 |
At 17% to 31% density, 4 mines instead of 3 move out of the zone and the safe first move opens 9 cells instead of 77. Mines touch the zone ring, so only the middle cell is a 0 and the spread stops at distance 1 |
| Slower spread | 01 RIP_DELAY 0.1 → 0.25 |
The farthest cell opens at 3.5s instead of 1.4s, so the spread ends at 4.0s and briefly overlaps the second click at 3.9s. The press time of one cell, 0.28s, is unchanged, so each cell moves the same way |
What to check before changing a value
A deduction hint comes only from opened numbers, so when the opened cells fall from 77 to 9 the material for hints shrinks with them. Growing the board shrinks the cell from 28px to 15px and makes taps harder to land, so on a touch screen pick the cell size before the board size.
One Opened Board Under Reduced Motion: Accessibility Through Sentences That Read Cell States and Keyboard Movement Between Cells
Arrow keys move, open and flag
All nine stages are role="application", and pressing an arrow key while focused stops the auto demo and hands control over. Enter and Space open the cell (a chord when it is an opened number), F plants a flag, P pauses, N takes the next action in the timetable and R restarts.
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();
}
The status line stays quiet during the demo
The status line is aria-live="off" during the demo, so it is not read out as things happen, and it switches to polite the moment you take over. 0.6s before a round ends, the current state sentence is written once into a hidden role="status" paragraph.
// Tell screen readers once that a demo round has ended, and do not read it again when the board repeats
let doneSaid = false;
function checkDone() {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
Reduced motion and a hidden tab
With prefers-reduced-motion on, there is no loop, and showRest runs the timetable up to REST_S seconds and draws one opened board. A hidden tab stops the loop and the first frame back drops its dt, so the hidden time does not pour in at once.
function showRest() {
stopLoop();
lap = 0;
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
showNow();
The number colors range from 5.7:1 to 14.3:1 contrast on the cream #fcf9f4 background.
Minesweeper Game in JavaScript FAQ: Building Without a Library, Choosing Number Colors and Handling a Board With No Certain Cell
Does a minesweeper game javascript project need a library
Canvas 2D is enough, and none of the nine scenes uses a library, WebGL or a CDN. Randomness comes from a seeded generator so the same input builds the same board, and the board functions do not know the DOM, so you can lift them out and run them in Node.
How do I pick number colors that read well
Separate colors let the distribution read at a glance, but color alone should not carry the meaning, so this build draws the digit too and keeps all 8 colors at 5.7:1 or more against the background. If the background changes, measure the contrast again.
What do I do when no cell is certain
Compute the odds as 06 does and pick the palest cell. In round 3 here the palest cell is 0%, so opening it leaves a certain safe cell.
For references, MDN's requestAnimationFrame and Canvas 2D pages cover the loop and drawing, while prefers-reduced-motion and live regions cover the motion setting and announcements. For the piece-placing rules see the jigsaw puzzle post, for tile merging the 2048 game post, and for path finding on a grid the maze generator post.









