05隣の数字の条件を重ねて確実なマスを見つける推理ヒント
二つの数字マスの未開封の隣の集合を重ね、一方が他方に含まれるときは差の分のマスが地雷か安全かと結論づけ、根拠の二つの数字マスを線でつないで確実なマスに印をつけます。
使いどころ詰まった盤面で次の一手を示すヒント機能
pairHintsubsetOfHINT_SHOW

<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 オリジナル · SCSS と React は zip に同梱 · マインスイーパ 作り方 9種 — 広がって開くマスから確率マップの推理ヒントまで
このデモが属する記事
マインスイーパ 作り方 9種 — 広がって開くマスから確率マップの推理ヒントまで最初のクリックが地雷だと盤が台なしですが、02番は開いたあとで盤を組み、05番は数字二つを重ねて確実なマスを見つけ、06番は確実なマスがないときマスごとの確率を塗ります。マインスイーパ 作り方 9種は、盤の状態を計算するコードと、その状態だ この記事のデモをすべて見る


