지뢰찾기 만들기 9종 — 번져 열리는 칸부터 확률 지도 추론 힌트까지
첫 클릭이 지뢰면 그 판은 실패인데, 02번은 첫 칸을 연 뒤에 판을 짜고 05번은 숫자 두 개를 겹쳐 확실한 칸을 가려내며 06번은 확실한 칸이 없을 때 칸마다 확률을 칠한다. 지뢰찾기 만들기 9종은 판 상태를 계산하는 코드와 그 상태만 그리는 캔버스를 나눠 만든 데모다.
지뢰찾기는 규칙이 아니라 계산으로 이해한다 — 지뢰찾기 만들기는 칸 하나의 이웃 여덟 개를 세는 일에서 시작한다
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 빈 칸을 열면 물결처럼 번지는 연쇄 열기
- 02 첫 수가 지뢰를 밟지 않게 판을 그 뒤에 짜는 안전 첫 수
- 03 여덟 이웃의 지뢰를 세어 숫자를 채우는 스캔
- 04 숫자와 깃발 수가 같으면 이웃을 한 번에 여는 코드 열기
- 05 이웃 숫자의 조건을 겹쳐 확실한 칸을 찾아내는 추론 힌트
- 06 확실한 칸이 없을 때 칸마다 지뢰 확률을 색 농도로 칠하는 확률 지도
- 07 지뢰를 밟으면 판 전체의 지뢰가 연쇄로 터지는 충격파
- 08 깃발을 꽂을 때 스프링으로 박히고 남은 지뢰 수가 굴러 줄어드는 깃발 계량기
- 09 소나 선이 돌며 칸 아래의 숫자를 드러내는 지뢰밭 히어로
아홉 장면은 같은 구조를 쓴다. 지뢰 배치·숫자·열림·깃발은 판 로직이 만든 값이고, 화면은 그 값과 장면 시계를 비교해 그리기만 하므로 보이는 것은 연출이 아니라 계산된 판이다. 라이브러리와 CDN 은 쓰지 않고 난수는 시드를 고정한 생성기 하나뿐이라, 같은 시드는 같은 판을 만든다.
판 로직과 캔버스를 나눈 이유
판은 newBoard 가 만든 배열 묶음이다. placeMines·calcNums·revealFrom·chordList·pairHint·probMap 은 이 배열만 읽고 쓰는 함수이고, drawCells 가 칸마다 열린 시각·터진 시각·깃발 시각을 장면 시계 sceneT 와 비교해 그린다. 아홉 파일 모두 같은 판 로직 함수를 담고 있어 화면을 바꿔도 지뢰찾기 규칙은 건드리지 않는다.
function newBoard(cols, rows, count) {
const b = { cols: cols, rows: rows, count: count, mine: [], num: [], st: [], openT: [], exT: [], flT: [], xT: [], placed: false };
for (let i = 0; i < cols * rows; i += 1) {
b.mine.push(false);
b.num.push(0);
b.st.push(0);
b.openT.push(0);
b.exT.push(-1);
b.flT.push(-1);
b.xT.push(-1);
}
return b;
}
한 판은 시간표로 흘러간다
장면마다 script 에 (시각, 할 일) 쌍을 채워 두고 advance(dt) 가 시각이 지난 일을 한 번씩 부른다. 한 판 LAP_S 가 지나면 시드를 lap % 2 로 갈아 두 판을 번갈아 보이고, 첫 판은 START_S 만큼 앞선 지점에서 시작해 이미 진행 중인 장면이 첫 화면에 뜬다.
장면마다 얹은 계산 한 겹
01 은 거리 순서로 지연되는 번짐, 02 는 첫 클릭 둘레를 비워 지뢰를 옮기는 재배치, 03 은 이웃 수 스캔, 04 는 깃발 수 조건을 얹었다. 05 는 두 숫자의 닫힌 이웃 집합 비교, 06 은 배치 전수 계산과 조합 가중, 07 은 거리에 따른 폭발 시각과 충격파 장, 08 은 깃발 수에서 남은 지뢰를 되짚는 계량기, 09 는 선이 지나친 각도로 밝기를 감쇠시키는 소나를 얹었다.
지뢰찾기 만들기 9종 — 번져 열리는 연쇄 열기·판을 뒤에 짜는 안전 첫 수·이웃을 세는 숫자 스캔·한 번에 여는 코드 열기·조건을 겹치는 추론 힌트·확률을 칠하는 지도·연쇄 폭발 충격파·스프링 깃발 계량기·소나가 도는 히어로
아홉 장면을 렌더해서 얻은 면적·프레임·강도
| 장면 | 누적면적 | 움직인 프레임 | 강도 평균 |
|---|---|---|---|
| 01 연쇄 열기 | 24.9% | 16/23 | 31.3 |
| 02 안전 첫 수 | 34.6% | 20/23 | 58.1 |
| 03 숫자 스캔 | 34.8% | 23/23 | 71.3 |
| 04 코드 열기 | 6.3% | 13/23 | 38.9 |
| 05 추론 힌트 | 20.1% | 15/23 | 56.5 |
| 06 확률 지도 | 30.2% | 14/23 | 50.6 |
| 07 충격파 | 63.4% | 22/23 | 35.3 |
| 08 깃발 계량기 | 4.6% | 22/23 | 78.1 |
| 09 소나 히어로 | 38.9% | 23/23 | 48.4 |
화면 전체가 흔들리는 07 이 63.4% 로 가장 넓고, 숫자 하나가 바뀌는 계량기 위주의 08 이 4.6% 로 가장 좁은데 강도 평균은 78.1 로 가장 높다. 매 프레임 움직인 장면은 03 과 09 뿐이고, 04 는 13/23 이라 코드 열기를 기다리는 동안 변화가 없는 프레임이 많다.
01빈 칸을 열면 물결처럼 번지는 연쇄 열기
지뢰찾기 학습 사이트의 첫 판 화면에 두면 빈 칸 하나가 열릴 때 이웃 빈 칸이 거리 순서로 시차를 두고 열리고 숫자 칸에서 멈추는 번짐이 바로 보인다. revealFrom 은 거리가 가장 작은 칸부터 꺼내 openT = t0 + d × RIP_DELAY 로 열릴 시각을 적고, 열린 칸이 0 일 때만 이웃을 대기열에 넣는다. 첫 클릭은 0.5초에 57칸을 열고 가장 먼 칸이 거리 14 라서 번짐은 1.4초에 끝난다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="지뢰찾기 시연 무대 — 방향키로 칸 이동, Enter 로 열기, F 로 깃발, P 로 일시정지, N 으로 한 걸음, R 로 다시">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="16×8 판에서 숫자 0 인 빈 칸 하나를 열면 이웃 빈 칸이 거리 순서대로 0.1초씩 늦게 열려 물결처럼 번지고, 숫자 칸에서 번짐이 멈춘다. 열리는 칸은 눌렸다 올라오며 시작 칸에서 민트색 고리가 퍼진다. 세 번 눌러 번짐 두 번과 숫자 칸 하나가 열린다"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">지뢰찾기 시연</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
<button class="vj__step" data-role="step" type="button">한 걸음</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(바깥 칸, 남은 지뢰)가지로 나눠 가진다
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();
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
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 는 일시정지, N 은 한 걸음, 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 '연쇄 열기 완료 — 열림 {o}칸 · 지뢰 {m}개'.replace('{o}', o).replace('{m}', String(MINES));
}
return '열림 {o}칸 · 지뢰 {m}개'.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 · 빈 칸을 열면 물결처럼 번지는 연쇄 열기 — 측정 누적면적 24.889% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
const todo = src.slice();
const out = [];
while (todo.length > 0) {
let k = 0;
for (let j = 1; j < todo.length; j += 1) {
if (todo[j].d < todo[k].d) {
k = j;
}
}
const cur = todo.splice(k, 1)[0];
if (b.st[cur.i] !== 0 || b.mine[cur.i]) {
continue;
}
b.st[cur.i] = 1;
b.openT[cur.i] = t0 + cur.d * delay;
out.push(cur.i);
if (b.num[cur.i] === 0) {
for (const j of nbrs(b, cur.i)) {
if (b.st[j] === 0) {
todo.push({ i: j, d: cur.d + 1 });
}
}
}
}
return out;
02첫 수가 지뢰를 밟지 않게 판을 그 뒤에 짜는 안전 첫 수
첫 클릭에 지는 불공정을 없애는 규칙을 설명할 때는 판이 비어 있다가 첫 칸을 연 순간 짜인다는 점이 이 장면의 핵심이다. placeMines 는 시드로 모든 칸을 섞어 앞쪽 count 칸에 지뢰를 놓되, 첫 칸과 둘레 여덟 칸에 걸린 지뢰는 섞인 순서 뒤쪽 빈 칸으로 옮기고 [원래 칸, 새 칸] 목록을 돌려준다. 첫 클릭 칸 55 는 먼저 놓은 판에서 지뢰였고 구역 안 지뢰 3개가 옮겨지면 구역 안 지뢰는 0, 열린 칸은 77칸이다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="지뢰찾기 시연 무대 — 방향키로 칸 이동, Enter 로 열기, F 로 깃발, P 로 일시정지, N 으로 한 걸음, R 로 다시">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="지뢰를 먼저 놓은 판에서 첫 클릭이 지뢰를 밟아 실패하는 장면과, 같은 시드로 판을 비워 두었다가 첫 칸과 이웃 여덟 칸을 지뢰 금지 구역으로 잡고 그 안의 지뢰를 빈 칸으로 옮긴 뒤 숫자를 채워 여는 안전 첫 수를 이어서 보여 준다"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">지뢰찾기 시연</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
<button class="vj__step" data-role="step" type="button">한 걸음</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(바깥 칸, 남은 지뢰)가지로 나눠 가진다
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();
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
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 는 일시정지, N 은 한 걸음, 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 지뢰를 먼저 놓은 판 · 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 = '지뢰를 먼저 놓은 판';
}
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() {
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) {
// 직접 하면 판이 아직 비어 있어도 그 칸 둘레가 안전 구역이다
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 · 첫 수가 지뢰를 밟지 않게 판을 그 뒤에 짜는 안전 첫 수 — 측정 누적면적 34.646% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
if (safe >= 0) {
zone[safe] = true;
for (const j of nbrs(b, safe)) {
zone[j] = true;
}
}
const moves = [];
let next = b.count;
for (let i = 0; i < n; i += 1) {
b.mine[i] = false;
}
for (let k = 0; k < b.count; k += 1) {
const c = order[k];
if (!zone[c]) {
b.mine[c] = true;
continue;
}
while (next < n && (zone[order[next]] || b.mine[order[next]])) {
next += 1;
}
b.mine[order[next]] = true;
moves.push([c, order[next]]);
next += 1;
}
03여덟 이웃의 지뢰를 세어 숫자를 채우는 스캔
숫자가 어떻게 만들어지는지 보여 주는 규칙 해설에는 스캔 틀이 칸을 한 개씩 훑으며 이웃을 세는 장면이 맞다. calcNums 가 nbrs 로 판 안의 이웃만 골라 지뢰를 세고, 장면은 bornT(i) = SCAN_START + i / SCAN_RATE 로 센 값이 떠오르는 시각을 정한다. 128칸을 초당 32칸으로 훑으면 4.3초에 끝나고 지뢰가 아닌 106칸의 숫자는 0 이 28칸, 1 이 43칸, 2 가 24칸, 3 이 7칸, 4 가 4칸이다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="지뢰찾기 시연 무대 — 방향키로 칸 이동, Enter 로 열기, F 로 깃발, P 로 일시정지, N 으로 한 걸음, R 로 다시">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="지뢰 22개가 놓인 16×8 판을 3×3 스캔 틀이 한 칸씩 훑으며 여덟 이웃의 지뢰를 세고, 센 값이 칸 위로 떠올라 숫자로 자리 잡는다. 지뢰 이웃은 분홍 고리로 표시되고 숫자마다 칸에 번지는 색이 다르다"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">지뢰찾기 시연</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
<button class="vj__step" data-role="step" type="button">한 걸음</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(바깥 칸, 남은 지뢰)가지로 나눠 가진다
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();
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
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 는 일시정지, N 은 한 걸음, 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 '스캔 완료 — {s}칸 · 지뢰 {m}개'.replace('{s}', s).replace('{m}', String(MINES));
}
return '스캔 {s}/{t}칸'.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 · 여덟 이웃의 지뢰를 세어 숫자를 채우는 스캔 — 측정 누적면적 34.759% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function calcNums(b) {
for (let i = 0; i < b.cols * b.rows; i += 1) {
let n = 0;
if (!b.mine[i]) {
for (const j of nbrs(b, i)) {
if (b.mine[j]) {
n += 1;
}
}
}
b.num[i] = n;
}
}
04숫자와 깃발 수가 같으면 이웃을 한 번에 여는 코드 열기
숙련자용 단축 조작을 소개하는 가이드에서는 깃발 수와 숫자가 같을 때만 이웃이 열린다는 조건이 핵심이다. chordList 는 열린 숫자 칸이면서 숫자가 0 이 아니고 flagsAround 가 숫자와 같을 때만 닫힌 이웃 목록을 돌려주고, doChord 는 그 목록에 지뢰가 섞여 있으면 exT 로 터뜨리며 틀린 깃발 칸에 가위표를 켠다. 첫 장면은 깃발 두 개를 0.9초와 1.35초에 꽂고 2.15초에 이웃을 열며, 틀린 깃발 장면은 6.6초의 코드 열기로 지뢰를 터뜨리는데 이때 열린 칸은 61칸이다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="지뢰찾기 시연 무대 — 방향키로 칸 이동, Enter 로 열기, F 로 깃발, P 로 일시정지, N 으로 한 걸음, R 로 다시">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="열린 숫자 칸의 지뢰 이웃에 깃발이 하나씩 내려와 깃발 수가 숫자와 같아지면 남은 닫힌 이웃이 한꺼번에 열리고, 이어서 깃발을 틀린 칸에 꽂은 두 번째 숫자에서는 같은 규칙으로 지뢰까지 열려 터지며 틀린 깃발에 가위표가 켜진다"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">지뢰찾기 시연</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
<button class="vj__step" data-role="step" type="button">한 걸음</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(바깥 칸, 남은 지뢰)가지로 나눠 가진다
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();
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
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 는 일시정지, N 은 한 걸음, 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 '깃발이 틀려 지뢰를 열었다 — 열린 칸 {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) { 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 · 숫자와 깃발 수가 같으면 이웃을 한 번에 여는 코드 열기 — 측정 누적면적 6.338% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function chordList(b, i) {
if (b.st[i] !== 1 || b.num[i] === 0 || flagsAround(b, i) !== b.num[i]) {
return [];
}
const out = [];
for (const j of nbrs(b, i)) {
if (b.st[j] === 0) {
out.push(j);
}
}
return out;
}
05이웃 숫자의 조건을 겹쳐 확실한 칸을 찾아내는 추론 힌트
막힌 판에서 다음 수를 알려 주는 힌트 기능에는 숫자 칸 둘의 조건을 겹쳐 보는 이 방식이 맞다. consOf 가 열린 숫자마다 닫힌 이웃 u 와 남은 지뢰 r 을 모으면, pairHint 는 한 숫자의 u 가 다른 숫자의 u 에 통째로 들어 있을 때 차집합의 지뢰 수 r_B − r_A 로 안전·지뢰를 가른다. 16×8 판 150개에서 나온 힌트 3,536개를 실제 지뢰 배치와 대조해 어긋난 것이 0건이었고, 시연 첫 판의 힌트 6개는 전부 두 숫자 힌트다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="지뢰찾기 시연 무대 — 방향키로 칸 이동, Enter 로 열기, F 로 깃발, P 로 일시정지, N 으로 한 걸음, R 로 다시">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="열린 판에서 두 숫자 칸의 닫힌 이웃 집합을 견줘 한쪽이 다른 쪽에 통째로 들어 있으면 나머지 칸의 지뢰 수를 뺄셈으로 구해, 두 근거 숫자에서 확실한 칸까지 선을 긋고 안전한 칸은 열고 지뢰인 칸에는 깃발을 꽂는 추론이 여섯 번 이어진다"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">지뢰찾기 시연</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
<button class="vj__step" data-role="step" type="button">한 걸음</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(바깥 칸, 남은 지뢰)가지로 나눠 가진다
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();
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
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 는 일시정지, N 은 한 걸음, 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 ? '추론 {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) { 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 · 이웃 숫자의 조건을 겹쳐 확실한 칸을 찾아내는 추론 힌트 — 측정 누적면적 20.097% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function pairHint(cs) {
for (const B of cs) {
if (B.r === 0 || B.r === B.u.length) {
continue;
}
for (const A of cs) {
if (A.c === B.c || A.u.length >= B.u.length || !subsetOf(A.u, B.u)) {
continue;
}
const diff = B.u.filter(function (v) { return A.u.indexOf(v) < 0; });
const rd = B.r - A.r;
if (rd === 0) {
return { kind: 0, cells: diff, a: A.c, b: B.c };
}
if (rd === diff.length) {
return { kind: 1, cells: diff, a: A.c, b: B.c };
}
}
}
return null;
}
06확실한 칸이 없을 때 칸마다 지뢰 확률을 색 농도로 칠하는 확률 지도
운이 갈리는 구간에서 가장 안전한 수를 고르는 해설에는 칸마다 확률을 색 농도로 칠한 지도가 맞다. probMap 은 열린 숫자와 맞닿은 경계 칸의 배치를 재귀로 모두 세고, 바깥 칸은 남은 지뢰마다 comb(바깥 칸, 남은 지뢰) 가중치로 나눠 가지며 탐색 노드가 PROB_NODE_CAP 을 넘으면 멈춘다. 6×4 판 25개를 배치까지 직접 세어 비교한 최대 차이는 0 이고, 시연에서 가장 옅은 칸은 라운드 1 이 13.8%, 라운드 2 가 13.9%, 라운드 3 이 0% 다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="지뢰찾기 시연 무대 — 방향키로 칸 이동, Enter 로 열기, F 로 깃발, P 로 일시정지, N 으로 한 걸음, R 로 다시">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="확실한 칸이 없는 판에서 열린 숫자 조건을 모두 만족하는 배치를 세어 닫힌 칸마다 지뢰 확률을 구하고, 확률이 낮으면 옅은 민트, 높으면 진한 분홍으로 칠한 뒤 가장 옅은 칸에 고리를 두르고 열어 보는 일이 세 라운드 이어지며, 열고 나면 확률이 0 이 된 칸이 확실히 안전한 칸으로 드러난다"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">지뢰찾기 시연</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
<button class="vj__step" data-role="step" type="button">한 걸음</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(바깥 칸, 남은 지뢰)가지로 나눠 가진다
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();
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
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 는 일시정지, N 은 한 걸음, 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 '확실히 안전한 칸 생김 — 확률 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) { 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 · 확실한 칸이 없을 때 칸마다 지뢰 확률을 색 농도로 칠하는 확률 지도 — 측정 누적면적 30.206% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
const M = b.count - flags;
let total = 0;
let innerMines = 0;
const pf = [];
for (let k = 0; k < F.length; k += 1) {
pf.push(0);
}
for (let m = 0; m <= F.length; m += 1) {
if (sol[m] === 0) {
continue;
}
const w = comb(inner.length, M - m);
if (w === 0) {
continue;
}
total += w * sol[m];
innerMines += w * sol[m] * (M - m);
for (let k = 0; k < F.length; k += 1) {
pf[k] += w * cm[m][k];
}
}
07지뢰를 밟으면 판 전체의 지뢰가 연쇄로 터지는 충격파
패배 연출이 필요한 게임 결과 화면에서는 밟은 칸에서 퍼지는 원이 닿는 순서로 나머지 지뢰가 터지는 장면이 잘 맞는다. startChain 이 지뢰마다 exT = sceneT + 거리 / RING_SPEED 로 터질 시각을 적고, 충격파 장은 exp(−((d − r) / WAVE_W)²) 에 시간 감쇠를 곱해 칸을 바깥으로 PUSH 만큼 밀었다 되돌린다. 지뢰 18개 중 시작 지뢰에서 가장 먼 것은 424px 거리라 마지막 폭발은 0.3 + 424/170 으로 2.79초에 일어난다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="지뢰찾기 시연 무대 — 방향키로 칸 이동, Enter 로 열기, F 로 깃발, P 로 일시정지, N 으로 한 걸음, R 로 다시">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="밟은 지뢰에서 분홍 고리가 퍼지며 고리가 닿는 순서대로 판의 다른 지뢰가 터지고, 터진 지뢰마다 충격파가 칸을 바깥 방향으로 밀었다 되돌리며 그을음 자국이 남는다. 반대편 지뢰에서 한 번 더 이어진다"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">지뢰찾기 시연</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
<button class="vj__step" data-role="step" type="button">한 걸음</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(바깥 칸, 남은 지뢰)가지로 나눠 가진다
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();
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
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 는 일시정지, N 은 한 걸음, 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 '연쇄 폭발 완료 — 지뢰 {m}개'.replace('{m}', String(MINES));
}
return '터진 지뢰 {k}/{m}개'.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 · 지뢰를 밟으면 판 전체의 지뢰가 연쇄로 터지는 충격파 — 측정 누적면적 63.435% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
const r = age * WAVE_SPEED;
const amp = Math.exp(-age * 1.6);
for (let i = 0; i < COLS * ROWS; i += 1) {
const ddx = cellCx(i) - cellCx(s);
const ddy = cellCy(i) - cellCy(s);
const d = Math.hypot(ddx, ddy);
if (d < 1) {
continue;
}
const q = (d - r) / WAVE_W;
const e = Math.exp(-q * q) * amp;
dxs[i] += ddx / d * e * PUSH;
dys[i] += ddy / d * e * PUSH;
glow[i] += e;
}
08깃발을 꽂을 때 스프링으로 박히고 남은 지뢰 수가 굴러 줄어드는 깃발 계량기
모바일 게임 상단 HUD 에 쓰는 카운터에는 깃발을 꽂을 때마다 숫자가 굴러 줄어드는 계량기가 맞다. remain() 은 MINES − countSt(B, 2) 로 남은 지뢰 수를 구하고, 값이 바뀌면 mT 에 시각을 적어 0.2초 동안 옛 숫자를 아래로 밀며 새 숫자를 위에서 내려 pad3 로 부호와 세 자리를 맞춘다. 깃발은 올바른 것 14개와 틀린 것 6개를 합쳐 20개이고 마지막 깃발은 4.48초에 꽂히며, 계량기는 18 에서 −2 까지 내려갔다가 틀린 깃발을 걷어 내면 4 로 돌아온다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="지뢰찾기 시연 무대 — 방향키로 칸 이동, Enter 로 열기, F 로 깃발, P 로 일시정지, N 으로 한 걸음, R 로 다시">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="깃발이 위에서 스프링으로 내려 꽂히고 판 위 남은 지뢰 계량기가 한 단계씩 굴러 내려간다. 올바른 깃발 14개와 틀린 깃발 6개가 섞여 꽂히면 계량기가 0 을 지나 음수로 내려가고, 확인 때 틀린 깃발에 가위표가 켜지며 걷어 내면 계량기가 다시 올라간다"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">지뢰찾기 시연</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
<button class="vj__step" data-role="step" type="button">한 걸음</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(바깥 칸, 남은 지뢰)가지로 나눠 가진다
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();
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
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 는 일시정지, N 은 한 걸음, 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 깃발이 스프링으로 꽂히고 남은 지뢰 수가 굴러 내려가는 깃발 계량기 ─────────────
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);
}
// 남은 지뢰 계량기 — 판 위. 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() {
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}개 · 틀린 깃발 {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) { 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 · 깃발을 꽂을 때 스프링으로 박히고 남은 지뢰 수가 굴러 줄어드는 깃발 계량기 — 측정 누적면적 4.63% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
ctx.save();
ctx.beginPath();
ctx.rect(bx, 41, 104, 26);
ctx.clip();
const p = ease((sceneT - mT) / 0.2);
text(pad3(mPrev), W / 2, 54 + p * 18, INK, 1 - p, 20, 800, 'center');
text(pad3(mNow), W / 2, 54 - (1 - p) * 18, INK, p, 20, 800, 'center');
ctx.restore();
09소나 선이 돌며 칸 아래의 숫자를 드러내는 지뢰밭 히어로
게임·보안 서비스 랜딩의 첫 화면에는 소나 선이 지나간 칸만 숫자가 떠오르는 어두운 판이 맞다. lagOf 가 선이 칸 중심 방향을 지나친 각도를 구하면 칸 밝기와 숫자 불투명도는 exp(−lag / FADE_ANGLE) 로, 지뢰의 점멸은 exp(−lag / BLINK_ANGLE) 로 정해진다. OMEGA 가 1.15 라서 선 한 바퀴는 5.46초이고 한 판 LAP_S 는 두 바퀴인 10.93초다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="지뢰찾기 시연 무대 — 방향키로 칸 이동, Enter 로 열기, F 로 깃발, P 로 일시정지, N 으로 한 걸음, R 로 다시">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="어두운 지뢰밭 위에서 소나 선이 판 가운데를 축으로 돌며 지나간 칸의 숫자를 밝히고 숫자는 천천히 가라앉는다. 지뢰 칸은 선이 닿을 때 분홍으로 짧게 깜빡인다. 두 바퀴가 한 판이다"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">지뢰찾기 시연</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
<button class="vj__step" data-role="step" type="button">한 걸음</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(바깥 칸, 남은 지뢰)가지로 나눠 가진다
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();
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
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 는 일시정지, N 은 한 걸음, 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 '소나 순찰 완료 — 지뢰 {m}개'.replace('{m}', String(MINES));
}
return '소나 {r}바퀴째 · 숫자가 보이는 칸 {n}칸'.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 · 소나 선이 돌며 칸 아래의 숫자를 드러내는 지뢰밭 히어로 — 측정 누적면적 38.867% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
const lag = lagOf(i, a);
let k = Math.exp(-lag / FADE_ANGLE);
const pa = sceneT - pingT[i];
if (pa >= 0 && pa < 2) {
k = Math.max(k, Math.exp(-pa / 0.7));
}
if (k < 0.04) {
k = 0;
}
if (k > 0.3) {
lit += 1;
}
지뢰찾기 게임 원리를 코드로 옮기면 칸은 몇 가지 상태를 가져야 할까?
칸이 가진 값은 세 가지 상태와 네 개의 시각이다
칸 하나는 지뢰 여부와 이웃 수, 닫힘·열림·깃발 세 상태, 그리고 일이 일어난 시각 넷으로 나뉜다. 시각을 상태와 따로 두면 번짐처럼 열림이 거리만큼 늦게 일어나는 장면도 상태는 바로 바꾸고 화면만 늦출 수 있다.
| 값 | 뜻 | 바꾸는 함수 |
|---|---|---|
mine |
지뢰 여부 | placeMines |
num |
이웃 지뢰 수 | calcNums |
st 0·1·2 |
닫힘·열림·깃발 | revealFrom·applyHint |
openT·exT·flT·xT |
열림·폭발·깃발·가위표가 일어난 시각 | revealFrom·startChain·applyHint·doChord |
지뢰찾기 게임 사이트에 붙일 때
지뢰찾기 게임 사이트에 넣는다면 05 의 findHint(b) 는 판 하나를 받아 힌트 하나를 돌려주는 함수라 버튼 하나에 이을 수 있다. 판 로직이 화면을 모르기 때문에 캔버스를 DOM 칸으로 바꿔도 같은 함수를 그대로 쓴다.
게임 만들기 과제로 낼 때
코딩 과제라면 03 의 calcNums 를 첫 과제, 01 의 revealFrom 을 둘째 과제로 나누면 이웃 세기와 번져 열기가 서로 다른 문제로 풀린다. 지뢰찾기 게임 공략을 다루는 글을 쓸 때도 05 의 부분집합 비교와 06 의 확률 지도가 설명 그림이 된다.
판을 배열로 만들고, 첫 클릭 뒤에 지뢰를 놓고, 이웃을 세고, 빈 칸에서 번져 열기까지 — 지뢰찾기 한 판이 만들어지는 순서
판을 만들고 열고 힌트를 세는 순서
05 의 build 가 이 순서를 한 번에 보여 준다. 새 판을 만들고, 중앙 칸을 안전 칸으로 placeMines 하고, revealFrom 으로 번지게 하고, 판 복사본에서 findHint 와 applyHint 를 여섯 번 돌려 힌트가 이어지는지 본 뒤 조건에 맞는 시드를 고른다.
B = newBoard(COLS, ROWS, MINES);
placeMines(B, base + k, center);
revealFrom(B, [{ i: center, d: 0 }], -9, 0);
const c = cloneBoard(B);
let pairs = 0;
let mineHints = 0;
let total = 0;
for (let s = 0; s < 6; s += 1) {
const h = findHint(c);
if (!h) {
break;
}
total += 1;
if (h.b >= 0) {
pairs += 1;
}
if (h.kind === 1) {
mineHints += 1;
}
applyHint(c, h, -9, 0);
}
if (total >= 6 && pairs >= 4 && mineHints >= 1 && countSt(B, 1) >= 30) {
break;
}
}
시연에 맞는 판을 고르는 탐색
시드가 좋은 판을 주지 않을 때를 대비해 장면마다 후보를 훑는다. 01 은 SEED + k 에서 floodSize 가 MIN_FLOOD 44 이상인 판을, 05 는 위 조건에 맞는 판을 120번까지, 06 은 열린 칸이 28~64칸인 판을 600번까지 찾는다.
지뢰찾기가 깨지는 순간 — 첫 클릭이 지뢰인 판·번져 열기가 판 가장자리를 넘어 읽는 칸·깃발 수가 남은 지뢰 수와 어긋나는 계량기
첫 클릭이 지뢰인 판
02 는 지뢰를 먼저 놓은 판과 첫 클릭 뒤에 짠 판을 나란히 보여 주는데, 시간표의 폭발은 1.0초라 첫 판이 시작하는 2.2초보다 앞이다. 그래서 실패판 대비는 두 번째 판부터 보이고, 첫 판에서는 이미 안전 구역이 표시되는 단계가 먼저 눈에 들어온다.
번져 열기가 판 가장자리를 넘어 읽는 칸
번져 열기는 이웃 여덟 칸을 읽는데, 가장자리 칸이 i + 1 만 더해 이웃을 찾으면 오른쪽 끝 칸의 이웃이 다음 줄 왼쪽 끝 칸이 된다. 이 편은 경계 검사를 nbrs 한 곳에만 두어 숫자 세기·번짐·힌트가 같은 이웃 목록을 쓴다.
function nbrs(b, i) {
const x = i % b.cols;
const y = Math.floor(i / b.cols);
const out = [];
for (let dy = -1; dy <= 1; dy += 1) {
for (let dx = -1; dx <= 1; dx += 1) {
if (dx === 0 && dy === 0) {
continue;
}
const nx = x + dx;
const ny = y + dy;
if (nx >= 0 && nx < b.cols && ny >= 0 && ny < b.rows) {
out.push(ny * b.cols + nx);
}
}
}
return out;
}
깃발 수가 남은 지뢰 수와 어긋나는 계량기
08 은 깃발을 지뢰 수 18개보다 많은 20개까지 꽂아 계량기가 −2 로 내려가게 한다. pad3 가 부호를 붙여 세 자리로 맞추므로 −2 는 -002 로 보이고, 깃발 수는 정답 여부를 모르는 값이라 틀린 깃발 6개는 확인 단계의 가위표로만 드러난다.
확실한 칸이 없는 판은 드물다
06 은 중앙 칸을 열어 번진 판 600개 중 닫힌 칸에 확률 0% 와 100% 가 하나도 없는 판이 19개뿐이었고 모두 열린 칸이 9~18칸이었다. 그래서 solveOut 으로 확실한 칸을 모두 처리한 열린 칸 28~64칸의 판을 쓴다.
장면 아홉 개의 완성본 소스와 편집용 파일을 묶은 zip 은 아래에서 받으며, 압축 비밀번호는 vh7z44k3 이다.
판 크기와 지뢰 밀도를 바꾸면 추론 힌트가 닿는 칸은 얼마나 달라질까 — 열 수·지뢰 수·시드를 바꿔 본 차이
| 이름 | 바꾼 값 | 느낌 |
|---|---|---|
| 판을 크게 | 01 COLS×ROWS 16×8 → 30×16 (MINES 16 → 60) |
밀도 12.5% 를 지키면 첫 번짐이 57칸에서 121칸으로 늘지만 가장 먼 칸 거리는 14 에서 16 이라 번지는 시간은 1.4초에서 1.6초다. 칸 크기가 28px 에서 15px 로 줄어 숫자 글자는 17px 에서 9px 가 된다 |
| 지뢰를 빽빽하게 | 02 MINES 22 → 40 |
밀도가 17% 에서 31% 가 되면 옮길 지뢰가 3개에서 4개로 늘고 안전 첫 수 뒤 열린 칸은 77칸에서 9칸으로 줄어든다. 구역 둘레 이웃에 지뢰가 닿아 숫자 0 이 가운데 한 칸뿐이라 번짐이 거리 1 에서 멈춘다 |
| 번짐을 느리게 | 01 RIP_DELAY 0.1 → 0.25 |
가장 먼 칸이 열리는 시각이 1.4초에서 3.5초로 늦어져 번짐은 4.0초에 끝나고 3.9초의 둘째 클릭과 잠깐 겹친다. 칸이 눌리는 시간 0.28초는 그대로라 칸 하나의 움직임은 같다 |
값을 바꾸기 전에 볼 것
추론 힌트는 열린 숫자 칸에서만 나오므로, 열린 칸이 77에서 9로 줄면 힌트의 재료도 그만큼 줄어든다. 판을 키울 때는 칸 크기 28px 에서 15px 로 줄어 눌러 맞추기 어려워지니 터치 화면에는 판 크기보다 칸 크기를 먼저 정하는 쪽이 낫다.
움직임 줄이기에서는 열린 판 한 장 — 칸 상태를 문장으로 읽어 주고 키보드로 칸을 옮기는 접근성
방향키로 칸을 옮기고 열고 깃발을 꽂는다
아홉 장면 모두 무대 상자가 role="application" 이고, 포커스를 둔 채 방향키를 누르면 자동 시연이 멈추고 직접 하기로 넘어간다. Enter·Space 는 그 칸 열기(열린 숫자면 코드 열기), F 는 깃발이고, P 는 일시정지, N 은 시간표의 다음 일 하나, R 은 다시 시작이다.
if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowUp' || k === 'ArrowDown') {
e.preventDefault();
takeOver();
moveCur(k === 'ArrowLeft' ? -1 : (k === 'ArrowRight' ? 1 : 0), k === 'ArrowUp' ? -1 : (k === 'ArrowDown' ? 1 : 0));
showNow();
}
else if (k === 'Enter' || k === ' ') {
e.preventDefault();
takeOver();
onAct(ky * gCols + kx);
showNow();
}
else if (k === 'f' || k === 'F') {
e.preventDefault();
takeOver();
onFlag(ky * gCols + kx);
showNow();
}
상태 줄은 시연 중에는 조용하다
상태 줄은 시연 중 aria-live="off" 라 일이 벌어질 때마다 읽히지 않고, 직접 하는 순간 polite 로 바뀐다. 한 판이 끝나기 0.6초 전에는 role="status" 숨김 문단에 현재 상태 문장을 한 번만 쓴다.
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
움직임 줄이기와 숨은 탭
prefers-reduced-motion 이 켜져 있으면 루프 없이 showRest 가 REST_S 초 지점까지 시간표를 돌려 열린 판 한 장만 그린다. 탭이 숨으면 루프를 멈추고 돌아오면 첫 프레임의 dt 를 버려서 숨은 시간이 한꺼번에 쏟아지지 않는다.
function showRest() {
stopLoop();
lap = 0;
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
showNow();
숫자 색 8종은 크림 #fcf9f4 바탕에서 대비가 5.7:1 에서 14.3:1 사이다.
지뢰찾기 게임 만들기 FAQ — 라이브러리 없이 만들기·숫자 색 고르기·확실한 칸이 없는 판의 처리
지뢰찾기 게임 만들기에 라이브러리가 필요한가요
캔버스 2D 만 있으면 되고, 이 편의 아홉 장면은 라이브러리·WebGL·CDN 을 하나도 쓰지 않는다. 난수는 시드를 고정한 생성기로 만들어 같은 입력이 같은 판을 만들고, 판 로직 함수는 DOM 을 모르므로 따로 떼어 노드에서 돌려 볼 수 있다.
숫자 색은 어떻게 골라야 읽히나요
숫자마다 색이 달라야 분포가 한눈에 읽히지만 색만으로 구분하면 안 되므로, 이 편은 숫자 글자를 같이 그리고 8종 모두 바탕과의 대비를 5.7:1 이상으로 맞췄다. 바탕이 바뀌면 숫자 색의 대비도 다시 재야 한다.
확실한 칸이 없으면 어떻게 하나요
그때는 06 처럼 확률을 계산해 가장 옅은 칸을 고르면 된다. 이 편의 라운드 3 에서는 가장 옅은 칸이 0% 라서, 열고 나면 확실히 안전한 칸이 생긴다.
참고로 MDN 의 requestAnimationFrame 문서와 Canvas 2D 문서, 움직임 줄이기 설정은 prefers-reduced-motion 문서와 라이브 영역 문서가 기준이다. 퍼즐을 맞추는 규칙은 직소 퍼즐 편을, 칸을 합치는 규칙은 2048 게임 편을, 격자 위에서 길을 찾는 장면은 미로 생성기 편을 이어서 보면 된다.









