数独 9種 — 候補の絞り込みから解がひとつの盤づくりまで
数独は当てずっぽうで解くゲームではありません。01番はマスごとの候補をビットひとつずつ消し、03番は候補がいちばん少ないマスから戻りながら解き、04番は穴を開けるたびに解がひとつだけか数えます。
数独は空きマスに数字を当ててみるゲームではなく、行・列・3×3の箱ですでに出た数字を消して、残った候補から解く計算です — 数独のゲームを作るには、候補をどんな値で持つかを決めることから始まります
01 sd-candidate-bitmask-map
02 sd-single-fill-cascade
03 sd-fewest-first-backtrack
04 sd-unique-dig-count
05 sd-conflict-peer-mark
06 sd-hint-reason-trace
07 sd-digit-left-ring
08 sd-solved-wave-flourish
09 sd-difficulty-score-bar
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 マスごとの候補をビットで消す絞り込み図
- 02 ひとつだけの候補を埋めると次が解ける連鎖
- 03 候補がいちばん少ないマスから戻る探索
- 04 穴を開けるたびに解の数を数える盤づくり
- 05 同じ行・箱で重なった数字を示す衝突の表示
- 06 理由まで見せるヒント — どの行が候補を消したか
- 07 数字ごとの残り数をリングで縮めるキーパッド
- 08 完成するとマスごとに時間差で広がる波
- 09 必要な手筋で測る難易度の点数バー
数独 解き方の基本は消去なので、九つの場面は同じ盤のモデルをひとつ使い、場面ごとに違うのはその盤から読む値だけです。候補をマスごとに別々に書き留めず盤の配列からそのつど計算すると、数字が変わった瞬間に候補も一緒に変わります。
候補は9ビットの整数ひとつです
盤は81マスの数字の配列(0は空きマス)で、数字dは(d-1)番目のビットで表します。全部の候補は ALL の511なので候補の集合がそのまま整数ひとつになり、残った候補の数は立っているビットを数える popcount で分かります。
乱数なしで同じ盤がもう一度出ます
盤は整数のハッシュ hash2(a, b) で混ぜた完成盤から穴を開けて作ります。同じ種なら同じ盤が出て、時間は場面の時計ひとつを1/60秒の固定の歩幅で消化します。
const ALL = 511;
function bit(d) { return 1 << (d - 1); }
function popcount(m) {
let c = 0;
let v = m;
while (v) {
v &= v - 1;
c += 1;
}
return c;
}
数独 9種 — 候補の絞り込み図・ひとつだけの候補の連鎖埋め・候補の少ないマスから戻る探索・解がひとつの盤づくり・重なった数字の表示・理由を見せるヒント・数字ごとの残り数・完成の波・難易度の点数
九つの場面を描画して測った面積とフレーム
| 場面 | 累積面積 | 動いたフレーム | 強さの平均 |
|---|---|---|---|
| 01 候補の絞り込み図 | 21.3% | 23/23 | 46.1 |
| 02 連鎖埋め | 24.6% | 23/23 | 40.3 |
| 03 少ないマスから戻る探索 | 12.4% | 21/23 | 96.7 |
| 04 穴を開ける盤づくり | 20.9% | 21/23 | 29.7 |
| 05 重なりの表示 | 17.1% | 14/23 | 42.8 |
| 06 絞り込みの根拠ヒント | 8.2% | 9/23 | 58.9 |
| 07 残り数のリング | 20.8% | 18/23 | 53.9 |
| 08 完成の波 | 31.9% | 17/23 | 92.5 |
| 09 難易度の点数 | 20.4% | 21/23 | 123.8 |
累積面積が最も広いのは08の31.9%で、最も狭いのは06の8.2%です。動いたフレームは01と02が23/23、06が9/23でいちばん少なく、強さの平均は09の123.8が最高、04の29.7が最低です。
01マスごとの候補をビットで消す絞り込み図
マスごとに1〜9を9ビットひとつで持ち、同じ行・列・箱にある数字のビットをORで集めて引くので、メモの候補を自動で入れて消すことに使えます。マスの色は候補がひとつなら緑、七つ以上なら地の色です。ノードで関数をそのまま動かすと、盤の空きマスの候補と一致します。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="数独の舞台 — 矢印キーでマス移動、数字キー1〜9で入力、Deleteで消去、Hでヒント、Nで新しい盤、Rで最初から、EnterかSpaceで一時停止">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="暗い梅色の背景に9×9の数独の盤があり、空きマスごとに残りの候補が九つの位置の小さな点で打たれる。候補が少ないマスほど明るいミント色になる。数字が八つ順に置かれると、同じ行・列・箱で候補が減るマスが置いたマスから遠いほど遅れて赤く広がる。右のパネルには比較用のマス一つについて行・列・箱が使った数字の9ビットの列とその合計、残った候補の列が出る。下の目盛りは定数名と今の値を示し、動きを減らす設定では数字がすぐ埋まった盤一枚だけを描きます"></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>
</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-ink;
}
// 무대 상자 — 눌러서 한 번 깨뜨리는 곳(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-cream;
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 {
padding: $sp-1 $sp-3;
border: 2px solid $subject-cream;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
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__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-ink;
color: $subject-cream;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면에만 있고 aria-live 는 늘 off
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 100px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
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__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 글리치 없는 원문 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause: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: #17141a;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #fff7e6;
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 {
padding: 4px 12px;
border: 2px solid #fff7e6;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
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__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 100px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
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__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause: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 easeOut(k) { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const SKY = [150, 192, 244];
const SUN = [250, 218, 146];
const NEU = [236, 230, 250];
const BGC = [46, 42, 68];
const PANEL = [62, 57, 92];
const DIM = [196, 190, 224];
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) + ')';
}
function hsl(h, s, l) {
const a = s * Math.min(l, 1 - l);
const f = function (n) {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a, b) {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s, a, b, c) {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v) { return String(Math.round(v * 10) / 10); }
function group(v) {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
o += ',';
}
o += s.charAt(i);
}
return o;
}
// ── 스도쿠 모델 — 판은 81칸 숫자 배열(0 은 빈칸). 후보는 9비트 한 수: 숫자 d 는 (d-1) 번째 비트
const ALL = 511;
function bit(d) { return 1 << (d - 1); }
function popcount(m) {
let c = 0;
let v = m;
while (v) {
v &= v - 1;
c += 1;
}
return c;
}
// 가장 낮은 비트 하나가 가리키는 숫자
function lowDigit(m) { return 32 - Math.clz32(m & -m); }
function bin9(m) {
let s = '';
for (let d = 9; d >= 1; d -= 1) {
s += (m & bit(d)) ? '1' : '0';
}
return s;
}
function rowOf(i) { return Math.floor(i / 9); }
function colOf(i) { return i % 9; }
function boxOf(i) { return Math.floor(i / 27) * 3 + Math.floor((i % 9) / 3); }
// 묶음 27개 — 줄 9 · 세로줄 9 · 箱 9. 칸의 이웃은 같은 묶음 세 곳의 다른 칸 20개
const UNITS = [];
const PEERS = [];
for (let k = 0; k < 27; k += 1) {
UNITS.push([]);
}
for (let i = 0; i < 81; i += 1) {
UNITS[rowOf(i)].push(i);
UNITS[9 + colOf(i)].push(i);
UNITS[18 + boxOf(i)].push(i);
}
for (let i = 0; i < 81; i += 1) {
const ps = [];
for (let j = 0; j < 81; j += 1) {
if (j !== i && (rowOf(j) === rowOf(i) || colOf(j) === colOf(i) || boxOf(j) === boxOf(i))) {
ps.push(j);
}
}
PEERS.push(ps);
}
// 묶음 종류 kind: 0 줄 · 1 세로줄 · 2 箱
function unitIndex(kind, i) { return kind === 0 ? rowOf(i) : (kind === 1 ? 9 + colOf(i) : 18 + boxOf(i)); }
function sharedKind(i, j) {
if (rowOf(i) === rowOf(j)) {
return 0;
}
if (colOf(i) === colOf(j)) {
return 1;
}
return boxOf(i) === boxOf(j) ? 2 : -1;
}
// 칸 i 가 속한 kind 묶음에서 i 를 뺀 칸들이 이미 쓴 숫자의 비트 合計(OR)
function usedMask(b, i, kind) {
const u = UNITS[unitIndex(kind, i)];
let m = 0;
for (let k = 0; k < 9; k += 1) {
if (u[k] !== i && b[u[k]] !== 0) {
m |= bit(b[u[k]]);
}
}
return m;
}
// 候補 = 9비트 전체에서 세 묶음이 쓴 비트를 뺀 것. 채워진 칸의 후보는 0
function candMask(b, i) {
if (b[i] !== 0) {
return 0;
}
return ALL & ~(usedMask(b, i, 0) | usedMask(b, i, 1) | usedMask(b, i, 2));
}
function filledOf(b) {
let c = 0;
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
c += 1;
}
}
return c;
}
function perm(n, seed) {
const a = [];
for (let i = 0; i < n; i += 1) {
a.push(i);
}
for (let i = n - 1; i > 0; i -= 1) {
const j = Math.floor(hash2(i, seed) * (i + 1));
const t = a[i];
a[i] = a[j];
a[j] = t;
}
return a;
}
// 완성판 — 기본 무늬를 숫자·줄·箱 묶음 순서로 섞는다. 같은 씨앗이면 같은 판
function fullBoard(seed) {
const dp = perm(9, seed * 7 + 1);
const bp = perm(3, seed * 7 + 2);
const sp = perm(3, seed * 7 + 3);
const rows = [];
const cols = [];
for (let k = 0; k < 3; k += 1) {
const rp = perm(3, seed * 13 + k * 5 + 4);
const cp = perm(3, seed * 13 + k * 5 + 9);
for (let j = 0; j < 3; j += 1) {
rows.push(bp[k] * 3 + rp[j]);
cols.push(sp[k] * 3 + cp[j]);
}
}
const b = [];
for (let r = 0; r < 9; r += 1) {
for (let c = 0; c < 9; c += 1) {
b.push(dp[(3 * (rows[r] % 3) + Math.floor(rows[r] / 3) + cols[c]) % 9] + 1);
}
}
return b;
}
function search(start, limit, cap, plain, wantTrace) {
const b = start.slice();
const rm = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const cm = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const xm = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
rm[rowOf(i)] |= bit(b[i]);
cm[colOf(i)] |= bit(b[i]);
xm[boxOf(i)] |= bit(b[i]);
}
}
const r = { count: 0, nodes: 0, dead: 0, branch: 0, capHit: false, first: [], trace: [] };
function rec() {
let best = -1;
let bm = 0;
let bc = 10;
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
continue;
}
const m = ALL & ~(rm[rowOf(i)] | cm[colOf(i)] | xm[boxOf(i)]);
const pc = popcount(m);
if (plain) {
best = i;
bm = m;
bc = pc;
break;
}
if (pc < bc) {
best = i;
bm = m;
bc = pc;
if (pc <= 1) {
break;
}
}
}
if (best < 0) {
r.count += 1;
if (r.count === 1) {
r.first = b.slice();
}
return;
}
if (bc === 0) {
r.dead += 1;
return;
}
if (bc > 1) {
r.branch += 1;
}
let mm = bm;
while (mm) {
const lo = mm & -mm;
mm ^= lo;
const d = 32 - Math.clz32(lo);
r.nodes += 1;
if (r.nodes > cap) {
r.capHit = true;
return;
}
b[best] = d;
rm[rowOf(best)] |= lo;
cm[colOf(best)] |= lo;
xm[boxOf(best)] |= lo;
if (wantTrace) {
r.trace.push((best << 4) | d);
}
rec();
if (r.count >= limit || r.capHit) {
return;
}
b[best] = 0;
rm[rowOf(best)] &= ~lo;
cm[colOf(best)] &= ~lo;
xm[boxOf(best)] &= ~lo;
if (wantTrace) {
r.trace.push((1 << 16) | (best << 4) | d);
}
}
}
rec();
return r;
}
function singlesFill(start) {
const b = start.slice();
const order = [];
const digits = [];
const avail = [];
for (;;) {
let first = -1;
let n = 0;
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0 && popcount(candMask(b, i)) === 1) {
if (first < 0) {
first = i;
}
n += 1;
}
}
if (first < 0) {
break;
}
const d = lowDigit(candMask(b, first));
b[first] = d;
order.push(first);
digits.push(d);
avail.push(n);
}
let solved = true;
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0) {
solved = false;
}
}
return { order: order, digits: digits, avail: avail, solved: solved, board: b };
}
function digBoard(sol, seed, target, singlesOnly) {
const puz = sol.slice();
const order = perm(81, seed * 31 + 5);
const steps = [];
let clues = 81;
for (let k = 0; k < 81; k += 1) {
if (clues <= target) {
break;
}
const c = order[k];
const d = puz[c];
puz[c] = 0;
const s = search(puz, 2, 6000, false, false);
const cnt = s.capHit ? 2 : Math.min(2, s.count);
const ok = cnt === 1 && (!singlesOnly || singlesFill(puz).solved);
steps.push({ cell: c, cnt: cnt, ok: ok, nodes: s.nodes });
if (ok) {
clues -= 1;
}
else {
puz[c] = d;
}
}
return { puz: puz, steps: steps, clues: clues };
}
// 놓은 숫자가 겹치는 쌍 — [칸, 칸, 묶음 종류]
function conflicts(b) {
const out = [];
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0) {
continue;
}
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (p > i && b[p] === b[i]) {
out.push([i, p, sharedKind(i, p)]);
}
}
}
return out;
}
// ── 판 상태 — 장면이 아니라 판이 들고 있는 것. 화면은 이 값을 읽어 그린다
const BX = 22;
const BY = 44;
const CS = 23;
const GAP = 3;
const PX = 258;
const PW = 210;
let g = [];
let giv = [];
let sol = [];
let pt = [];
let cur = 40;
let solvedT = -1;
let lastCell = -1;
let rip = { i: -1, t: -9, list: [], d: 0 };
let selDigit = 0;
let still = false;
let hintHook = null;
function cellX(i) { const c = colOf(i); return BX + c * CS + Math.floor(c / 3) * GAP; }
function cellY(i) { const r = rowOf(i); return BY + r * CS + Math.floor(r / 3) * GAP; }
function cellCx(i) { return cellX(i) + CS / 2; }
function cellCy(i) { return cellY(i) + CS / 2; }
function cellAt(x, y) {
let c = -1;
let r = -1;
for (let k = 0; k < 9; k += 1) {
const x0 = BX + k * CS + Math.floor(k / 3) * GAP;
const y0 = BY + k * CS + Math.floor(k / 3) * GAP;
if (x >= x0 && x < x0 + CS) {
c = k;
}
if (y >= y0 && y < y0 + CS) {
r = k;
}
}
return c < 0 || r < 0 ? -1 : r * 9 + c;
}
function setBoard(puz, solution) {
g = puz.slice();
sol = solution.slice();
giv = [];
pt = [];
for (let i = 0; i < 81; i += 1) {
giv.push(puz[i] !== 0);
pt.push(-9);
}
solvedT = -1;
lastCell = -1;
rip = { i: -1, t: -9, list: [], d: 0 };
}
function solvedNow() { return filledOf(g) === 81 && conflicts(g).length === 0; }
function checkSolved() {
if (solvedNow()) {
if (solvedT < 0) {
solvedT = sceneT;
}
}
else {
solvedT = -1;
}
}
// 숫자를 놓는다 — 놓기 전에 그 숫자가 후보에 있던 이웃 칸을 기억해 두면 소거가 번지는 모습을 그릴 수 있다
function putDigit(i, d) {
if (giv[i] || d < 1 || d > 9) {
return false;
}
const list = [];
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (g[p] === 0 && (candMask(g, p) & bit(d)) !== 0) {
list.push(p);
}
}
g[i] = d;
pt[i] = sceneT;
rip = { i: i, t: sceneT, list: list, d: d };
lastCell = i;
checkSolved();
return true;
}
function clearDigit(i) {
if (giv[i] || g[i] === 0) {
return;
}
g[i] = 0;
pt[i] = -9;
checkSolved();
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// ── 그리기 도구
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 doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 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 disc(x, y, r, c, a) {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, c, a, w) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s, x, y, c, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x, y, w, h) { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name, val, frac) {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// ── 판 그리기 — 칸 색은 장면이 정하고, 숫자는 판이 그린다
const EMPTY = [58, 53, 88];
const PLACED = [190, 214, 246];
const PLACED_INK = [28, 54, 118];
function ring(x, y, w, h, r, c, a, lw) {
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = lw;
ctx.stroke();
}
function drawBoard(fillOf, extra) {
const s = 9 * CS + 2 * GAP;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < 81; i += 1) {
const x = cellX(i);
const y = cellY(i);
let f = fillOf(i);
if (f === null) {
f = g[i] === 0 ? EMPTY : (giv[i] ? NEU : PLACED);
}
box(x + 1, y + 1, CS - 2, CS - 2, 4, f, 1);
if (g[i] !== 0) {
const a = still ? 99 : sceneT - pt[i];
const k = a < 0.3 ? 0.7 + 0.3 * easeOut(a / 0.3) : 1;
text(String(g[i]), x + CS / 2, y + CS / 2 + 1, giv[i] ? INK : PLACED_INK, Math.round(14 * k), giv[i] ? 800 : 700, 'center');
}
else if (extra !== null) {
extra(i, x, y);
}
}
if (!demo) {
ring(cellX(cur) + 0.5, cellY(cur) + 0.5, CS - 1, CS - 1, 4, PAPER, 1, 2);
}
}
// 묶음 하나를 옅게 물들인다
function tintUnit(u, c, a) {
for (let k = 0; k < 9; k += 1) {
const i = UNITS[u][k];
box(cellX(i) + 1, cellY(i) + 1, CS - 2, CS - 2, 4, c, a);
// 물들임이 숫자를 흐리지 않도록 숫자를 다시 얹는다
if (g[i] !== 0) {
text(String(g[i]), cellCx(i), cellCy(i) + 1, giv[i] ? INK : PLACED_INK, 14, giv[i] ? 800 : 700, 'center');
}
}
}
// 候補 비트 줄 — 숫자 1~9 칸 아홉 개, 켜진 비트만 밝다
function bitRow(x, y, m, on, off) {
for (let d = 1; d <= 9; d += 1) {
const lit = (m & bit(d)) !== 0;
const bx = x + (d - 1) * 18;
box(bx, y, 16, 16, 4, lit ? on : off, 1);
text(String(d), bx + 8, y + 8.5, lit ? INK : DIM, 10, 700, 'center');
}
}
// ── 상태 줄·낭독 문장
const SPK_FILL = '{a} マスのうち {b} マスが埋まっている';
const SPK_DONE = '盤が完成、{a} マスすべてが埋まった';
function speakBoard() {
return solvedNow() ? fill(SPK_DONE, '81') : fill(SPK_FILL, '81', String(filledOf(g)));
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 100 + 채운 칸 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode) {
if (solvedNow()) {
return 1000;
}
return manual ? 100 + filledOf(g) : sceneMode;
}
// 방금 놓은 칸의 줄·세로줄·箱 세 묶음을 한 번 물들였다 서서히 빼는 번짐 — 숫자 하나가 어디까지 영향을 주는지 보인다
function unitPulse(c, a, dur) {
if (rip.i < 0 || still) {
return;
}
const k = (sceneT - rip.t) / dur;
if (k < 0 || k >= 1) {
return;
}
for (let kind = 0; kind < 3; kind += 1) {
tintUnit(unitIndex(kind, rip.i), c, a * (1 - k));
}
}
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 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
}
function takeOver() {
if (manual) {
return;
}
manual = true;
wake();
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say() {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
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);
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow() {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) {
say();
}
}
}
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;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest() {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak) {
if (paused || still) {
sceneT += 1;
}
update(0);
showNow();
if (speak) {
say();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
say();
}
function restart() {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// 새 판 — 씨앗이 달라진 판으로 시간표를 처음부터. 손으로 하던 중이어도 자동 시연으로 돌아간다
function newBoard() {
manual = false;
lap += 1;
begin();
update(0);
showNow();
say();
}
function moveCur(dx, dy) {
takeOver();
cur = clamp(rowOf(cur) + dy, 0, 8) * 9 + clamp(colOf(cur) + dx, 0, 8);
afterInput(true);
}
// 힌트 기본 — 고른 빈칸에 정답 숫자를 놓는다. 장면이 hintHook 을 걸어 두면 그쪽이 먼저
function hintDefault() {
if (!giv[cur] && g[cur] === 0) {
putDigit(cur, sol[cur]);
}
}
function toCanvas(e) {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 칸 이동, 1~9 = 숫자 입력, Delete·Backspace·0 = 지우기, H = 힌트,
// N = 새 판, R = 처음부터, Enter·Space = 一時停止
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'r' || k === 'R') {
restart();
}
else if (k === 'n' || k === 'N') {
e.preventDefault();
newBoard();
}
else if (k === 'ArrowLeft') {
e.preventDefault();
moveCur(-1, 0);
}
else if (k === 'ArrowRight') {
e.preventDefault();
moveCur(1, 0);
}
else if (k === 'ArrowUp') {
e.preventDefault();
moveCur(0, -1);
}
else if (k === 'ArrowDown') {
e.preventDefault();
moveCur(0, 1);
}
else if (k.length === 1 && k >= '1' && k <= '9') {
e.preventDefault();
takeOver();
selDigit = Number(k);
putDigit(cur, selDigit);
afterInput(true);
}
else if (k === 'Delete' || k === 'Backspace' || k === '0') {
e.preventDefault();
takeOver();
clearDigit(cur);
afterInput(true);
}
else if (k === 'h' || k === 'H') {
e.preventDefault();
takeOver();
if (hintHook !== null) {
hintHook();
}
else {
hintDefault();
}
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
pad.addEventListener('pointerdown', function (e) {
takeOver();
ptrDown = true;
try {
pad.setPointerCapture(e.pointerId);
}
catch (err) {
ptrDown = true;
}
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) {
cur = c;
}
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (!ptrDown) {
return;
}
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) {
cur = c;
}
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e) {
if (!ptrDown) {
return;
}
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { 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 — 칸마다 후보를 9비트 한 수로 들고, 같은 줄·세로줄·상자의 사용 숫자 비트를 OR 로 모아 뺀다. 숫자를 놓으면 후보가 줄어드는 칸이 번지고 칸 색은 남은 候補 수로 정해진다
const CLUES_01 = 34;
const PLACE_GAP = 0.5;
const RIPPLE_S = 0.9;
const PLACES = 8;
let LAP_S = 7.2;
let REST_S = 5.6;
let focus = -1;
let plan = [];
let placedN = 0;
const SPK_PUT = '置いた数字 {a}、候補が減った隣のマス {b} 個';
// 후보가 적을수록 밝은 민트, 많을수록 어두운 바탕
function depthFill(pc) {
return mix(MINT, EMPTY, clamp((pc - 1) / 6, 0, 1));
}
function dropOf(f) {
const tmp = g.slice();
for (let k = 0; k < plan.length; k += 1) {
tmp[plan[k]] = sol[plan[k]];
}
return popcount(candMask(g, f)) - popcount(candMask(tmp, f));
}
function placeAt(k) {
return function () {
if (putDigit(plan[k], sol[plan[k]])) {
placedN += 1;
}
};
}
function build() {
placedN = 0;
const s = fullBoard(11 + lap * 3);
const d = digBoard(s, 21 + lap, CLUES_01, false);
setBoard(d.puz, s);
const empties = [];
const order = perm(81, 5 + lap);
for (let k = 0; k < 81; k += 1) {
if (g[order[k]] === 0 && empties.length < PLACES) {
empties.push(order[k]);
}
}
plan = empties;
// 시연이 바꿀 후보가 가장 많이 줄어드는 빈칸을 비교 칸으로 삼는다
let best = -1;
let bd2 = -1;
for (let i = 0; i < 81; i += 1) {
if (g[i] !== 0 || plan.indexOf(i) >= 0) {
continue;
}
const dr = dropOf(i);
if (dr > bd2) {
bd2 = dr;
best = i;
}
}
focus = best;
for (let k = 0; k < PLACES; k += 1) {
script.push({ t: 0.3 + k * PLACE_GAP, f: placeAt(k) });
}
}
function update(dt) { if (dt < 0) {
return;
} }
function singlesCount() {
let n = 0;
for (let i = 0; i < 81; i += 1) {
if (g[i] === 0 && popcount(candMask(g, i)) === 1) {
n += 1;
}
}
return n;
}
function draw() {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoard(function (i) {
if (g[i] !== 0) {
return null;
}
return depthFill(popcount(candMask(g, i)));
}, function (i, x, y) {
const m = candMask(g, i);
const dark = popcount(m) <= 2;
for (let d = 1; d <= 9; d += 1) {
if ((m & bit(d)) !== 0) {
disc(x + CS / 2 + (((d - 1) % 3) - 1) * 6, y + CS / 2 + (Math.floor((d - 1) / 3) - 1) * 6, 1.5, dark ? INK : DIM, 0.95);
}
}
});
unitPulse(SUN, 0.3, RIPPLE_S);
// 번지는 소거 — 놓은 칸에서 멀수록 늦게 붉은 막이 켜졌다 꺼진다
if (rip.i >= 0 && !still) {
for (let k = 0; k < rip.list.length; k += 1) {
const j = rip.list[k];
const dist = Math.abs(rowOf(j) - rowOf(rip.i)) + Math.abs(colOf(j) - colOf(rip.i));
const p = (sceneT - rip.t - dist * 0.03) / RIPPLE_S;
if (p >= 0 && p < 1) {
box(cellX(j) + 1, cellY(j) + 1, CS - 2, CS - 2, 4, ROSE, 0.75 * (1 - p));
}
}
const rk = (sceneT - rip.t) / RIPPLE_S;
if (rk >= 0 && rk < 1) {
ctx.beginPath();
ctx.arc(cellCx(rip.i), cellCy(rip.i), lerp(CS * 0.5, CS * 3.2, easeOut(rk)), 0, TAU);
ctx.strokeStyle = rgba(PAPER, 0.8 * (1 - rk));
ctx.lineWidth = 2;
ctx.stroke();
}
}
if (focus >= 0 && g[focus] === 0) {
ring(cellX(focus) + 0.5, cellY(focus) + 0.5, CS - 1, CS - 1, 4, SUN, 1, 2);
}
panel(PX, BY - 5, PW, 223);
const f = focus >= 0 ? focus : 0;
const rm = usedMask(g, f, 0);
const cm = usedMask(g, f, 1);
const xm = usedMask(g, f, 2);
const um = rm | cm | xm;
const cand = ALL & ~um;
text('候補の計算', PX + 12, BY + 8, DIM, 11, 700, 'left');
const rows = [['行', rm, ROSE], ['列', cm, ROSE], ['箱', xm, ROSE], ['合計', um, SUN], ['候補', cand, MINT]];
for (let k = 0; k < 5; k += 1) {
const y = BY + 26 + k * 28;
text(rows[k][0], PX + 8, y + 8.5, NEU, 10, 700, 'left');
bitRow(PX + 40, y, rows[k][1], rows[k][2], [48, 44, 74]);
}
text('popcount ' + popcount(cand), PX + 12, BY + 26 + 5 * 28 + 10, NEU, 11, 700, 'left');
hud('candMask', '0b' + bin9(cand), popcount(cand) / 9);
}
function statusText() {
return fill('置いた数字 {a} 個、候補がひとつのマス {b} 個', String(placedN), String(singlesCount()));
}
function modeId() { return modeBase(Math.min(3, Math.floor(placedN / 3))); }
function speakText() {
const m = rip.i >= 0 ? fill(SPK_PUT, String(rip.d), String(rip.list.length)) + '. ' : '';
return m + speakBoard();
}
function onPtr(kind, x, y) { if (kind < 0 || x < 0 || y < 0) {
return;
} }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// CandidateBitmaskMap - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./CandidateBitmaskMap.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function CandidateBitmaskMap() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [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 easeOut(k: number): number { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const SKY: RGB = [150, 192, 244];
const SUN: RGB = [250, 218, 146];
const NEU: RGB = [236, 230, 250];
const BGC: RGB = [46, 42, 68];
const PANEL: RGB = [62, 57, 92];
const DIM: RGB = [196, 190, 224];
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) + ')';
}
function hsl(h: number, s: number, l: number): RGB {
const a = s * Math.min(l, 1 - l);
const f = function (n: number): number {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a: number, b: number): number {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s: string, a: string, b?: string, c?: string): string {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v: number): string { return String(Math.round(v * 10) / 10); }
function group(v: number): string {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { o += ','; }
o += s.charAt(i);
}
return o;
}
// ── 스도쿠 모델 — 판은 81칸 숫자 배열(0 은 빈칸). 후보는 9비트 한 수: 숫자 d 는 (d-1) 번째 비트
const ALL = 511;
function bit(d: number): number { return 1 << (d - 1); }
function popcount(m: number): number {
let c = 0;
let v = m;
while (v) { v &= v - 1; c += 1; }
return c;
}
// 가장 낮은 비트 하나가 가리키는 숫자
function lowDigit(m: number): number { return 32 - Math.clz32(m & -m); }
function bin9(m: number): string {
let s = '';
for (let d = 9; d >= 1; d -= 1) { s += (m & bit(d)) ? '1' : '0'; }
return s;
}
function rowOf(i: number): number { return Math.floor(i / 9); }
function colOf(i: number): number { return i % 9; }
function boxOf(i: number): number { return Math.floor(i / 27) * 3 + Math.floor((i % 9) / 3); }
// 묶음 27개 — 줄 9 · 세로줄 9 · 상자 9. 칸의 이웃은 같은 묶음 세 곳의 다른 칸 20개
const UNITS: number[][] = [];
const PEERS: number[][] = [];
for (let k = 0; k < 27; k += 1) { UNITS.push([]); }
for (let i = 0; i < 81; i += 1) {
UNITS[rowOf(i)].push(i);
UNITS[9 + colOf(i)].push(i);
UNITS[18 + boxOf(i)].push(i);
}
for (let i = 0; i < 81; i += 1) {
const ps: number[] = [];
for (let j = 0; j < 81; j += 1) {
if (j !== i && (rowOf(j) === rowOf(i) || colOf(j) === colOf(i) || boxOf(j) === boxOf(i))) { ps.push(j); }
}
PEERS.push(ps);
}
// 묶음 종류 kind: 0 줄 · 1 세로줄 · 2 상자
function unitIndex(kind: number, i: number): number { return kind === 0 ? rowOf(i) : (kind === 1 ? 9 + colOf(i) : 18 + boxOf(i)); }
function sharedKind(i: number, j: number): number {
if (rowOf(i) === rowOf(j)) { return 0; }
if (colOf(i) === colOf(j)) { return 1; }
return boxOf(i) === boxOf(j) ? 2 : -1;
}
// 칸 i 가 속한 kind 묶음에서 i 를 뺀 칸들이 이미 쓴 숫자의 비트 합(OR)
function usedMask(b: number[], i: number, kind: number): number {
const u = UNITS[unitIndex(kind, i)];
let m = 0;
for (let k = 0; k < 9; k += 1) { if (u[k] !== i && b[u[k]] !== 0) { m |= bit(b[u[k]]); } }
return m;
}
// 후보 = 9비트 전체에서 세 묶음이 쓴 비트를 뺀 것. 채워진 칸의 후보는 0
function candMask(b: number[], i: number): number {
if (b[i] !== 0) { return 0; }
return ALL & ~(usedMask(b, i, 0) | usedMask(b, i, 1) | usedMask(b, i, 2));
}
function filledOf(b: number[]): number {
let c = 0;
for (let i = 0; i < 81; i += 1) { if (b[i] !== 0) { c += 1; } }
return c;
}
function perm(n: number, seed: number): number[] {
const a: number[] = [];
for (let i = 0; i < n; i += 1) { a.push(i); }
for (let i = n - 1; i > 0; i -= 1) {
const j = Math.floor(hash2(i, seed) * (i + 1));
const t = a[i];
a[i] = a[j];
a[j] = t;
}
return a;
}
// 완성판 — 기본 무늬를 숫자·줄·상자 묶음 순서로 섞는다. 같은 씨앗이면 같은 판
function fullBoard(seed: number): number[] {
const dp = perm(9, seed * 7 + 1);
const bp = perm(3, seed * 7 + 2);
const sp = perm(3, seed * 7 + 3);
const rows: number[] = [];
const cols: number[] = [];
for (let k = 0; k < 3; k += 1) {
const rp = perm(3, seed * 13 + k * 5 + 4);
const cp = perm(3, seed * 13 + k * 5 + 9);
for (let j = 0; j < 3; j += 1) {
rows.push(bp[k] * 3 + rp[j]);
cols.push(sp[k] * 3 + cp[j]);
}
}
const b: number[] = [];
for (let r = 0; r < 9; r += 1) {
for (let c = 0; c < 9; c += 1) {
b.push(dp[(3 * (rows[r] % 3) + Math.floor(rows[r] / 3) + cols[c]) % 9] + 1);
}
}
return b;
}
// 풀이 탐색 — 가장 후보가 적은 칸(plain 이면 앞쪽 빈칸)부터 숫자를 걸고, 막히면 되돌린다.
// count 가 limit 에 닿으면 멈추고(풀이 개수는 limit 까지만 센다), 걸어 본 숫자가 cap 을 넘으면 멈춘다.
// trace 에는 (종류 << 16) | (칸 << 4) | 숫자 — 종류 0 은 걸기, 1 은 되돌리기
type Srch = { count: number; nodes: number; dead: number; branch: number; capHit: boolean; first: number[]; trace: number[] };
function search(start: number[], limit: number, cap: number, plain: boolean, wantTrace: boolean): Srch {
const b = start.slice();
const rm: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const cm: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const xm: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
rm[rowOf(i)] |= bit(b[i]);
cm[colOf(i)] |= bit(b[i]);
xm[boxOf(i)] |= bit(b[i]);
}
}
const r: Srch = { count: 0, nodes: 0, dead: 0, branch: 0, capHit: false, first: [], trace: [] };
function rec(): void {
let best = -1;
let bm = 0;
let bc = 10;
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) { continue; }
const m = ALL & ~(rm[rowOf(i)] | cm[colOf(i)] | xm[boxOf(i)]);
const pc = popcount(m);
if (plain) { best = i; bm = m; bc = pc; break; }
if (pc < bc) { best = i; bm = m; bc = pc; if (pc <= 1) { break; } }
}
if (best < 0) {
r.count += 1;
if (r.count === 1) { r.first = b.slice(); }
return;
}
if (bc === 0) { r.dead += 1; return; }
if (bc > 1) { r.branch += 1; }
let mm = bm;
while (mm) {
const lo = mm & -mm;
mm ^= lo;
const d = 32 - Math.clz32(lo);
r.nodes += 1;
if (r.nodes > cap) { r.capHit = true; return; }
b[best] = d;
rm[rowOf(best)] |= lo;
cm[colOf(best)] |= lo;
xm[boxOf(best)] |= lo;
if (wantTrace) { r.trace.push((best << 4) | d); }
rec();
if (r.count >= limit || r.capHit) { return; }
b[best] = 0;
rm[rowOf(best)] &= ~lo;
cm[colOf(best)] &= ~lo;
xm[boxOf(best)] &= ~lo;
if (wantTrace) { r.trace.push((1 << 16) | (best << 4) | d); }
}
}
rec();
return r;
}
// 하나뿐인 후보 채우기 — 후보가 하나인 칸(번호가 가장 앞선 칸)을 채우고 다시 센다. 더 없으면 멈춘다
type Singles = { order: number[]; digits: number[]; avail: number[]; solved: boolean; board: number[] };
function singlesFill(start: number[]): Singles {
const b = start.slice();
const order: number[] = [];
const digits: number[] = [];
const avail: number[] = [];
for (;;) {
let first = -1;
let n = 0;
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0 && popcount(candMask(b, i)) === 1) {
if (first < 0) { first = i; }
n += 1;
}
}
if (first < 0) { break; }
const d = lowDigit(candMask(b, first));
b[first] = d;
order.push(first);
digits.push(d);
avail.push(n);
}
let solved = true;
for (let i = 0; i < 81; i += 1) { if (b[i] === 0) { solved = false; } }
return { order: order, digits: digits, avail: avail, solved: solved, board: b };
}
// 구멍 파기 — 완성판에서 칸을 해시 순서로 하나씩 비우고 풀이 개수를 2 까지만 센다.
// 하나뿐이면 비운 채로 두고, 둘 이상이면 되돌린다. singlesOnly 이면 하나뿐인 후보만으로 풀리는 판만 남긴다
type DigStep = { cell: number; cnt: number; ok: boolean; nodes: number };
type Dug = { puz: number[]; steps: DigStep[]; clues: number };
function digBoard(sol: number[], seed: number, target: number, singlesOnly: boolean): Dug {
const puz = sol.slice();
const order = perm(81, seed * 31 + 5);
const steps: DigStep[] = [];
let clues = 81;
for (let k = 0; k < 81; k += 1) {
if (clues <= target) { break; }
const c = order[k];
const d = puz[c];
puz[c] = 0;
const s = search(puz, 2, 6000, false, false);
const cnt = s.capHit ? 2 : Math.min(2, s.count);
const ok = cnt === 1 && (!singlesOnly || singlesFill(puz).solved);
steps.push({ cell: c, cnt: cnt, ok: ok, nodes: s.nodes });
if (ok) { clues -= 1; } else { puz[c] = d; }
}
return { puz: puz, steps: steps, clues: clues };
}
// 놓은 숫자가 겹치는 쌍 — [칸, 칸, 묶음 종류]
function conflicts(b: number[]): number[][] {
const out: number[][] = [];
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0) { continue; }
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (p > i && b[p] === b[i]) { out.push([i, p, sharedKind(i, p)]); }
}
}
return out;
}
// ── 판 상태 — 장면이 아니라 판이 들고 있는 것. 화면은 이 값을 읽어 그린다
const BX = 22;
const BY = 44;
const CS = 23;
const GAP = 3;
const PX = 258;
const PW = 210;
let g: number[] = [];
let giv: boolean[] = [];
let sol: number[] = [];
let pt: number[] = [];
let cur = 40;
let solvedT = -1;
let lastCell = -1;
type Rip = { i: number; t: number; list: number[]; d: number };
let rip: Rip = { i: -1, t: -9, list: [], d: 0 };
let selDigit = 0;
let still = false;
let hintHook = null as (() => void) | null;
function cellX(i: number): number { const c = colOf(i); return BX + c * CS + Math.floor(c / 3) * GAP; }
function cellY(i: number): number { const r = rowOf(i); return BY + r * CS + Math.floor(r / 3) * GAP; }
function cellCx(i: number): number { return cellX(i) + CS / 2; }
function cellCy(i: number): number { return cellY(i) + CS / 2; }
function cellAt(x: number, y: number): number {
let c = -1;
let r = -1;
for (let k = 0; k < 9; k += 1) {
const x0 = BX + k * CS + Math.floor(k / 3) * GAP;
const y0 = BY + k * CS + Math.floor(k / 3) * GAP;
if (x >= x0 && x < x0 + CS) { c = k; }
if (y >= y0 && y < y0 + CS) { r = k; }
}
return c < 0 || r < 0 ? -1 : r * 9 + c;
}
function setBoard(puz: number[], solution: number[]): void {
g = puz.slice();
sol = solution.slice();
giv = [];
pt = [];
for (let i = 0; i < 81; i += 1) { giv.push(puz[i] !== 0); pt.push(-9); }
solvedT = -1;
lastCell = -1;
rip = { i: -1, t: -9, list: [], d: 0 };
}
function solvedNow(): boolean { return filledOf(g) === 81 && conflicts(g).length === 0; }
function checkSolved(): void {
if (solvedNow()) { if (solvedT < 0) { solvedT = sceneT; } } else { solvedT = -1; }
}
// 숫자를 놓는다 — 놓기 전에 그 숫자가 후보에 있던 이웃 칸을 기억해 두면 소거가 번지는 모습을 그릴 수 있다
function putDigit(i: number, d: number): boolean {
if (giv[i] || d < 1 || d > 9) { return false; }
const list: number[] = [];
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (g[p] === 0 && (candMask(g, p) & bit(d)) !== 0) { list.push(p); }
}
g[i] = d;
pt[i] = sceneT;
rip = { i: i, t: sceneT, list: list, d: d };
lastCell = i;
checkSolved();
return true;
}
function clearDigit(i: number): void {
if (giv[i] || g[i] === 0) { return; }
g[i] = 0;
pt[i] = -9;
checkSolved();
}
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)]; }
// ── 그리기 도구
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 doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 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 disc(x: number, y: number, r: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, c: RGB, a: number, w: number): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x: number, y: number, w: number, h: number): void { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name: string, val: string, frac: number): void {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// ── 판 그리기 — 칸 색은 장면이 정하고, 숫자는 판이 그린다
const EMPTY: RGB = [58, 53, 88];
const PLACED: RGB = [190, 214, 246];
const PLACED_INK: RGB = [28, 54, 118];
function ring(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number, lw: number): void {
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = lw;
ctx.stroke();
}
function drawBoard(fillOf: (i: number) => RGB | null, extra: ((i: number, x: number, y: number) => void) | null): void {
const s = 9 * CS + 2 * GAP;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < 81; i += 1) {
const x = cellX(i);
const y = cellY(i);
let f: RGB | null = fillOf(i);
if (f === null) { f = g[i] === 0 ? EMPTY : (giv[i] ? NEU : PLACED); }
box(x + 1, y + 1, CS - 2, CS - 2, 4, f, 1);
if (g[i] !== 0) {
const a = still ? 99 : sceneT - pt[i];
const k = a < 0.3 ? 0.7 + 0.3 * easeOut(a / 0.3) : 1;
text(String(g[i]), x + CS / 2, y + CS / 2 + 1, giv[i] ? INK : PLACED_INK, Math.round(14 * k), giv[i] ? 800 : 700, 'center');
} else if (extra !== null) {
extra(i, x, y);
}
}
if (!demo) { ring(cellX(cur) + 0.5, cellY(cur) + 0.5, CS - 1, CS - 1, 4, PAPER, 1, 2); }
}
// 묶음 하나를 옅게 물들인다
function tintUnit(u: number, c: RGB, a: number): void {
for (let k = 0; k < 9; k += 1) {
const i = UNITS[u][k];
box(cellX(i) + 1, cellY(i) + 1, CS - 2, CS - 2, 4, c, a);
// 물들임이 숫자를 흐리지 않도록 숫자를 다시 얹는다
if (g[i] !== 0) { text(String(g[i]), cellCx(i), cellCy(i) + 1, giv[i] ? INK : PLACED_INK, 14, giv[i] ? 800 : 700, 'center'); }
}
}
// 후보 비트 줄 — 숫자 1~9 칸 아홉 개, 켜진 비트만 밝다
function bitRow(x: number, y: number, m: number, on: RGB, off: RGB): void {
for (let d = 1; d <= 9; d += 1) {
const lit = (m & bit(d)) !== 0;
const bx = x + (d - 1) * 18;
box(bx, y, 16, 16, 4, lit ? on : off, 1);
text(String(d), bx + 8, y + 8.5, lit ? INK : DIM, 10, 700, 'center');
}
}
// ── 상태 줄·낭독 문장
const SPK_FILL = '{a}칸 중 {b}칸이 채워져 있다';
const SPK_DONE = '판 완성, {a}칸이 모두 채워졌다';
function speakBoard(): string {
return solvedNow() ? fill(SPK_DONE, '81') : fill(SPK_FILL, '81', String(filledOf(g)));
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 100 + 채운 칸 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode: number): number {
if (solvedNow()) { return 1000; }
return manual ? 100 + filledOf(g) : sceneMode;
}
// 방금 놓은 칸의 줄·세로줄·상자 세 묶음을 한 번 물들였다 서서히 빼는 번짐 — 숫자 하나가 어디까지 영향을 주는지 보인다
function unitPulse(c: RGB, a: number, dur: number): void {
if (rip.i < 0 || still) { return; }
const k = (sceneT - rip.t) / dur;
if (k < 0 || k >= 1) { return; }
for (let kind = 0; kind < 3; kind += 1) { tintUnit(unitIndex(kind, rip.i), c, a * (1 - k)); }
}
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 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
}
function takeOver(): void {
if (manual) { return; }
manual = true;
wake();
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say(): void {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
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);
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow(): void {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) { say(); }
}
}
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;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest(): void {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak: boolean): void {
if (paused || still) { sceneT += 1; }
update(0);
showNow();
if (speak) { say(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
say();
}
function restart(): void {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// 새 판 — 씨앗이 달라진 판으로 시간표를 처음부터. 손으로 하던 중이어도 자동 시연으로 돌아간다
function newBoard(): void {
manual = false;
lap += 1;
begin();
update(0);
showNow();
say();
}
function moveCur(dx: number, dy: number): void {
takeOver();
cur = clamp(rowOf(cur) + dy, 0, 8) * 9 + clamp(colOf(cur) + dx, 0, 8);
afterInput(true);
}
// 힌트 기본 — 고른 빈칸에 정답 숫자를 놓는다. 장면이 hintHook 을 걸어 두면 그쪽이 먼저
function hintDefault(): void {
if (!giv[cur] && g[cur] === 0) { putDigit(cur, sol[cur]); }
}
function toCanvas(e: PointerEvent): number[] {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 칸 이동, 1~9 = 숫자 입력, Delete·Backspace·0 = 지우기, H = 힌트,
// N = 새 판, R = 처음부터, Enter·Space = 일시정지
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'r' || k === 'R') {
restart();
} else if (k === 'n' || k === 'N') {
e.preventDefault();
newBoard();
} else if (k === 'ArrowLeft') {
e.preventDefault();
moveCur(-1, 0);
} else if (k === 'ArrowRight') {
e.preventDefault();
moveCur(1, 0);
} else if (k === 'ArrowUp') {
e.preventDefault();
moveCur(0, -1);
} else if (k === 'ArrowDown') {
e.preventDefault();
moveCur(0, 1);
} else if (k.length === 1 && k >= '1' && k <= '9') {
e.preventDefault();
takeOver();
selDigit = Number(k);
putDigit(cur, selDigit);
afterInput(true);
} else if (k === 'Delete' || k === 'Backspace' || k === '0') {
e.preventDefault();
takeOver();
clearDigit(cur);
afterInput(true);
} else if (k === 'h' || k === 'H') {
e.preventDefault();
takeOver();
if (hintHook !== null) { hintHook(); } else { hintDefault(); }
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
ptrDown = true;
try { pad.setPointerCapture(e.pointerId); } catch (err) { ptrDown = true; }
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) { cur = c; }
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (!ptrDown) { return; }
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) { cur = c; }
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e: PointerEvent): void {
if (!ptrDown) { return; }
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { 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 — 칸마다 후보를 9비트 한 수로 들고, 같은 줄·세로줄·상자의 사용 숫자 비트를 OR 로 모아 뺀다. 숫자를 놓으면 후보가 줄어드는 칸이 번지고 칸 색은 남은 후보 수로 정해진다
const CLUES_01 = 34;
const PLACE_GAP = 0.5;
const RIPPLE_S = 0.9;
const PLACES = 8;
let LAP_S = 7.2;
let REST_S = 5.6;
let focus = -1;
let plan: number[] = [];
let placedN = 0;
const SPK_PUT = '놓은 숫자 {a}, 후보가 줄어든 이웃 {b}칸';
// 후보가 적을수록 밝은 민트, 많을수록 어두운 바탕
function depthFill(pc: number): RGB {
return mix(MINT, EMPTY, clamp((pc - 1) / 6, 0, 1));
}
function dropOf(f: number): number {
const tmp = g.slice();
for (let k = 0; k < plan.length; k += 1) { tmp[plan[k]] = sol[plan[k]]; }
return popcount(candMask(g, f)) - popcount(candMask(tmp, f));
}
function placeAt(k: number): () => void {
return function (): void {
if (putDigit(plan[k], sol[plan[k]])) { placedN += 1; }
};
}
function build(): void {
placedN = 0;
const s = fullBoard(11 + lap * 3);
const d = digBoard(s, 21 + lap, CLUES_01, false);
setBoard(d.puz, s);
const empties: number[] = [];
const order = perm(81, 5 + lap);
for (let k = 0; k < 81; k += 1) { if (g[order[k]] === 0 && empties.length < PLACES) { empties.push(order[k]); } }
plan = empties;
// 시연이 바꿀 후보가 가장 많이 줄어드는 빈칸을 비교 칸으로 삼는다
let best = -1;
let bd2 = -1;
for (let i = 0; i < 81; i += 1) {
if (g[i] !== 0 || plan.indexOf(i) >= 0) { continue; }
const dr = dropOf(i);
if (dr > bd2) { bd2 = dr; best = i; }
}
focus = best;
for (let k = 0; k < PLACES; k += 1) { script.push({ t: 0.3 + k * PLACE_GAP, f: placeAt(k) }); }
}
function update(dt: number): void { if (dt < 0) { return; } }
function singlesCount(): number {
let n = 0;
for (let i = 0; i < 81; i += 1) { if (g[i] === 0 && popcount(candMask(g, i)) === 1) { n += 1; } }
return n;
}
function draw(): void {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoard(function (i: number): RGB | null {
if (g[i] !== 0) { return null; }
return depthFill(popcount(candMask(g, i)));
}, function (i: number, x: number, y: number): void {
const m = candMask(g, i);
const dark = popcount(m) <= 2;
for (let d = 1; d <= 9; d += 1) {
if ((m & bit(d)) !== 0) { disc(x + CS / 2 + (((d - 1) % 3) - 1) * 6, y + CS / 2 + (Math.floor((d - 1) / 3) - 1) * 6, 1.5, dark ? INK : DIM, 0.95); }
}
});
unitPulse(SUN, 0.3, RIPPLE_S);
// 번지는 소거 — 놓은 칸에서 멀수록 늦게 붉은 막이 켜졌다 꺼진다
if (rip.i >= 0 && !still) {
for (let k = 0; k < rip.list.length; k += 1) {
const j = rip.list[k];
const dist = Math.abs(rowOf(j) - rowOf(rip.i)) + Math.abs(colOf(j) - colOf(rip.i));
const p = (sceneT - rip.t - dist * 0.03) / RIPPLE_S;
if (p >= 0 && p < 1) { box(cellX(j) + 1, cellY(j) + 1, CS - 2, CS - 2, 4, ROSE, 0.75 * (1 - p)); }
}
const rk = (sceneT - rip.t) / RIPPLE_S;
if (rk >= 0 && rk < 1) {
ctx.beginPath();
ctx.arc(cellCx(rip.i), cellCy(rip.i), lerp(CS * 0.5, CS * 3.2, easeOut(rk)), 0, TAU);
ctx.strokeStyle = rgba(PAPER, 0.8 * (1 - rk));
ctx.lineWidth = 2;
ctx.stroke();
}
}
if (focus >= 0 && g[focus] === 0) { ring(cellX(focus) + 0.5, cellY(focus) + 0.5, CS - 1, CS - 1, 4, SUN, 1, 2); }
panel(PX, BY - 5, PW, 223);
const f = focus >= 0 ? focus : 0;
const rm = usedMask(g, f, 0);
const cm = usedMask(g, f, 1);
const xm = usedMask(g, f, 2);
const um = rm | cm | xm;
const cand = ALL & ~um;
text('후보 계산', PX + 12, BY + 8, DIM, 11, 700, 'left');
const rows: [string, number, RGB][] = [['행', rm, ROSE], ['열', cm, ROSE], ['상자', xm, ROSE], ['합', um, SUN], ['후보', cand, MINT]];
for (let k = 0; k < 5; k += 1) {
const y = BY + 26 + k * 28;
text(rows[k][0], PX + 8, y + 8.5, NEU, 10, 700, 'left');
bitRow(PX + 40, y, rows[k][1], rows[k][2], [48, 44, 74]);
}
text('popcount ' + popcount(cand), PX + 12, BY + 26 + 5 * 28 + 10, NEU, 11, 700, 'left');
hud('candMask', '0b' + bin9(cand), popcount(cand) / 9);
}
function statusText(): string {
return fill('놓은 숫자 {a}개, 후보가 하나인 칸 {b}개', String(placedN), String(singlesCount()));
}
function modeId(): number { return modeBase(Math.min(3, Math.floor(placedN / 3))); }
function speakText(): string {
const m = rip.i >= 0 ? fill(SPK_PUT, String(rip.d), String(rip.list.length)) + '. ' : '';
return m + speakBoard();
}
function onPtr(kind: number, x: number, y: number): void { if (kind < 0 || x < 0 || y < 0) { 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={'스도쿠 시연 무대 — 방향키로 칸 이동, 1부터 9 숫자키로 입력, Delete 로 지우기, H 로 힌트, N 으로 새 판, R 로 처음부터, Enter 또는 Space 로 일시정지'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'어두운 자두색 바탕에 9×9 스도쿠 판이 있고 빈칸마다 남은 후보가 작은 점 아홉 자리로 찍힌다. 후보가 적은 칸일수록 밝은 민트색이다. 숫자 여덟 개가 차례로 놓이면 같은 줄·세로줄·상자의 후보가 줄어드는 칸이 놓인 칸에서 멀수록 늦게 붉게 번진다. 오른쪽 패널에는 비교 칸 하나의 행·열·상자가 쓴 숫자의 9비트 줄과 그 합, 남은 후보 줄이 나온다. 아래 눈금은 상수 이름과 지금 값을 보이고, 움직임 줄이기에서는 숫자가 바로 채워진 판 한 장만 그려진다'}></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>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}01 · マスごとの候補をビットで消す絞り込み図 — 計測 累積面積 21.349% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// マスiが属するkindの組からiを除いたマスがすでに使った数字のビットの合計(OR)
function usedMask(b, i, kind) {
const u = UNITS[unitIndex(kind, i)];
let m = 0;
for (let k = 0; k < 9; k += 1) {
if (u[k] !== i && b[u[k]] !== 0) {
m |= bit(b[u[k]]);
}
}
return m;
}
// 候補 = 9ビット全体から三つの組が使ったビットを引いたもの。埋まったマスの候補は0
function candMask(b, i) {
if (b[i] !== 0) {
return 0;
}
return ALL & ~(usedMask(b, i, 0) | usedMask(b, i, 1) | usedMask(b, i, 2));
}
ノードで動かすと、四つの盤の空きマス188マスすべてでこのビット計算が集合で数えた候補と一致し、最初の盤は場面が置く8マスを埋めると空きマスが47から39に、1マスあたりの平均の候補が3.15から2.28に減ります。
02ひとつだけの候補を埋めると次が解ける連鎖
候補がちょうどひとつのマスを埋めて候補を数え直すことを繰り返すので、人が詰まったときに次のマスを示す画面に使えます。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="数独の舞台 — 矢印キーでマス移動、数字キー1〜9で入力、Deleteで消去、Hでヒント、Nで新しい盤、Rで最初から、EnterかSpaceで一時停止">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="単純なヒントの少ない9×9の盤で、候補がひとつだけのマスが順に埋まる。埋めたマスの角には埋めた順の番号が付き、前後で隣り合うマスは線でつながれ、色がミントから空色へ変わる。右のパネルには埋めた数と、埋めるたびにその時点で候補がひとつだけのマスがいくつあったかの棒が積まれ、もう埋められるマスがないと候補がいちばん少ないマスに赤い枠が点滅し行き詰まりと表示される。下の目盛りは定数名と今の値を示し、動きを減らす設定では数字がすぐ埋まった盤一枚だけを描きます"></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>
</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-ink;
}
// 무대 상자 — 눌러서 한 번 깨뜨리는 곳(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-cream;
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 {
padding: $sp-1 $sp-3;
border: 2px solid $subject-cream;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
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__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-ink;
color: $subject-cream;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면에만 있고 aria-live 는 늘 off
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 100px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
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__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 글리치 없는 원문 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause: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: #17141a;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #fff7e6;
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 {
padding: 4px 12px;
border: 2px solid #fff7e6;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
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__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 100px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
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__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause: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 easeOut(k) { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const SKY = [150, 192, 244];
const SUN = [250, 218, 146];
const NEU = [236, 230, 250];
const BGC = [46, 42, 68];
const PANEL = [62, 57, 92];
const DIM = [196, 190, 224];
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) + ')';
}
function hsl(h, s, l) {
const a = s * Math.min(l, 1 - l);
const f = function (n) {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a, b) {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s, a, b, c) {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v) { return String(Math.round(v * 10) / 10); }
function group(v) {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
o += ',';
}
o += s.charAt(i);
}
return o;
}
// ── 스도쿠 모델 — 판은 81칸 숫자 배열(0 은 빈칸). 후보는 9비트 한 수: 숫자 d 는 (d-1) 번째 비트
const ALL = 511;
function bit(d) { return 1 << (d - 1); }
function popcount(m) {
let c = 0;
let v = m;
while (v) {
v &= v - 1;
c += 1;
}
return c;
}
// 가장 낮은 비트 하나가 가리키는 숫자
function lowDigit(m) { return 32 - Math.clz32(m & -m); }
function bin9(m) {
let s = '';
for (let d = 9; d >= 1; d -= 1) {
s += (m & bit(d)) ? '1' : '0';
}
return s;
}
function rowOf(i) { return Math.floor(i / 9); }
function colOf(i) { return i % 9; }
function boxOf(i) { return Math.floor(i / 27) * 3 + Math.floor((i % 9) / 3); }
// 묶음 27개 — 줄 9 · 세로줄 9 · 箱 9. 칸의 이웃은 같은 묶음 세 곳의 다른 칸 20개
const UNITS = [];
const PEERS = [];
for (let k = 0; k < 27; k += 1) {
UNITS.push([]);
}
for (let i = 0; i < 81; i += 1) {
UNITS[rowOf(i)].push(i);
UNITS[9 + colOf(i)].push(i);
UNITS[18 + boxOf(i)].push(i);
}
for (let i = 0; i < 81; i += 1) {
const ps = [];
for (let j = 0; j < 81; j += 1) {
if (j !== i && (rowOf(j) === rowOf(i) || colOf(j) === colOf(i) || boxOf(j) === boxOf(i))) {
ps.push(j);
}
}
PEERS.push(ps);
}
// 묶음 종류 kind: 0 줄 · 1 세로줄 · 2 箱
function unitIndex(kind, i) { return kind === 0 ? rowOf(i) : (kind === 1 ? 9 + colOf(i) : 18 + boxOf(i)); }
function sharedKind(i, j) {
if (rowOf(i) === rowOf(j)) {
return 0;
}
if (colOf(i) === colOf(j)) {
return 1;
}
return boxOf(i) === boxOf(j) ? 2 : -1;
}
// 칸 i 가 속한 kind 묶음에서 i 를 뺀 칸들이 이미 쓴 숫자의 비트 合計(OR)
function usedMask(b, i, kind) {
const u = UNITS[unitIndex(kind, i)];
let m = 0;
for (let k = 0; k < 9; k += 1) {
if (u[k] !== i && b[u[k]] !== 0) {
m |= bit(b[u[k]]);
}
}
return m;
}
// 候補 = 9비트 전체에서 세 묶음이 쓴 비트를 뺀 것. 채워진 칸의 후보는 0
function candMask(b, i) {
if (b[i] !== 0) {
return 0;
}
return ALL & ~(usedMask(b, i, 0) | usedMask(b, i, 1) | usedMask(b, i, 2));
}
function filledOf(b) {
let c = 0;
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
c += 1;
}
}
return c;
}
function perm(n, seed) {
const a = [];
for (let i = 0; i < n; i += 1) {
a.push(i);
}
for (let i = n - 1; i > 0; i -= 1) {
const j = Math.floor(hash2(i, seed) * (i + 1));
const t = a[i];
a[i] = a[j];
a[j] = t;
}
return a;
}
// 완성판 — 기본 무늬를 숫자·줄·箱 묶음 순서로 섞는다. 같은 씨앗이면 같은 판
function fullBoard(seed) {
const dp = perm(9, seed * 7 + 1);
const bp = perm(3, seed * 7 + 2);
const sp = perm(3, seed * 7 + 3);
const rows = [];
const cols = [];
for (let k = 0; k < 3; k += 1) {
const rp = perm(3, seed * 13 + k * 5 + 4);
const cp = perm(3, seed * 13 + k * 5 + 9);
for (let j = 0; j < 3; j += 1) {
rows.push(bp[k] * 3 + rp[j]);
cols.push(sp[k] * 3 + cp[j]);
}
}
const b = [];
for (let r = 0; r < 9; r += 1) {
for (let c = 0; c < 9; c += 1) {
b.push(dp[(3 * (rows[r] % 3) + Math.floor(rows[r] / 3) + cols[c]) % 9] + 1);
}
}
return b;
}
function search(start, limit, cap, plain, wantTrace) {
const b = start.slice();
const rm = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const cm = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const xm = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
rm[rowOf(i)] |= bit(b[i]);
cm[colOf(i)] |= bit(b[i]);
xm[boxOf(i)] |= bit(b[i]);
}
}
const r = { count: 0, nodes: 0, dead: 0, branch: 0, capHit: false, first: [], trace: [] };
function rec() {
let best = -1;
let bm = 0;
let bc = 10;
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
continue;
}
const m = ALL & ~(rm[rowOf(i)] | cm[colOf(i)] | xm[boxOf(i)]);
const pc = popcount(m);
if (plain) {
best = i;
bm = m;
bc = pc;
break;
}
if (pc < bc) {
best = i;
bm = m;
bc = pc;
if (pc <= 1) {
break;
}
}
}
if (best < 0) {
r.count += 1;
if (r.count === 1) {
r.first = b.slice();
}
return;
}
if (bc === 0) {
r.dead += 1;
return;
}
if (bc > 1) {
r.branch += 1;
}
let mm = bm;
while (mm) {
const lo = mm & -mm;
mm ^= lo;
const d = 32 - Math.clz32(lo);
r.nodes += 1;
if (r.nodes > cap) {
r.capHit = true;
return;
}
b[best] = d;
rm[rowOf(best)] |= lo;
cm[colOf(best)] |= lo;
xm[boxOf(best)] |= lo;
if (wantTrace) {
r.trace.push((best << 4) | d);
}
rec();
if (r.count >= limit || r.capHit) {
return;
}
b[best] = 0;
rm[rowOf(best)] &= ~lo;
cm[colOf(best)] &= ~lo;
xm[boxOf(best)] &= ~lo;
if (wantTrace) {
r.trace.push((1 << 16) | (best << 4) | d);
}
}
}
rec();
return r;
}
function singlesFill(start) {
const b = start.slice();
const order = [];
const digits = [];
const avail = [];
for (;;) {
let first = -1;
let n = 0;
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0 && popcount(candMask(b, i)) === 1) {
if (first < 0) {
first = i;
}
n += 1;
}
}
if (first < 0) {
break;
}
const d = lowDigit(candMask(b, first));
b[first] = d;
order.push(first);
digits.push(d);
avail.push(n);
}
let solved = true;
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0) {
solved = false;
}
}
return { order: order, digits: digits, avail: avail, solved: solved, board: b };
}
function digBoard(sol, seed, target, singlesOnly) {
const puz = sol.slice();
const order = perm(81, seed * 31 + 5);
const steps = [];
let clues = 81;
for (let k = 0; k < 81; k += 1) {
if (clues <= target) {
break;
}
const c = order[k];
const d = puz[c];
puz[c] = 0;
const s = search(puz, 2, 6000, false, false);
const cnt = s.capHit ? 2 : Math.min(2, s.count);
const ok = cnt === 1 && (!singlesOnly || singlesFill(puz).solved);
steps.push({ cell: c, cnt: cnt, ok: ok, nodes: s.nodes });
if (ok) {
clues -= 1;
}
else {
puz[c] = d;
}
}
return { puz: puz, steps: steps, clues: clues };
}
// 놓은 숫자가 겹치는 쌍 — [칸, 칸, 묶음 종류]
function conflicts(b) {
const out = [];
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0) {
continue;
}
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (p > i && b[p] === b[i]) {
out.push([i, p, sharedKind(i, p)]);
}
}
}
return out;
}
// ── 판 상태 — 장면이 아니라 판이 들고 있는 것. 화면은 이 값을 읽어 그린다
const BX = 22;
const BY = 44;
const CS = 23;
const GAP = 3;
const PX = 258;
const PW = 210;
let g = [];
let giv = [];
let sol = [];
let pt = [];
let cur = 40;
let solvedT = -1;
let lastCell = -1;
let rip = { i: -1, t: -9, list: [], d: 0 };
let selDigit = 0;
let still = false;
let hintHook = null;
function cellX(i) { const c = colOf(i); return BX + c * CS + Math.floor(c / 3) * GAP; }
function cellY(i) { const r = rowOf(i); return BY + r * CS + Math.floor(r / 3) * GAP; }
function cellCx(i) { return cellX(i) + CS / 2; }
function cellCy(i) { return cellY(i) + CS / 2; }
function cellAt(x, y) {
let c = -1;
let r = -1;
for (let k = 0; k < 9; k += 1) {
const x0 = BX + k * CS + Math.floor(k / 3) * GAP;
const y0 = BY + k * CS + Math.floor(k / 3) * GAP;
if (x >= x0 && x < x0 + CS) {
c = k;
}
if (y >= y0 && y < y0 + CS) {
r = k;
}
}
return c < 0 || r < 0 ? -1 : r * 9 + c;
}
function setBoard(puz, solution) {
g = puz.slice();
sol = solution.slice();
giv = [];
pt = [];
for (let i = 0; i < 81; i += 1) {
giv.push(puz[i] !== 0);
pt.push(-9);
}
solvedT = -1;
lastCell = -1;
rip = { i: -1, t: -9, list: [], d: 0 };
}
function solvedNow() { return filledOf(g) === 81 && conflicts(g).length === 0; }
function checkSolved() {
if (solvedNow()) {
if (solvedT < 0) {
solvedT = sceneT;
}
}
else {
solvedT = -1;
}
}
// 숫자를 놓는다 — 놓기 전에 그 숫자가 후보에 있던 이웃 칸을 기억해 두면 소거가 번지는 모습을 그릴 수 있다
function putDigit(i, d) {
if (giv[i] || d < 1 || d > 9) {
return false;
}
const list = [];
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (g[p] === 0 && (candMask(g, p) & bit(d)) !== 0) {
list.push(p);
}
}
g[i] = d;
pt[i] = sceneT;
rip = { i: i, t: sceneT, list: list, d: d };
lastCell = i;
checkSolved();
return true;
}
function clearDigit(i) {
if (giv[i] || g[i] === 0) {
return;
}
g[i] = 0;
pt[i] = -9;
checkSolved();
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// ── 그리기 도구
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 doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 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 disc(x, y, r, c, a) {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, c, a, w) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s, x, y, c, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x, y, w, h) { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name, val, frac) {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// ── 판 그리기 — 칸 색은 장면이 정하고, 숫자는 판이 그린다
const EMPTY = [58, 53, 88];
const PLACED = [190, 214, 246];
const PLACED_INK = [28, 54, 118];
function ring(x, y, w, h, r, c, a, lw) {
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = lw;
ctx.stroke();
}
function drawBoard(fillOf, extra) {
const s = 9 * CS + 2 * GAP;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < 81; i += 1) {
const x = cellX(i);
const y = cellY(i);
let f = fillOf(i);
if (f === null) {
f = g[i] === 0 ? EMPTY : (giv[i] ? NEU : PLACED);
}
box(x + 1, y + 1, CS - 2, CS - 2, 4, f, 1);
if (g[i] !== 0) {
const a = still ? 99 : sceneT - pt[i];
const k = a < 0.3 ? 0.7 + 0.3 * easeOut(a / 0.3) : 1;
text(String(g[i]), x + CS / 2, y + CS / 2 + 1, giv[i] ? INK : PLACED_INK, Math.round(14 * k), giv[i] ? 800 : 700, 'center');
}
else if (extra !== null) {
extra(i, x, y);
}
}
if (!demo) {
ring(cellX(cur) + 0.5, cellY(cur) + 0.5, CS - 1, CS - 1, 4, PAPER, 1, 2);
}
}
// 묶음 하나를 옅게 물들인다
function tintUnit(u, c, a) {
for (let k = 0; k < 9; k += 1) {
const i = UNITS[u][k];
box(cellX(i) + 1, cellY(i) + 1, CS - 2, CS - 2, 4, c, a);
// 물들임이 숫자를 흐리지 않도록 숫자를 다시 얹는다
if (g[i] !== 0) {
text(String(g[i]), cellCx(i), cellCy(i) + 1, giv[i] ? INK : PLACED_INK, 14, giv[i] ? 800 : 700, 'center');
}
}
}
// 候補 비트 줄 — 숫자 1~9 칸 아홉 개, 켜진 비트만 밝다
function bitRow(x, y, m, on, off) {
for (let d = 1; d <= 9; d += 1) {
const lit = (m & bit(d)) !== 0;
const bx = x + (d - 1) * 18;
box(bx, y, 16, 16, 4, lit ? on : off, 1);
text(String(d), bx + 8, y + 8.5, lit ? INK : DIM, 10, 700, 'center');
}
}
// ── 상태 줄·낭독 문장
const SPK_FILL = '{a} マスのうち {b} マスが埋まっている';
const SPK_DONE = '盤が完成、{a} マスすべてが埋まった';
function speakBoard() {
return solvedNow() ? fill(SPK_DONE, '81') : fill(SPK_FILL, '81', String(filledOf(g)));
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 100 + 채운 칸 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode) {
if (solvedNow()) {
return 1000;
}
return manual ? 100 + filledOf(g) : sceneMode;
}
// 방금 놓은 칸의 줄·세로줄·箱 세 묶음을 한 번 물들였다 서서히 빼는 번짐 — 숫자 하나가 어디까지 영향을 주는지 보인다
function unitPulse(c, a, dur) {
if (rip.i < 0 || still) {
return;
}
const k = (sceneT - rip.t) / dur;
if (k < 0 || k >= 1) {
return;
}
for (let kind = 0; kind < 3; kind += 1) {
tintUnit(unitIndex(kind, rip.i), c, a * (1 - k));
}
}
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 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
}
function takeOver() {
if (manual) {
return;
}
manual = true;
wake();
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say() {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
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);
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow() {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) {
say();
}
}
}
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;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest() {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak) {
if (paused || still) {
sceneT += 1;
}
update(0);
showNow();
if (speak) {
say();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
say();
}
function restart() {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// 새 판 — 씨앗이 달라진 판으로 시간표를 처음부터. 손으로 하던 중이어도 자동 시연으로 돌아간다
function newBoard() {
manual = false;
lap += 1;
begin();
update(0);
showNow();
say();
}
function moveCur(dx, dy) {
takeOver();
cur = clamp(rowOf(cur) + dy, 0, 8) * 9 + clamp(colOf(cur) + dx, 0, 8);
afterInput(true);
}
// 힌트 기본 — 고른 빈칸에 정답 숫자를 놓는다. 장면이 hintHook 을 걸어 두면 그쪽이 먼저
function hintDefault() {
if (!giv[cur] && g[cur] === 0) {
putDigit(cur, sol[cur]);
}
}
function toCanvas(e) {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 칸 이동, 1~9 = 숫자 입력, Delete·Backspace·0 = 지우기, H = 힌트,
// N = 새 판, R = 처음부터, Enter·Space = 一時停止
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'r' || k === 'R') {
restart();
}
else if (k === 'n' || k === 'N') {
e.preventDefault();
newBoard();
}
else if (k === 'ArrowLeft') {
e.preventDefault();
moveCur(-1, 0);
}
else if (k === 'ArrowRight') {
e.preventDefault();
moveCur(1, 0);
}
else if (k === 'ArrowUp') {
e.preventDefault();
moveCur(0, -1);
}
else if (k === 'ArrowDown') {
e.preventDefault();
moveCur(0, 1);
}
else if (k.length === 1 && k >= '1' && k <= '9') {
e.preventDefault();
takeOver();
selDigit = Number(k);
putDigit(cur, selDigit);
afterInput(true);
}
else if (k === 'Delete' || k === 'Backspace' || k === '0') {
e.preventDefault();
takeOver();
clearDigit(cur);
afterInput(true);
}
else if (k === 'h' || k === 'H') {
e.preventDefault();
takeOver();
if (hintHook !== null) {
hintHook();
}
else {
hintDefault();
}
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
pad.addEventListener('pointerdown', function (e) {
takeOver();
ptrDown = true;
try {
pad.setPointerCapture(e.pointerId);
}
catch (err) {
ptrDown = true;
}
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) {
cur = c;
}
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (!ptrDown) {
return;
}
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) {
cur = c;
}
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e) {
if (!ptrDown) {
return;
}
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { 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 CLUES_02 = 32;
const SEEDS_02 = [19, 37, 5, 10];
const FILL_GAP = 0.12;
const GLOW_S = 0.8;
let LAP_S = 8;
let REST_S = 5;
let chain = { order: [], digits: [], avail: [], solved: false, board: [] };
let chainShown = 0;
let stuckT = 99;
let orderOf = [];
const SPK_CHAIN = '候補がひとつだけのマスを {a} 個埋めた';
function fillAt(k) {
return function () {
if (putDigit(chain.order[k], chain.digits[k])) {
chainShown += 1;
}
};
}
function build() {
chainShown = 0;
const seed = SEEDS_02[lap % SEEDS_02.length];
const s = fullBoard(seed);
const d = digBoard(s, seed + 40, CLUES_02, false);
setBoard(d.puz, s);
chain = singlesFill(d.puz);
orderOf = [];
for (let i = 0; i < 81; i += 1) {
orderOf.push(-1);
}
for (let k = 0; k < chain.order.length; k += 1) {
orderOf[chain.order[k]] = k;
}
const gap = Math.min(FILL_GAP, 4.6 / Math.max(1, chain.order.length));
for (let k = 0; k < chain.order.length; k += 1) {
script.push({ t: 0.3 + k * gap, f: fillAt(k) });
}
stuckT = 0.3 + chain.order.length * gap + 0.2;
LAP_S = stuckT + 2.6;
REST_S = stuckT + 0.6;
}
function update(dt) { if (dt < 0) {
return;
} }
function isStuck() { return !chain.solved && chainShown >= chain.order.length && sceneT >= stuckT; }
function draw() {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
const n = Math.max(1, chain.order.length);
drawBoard(function (i) {
if (g[i] !== 0 && !giv[i] && orderOf[i] >= 0) {
// 방금 채운 칸은 노란 빛에서 제 색으로 가라앉는다
const base = mix(MINT, SKY, orderOf[i] / n);
const age = still ? 99 : sceneT - pt[i];
return age < GLOW_S ? mix(SUN, base, easeOut(age / GLOW_S)) : base;
}
return null;
}, null);
unitPulse(ROSE, 0.22, GLOW_S);
// 채운 순서의 선 — 앞에 채운 칸과 이웃이면 이어서 그린다
for (let k = 1; k < chainShown && k < chain.order.length; k += 1) {
const a = chain.order[k - 1];
const b = chain.order[k];
if (sharedKind(a, b) >= 0 && Math.max(Math.abs(rowOf(a) - rowOf(b)), Math.abs(colOf(a) - colOf(b))) <= 3) {
const recent = k >= chainShown - 4;
seg(cellCx(a), cellCy(a), cellCx(b), cellCy(b), SUN, recent ? 0.95 : 0.4, recent ? 2.2 : 1.2);
}
}
// 번호 — 채운 칸 모서리에 순서
for (let k = 0; k < chainShown && k < chain.order.length; k += 1) {
const i = chain.order[k];
text(String(k + 1), cellX(i) + 3, cellY(i) + 5, INK, 7, 700, 'left');
}
if (isStuck()) {
let min = 10;
for (let i = 0; i < 81; i += 1) {
if (g[i] === 0) {
min = Math.min(min, popcount(candMask(g, i)));
}
}
const pulse = 0.55 + 0.45 * Math.sin((sceneT - stuckT) * 5);
for (let i = 0; i < 81; i += 1) {
if (g[i] === 0 && popcount(candMask(g, i)) === min) {
ring(cellX(i) + 0.5, cellY(i) + 0.5, CS - 1, CS - 1, 4, ROSE, pulse, 2);
}
}
}
panel(PX, BY - 5, PW, 223);
text('候補がひとつのマスの数', PX + 12, BY + 8, DIM, 11, 700, 'left');
text(fill('埋めた {a}', String(chainShown)), PX + 12, BY + 38, PAPER, 26, 800, 'left');
// 막대 — 채울 때마다 그 시점에 후보가 하나뿐이던 칸 수
const bw = Math.max(2, Math.min(9, Math.floor(186 / n) - 1));
let top = 1;
for (let k = 0; k < chain.avail.length; k += 1) {
top = Math.max(top, chain.avail[k]);
}
for (let k = 0; k < chain.avail.length; k += 1) {
const x = PX + 12 + k * (bw + 1);
const h = Math.max(2, 70 * chain.avail[k] / top);
const on = k < chainShown;
box(x, BY + 150 - h, bw, h, 2, on ? (k === chainShown - 1 ? ROSE : MINT) : [48, 44, 74], 1);
}
seg(PX + 12, BY + 152, PX + PW - 12, BY + 152, DIM, 0.6, 1);
if (isStuck()) {
text('行き詰まり: 候補が2以上のマスだけ', PX + 12, BY + 172, ROSE, 11, 700, 'left');
}
else if (chain.solved && chainShown >= chain.order.length) {
text('最後まで解けた', PX + 12, BY + 172, MINT, 11, 700, 'left');
}
else {
text('埋めるたび次のマスがひとつになる', PX + 12, BY + 172, NEU, 11, 700, 'left');
}
const now = chainShown > 0 && chainShown <= chain.avail.length ? chain.avail[chainShown - 1] : 0;
hud('singles', String(now), now / Math.max(1, top));
}
function statusText() {
return fill('埋めた {a} マス、いま候補がひとつのマス {b}', String(chainShown), String(chainShown > 0 && chainShown <= chain.avail.length ? chain.avail[chainShown - 1] : 0));
}
function modeId() { return modeBase(isStuck() ? 2 : (chainShown > 0 ? 1 : 0)); }
function speakText() {
return fill(SPK_CHAIN, String(chainShown)) + (isStuck() ? '。もう埋められるマスがなく止まった' : '') + '. ' + speakBoard();
}
function onPtr(kind, x, y) { if (kind < 0 || x < 0 || y < 0) {
return;
} }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// SingleFillCascade - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./SingleFillCascade.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SingleFillCascade() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [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 easeOut(k: number): number { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const SKY: RGB = [150, 192, 244];
const SUN: RGB = [250, 218, 146];
const NEU: RGB = [236, 230, 250];
const BGC: RGB = [46, 42, 68];
const PANEL: RGB = [62, 57, 92];
const DIM: RGB = [196, 190, 224];
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) + ')';
}
function hsl(h: number, s: number, l: number): RGB {
const a = s * Math.min(l, 1 - l);
const f = function (n: number): number {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a: number, b: number): number {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s: string, a: string, b?: string, c?: string): string {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v: number): string { return String(Math.round(v * 10) / 10); }
function group(v: number): string {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { o += ','; }
o += s.charAt(i);
}
return o;
}
// ── 스도쿠 모델 — 판은 81칸 숫자 배열(0 은 빈칸). 후보는 9비트 한 수: 숫자 d 는 (d-1) 번째 비트
const ALL = 511;
function bit(d: number): number { return 1 << (d - 1); }
function popcount(m: number): number {
let c = 0;
let v = m;
while (v) { v &= v - 1; c += 1; }
return c;
}
// 가장 낮은 비트 하나가 가리키는 숫자
function lowDigit(m: number): number { return 32 - Math.clz32(m & -m); }
function bin9(m: number): string {
let s = '';
for (let d = 9; d >= 1; d -= 1) { s += (m & bit(d)) ? '1' : '0'; }
return s;
}
function rowOf(i: number): number { return Math.floor(i / 9); }
function colOf(i: number): number { return i % 9; }
function boxOf(i: number): number { return Math.floor(i / 27) * 3 + Math.floor((i % 9) / 3); }
// 묶음 27개 — 줄 9 · 세로줄 9 · 상자 9. 칸의 이웃은 같은 묶음 세 곳의 다른 칸 20개
const UNITS: number[][] = [];
const PEERS: number[][] = [];
for (let k = 0; k < 27; k += 1) { UNITS.push([]); }
for (let i = 0; i < 81; i += 1) {
UNITS[rowOf(i)].push(i);
UNITS[9 + colOf(i)].push(i);
UNITS[18 + boxOf(i)].push(i);
}
for (let i = 0; i < 81; i += 1) {
const ps: number[] = [];
for (let j = 0; j < 81; j += 1) {
if (j !== i && (rowOf(j) === rowOf(i) || colOf(j) === colOf(i) || boxOf(j) === boxOf(i))) { ps.push(j); }
}
PEERS.push(ps);
}
// 묶음 종류 kind: 0 줄 · 1 세로줄 · 2 상자
function unitIndex(kind: number, i: number): number { return kind === 0 ? rowOf(i) : (kind === 1 ? 9 + colOf(i) : 18 + boxOf(i)); }
function sharedKind(i: number, j: number): number {
if (rowOf(i) === rowOf(j)) { return 0; }
if (colOf(i) === colOf(j)) { return 1; }
return boxOf(i) === boxOf(j) ? 2 : -1;
}
// 칸 i 가 속한 kind 묶음에서 i 를 뺀 칸들이 이미 쓴 숫자의 비트 합(OR)
function usedMask(b: number[], i: number, kind: number): number {
const u = UNITS[unitIndex(kind, i)];
let m = 0;
for (let k = 0; k < 9; k += 1) { if (u[k] !== i && b[u[k]] !== 0) { m |= bit(b[u[k]]); } }
return m;
}
// 후보 = 9비트 전체에서 세 묶음이 쓴 비트를 뺀 것. 채워진 칸의 후보는 0
function candMask(b: number[], i: number): number {
if (b[i] !== 0) { return 0; }
return ALL & ~(usedMask(b, i, 0) | usedMask(b, i, 1) | usedMask(b, i, 2));
}
function filledOf(b: number[]): number {
let c = 0;
for (let i = 0; i < 81; i += 1) { if (b[i] !== 0) { c += 1; } }
return c;
}
function perm(n: number, seed: number): number[] {
const a: number[] = [];
for (let i = 0; i < n; i += 1) { a.push(i); }
for (let i = n - 1; i > 0; i -= 1) {
const j = Math.floor(hash2(i, seed) * (i + 1));
const t = a[i];
a[i] = a[j];
a[j] = t;
}
return a;
}
// 완성판 — 기본 무늬를 숫자·줄·상자 묶음 순서로 섞는다. 같은 씨앗이면 같은 판
function fullBoard(seed: number): number[] {
const dp = perm(9, seed * 7 + 1);
const bp = perm(3, seed * 7 + 2);
const sp = perm(3, seed * 7 + 3);
const rows: number[] = [];
const cols: number[] = [];
for (let k = 0; k < 3; k += 1) {
const rp = perm(3, seed * 13 + k * 5 + 4);
const cp = perm(3, seed * 13 + k * 5 + 9);
for (let j = 0; j < 3; j += 1) {
rows.push(bp[k] * 3 + rp[j]);
cols.push(sp[k] * 3 + cp[j]);
}
}
const b: number[] = [];
for (let r = 0; r < 9; r += 1) {
for (let c = 0; c < 9; c += 1) {
b.push(dp[(3 * (rows[r] % 3) + Math.floor(rows[r] / 3) + cols[c]) % 9] + 1);
}
}
return b;
}
// 풀이 탐색 — 가장 후보가 적은 칸(plain 이면 앞쪽 빈칸)부터 숫자를 걸고, 막히면 되돌린다.
// count 가 limit 에 닿으면 멈추고(풀이 개수는 limit 까지만 센다), 걸어 본 숫자가 cap 을 넘으면 멈춘다.
// trace 에는 (종류 << 16) | (칸 << 4) | 숫자 — 종류 0 은 걸기, 1 은 되돌리기
type Srch = { count: number; nodes: number; dead: number; branch: number; capHit: boolean; first: number[]; trace: number[] };
function search(start: number[], limit: number, cap: number, plain: boolean, wantTrace: boolean): Srch {
const b = start.slice();
const rm: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const cm: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const xm: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
rm[rowOf(i)] |= bit(b[i]);
cm[colOf(i)] |= bit(b[i]);
xm[boxOf(i)] |= bit(b[i]);
}
}
const r: Srch = { count: 0, nodes: 0, dead: 0, branch: 0, capHit: false, first: [], trace: [] };
function rec(): void {
let best = -1;
let bm = 0;
let bc = 10;
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) { continue; }
const m = ALL & ~(rm[rowOf(i)] | cm[colOf(i)] | xm[boxOf(i)]);
const pc = popcount(m);
if (plain) { best = i; bm = m; bc = pc; break; }
if (pc < bc) { best = i; bm = m; bc = pc; if (pc <= 1) { break; } }
}
if (best < 0) {
r.count += 1;
if (r.count === 1) { r.first = b.slice(); }
return;
}
if (bc === 0) { r.dead += 1; return; }
if (bc > 1) { r.branch += 1; }
let mm = bm;
while (mm) {
const lo = mm & -mm;
mm ^= lo;
const d = 32 - Math.clz32(lo);
r.nodes += 1;
if (r.nodes > cap) { r.capHit = true; return; }
b[best] = d;
rm[rowOf(best)] |= lo;
cm[colOf(best)] |= lo;
xm[boxOf(best)] |= lo;
if (wantTrace) { r.trace.push((best << 4) | d); }
rec();
if (r.count >= limit || r.capHit) { return; }
b[best] = 0;
rm[rowOf(best)] &= ~lo;
cm[colOf(best)] &= ~lo;
xm[boxOf(best)] &= ~lo;
if (wantTrace) { r.trace.push((1 << 16) | (best << 4) | d); }
}
}
rec();
return r;
}
// 하나뿐인 후보 채우기 — 후보가 하나인 칸(번호가 가장 앞선 칸)을 채우고 다시 센다. 더 없으면 멈춘다
type Singles = { order: number[]; digits: number[]; avail: number[]; solved: boolean; board: number[] };
function singlesFill(start: number[]): Singles {
const b = start.slice();
const order: number[] = [];
const digits: number[] = [];
const avail: number[] = [];
for (;;) {
let first = -1;
let n = 0;
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0 && popcount(candMask(b, i)) === 1) {
if (first < 0) { first = i; }
n += 1;
}
}
if (first < 0) { break; }
const d = lowDigit(candMask(b, first));
b[first] = d;
order.push(first);
digits.push(d);
avail.push(n);
}
let solved = true;
for (let i = 0; i < 81; i += 1) { if (b[i] === 0) { solved = false; } }
return { order: order, digits: digits, avail: avail, solved: solved, board: b };
}
// 구멍 파기 — 완성판에서 칸을 해시 순서로 하나씩 비우고 풀이 개수를 2 까지만 센다.
// 하나뿐이면 비운 채로 두고, 둘 이상이면 되돌린다. singlesOnly 이면 하나뿐인 후보만으로 풀리는 판만 남긴다
type DigStep = { cell: number; cnt: number; ok: boolean; nodes: number };
type Dug = { puz: number[]; steps: DigStep[]; clues: number };
function digBoard(sol: number[], seed: number, target: number, singlesOnly: boolean): Dug {
const puz = sol.slice();
const order = perm(81, seed * 31 + 5);
const steps: DigStep[] = [];
let clues = 81;
for (let k = 0; k < 81; k += 1) {
if (clues <= target) { break; }
const c = order[k];
const d = puz[c];
puz[c] = 0;
const s = search(puz, 2, 6000, false, false);
const cnt = s.capHit ? 2 : Math.min(2, s.count);
const ok = cnt === 1 && (!singlesOnly || singlesFill(puz).solved);
steps.push({ cell: c, cnt: cnt, ok: ok, nodes: s.nodes });
if (ok) { clues -= 1; } else { puz[c] = d; }
}
return { puz: puz, steps: steps, clues: clues };
}
// 놓은 숫자가 겹치는 쌍 — [칸, 칸, 묶음 종류]
function conflicts(b: number[]): number[][] {
const out: number[][] = [];
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0) { continue; }
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (p > i && b[p] === b[i]) { out.push([i, p, sharedKind(i, p)]); }
}
}
return out;
}
// ── 판 상태 — 장면이 아니라 판이 들고 있는 것. 화면은 이 값을 읽어 그린다
const BX = 22;
const BY = 44;
const CS = 23;
const GAP = 3;
const PX = 258;
const PW = 210;
let g: number[] = [];
let giv: boolean[] = [];
let sol: number[] = [];
let pt: number[] = [];
let cur = 40;
let solvedT = -1;
let lastCell = -1;
type Rip = { i: number; t: number; list: number[]; d: number };
let rip: Rip = { i: -1, t: -9, list: [], d: 0 };
let selDigit = 0;
let still = false;
let hintHook = null as (() => void) | null;
function cellX(i: number): number { const c = colOf(i); return BX + c * CS + Math.floor(c / 3) * GAP; }
function cellY(i: number): number { const r = rowOf(i); return BY + r * CS + Math.floor(r / 3) * GAP; }
function cellCx(i: number): number { return cellX(i) + CS / 2; }
function cellCy(i: number): number { return cellY(i) + CS / 2; }
function cellAt(x: number, y: number): number {
let c = -1;
let r = -1;
for (let k = 0; k < 9; k += 1) {
const x0 = BX + k * CS + Math.floor(k / 3) * GAP;
const y0 = BY + k * CS + Math.floor(k / 3) * GAP;
if (x >= x0 && x < x0 + CS) { c = k; }
if (y >= y0 && y < y0 + CS) { r = k; }
}
return c < 0 || r < 0 ? -1 : r * 9 + c;
}
function setBoard(puz: number[], solution: number[]): void {
g = puz.slice();
sol = solution.slice();
giv = [];
pt = [];
for (let i = 0; i < 81; i += 1) { giv.push(puz[i] !== 0); pt.push(-9); }
solvedT = -1;
lastCell = -1;
rip = { i: -1, t: -9, list: [], d: 0 };
}
function solvedNow(): boolean { return filledOf(g) === 81 && conflicts(g).length === 0; }
function checkSolved(): void {
if (solvedNow()) { if (solvedT < 0) { solvedT = sceneT; } } else { solvedT = -1; }
}
// 숫자를 놓는다 — 놓기 전에 그 숫자가 후보에 있던 이웃 칸을 기억해 두면 소거가 번지는 모습을 그릴 수 있다
function putDigit(i: number, d: number): boolean {
if (giv[i] || d < 1 || d > 9) { return false; }
const list: number[] = [];
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (g[p] === 0 && (candMask(g, p) & bit(d)) !== 0) { list.push(p); }
}
g[i] = d;
pt[i] = sceneT;
rip = { i: i, t: sceneT, list: list, d: d };
lastCell = i;
checkSolved();
return true;
}
function clearDigit(i: number): void {
if (giv[i] || g[i] === 0) { return; }
g[i] = 0;
pt[i] = -9;
checkSolved();
}
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)]; }
// ── 그리기 도구
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 doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 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 disc(x: number, y: number, r: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, c: RGB, a: number, w: number): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x: number, y: number, w: number, h: number): void { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name: string, val: string, frac: number): void {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// ── 판 그리기 — 칸 색은 장면이 정하고, 숫자는 판이 그린다
const EMPTY: RGB = [58, 53, 88];
const PLACED: RGB = [190, 214, 246];
const PLACED_INK: RGB = [28, 54, 118];
function ring(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number, lw: number): void {
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = lw;
ctx.stroke();
}
function drawBoard(fillOf: (i: number) => RGB | null, extra: ((i: number, x: number, y: number) => void) | null): void {
const s = 9 * CS + 2 * GAP;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < 81; i += 1) {
const x = cellX(i);
const y = cellY(i);
let f: RGB | null = fillOf(i);
if (f === null) { f = g[i] === 0 ? EMPTY : (giv[i] ? NEU : PLACED); }
box(x + 1, y + 1, CS - 2, CS - 2, 4, f, 1);
if (g[i] !== 0) {
const a = still ? 99 : sceneT - pt[i];
const k = a < 0.3 ? 0.7 + 0.3 * easeOut(a / 0.3) : 1;
text(String(g[i]), x + CS / 2, y + CS / 2 + 1, giv[i] ? INK : PLACED_INK, Math.round(14 * k), giv[i] ? 800 : 700, 'center');
} else if (extra !== null) {
extra(i, x, y);
}
}
if (!demo) { ring(cellX(cur) + 0.5, cellY(cur) + 0.5, CS - 1, CS - 1, 4, PAPER, 1, 2); }
}
// 묶음 하나를 옅게 물들인다
function tintUnit(u: number, c: RGB, a: number): void {
for (let k = 0; k < 9; k += 1) {
const i = UNITS[u][k];
box(cellX(i) + 1, cellY(i) + 1, CS - 2, CS - 2, 4, c, a);
// 물들임이 숫자를 흐리지 않도록 숫자를 다시 얹는다
if (g[i] !== 0) { text(String(g[i]), cellCx(i), cellCy(i) + 1, giv[i] ? INK : PLACED_INK, 14, giv[i] ? 800 : 700, 'center'); }
}
}
// 후보 비트 줄 — 숫자 1~9 칸 아홉 개, 켜진 비트만 밝다
function bitRow(x: number, y: number, m: number, on: RGB, off: RGB): void {
for (let d = 1; d <= 9; d += 1) {
const lit = (m & bit(d)) !== 0;
const bx = x + (d - 1) * 18;
box(bx, y, 16, 16, 4, lit ? on : off, 1);
text(String(d), bx + 8, y + 8.5, lit ? INK : DIM, 10, 700, 'center');
}
}
// ── 상태 줄·낭독 문장
const SPK_FILL = '{a}칸 중 {b}칸이 채워져 있다';
const SPK_DONE = '판 완성, {a}칸이 모두 채워졌다';
function speakBoard(): string {
return solvedNow() ? fill(SPK_DONE, '81') : fill(SPK_FILL, '81', String(filledOf(g)));
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 100 + 채운 칸 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode: number): number {
if (solvedNow()) { return 1000; }
return manual ? 100 + filledOf(g) : sceneMode;
}
// 방금 놓은 칸의 줄·세로줄·상자 세 묶음을 한 번 물들였다 서서히 빼는 번짐 — 숫자 하나가 어디까지 영향을 주는지 보인다
function unitPulse(c: RGB, a: number, dur: number): void {
if (rip.i < 0 || still) { return; }
const k = (sceneT - rip.t) / dur;
if (k < 0 || k >= 1) { return; }
for (let kind = 0; kind < 3; kind += 1) { tintUnit(unitIndex(kind, rip.i), c, a * (1 - k)); }
}
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 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
}
function takeOver(): void {
if (manual) { return; }
manual = true;
wake();
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say(): void {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
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);
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow(): void {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) { say(); }
}
}
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;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest(): void {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak: boolean): void {
if (paused || still) { sceneT += 1; }
update(0);
showNow();
if (speak) { say(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
say();
}
function restart(): void {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// 새 판 — 씨앗이 달라진 판으로 시간표를 처음부터. 손으로 하던 중이어도 자동 시연으로 돌아간다
function newBoard(): void {
manual = false;
lap += 1;
begin();
update(0);
showNow();
say();
}
function moveCur(dx: number, dy: number): void {
takeOver();
cur = clamp(rowOf(cur) + dy, 0, 8) * 9 + clamp(colOf(cur) + dx, 0, 8);
afterInput(true);
}
// 힌트 기본 — 고른 빈칸에 정답 숫자를 놓는다. 장면이 hintHook 을 걸어 두면 그쪽이 먼저
function hintDefault(): void {
if (!giv[cur] && g[cur] === 0) { putDigit(cur, sol[cur]); }
}
function toCanvas(e: PointerEvent): number[] {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 칸 이동, 1~9 = 숫자 입력, Delete·Backspace·0 = 지우기, H = 힌트,
// N = 새 판, R = 처음부터, Enter·Space = 일시정지
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'r' || k === 'R') {
restart();
} else if (k === 'n' || k === 'N') {
e.preventDefault();
newBoard();
} else if (k === 'ArrowLeft') {
e.preventDefault();
moveCur(-1, 0);
} else if (k === 'ArrowRight') {
e.preventDefault();
moveCur(1, 0);
} else if (k === 'ArrowUp') {
e.preventDefault();
moveCur(0, -1);
} else if (k === 'ArrowDown') {
e.preventDefault();
moveCur(0, 1);
} else if (k.length === 1 && k >= '1' && k <= '9') {
e.preventDefault();
takeOver();
selDigit = Number(k);
putDigit(cur, selDigit);
afterInput(true);
} else if (k === 'Delete' || k === 'Backspace' || k === '0') {
e.preventDefault();
takeOver();
clearDigit(cur);
afterInput(true);
} else if (k === 'h' || k === 'H') {
e.preventDefault();
takeOver();
if (hintHook !== null) { hintHook(); } else { hintDefault(); }
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
ptrDown = true;
try { pad.setPointerCapture(e.pointerId); } catch (err) { ptrDown = true; }
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) { cur = c; }
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (!ptrDown) { return; }
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) { cur = c; }
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e: PointerEvent): void {
if (!ptrDown) { return; }
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { 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 CLUES_02 = 32;
const SEEDS_02 = [19, 37, 5, 10];
const FILL_GAP = 0.12;
const GLOW_S = 0.8;
let LAP_S = 8;
let REST_S = 5;
let chain: Singles = { order: [], digits: [], avail: [], solved: false, board: [] };
let chainShown = 0;
let stuckT = 99;
let orderOf: number[] = [];
const SPK_CHAIN = '하나뿐인 후보를 {a}칸 채웠다';
function fillAt(k: number): () => void {
return function (): void {
if (putDigit(chain.order[k], chain.digits[k])) { chainShown += 1; }
};
}
function build(): void {
chainShown = 0;
const seed = SEEDS_02[lap % SEEDS_02.length];
const s = fullBoard(seed);
const d = digBoard(s, seed + 40, CLUES_02, false);
setBoard(d.puz, s);
chain = singlesFill(d.puz);
orderOf = [];
for (let i = 0; i < 81; i += 1) { orderOf.push(-1); }
for (let k = 0; k < chain.order.length; k += 1) { orderOf[chain.order[k]] = k; }
const gap = Math.min(FILL_GAP, 4.6 / Math.max(1, chain.order.length));
for (let k = 0; k < chain.order.length; k += 1) { script.push({ t: 0.3 + k * gap, f: fillAt(k) }); }
stuckT = 0.3 + chain.order.length * gap + 0.2;
LAP_S = stuckT + 2.6;
REST_S = stuckT + 0.6;
}
function update(dt: number): void { if (dt < 0) { return; } }
function isStuck(): boolean { return !chain.solved && chainShown >= chain.order.length && sceneT >= stuckT; }
function draw(): void {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
const n = Math.max(1, chain.order.length);
drawBoard(function (i: number): RGB | null {
if (g[i] !== 0 && !giv[i] && orderOf[i] >= 0) {
// 방금 채운 칸은 노란 빛에서 제 색으로 가라앉는다
const base = mix(MINT, SKY, orderOf[i] / n);
const age = still ? 99 : sceneT - pt[i];
return age < GLOW_S ? mix(SUN, base, easeOut(age / GLOW_S)) : base;
}
return null;
}, null);
unitPulse(ROSE, 0.22, GLOW_S);
// 채운 순서의 선 — 앞에 채운 칸과 이웃이면 이어서 그린다
for (let k = 1; k < chainShown && k < chain.order.length; k += 1) {
const a = chain.order[k - 1];
const b = chain.order[k];
if (sharedKind(a, b) >= 0 && Math.max(Math.abs(rowOf(a) - rowOf(b)), Math.abs(colOf(a) - colOf(b))) <= 3) {
const recent = k >= chainShown - 4;
seg(cellCx(a), cellCy(a), cellCx(b), cellCy(b), SUN, recent ? 0.95 : 0.4, recent ? 2.2 : 1.2);
}
}
// 번호 — 채운 칸 모서리에 순서
for (let k = 0; k < chainShown && k < chain.order.length; k += 1) {
const i = chain.order[k];
text(String(k + 1), cellX(i) + 3, cellY(i) + 5, INK, 7, 700, 'left');
}
if (isStuck()) {
let min = 10;
for (let i = 0; i < 81; i += 1) { if (g[i] === 0) { min = Math.min(min, popcount(candMask(g, i))); } }
const pulse = 0.55 + 0.45 * Math.sin((sceneT - stuckT) * 5);
for (let i = 0; i < 81; i += 1) {
if (g[i] === 0 && popcount(candMask(g, i)) === min) { ring(cellX(i) + 0.5, cellY(i) + 0.5, CS - 1, CS - 1, 4, ROSE, pulse, 2); }
}
}
panel(PX, BY - 5, PW, 223);
text('하나뿐인 칸 수', PX + 12, BY + 8, DIM, 11, 700, 'left');
text(fill('채움 {a}', String(chainShown)), PX + 12, BY + 38, PAPER, 26, 800, 'left');
// 막대 — 채울 때마다 그 시점에 후보가 하나뿐이던 칸 수
const bw = Math.max(2, Math.min(9, Math.floor(186 / n) - 1));
let top = 1;
for (let k = 0; k < chain.avail.length; k += 1) { top = Math.max(top, chain.avail[k]); }
for (let k = 0; k < chain.avail.length; k += 1) {
const x = PX + 12 + k * (bw + 1);
const h = Math.max(2, 70 * chain.avail[k] / top);
const on = k < chainShown;
box(x, BY + 150 - h, bw, h, 2, on ? (k === chainShown - 1 ? ROSE : MINT) : [48, 44, 74], 1);
}
seg(PX + 12, BY + 152, PX + PW - 12, BY + 152, DIM, 0.6, 1);
if (isStuck()) { text('막힘: 후보가 둘 이상인 칸만 남음', PX + 12, BY + 172, ROSE, 11, 700, 'left'); }
else if (chain.solved && chainShown >= chain.order.length) { text('끝까지 풀림', PX + 12, BY + 172, MINT, 11, 700, 'left'); }
else { text('채울 때마다 다음 칸이 하나가 된다', PX + 12, BY + 172, NEU, 11, 700, 'left'); }
const now = chainShown > 0 && chainShown <= chain.avail.length ? chain.avail[chainShown - 1] : 0;
hud('singles', String(now), now / Math.max(1, top));
}
function statusText(): string {
return fill('채움 {a}칸, 지금 남은 단일 후보 {b}칸', String(chainShown), String(chainShown > 0 && chainShown <= chain.avail.length ? chain.avail[chainShown - 1] : 0));
}
function modeId(): number { return modeBase(isStuck() ? 2 : (chainShown > 0 ? 1 : 0)); }
function speakText(): string {
return fill(SPK_CHAIN, String(chainShown)) + (isStuck() ? '. 더 채울 칸이 없어 멈췄다' : '') + '. ' + speakBoard();
}
function onPtr(kind: number, x: number, y: number): void { if (kind < 0 || x < 0 || y < 0) { 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={'스도쿠 시연 무대 — 방향키로 칸 이동, 1부터 9 숫자키로 입력, Delete 로 지우기, H 로 힌트, N 으로 새 판, R 로 처음부터, Enter 또는 Space 로 일시정지'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'단서가 적은 9×9 스도쿠 판에서 후보가 하나뿐인 칸이 차례로 채워진다. 채운 칸마다 모서리에 채운 순서 번호가 붙고 앞뒤로 이웃한 칸은 선으로 이어지며 색이 민트에서 하늘색으로 변한다. 오른쪽 패널에는 채운 칸 수와 채울 때마다 그 시점의 하나뿐인 칸 수 막대가 쌓이고, 더 채울 칸이 없으면 후보가 가장 적은 칸에 붉은 테가 깜빡이며 막힘이라고 표시된다. 아래 눈금은 상수 이름과 지금 값을 보이고, 움직임 줄이기에서는 숫자가 바로 채워진 판 한 장만 그려진다'}></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>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}02 · ひとつだけの候補を埋めると次が解ける連鎖 — 計測 累積面積 24.627% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function singlesFill(start) {
const b = start.slice();
const order = [];
const digits = [];
const avail = [];
for (;;) {
let first = -1;
let n = 0;
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0 && popcount(candMask(b, i)) === 1) {
if (first < 0) {
first = i;
}
n += 1;
}
}
if (first < 0) {
break;
}
const d = lowDigit(candMask(b, first));
b[first] = d;
order.push(first);
digits.push(d);
avail.push(n);
}
ヒント32の四つの盤で埋まったマスは27・20・19・17個で、どれも正解と同じでした。ただし四つとも、ひとつだけの候補では最後まで解けずに止まり、候補がいちばん少ないマスに赤い枠が残ります。
03候補がいちばん少ないマスから戻る探索
ひとつだけの候補で解けなくなったら、候補がいちばん少ないマスに数字を仮置きし、行き詰まれば戻すので、筋が見えない難しい盤を最後まで解くのに使います。仮置きの数は NODE_CAP の400で打ち切ります。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="数独の舞台 — 矢印キーでマス移動、数字キー1〜9で入力、Deleteで消去、Hでヒント、Nで新しい盤、Rで最初から、EnterかSpaceで一時停止">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="ヒント24個の難しい9×9の盤で、候補がいちばん少ないマスから数字を仮置きし、行き詰まれば戻す。仮置きした数字は空色で現れ、戻して消えたマスは少しのあいだ赤く光る。右のパネルではNODE_CAP 400の目盛りに試した数字の数が溜まり、戻した回数と、候補の少ないマスから解いたノード数、空き順で解いたノード数が対数の棒で並ぶ。上限に届くと停止が表示される。下の目盛りは定数名と今の値を示し、動きを減らす設定では数字がすぐ埋まった盤一枚だけを描きます"></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>
</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-ink;
}
// 무대 상자 — 눌러서 한 번 깨뜨리는 곳(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-cream;
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 {
padding: $sp-1 $sp-3;
border: 2px solid $subject-cream;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
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__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-ink;
color: $subject-cream;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면에만 있고 aria-live 는 늘 off
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 100px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
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__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 글리치 없는 원문 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause: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: #17141a;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #fff7e6;
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 {
padding: 4px 12px;
border: 2px solid #fff7e6;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
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__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 100px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
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__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause: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 easeOut(k) { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const SKY = [150, 192, 244];
const SUN = [250, 218, 146];
const NEU = [236, 230, 250];
const BGC = [46, 42, 68];
const PANEL = [62, 57, 92];
const DIM = [196, 190, 224];
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) + ')';
}
function hsl(h, s, l) {
const a = s * Math.min(l, 1 - l);
const f = function (n) {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a, b) {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s, a, b, c) {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v) { return String(Math.round(v * 10) / 10); }
function group(v) {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
o += ',';
}
o += s.charAt(i);
}
return o;
}
// ── 스도쿠 모델 — 판은 81칸 숫자 배열(0 은 빈칸). 후보는 9비트 한 수: 숫자 d 는 (d-1) 번째 비트
const ALL = 511;
function bit(d) { return 1 << (d - 1); }
function popcount(m) {
let c = 0;
let v = m;
while (v) {
v &= v - 1;
c += 1;
}
return c;
}
// 가장 낮은 비트 하나가 가리키는 숫자
function lowDigit(m) { return 32 - Math.clz32(m & -m); }
function bin9(m) {
let s = '';
for (let d = 9; d >= 1; d -= 1) {
s += (m & bit(d)) ? '1' : '0';
}
return s;
}
function rowOf(i) { return Math.floor(i / 9); }
function colOf(i) { return i % 9; }
function boxOf(i) { return Math.floor(i / 27) * 3 + Math.floor((i % 9) / 3); }
// 묶음 27개 — 줄 9 · 세로줄 9 · 箱 9. 칸의 이웃은 같은 묶음 세 곳의 다른 칸 20개
const UNITS = [];
const PEERS = [];
for (let k = 0; k < 27; k += 1) {
UNITS.push([]);
}
for (let i = 0; i < 81; i += 1) {
UNITS[rowOf(i)].push(i);
UNITS[9 + colOf(i)].push(i);
UNITS[18 + boxOf(i)].push(i);
}
for (let i = 0; i < 81; i += 1) {
const ps = [];
for (let j = 0; j < 81; j += 1) {
if (j !== i && (rowOf(j) === rowOf(i) || colOf(j) === colOf(i) || boxOf(j) === boxOf(i))) {
ps.push(j);
}
}
PEERS.push(ps);
}
// 묶음 종류 kind: 0 줄 · 1 세로줄 · 2 箱
function unitIndex(kind, i) { return kind === 0 ? rowOf(i) : (kind === 1 ? 9 + colOf(i) : 18 + boxOf(i)); }
function sharedKind(i, j) {
if (rowOf(i) === rowOf(j)) {
return 0;
}
if (colOf(i) === colOf(j)) {
return 1;
}
return boxOf(i) === boxOf(j) ? 2 : -1;
}
// 칸 i 가 속한 kind 묶음에서 i 를 뺀 칸들이 이미 쓴 숫자의 비트 合計(OR)
function usedMask(b, i, kind) {
const u = UNITS[unitIndex(kind, i)];
let m = 0;
for (let k = 0; k < 9; k += 1) {
if (u[k] !== i && b[u[k]] !== 0) {
m |= bit(b[u[k]]);
}
}
return m;
}
// 候補 = 9비트 전체에서 세 묶음이 쓴 비트를 뺀 것. 채워진 칸의 후보는 0
function candMask(b, i) {
if (b[i] !== 0) {
return 0;
}
return ALL & ~(usedMask(b, i, 0) | usedMask(b, i, 1) | usedMask(b, i, 2));
}
function filledOf(b) {
let c = 0;
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
c += 1;
}
}
return c;
}
function perm(n, seed) {
const a = [];
for (let i = 0; i < n; i += 1) {
a.push(i);
}
for (let i = n - 1; i > 0; i -= 1) {
const j = Math.floor(hash2(i, seed) * (i + 1));
const t = a[i];
a[i] = a[j];
a[j] = t;
}
return a;
}
// 완성판 — 기본 무늬를 숫자·줄·箱 묶음 순서로 섞는다. 같은 씨앗이면 같은 판
function fullBoard(seed) {
const dp = perm(9, seed * 7 + 1);
const bp = perm(3, seed * 7 + 2);
const sp = perm(3, seed * 7 + 3);
const rows = [];
const cols = [];
for (let k = 0; k < 3; k += 1) {
const rp = perm(3, seed * 13 + k * 5 + 4);
const cp = perm(3, seed * 13 + k * 5 + 9);
for (let j = 0; j < 3; j += 1) {
rows.push(bp[k] * 3 + rp[j]);
cols.push(sp[k] * 3 + cp[j]);
}
}
const b = [];
for (let r = 0; r < 9; r += 1) {
for (let c = 0; c < 9; c += 1) {
b.push(dp[(3 * (rows[r] % 3) + Math.floor(rows[r] / 3) + cols[c]) % 9] + 1);
}
}
return b;
}
function search(start, limit, cap, plain, wantTrace) {
const b = start.slice();
const rm = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const cm = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const xm = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
rm[rowOf(i)] |= bit(b[i]);
cm[colOf(i)] |= bit(b[i]);
xm[boxOf(i)] |= bit(b[i]);
}
}
const r = { count: 0, nodes: 0, dead: 0, branch: 0, capHit: false, first: [], trace: [] };
function rec() {
let best = -1;
let bm = 0;
let bc = 10;
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
continue;
}
const m = ALL & ~(rm[rowOf(i)] | cm[colOf(i)] | xm[boxOf(i)]);
const pc = popcount(m);
if (plain) {
best = i;
bm = m;
bc = pc;
break;
}
if (pc < bc) {
best = i;
bm = m;
bc = pc;
if (pc <= 1) {
break;
}
}
}
if (best < 0) {
r.count += 1;
if (r.count === 1) {
r.first = b.slice();
}
return;
}
if (bc === 0) {
r.dead += 1;
return;
}
if (bc > 1) {
r.branch += 1;
}
let mm = bm;
while (mm) {
const lo = mm & -mm;
mm ^= lo;
const d = 32 - Math.clz32(lo);
r.nodes += 1;
if (r.nodes > cap) {
r.capHit = true;
return;
}
b[best] = d;
rm[rowOf(best)] |= lo;
cm[colOf(best)] |= lo;
xm[boxOf(best)] |= lo;
if (wantTrace) {
r.trace.push((best << 4) | d);
}
rec();
if (r.count >= limit || r.capHit) {
return;
}
b[best] = 0;
rm[rowOf(best)] &= ~lo;
cm[colOf(best)] &= ~lo;
xm[boxOf(best)] &= ~lo;
if (wantTrace) {
r.trace.push((1 << 16) | (best << 4) | d);
}
}
}
rec();
return r;
}
function singlesFill(start) {
const b = start.slice();
const order = [];
const digits = [];
const avail = [];
for (;;) {
let first = -1;
let n = 0;
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0 && popcount(candMask(b, i)) === 1) {
if (first < 0) {
first = i;
}
n += 1;
}
}
if (first < 0) {
break;
}
const d = lowDigit(candMask(b, first));
b[first] = d;
order.push(first);
digits.push(d);
avail.push(n);
}
let solved = true;
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0) {
solved = false;
}
}
return { order: order, digits: digits, avail: avail, solved: solved, board: b };
}
function digBoard(sol, seed, target, singlesOnly) {
const puz = sol.slice();
const order = perm(81, seed * 31 + 5);
const steps = [];
let clues = 81;
for (let k = 0; k < 81; k += 1) {
if (clues <= target) {
break;
}
const c = order[k];
const d = puz[c];
puz[c] = 0;
const s = search(puz, 2, 6000, false, false);
const cnt = s.capHit ? 2 : Math.min(2, s.count);
const ok = cnt === 1 && (!singlesOnly || singlesFill(puz).solved);
steps.push({ cell: c, cnt: cnt, ok: ok, nodes: s.nodes });
if (ok) {
clues -= 1;
}
else {
puz[c] = d;
}
}
return { puz: puz, steps: steps, clues: clues };
}
// 놓은 숫자가 겹치는 쌍 — [칸, 칸, 묶음 종류]
function conflicts(b) {
const out = [];
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0) {
continue;
}
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (p > i && b[p] === b[i]) {
out.push([i, p, sharedKind(i, p)]);
}
}
}
return out;
}
// ── 판 상태 — 장면이 아니라 판이 들고 있는 것. 화면은 이 값을 읽어 그린다
const BX = 22;
const BY = 44;
const CS = 23;
const GAP = 3;
const PX = 258;
const PW = 210;
let g = [];
let giv = [];
let sol = [];
let pt = [];
let cur = 40;
let solvedT = -1;
let lastCell = -1;
let rip = { i: -1, t: -9, list: [], d: 0 };
let selDigit = 0;
let still = false;
let hintHook = null;
function cellX(i) { const c = colOf(i); return BX + c * CS + Math.floor(c / 3) * GAP; }
function cellY(i) { const r = rowOf(i); return BY + r * CS + Math.floor(r / 3) * GAP; }
function cellCx(i) { return cellX(i) + CS / 2; }
function cellCy(i) { return cellY(i) + CS / 2; }
function cellAt(x, y) {
let c = -1;
let r = -1;
for (let k = 0; k < 9; k += 1) {
const x0 = BX + k * CS + Math.floor(k / 3) * GAP;
const y0 = BY + k * CS + Math.floor(k / 3) * GAP;
if (x >= x0 && x < x0 + CS) {
c = k;
}
if (y >= y0 && y < y0 + CS) {
r = k;
}
}
return c < 0 || r < 0 ? -1 : r * 9 + c;
}
function setBoard(puz, solution) {
g = puz.slice();
sol = solution.slice();
giv = [];
pt = [];
for (let i = 0; i < 81; i += 1) {
giv.push(puz[i] !== 0);
pt.push(-9);
}
solvedT = -1;
lastCell = -1;
rip = { i: -1, t: -9, list: [], d: 0 };
}
function solvedNow() { return filledOf(g) === 81 && conflicts(g).length === 0; }
function checkSolved() {
if (solvedNow()) {
if (solvedT < 0) {
solvedT = sceneT;
}
}
else {
solvedT = -1;
}
}
// 숫자를 놓는다 — 놓기 전에 그 숫자가 후보에 있던 이웃 칸을 기억해 두면 소거가 번지는 모습을 그릴 수 있다
function putDigit(i, d) {
if (giv[i] || d < 1 || d > 9) {
return false;
}
const list = [];
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (g[p] === 0 && (candMask(g, p) & bit(d)) !== 0) {
list.push(p);
}
}
g[i] = d;
pt[i] = sceneT;
rip = { i: i, t: sceneT, list: list, d: d };
lastCell = i;
checkSolved();
return true;
}
function clearDigit(i) {
if (giv[i] || g[i] === 0) {
return;
}
g[i] = 0;
pt[i] = -9;
checkSolved();
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// ── 그리기 도구
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 doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 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 disc(x, y, r, c, a) {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, c, a, w) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s, x, y, c, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x, y, w, h) { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name, val, frac) {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// ── 판 그리기 — 칸 색은 장면이 정하고, 숫자는 판이 그린다
const EMPTY = [58, 53, 88];
const PLACED = [190, 214, 246];
const PLACED_INK = [28, 54, 118];
function ring(x, y, w, h, r, c, a, lw) {
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = lw;
ctx.stroke();
}
function drawBoard(fillOf, extra) {
const s = 9 * CS + 2 * GAP;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < 81; i += 1) {
const x = cellX(i);
const y = cellY(i);
let f = fillOf(i);
if (f === null) {
f = g[i] === 0 ? EMPTY : (giv[i] ? NEU : PLACED);
}
box(x + 1, y + 1, CS - 2, CS - 2, 4, f, 1);
if (g[i] !== 0) {
const a = still ? 99 : sceneT - pt[i];
const k = a < 0.3 ? 0.7 + 0.3 * easeOut(a / 0.3) : 1;
text(String(g[i]), x + CS / 2, y + CS / 2 + 1, giv[i] ? INK : PLACED_INK, Math.round(14 * k), giv[i] ? 800 : 700, 'center');
}
else if (extra !== null) {
extra(i, x, y);
}
}
if (!demo) {
ring(cellX(cur) + 0.5, cellY(cur) + 0.5, CS - 1, CS - 1, 4, PAPER, 1, 2);
}
}
// 묶음 하나를 옅게 물들인다
function tintUnit(u, c, a) {
for (let k = 0; k < 9; k += 1) {
const i = UNITS[u][k];
box(cellX(i) + 1, cellY(i) + 1, CS - 2, CS - 2, 4, c, a);
// 물들임이 숫자를 흐리지 않도록 숫자를 다시 얹는다
if (g[i] !== 0) {
text(String(g[i]), cellCx(i), cellCy(i) + 1, giv[i] ? INK : PLACED_INK, 14, giv[i] ? 800 : 700, 'center');
}
}
}
// 候補 비트 줄 — 숫자 1~9 칸 아홉 개, 켜진 비트만 밝다
function bitRow(x, y, m, on, off) {
for (let d = 1; d <= 9; d += 1) {
const lit = (m & bit(d)) !== 0;
const bx = x + (d - 1) * 18;
box(bx, y, 16, 16, 4, lit ? on : off, 1);
text(String(d), bx + 8, y + 8.5, lit ? INK : DIM, 10, 700, 'center');
}
}
// ── 상태 줄·낭독 문장
const SPK_FILL = '{a} マスのうち {b} マスが埋まっている';
const SPK_DONE = '盤が完成、{a} マスすべてが埋まった';
function speakBoard() {
return solvedNow() ? fill(SPK_DONE, '81') : fill(SPK_FILL, '81', String(filledOf(g)));
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 100 + 채운 칸 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode) {
if (solvedNow()) {
return 1000;
}
return manual ? 100 + filledOf(g) : sceneMode;
}
// 방금 놓은 칸의 줄·세로줄·箱 세 묶음을 한 번 물들였다 서서히 빼는 번짐 — 숫자 하나가 어디까지 영향을 주는지 보인다
function unitPulse(c, a, dur) {
if (rip.i < 0 || still) {
return;
}
const k = (sceneT - rip.t) / dur;
if (k < 0 || k >= 1) {
return;
}
for (let kind = 0; kind < 3; kind += 1) {
tintUnit(unitIndex(kind, rip.i), c, a * (1 - k));
}
}
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 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
}
function takeOver() {
if (manual) {
return;
}
manual = true;
wake();
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say() {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
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);
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow() {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) {
say();
}
}
}
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;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest() {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak) {
if (paused || still) {
sceneT += 1;
}
update(0);
showNow();
if (speak) {
say();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
say();
}
function restart() {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// 새 판 — 씨앗이 달라진 판으로 시간표를 처음부터. 손으로 하던 중이어도 자동 시연으로 돌아간다
function newBoard() {
manual = false;
lap += 1;
begin();
update(0);
showNow();
say();
}
function moveCur(dx, dy) {
takeOver();
cur = clamp(rowOf(cur) + dy, 0, 8) * 9 + clamp(colOf(cur) + dx, 0, 8);
afterInput(true);
}
// 힌트 기본 — 고른 빈칸에 정답 숫자를 놓는다. 장면이 hintHook 을 걸어 두면 그쪽이 먼저
function hintDefault() {
if (!giv[cur] && g[cur] === 0) {
putDigit(cur, sol[cur]);
}
}
function toCanvas(e) {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 칸 이동, 1~9 = 숫자 입력, Delete·Backspace·0 = 지우기, H = 힌트,
// N = 새 판, R = 처음부터, Enter·Space = 一時停止
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'r' || k === 'R') {
restart();
}
else if (k === 'n' || k === 'N') {
e.preventDefault();
newBoard();
}
else if (k === 'ArrowLeft') {
e.preventDefault();
moveCur(-1, 0);
}
else if (k === 'ArrowRight') {
e.preventDefault();
moveCur(1, 0);
}
else if (k === 'ArrowUp') {
e.preventDefault();
moveCur(0, -1);
}
else if (k === 'ArrowDown') {
e.preventDefault();
moveCur(0, 1);
}
else if (k.length === 1 && k >= '1' && k <= '9') {
e.preventDefault();
takeOver();
selDigit = Number(k);
putDigit(cur, selDigit);
afterInput(true);
}
else if (k === 'Delete' || k === 'Backspace' || k === '0') {
e.preventDefault();
takeOver();
clearDigit(cur);
afterInput(true);
}
else if (k === 'h' || k === 'H') {
e.preventDefault();
takeOver();
if (hintHook !== null) {
hintHook();
}
else {
hintDefault();
}
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
pad.addEventListener('pointerdown', function (e) {
takeOver();
ptrDown = true;
try {
pad.setPointerCapture(e.pointerId);
}
catch (err) {
ptrDown = true;
}
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) {
cur = c;
}
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (!ptrDown) {
return;
}
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) {
cur = c;
}
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e) {
if (!ptrDown) {
return;
}
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { 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 — 하나뿐인 후보로 풀리지 않으면 후보가 가장 적은 칸에 숫자를 걸고 이어 가다 막히면 되돌린다. 걸린 숫자를 세어 NODE_CAP 에서 끊고, 앞쪽 빈칸부터 푸는 순서의 노드 수를 옆에 둔다
const NODE_CAP = 400;
const PLAIN_CAP = 20000;
const CLUES_03 = 24;
const SEARCH_S = 4.2;
const SEEDS_03 = [1, 5, 23, 17];
let LAP_S = 7;
let REST_S = 5.1;
let fs = { count: 0, nodes: 0, dead: 0, branch: 0, capHit: false, first: [], trace: [] };
let plainNodes = 0;
let plainCapHit = false;
let tpos = 0;
let placedLive = 0;
let undoLive = 0;
let undoT = [];
let startPuz = [];
function build() {
tpos = 0;
placedLive = 0;
undoLive = 0;
undoT = [];
for (let i = 0; i < 81; i += 1) {
undoT.push(-9);
}
const seed = SEEDS_03[lap % SEEDS_03.length];
const s = fullBoard(seed);
const d = digBoard(s, seed + 40, CLUES_03, false);
setBoard(d.puz, s);
startPuz = d.puz.slice();
fs = search(startPuz, 1, NODE_CAP, false, true);
const pl = search(startPuz, 1, PLAIN_CAP, true, false);
plainNodes = pl.nodes;
plainCapHit = pl.capHit;
}
// 시간표 대신 진행도로 되짚기를 재생한다 — 보인 걸음 수는 시계에서 곧바로 나온다
function update(dt) {
if (dt < 0) {
return;
}
const want = Math.floor(clamp((sceneT - 0.5) / SEARCH_S, 0, 1) * fs.trace.length);
while (tpos < want) {
const ev = fs.trace[tpos];
tpos += 1;
const cell = (ev >> 4) & 4095;
const dg = ev & 15;
if ((ev >> 16) === 0) {
g[cell] = dg;
pt[cell] = sceneT;
placedLive += 1;
}
else {
g[cell] = 0;
pt[cell] = -9;
undoT[cell] = sceneT;
undoLive += 1;
}
}
if (tpos >= fs.trace.length && fs.trace.length > 0) {
checkSolved();
}
}
function logBar(n) { return Math.log(n + 1) / Math.log(PLAIN_CAP + 1); }
function draw() {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoard(function (i) {
if (g[i] !== 0 && !giv[i]) {
return SKY;
}
const u = still ? 99 : sceneT - undoT[i];
if (g[i] === 0 && u >= 0 && u < 0.35) {
return mix(ROSE, EMPTY, u / 0.35);
}
return null;
}, null);
panel(PX, BY - 5, PW, 223);
text('NODE_CAP ' + NODE_CAP, PX + 12, BY + 8, DIM, 11, 700, 'left');
box(PX + 12, BY + 20, PW - 24, 10, 5, [48, 44, 74], 1);
box(PX + 12, BY + 20, (PW - 24) * clamp(placedLive / NODE_CAP, 0, 1), 10, 5, placedLive >= NODE_CAP ? ROSE : MINT, 1);
text(fill('候補の少ないマスから {a}', String(placedLive)), PX + 12, BY + 46, NEU, 12, 700, 'left');
text(fill('戻した回数 {a}', String(undoLive)), PX + 12, BY + 66, NEU, 12, 700, 'left');
text('空き順', PX + 12, BY + 96, DIM, 11, 700, 'left');
const full = PW - 24;
box(PX + 12, BY + 108, full, 10, 5, [48, 44, 74], 1);
box(PX + 12, BY + 108, full * logBar(plainNodes), 10, 5, SUN, 1);
text(group(plainNodes) + (plainCapHit ? '+' : ''), PX + 12, BY + 134, NEU, 12, 700, 'left');
const first = Math.max(1, fs.nodes);
box(PX + 12, BY + 148, full, 10, 5, [48, 44, 74], 1);
box(PX + 12, BY + 148, full * logBar(Math.min(fs.nodes, NODE_CAP)), 10, 5, MINT, 1);
text(fill('候補の少ないマスから {a}', String(first)), PX + 12, BY + 174, NEU, 12, 700, 'left');
const doneAll = tpos >= fs.trace.length && fs.trace.length > 0;
if (doneAll) {
if (fs.capHit) {
text('NODE_CAPに到達、停止', PX + 12, BY + 200, ROSE, 12, 800, 'left');
}
else if (fs.count > 0) {
text('解を発見', PX + 12, BY + 200, MINT, 12, 800, 'left');
}
}
hud('NODE_CAP', String(NODE_CAP), placedLive / NODE_CAP);
}
function statusText() {
return fill('試した数字 {a} 個、戻し {b} 回', String(placedLive), String(undoLive));
}
function modeId() {
const doneAll = tpos >= fs.trace.length && fs.trace.length > 0;
return modeBase(doneAll ? 3 : (tpos > 0 ? 1 : 0));
}
function speakText() {
const doneAll = tpos >= fs.trace.length && fs.trace.length > 0;
const m = doneAll ? (fs.capHit ? 'NODE_CAPで止まった' : '戻る探索で解が見つかった') + '. ' : '';
return m + fill('試した数字 {a} 個、戻し {b} 回', String(placedLive), String(undoLive)) + '. ' + speakBoard();
}
function onPtr(kind, x, y) { if (kind < 0 || x < 0 || y < 0) {
return;
} }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// FewestFirstBacktrack - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./FewestFirstBacktrack.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function FewestFirstBacktrack() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [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 easeOut(k: number): number { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const SKY: RGB = [150, 192, 244];
const SUN: RGB = [250, 218, 146];
const NEU: RGB = [236, 230, 250];
const BGC: RGB = [46, 42, 68];
const PANEL: RGB = [62, 57, 92];
const DIM: RGB = [196, 190, 224];
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) + ')';
}
function hsl(h: number, s: number, l: number): RGB {
const a = s * Math.min(l, 1 - l);
const f = function (n: number): number {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a: number, b: number): number {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s: string, a: string, b?: string, c?: string): string {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v: number): string { return String(Math.round(v * 10) / 10); }
function group(v: number): string {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { o += ','; }
o += s.charAt(i);
}
return o;
}
// ── 스도쿠 모델 — 판은 81칸 숫자 배열(0 은 빈칸). 후보는 9비트 한 수: 숫자 d 는 (d-1) 번째 비트
const ALL = 511;
function bit(d: number): number { return 1 << (d - 1); }
function popcount(m: number): number {
let c = 0;
let v = m;
while (v) { v &= v - 1; c += 1; }
return c;
}
// 가장 낮은 비트 하나가 가리키는 숫자
function lowDigit(m: number): number { return 32 - Math.clz32(m & -m); }
function bin9(m: number): string {
let s = '';
for (let d = 9; d >= 1; d -= 1) { s += (m & bit(d)) ? '1' : '0'; }
return s;
}
function rowOf(i: number): number { return Math.floor(i / 9); }
function colOf(i: number): number { return i % 9; }
function boxOf(i: number): number { return Math.floor(i / 27) * 3 + Math.floor((i % 9) / 3); }
// 묶음 27개 — 줄 9 · 세로줄 9 · 상자 9. 칸의 이웃은 같은 묶음 세 곳의 다른 칸 20개
const UNITS: number[][] = [];
const PEERS: number[][] = [];
for (let k = 0; k < 27; k += 1) { UNITS.push([]); }
for (let i = 0; i < 81; i += 1) {
UNITS[rowOf(i)].push(i);
UNITS[9 + colOf(i)].push(i);
UNITS[18 + boxOf(i)].push(i);
}
for (let i = 0; i < 81; i += 1) {
const ps: number[] = [];
for (let j = 0; j < 81; j += 1) {
if (j !== i && (rowOf(j) === rowOf(i) || colOf(j) === colOf(i) || boxOf(j) === boxOf(i))) { ps.push(j); }
}
PEERS.push(ps);
}
// 묶음 종류 kind: 0 줄 · 1 세로줄 · 2 상자
function unitIndex(kind: number, i: number): number { return kind === 0 ? rowOf(i) : (kind === 1 ? 9 + colOf(i) : 18 + boxOf(i)); }
function sharedKind(i: number, j: number): number {
if (rowOf(i) === rowOf(j)) { return 0; }
if (colOf(i) === colOf(j)) { return 1; }
return boxOf(i) === boxOf(j) ? 2 : -1;
}
// 칸 i 가 속한 kind 묶음에서 i 를 뺀 칸들이 이미 쓴 숫자의 비트 합(OR)
function usedMask(b: number[], i: number, kind: number): number {
const u = UNITS[unitIndex(kind, i)];
let m = 0;
for (let k = 0; k < 9; k += 1) { if (u[k] !== i && b[u[k]] !== 0) { m |= bit(b[u[k]]); } }
return m;
}
// 후보 = 9비트 전체에서 세 묶음이 쓴 비트를 뺀 것. 채워진 칸의 후보는 0
function candMask(b: number[], i: number): number {
if (b[i] !== 0) { return 0; }
return ALL & ~(usedMask(b, i, 0) | usedMask(b, i, 1) | usedMask(b, i, 2));
}
function filledOf(b: number[]): number {
let c = 0;
for (let i = 0; i < 81; i += 1) { if (b[i] !== 0) { c += 1; } }
return c;
}
function perm(n: number, seed: number): number[] {
const a: number[] = [];
for (let i = 0; i < n; i += 1) { a.push(i); }
for (let i = n - 1; i > 0; i -= 1) {
const j = Math.floor(hash2(i, seed) * (i + 1));
const t = a[i];
a[i] = a[j];
a[j] = t;
}
return a;
}
// 완성판 — 기본 무늬를 숫자·줄·상자 묶음 순서로 섞는다. 같은 씨앗이면 같은 판
function fullBoard(seed: number): number[] {
const dp = perm(9, seed * 7 + 1);
const bp = perm(3, seed * 7 + 2);
const sp = perm(3, seed * 7 + 3);
const rows: number[] = [];
const cols: number[] = [];
for (let k = 0; k < 3; k += 1) {
const rp = perm(3, seed * 13 + k * 5 + 4);
const cp = perm(3, seed * 13 + k * 5 + 9);
for (let j = 0; j < 3; j += 1) {
rows.push(bp[k] * 3 + rp[j]);
cols.push(sp[k] * 3 + cp[j]);
}
}
const b: number[] = [];
for (let r = 0; r < 9; r += 1) {
for (let c = 0; c < 9; c += 1) {
b.push(dp[(3 * (rows[r] % 3) + Math.floor(rows[r] / 3) + cols[c]) % 9] + 1);
}
}
return b;
}
// 풀이 탐색 — 가장 후보가 적은 칸(plain 이면 앞쪽 빈칸)부터 숫자를 걸고, 막히면 되돌린다.
// count 가 limit 에 닿으면 멈추고(풀이 개수는 limit 까지만 센다), 걸어 본 숫자가 cap 을 넘으면 멈춘다.
// trace 에는 (종류 << 16) | (칸 << 4) | 숫자 — 종류 0 은 걸기, 1 은 되돌리기
type Srch = { count: number; nodes: number; dead: number; branch: number; capHit: boolean; first: number[]; trace: number[] };
function search(start: number[], limit: number, cap: number, plain: boolean, wantTrace: boolean): Srch {
const b = start.slice();
const rm: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const cm: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const xm: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
rm[rowOf(i)] |= bit(b[i]);
cm[colOf(i)] |= bit(b[i]);
xm[boxOf(i)] |= bit(b[i]);
}
}
const r: Srch = { count: 0, nodes: 0, dead: 0, branch: 0, capHit: false, first: [], trace: [] };
function rec(): void {
let best = -1;
let bm = 0;
let bc = 10;
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) { continue; }
const m = ALL & ~(rm[rowOf(i)] | cm[colOf(i)] | xm[boxOf(i)]);
const pc = popcount(m);
if (plain) { best = i; bm = m; bc = pc; break; }
if (pc < bc) { best = i; bm = m; bc = pc; if (pc <= 1) { break; } }
}
if (best < 0) {
r.count += 1;
if (r.count === 1) { r.first = b.slice(); }
return;
}
if (bc === 0) { r.dead += 1; return; }
if (bc > 1) { r.branch += 1; }
let mm = bm;
while (mm) {
const lo = mm & -mm;
mm ^= lo;
const d = 32 - Math.clz32(lo);
r.nodes += 1;
if (r.nodes > cap) { r.capHit = true; return; }
b[best] = d;
rm[rowOf(best)] |= lo;
cm[colOf(best)] |= lo;
xm[boxOf(best)] |= lo;
if (wantTrace) { r.trace.push((best << 4) | d); }
rec();
if (r.count >= limit || r.capHit) { return; }
b[best] = 0;
rm[rowOf(best)] &= ~lo;
cm[colOf(best)] &= ~lo;
xm[boxOf(best)] &= ~lo;
if (wantTrace) { r.trace.push((1 << 16) | (best << 4) | d); }
}
}
rec();
return r;
}
// 하나뿐인 후보 채우기 — 후보가 하나인 칸(번호가 가장 앞선 칸)을 채우고 다시 센다. 더 없으면 멈춘다
type Singles = { order: number[]; digits: number[]; avail: number[]; solved: boolean; board: number[] };
function singlesFill(start: number[]): Singles {
const b = start.slice();
const order: number[] = [];
const digits: number[] = [];
const avail: number[] = [];
for (;;) {
let first = -1;
let n = 0;
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0 && popcount(candMask(b, i)) === 1) {
if (first < 0) { first = i; }
n += 1;
}
}
if (first < 0) { break; }
const d = lowDigit(candMask(b, first));
b[first] = d;
order.push(first);
digits.push(d);
avail.push(n);
}
let solved = true;
for (let i = 0; i < 81; i += 1) { if (b[i] === 0) { solved = false; } }
return { order: order, digits: digits, avail: avail, solved: solved, board: b };
}
// 구멍 파기 — 완성판에서 칸을 해시 순서로 하나씩 비우고 풀이 개수를 2 까지만 센다.
// 하나뿐이면 비운 채로 두고, 둘 이상이면 되돌린다. singlesOnly 이면 하나뿐인 후보만으로 풀리는 판만 남긴다
type DigStep = { cell: number; cnt: number; ok: boolean; nodes: number };
type Dug = { puz: number[]; steps: DigStep[]; clues: number };
function digBoard(sol: number[], seed: number, target: number, singlesOnly: boolean): Dug {
const puz = sol.slice();
const order = perm(81, seed * 31 + 5);
const steps: DigStep[] = [];
let clues = 81;
for (let k = 0; k < 81; k += 1) {
if (clues <= target) { break; }
const c = order[k];
const d = puz[c];
puz[c] = 0;
const s = search(puz, 2, 6000, false, false);
const cnt = s.capHit ? 2 : Math.min(2, s.count);
const ok = cnt === 1 && (!singlesOnly || singlesFill(puz).solved);
steps.push({ cell: c, cnt: cnt, ok: ok, nodes: s.nodes });
if (ok) { clues -= 1; } else { puz[c] = d; }
}
return { puz: puz, steps: steps, clues: clues };
}
// 놓은 숫자가 겹치는 쌍 — [칸, 칸, 묶음 종류]
function conflicts(b: number[]): number[][] {
const out: number[][] = [];
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0) { continue; }
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (p > i && b[p] === b[i]) { out.push([i, p, sharedKind(i, p)]); }
}
}
return out;
}
// ── 판 상태 — 장면이 아니라 판이 들고 있는 것. 화면은 이 값을 읽어 그린다
const BX = 22;
const BY = 44;
const CS = 23;
const GAP = 3;
const PX = 258;
const PW = 210;
let g: number[] = [];
let giv: boolean[] = [];
let sol: number[] = [];
let pt: number[] = [];
let cur = 40;
let solvedT = -1;
let lastCell = -1;
type Rip = { i: number; t: number; list: number[]; d: number };
let rip: Rip = { i: -1, t: -9, list: [], d: 0 };
let selDigit = 0;
let still = false;
let hintHook = null as (() => void) | null;
function cellX(i: number): number { const c = colOf(i); return BX + c * CS + Math.floor(c / 3) * GAP; }
function cellY(i: number): number { const r = rowOf(i); return BY + r * CS + Math.floor(r / 3) * GAP; }
function cellCx(i: number): number { return cellX(i) + CS / 2; }
function cellCy(i: number): number { return cellY(i) + CS / 2; }
function cellAt(x: number, y: number): number {
let c = -1;
let r = -1;
for (let k = 0; k < 9; k += 1) {
const x0 = BX + k * CS + Math.floor(k / 3) * GAP;
const y0 = BY + k * CS + Math.floor(k / 3) * GAP;
if (x >= x0 && x < x0 + CS) { c = k; }
if (y >= y0 && y < y0 + CS) { r = k; }
}
return c < 0 || r < 0 ? -1 : r * 9 + c;
}
function setBoard(puz: number[], solution: number[]): void {
g = puz.slice();
sol = solution.slice();
giv = [];
pt = [];
for (let i = 0; i < 81; i += 1) { giv.push(puz[i] !== 0); pt.push(-9); }
solvedT = -1;
lastCell = -1;
rip = { i: -1, t: -9, list: [], d: 0 };
}
function solvedNow(): boolean { return filledOf(g) === 81 && conflicts(g).length === 0; }
function checkSolved(): void {
if (solvedNow()) { if (solvedT < 0) { solvedT = sceneT; } } else { solvedT = -1; }
}
// 숫자를 놓는다 — 놓기 전에 그 숫자가 후보에 있던 이웃 칸을 기억해 두면 소거가 번지는 모습을 그릴 수 있다
function putDigit(i: number, d: number): boolean {
if (giv[i] || d < 1 || d > 9) { return false; }
const list: number[] = [];
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (g[p] === 0 && (candMask(g, p) & bit(d)) !== 0) { list.push(p); }
}
g[i] = d;
pt[i] = sceneT;
rip = { i: i, t: sceneT, list: list, d: d };
lastCell = i;
checkSolved();
return true;
}
function clearDigit(i: number): void {
if (giv[i] || g[i] === 0) { return; }
g[i] = 0;
pt[i] = -9;
checkSolved();
}
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)]; }
// ── 그리기 도구
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 doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 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 disc(x: number, y: number, r: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, c: RGB, a: number, w: number): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x: number, y: number, w: number, h: number): void { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name: string, val: string, frac: number): void {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// ── 판 그리기 — 칸 색은 장면이 정하고, 숫자는 판이 그린다
const EMPTY: RGB = [58, 53, 88];
const PLACED: RGB = [190, 214, 246];
const PLACED_INK: RGB = [28, 54, 118];
function ring(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number, lw: number): void {
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = lw;
ctx.stroke();
}
function drawBoard(fillOf: (i: number) => RGB | null, extra: ((i: number, x: number, y: number) => void) | null): void {
const s = 9 * CS + 2 * GAP;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < 81; i += 1) {
const x = cellX(i);
const y = cellY(i);
let f: RGB | null = fillOf(i);
if (f === null) { f = g[i] === 0 ? EMPTY : (giv[i] ? NEU : PLACED); }
box(x + 1, y + 1, CS - 2, CS - 2, 4, f, 1);
if (g[i] !== 0) {
const a = still ? 99 : sceneT - pt[i];
const k = a < 0.3 ? 0.7 + 0.3 * easeOut(a / 0.3) : 1;
text(String(g[i]), x + CS / 2, y + CS / 2 + 1, giv[i] ? INK : PLACED_INK, Math.round(14 * k), giv[i] ? 800 : 700, 'center');
} else if (extra !== null) {
extra(i, x, y);
}
}
if (!demo) { ring(cellX(cur) + 0.5, cellY(cur) + 0.5, CS - 1, CS - 1, 4, PAPER, 1, 2); }
}
// 묶음 하나를 옅게 물들인다
function tintUnit(u: number, c: RGB, a: number): void {
for (let k = 0; k < 9; k += 1) {
const i = UNITS[u][k];
box(cellX(i) + 1, cellY(i) + 1, CS - 2, CS - 2, 4, c, a);
// 물들임이 숫자를 흐리지 않도록 숫자를 다시 얹는다
if (g[i] !== 0) { text(String(g[i]), cellCx(i), cellCy(i) + 1, giv[i] ? INK : PLACED_INK, 14, giv[i] ? 800 : 700, 'center'); }
}
}
// 후보 비트 줄 — 숫자 1~9 칸 아홉 개, 켜진 비트만 밝다
function bitRow(x: number, y: number, m: number, on: RGB, off: RGB): void {
for (let d = 1; d <= 9; d += 1) {
const lit = (m & bit(d)) !== 0;
const bx = x + (d - 1) * 18;
box(bx, y, 16, 16, 4, lit ? on : off, 1);
text(String(d), bx + 8, y + 8.5, lit ? INK : DIM, 10, 700, 'center');
}
}
// ── 상태 줄·낭독 문장
const SPK_FILL = '{a}칸 중 {b}칸이 채워져 있다';
const SPK_DONE = '판 완성, {a}칸이 모두 채워졌다';
function speakBoard(): string {
return solvedNow() ? fill(SPK_DONE, '81') : fill(SPK_FILL, '81', String(filledOf(g)));
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 100 + 채운 칸 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode: number): number {
if (solvedNow()) { return 1000; }
return manual ? 100 + filledOf(g) : sceneMode;
}
// 방금 놓은 칸의 줄·세로줄·상자 세 묶음을 한 번 물들였다 서서히 빼는 번짐 — 숫자 하나가 어디까지 영향을 주는지 보인다
function unitPulse(c: RGB, a: number, dur: number): void {
if (rip.i < 0 || still) { return; }
const k = (sceneT - rip.t) / dur;
if (k < 0 || k >= 1) { return; }
for (let kind = 0; kind < 3; kind += 1) { tintUnit(unitIndex(kind, rip.i), c, a * (1 - k)); }
}
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 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
}
function takeOver(): void {
if (manual) { return; }
manual = true;
wake();
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say(): void {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
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);
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow(): void {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) { say(); }
}
}
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;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest(): void {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak: boolean): void {
if (paused || still) { sceneT += 1; }
update(0);
showNow();
if (speak) { say(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
say();
}
function restart(): void {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// 새 판 — 씨앗이 달라진 판으로 시간표를 처음부터. 손으로 하던 중이어도 자동 시연으로 돌아간다
function newBoard(): void {
manual = false;
lap += 1;
begin();
update(0);
showNow();
say();
}
function moveCur(dx: number, dy: number): void {
takeOver();
cur = clamp(rowOf(cur) + dy, 0, 8) * 9 + clamp(colOf(cur) + dx, 0, 8);
afterInput(true);
}
// 힌트 기본 — 고른 빈칸에 정답 숫자를 놓는다. 장면이 hintHook 을 걸어 두면 그쪽이 먼저
function hintDefault(): void {
if (!giv[cur] && g[cur] === 0) { putDigit(cur, sol[cur]); }
}
function toCanvas(e: PointerEvent): number[] {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 칸 이동, 1~9 = 숫자 입력, Delete·Backspace·0 = 지우기, H = 힌트,
// N = 새 판, R = 처음부터, Enter·Space = 일시정지
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'r' || k === 'R') {
restart();
} else if (k === 'n' || k === 'N') {
e.preventDefault();
newBoard();
} else if (k === 'ArrowLeft') {
e.preventDefault();
moveCur(-1, 0);
} else if (k === 'ArrowRight') {
e.preventDefault();
moveCur(1, 0);
} else if (k === 'ArrowUp') {
e.preventDefault();
moveCur(0, -1);
} else if (k === 'ArrowDown') {
e.preventDefault();
moveCur(0, 1);
} else if (k.length === 1 && k >= '1' && k <= '9') {
e.preventDefault();
takeOver();
selDigit = Number(k);
putDigit(cur, selDigit);
afterInput(true);
} else if (k === 'Delete' || k === 'Backspace' || k === '0') {
e.preventDefault();
takeOver();
clearDigit(cur);
afterInput(true);
} else if (k === 'h' || k === 'H') {
e.preventDefault();
takeOver();
if (hintHook !== null) { hintHook(); } else { hintDefault(); }
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
ptrDown = true;
try { pad.setPointerCapture(e.pointerId); } catch (err) { ptrDown = true; }
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) { cur = c; }
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (!ptrDown) { return; }
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) { cur = c; }
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e: PointerEvent): void {
if (!ptrDown) { return; }
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { 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 — 하나뿐인 후보로 풀리지 않으면 후보가 가장 적은 칸에 숫자를 걸고 이어 가다 막히면 되돌린다. 걸린 숫자를 세어 NODE_CAP 에서 끊고, 앞쪽 빈칸부터 푸는 순서의 노드 수를 옆에 둔다
const NODE_CAP = 400;
const PLAIN_CAP = 20000;
const CLUES_03 = 24;
const SEARCH_S = 4.2;
const SEEDS_03 = [1, 5, 23, 17];
let LAP_S = 7;
let REST_S = 5.1;
let fs: Srch = { count: 0, nodes: 0, dead: 0, branch: 0, capHit: false, first: [], trace: [] };
let plainNodes = 0;
let plainCapHit = false;
let tpos = 0;
let placedLive = 0;
let undoLive = 0;
let undoT: number[] = [];
let startPuz: number[] = [];
function build(): void {
tpos = 0;
placedLive = 0;
undoLive = 0;
undoT = [];
for (let i = 0; i < 81; i += 1) { undoT.push(-9); }
const seed = SEEDS_03[lap % SEEDS_03.length];
const s = fullBoard(seed);
const d = digBoard(s, seed + 40, CLUES_03, false);
setBoard(d.puz, s);
startPuz = d.puz.slice();
fs = search(startPuz, 1, NODE_CAP, false, true);
const pl = search(startPuz, 1, PLAIN_CAP, true, false);
plainNodes = pl.nodes;
plainCapHit = pl.capHit;
}
// 시간표 대신 진행도로 되짚기를 재생한다 — 보인 걸음 수는 시계에서 곧바로 나온다
function update(dt: number): void {
if (dt < 0) { return; }
const want = Math.floor(clamp((sceneT - 0.5) / SEARCH_S, 0, 1) * fs.trace.length);
while (tpos < want) {
const ev = fs.trace[tpos];
tpos += 1;
const cell = (ev >> 4) & 4095;
const dg = ev & 15;
if ((ev >> 16) === 0) {
g[cell] = dg;
pt[cell] = sceneT;
placedLive += 1;
} else {
g[cell] = 0;
pt[cell] = -9;
undoT[cell] = sceneT;
undoLive += 1;
}
}
if (tpos >= fs.trace.length && fs.trace.length > 0) { checkSolved(); }
}
function logBar(n: number): number { return Math.log(n + 1) / Math.log(PLAIN_CAP + 1); }
function draw(): void {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoard(function (i: number): RGB | null {
if (g[i] !== 0 && !giv[i]) { return SKY; }
const u = still ? 99 : sceneT - undoT[i];
if (g[i] === 0 && u >= 0 && u < 0.35) { return mix(ROSE, EMPTY, u / 0.35); }
return null;
}, null);
panel(PX, BY - 5, PW, 223);
text('NODE_CAP ' + NODE_CAP, PX + 12, BY + 8, DIM, 11, 700, 'left');
box(PX + 12, BY + 20, PW - 24, 10, 5, [48, 44, 74], 1);
box(PX + 12, BY + 20, (PW - 24) * clamp(placedLive / NODE_CAP, 0, 1), 10, 5, placedLive >= NODE_CAP ? ROSE : MINT, 1);
text(fill('후보 적은 칸 먼저 {a}', String(placedLive)), PX + 12, BY + 46, NEU, 12, 700, 'left');
text(fill('되돌린 횟수 {a}', String(undoLive)), PX + 12, BY + 66, NEU, 12, 700, 'left');
text('빈칸 순서대로', PX + 12, BY + 96, DIM, 11, 700, 'left');
const full = PW - 24;
box(PX + 12, BY + 108, full, 10, 5, [48, 44, 74], 1);
box(PX + 12, BY + 108, full * logBar(plainNodes), 10, 5, SUN, 1);
text(group(plainNodes) + (plainCapHit ? '+' : ''), PX + 12, BY + 134, NEU, 12, 700, 'left');
const first = Math.max(1, fs.nodes);
box(PX + 12, BY + 148, full, 10, 5, [48, 44, 74], 1);
box(PX + 12, BY + 148, full * logBar(Math.min(fs.nodes, NODE_CAP)), 10, 5, MINT, 1);
text(fill('후보 적은 칸 먼저 {a}', String(first)), PX + 12, BY + 174, NEU, 12, 700, 'left');
const doneAll = tpos >= fs.trace.length && fs.trace.length > 0;
if (doneAll) {
if (fs.capHit) { text('NODE_CAP 도달, 멈춤', PX + 12, BY + 200, ROSE, 12, 800, 'left'); }
else if (fs.count > 0) { text('풀이 찾음', PX + 12, BY + 200, MINT, 12, 800, 'left'); }
}
hud('NODE_CAP', String(NODE_CAP), placedLive / NODE_CAP);
}
function statusText(): string {
return fill('걸어 본 숫자 {a}개, 되돌림 {b}번', String(placedLive), String(undoLive));
}
function modeId(): number {
const doneAll = tpos >= fs.trace.length && fs.trace.length > 0;
return modeBase(doneAll ? 3 : (tpos > 0 ? 1 : 0));
}
function speakText(): string {
const doneAll = tpos >= fs.trace.length && fs.trace.length > 0;
const m = doneAll ? (fs.capHit ? 'NODE_CAP 에서 멈췄다' : '되짚기로 풀이를 찾았다') + '. ' : '';
return m + fill('걸어 본 숫자 {a}개, 되돌림 {b}번', String(placedLive), String(undoLive)) + '. ' + speakBoard();
}
function onPtr(kind: number, x: number, y: number): void { if (kind < 0 || x < 0 || y < 0) { 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={'스도쿠 시연 무대 — 방향키로 칸 이동, 1부터 9 숫자키로 입력, Delete 로 지우기, H 로 힌트, N 으로 새 판, R 로 처음부터, Enter 또는 Space 로 일시정지'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'단서 24개의 어려운 9×9 판에서 후보가 가장 적은 칸부터 숫자를 걸고 막히면 되돌린다. 걸린 숫자는 하늘색으로 나타나고 되돌려 지워진 칸은 붉게 잠깐 빛난다. 오른쪽 패널에는 NODE_CAP 400 눈금에 걸어 본 숫자 수가 차오르고, 되돌린 횟수와 후보 적은 칸 먼저 푼 노드 수, 빈칸 순서대로 푼 노드 수가 로그 막대로 나란히 나온다. 한도에 닿으면 멈춤이 표시된다. 아래 눈금은 상수 이름과 지금 값을 보이고, 움직임 줄이기에서는 숫자가 바로 채워진 판 한 장만 그려진다'}></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>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}03 · 候補がいちばん少ないマスから戻る探索 — 計測 累積面積 12.417% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
let mm = bm;
while (mm) {
const lo = mm & -mm;
mm ^= lo;
const d = 32 - Math.clz32(lo);
r.nodes += 1;
if (r.nodes > cap) {
r.capHit = true;
return;
}
b[best] = d;
rm[rowOf(best)] |= lo;
cm[colOf(best)] |= lo;
xm[boxOf(best)] |= lo;
if (wantTrace) {
r.trace.push((best << 4) | d);
}
rec();
ヒント24〜25の四つの盤は、候補の少ないマスから解くとノード150・167・173・244個で解け、最初の空き順で解くと四つとも上限の20,000に届いて20,001ノードで止まります。
04穴を開けるたびに解の数を数える盤づくり
完成した盤からマスをひとつずつ空にして解の数を2までだけ数え、ひとつなら空きのまま、二つ以上なら数字を戻すので、解がひとつだけの新しい盤を出し続けるときに使います。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="数独の舞台 — 矢印キーでマス移動、数字キー1〜9で入力、Deleteで消去、Hでヒント、Nで新しい盤、Rで最初から、EnterかSpaceで一時停止">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="完成した9×9の盤からマスをハッシュの順にひとつずつ空にする。空けたあと解の数を2までだけ数え、ひとつなら空けたまま、二つ以上なら数字を戻してそのマスに赤い枠が残る。右のパネルにはヒントの数が81からCLUES 36まで減る大きな数字と折れ線、直近の結果の点(ミントは空けた、バラ色は戻した)、直前の探索ノード数と戻したマス数が出て、最後に解がひとつだけと表示される。下の目盛りは定数名と今の値を示し、動きを減らす設定では数字がすぐ埋まった盤一枚だけを描きます"></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>
</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-ink;
}
// 무대 상자 — 눌러서 한 번 깨뜨리는 곳(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-cream;
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 {
padding: $sp-1 $sp-3;
border: 2px solid $subject-cream;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
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__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-ink;
color: $subject-cream;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면에만 있고 aria-live 는 늘 off
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 100px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
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__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 글리치 없는 원문 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause: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: #17141a;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #fff7e6;
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 {
padding: 4px 12px;
border: 2px solid #fff7e6;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
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__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 100px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
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__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause: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 easeOut(k) { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const SKY = [150, 192, 244];
const SUN = [250, 218, 146];
const NEU = [236, 230, 250];
const BGC = [46, 42, 68];
const PANEL = [62, 57, 92];
const DIM = [196, 190, 224];
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) + ')';
}
function hsl(h, s, l) {
const a = s * Math.min(l, 1 - l);
const f = function (n) {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a, b) {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s, a, b, c) {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v) { return String(Math.round(v * 10) / 10); }
function group(v) {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
o += ',';
}
o += s.charAt(i);
}
return o;
}
// ── 스도쿠 모델 — 판은 81칸 숫자 배열(0 은 빈칸). 후보는 9비트 한 수: 숫자 d 는 (d-1) 번째 비트
const ALL = 511;
function bit(d) { return 1 << (d - 1); }
function popcount(m) {
let c = 0;
let v = m;
while (v) {
v &= v - 1;
c += 1;
}
return c;
}
// 가장 낮은 비트 하나가 가리키는 숫자
function lowDigit(m) { return 32 - Math.clz32(m & -m); }
function bin9(m) {
let s = '';
for (let d = 9; d >= 1; d -= 1) {
s += (m & bit(d)) ? '1' : '0';
}
return s;
}
function rowOf(i) { return Math.floor(i / 9); }
function colOf(i) { return i % 9; }
function boxOf(i) { return Math.floor(i / 27) * 3 + Math.floor((i % 9) / 3); }
// 묶음 27개 — 줄 9 · 세로줄 9 · 箱 9. 칸의 이웃은 같은 묶음 세 곳의 다른 칸 20개
const UNITS = [];
const PEERS = [];
for (let k = 0; k < 27; k += 1) {
UNITS.push([]);
}
for (let i = 0; i < 81; i += 1) {
UNITS[rowOf(i)].push(i);
UNITS[9 + colOf(i)].push(i);
UNITS[18 + boxOf(i)].push(i);
}
for (let i = 0; i < 81; i += 1) {
const ps = [];
for (let j = 0; j < 81; j += 1) {
if (j !== i && (rowOf(j) === rowOf(i) || colOf(j) === colOf(i) || boxOf(j) === boxOf(i))) {
ps.push(j);
}
}
PEERS.push(ps);
}
// 묶음 종류 kind: 0 줄 · 1 세로줄 · 2 箱
function unitIndex(kind, i) { return kind === 0 ? rowOf(i) : (kind === 1 ? 9 + colOf(i) : 18 + boxOf(i)); }
function sharedKind(i, j) {
if (rowOf(i) === rowOf(j)) {
return 0;
}
if (colOf(i) === colOf(j)) {
return 1;
}
return boxOf(i) === boxOf(j) ? 2 : -1;
}
// 칸 i 가 속한 kind 묶음에서 i 를 뺀 칸들이 이미 쓴 숫자의 비트 合計(OR)
function usedMask(b, i, kind) {
const u = UNITS[unitIndex(kind, i)];
let m = 0;
for (let k = 0; k < 9; k += 1) {
if (u[k] !== i && b[u[k]] !== 0) {
m |= bit(b[u[k]]);
}
}
return m;
}
// 候補 = 9비트 전체에서 세 묶음이 쓴 비트를 뺀 것. 채워진 칸의 후보는 0
function candMask(b, i) {
if (b[i] !== 0) {
return 0;
}
return ALL & ~(usedMask(b, i, 0) | usedMask(b, i, 1) | usedMask(b, i, 2));
}
function filledOf(b) {
let c = 0;
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
c += 1;
}
}
return c;
}
function perm(n, seed) {
const a = [];
for (let i = 0; i < n; i += 1) {
a.push(i);
}
for (let i = n - 1; i > 0; i -= 1) {
const j = Math.floor(hash2(i, seed) * (i + 1));
const t = a[i];
a[i] = a[j];
a[j] = t;
}
return a;
}
// 완성판 — 기본 무늬를 숫자·줄·箱 묶음 순서로 섞는다. 같은 씨앗이면 같은 판
function fullBoard(seed) {
const dp = perm(9, seed * 7 + 1);
const bp = perm(3, seed * 7 + 2);
const sp = perm(3, seed * 7 + 3);
const rows = [];
const cols = [];
for (let k = 0; k < 3; k += 1) {
const rp = perm(3, seed * 13 + k * 5 + 4);
const cp = perm(3, seed * 13 + k * 5 + 9);
for (let j = 0; j < 3; j += 1) {
rows.push(bp[k] * 3 + rp[j]);
cols.push(sp[k] * 3 + cp[j]);
}
}
const b = [];
for (let r = 0; r < 9; r += 1) {
for (let c = 0; c < 9; c += 1) {
b.push(dp[(3 * (rows[r] % 3) + Math.floor(rows[r] / 3) + cols[c]) % 9] + 1);
}
}
return b;
}
function search(start, limit, cap, plain, wantTrace) {
const b = start.slice();
const rm = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const cm = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const xm = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
rm[rowOf(i)] |= bit(b[i]);
cm[colOf(i)] |= bit(b[i]);
xm[boxOf(i)] |= bit(b[i]);
}
}
const r = { count: 0, nodes: 0, dead: 0, branch: 0, capHit: false, first: [], trace: [] };
function rec() {
let best = -1;
let bm = 0;
let bc = 10;
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
continue;
}
const m = ALL & ~(rm[rowOf(i)] | cm[colOf(i)] | xm[boxOf(i)]);
const pc = popcount(m);
if (plain) {
best = i;
bm = m;
bc = pc;
break;
}
if (pc < bc) {
best = i;
bm = m;
bc = pc;
if (pc <= 1) {
break;
}
}
}
if (best < 0) {
r.count += 1;
if (r.count === 1) {
r.first = b.slice();
}
return;
}
if (bc === 0) {
r.dead += 1;
return;
}
if (bc > 1) {
r.branch += 1;
}
let mm = bm;
while (mm) {
const lo = mm & -mm;
mm ^= lo;
const d = 32 - Math.clz32(lo);
r.nodes += 1;
if (r.nodes > cap) {
r.capHit = true;
return;
}
b[best] = d;
rm[rowOf(best)] |= lo;
cm[colOf(best)] |= lo;
xm[boxOf(best)] |= lo;
if (wantTrace) {
r.trace.push((best << 4) | d);
}
rec();
if (r.count >= limit || r.capHit) {
return;
}
b[best] = 0;
rm[rowOf(best)] &= ~lo;
cm[colOf(best)] &= ~lo;
xm[boxOf(best)] &= ~lo;
if (wantTrace) {
r.trace.push((1 << 16) | (best << 4) | d);
}
}
}
rec();
return r;
}
function singlesFill(start) {
const b = start.slice();
const order = [];
const digits = [];
const avail = [];
for (;;) {
let first = -1;
let n = 0;
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0 && popcount(candMask(b, i)) === 1) {
if (first < 0) {
first = i;
}
n += 1;
}
}
if (first < 0) {
break;
}
const d = lowDigit(candMask(b, first));
b[first] = d;
order.push(first);
digits.push(d);
avail.push(n);
}
let solved = true;
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0) {
solved = false;
}
}
return { order: order, digits: digits, avail: avail, solved: solved, board: b };
}
function digBoard(sol, seed, target, singlesOnly) {
const puz = sol.slice();
const order = perm(81, seed * 31 + 5);
const steps = [];
let clues = 81;
for (let k = 0; k < 81; k += 1) {
if (clues <= target) {
break;
}
const c = order[k];
const d = puz[c];
puz[c] = 0;
const s = search(puz, 2, 6000, false, false);
const cnt = s.capHit ? 2 : Math.min(2, s.count);
const ok = cnt === 1 && (!singlesOnly || singlesFill(puz).solved);
steps.push({ cell: c, cnt: cnt, ok: ok, nodes: s.nodes });
if (ok) {
clues -= 1;
}
else {
puz[c] = d;
}
}
return { puz: puz, steps: steps, clues: clues };
}
// 놓은 숫자가 겹치는 쌍 — [칸, 칸, 묶음 종류]
function conflicts(b) {
const out = [];
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0) {
continue;
}
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (p > i && b[p] === b[i]) {
out.push([i, p, sharedKind(i, p)]);
}
}
}
return out;
}
// ── 판 상태 — 장면이 아니라 판이 들고 있는 것. 화면은 이 값을 읽어 그린다
const BX = 22;
const BY = 44;
const CS = 23;
const GAP = 3;
const PX = 258;
const PW = 210;
let g = [];
let giv = [];
let sol = [];
let pt = [];
let cur = 40;
let solvedT = -1;
let lastCell = -1;
let rip = { i: -1, t: -9, list: [], d: 0 };
let selDigit = 0;
let still = false;
let hintHook = null;
function cellX(i) { const c = colOf(i); return BX + c * CS + Math.floor(c / 3) * GAP; }
function cellY(i) { const r = rowOf(i); return BY + r * CS + Math.floor(r / 3) * GAP; }
function cellCx(i) { return cellX(i) + CS / 2; }
function cellCy(i) { return cellY(i) + CS / 2; }
function cellAt(x, y) {
let c = -1;
let r = -1;
for (let k = 0; k < 9; k += 1) {
const x0 = BX + k * CS + Math.floor(k / 3) * GAP;
const y0 = BY + k * CS + Math.floor(k / 3) * GAP;
if (x >= x0 && x < x0 + CS) {
c = k;
}
if (y >= y0 && y < y0 + CS) {
r = k;
}
}
return c < 0 || r < 0 ? -1 : r * 9 + c;
}
function setBoard(puz, solution) {
g = puz.slice();
sol = solution.slice();
giv = [];
pt = [];
for (let i = 0; i < 81; i += 1) {
giv.push(puz[i] !== 0);
pt.push(-9);
}
solvedT = -1;
lastCell = -1;
rip = { i: -1, t: -9, list: [], d: 0 };
}
function solvedNow() { return filledOf(g) === 81 && conflicts(g).length === 0; }
function checkSolved() {
if (solvedNow()) {
if (solvedT < 0) {
solvedT = sceneT;
}
}
else {
solvedT = -1;
}
}
// 숫자를 놓는다 — 놓기 전에 그 숫자가 후보에 있던 이웃 칸을 기억해 두면 소거가 번지는 모습을 그릴 수 있다
function putDigit(i, d) {
if (giv[i] || d < 1 || d > 9) {
return false;
}
const list = [];
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (g[p] === 0 && (candMask(g, p) & bit(d)) !== 0) {
list.push(p);
}
}
g[i] = d;
pt[i] = sceneT;
rip = { i: i, t: sceneT, list: list, d: d };
lastCell = i;
checkSolved();
return true;
}
function clearDigit(i) {
if (giv[i] || g[i] === 0) {
return;
}
g[i] = 0;
pt[i] = -9;
checkSolved();
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// ── 그리기 도구
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 doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 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 disc(x, y, r, c, a) {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, c, a, w) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s, x, y, c, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x, y, w, h) { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name, val, frac) {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// ── 판 그리기 — 칸 색은 장면이 정하고, 숫자는 판이 그린다
const EMPTY = [58, 53, 88];
const PLACED = [190, 214, 246];
const PLACED_INK = [28, 54, 118];
function ring(x, y, w, h, r, c, a, lw) {
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = lw;
ctx.stroke();
}
function drawBoard(fillOf, extra) {
const s = 9 * CS + 2 * GAP;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < 81; i += 1) {
const x = cellX(i);
const y = cellY(i);
let f = fillOf(i);
if (f === null) {
f = g[i] === 0 ? EMPTY : (giv[i] ? NEU : PLACED);
}
box(x + 1, y + 1, CS - 2, CS - 2, 4, f, 1);
if (g[i] !== 0) {
const a = still ? 99 : sceneT - pt[i];
const k = a < 0.3 ? 0.7 + 0.3 * easeOut(a / 0.3) : 1;
text(String(g[i]), x + CS / 2, y + CS / 2 + 1, giv[i] ? INK : PLACED_INK, Math.round(14 * k), giv[i] ? 800 : 700, 'center');
}
else if (extra !== null) {
extra(i, x, y);
}
}
if (!demo) {
ring(cellX(cur) + 0.5, cellY(cur) + 0.5, CS - 1, CS - 1, 4, PAPER, 1, 2);
}
}
// 묶음 하나를 옅게 물들인다
function tintUnit(u, c, a) {
for (let k = 0; k < 9; k += 1) {
const i = UNITS[u][k];
box(cellX(i) + 1, cellY(i) + 1, CS - 2, CS - 2, 4, c, a);
// 물들임이 숫자를 흐리지 않도록 숫자를 다시 얹는다
if (g[i] !== 0) {
text(String(g[i]), cellCx(i), cellCy(i) + 1, giv[i] ? INK : PLACED_INK, 14, giv[i] ? 800 : 700, 'center');
}
}
}
// 候補 비트 줄 — 숫자 1~9 칸 아홉 개, 켜진 비트만 밝다
function bitRow(x, y, m, on, off) {
for (let d = 1; d <= 9; d += 1) {
const lit = (m & bit(d)) !== 0;
const bx = x + (d - 1) * 18;
box(bx, y, 16, 16, 4, lit ? on : off, 1);
text(String(d), bx + 8, y + 8.5, lit ? INK : DIM, 10, 700, 'center');
}
}
// ── 상태 줄·낭독 문장
const SPK_FILL = '{a} マスのうち {b} マスが埋まっている';
const SPK_DONE = '盤が完成、{a} マスすべてが埋まった';
function speakBoard() {
return solvedNow() ? fill(SPK_DONE, '81') : fill(SPK_FILL, '81', String(filledOf(g)));
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 100 + 채운 칸 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode) {
if (solvedNow()) {
return 1000;
}
return manual ? 100 + filledOf(g) : sceneMode;
}
// 방금 놓은 칸의 줄·세로줄·箱 세 묶음을 한 번 물들였다 서서히 빼는 번짐 — 숫자 하나가 어디까지 영향을 주는지 보인다
function unitPulse(c, a, dur) {
if (rip.i < 0 || still) {
return;
}
const k = (sceneT - rip.t) / dur;
if (k < 0 || k >= 1) {
return;
}
for (let kind = 0; kind < 3; kind += 1) {
tintUnit(unitIndex(kind, rip.i), c, a * (1 - k));
}
}
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 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
}
function takeOver() {
if (manual) {
return;
}
manual = true;
wake();
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say() {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
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);
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow() {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) {
say();
}
}
}
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;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest() {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak) {
if (paused || still) {
sceneT += 1;
}
update(0);
showNow();
if (speak) {
say();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
say();
}
function restart() {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// 새 판 — 씨앗이 달라진 판으로 시간표를 처음부터. 손으로 하던 중이어도 자동 시연으로 돌아간다
function newBoard() {
manual = false;
lap += 1;
begin();
update(0);
showNow();
say();
}
function moveCur(dx, dy) {
takeOver();
cur = clamp(rowOf(cur) + dy, 0, 8) * 9 + clamp(colOf(cur) + dx, 0, 8);
afterInput(true);
}
// 힌트 기본 — 고른 빈칸에 정답 숫자를 놓는다. 장면이 hintHook 을 걸어 두면 그쪽이 먼저
function hintDefault() {
if (!giv[cur] && g[cur] === 0) {
putDigit(cur, sol[cur]);
}
}
function toCanvas(e) {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 칸 이동, 1~9 = 숫자 입력, Delete·Backspace·0 = 지우기, H = 힌트,
// N = 새 판, R = 처음부터, Enter·Space = 一時停止
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'r' || k === 'R') {
restart();
}
else if (k === 'n' || k === 'N') {
e.preventDefault();
newBoard();
}
else if (k === 'ArrowLeft') {
e.preventDefault();
moveCur(-1, 0);
}
else if (k === 'ArrowRight') {
e.preventDefault();
moveCur(1, 0);
}
else if (k === 'ArrowUp') {
e.preventDefault();
moveCur(0, -1);
}
else if (k === 'ArrowDown') {
e.preventDefault();
moveCur(0, 1);
}
else if (k.length === 1 && k >= '1' && k <= '9') {
e.preventDefault();
takeOver();
selDigit = Number(k);
putDigit(cur, selDigit);
afterInput(true);
}
else if (k === 'Delete' || k === 'Backspace' || k === '0') {
e.preventDefault();
takeOver();
clearDigit(cur);
afterInput(true);
}
else if (k === 'h' || k === 'H') {
e.preventDefault();
takeOver();
if (hintHook !== null) {
hintHook();
}
else {
hintDefault();
}
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
pad.addEventListener('pointerdown', function (e) {
takeOver();
ptrDown = true;
try {
pad.setPointerCapture(e.pointerId);
}
catch (err) {
ptrDown = true;
}
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) {
cur = c;
}
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (!ptrDown) {
return;
}
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) {
cur = c;
}
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e) {
if (!ptrDown) {
return;
}
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { 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 — 완성판에서 칸을 하나씩 비우고 풀이 개수를 2 까지만 센다. 하나면 비운 채로 두고 둘이면 되돌린다. 비운 순서와 되돌린 칸을 표시해 ヒント 수가 줄어드는 모습을 보인다
const CLUES = 36;
const SEEDS_04 = [4, 9, 15, 22];
let LAP_S = 8;
let REST_S = 6;
let dug = { puz: [], steps: [], clues: 81 };
let gapS = 0.1;
let stepShown = 0;
let clueNow = 81;
let restored = 0;
let lastCnt = 1;
let lastNodes = 0;
let restT = [];
let dugAt = [];
let clueHist = [];
let uniqueFinal = 0;
// 풀이 개수를 세는 동안 그 칸의 줄·세로줄·상자를 훑는 표시
let scanCell = -1;
let scanT = -9;
let scanOk = true;
const SCAN_S = 0.6;
function stepAt(k) {
return function () {
const st = dug.steps[k];
stepShown = k + 1;
lastCnt = st.cnt;
lastNodes = st.nodes;
scanCell = st.cell;
scanT = sceneT;
scanOk = st.ok;
if (st.ok) {
g[st.cell] = 0;
giv[st.cell] = false;
pt[st.cell] = -9;
clueNow -= 1;
dugAt[st.cell] = sceneT;
}
else {
restored += 1;
restT[st.cell] = sceneT;
}
clueHist.push(clueNow);
if (k === dug.steps.length - 1) {
uniqueFinal = search(g, 2, 6000, false, false).count;
}
};
}
function build() {
stepShown = 0;
clueNow = 81;
restored = 0;
lastCnt = 1;
lastNodes = 0;
uniqueFinal = 0;
scanCell = -1;
scanT = -9;
clueHist = [];
restT = [];
dugAt = [];
for (let i = 0; i < 81; i += 1) {
restT.push(-9);
dugAt.push(-9);
}
const seed = SEEDS_04[lap % SEEDS_04.length];
const s = fullBoard(seed);
dug = digBoard(s, seed + 60, CLUES, false);
setBoard(s, s);
gapS = clamp(5.2 / Math.max(1, dug.steps.length), 0.04, 0.12);
for (let k = 0; k < dug.steps.length; k += 1) {
script.push({ t: 0.4 + k * gapS, f: stepAt(k) });
}
const end = 0.4 + dug.steps.length * gapS;
LAP_S = end + 2.4;
REST_S = end + 0.3;
}
function update(dt) { if (dt < 0) {
return;
} }
function draw() {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoard(function (i) {
const a = still ? 99 : sceneT - dugAt[i];
if (g[i] === 0 && a >= 0 && a < 0.8) {
return mix(SUN, EMPTY, a / 0.8);
}
return null;
}, null);
const sk = (sceneT - scanT) / SCAN_S;
if (scanCell >= 0 && !still && sk >= 0 && sk < 1) {
for (let kind = 0; kind < 3; kind += 1) {
tintUnit(unitIndex(kind, scanCell), scanOk ? SUN : ROSE, 0.2 * (1 - sk));
}
}
// 되돌린 칸 — 비우려다 풀이가 둘이 되어 제자리로 돌아간 칸에 붉은 테가 남는다
for (let i = 0; i < 81; i += 1) {
const a = sceneT - restT[i];
if (restT[i] > -9 && g[i] !== 0) {
ring(cellX(i) + 1, cellY(i) + 1, CS - 2, CS - 2, 4, ROSE, still ? 0.6 : Math.max(0.35, 1 - a / 1.5), 2);
}
}
panel(PX, BY - 5, PW, 223);
text('ヒント', PX + 12, BY + 8, DIM, 11, 700, 'left');
text(String(clueNow), PX + 12, BY + 36, PAPER, 30, 800, 'left');
// ヒント 수가 줄어드는 꺾은선
const x0 = PX + 70;
const w0 = PW - 82;
box(x0, BY + 14, w0, 44, 6, [48, 44, 74], 1);
ctx.beginPath();
const total = Math.max(1, dug.steps.length);
for (let k = 0; k < clueHist.length; k += 1) {
const x = x0 + 4 + (w0 - 8) * (k + 1) / total;
const y = BY + 18 + 36 * (81 - clueHist[k]) / (81 - CLUES);
if (k === 0) {
ctx.moveTo(x, y);
}
else {
ctx.lineTo(x, y);
}
}
ctx.strokeStyle = rgba(MINT, 1);
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.stroke();
// 최근 풀이 개수 결과 점 — 1 은 민트(비움), 2 는 장미(되돌림)
text('解の数 (2までしか数えない)', PX + 12, BY + 76, DIM, 11, 700, 'left');
const from = Math.max(0, stepShown - 18);
for (let k = from; k < stepShown; k += 1) {
const x = PX + 18 + (k - from) * 10.5;
disc(x, BY + 94, 4.2, dug.steps[k].ok ? MINT : ROSE, 1);
}
if (stepShown > 0) {
text(lastCnt === 1 ? fill('空けた: 解 {a} 個', '1') : '戻した: 解が2個以上', PX + 12, BY + 118, lastCnt === 1 ? MINT : ROSE, 12, 800, 'left');
text(fill('探索ノード {a}', String(lastNodes)), PX + 12, BY + 138, NEU, 11, 700, 'left');
}
text(fill('戻したマス {a}', String(restored)), PX + 12, BY + 160, NEU, 12, 700, 'left');
if (stepShown >= dug.steps.length && dug.steps.length > 0) {
text(uniqueFinal === 1 ? '完成: 解はひとつだけ' : '解がひとつではない', PX + 12, BY + 190, uniqueFinal === 1 ? MINT : ROSE, 12, 800, 'left');
}
hud('CLUES', String(CLUES), clueNow / 81);
}
function statusText() {
return fill('ヒント {a}、直前の解の数 {b}', String(clueNow), String(lastCnt));
}
function modeId() { return modeBase(stepShown >= dug.steps.length ? 3 : (stepShown > 0 ? 1 : 0)); }
function speakText() {
return fill('ヒント {a} 個、戻したマス {b} 個', String(clueNow), String(restored)) + '. ' + speakBoard();
}
function onPtr(kind, x, y) { if (kind < 0 || x < 0 || y < 0) {
return;
} }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// UniqueDigCount - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./UniqueDigCount.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function UniqueDigCount() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [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 easeOut(k: number): number { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const SKY: RGB = [150, 192, 244];
const SUN: RGB = [250, 218, 146];
const NEU: RGB = [236, 230, 250];
const BGC: RGB = [46, 42, 68];
const PANEL: RGB = [62, 57, 92];
const DIM: RGB = [196, 190, 224];
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) + ')';
}
function hsl(h: number, s: number, l: number): RGB {
const a = s * Math.min(l, 1 - l);
const f = function (n: number): number {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a: number, b: number): number {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s: string, a: string, b?: string, c?: string): string {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v: number): string { return String(Math.round(v * 10) / 10); }
function group(v: number): string {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { o += ','; }
o += s.charAt(i);
}
return o;
}
// ── 스도쿠 모델 — 판은 81칸 숫자 배열(0 은 빈칸). 후보는 9비트 한 수: 숫자 d 는 (d-1) 번째 비트
const ALL = 511;
function bit(d: number): number { return 1 << (d - 1); }
function popcount(m: number): number {
let c = 0;
let v = m;
while (v) { v &= v - 1; c += 1; }
return c;
}
// 가장 낮은 비트 하나가 가리키는 숫자
function lowDigit(m: number): number { return 32 - Math.clz32(m & -m); }
function bin9(m: number): string {
let s = '';
for (let d = 9; d >= 1; d -= 1) { s += (m & bit(d)) ? '1' : '0'; }
return s;
}
function rowOf(i: number): number { return Math.floor(i / 9); }
function colOf(i: number): number { return i % 9; }
function boxOf(i: number): number { return Math.floor(i / 27) * 3 + Math.floor((i % 9) / 3); }
// 묶음 27개 — 줄 9 · 세로줄 9 · 상자 9. 칸의 이웃은 같은 묶음 세 곳의 다른 칸 20개
const UNITS: number[][] = [];
const PEERS: number[][] = [];
for (let k = 0; k < 27; k += 1) { UNITS.push([]); }
for (let i = 0; i < 81; i += 1) {
UNITS[rowOf(i)].push(i);
UNITS[9 + colOf(i)].push(i);
UNITS[18 + boxOf(i)].push(i);
}
for (let i = 0; i < 81; i += 1) {
const ps: number[] = [];
for (let j = 0; j < 81; j += 1) {
if (j !== i && (rowOf(j) === rowOf(i) || colOf(j) === colOf(i) || boxOf(j) === boxOf(i))) { ps.push(j); }
}
PEERS.push(ps);
}
// 묶음 종류 kind: 0 줄 · 1 세로줄 · 2 상자
function unitIndex(kind: number, i: number): number { return kind === 0 ? rowOf(i) : (kind === 1 ? 9 + colOf(i) : 18 + boxOf(i)); }
function sharedKind(i: number, j: number): number {
if (rowOf(i) === rowOf(j)) { return 0; }
if (colOf(i) === colOf(j)) { return 1; }
return boxOf(i) === boxOf(j) ? 2 : -1;
}
// 칸 i 가 속한 kind 묶음에서 i 를 뺀 칸들이 이미 쓴 숫자의 비트 합(OR)
function usedMask(b: number[], i: number, kind: number): number {
const u = UNITS[unitIndex(kind, i)];
let m = 0;
for (let k = 0; k < 9; k += 1) { if (u[k] !== i && b[u[k]] !== 0) { m |= bit(b[u[k]]); } }
return m;
}
// 후보 = 9비트 전체에서 세 묶음이 쓴 비트를 뺀 것. 채워진 칸의 후보는 0
function candMask(b: number[], i: number): number {
if (b[i] !== 0) { return 0; }
return ALL & ~(usedMask(b, i, 0) | usedMask(b, i, 1) | usedMask(b, i, 2));
}
function filledOf(b: number[]): number {
let c = 0;
for (let i = 0; i < 81; i += 1) { if (b[i] !== 0) { c += 1; } }
return c;
}
function perm(n: number, seed: number): number[] {
const a: number[] = [];
for (let i = 0; i < n; i += 1) { a.push(i); }
for (let i = n - 1; i > 0; i -= 1) {
const j = Math.floor(hash2(i, seed) * (i + 1));
const t = a[i];
a[i] = a[j];
a[j] = t;
}
return a;
}
// 완성판 — 기본 무늬를 숫자·줄·상자 묶음 순서로 섞는다. 같은 씨앗이면 같은 판
function fullBoard(seed: number): number[] {
const dp = perm(9, seed * 7 + 1);
const bp = perm(3, seed * 7 + 2);
const sp = perm(3, seed * 7 + 3);
const rows: number[] = [];
const cols: number[] = [];
for (let k = 0; k < 3; k += 1) {
const rp = perm(3, seed * 13 + k * 5 + 4);
const cp = perm(3, seed * 13 + k * 5 + 9);
for (let j = 0; j < 3; j += 1) {
rows.push(bp[k] * 3 + rp[j]);
cols.push(sp[k] * 3 + cp[j]);
}
}
const b: number[] = [];
for (let r = 0; r < 9; r += 1) {
for (let c = 0; c < 9; c += 1) {
b.push(dp[(3 * (rows[r] % 3) + Math.floor(rows[r] / 3) + cols[c]) % 9] + 1);
}
}
return b;
}
// 풀이 탐색 — 가장 후보가 적은 칸(plain 이면 앞쪽 빈칸)부터 숫자를 걸고, 막히면 되돌린다.
// count 가 limit 에 닿으면 멈추고(풀이 개수는 limit 까지만 센다), 걸어 본 숫자가 cap 을 넘으면 멈춘다.
// trace 에는 (종류 << 16) | (칸 << 4) | 숫자 — 종류 0 은 걸기, 1 은 되돌리기
type Srch = { count: number; nodes: number; dead: number; branch: number; capHit: boolean; first: number[]; trace: number[] };
function search(start: number[], limit: number, cap: number, plain: boolean, wantTrace: boolean): Srch {
const b = start.slice();
const rm: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const cm: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const xm: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
rm[rowOf(i)] |= bit(b[i]);
cm[colOf(i)] |= bit(b[i]);
xm[boxOf(i)] |= bit(b[i]);
}
}
const r: Srch = { count: 0, nodes: 0, dead: 0, branch: 0, capHit: false, first: [], trace: [] };
function rec(): void {
let best = -1;
let bm = 0;
let bc = 10;
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) { continue; }
const m = ALL & ~(rm[rowOf(i)] | cm[colOf(i)] | xm[boxOf(i)]);
const pc = popcount(m);
if (plain) { best = i; bm = m; bc = pc; break; }
if (pc < bc) { best = i; bm = m; bc = pc; if (pc <= 1) { break; } }
}
if (best < 0) {
r.count += 1;
if (r.count === 1) { r.first = b.slice(); }
return;
}
if (bc === 0) { r.dead += 1; return; }
if (bc > 1) { r.branch += 1; }
let mm = bm;
while (mm) {
const lo = mm & -mm;
mm ^= lo;
const d = 32 - Math.clz32(lo);
r.nodes += 1;
if (r.nodes > cap) { r.capHit = true; return; }
b[best] = d;
rm[rowOf(best)] |= lo;
cm[colOf(best)] |= lo;
xm[boxOf(best)] |= lo;
if (wantTrace) { r.trace.push((best << 4) | d); }
rec();
if (r.count >= limit || r.capHit) { return; }
b[best] = 0;
rm[rowOf(best)] &= ~lo;
cm[colOf(best)] &= ~lo;
xm[boxOf(best)] &= ~lo;
if (wantTrace) { r.trace.push((1 << 16) | (best << 4) | d); }
}
}
rec();
return r;
}
// 하나뿐인 후보 채우기 — 후보가 하나인 칸(번호가 가장 앞선 칸)을 채우고 다시 센다. 더 없으면 멈춘다
type Singles = { order: number[]; digits: number[]; avail: number[]; solved: boolean; board: number[] };
function singlesFill(start: number[]): Singles {
const b = start.slice();
const order: number[] = [];
const digits: number[] = [];
const avail: number[] = [];
for (;;) {
let first = -1;
let n = 0;
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0 && popcount(candMask(b, i)) === 1) {
if (first < 0) { first = i; }
n += 1;
}
}
if (first < 0) { break; }
const d = lowDigit(candMask(b, first));
b[first] = d;
order.push(first);
digits.push(d);
avail.push(n);
}
let solved = true;
for (let i = 0; i < 81; i += 1) { if (b[i] === 0) { solved = false; } }
return { order: order, digits: digits, avail: avail, solved: solved, board: b };
}
// 구멍 파기 — 완성판에서 칸을 해시 순서로 하나씩 비우고 풀이 개수를 2 까지만 센다.
// 하나뿐이면 비운 채로 두고, 둘 이상이면 되돌린다. singlesOnly 이면 하나뿐인 후보만으로 풀리는 판만 남긴다
type DigStep = { cell: number; cnt: number; ok: boolean; nodes: number };
type Dug = { puz: number[]; steps: DigStep[]; clues: number };
function digBoard(sol: number[], seed: number, target: number, singlesOnly: boolean): Dug {
const puz = sol.slice();
const order = perm(81, seed * 31 + 5);
const steps: DigStep[] = [];
let clues = 81;
for (let k = 0; k < 81; k += 1) {
if (clues <= target) { break; }
const c = order[k];
const d = puz[c];
puz[c] = 0;
const s = search(puz, 2, 6000, false, false);
const cnt = s.capHit ? 2 : Math.min(2, s.count);
const ok = cnt === 1 && (!singlesOnly || singlesFill(puz).solved);
steps.push({ cell: c, cnt: cnt, ok: ok, nodes: s.nodes });
if (ok) { clues -= 1; } else { puz[c] = d; }
}
return { puz: puz, steps: steps, clues: clues };
}
// 놓은 숫자가 겹치는 쌍 — [칸, 칸, 묶음 종류]
function conflicts(b: number[]): number[][] {
const out: number[][] = [];
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0) { continue; }
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (p > i && b[p] === b[i]) { out.push([i, p, sharedKind(i, p)]); }
}
}
return out;
}
// ── 판 상태 — 장면이 아니라 판이 들고 있는 것. 화면은 이 값을 읽어 그린다
const BX = 22;
const BY = 44;
const CS = 23;
const GAP = 3;
const PX = 258;
const PW = 210;
let g: number[] = [];
let giv: boolean[] = [];
let sol: number[] = [];
let pt: number[] = [];
let cur = 40;
let solvedT = -1;
let lastCell = -1;
type Rip = { i: number; t: number; list: number[]; d: number };
let rip: Rip = { i: -1, t: -9, list: [], d: 0 };
let selDigit = 0;
let still = false;
let hintHook = null as (() => void) | null;
function cellX(i: number): number { const c = colOf(i); return BX + c * CS + Math.floor(c / 3) * GAP; }
function cellY(i: number): number { const r = rowOf(i); return BY + r * CS + Math.floor(r / 3) * GAP; }
function cellCx(i: number): number { return cellX(i) + CS / 2; }
function cellCy(i: number): number { return cellY(i) + CS / 2; }
function cellAt(x: number, y: number): number {
let c = -1;
let r = -1;
for (let k = 0; k < 9; k += 1) {
const x0 = BX + k * CS + Math.floor(k / 3) * GAP;
const y0 = BY + k * CS + Math.floor(k / 3) * GAP;
if (x >= x0 && x < x0 + CS) { c = k; }
if (y >= y0 && y < y0 + CS) { r = k; }
}
return c < 0 || r < 0 ? -1 : r * 9 + c;
}
function setBoard(puz: number[], solution: number[]): void {
g = puz.slice();
sol = solution.slice();
giv = [];
pt = [];
for (let i = 0; i < 81; i += 1) { giv.push(puz[i] !== 0); pt.push(-9); }
solvedT = -1;
lastCell = -1;
rip = { i: -1, t: -9, list: [], d: 0 };
}
function solvedNow(): boolean { return filledOf(g) === 81 && conflicts(g).length === 0; }
function checkSolved(): void {
if (solvedNow()) { if (solvedT < 0) { solvedT = sceneT; } } else { solvedT = -1; }
}
// 숫자를 놓는다 — 놓기 전에 그 숫자가 후보에 있던 이웃 칸을 기억해 두면 소거가 번지는 모습을 그릴 수 있다
function putDigit(i: number, d: number): boolean {
if (giv[i] || d < 1 || d > 9) { return false; }
const list: number[] = [];
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (g[p] === 0 && (candMask(g, p) & bit(d)) !== 0) { list.push(p); }
}
g[i] = d;
pt[i] = sceneT;
rip = { i: i, t: sceneT, list: list, d: d };
lastCell = i;
checkSolved();
return true;
}
function clearDigit(i: number): void {
if (giv[i] || g[i] === 0) { return; }
g[i] = 0;
pt[i] = -9;
checkSolved();
}
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)]; }
// ── 그리기 도구
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 doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 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 disc(x: number, y: number, r: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, c: RGB, a: number, w: number): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x: number, y: number, w: number, h: number): void { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name: string, val: string, frac: number): void {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// ── 판 그리기 — 칸 색은 장면이 정하고, 숫자는 판이 그린다
const EMPTY: RGB = [58, 53, 88];
const PLACED: RGB = [190, 214, 246];
const PLACED_INK: RGB = [28, 54, 118];
function ring(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number, lw: number): void {
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = lw;
ctx.stroke();
}
function drawBoard(fillOf: (i: number) => RGB | null, extra: ((i: number, x: number, y: number) => void) | null): void {
const s = 9 * CS + 2 * GAP;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < 81; i += 1) {
const x = cellX(i);
const y = cellY(i);
let f: RGB | null = fillOf(i);
if (f === null) { f = g[i] === 0 ? EMPTY : (giv[i] ? NEU : PLACED); }
box(x + 1, y + 1, CS - 2, CS - 2, 4, f, 1);
if (g[i] !== 0) {
const a = still ? 99 : sceneT - pt[i];
const k = a < 0.3 ? 0.7 + 0.3 * easeOut(a / 0.3) : 1;
text(String(g[i]), x + CS / 2, y + CS / 2 + 1, giv[i] ? INK : PLACED_INK, Math.round(14 * k), giv[i] ? 800 : 700, 'center');
} else if (extra !== null) {
extra(i, x, y);
}
}
if (!demo) { ring(cellX(cur) + 0.5, cellY(cur) + 0.5, CS - 1, CS - 1, 4, PAPER, 1, 2); }
}
// 묶음 하나를 옅게 물들인다
function tintUnit(u: number, c: RGB, a: number): void {
for (let k = 0; k < 9; k += 1) {
const i = UNITS[u][k];
box(cellX(i) + 1, cellY(i) + 1, CS - 2, CS - 2, 4, c, a);
// 물들임이 숫자를 흐리지 않도록 숫자를 다시 얹는다
if (g[i] !== 0) { text(String(g[i]), cellCx(i), cellCy(i) + 1, giv[i] ? INK : PLACED_INK, 14, giv[i] ? 800 : 700, 'center'); }
}
}
// 후보 비트 줄 — 숫자 1~9 칸 아홉 개, 켜진 비트만 밝다
function bitRow(x: number, y: number, m: number, on: RGB, off: RGB): void {
for (let d = 1; d <= 9; d += 1) {
const lit = (m & bit(d)) !== 0;
const bx = x + (d - 1) * 18;
box(bx, y, 16, 16, 4, lit ? on : off, 1);
text(String(d), bx + 8, y + 8.5, lit ? INK : DIM, 10, 700, 'center');
}
}
// ── 상태 줄·낭독 문장
const SPK_FILL = '{a}칸 중 {b}칸이 채워져 있다';
const SPK_DONE = '판 완성, {a}칸이 모두 채워졌다';
function speakBoard(): string {
return solvedNow() ? fill(SPK_DONE, '81') : fill(SPK_FILL, '81', String(filledOf(g)));
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 100 + 채운 칸 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode: number): number {
if (solvedNow()) { return 1000; }
return manual ? 100 + filledOf(g) : sceneMode;
}
// 방금 놓은 칸의 줄·세로줄·상자 세 묶음을 한 번 물들였다 서서히 빼는 번짐 — 숫자 하나가 어디까지 영향을 주는지 보인다
function unitPulse(c: RGB, a: number, dur: number): void {
if (rip.i < 0 || still) { return; }
const k = (sceneT - rip.t) / dur;
if (k < 0 || k >= 1) { return; }
for (let kind = 0; kind < 3; kind += 1) { tintUnit(unitIndex(kind, rip.i), c, a * (1 - k)); }
}
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 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
}
function takeOver(): void {
if (manual) { return; }
manual = true;
wake();
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say(): void {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
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);
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow(): void {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) { say(); }
}
}
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;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest(): void {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak: boolean): void {
if (paused || still) { sceneT += 1; }
update(0);
showNow();
if (speak) { say(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
say();
}
function restart(): void {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// 새 판 — 씨앗이 달라진 판으로 시간표를 처음부터. 손으로 하던 중이어도 자동 시연으로 돌아간다
function newBoard(): void {
manual = false;
lap += 1;
begin();
update(0);
showNow();
say();
}
function moveCur(dx: number, dy: number): void {
takeOver();
cur = clamp(rowOf(cur) + dy, 0, 8) * 9 + clamp(colOf(cur) + dx, 0, 8);
afterInput(true);
}
// 힌트 기본 — 고른 빈칸에 정답 숫자를 놓는다. 장면이 hintHook 을 걸어 두면 그쪽이 먼저
function hintDefault(): void {
if (!giv[cur] && g[cur] === 0) { putDigit(cur, sol[cur]); }
}
function toCanvas(e: PointerEvent): number[] {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 칸 이동, 1~9 = 숫자 입력, Delete·Backspace·0 = 지우기, H = 힌트,
// N = 새 판, R = 처음부터, Enter·Space = 일시정지
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'r' || k === 'R') {
restart();
} else if (k === 'n' || k === 'N') {
e.preventDefault();
newBoard();
} else if (k === 'ArrowLeft') {
e.preventDefault();
moveCur(-1, 0);
} else if (k === 'ArrowRight') {
e.preventDefault();
moveCur(1, 0);
} else if (k === 'ArrowUp') {
e.preventDefault();
moveCur(0, -1);
} else if (k === 'ArrowDown') {
e.preventDefault();
moveCur(0, 1);
} else if (k.length === 1 && k >= '1' && k <= '9') {
e.preventDefault();
takeOver();
selDigit = Number(k);
putDigit(cur, selDigit);
afterInput(true);
} else if (k === 'Delete' || k === 'Backspace' || k === '0') {
e.preventDefault();
takeOver();
clearDigit(cur);
afterInput(true);
} else if (k === 'h' || k === 'H') {
e.preventDefault();
takeOver();
if (hintHook !== null) { hintHook(); } else { hintDefault(); }
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
ptrDown = true;
try { pad.setPointerCapture(e.pointerId); } catch (err) { ptrDown = true; }
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) { cur = c; }
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (!ptrDown) { return; }
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) { cur = c; }
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e: PointerEvent): void {
if (!ptrDown) { return; }
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { 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 — 완성판에서 칸을 하나씩 비우고 풀이 개수를 2 까지만 센다. 하나면 비운 채로 두고 둘이면 되돌린다. 비운 순서와 되돌린 칸을 표시해 단서 수가 줄어드는 모습을 보인다
const CLUES = 36;
const SEEDS_04 = [4, 9, 15, 22];
let LAP_S = 8;
let REST_S = 6;
let dug: Dug = { puz: [], steps: [], clues: 81 };
let gapS = 0.1;
let stepShown = 0;
let clueNow = 81;
let restored = 0;
let lastCnt = 1;
let lastNodes = 0;
let restT: number[] = [];
let dugAt: number[] = [];
let clueHist: number[] = [];
let uniqueFinal = 0;
// 풀이 개수를 세는 동안 그 칸의 줄·세로줄·상자를 훑는 표시
let scanCell = -1;
let scanT = -9;
let scanOk = true;
const SCAN_S = 0.6;
function stepAt(k: number): () => void {
return function (): void {
const st = dug.steps[k];
stepShown = k + 1;
lastCnt = st.cnt;
lastNodes = st.nodes;
scanCell = st.cell;
scanT = sceneT;
scanOk = st.ok;
if (st.ok) {
g[st.cell] = 0;
giv[st.cell] = false;
pt[st.cell] = -9;
clueNow -= 1;
dugAt[st.cell] = sceneT;
} else {
restored += 1;
restT[st.cell] = sceneT;
}
clueHist.push(clueNow);
if (k === dug.steps.length - 1) { uniqueFinal = search(g, 2, 6000, false, false).count; }
};
}
function build(): void {
stepShown = 0;
clueNow = 81;
restored = 0;
lastCnt = 1;
lastNodes = 0;
uniqueFinal = 0;
scanCell = -1;
scanT = -9;
clueHist = [];
restT = [];
dugAt = [];
for (let i = 0; i < 81; i += 1) { restT.push(-9); dugAt.push(-9); }
const seed = SEEDS_04[lap % SEEDS_04.length];
const s = fullBoard(seed);
dug = digBoard(s, seed + 60, CLUES, false);
setBoard(s, s);
gapS = clamp(5.2 / Math.max(1, dug.steps.length), 0.04, 0.12);
for (let k = 0; k < dug.steps.length; k += 1) { script.push({ t: 0.4 + k * gapS, f: stepAt(k) }); }
const end = 0.4 + dug.steps.length * gapS;
LAP_S = end + 2.4;
REST_S = end + 0.3;
}
function update(dt: number): void { if (dt < 0) { return; } }
function draw(): void {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoard(function (i: number): RGB | null {
const a = still ? 99 : sceneT - dugAt[i];
if (g[i] === 0 && a >= 0 && a < 0.8) { return mix(SUN, EMPTY, a / 0.8); }
return null;
}, null);
const sk = (sceneT - scanT) / SCAN_S;
if (scanCell >= 0 && !still && sk >= 0 && sk < 1) {
for (let kind = 0; kind < 3; kind += 1) { tintUnit(unitIndex(kind, scanCell), scanOk ? SUN : ROSE, 0.2 * (1 - sk)); }
}
// 되돌린 칸 — 비우려다 풀이가 둘이 되어 제자리로 돌아간 칸에 붉은 테가 남는다
for (let i = 0; i < 81; i += 1) {
const a = sceneT - restT[i];
if (restT[i] > -9 && g[i] !== 0) { ring(cellX(i) + 1, cellY(i) + 1, CS - 2, CS - 2, 4, ROSE, still ? 0.6 : Math.max(0.35, 1 - a / 1.5), 2); }
}
panel(PX, BY - 5, PW, 223);
text('단서', PX + 12, BY + 8, DIM, 11, 700, 'left');
text(String(clueNow), PX + 12, BY + 36, PAPER, 30, 800, 'left');
// 단서 수가 줄어드는 꺾은선
const x0 = PX + 70;
const w0 = PW - 82;
box(x0, BY + 14, w0, 44, 6, [48, 44, 74], 1);
ctx.beginPath();
const total = Math.max(1, dug.steps.length);
for (let k = 0; k < clueHist.length; k += 1) {
const x = x0 + 4 + (w0 - 8) * (k + 1) / total;
const y = BY + 18 + 36 * (81 - clueHist[k]) / (81 - CLUES);
if (k === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); }
}
ctx.strokeStyle = rgba(MINT, 1);
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.stroke();
// 최근 풀이 개수 결과 점 — 1 은 민트(비움), 2 는 장미(되돌림)
text('풀이 개수 (2 까지만 센다)', PX + 12, BY + 76, DIM, 11, 700, 'left');
const from = Math.max(0, stepShown - 18);
for (let k = from; k < stepShown; k += 1) {
const x = PX + 18 + (k - from) * 10.5;
disc(x, BY + 94, 4.2, dug.steps[k].ok ? MINT : ROSE, 1);
}
if (stepShown > 0) {
text(lastCnt === 1 ? fill('비움: 풀이 {a}개', '1') : '되돌림: 풀이 2개 이상', PX + 12, BY + 118, lastCnt === 1 ? MINT : ROSE, 12, 800, 'left');
text(fill('탐색 노드 {a}', String(lastNodes)), PX + 12, BY + 138, NEU, 11, 700, 'left');
}
text(fill('되돌린 칸 {a}', String(restored)), PX + 12, BY + 160, NEU, 12, 700, 'left');
if (stepShown >= dug.steps.length && dug.steps.length > 0) {
text(uniqueFinal === 1 ? '완성: 풀이가 하나뿐' : '풀이가 하나가 아님', PX + 12, BY + 190, uniqueFinal === 1 ? MINT : ROSE, 12, 800, 'left');
}
hud('CLUES', String(CLUES), clueNow / 81);
}
function statusText(): string {
return fill('단서 {a}, 마지막 풀이 개수 {b}', String(clueNow), String(lastCnt));
}
function modeId(): number { return modeBase(stepShown >= dug.steps.length ? 3 : (stepShown > 0 ? 1 : 0)); }
function speakText(): string {
return fill('단서 {a}개, 되돌린 칸 {b}개', String(clueNow), String(restored)) + '. ' + speakBoard();
}
function onPtr(kind: number, x: number, y: number): void { if (kind < 0 || x < 0 || y < 0) { 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={'스도쿠 시연 무대 — 방향키로 칸 이동, 1부터 9 숫자키로 입력, Delete 로 지우기, H 로 힌트, N 으로 새 판, R 로 처음부터, Enter 또는 Space 로 일시정지'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'완성된 9×9 판에서 칸을 해시 순서로 하나씩 비운다. 비운 뒤 풀이 개수를 2까지만 세어, 하나이면 비운 칸을 그대로 두고 둘 이상이면 숫자를 되돌리며 그 칸에 붉은 테가 남는다. 오른쪽 패널에는 단서 수가 81에서 CLUES 36까지 줄어드는 큰 숫자와 꺾은선, 최근 결과 점(민트는 비움, 장미는 되돌림), 마지막 탐색 노드 수와 되돌린 칸 수가 나오고 끝에는 풀이가 하나뿐이라고 표시된다. 아래 눈금은 상수 이름과 지금 값을 보이고, 움직임 줄이기에서는 숫자가 바로 채워진 판 한 장만 그려진다'}></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>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}04 · 穴を開けるたびに解の数を数える盤づくり — 計測 累積面積 20.873% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function digBoard(sol, seed, target, singlesOnly) {
const puz = sol.slice();
const order = perm(81, seed * 31 + 5);
const steps = [];
let clues = 81;
for (let k = 0; k < 81; k += 1) {
if (clues <= target) {
break;
}
const c = order[k];
const d = puz[c];
puz[c] = 0;
const s = search(puz, 2, 6000, false, false);
const cnt = s.capHit ? 2 : Math.min(2, s.count);
const ok = cnt === 1 && (!singlesOnly || singlesFill(puz).solved);
steps.push({ cell: c, cnt: cnt, ok: ok, nodes: s.nodes });
if (ok) {
clues -= 1;
}
else {
puz[c] = d;
}
}
return { puz: puz, steps: steps, clues: clues };
}
CLUES を36にして四つの盤を作ると、試した回数は47・46・45・45回、戻したマスは2・1・0・0個で、四つとも最終のヒントは36、解の数は1でした。
05同じ行・列・箱で重なった数字を示す衝突の表示
置いた数字が同じ行・列・箱にすでにあると、重なった二つのマスをバラ色に塗って間違った入力をすぐ示します。表示するのは規則違反だけで、解けない盤かどうかは03番の探索が別に判定します。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="数独の舞台 — 矢印キーでマス移動、数字キー1〜9で入力、Deleteで消去、Hでヒント、Nで新しい盤、Rで最初から、EnterかSpaceで一時停止">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="9×9の盤に数字が八つ入力される。規則に合えばそのまま残り、同じ行・列・箱に同じ数字がすでにあると重なった二つのマスが赤く染まり、二つのマスをつなぐ線が引かれてその行全体がうすく染まる。少し後に間違って置いた数字は消される。右のパネルには重なった組の数と、行・列・箱ごとの棒、直近四件の入力のチェックとバツが出る。下の目盛りは定数名と今の値を示し、動きを減らす設定では数字がすぐ埋まった盤一枚だけを描きます"></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>
</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-ink;
}
// 무대 상자 — 눌러서 한 번 깨뜨리는 곳(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-cream;
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 {
padding: $sp-1 $sp-3;
border: 2px solid $subject-cream;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
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__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-ink;
color: $subject-cream;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면에만 있고 aria-live 는 늘 off
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 100px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
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__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 글리치 없는 원문 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause: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: #17141a;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #fff7e6;
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 {
padding: 4px 12px;
border: 2px solid #fff7e6;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
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__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 100px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
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__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause: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 easeOut(k) { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const SKY = [150, 192, 244];
const SUN = [250, 218, 146];
const NEU = [236, 230, 250];
const BGC = [46, 42, 68];
const PANEL = [62, 57, 92];
const DIM = [196, 190, 224];
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) + ')';
}
function hsl(h, s, l) {
const a = s * Math.min(l, 1 - l);
const f = function (n) {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a, b) {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s, a, b, c) {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v) { return String(Math.round(v * 10) / 10); }
function group(v) {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
o += ',';
}
o += s.charAt(i);
}
return o;
}
// ── 스도쿠 모델 — 판은 81칸 숫자 배열(0 은 빈칸). 후보는 9비트 한 수: 숫자 d 는 (d-1) 번째 비트
const ALL = 511;
function bit(d) { return 1 << (d - 1); }
function popcount(m) {
let c = 0;
let v = m;
while (v) {
v &= v - 1;
c += 1;
}
return c;
}
// 가장 낮은 비트 하나가 가리키는 숫자
function lowDigit(m) { return 32 - Math.clz32(m & -m); }
function bin9(m) {
let s = '';
for (let d = 9; d >= 1; d -= 1) {
s += (m & bit(d)) ? '1' : '0';
}
return s;
}
function rowOf(i) { return Math.floor(i / 9); }
function colOf(i) { return i % 9; }
function boxOf(i) { return Math.floor(i / 27) * 3 + Math.floor((i % 9) / 3); }
// 묶음 27개 — 줄 9 · 세로줄 9 · 箱 9. 칸의 이웃은 같은 묶음 세 곳의 다른 칸 20개
const UNITS = [];
const PEERS = [];
for (let k = 0; k < 27; k += 1) {
UNITS.push([]);
}
for (let i = 0; i < 81; i += 1) {
UNITS[rowOf(i)].push(i);
UNITS[9 + colOf(i)].push(i);
UNITS[18 + boxOf(i)].push(i);
}
for (let i = 0; i < 81; i += 1) {
const ps = [];
for (let j = 0; j < 81; j += 1) {
if (j !== i && (rowOf(j) === rowOf(i) || colOf(j) === colOf(i) || boxOf(j) === boxOf(i))) {
ps.push(j);
}
}
PEERS.push(ps);
}
// 묶음 종류 kind: 0 줄 · 1 세로줄 · 2 箱
function unitIndex(kind, i) { return kind === 0 ? rowOf(i) : (kind === 1 ? 9 + colOf(i) : 18 + boxOf(i)); }
function sharedKind(i, j) {
if (rowOf(i) === rowOf(j)) {
return 0;
}
if (colOf(i) === colOf(j)) {
return 1;
}
return boxOf(i) === boxOf(j) ? 2 : -1;
}
// 칸 i 가 속한 kind 묶음에서 i 를 뺀 칸들이 이미 쓴 숫자의 비트 合計(OR)
function usedMask(b, i, kind) {
const u = UNITS[unitIndex(kind, i)];
let m = 0;
for (let k = 0; k < 9; k += 1) {
if (u[k] !== i && b[u[k]] !== 0) {
m |= bit(b[u[k]]);
}
}
return m;
}
// 候補 = 9비트 전체에서 세 묶음이 쓴 비트를 뺀 것. 채워진 칸의 후보는 0
function candMask(b, i) {
if (b[i] !== 0) {
return 0;
}
return ALL & ~(usedMask(b, i, 0) | usedMask(b, i, 1) | usedMask(b, i, 2));
}
function filledOf(b) {
let c = 0;
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
c += 1;
}
}
return c;
}
function perm(n, seed) {
const a = [];
for (let i = 0; i < n; i += 1) {
a.push(i);
}
for (let i = n - 1; i > 0; i -= 1) {
const j = Math.floor(hash2(i, seed) * (i + 1));
const t = a[i];
a[i] = a[j];
a[j] = t;
}
return a;
}
// 완성판 — 기본 무늬를 숫자·줄·箱 묶음 순서로 섞는다. 같은 씨앗이면 같은 판
function fullBoard(seed) {
const dp = perm(9, seed * 7 + 1);
const bp = perm(3, seed * 7 + 2);
const sp = perm(3, seed * 7 + 3);
const rows = [];
const cols = [];
for (let k = 0; k < 3; k += 1) {
const rp = perm(3, seed * 13 + k * 5 + 4);
const cp = perm(3, seed * 13 + k * 5 + 9);
for (let j = 0; j < 3; j += 1) {
rows.push(bp[k] * 3 + rp[j]);
cols.push(sp[k] * 3 + cp[j]);
}
}
const b = [];
for (let r = 0; r < 9; r += 1) {
for (let c = 0; c < 9; c += 1) {
b.push(dp[(3 * (rows[r] % 3) + Math.floor(rows[r] / 3) + cols[c]) % 9] + 1);
}
}
return b;
}
function search(start, limit, cap, plain, wantTrace) {
const b = start.slice();
const rm = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const cm = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const xm = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
rm[rowOf(i)] |= bit(b[i]);
cm[colOf(i)] |= bit(b[i]);
xm[boxOf(i)] |= bit(b[i]);
}
}
const r = { count: 0, nodes: 0, dead: 0, branch: 0, capHit: false, first: [], trace: [] };
function rec() {
let best = -1;
let bm = 0;
let bc = 10;
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
continue;
}
const m = ALL & ~(rm[rowOf(i)] | cm[colOf(i)] | xm[boxOf(i)]);
const pc = popcount(m);
if (plain) {
best = i;
bm = m;
bc = pc;
break;
}
if (pc < bc) {
best = i;
bm = m;
bc = pc;
if (pc <= 1) {
break;
}
}
}
if (best < 0) {
r.count += 1;
if (r.count === 1) {
r.first = b.slice();
}
return;
}
if (bc === 0) {
r.dead += 1;
return;
}
if (bc > 1) {
r.branch += 1;
}
let mm = bm;
while (mm) {
const lo = mm & -mm;
mm ^= lo;
const d = 32 - Math.clz32(lo);
r.nodes += 1;
if (r.nodes > cap) {
r.capHit = true;
return;
}
b[best] = d;
rm[rowOf(best)] |= lo;
cm[colOf(best)] |= lo;
xm[boxOf(best)] |= lo;
if (wantTrace) {
r.trace.push((best << 4) | d);
}
rec();
if (r.count >= limit || r.capHit) {
return;
}
b[best] = 0;
rm[rowOf(best)] &= ~lo;
cm[colOf(best)] &= ~lo;
xm[boxOf(best)] &= ~lo;
if (wantTrace) {
r.trace.push((1 << 16) | (best << 4) | d);
}
}
}
rec();
return r;
}
function singlesFill(start) {
const b = start.slice();
const order = [];
const digits = [];
const avail = [];
for (;;) {
let first = -1;
let n = 0;
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0 && popcount(candMask(b, i)) === 1) {
if (first < 0) {
first = i;
}
n += 1;
}
}
if (first < 0) {
break;
}
const d = lowDigit(candMask(b, first));
b[first] = d;
order.push(first);
digits.push(d);
avail.push(n);
}
let solved = true;
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0) {
solved = false;
}
}
return { order: order, digits: digits, avail: avail, solved: solved, board: b };
}
function digBoard(sol, seed, target, singlesOnly) {
const puz = sol.slice();
const order = perm(81, seed * 31 + 5);
const steps = [];
let clues = 81;
for (let k = 0; k < 81; k += 1) {
if (clues <= target) {
break;
}
const c = order[k];
const d = puz[c];
puz[c] = 0;
const s = search(puz, 2, 6000, false, false);
const cnt = s.capHit ? 2 : Math.min(2, s.count);
const ok = cnt === 1 && (!singlesOnly || singlesFill(puz).solved);
steps.push({ cell: c, cnt: cnt, ok: ok, nodes: s.nodes });
if (ok) {
clues -= 1;
}
else {
puz[c] = d;
}
}
return { puz: puz, steps: steps, clues: clues };
}
// 놓은 숫자가 겹치는 쌍 — [칸, 칸, 묶음 종류]
function conflicts(b) {
const out = [];
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0) {
continue;
}
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (p > i && b[p] === b[i]) {
out.push([i, p, sharedKind(i, p)]);
}
}
}
return out;
}
// ── 판 상태 — 장면이 아니라 판이 들고 있는 것. 화면은 이 값을 읽어 그린다
const BX = 22;
const BY = 44;
const CS = 23;
const GAP = 3;
const PX = 258;
const PW = 210;
let g = [];
let giv = [];
let sol = [];
let pt = [];
let cur = 40;
let solvedT = -1;
let lastCell = -1;
let rip = { i: -1, t: -9, list: [], d: 0 };
let selDigit = 0;
let still = false;
let hintHook = null;
function cellX(i) { const c = colOf(i); return BX + c * CS + Math.floor(c / 3) * GAP; }
function cellY(i) { const r = rowOf(i); return BY + r * CS + Math.floor(r / 3) * GAP; }
function cellCx(i) { return cellX(i) + CS / 2; }
function cellCy(i) { return cellY(i) + CS / 2; }
function cellAt(x, y) {
let c = -1;
let r = -1;
for (let k = 0; k < 9; k += 1) {
const x0 = BX + k * CS + Math.floor(k / 3) * GAP;
const y0 = BY + k * CS + Math.floor(k / 3) * GAP;
if (x >= x0 && x < x0 + CS) {
c = k;
}
if (y >= y0 && y < y0 + CS) {
r = k;
}
}
return c < 0 || r < 0 ? -1 : r * 9 + c;
}
function setBoard(puz, solution) {
g = puz.slice();
sol = solution.slice();
giv = [];
pt = [];
for (let i = 0; i < 81; i += 1) {
giv.push(puz[i] !== 0);
pt.push(-9);
}
solvedT = -1;
lastCell = -1;
rip = { i: -1, t: -9, list: [], d: 0 };
}
function solvedNow() { return filledOf(g) === 81 && conflicts(g).length === 0; }
function checkSolved() {
if (solvedNow()) {
if (solvedT < 0) {
solvedT = sceneT;
}
}
else {
solvedT = -1;
}
}
// 숫자를 놓는다 — 놓기 전에 그 숫자가 후보에 있던 이웃 칸을 기억해 두면 소거가 번지는 모습을 그릴 수 있다
function putDigit(i, d) {
if (giv[i] || d < 1 || d > 9) {
return false;
}
const list = [];
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (g[p] === 0 && (candMask(g, p) & bit(d)) !== 0) {
list.push(p);
}
}
g[i] = d;
pt[i] = sceneT;
rip = { i: i, t: sceneT, list: list, d: d };
lastCell = i;
checkSolved();
return true;
}
function clearDigit(i) {
if (giv[i] || g[i] === 0) {
return;
}
g[i] = 0;
pt[i] = -9;
checkSolved();
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// ── 그리기 도구
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 doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 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 disc(x, y, r, c, a) {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, c, a, w) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s, x, y, c, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x, y, w, h) { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name, val, frac) {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// ── 판 그리기 — 칸 색은 장면이 정하고, 숫자는 판이 그린다
const EMPTY = [58, 53, 88];
const PLACED = [190, 214, 246];
const PLACED_INK = [28, 54, 118];
function ring(x, y, w, h, r, c, a, lw) {
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = lw;
ctx.stroke();
}
function drawBoard(fillOf, extra) {
const s = 9 * CS + 2 * GAP;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < 81; i += 1) {
const x = cellX(i);
const y = cellY(i);
let f = fillOf(i);
if (f === null) {
f = g[i] === 0 ? EMPTY : (giv[i] ? NEU : PLACED);
}
box(x + 1, y + 1, CS - 2, CS - 2, 4, f, 1);
if (g[i] !== 0) {
const a = still ? 99 : sceneT - pt[i];
const k = a < 0.3 ? 0.7 + 0.3 * easeOut(a / 0.3) : 1;
text(String(g[i]), x + CS / 2, y + CS / 2 + 1, giv[i] ? INK : PLACED_INK, Math.round(14 * k), giv[i] ? 800 : 700, 'center');
}
else if (extra !== null) {
extra(i, x, y);
}
}
if (!demo) {
ring(cellX(cur) + 0.5, cellY(cur) + 0.5, CS - 1, CS - 1, 4, PAPER, 1, 2);
}
}
// 묶음 하나를 옅게 물들인다
function tintUnit(u, c, a) {
for (let k = 0; k < 9; k += 1) {
const i = UNITS[u][k];
box(cellX(i) + 1, cellY(i) + 1, CS - 2, CS - 2, 4, c, a);
// 물들임이 숫자를 흐리지 않도록 숫자를 다시 얹는다
if (g[i] !== 0) {
text(String(g[i]), cellCx(i), cellCy(i) + 1, giv[i] ? INK : PLACED_INK, 14, giv[i] ? 800 : 700, 'center');
}
}
}
// 候補 비트 줄 — 숫자 1~9 칸 아홉 개, 켜진 비트만 밝다
function bitRow(x, y, m, on, off) {
for (let d = 1; d <= 9; d += 1) {
const lit = (m & bit(d)) !== 0;
const bx = x + (d - 1) * 18;
box(bx, y, 16, 16, 4, lit ? on : off, 1);
text(String(d), bx + 8, y + 8.5, lit ? INK : DIM, 10, 700, 'center');
}
}
// ── 상태 줄·낭독 문장
const SPK_FILL = '{a} マスのうち {b} マスが埋まっている';
const SPK_DONE = '盤が完成、{a} マスすべてが埋まった';
function speakBoard() {
return solvedNow() ? fill(SPK_DONE, '81') : fill(SPK_FILL, '81', String(filledOf(g)));
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 100 + 채운 칸 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode) {
if (solvedNow()) {
return 1000;
}
return manual ? 100 + filledOf(g) : sceneMode;
}
// 방금 놓은 칸의 줄·세로줄·箱 세 묶음을 한 번 물들였다 서서히 빼는 번짐 — 숫자 하나가 어디까지 영향을 주는지 보인다
function unitPulse(c, a, dur) {
if (rip.i < 0 || still) {
return;
}
const k = (sceneT - rip.t) / dur;
if (k < 0 || k >= 1) {
return;
}
for (let kind = 0; kind < 3; kind += 1) {
tintUnit(unitIndex(kind, rip.i), c, a * (1 - k));
}
}
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 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
}
function takeOver() {
if (manual) {
return;
}
manual = true;
wake();
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say() {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
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);
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow() {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) {
say();
}
}
}
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;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest() {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak) {
if (paused || still) {
sceneT += 1;
}
update(0);
showNow();
if (speak) {
say();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
say();
}
function restart() {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// 새 판 — 씨앗이 달라진 판으로 시간표를 처음부터. 손으로 하던 중이어도 자동 시연으로 돌아간다
function newBoard() {
manual = false;
lap += 1;
begin();
update(0);
showNow();
say();
}
function moveCur(dx, dy) {
takeOver();
cur = clamp(rowOf(cur) + dy, 0, 8) * 9 + clamp(colOf(cur) + dx, 0, 8);
afterInput(true);
}
// 힌트 기본 — 고른 빈칸에 정답 숫자를 놓는다. 장면이 hintHook 을 걸어 두면 그쪽이 먼저
function hintDefault() {
if (!giv[cur] && g[cur] === 0) {
putDigit(cur, sol[cur]);
}
}
function toCanvas(e) {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 칸 이동, 1~9 = 숫자 입력, Delete·Backspace·0 = 지우기, H = 힌트,
// N = 새 판, R = 처음부터, Enter·Space = 一時停止
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'r' || k === 'R') {
restart();
}
else if (k === 'n' || k === 'N') {
e.preventDefault();
newBoard();
}
else if (k === 'ArrowLeft') {
e.preventDefault();
moveCur(-1, 0);
}
else if (k === 'ArrowRight') {
e.preventDefault();
moveCur(1, 0);
}
else if (k === 'ArrowUp') {
e.preventDefault();
moveCur(0, -1);
}
else if (k === 'ArrowDown') {
e.preventDefault();
moveCur(0, 1);
}
else if (k.length === 1 && k >= '1' && k <= '9') {
e.preventDefault();
takeOver();
selDigit = Number(k);
putDigit(cur, selDigit);
afterInput(true);
}
else if (k === 'Delete' || k === 'Backspace' || k === '0') {
e.preventDefault();
takeOver();
clearDigit(cur);
afterInput(true);
}
else if (k === 'h' || k === 'H') {
e.preventDefault();
takeOver();
if (hintHook !== null) {
hintHook();
}
else {
hintDefault();
}
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
pad.addEventListener('pointerdown', function (e) {
takeOver();
ptrDown = true;
try {
pad.setPointerCapture(e.pointerId);
}
catch (err) {
ptrDown = true;
}
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) {
cur = c;
}
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (!ptrDown) {
return;
}
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) {
cur = c;
}
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e) {
if (!ptrDown) {
return;
}
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { 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 — 놓은 숫자가 같은 줄·세로줄·상자에 이미 있으면 겹친 두 칸에 선을 잇고 그 묶음을 옅게 물들인다. 규칙을 어긴 것만 표시하고 풀 수 있는 판인지는 03번 탐색이 따로 가린다
const CLUES_05 = 40;
const ENTRY_GAP = 0.5;
const FIX_S = 0.42;
const KINDS_05 = [0, 1, 0, 1, 1, 0, 1, 0];
let LAP_S = 8;
let REST_S = 0.9;
let entries = [];
let plan05 = [];
let enteredN = 0;
let badSeen = 0;
const SPK_BAD = '重なった数字 {a}、重なり {b} 組';
function entryAt(k) {
return function () {
const c = plan05[k][0];
const d = plan05[k][1];
putDigit(c, d);
enteredN += 1;
let bad = false;
const cf = conflicts(g);
for (let q = 0; q < cf.length; q += 1) {
if (cf[q][0] === c || cf[q][1] === c) {
bad = true;
}
}
entries.push({ i: c, d: d, ok: !bad });
if (bad) {
badSeen += 1;
}
};
}
function fixAt(k) {
return function () { clearDigit(plan05[k][0]); };
}
function build() {
entries = [];
enteredN = 0;
badSeen = 0;
const s = fullBoard(51 + lap);
const d = digBoard(s, 61 + lap, CLUES_05, false);
setBoard(d.puz, s);
plan05 = [];
const order = perm(81, 17 + lap);
let used = 0;
for (let q = 0; q < 81 && used < KINDS_05.length; q += 1) {
const c = order[q];
if (g[c] !== 0) {
continue;
}
let dg = sol[c];
if (KINDS_05[used] === 1) {
const seen = [];
for (let k = 0; k < 20; k += 1) {
const v = g[PEERS[c][k]];
if (v !== 0 && v !== sol[c] && seen.indexOf(v) < 0) {
seen.push(v);
}
}
dg = seen[Math.floor(hash2(c, 3 + lap) * seen.length)];
}
plan05.push([c, dg]);
used += 1;
}
for (let k = 0; k < plan05.length; k += 1) {
script.push({ t: 0.25 + k * ENTRY_GAP, f: entryAt(k) });
if (KINDS_05[k] === 1) {
script.push({ t: 0.25 + k * ENTRY_GAP + FIX_S, f: fixAt(k) });
}
}
LAP_S = 0.25 + plan05.length * ENTRY_GAP + 1.4;
}
function update(dt) { if (dt < 0) {
return;
} }
function draw() {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
const cf = conflicts(g);
const hot = [];
for (let i = 0; i < 81; i += 1) {
hot.push(false);
}
for (let q = 0; q < cf.length; q += 1) {
hot[cf[q][0]] = true;
hot[cf[q][1]] = true;
}
drawBoard(function (i) {
if (hot[i]) {
return ROSE;
}
const age = still ? 99 : sceneT - pt[i];
return g[i] !== 0 && !giv[i] && age < 0.9 ? mix(MINT, PLACED, age / 0.9) : null;
}, null);
if (cf.length === 0) {
unitPulse(MINT, 0.22, 0.9);
}
const per = [0, 0, 0];
for (let q = 0; q < cf.length; q += 1) {
const a = cf[q][0];
const b = cf[q][1];
const k = cf[q][2];
per[k] += 1;
tintUnit(unitIndex(k, a), ROSE, 0.2);
// 선을 먼저 긋고 숫자를 그 위에 다시 얹어 겹친 숫자가 보이게 한다
seg(cellCx(a), cellCy(a), cellCx(b), cellCy(b), ROSE, 1, 3);
text(String(g[a]), cellCx(a), cellCy(a) + 1, INK, 14, 800, 'center');
text(String(g[b]), cellCx(b), cellCy(b) + 1, INK, 14, 800, 'center');
}
panel(PX, BY - 5, PW, 223);
text('重なり', PX + 12, BY + 8, DIM, 11, 700, 'left');
text(fill('{a} 組', String(cf.length)), PX + 12, BY + 36, cf.length > 0 ? ROSE : MINT, 28, 800, 'left');
const names = ['行', '列', '箱'];
for (let k = 0; k < 3; k += 1) {
const y = BY + 62 + k * 22;
text(names[k], PX + 12, y + 7, NEU, 11, 700, 'left');
box(PX + 56, y, 130, 14, 4, [48, 44, 74], 1);
box(PX + 56, y, Math.min(130, per[k] * 44), 14, 4, ROSE, 1);
}
// 최근 입력 기록 — 규칙에 맞으면 체크, 어기면 가위표
const from = Math.max(0, entries.length - 4);
for (let k = from; k < entries.length; k += 1) {
const y = BY + 140 + (k - from) * 20;
const e = entries[k];
if (e.ok) {
seg(PX + 14, y + 6, PX + 18, y + 10, MINT, 1, 3);
seg(PX + 18, y + 10, PX + 25, y + 2, MINT, 1, 3);
}
else {
seg(PX + 14, y + 2, PX + 24, y + 10, ROSE, 1, 3);
seg(PX + 24, y + 2, PX + 14, y + 10, ROSE, 1, 3);
}
text(String(e.d) + ' R' + (rowOf(e.i) + 1) + 'C' + (colOf(e.i) + 1), PX + 36, y + 6, NEU, 12, 700, 'left');
}
hud('conflicts', String(cf.length), cf.length / 4);
}
function statusText() {
return fill('入力 {a} 回、規則違反 {b} 回', String(enteredN), String(badSeen));
}
function modeId() { return modeBase(Math.min(3, Math.floor(enteredN / 3))); }
function speakText() {
const cf = conflicts(g);
const m = cf.length > 0 ? fill(SPK_BAD, String(g[cf[0][0]]), String(cf.length)) + '. ' : '';
return m + speakBoard();
}
function onPtr(kind, x, y) { if (kind < 0 || x < 0 || y < 0) {
return;
} }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// ConflictPeerMark - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./ConflictPeerMark.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ConflictPeerMark() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [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 easeOut(k: number): number { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const SKY: RGB = [150, 192, 244];
const SUN: RGB = [250, 218, 146];
const NEU: RGB = [236, 230, 250];
const BGC: RGB = [46, 42, 68];
const PANEL: RGB = [62, 57, 92];
const DIM: RGB = [196, 190, 224];
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) + ')';
}
function hsl(h: number, s: number, l: number): RGB {
const a = s * Math.min(l, 1 - l);
const f = function (n: number): number {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a: number, b: number): number {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s: string, a: string, b?: string, c?: string): string {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v: number): string { return String(Math.round(v * 10) / 10); }
function group(v: number): string {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { o += ','; }
o += s.charAt(i);
}
return o;
}
// ── 스도쿠 모델 — 판은 81칸 숫자 배열(0 은 빈칸). 후보는 9비트 한 수: 숫자 d 는 (d-1) 번째 비트
const ALL = 511;
function bit(d: number): number { return 1 << (d - 1); }
function popcount(m: number): number {
let c = 0;
let v = m;
while (v) { v &= v - 1; c += 1; }
return c;
}
// 가장 낮은 비트 하나가 가리키는 숫자
function lowDigit(m: number): number { return 32 - Math.clz32(m & -m); }
function bin9(m: number): string {
let s = '';
for (let d = 9; d >= 1; d -= 1) { s += (m & bit(d)) ? '1' : '0'; }
return s;
}
function rowOf(i: number): number { return Math.floor(i / 9); }
function colOf(i: number): number { return i % 9; }
function boxOf(i: number): number { return Math.floor(i / 27) * 3 + Math.floor((i % 9) / 3); }
// 묶음 27개 — 줄 9 · 세로줄 9 · 상자 9. 칸의 이웃은 같은 묶음 세 곳의 다른 칸 20개
const UNITS: number[][] = [];
const PEERS: number[][] = [];
for (let k = 0; k < 27; k += 1) { UNITS.push([]); }
for (let i = 0; i < 81; i += 1) {
UNITS[rowOf(i)].push(i);
UNITS[9 + colOf(i)].push(i);
UNITS[18 + boxOf(i)].push(i);
}
for (let i = 0; i < 81; i += 1) {
const ps: number[] = [];
for (let j = 0; j < 81; j += 1) {
if (j !== i && (rowOf(j) === rowOf(i) || colOf(j) === colOf(i) || boxOf(j) === boxOf(i))) { ps.push(j); }
}
PEERS.push(ps);
}
// 묶음 종류 kind: 0 줄 · 1 세로줄 · 2 상자
function unitIndex(kind: number, i: number): number { return kind === 0 ? rowOf(i) : (kind === 1 ? 9 + colOf(i) : 18 + boxOf(i)); }
function sharedKind(i: number, j: number): number {
if (rowOf(i) === rowOf(j)) { return 0; }
if (colOf(i) === colOf(j)) { return 1; }
return boxOf(i) === boxOf(j) ? 2 : -1;
}
// 칸 i 가 속한 kind 묶음에서 i 를 뺀 칸들이 이미 쓴 숫자의 비트 합(OR)
function usedMask(b: number[], i: number, kind: number): number {
const u = UNITS[unitIndex(kind, i)];
let m = 0;
for (let k = 0; k < 9; k += 1) { if (u[k] !== i && b[u[k]] !== 0) { m |= bit(b[u[k]]); } }
return m;
}
// 후보 = 9비트 전체에서 세 묶음이 쓴 비트를 뺀 것. 채워진 칸의 후보는 0
function candMask(b: number[], i: number): number {
if (b[i] !== 0) { return 0; }
return ALL & ~(usedMask(b, i, 0) | usedMask(b, i, 1) | usedMask(b, i, 2));
}
function filledOf(b: number[]): number {
let c = 0;
for (let i = 0; i < 81; i += 1) { if (b[i] !== 0) { c += 1; } }
return c;
}
function perm(n: number, seed: number): number[] {
const a: number[] = [];
for (let i = 0; i < n; i += 1) { a.push(i); }
for (let i = n - 1; i > 0; i -= 1) {
const j = Math.floor(hash2(i, seed) * (i + 1));
const t = a[i];
a[i] = a[j];
a[j] = t;
}
return a;
}
// 완성판 — 기본 무늬를 숫자·줄·상자 묶음 순서로 섞는다. 같은 씨앗이면 같은 판
function fullBoard(seed: number): number[] {
const dp = perm(9, seed * 7 + 1);
const bp = perm(3, seed * 7 + 2);
const sp = perm(3, seed * 7 + 3);
const rows: number[] = [];
const cols: number[] = [];
for (let k = 0; k < 3; k += 1) {
const rp = perm(3, seed * 13 + k * 5 + 4);
const cp = perm(3, seed * 13 + k * 5 + 9);
for (let j = 0; j < 3; j += 1) {
rows.push(bp[k] * 3 + rp[j]);
cols.push(sp[k] * 3 + cp[j]);
}
}
const b: number[] = [];
for (let r = 0; r < 9; r += 1) {
for (let c = 0; c < 9; c += 1) {
b.push(dp[(3 * (rows[r] % 3) + Math.floor(rows[r] / 3) + cols[c]) % 9] + 1);
}
}
return b;
}
// 풀이 탐색 — 가장 후보가 적은 칸(plain 이면 앞쪽 빈칸)부터 숫자를 걸고, 막히면 되돌린다.
// count 가 limit 에 닿으면 멈추고(풀이 개수는 limit 까지만 센다), 걸어 본 숫자가 cap 을 넘으면 멈춘다.
// trace 에는 (종류 << 16) | (칸 << 4) | 숫자 — 종류 0 은 걸기, 1 은 되돌리기
type Srch = { count: number; nodes: number; dead: number; branch: number; capHit: boolean; first: number[]; trace: number[] };
function search(start: number[], limit: number, cap: number, plain: boolean, wantTrace: boolean): Srch {
const b = start.slice();
const rm: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const cm: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const xm: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
rm[rowOf(i)] |= bit(b[i]);
cm[colOf(i)] |= bit(b[i]);
xm[boxOf(i)] |= bit(b[i]);
}
}
const r: Srch = { count: 0, nodes: 0, dead: 0, branch: 0, capHit: false, first: [], trace: [] };
function rec(): void {
let best = -1;
let bm = 0;
let bc = 10;
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) { continue; }
const m = ALL & ~(rm[rowOf(i)] | cm[colOf(i)] | xm[boxOf(i)]);
const pc = popcount(m);
if (plain) { best = i; bm = m; bc = pc; break; }
if (pc < bc) { best = i; bm = m; bc = pc; if (pc <= 1) { break; } }
}
if (best < 0) {
r.count += 1;
if (r.count === 1) { r.first = b.slice(); }
return;
}
if (bc === 0) { r.dead += 1; return; }
if (bc > 1) { r.branch += 1; }
let mm = bm;
while (mm) {
const lo = mm & -mm;
mm ^= lo;
const d = 32 - Math.clz32(lo);
r.nodes += 1;
if (r.nodes > cap) { r.capHit = true; return; }
b[best] = d;
rm[rowOf(best)] |= lo;
cm[colOf(best)] |= lo;
xm[boxOf(best)] |= lo;
if (wantTrace) { r.trace.push((best << 4) | d); }
rec();
if (r.count >= limit || r.capHit) { return; }
b[best] = 0;
rm[rowOf(best)] &= ~lo;
cm[colOf(best)] &= ~lo;
xm[boxOf(best)] &= ~lo;
if (wantTrace) { r.trace.push((1 << 16) | (best << 4) | d); }
}
}
rec();
return r;
}
// 하나뿐인 후보 채우기 — 후보가 하나인 칸(번호가 가장 앞선 칸)을 채우고 다시 센다. 더 없으면 멈춘다
type Singles = { order: number[]; digits: number[]; avail: number[]; solved: boolean; board: number[] };
function singlesFill(start: number[]): Singles {
const b = start.slice();
const order: number[] = [];
const digits: number[] = [];
const avail: number[] = [];
for (;;) {
let first = -1;
let n = 0;
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0 && popcount(candMask(b, i)) === 1) {
if (first < 0) { first = i; }
n += 1;
}
}
if (first < 0) { break; }
const d = lowDigit(candMask(b, first));
b[first] = d;
order.push(first);
digits.push(d);
avail.push(n);
}
let solved = true;
for (let i = 0; i < 81; i += 1) { if (b[i] === 0) { solved = false; } }
return { order: order, digits: digits, avail: avail, solved: solved, board: b };
}
// 구멍 파기 — 완성판에서 칸을 해시 순서로 하나씩 비우고 풀이 개수를 2 까지만 센다.
// 하나뿐이면 비운 채로 두고, 둘 이상이면 되돌린다. singlesOnly 이면 하나뿐인 후보만으로 풀리는 판만 남긴다
type DigStep = { cell: number; cnt: number; ok: boolean; nodes: number };
type Dug = { puz: number[]; steps: DigStep[]; clues: number };
function digBoard(sol: number[], seed: number, target: number, singlesOnly: boolean): Dug {
const puz = sol.slice();
const order = perm(81, seed * 31 + 5);
const steps: DigStep[] = [];
let clues = 81;
for (let k = 0; k < 81; k += 1) {
if (clues <= target) { break; }
const c = order[k];
const d = puz[c];
puz[c] = 0;
const s = search(puz, 2, 6000, false, false);
const cnt = s.capHit ? 2 : Math.min(2, s.count);
const ok = cnt === 1 && (!singlesOnly || singlesFill(puz).solved);
steps.push({ cell: c, cnt: cnt, ok: ok, nodes: s.nodes });
if (ok) { clues -= 1; } else { puz[c] = d; }
}
return { puz: puz, steps: steps, clues: clues };
}
// 놓은 숫자가 겹치는 쌍 — [칸, 칸, 묶음 종류]
function conflicts(b: number[]): number[][] {
const out: number[][] = [];
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0) { continue; }
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (p > i && b[p] === b[i]) { out.push([i, p, sharedKind(i, p)]); }
}
}
return out;
}
// ── 판 상태 — 장면이 아니라 판이 들고 있는 것. 화면은 이 값을 읽어 그린다
const BX = 22;
const BY = 44;
const CS = 23;
const GAP = 3;
const PX = 258;
const PW = 210;
let g: number[] = [];
let giv: boolean[] = [];
let sol: number[] = [];
let pt: number[] = [];
let cur = 40;
let solvedT = -1;
let lastCell = -1;
type Rip = { i: number; t: number; list: number[]; d: number };
let rip: Rip = { i: -1, t: -9, list: [], d: 0 };
let selDigit = 0;
let still = false;
let hintHook = null as (() => void) | null;
function cellX(i: number): number { const c = colOf(i); return BX + c * CS + Math.floor(c / 3) * GAP; }
function cellY(i: number): number { const r = rowOf(i); return BY + r * CS + Math.floor(r / 3) * GAP; }
function cellCx(i: number): number { return cellX(i) + CS / 2; }
function cellCy(i: number): number { return cellY(i) + CS / 2; }
function cellAt(x: number, y: number): number {
let c = -1;
let r = -1;
for (let k = 0; k < 9; k += 1) {
const x0 = BX + k * CS + Math.floor(k / 3) * GAP;
const y0 = BY + k * CS + Math.floor(k / 3) * GAP;
if (x >= x0 && x < x0 + CS) { c = k; }
if (y >= y0 && y < y0 + CS) { r = k; }
}
return c < 0 || r < 0 ? -1 : r * 9 + c;
}
function setBoard(puz: number[], solution: number[]): void {
g = puz.slice();
sol = solution.slice();
giv = [];
pt = [];
for (let i = 0; i < 81; i += 1) { giv.push(puz[i] !== 0); pt.push(-9); }
solvedT = -1;
lastCell = -1;
rip = { i: -1, t: -9, list: [], d: 0 };
}
function solvedNow(): boolean { return filledOf(g) === 81 && conflicts(g).length === 0; }
function checkSolved(): void {
if (solvedNow()) { if (solvedT < 0) { solvedT = sceneT; } } else { solvedT = -1; }
}
// 숫자를 놓는다 — 놓기 전에 그 숫자가 후보에 있던 이웃 칸을 기억해 두면 소거가 번지는 모습을 그릴 수 있다
function putDigit(i: number, d: number): boolean {
if (giv[i] || d < 1 || d > 9) { return false; }
const list: number[] = [];
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (g[p] === 0 && (candMask(g, p) & bit(d)) !== 0) { list.push(p); }
}
g[i] = d;
pt[i] = sceneT;
rip = { i: i, t: sceneT, list: list, d: d };
lastCell = i;
checkSolved();
return true;
}
function clearDigit(i: number): void {
if (giv[i] || g[i] === 0) { return; }
g[i] = 0;
pt[i] = -9;
checkSolved();
}
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)]; }
// ── 그리기 도구
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 doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 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 disc(x: number, y: number, r: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, c: RGB, a: number, w: number): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x: number, y: number, w: number, h: number): void { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name: string, val: string, frac: number): void {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// ── 판 그리기 — 칸 색은 장면이 정하고, 숫자는 판이 그린다
const EMPTY: RGB = [58, 53, 88];
const PLACED: RGB = [190, 214, 246];
const PLACED_INK: RGB = [28, 54, 118];
function ring(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number, lw: number): void {
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = lw;
ctx.stroke();
}
function drawBoard(fillOf: (i: number) => RGB | null, extra: ((i: number, x: number, y: number) => void) | null): void {
const s = 9 * CS + 2 * GAP;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < 81; i += 1) {
const x = cellX(i);
const y = cellY(i);
let f: RGB | null = fillOf(i);
if (f === null) { f = g[i] === 0 ? EMPTY : (giv[i] ? NEU : PLACED); }
box(x + 1, y + 1, CS - 2, CS - 2, 4, f, 1);
if (g[i] !== 0) {
const a = still ? 99 : sceneT - pt[i];
const k = a < 0.3 ? 0.7 + 0.3 * easeOut(a / 0.3) : 1;
text(String(g[i]), x + CS / 2, y + CS / 2 + 1, giv[i] ? INK : PLACED_INK, Math.round(14 * k), giv[i] ? 800 : 700, 'center');
} else if (extra !== null) {
extra(i, x, y);
}
}
if (!demo) { ring(cellX(cur) + 0.5, cellY(cur) + 0.5, CS - 1, CS - 1, 4, PAPER, 1, 2); }
}
// 묶음 하나를 옅게 물들인다
function tintUnit(u: number, c: RGB, a: number): void {
for (let k = 0; k < 9; k += 1) {
const i = UNITS[u][k];
box(cellX(i) + 1, cellY(i) + 1, CS - 2, CS - 2, 4, c, a);
// 물들임이 숫자를 흐리지 않도록 숫자를 다시 얹는다
if (g[i] !== 0) { text(String(g[i]), cellCx(i), cellCy(i) + 1, giv[i] ? INK : PLACED_INK, 14, giv[i] ? 800 : 700, 'center'); }
}
}
// 후보 비트 줄 — 숫자 1~9 칸 아홉 개, 켜진 비트만 밝다
function bitRow(x: number, y: number, m: number, on: RGB, off: RGB): void {
for (let d = 1; d <= 9; d += 1) {
const lit = (m & bit(d)) !== 0;
const bx = x + (d - 1) * 18;
box(bx, y, 16, 16, 4, lit ? on : off, 1);
text(String(d), bx + 8, y + 8.5, lit ? INK : DIM, 10, 700, 'center');
}
}
// ── 상태 줄·낭독 문장
const SPK_FILL = '{a}칸 중 {b}칸이 채워져 있다';
const SPK_DONE = '판 완성, {a}칸이 모두 채워졌다';
function speakBoard(): string {
return solvedNow() ? fill(SPK_DONE, '81') : fill(SPK_FILL, '81', String(filledOf(g)));
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 100 + 채운 칸 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode: number): number {
if (solvedNow()) { return 1000; }
return manual ? 100 + filledOf(g) : sceneMode;
}
// 방금 놓은 칸의 줄·세로줄·상자 세 묶음을 한 번 물들였다 서서히 빼는 번짐 — 숫자 하나가 어디까지 영향을 주는지 보인다
function unitPulse(c: RGB, a: number, dur: number): void {
if (rip.i < 0 || still) { return; }
const k = (sceneT - rip.t) / dur;
if (k < 0 || k >= 1) { return; }
for (let kind = 0; kind < 3; kind += 1) { tintUnit(unitIndex(kind, rip.i), c, a * (1 - k)); }
}
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 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
}
function takeOver(): void {
if (manual) { return; }
manual = true;
wake();
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say(): void {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
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);
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow(): void {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) { say(); }
}
}
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;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest(): void {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak: boolean): void {
if (paused || still) { sceneT += 1; }
update(0);
showNow();
if (speak) { say(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
say();
}
function restart(): void {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// 새 판 — 씨앗이 달라진 판으로 시간표를 처음부터. 손으로 하던 중이어도 자동 시연으로 돌아간다
function newBoard(): void {
manual = false;
lap += 1;
begin();
update(0);
showNow();
say();
}
function moveCur(dx: number, dy: number): void {
takeOver();
cur = clamp(rowOf(cur) + dy, 0, 8) * 9 + clamp(colOf(cur) + dx, 0, 8);
afterInput(true);
}
// 힌트 기본 — 고른 빈칸에 정답 숫자를 놓는다. 장면이 hintHook 을 걸어 두면 그쪽이 먼저
function hintDefault(): void {
if (!giv[cur] && g[cur] === 0) { putDigit(cur, sol[cur]); }
}
function toCanvas(e: PointerEvent): number[] {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 칸 이동, 1~9 = 숫자 입력, Delete·Backspace·0 = 지우기, H = 힌트,
// N = 새 판, R = 처음부터, Enter·Space = 일시정지
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'r' || k === 'R') {
restart();
} else if (k === 'n' || k === 'N') {
e.preventDefault();
newBoard();
} else if (k === 'ArrowLeft') {
e.preventDefault();
moveCur(-1, 0);
} else if (k === 'ArrowRight') {
e.preventDefault();
moveCur(1, 0);
} else if (k === 'ArrowUp') {
e.preventDefault();
moveCur(0, -1);
} else if (k === 'ArrowDown') {
e.preventDefault();
moveCur(0, 1);
} else if (k.length === 1 && k >= '1' && k <= '9') {
e.preventDefault();
takeOver();
selDigit = Number(k);
putDigit(cur, selDigit);
afterInput(true);
} else if (k === 'Delete' || k === 'Backspace' || k === '0') {
e.preventDefault();
takeOver();
clearDigit(cur);
afterInput(true);
} else if (k === 'h' || k === 'H') {
e.preventDefault();
takeOver();
if (hintHook !== null) { hintHook(); } else { hintDefault(); }
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
ptrDown = true;
try { pad.setPointerCapture(e.pointerId); } catch (err) { ptrDown = true; }
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) { cur = c; }
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (!ptrDown) { return; }
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) { cur = c; }
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e: PointerEvent): void {
if (!ptrDown) { return; }
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { 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 — 놓은 숫자가 같은 줄·세로줄·상자에 이미 있으면 겹친 두 칸에 선을 잇고 그 묶음을 옅게 물들인다. 규칙을 어긴 것만 표시하고 풀 수 있는 판인지는 03번 탐색이 따로 가린다
const CLUES_05 = 40;
const ENTRY_GAP = 0.5;
const FIX_S = 0.42;
const KINDS_05 = [0, 1, 0, 1, 1, 0, 1, 0];
let LAP_S = 8;
let REST_S = 0.9;
type Entry = { i: number; d: number; ok: boolean };
let entries: Entry[] = [];
let plan05: number[][] = [];
let enteredN = 0;
let badSeen = 0;
const SPK_BAD = '겹친 숫자 {a}, 겹침 {b}쌍';
function entryAt(k: number): () => void {
return function (): void {
const c = plan05[k][0];
const d = plan05[k][1];
putDigit(c, d);
enteredN += 1;
let bad = false;
const cf = conflicts(g);
for (let q = 0; q < cf.length; q += 1) { if (cf[q][0] === c || cf[q][1] === c) { bad = true; } }
entries.push({ i: c, d: d, ok: !bad });
if (bad) { badSeen += 1; }
};
}
function fixAt(k: number): () => void {
return function (): void { clearDigit(plan05[k][0]); };
}
function build(): void {
entries = [];
enteredN = 0;
badSeen = 0;
const s = fullBoard(51 + lap);
const d = digBoard(s, 61 + lap, CLUES_05, false);
setBoard(d.puz, s);
plan05 = [];
const order = perm(81, 17 + lap);
let used = 0;
for (let q = 0; q < 81 && used < KINDS_05.length; q += 1) {
const c = order[q];
if (g[c] !== 0) { continue; }
let dg = sol[c];
if (KINDS_05[used] === 1) {
const seen: number[] = [];
for (let k = 0; k < 20; k += 1) {
const v = g[PEERS[c][k]];
if (v !== 0 && v !== sol[c] && seen.indexOf(v) < 0) { seen.push(v); }
}
dg = seen[Math.floor(hash2(c, 3 + lap) * seen.length)];
}
plan05.push([c, dg]);
used += 1;
}
for (let k = 0; k < plan05.length; k += 1) {
script.push({ t: 0.25 + k * ENTRY_GAP, f: entryAt(k) });
if (KINDS_05[k] === 1) { script.push({ t: 0.25 + k * ENTRY_GAP + FIX_S, f: fixAt(k) }); }
}
LAP_S = 0.25 + plan05.length * ENTRY_GAP + 1.4;
}
function update(dt: number): void { if (dt < 0) { return; } }
function draw(): void {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
const cf = conflicts(g);
const hot: boolean[] = [];
for (let i = 0; i < 81; i += 1) { hot.push(false); }
for (let q = 0; q < cf.length; q += 1) { hot[cf[q][0]] = true; hot[cf[q][1]] = true; }
drawBoard(function (i: number): RGB | null {
if (hot[i]) { return ROSE; }
const age = still ? 99 : sceneT - pt[i];
return g[i] !== 0 && !giv[i] && age < 0.9 ? mix(MINT, PLACED, age / 0.9) : null;
}, null);
if (cf.length === 0) { unitPulse(MINT, 0.22, 0.9); }
const per = [0, 0, 0];
for (let q = 0; q < cf.length; q += 1) {
const a = cf[q][0];
const b = cf[q][1];
const k = cf[q][2];
per[k] += 1;
tintUnit(unitIndex(k, a), ROSE, 0.2);
// 선을 먼저 긋고 숫자를 그 위에 다시 얹어 겹친 숫자가 보이게 한다
seg(cellCx(a), cellCy(a), cellCx(b), cellCy(b), ROSE, 1, 3);
text(String(g[a]), cellCx(a), cellCy(a) + 1, INK, 14, 800, 'center');
text(String(g[b]), cellCx(b), cellCy(b) + 1, INK, 14, 800, 'center');
}
panel(PX, BY - 5, PW, 223);
text('겹침', PX + 12, BY + 8, DIM, 11, 700, 'left');
text(fill('{a}쌍', String(cf.length)), PX + 12, BY + 36, cf.length > 0 ? ROSE : MINT, 28, 800, 'left');
const names = ['행', '열', '상자'];
for (let k = 0; k < 3; k += 1) {
const y = BY + 62 + k * 22;
text(names[k], PX + 12, y + 7, NEU, 11, 700, 'left');
box(PX + 56, y, 130, 14, 4, [48, 44, 74], 1);
box(PX + 56, y, Math.min(130, per[k] * 44), 14, 4, ROSE, 1);
}
// 최근 입력 기록 — 규칙에 맞으면 체크, 어기면 가위표
const from = Math.max(0, entries.length - 4);
for (let k = from; k < entries.length; k += 1) {
const y = BY + 140 + (k - from) * 20;
const e = entries[k];
if (e.ok) {
seg(PX + 14, y + 6, PX + 18, y + 10, MINT, 1, 3);
seg(PX + 18, y + 10, PX + 25, y + 2, MINT, 1, 3);
} else {
seg(PX + 14, y + 2, PX + 24, y + 10, ROSE, 1, 3);
seg(PX + 24, y + 2, PX + 14, y + 10, ROSE, 1, 3);
}
text(String(e.d) + ' R' + (rowOf(e.i) + 1) + 'C' + (colOf(e.i) + 1), PX + 36, y + 6, NEU, 12, 700, 'left');
}
hud('conflicts', String(cf.length), cf.length / 4);
}
function statusText(): string {
return fill('입력 {a}번, 규칙 위반 {b}번', String(enteredN), String(badSeen));
}
function modeId(): number { return modeBase(Math.min(3, Math.floor(enteredN / 3))); }
function speakText(): string {
const cf = conflicts(g);
const m = cf.length > 0 ? fill(SPK_BAD, String(g[cf[0][0]]), String(cf.length)) + '. ' : '';
return m + speakBoard();
}
function onPtr(kind: number, x: number, y: number): void { if (kind < 0 || x < 0 || y < 0) { 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={'스도쿠 시연 무대 — 방향키로 칸 이동, 1부터 9 숫자키로 입력, Delete 로 지우기, H 로 힌트, N 으로 새 판, R 로 처음부터, Enter 또는 Space 로 일시정지'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'9×9 판에 숫자 여덟 개가 입력된다. 규칙에 맞으면 그대로 두고, 같은 줄·세로줄·상자에 같은 숫자가 이미 있으면 겹친 두 칸이 붉게 물들고 두 칸을 잇는 선이 그려지며 그 묶음 전체가 옅게 물든다. 잠시 뒤 잘못 놓은 숫자는 지워진다. 오른쪽 패널에는 겹침 쌍 수와 행·열·상자별 막대, 최근 입력 네 건의 체크와 가위표가 나온다. 아래 눈금은 상수 이름과 지금 값을 보이고, 움직임 줄이기에서는 숫자가 바로 채워진 판 한 장만 그려진다'}></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>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}05 · 同じ行・箱で重なった数字を示す衝突の表示 — 計測 累積面積 17.101% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 置いた数字が重なる組 — [マス, マス, 組の種類]
function conflicts(b) {
const out = [];
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0) {
continue;
}
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (p > i && b[p] === b[i]) {
out.push([i, p, sharedKind(i, p)]);
}
}
}
return out;
}
盤ごとに入力8回のうち規則違反は4回で、間違った入力は0.42秒後に消えます。
06理由まで見せるヒント — どの行が候補を消したか
ヒントが答えの数字だけを知らせず、そのマスの候補を消した行・列・箱を順に光らせるので、なぜその数字なのかを学ばせる画面に使います。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="数独の舞台 — 矢印キーでマス移動、数字キー1〜9で入力、Deleteで消去、Hでヒント、Nで新しい盤、Rで最初から、EnterかSpaceで一時停止">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="9×9の盤で候補がひとつだけのマスが黄色い枠で示され、そのマスの候補を消した行・列・箱が順に黄色く染まり、その組ですでに置かれた数字のマスが赤く光って線でつながれる。右のパネルには数字1から9の候補の列があり、消えた数字の下に根拠の名前(行・列・箱)が付き、最後に残った数字がミント色でマスに現れる。Hキーで選んだマスのヒントをもう一度見られる。下の目盛りは定数名と今の値を示し、動きを減らす設定では数字がすぐ埋まった盤一枚だけを描きます"></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>
</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-ink;
}
// 무대 상자 — 눌러서 한 번 깨뜨리는 곳(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-cream;
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 {
padding: $sp-1 $sp-3;
border: 2px solid $subject-cream;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
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__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-ink;
color: $subject-cream;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면에만 있고 aria-live 는 늘 off
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 100px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
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__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 글리치 없는 원문 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause: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: #17141a;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #fff7e6;
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 {
padding: 4px 12px;
border: 2px solid #fff7e6;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
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__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 100px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
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__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause: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 easeOut(k) { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const SKY = [150, 192, 244];
const SUN = [250, 218, 146];
const NEU = [236, 230, 250];
const BGC = [46, 42, 68];
const PANEL = [62, 57, 92];
const DIM = [196, 190, 224];
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) + ')';
}
function hsl(h, s, l) {
const a = s * Math.min(l, 1 - l);
const f = function (n) {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a, b) {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s, a, b, c) {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v) { return String(Math.round(v * 10) / 10); }
function group(v) {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
o += ',';
}
o += s.charAt(i);
}
return o;
}
// ── 스도쿠 모델 — 판은 81칸 숫자 배열(0 은 빈칸). 후보는 9비트 한 수: 숫자 d 는 (d-1) 번째 비트
const ALL = 511;
function bit(d) { return 1 << (d - 1); }
function popcount(m) {
let c = 0;
let v = m;
while (v) {
v &= v - 1;
c += 1;
}
return c;
}
// 가장 낮은 비트 하나가 가리키는 숫자
function lowDigit(m) { return 32 - Math.clz32(m & -m); }
function bin9(m) {
let s = '';
for (let d = 9; d >= 1; d -= 1) {
s += (m & bit(d)) ? '1' : '0';
}
return s;
}
function rowOf(i) { return Math.floor(i / 9); }
function colOf(i) { return i % 9; }
function boxOf(i) { return Math.floor(i / 27) * 3 + Math.floor((i % 9) / 3); }
// 묶음 27개 — 줄 9 · 세로줄 9 · 箱 9. 칸의 이웃은 같은 묶음 세 곳의 다른 칸 20개
const UNITS = [];
const PEERS = [];
for (let k = 0; k < 27; k += 1) {
UNITS.push([]);
}
for (let i = 0; i < 81; i += 1) {
UNITS[rowOf(i)].push(i);
UNITS[9 + colOf(i)].push(i);
UNITS[18 + boxOf(i)].push(i);
}
for (let i = 0; i < 81; i += 1) {
const ps = [];
for (let j = 0; j < 81; j += 1) {
if (j !== i && (rowOf(j) === rowOf(i) || colOf(j) === colOf(i) || boxOf(j) === boxOf(i))) {
ps.push(j);
}
}
PEERS.push(ps);
}
// 묶음 종류 kind: 0 줄 · 1 세로줄 · 2 箱
function unitIndex(kind, i) { return kind === 0 ? rowOf(i) : (kind === 1 ? 9 + colOf(i) : 18 + boxOf(i)); }
function sharedKind(i, j) {
if (rowOf(i) === rowOf(j)) {
return 0;
}
if (colOf(i) === colOf(j)) {
return 1;
}
return boxOf(i) === boxOf(j) ? 2 : -1;
}
// 칸 i 가 속한 kind 묶음에서 i 를 뺀 칸들이 이미 쓴 숫자의 비트 合計(OR)
function usedMask(b, i, kind) {
const u = UNITS[unitIndex(kind, i)];
let m = 0;
for (let k = 0; k < 9; k += 1) {
if (u[k] !== i && b[u[k]] !== 0) {
m |= bit(b[u[k]]);
}
}
return m;
}
// 候補 = 9비트 전체에서 세 묶음이 쓴 비트를 뺀 것. 채워진 칸의 후보는 0
function candMask(b, i) {
if (b[i] !== 0) {
return 0;
}
return ALL & ~(usedMask(b, i, 0) | usedMask(b, i, 1) | usedMask(b, i, 2));
}
function filledOf(b) {
let c = 0;
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
c += 1;
}
}
return c;
}
function perm(n, seed) {
const a = [];
for (let i = 0; i < n; i += 1) {
a.push(i);
}
for (let i = n - 1; i > 0; i -= 1) {
const j = Math.floor(hash2(i, seed) * (i + 1));
const t = a[i];
a[i] = a[j];
a[j] = t;
}
return a;
}
// 완성판 — 기본 무늬를 숫자·줄·箱 묶음 순서로 섞는다. 같은 씨앗이면 같은 판
function fullBoard(seed) {
const dp = perm(9, seed * 7 + 1);
const bp = perm(3, seed * 7 + 2);
const sp = perm(3, seed * 7 + 3);
const rows = [];
const cols = [];
for (let k = 0; k < 3; k += 1) {
const rp = perm(3, seed * 13 + k * 5 + 4);
const cp = perm(3, seed * 13 + k * 5 + 9);
for (let j = 0; j < 3; j += 1) {
rows.push(bp[k] * 3 + rp[j]);
cols.push(sp[k] * 3 + cp[j]);
}
}
const b = [];
for (let r = 0; r < 9; r += 1) {
for (let c = 0; c < 9; c += 1) {
b.push(dp[(3 * (rows[r] % 3) + Math.floor(rows[r] / 3) + cols[c]) % 9] + 1);
}
}
return b;
}
function search(start, limit, cap, plain, wantTrace) {
const b = start.slice();
const rm = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const cm = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const xm = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
rm[rowOf(i)] |= bit(b[i]);
cm[colOf(i)] |= bit(b[i]);
xm[boxOf(i)] |= bit(b[i]);
}
}
const r = { count: 0, nodes: 0, dead: 0, branch: 0, capHit: false, first: [], trace: [] };
function rec() {
let best = -1;
let bm = 0;
let bc = 10;
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
continue;
}
const m = ALL & ~(rm[rowOf(i)] | cm[colOf(i)] | xm[boxOf(i)]);
const pc = popcount(m);
if (plain) {
best = i;
bm = m;
bc = pc;
break;
}
if (pc < bc) {
best = i;
bm = m;
bc = pc;
if (pc <= 1) {
break;
}
}
}
if (best < 0) {
r.count += 1;
if (r.count === 1) {
r.first = b.slice();
}
return;
}
if (bc === 0) {
r.dead += 1;
return;
}
if (bc > 1) {
r.branch += 1;
}
let mm = bm;
while (mm) {
const lo = mm & -mm;
mm ^= lo;
const d = 32 - Math.clz32(lo);
r.nodes += 1;
if (r.nodes > cap) {
r.capHit = true;
return;
}
b[best] = d;
rm[rowOf(best)] |= lo;
cm[colOf(best)] |= lo;
xm[boxOf(best)] |= lo;
if (wantTrace) {
r.trace.push((best << 4) | d);
}
rec();
if (r.count >= limit || r.capHit) {
return;
}
b[best] = 0;
rm[rowOf(best)] &= ~lo;
cm[colOf(best)] &= ~lo;
xm[boxOf(best)] &= ~lo;
if (wantTrace) {
r.trace.push((1 << 16) | (best << 4) | d);
}
}
}
rec();
return r;
}
function singlesFill(start) {
const b = start.slice();
const order = [];
const digits = [];
const avail = [];
for (;;) {
let first = -1;
let n = 0;
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0 && popcount(candMask(b, i)) === 1) {
if (first < 0) {
first = i;
}
n += 1;
}
}
if (first < 0) {
break;
}
const d = lowDigit(candMask(b, first));
b[first] = d;
order.push(first);
digits.push(d);
avail.push(n);
}
let solved = true;
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0) {
solved = false;
}
}
return { order: order, digits: digits, avail: avail, solved: solved, board: b };
}
function digBoard(sol, seed, target, singlesOnly) {
const puz = sol.slice();
const order = perm(81, seed * 31 + 5);
const steps = [];
let clues = 81;
for (let k = 0; k < 81; k += 1) {
if (clues <= target) {
break;
}
const c = order[k];
const d = puz[c];
puz[c] = 0;
const s = search(puz, 2, 6000, false, false);
const cnt = s.capHit ? 2 : Math.min(2, s.count);
const ok = cnt === 1 && (!singlesOnly || singlesFill(puz).solved);
steps.push({ cell: c, cnt: cnt, ok: ok, nodes: s.nodes });
if (ok) {
clues -= 1;
}
else {
puz[c] = d;
}
}
return { puz: puz, steps: steps, clues: clues };
}
// 놓은 숫자가 겹치는 쌍 — [칸, 칸, 묶음 종류]
function conflicts(b) {
const out = [];
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0) {
continue;
}
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (p > i && b[p] === b[i]) {
out.push([i, p, sharedKind(i, p)]);
}
}
}
return out;
}
// ── 판 상태 — 장면이 아니라 판이 들고 있는 것. 화면은 이 값을 읽어 그린다
const BX = 22;
const BY = 44;
const CS = 23;
const GAP = 3;
const PX = 258;
const PW = 210;
let g = [];
let giv = [];
let sol = [];
let pt = [];
let cur = 40;
let solvedT = -1;
let lastCell = -1;
let rip = { i: -1, t: -9, list: [], d: 0 };
let selDigit = 0;
let still = false;
let hintHook = null;
function cellX(i) { const c = colOf(i); return BX + c * CS + Math.floor(c / 3) * GAP; }
function cellY(i) { const r = rowOf(i); return BY + r * CS + Math.floor(r / 3) * GAP; }
function cellCx(i) { return cellX(i) + CS / 2; }
function cellCy(i) { return cellY(i) + CS / 2; }
function cellAt(x, y) {
let c = -1;
let r = -1;
for (let k = 0; k < 9; k += 1) {
const x0 = BX + k * CS + Math.floor(k / 3) * GAP;
const y0 = BY + k * CS + Math.floor(k / 3) * GAP;
if (x >= x0 && x < x0 + CS) {
c = k;
}
if (y >= y0 && y < y0 + CS) {
r = k;
}
}
return c < 0 || r < 0 ? -1 : r * 9 + c;
}
function setBoard(puz, solution) {
g = puz.slice();
sol = solution.slice();
giv = [];
pt = [];
for (let i = 0; i < 81; i += 1) {
giv.push(puz[i] !== 0);
pt.push(-9);
}
solvedT = -1;
lastCell = -1;
rip = { i: -1, t: -9, list: [], d: 0 };
}
function solvedNow() { return filledOf(g) === 81 && conflicts(g).length === 0; }
function checkSolved() {
if (solvedNow()) {
if (solvedT < 0) {
solvedT = sceneT;
}
}
else {
solvedT = -1;
}
}
// 숫자를 놓는다 — 놓기 전에 그 숫자가 후보에 있던 이웃 칸을 기억해 두면 소거가 번지는 모습을 그릴 수 있다
function putDigit(i, d) {
if (giv[i] || d < 1 || d > 9) {
return false;
}
const list = [];
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (g[p] === 0 && (candMask(g, p) & bit(d)) !== 0) {
list.push(p);
}
}
g[i] = d;
pt[i] = sceneT;
rip = { i: i, t: sceneT, list: list, d: d };
lastCell = i;
checkSolved();
return true;
}
function clearDigit(i) {
if (giv[i] || g[i] === 0) {
return;
}
g[i] = 0;
pt[i] = -9;
checkSolved();
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// ── 그리기 도구
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 doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 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 disc(x, y, r, c, a) {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, c, a, w) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s, x, y, c, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x, y, w, h) { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name, val, frac) {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// ── 판 그리기 — 칸 색은 장면이 정하고, 숫자는 판이 그린다
const EMPTY = [58, 53, 88];
const PLACED = [190, 214, 246];
const PLACED_INK = [28, 54, 118];
function ring(x, y, w, h, r, c, a, lw) {
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = lw;
ctx.stroke();
}
function drawBoard(fillOf, extra) {
const s = 9 * CS + 2 * GAP;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < 81; i += 1) {
const x = cellX(i);
const y = cellY(i);
let f = fillOf(i);
if (f === null) {
f = g[i] === 0 ? EMPTY : (giv[i] ? NEU : PLACED);
}
box(x + 1, y + 1, CS - 2, CS - 2, 4, f, 1);
if (g[i] !== 0) {
const a = still ? 99 : sceneT - pt[i];
const k = a < 0.3 ? 0.7 + 0.3 * easeOut(a / 0.3) : 1;
text(String(g[i]), x + CS / 2, y + CS / 2 + 1, giv[i] ? INK : PLACED_INK, Math.round(14 * k), giv[i] ? 800 : 700, 'center');
}
else if (extra !== null) {
extra(i, x, y);
}
}
if (!demo) {
ring(cellX(cur) + 0.5, cellY(cur) + 0.5, CS - 1, CS - 1, 4, PAPER, 1, 2);
}
}
// 묶음 하나를 옅게 물들인다
function tintUnit(u, c, a) {
for (let k = 0; k < 9; k += 1) {
const i = UNITS[u][k];
box(cellX(i) + 1, cellY(i) + 1, CS - 2, CS - 2, 4, c, a);
// 물들임이 숫자를 흐리지 않도록 숫자를 다시 얹는다
if (g[i] !== 0) {
text(String(g[i]), cellCx(i), cellCy(i) + 1, giv[i] ? INK : PLACED_INK, 14, giv[i] ? 800 : 700, 'center');
}
}
}
// 候補 비트 줄 — 숫자 1~9 칸 아홉 개, 켜진 비트만 밝다
function bitRow(x, y, m, on, off) {
for (let d = 1; d <= 9; d += 1) {
const lit = (m & bit(d)) !== 0;
const bx = x + (d - 1) * 18;
box(bx, y, 16, 16, 4, lit ? on : off, 1);
text(String(d), bx + 8, y + 8.5, lit ? INK : DIM, 10, 700, 'center');
}
}
// ── 상태 줄·낭독 문장
const SPK_FILL = '{a} マスのうち {b} マスが埋まっている';
const SPK_DONE = '盤が完成、{a} マスすべてが埋まった';
function speakBoard() {
return solvedNow() ? fill(SPK_DONE, '81') : fill(SPK_FILL, '81', String(filledOf(g)));
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 100 + 채운 칸 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode) {
if (solvedNow()) {
return 1000;
}
return manual ? 100 + filledOf(g) : sceneMode;
}
// 방금 놓은 칸의 줄·세로줄·箱 세 묶음을 한 번 물들였다 서서히 빼는 번짐 — 숫자 하나가 어디까지 영향을 주는지 보인다
function unitPulse(c, a, dur) {
if (rip.i < 0 || still) {
return;
}
const k = (sceneT - rip.t) / dur;
if (k < 0 || k >= 1) {
return;
}
for (let kind = 0; kind < 3; kind += 1) {
tintUnit(unitIndex(kind, rip.i), c, a * (1 - k));
}
}
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 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
}
function takeOver() {
if (manual) {
return;
}
manual = true;
wake();
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say() {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
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);
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow() {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) {
say();
}
}
}
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;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest() {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak) {
if (paused || still) {
sceneT += 1;
}
update(0);
showNow();
if (speak) {
say();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
say();
}
function restart() {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// 새 판 — 씨앗이 달라진 판으로 시간표를 처음부터. 손으로 하던 중이어도 자동 시연으로 돌아간다
function newBoard() {
manual = false;
lap += 1;
begin();
update(0);
showNow();
say();
}
function moveCur(dx, dy) {
takeOver();
cur = clamp(rowOf(cur) + dy, 0, 8) * 9 + clamp(colOf(cur) + dx, 0, 8);
afterInput(true);
}
// 힌트 기본 — 고른 빈칸에 정답 숫자를 놓는다. 장면이 hintHook 을 걸어 두면 그쪽이 먼저
function hintDefault() {
if (!giv[cur] && g[cur] === 0) {
putDigit(cur, sol[cur]);
}
}
function toCanvas(e) {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 칸 이동, 1~9 = 숫자 입력, Delete·Backspace·0 = 지우기, H = 힌트,
// N = 새 판, R = 처음부터, Enter·Space = 一時停止
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'r' || k === 'R') {
restart();
}
else if (k === 'n' || k === 'N') {
e.preventDefault();
newBoard();
}
else if (k === 'ArrowLeft') {
e.preventDefault();
moveCur(-1, 0);
}
else if (k === 'ArrowRight') {
e.preventDefault();
moveCur(1, 0);
}
else if (k === 'ArrowUp') {
e.preventDefault();
moveCur(0, -1);
}
else if (k === 'ArrowDown') {
e.preventDefault();
moveCur(0, 1);
}
else if (k.length === 1 && k >= '1' && k <= '9') {
e.preventDefault();
takeOver();
selDigit = Number(k);
putDigit(cur, selDigit);
afterInput(true);
}
else if (k === 'Delete' || k === 'Backspace' || k === '0') {
e.preventDefault();
takeOver();
clearDigit(cur);
afterInput(true);
}
else if (k === 'h' || k === 'H') {
e.preventDefault();
takeOver();
if (hintHook !== null) {
hintHook();
}
else {
hintDefault();
}
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
pad.addEventListener('pointerdown', function (e) {
takeOver();
ptrDown = true;
try {
pad.setPointerCapture(e.pointerId);
}
catch (err) {
ptrDown = true;
}
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) {
cur = c;
}
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (!ptrDown) {
return;
}
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) {
cur = c;
}
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e) {
if (!ptrDown) {
return;
}
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { 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 CLUES_06 = 34;
const HINT_GAP = 0.28;
let LAP_S = 7.4;
let REST_S = 5.4;
let hintCell = -1;
let hintT0 = 0.25;
let whys = [];
let leftMask = 0;
const KIND_NAME = ['行', '列', '箱'];
const SPK_WHY = '{a}にすでにある数字 {b}、候補から外れた';
const SPK_LEFT = '残りの候補 {a} 個';
// 칸 c 의 후보에서 빠진 숫자마다 근거 묶음 하나를 고른다 — 行·列·상자를 돌려 가며 골라 세 가지가 고루 나온다
function computeHint(c) {
hintCell = c;
whys = [];
if (g[c] !== 0) {
leftMask = 0;
hintCell = -1;
return;
}
const cand = candMask(g, c);
leftMask = cand;
let n = 0;
for (let d = 1; d <= 9; d += 1) {
if ((cand & bit(d)) !== 0) {
continue;
}
const srcs = [-1, -1, -1];
for (let kind = 0; kind < 3; kind += 1) {
const u = UNITS[unitIndex(kind, c)];
for (let k = 0; k < 9; k += 1) {
if (u[k] !== c && g[u[k]] === d) {
srcs[kind] = u[k];
}
}
}
let pick = -1;
for (let q = 0; q < 3; q += 1) {
const kk = (n + q) % 3;
if (srcs[kk] >= 0 && pick < 0) {
pick = kk;
}
}
if (pick >= 0) {
whys.push({ d: d, kind: pick, src: srcs[pick] });
n += 1;
}
}
}
function kindsUsed() {
let m = 0;
for (let k = 0; k < whys.length; k += 1) {
m |= 1 << whys[k].kind;
}
return popcount(m);
}
function hintSix() {
computeHint(cur);
hintT0 = sceneT;
}
hintHook = hintSix;
function build() {
const s = fullBoard(71 + lap * 2);
const d = digBoard(s, 81 + lap, CLUES_06, false);
setBoard(d.puz, s);
const singles = [];
for (let i = 0; i < 81; i += 1) {
if (g[i] === 0 && popcount(candMask(g, i)) === 1) {
singles.push(i);
}
}
let pick = singles.length > 0 ? singles[0] : 0;
const start = singles.length > 0 ? Math.floor(hash2(lap, 9) * singles.length) : 0;
for (let q = 0; q < singles.length; q += 1) {
computeHint(singles[(start + q) % singles.length]);
if (kindsUsed() === 3) {
pick = singles[(start + q) % singles.length];
break;
}
}
computeHint(pick);
hintT0 = 0.25;
LAP_S = hintT0 + whys.length * HINT_GAP + 2.2;
REST_S = hintT0 + whys.length * HINT_GAP + 0.4;
}
function update(dt) { if (dt < 0) {
return;
} }
function shownN() {
if (hintCell < 0) {
return 0;
}
if (still) {
return whys.length;
}
return clamp(Math.floor((sceneT - hintT0) / HINT_GAP) + 1, 0, whys.length);
}
function elimShown(n) {
let m = 0;
for (let k = 0; k < n; k += 1) {
m |= bit(whys[k].d);
}
return m;
}
function hintDone() {
return hintCell >= 0 && (still || sceneT >= hintT0 + whys.length * HINT_GAP + 0.1);
}
function draw() {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoard(function (i) { return i === hintCell ? [74, 68, 112] : null; }, null);
const n = shownN();
for (let k = 0; k < n; k += 1) {
const w = whys[k];
const last = k === n - 1;
tintUnit(unitIndex(w.kind, hintCell), SUN, last ? 0.42 : 0.12);
box(cellX(w.src) + 1, cellY(w.src) + 1, CS - 2, CS - 2, 4, ROSE, last ? 0.8 : 0.4);
text(String(g[w.src]), cellCx(w.src), cellCy(w.src) + 1, INK, 14, 800, 'center');
if (last) {
seg(cellCx(w.src), cellCy(w.src), cellCx(hintCell), cellCy(hintCell), ROSE, 0.9, 2.4);
}
}
if (hintCell >= 0) {
ring(cellX(hintCell) + 0.5, cellY(hintCell) + 0.5, CS - 1, CS - 1, 4, SUN, 1, 2);
if (hintDone() && popcount(leftMask) === 1) {
box(cellX(hintCell) + 1, cellY(hintCell) + 1, CS - 2, CS - 2, 4, MINT, 1);
text(String(lowDigit(leftMask)), cellCx(hintCell), cellCy(hintCell) + 1, INK, 16, 800, 'center');
}
}
panel(PX, BY - 5, PW, 223);
text('候補を消した根拠', PX + 12, BY + 8, DIM, 11, 700, 'left');
const remain = ALL & ~elimShown(n);
bitRow(PX + 24, BY + 26, remain, MINT, [48, 44, 74]);
for (let k = 0; k < n; k += 1) {
const w = whys[k];
text(KIND_NAME[w.kind], PX + 24 + (w.d - 1) * 18 + 8, BY + 52, ROSE, 9, 700, 'center');
}
if (n > 0) {
const w = whys[n - 1];
text(fill('{a}にすでに {b}', KIND_NAME[w.kind], String(w.d)), PX + 12, BY + 86, PAPER, 14, 800, 'left');
}
else {
text('マスを選んで H を押す', PX + 12, BY + 86, NEU, 12, 700, 'left');
}
if (hintCell >= 0) {
text(fill('残りの候補 {a} 個', String(popcount(remain))), PX + 12, BY + 112, NEU, 12, 700, 'left');
text('行・列・箱が順に消す', PX + 12, BY + 138, DIM, 11, 700, 'left');
}
hud('lastBit', '0b' + bin9(remain), popcount(remain) / 9);
}
function statusText() {
const n = shownN();
return fill('根拠 {a} 個を確認、残りの候補 {b} 個', String(n), String(popcount(ALL & ~elimShown(n))));
}
function modeId() { return modeBase(hintDone() ? 2 : (shownN() > 0 ? 1 : 0)); }
function speakText() {
const n = shownN();
const m = n > 0 ? fill(SPK_WHY, KIND_NAME[whys[n - 1].kind], String(whys[n - 1].d)) + '. ' + fill(SPK_LEFT, String(popcount(hintCell >= 0 ? leftMask : ALL))) + '. ' : '';
return m + speakBoard();
}
function onPtr(kind, x, y) { if (kind < 0 || x < 0 || y < 0) {
return;
} }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// HintReasonTrace - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./HintReasonTrace.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function HintReasonTrace() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [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 easeOut(k: number): number { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const SKY: RGB = [150, 192, 244];
const SUN: RGB = [250, 218, 146];
const NEU: RGB = [236, 230, 250];
const BGC: RGB = [46, 42, 68];
const PANEL: RGB = [62, 57, 92];
const DIM: RGB = [196, 190, 224];
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) + ')';
}
function hsl(h: number, s: number, l: number): RGB {
const a = s * Math.min(l, 1 - l);
const f = function (n: number): number {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a: number, b: number): number {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s: string, a: string, b?: string, c?: string): string {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v: number): string { return String(Math.round(v * 10) / 10); }
function group(v: number): string {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { o += ','; }
o += s.charAt(i);
}
return o;
}
// ── 스도쿠 모델 — 판은 81칸 숫자 배열(0 은 빈칸). 후보는 9비트 한 수: 숫자 d 는 (d-1) 번째 비트
const ALL = 511;
function bit(d: number): number { return 1 << (d - 1); }
function popcount(m: number): number {
let c = 0;
let v = m;
while (v) { v &= v - 1; c += 1; }
return c;
}
// 가장 낮은 비트 하나가 가리키는 숫자
function lowDigit(m: number): number { return 32 - Math.clz32(m & -m); }
function bin9(m: number): string {
let s = '';
for (let d = 9; d >= 1; d -= 1) { s += (m & bit(d)) ? '1' : '0'; }
return s;
}
function rowOf(i: number): number { return Math.floor(i / 9); }
function colOf(i: number): number { return i % 9; }
function boxOf(i: number): number { return Math.floor(i / 27) * 3 + Math.floor((i % 9) / 3); }
// 묶음 27개 — 줄 9 · 세로줄 9 · 상자 9. 칸의 이웃은 같은 묶음 세 곳의 다른 칸 20개
const UNITS: number[][] = [];
const PEERS: number[][] = [];
for (let k = 0; k < 27; k += 1) { UNITS.push([]); }
for (let i = 0; i < 81; i += 1) {
UNITS[rowOf(i)].push(i);
UNITS[9 + colOf(i)].push(i);
UNITS[18 + boxOf(i)].push(i);
}
for (let i = 0; i < 81; i += 1) {
const ps: number[] = [];
for (let j = 0; j < 81; j += 1) {
if (j !== i && (rowOf(j) === rowOf(i) || colOf(j) === colOf(i) || boxOf(j) === boxOf(i))) { ps.push(j); }
}
PEERS.push(ps);
}
// 묶음 종류 kind: 0 줄 · 1 세로줄 · 2 상자
function unitIndex(kind: number, i: number): number { return kind === 0 ? rowOf(i) : (kind === 1 ? 9 + colOf(i) : 18 + boxOf(i)); }
function sharedKind(i: number, j: number): number {
if (rowOf(i) === rowOf(j)) { return 0; }
if (colOf(i) === colOf(j)) { return 1; }
return boxOf(i) === boxOf(j) ? 2 : -1;
}
// 칸 i 가 속한 kind 묶음에서 i 를 뺀 칸들이 이미 쓴 숫자의 비트 합(OR)
function usedMask(b: number[], i: number, kind: number): number {
const u = UNITS[unitIndex(kind, i)];
let m = 0;
for (let k = 0; k < 9; k += 1) { if (u[k] !== i && b[u[k]] !== 0) { m |= bit(b[u[k]]); } }
return m;
}
// 후보 = 9비트 전체에서 세 묶음이 쓴 비트를 뺀 것. 채워진 칸의 후보는 0
function candMask(b: number[], i: number): number {
if (b[i] !== 0) { return 0; }
return ALL & ~(usedMask(b, i, 0) | usedMask(b, i, 1) | usedMask(b, i, 2));
}
function filledOf(b: number[]): number {
let c = 0;
for (let i = 0; i < 81; i += 1) { if (b[i] !== 0) { c += 1; } }
return c;
}
function perm(n: number, seed: number): number[] {
const a: number[] = [];
for (let i = 0; i < n; i += 1) { a.push(i); }
for (let i = n - 1; i > 0; i -= 1) {
const j = Math.floor(hash2(i, seed) * (i + 1));
const t = a[i];
a[i] = a[j];
a[j] = t;
}
return a;
}
// 완성판 — 기본 무늬를 숫자·줄·상자 묶음 순서로 섞는다. 같은 씨앗이면 같은 판
function fullBoard(seed: number): number[] {
const dp = perm(9, seed * 7 + 1);
const bp = perm(3, seed * 7 + 2);
const sp = perm(3, seed * 7 + 3);
const rows: number[] = [];
const cols: number[] = [];
for (let k = 0; k < 3; k += 1) {
const rp = perm(3, seed * 13 + k * 5 + 4);
const cp = perm(3, seed * 13 + k * 5 + 9);
for (let j = 0; j < 3; j += 1) {
rows.push(bp[k] * 3 + rp[j]);
cols.push(sp[k] * 3 + cp[j]);
}
}
const b: number[] = [];
for (let r = 0; r < 9; r += 1) {
for (let c = 0; c < 9; c += 1) {
b.push(dp[(3 * (rows[r] % 3) + Math.floor(rows[r] / 3) + cols[c]) % 9] + 1);
}
}
return b;
}
// 풀이 탐색 — 가장 후보가 적은 칸(plain 이면 앞쪽 빈칸)부터 숫자를 걸고, 막히면 되돌린다.
// count 가 limit 에 닿으면 멈추고(풀이 개수는 limit 까지만 센다), 걸어 본 숫자가 cap 을 넘으면 멈춘다.
// trace 에는 (종류 << 16) | (칸 << 4) | 숫자 — 종류 0 은 걸기, 1 은 되돌리기
type Srch = { count: number; nodes: number; dead: number; branch: number; capHit: boolean; first: number[]; trace: number[] };
function search(start: number[], limit: number, cap: number, plain: boolean, wantTrace: boolean): Srch {
const b = start.slice();
const rm: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const cm: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const xm: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
rm[rowOf(i)] |= bit(b[i]);
cm[colOf(i)] |= bit(b[i]);
xm[boxOf(i)] |= bit(b[i]);
}
}
const r: Srch = { count: 0, nodes: 0, dead: 0, branch: 0, capHit: false, first: [], trace: [] };
function rec(): void {
let best = -1;
let bm = 0;
let bc = 10;
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) { continue; }
const m = ALL & ~(rm[rowOf(i)] | cm[colOf(i)] | xm[boxOf(i)]);
const pc = popcount(m);
if (plain) { best = i; bm = m; bc = pc; break; }
if (pc < bc) { best = i; bm = m; bc = pc; if (pc <= 1) { break; } }
}
if (best < 0) {
r.count += 1;
if (r.count === 1) { r.first = b.slice(); }
return;
}
if (bc === 0) { r.dead += 1; return; }
if (bc > 1) { r.branch += 1; }
let mm = bm;
while (mm) {
const lo = mm & -mm;
mm ^= lo;
const d = 32 - Math.clz32(lo);
r.nodes += 1;
if (r.nodes > cap) { r.capHit = true; return; }
b[best] = d;
rm[rowOf(best)] |= lo;
cm[colOf(best)] |= lo;
xm[boxOf(best)] |= lo;
if (wantTrace) { r.trace.push((best << 4) | d); }
rec();
if (r.count >= limit || r.capHit) { return; }
b[best] = 0;
rm[rowOf(best)] &= ~lo;
cm[colOf(best)] &= ~lo;
xm[boxOf(best)] &= ~lo;
if (wantTrace) { r.trace.push((1 << 16) | (best << 4) | d); }
}
}
rec();
return r;
}
// 하나뿐인 후보 채우기 — 후보가 하나인 칸(번호가 가장 앞선 칸)을 채우고 다시 센다. 더 없으면 멈춘다
type Singles = { order: number[]; digits: number[]; avail: number[]; solved: boolean; board: number[] };
function singlesFill(start: number[]): Singles {
const b = start.slice();
const order: number[] = [];
const digits: number[] = [];
const avail: number[] = [];
for (;;) {
let first = -1;
let n = 0;
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0 && popcount(candMask(b, i)) === 1) {
if (first < 0) { first = i; }
n += 1;
}
}
if (first < 0) { break; }
const d = lowDigit(candMask(b, first));
b[first] = d;
order.push(first);
digits.push(d);
avail.push(n);
}
let solved = true;
for (let i = 0; i < 81; i += 1) { if (b[i] === 0) { solved = false; } }
return { order: order, digits: digits, avail: avail, solved: solved, board: b };
}
// 구멍 파기 — 완성판에서 칸을 해시 순서로 하나씩 비우고 풀이 개수를 2 까지만 센다.
// 하나뿐이면 비운 채로 두고, 둘 이상이면 되돌린다. singlesOnly 이면 하나뿐인 후보만으로 풀리는 판만 남긴다
type DigStep = { cell: number; cnt: number; ok: boolean; nodes: number };
type Dug = { puz: number[]; steps: DigStep[]; clues: number };
function digBoard(sol: number[], seed: number, target: number, singlesOnly: boolean): Dug {
const puz = sol.slice();
const order = perm(81, seed * 31 + 5);
const steps: DigStep[] = [];
let clues = 81;
for (let k = 0; k < 81; k += 1) {
if (clues <= target) { break; }
const c = order[k];
const d = puz[c];
puz[c] = 0;
const s = search(puz, 2, 6000, false, false);
const cnt = s.capHit ? 2 : Math.min(2, s.count);
const ok = cnt === 1 && (!singlesOnly || singlesFill(puz).solved);
steps.push({ cell: c, cnt: cnt, ok: ok, nodes: s.nodes });
if (ok) { clues -= 1; } else { puz[c] = d; }
}
return { puz: puz, steps: steps, clues: clues };
}
// 놓은 숫자가 겹치는 쌍 — [칸, 칸, 묶음 종류]
function conflicts(b: number[]): number[][] {
const out: number[][] = [];
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0) { continue; }
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (p > i && b[p] === b[i]) { out.push([i, p, sharedKind(i, p)]); }
}
}
return out;
}
// ── 판 상태 — 장면이 아니라 판이 들고 있는 것. 화면은 이 값을 읽어 그린다
const BX = 22;
const BY = 44;
const CS = 23;
const GAP = 3;
const PX = 258;
const PW = 210;
let g: number[] = [];
let giv: boolean[] = [];
let sol: number[] = [];
let pt: number[] = [];
let cur = 40;
let solvedT = -1;
let lastCell = -1;
type Rip = { i: number; t: number; list: number[]; d: number };
let rip: Rip = { i: -1, t: -9, list: [], d: 0 };
let selDigit = 0;
let still = false;
let hintHook = null as (() => void) | null;
function cellX(i: number): number { const c = colOf(i); return BX + c * CS + Math.floor(c / 3) * GAP; }
function cellY(i: number): number { const r = rowOf(i); return BY + r * CS + Math.floor(r / 3) * GAP; }
function cellCx(i: number): number { return cellX(i) + CS / 2; }
function cellCy(i: number): number { return cellY(i) + CS / 2; }
function cellAt(x: number, y: number): number {
let c = -1;
let r = -1;
for (let k = 0; k < 9; k += 1) {
const x0 = BX + k * CS + Math.floor(k / 3) * GAP;
const y0 = BY + k * CS + Math.floor(k / 3) * GAP;
if (x >= x0 && x < x0 + CS) { c = k; }
if (y >= y0 && y < y0 + CS) { r = k; }
}
return c < 0 || r < 0 ? -1 : r * 9 + c;
}
function setBoard(puz: number[], solution: number[]): void {
g = puz.slice();
sol = solution.slice();
giv = [];
pt = [];
for (let i = 0; i < 81; i += 1) { giv.push(puz[i] !== 0); pt.push(-9); }
solvedT = -1;
lastCell = -1;
rip = { i: -1, t: -9, list: [], d: 0 };
}
function solvedNow(): boolean { return filledOf(g) === 81 && conflicts(g).length === 0; }
function checkSolved(): void {
if (solvedNow()) { if (solvedT < 0) { solvedT = sceneT; } } else { solvedT = -1; }
}
// 숫자를 놓는다 — 놓기 전에 그 숫자가 후보에 있던 이웃 칸을 기억해 두면 소거가 번지는 모습을 그릴 수 있다
function putDigit(i: number, d: number): boolean {
if (giv[i] || d < 1 || d > 9) { return false; }
const list: number[] = [];
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (g[p] === 0 && (candMask(g, p) & bit(d)) !== 0) { list.push(p); }
}
g[i] = d;
pt[i] = sceneT;
rip = { i: i, t: sceneT, list: list, d: d };
lastCell = i;
checkSolved();
return true;
}
function clearDigit(i: number): void {
if (giv[i] || g[i] === 0) { return; }
g[i] = 0;
pt[i] = -9;
checkSolved();
}
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)]; }
// ── 그리기 도구
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 doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 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 disc(x: number, y: number, r: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, c: RGB, a: number, w: number): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x: number, y: number, w: number, h: number): void { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name: string, val: string, frac: number): void {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// ── 판 그리기 — 칸 색은 장면이 정하고, 숫자는 판이 그린다
const EMPTY: RGB = [58, 53, 88];
const PLACED: RGB = [190, 214, 246];
const PLACED_INK: RGB = [28, 54, 118];
function ring(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number, lw: number): void {
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = lw;
ctx.stroke();
}
function drawBoard(fillOf: (i: number) => RGB | null, extra: ((i: number, x: number, y: number) => void) | null): void {
const s = 9 * CS + 2 * GAP;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < 81; i += 1) {
const x = cellX(i);
const y = cellY(i);
let f: RGB | null = fillOf(i);
if (f === null) { f = g[i] === 0 ? EMPTY : (giv[i] ? NEU : PLACED); }
box(x + 1, y + 1, CS - 2, CS - 2, 4, f, 1);
if (g[i] !== 0) {
const a = still ? 99 : sceneT - pt[i];
const k = a < 0.3 ? 0.7 + 0.3 * easeOut(a / 0.3) : 1;
text(String(g[i]), x + CS / 2, y + CS / 2 + 1, giv[i] ? INK : PLACED_INK, Math.round(14 * k), giv[i] ? 800 : 700, 'center');
} else if (extra !== null) {
extra(i, x, y);
}
}
if (!demo) { ring(cellX(cur) + 0.5, cellY(cur) + 0.5, CS - 1, CS - 1, 4, PAPER, 1, 2); }
}
// 묶음 하나를 옅게 물들인다
function tintUnit(u: number, c: RGB, a: number): void {
for (let k = 0; k < 9; k += 1) {
const i = UNITS[u][k];
box(cellX(i) + 1, cellY(i) + 1, CS - 2, CS - 2, 4, c, a);
// 물들임이 숫자를 흐리지 않도록 숫자를 다시 얹는다
if (g[i] !== 0) { text(String(g[i]), cellCx(i), cellCy(i) + 1, giv[i] ? INK : PLACED_INK, 14, giv[i] ? 800 : 700, 'center'); }
}
}
// 후보 비트 줄 — 숫자 1~9 칸 아홉 개, 켜진 비트만 밝다
function bitRow(x: number, y: number, m: number, on: RGB, off: RGB): void {
for (let d = 1; d <= 9; d += 1) {
const lit = (m & bit(d)) !== 0;
const bx = x + (d - 1) * 18;
box(bx, y, 16, 16, 4, lit ? on : off, 1);
text(String(d), bx + 8, y + 8.5, lit ? INK : DIM, 10, 700, 'center');
}
}
// ── 상태 줄·낭독 문장
const SPK_FILL = '{a}칸 중 {b}칸이 채워져 있다';
const SPK_DONE = '판 완성, {a}칸이 모두 채워졌다';
function speakBoard(): string {
return solvedNow() ? fill(SPK_DONE, '81') : fill(SPK_FILL, '81', String(filledOf(g)));
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 100 + 채운 칸 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode: number): number {
if (solvedNow()) { return 1000; }
return manual ? 100 + filledOf(g) : sceneMode;
}
// 방금 놓은 칸의 줄·세로줄·상자 세 묶음을 한 번 물들였다 서서히 빼는 번짐 — 숫자 하나가 어디까지 영향을 주는지 보인다
function unitPulse(c: RGB, a: number, dur: number): void {
if (rip.i < 0 || still) { return; }
const k = (sceneT - rip.t) / dur;
if (k < 0 || k >= 1) { return; }
for (let kind = 0; kind < 3; kind += 1) { tintUnit(unitIndex(kind, rip.i), c, a * (1 - k)); }
}
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 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
}
function takeOver(): void {
if (manual) { return; }
manual = true;
wake();
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say(): void {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
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);
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow(): void {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) { say(); }
}
}
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;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest(): void {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak: boolean): void {
if (paused || still) { sceneT += 1; }
update(0);
showNow();
if (speak) { say(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
say();
}
function restart(): void {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// 새 판 — 씨앗이 달라진 판으로 시간표를 처음부터. 손으로 하던 중이어도 자동 시연으로 돌아간다
function newBoard(): void {
manual = false;
lap += 1;
begin();
update(0);
showNow();
say();
}
function moveCur(dx: number, dy: number): void {
takeOver();
cur = clamp(rowOf(cur) + dy, 0, 8) * 9 + clamp(colOf(cur) + dx, 0, 8);
afterInput(true);
}
// 힌트 기본 — 고른 빈칸에 정답 숫자를 놓는다. 장면이 hintHook 을 걸어 두면 그쪽이 먼저
function hintDefault(): void {
if (!giv[cur] && g[cur] === 0) { putDigit(cur, sol[cur]); }
}
function toCanvas(e: PointerEvent): number[] {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 칸 이동, 1~9 = 숫자 입력, Delete·Backspace·0 = 지우기, H = 힌트,
// N = 새 판, R = 처음부터, Enter·Space = 일시정지
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'r' || k === 'R') {
restart();
} else if (k === 'n' || k === 'N') {
e.preventDefault();
newBoard();
} else if (k === 'ArrowLeft') {
e.preventDefault();
moveCur(-1, 0);
} else if (k === 'ArrowRight') {
e.preventDefault();
moveCur(1, 0);
} else if (k === 'ArrowUp') {
e.preventDefault();
moveCur(0, -1);
} else if (k === 'ArrowDown') {
e.preventDefault();
moveCur(0, 1);
} else if (k.length === 1 && k >= '1' && k <= '9') {
e.preventDefault();
takeOver();
selDigit = Number(k);
putDigit(cur, selDigit);
afterInput(true);
} else if (k === 'Delete' || k === 'Backspace' || k === '0') {
e.preventDefault();
takeOver();
clearDigit(cur);
afterInput(true);
} else if (k === 'h' || k === 'H') {
e.preventDefault();
takeOver();
if (hintHook !== null) { hintHook(); } else { hintDefault(); }
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
ptrDown = true;
try { pad.setPointerCapture(e.pointerId); } catch (err) { ptrDown = true; }
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) { cur = c; }
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (!ptrDown) { return; }
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) { cur = c; }
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e: PointerEvent): void {
if (!ptrDown) { return; }
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { 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 CLUES_06 = 34;
const HINT_GAP = 0.28;
let LAP_S = 7.4;
let REST_S = 5.4;
type Why = { d: number; kind: number; src: number };
let hintCell = -1;
let hintT0 = 0.25;
let whys: Why[] = [];
let leftMask = 0;
const KIND_NAME = ['행', '열', '상자'];
const SPK_WHY = '{a}에 이미 있는 숫자 {b}, 후보에서 빠졌다';
const SPK_LEFT = '남은 후보 {a}개';
// 칸 c 의 후보에서 빠진 숫자마다 근거 묶음 하나를 고른다 — 행·열·상자를 돌려 가며 골라 세 가지가 고루 나온다
function computeHint(c: number): void {
hintCell = c;
whys = [];
if (g[c] !== 0) { leftMask = 0; hintCell = -1; return; }
const cand = candMask(g, c);
leftMask = cand;
let n = 0;
for (let d = 1; d <= 9; d += 1) {
if ((cand & bit(d)) !== 0) { continue; }
const srcs = [-1, -1, -1];
for (let kind = 0; kind < 3; kind += 1) {
const u = UNITS[unitIndex(kind, c)];
for (let k = 0; k < 9; k += 1) { if (u[k] !== c && g[u[k]] === d) { srcs[kind] = u[k]; } }
}
let pick = -1;
for (let q = 0; q < 3; q += 1) {
const kk = (n + q) % 3;
if (srcs[kk] >= 0 && pick < 0) { pick = kk; }
}
if (pick >= 0) { whys.push({ d: d, kind: pick, src: srcs[pick] }); n += 1; }
}
}
function kindsUsed(): number {
let m = 0;
for (let k = 0; k < whys.length; k += 1) { m |= 1 << whys[k].kind; }
return popcount(m);
}
function hintSix(): void {
computeHint(cur);
hintT0 = sceneT;
}
hintHook = hintSix;
function build(): void {
const s = fullBoard(71 + lap * 2);
const d = digBoard(s, 81 + lap, CLUES_06, false);
setBoard(d.puz, s);
const singles: number[] = [];
for (let i = 0; i < 81; i += 1) { if (g[i] === 0 && popcount(candMask(g, i)) === 1) { singles.push(i); } }
let pick = singles.length > 0 ? singles[0] : 0;
const start = singles.length > 0 ? Math.floor(hash2(lap, 9) * singles.length) : 0;
for (let q = 0; q < singles.length; q += 1) {
computeHint(singles[(start + q) % singles.length]);
if (kindsUsed() === 3) { pick = singles[(start + q) % singles.length]; break; }
}
computeHint(pick);
hintT0 = 0.25;
LAP_S = hintT0 + whys.length * HINT_GAP + 2.2;
REST_S = hintT0 + whys.length * HINT_GAP + 0.4;
}
function update(dt: number): void { if (dt < 0) { return; } }
function shownN(): number {
if (hintCell < 0) { return 0; }
if (still) { return whys.length; }
return clamp(Math.floor((sceneT - hintT0) / HINT_GAP) + 1, 0, whys.length);
}
function elimShown(n: number): number {
let m = 0;
for (let k = 0; k < n; k += 1) { m |= bit(whys[k].d); }
return m;
}
function hintDone(): boolean {
return hintCell >= 0 && (still || sceneT >= hintT0 + whys.length * HINT_GAP + 0.1);
}
function draw(): void {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoard(function (i: number): RGB | null { return i === hintCell ? [74, 68, 112] : null; }, null);
const n = shownN();
for (let k = 0; k < n; k += 1) {
const w = whys[k];
const last = k === n - 1;
tintUnit(unitIndex(w.kind, hintCell), SUN, last ? 0.42 : 0.12);
box(cellX(w.src) + 1, cellY(w.src) + 1, CS - 2, CS - 2, 4, ROSE, last ? 0.8 : 0.4);
text(String(g[w.src]), cellCx(w.src), cellCy(w.src) + 1, INK, 14, 800, 'center');
if (last) { seg(cellCx(w.src), cellCy(w.src), cellCx(hintCell), cellCy(hintCell), ROSE, 0.9, 2.4); }
}
if (hintCell >= 0) {
ring(cellX(hintCell) + 0.5, cellY(hintCell) + 0.5, CS - 1, CS - 1, 4, SUN, 1, 2);
if (hintDone() && popcount(leftMask) === 1) {
box(cellX(hintCell) + 1, cellY(hintCell) + 1, CS - 2, CS - 2, 4, MINT, 1);
text(String(lowDigit(leftMask)), cellCx(hintCell), cellCy(hintCell) + 1, INK, 16, 800, 'center');
}
}
panel(PX, BY - 5, PW, 223);
text('후보를 지운 근거', PX + 12, BY + 8, DIM, 11, 700, 'left');
const remain = ALL & ~elimShown(n);
bitRow(PX + 24, BY + 26, remain, MINT, [48, 44, 74]);
for (let k = 0; k < n; k += 1) {
const w = whys[k];
text(KIND_NAME[w.kind], PX + 24 + (w.d - 1) * 18 + 8, BY + 52, ROSE, 9, 700, 'center');
}
if (n > 0) {
const w = whys[n - 1];
text(fill('{a}에 이미 {b}', KIND_NAME[w.kind], String(w.d)), PX + 12, BY + 86, PAPER, 14, 800, 'left');
} else {
text('칸을 고르고 H 를 누른다', PX + 12, BY + 86, NEU, 12, 700, 'left');
}
if (hintCell >= 0) {
text(fill('남은 후보 {a}개', String(popcount(remain))), PX + 12, BY + 112, NEU, 12, 700, 'left');
text('행·열·상자가 차례로 지운다', PX + 12, BY + 138, DIM, 11, 700, 'left');
}
hud('lastBit', '0b' + bin9(remain), popcount(remain) / 9);
}
function statusText(): string {
const n = shownN();
return fill('근거 {a}개 확인, 남은 후보 {b}개', String(n), String(popcount(ALL & ~elimShown(n))));
}
function modeId(): number { return modeBase(hintDone() ? 2 : (shownN() > 0 ? 1 : 0)); }
function speakText(): string {
const n = shownN();
const m = n > 0 ? fill(SPK_WHY, KIND_NAME[whys[n - 1].kind], String(whys[n - 1].d)) + '. ' + fill(SPK_LEFT, String(popcount(hintCell >= 0 ? leftMask : ALL))) + '. ' : '';
return m + speakBoard();
}
function onPtr(kind: number, x: number, y: number): void { if (kind < 0 || x < 0 || y < 0) { 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={'스도쿠 시연 무대 — 방향키로 칸 이동, 1부터 9 숫자키로 입력, Delete 로 지우기, H 로 힌트, N 으로 새 판, R 로 처음부터, Enter 또는 Space 로 일시정지'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'9×9 판에서 후보가 하나뿐인 칸 하나가 노란 테로 표시되고, 그 칸의 후보를 지운 줄·세로줄·상자가 차례로 노랗게 물들며 그 묶음에서 이미 놓인 숫자 칸이 붉게 빛나 선으로 이어진다. 오른쪽 패널에는 숫자 1부터 9의 후보 줄이 있고 지워진 숫자 아래에 행·열·상자 근거 이름이 붙으며, 마지막 남은 숫자가 민트색으로 칸에 나타난다. H 키로 고른 칸의 힌트를 다시 본다. 아래 눈금은 상수 이름과 지금 값을 보이고, 움직임 줄이기에서는 숫자가 바로 채워진 판 한 장만 그려진다'}></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>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}06 · 理由まで見せるヒント — どの行が候補を消したか — 計測 累積面積 8.234% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function computeHint(c) {
hintCell = c;
whys = [];
if (g[c] !== 0) {
leftMask = 0;
hintCell = -1;
return;
}
const cand = candMask(g, c);
leftMask = cand;
let n = 0;
for (let d = 1; d <= 9; d += 1) {
if ((cand & bit(d)) !== 0) {
continue;
}
const srcs = [-1, -1, -1];
for (let kind = 0; kind < 3; kind += 1) {
const u = UNITS[unitIndex(kind, c)];
for (let k = 0; k < 9; k += 1) {
if (u[k] !== c && g[u[k]] === d) {
srcs[kind] = u[k];
}
}
}
let pick = -1;
四つの盤の目的のマスは59・41・20・48番で、候補から外れた数字の根拠はマスごとに8個、行・列・箱の三種類がすべて出ます。最後にビットがひとつ残り、その数字がマスに現れます。
07数字ごとの残り数をリングで縮めるキーパッド
1〜9の各キーにまだ置いていない数を輪の弧で描き、9個置くとキーが薄くなるので、どの数字があと何個かひと目で分かる入力パネルになります。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="数独の舞台 — 矢印キーでマス移動、数字キー1〜9で入力、Deleteで消去、Hでヒント、Nで新しい盤、Rで最初から、EnterかSpaceで一時停止">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="9×9の盤の横に数字1から9の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>
</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-ink;
}
// 무대 상자 — 눌러서 한 번 깨뜨리는 곳(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-cream;
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 {
padding: $sp-1 $sp-3;
border: 2px solid $subject-cream;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
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__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-ink;
color: $subject-cream;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면에만 있고 aria-live 는 늘 off
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 100px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
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__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 글리치 없는 원문 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause: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: #17141a;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #fff7e6;
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 {
padding: 4px 12px;
border: 2px solid #fff7e6;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
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__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 100px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
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__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause: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 easeOut(k) { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const SKY = [150, 192, 244];
const SUN = [250, 218, 146];
const NEU = [236, 230, 250];
const BGC = [46, 42, 68];
const PANEL = [62, 57, 92];
const DIM = [196, 190, 224];
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) + ')';
}
function hsl(h, s, l) {
const a = s * Math.min(l, 1 - l);
const f = function (n) {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a, b) {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s, a, b, c) {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v) { return String(Math.round(v * 10) / 10); }
function group(v) {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
o += ',';
}
o += s.charAt(i);
}
return o;
}
// ── 스도쿠 모델 — 판은 81칸 숫자 배열(0 은 빈칸). 후보는 9비트 한 수: 숫자 d 는 (d-1) 번째 비트
const ALL = 511;
function bit(d) { return 1 << (d - 1); }
function popcount(m) {
let c = 0;
let v = m;
while (v) {
v &= v - 1;
c += 1;
}
return c;
}
// 가장 낮은 비트 하나가 가리키는 숫자
function lowDigit(m) { return 32 - Math.clz32(m & -m); }
function bin9(m) {
let s = '';
for (let d = 9; d >= 1; d -= 1) {
s += (m & bit(d)) ? '1' : '0';
}
return s;
}
function rowOf(i) { return Math.floor(i / 9); }
function colOf(i) { return i % 9; }
function boxOf(i) { return Math.floor(i / 27) * 3 + Math.floor((i % 9) / 3); }
// 묶음 27개 — 줄 9 · 세로줄 9 · 箱 9. 칸의 이웃은 같은 묶음 세 곳의 다른 칸 20개
const UNITS = [];
const PEERS = [];
for (let k = 0; k < 27; k += 1) {
UNITS.push([]);
}
for (let i = 0; i < 81; i += 1) {
UNITS[rowOf(i)].push(i);
UNITS[9 + colOf(i)].push(i);
UNITS[18 + boxOf(i)].push(i);
}
for (let i = 0; i < 81; i += 1) {
const ps = [];
for (let j = 0; j < 81; j += 1) {
if (j !== i && (rowOf(j) === rowOf(i) || colOf(j) === colOf(i) || boxOf(j) === boxOf(i))) {
ps.push(j);
}
}
PEERS.push(ps);
}
// 묶음 종류 kind: 0 줄 · 1 세로줄 · 2 箱
function unitIndex(kind, i) { return kind === 0 ? rowOf(i) : (kind === 1 ? 9 + colOf(i) : 18 + boxOf(i)); }
function sharedKind(i, j) {
if (rowOf(i) === rowOf(j)) {
return 0;
}
if (colOf(i) === colOf(j)) {
return 1;
}
return boxOf(i) === boxOf(j) ? 2 : -1;
}
// 칸 i 가 속한 kind 묶음에서 i 를 뺀 칸들이 이미 쓴 숫자의 비트 合計(OR)
function usedMask(b, i, kind) {
const u = UNITS[unitIndex(kind, i)];
let m = 0;
for (let k = 0; k < 9; k += 1) {
if (u[k] !== i && b[u[k]] !== 0) {
m |= bit(b[u[k]]);
}
}
return m;
}
// 候補 = 9비트 전체에서 세 묶음이 쓴 비트를 뺀 것. 채워진 칸의 후보는 0
function candMask(b, i) {
if (b[i] !== 0) {
return 0;
}
return ALL & ~(usedMask(b, i, 0) | usedMask(b, i, 1) | usedMask(b, i, 2));
}
function filledOf(b) {
let c = 0;
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
c += 1;
}
}
return c;
}
function perm(n, seed) {
const a = [];
for (let i = 0; i < n; i += 1) {
a.push(i);
}
for (let i = n - 1; i > 0; i -= 1) {
const j = Math.floor(hash2(i, seed) * (i + 1));
const t = a[i];
a[i] = a[j];
a[j] = t;
}
return a;
}
// 완성판 — 기본 무늬를 숫자·줄·箱 묶음 순서로 섞는다. 같은 씨앗이면 같은 판
function fullBoard(seed) {
const dp = perm(9, seed * 7 + 1);
const bp = perm(3, seed * 7 + 2);
const sp = perm(3, seed * 7 + 3);
const rows = [];
const cols = [];
for (let k = 0; k < 3; k += 1) {
const rp = perm(3, seed * 13 + k * 5 + 4);
const cp = perm(3, seed * 13 + k * 5 + 9);
for (let j = 0; j < 3; j += 1) {
rows.push(bp[k] * 3 + rp[j]);
cols.push(sp[k] * 3 + cp[j]);
}
}
const b = [];
for (let r = 0; r < 9; r += 1) {
for (let c = 0; c < 9; c += 1) {
b.push(dp[(3 * (rows[r] % 3) + Math.floor(rows[r] / 3) + cols[c]) % 9] + 1);
}
}
return b;
}
function search(start, limit, cap, plain, wantTrace) {
const b = start.slice();
const rm = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const cm = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const xm = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
rm[rowOf(i)] |= bit(b[i]);
cm[colOf(i)] |= bit(b[i]);
xm[boxOf(i)] |= bit(b[i]);
}
}
const r = { count: 0, nodes: 0, dead: 0, branch: 0, capHit: false, first: [], trace: [] };
function rec() {
let best = -1;
let bm = 0;
let bc = 10;
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
continue;
}
const m = ALL & ~(rm[rowOf(i)] | cm[colOf(i)] | xm[boxOf(i)]);
const pc = popcount(m);
if (plain) {
best = i;
bm = m;
bc = pc;
break;
}
if (pc < bc) {
best = i;
bm = m;
bc = pc;
if (pc <= 1) {
break;
}
}
}
if (best < 0) {
r.count += 1;
if (r.count === 1) {
r.first = b.slice();
}
return;
}
if (bc === 0) {
r.dead += 1;
return;
}
if (bc > 1) {
r.branch += 1;
}
let mm = bm;
while (mm) {
const lo = mm & -mm;
mm ^= lo;
const d = 32 - Math.clz32(lo);
r.nodes += 1;
if (r.nodes > cap) {
r.capHit = true;
return;
}
b[best] = d;
rm[rowOf(best)] |= lo;
cm[colOf(best)] |= lo;
xm[boxOf(best)] |= lo;
if (wantTrace) {
r.trace.push((best << 4) | d);
}
rec();
if (r.count >= limit || r.capHit) {
return;
}
b[best] = 0;
rm[rowOf(best)] &= ~lo;
cm[colOf(best)] &= ~lo;
xm[boxOf(best)] &= ~lo;
if (wantTrace) {
r.trace.push((1 << 16) | (best << 4) | d);
}
}
}
rec();
return r;
}
function singlesFill(start) {
const b = start.slice();
const order = [];
const digits = [];
const avail = [];
for (;;) {
let first = -1;
let n = 0;
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0 && popcount(candMask(b, i)) === 1) {
if (first < 0) {
first = i;
}
n += 1;
}
}
if (first < 0) {
break;
}
const d = lowDigit(candMask(b, first));
b[first] = d;
order.push(first);
digits.push(d);
avail.push(n);
}
let solved = true;
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0) {
solved = false;
}
}
return { order: order, digits: digits, avail: avail, solved: solved, board: b };
}
function digBoard(sol, seed, target, singlesOnly) {
const puz = sol.slice();
const order = perm(81, seed * 31 + 5);
const steps = [];
let clues = 81;
for (let k = 0; k < 81; k += 1) {
if (clues <= target) {
break;
}
const c = order[k];
const d = puz[c];
puz[c] = 0;
const s = search(puz, 2, 6000, false, false);
const cnt = s.capHit ? 2 : Math.min(2, s.count);
const ok = cnt === 1 && (!singlesOnly || singlesFill(puz).solved);
steps.push({ cell: c, cnt: cnt, ok: ok, nodes: s.nodes });
if (ok) {
clues -= 1;
}
else {
puz[c] = d;
}
}
return { puz: puz, steps: steps, clues: clues };
}
// 놓은 숫자가 겹치는 쌍 — [칸, 칸, 묶음 종류]
function conflicts(b) {
const out = [];
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0) {
continue;
}
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (p > i && b[p] === b[i]) {
out.push([i, p, sharedKind(i, p)]);
}
}
}
return out;
}
// ── 판 상태 — 장면이 아니라 판이 들고 있는 것. 화면은 이 값을 읽어 그린다
const BX = 22;
const BY = 44;
const CS = 23;
const GAP = 3;
const PX = 258;
const PW = 210;
let g = [];
let giv = [];
let sol = [];
let pt = [];
let cur = 40;
let solvedT = -1;
let lastCell = -1;
let rip = { i: -1, t: -9, list: [], d: 0 };
let selDigit = 0;
let still = false;
let hintHook = null;
function cellX(i) { const c = colOf(i); return BX + c * CS + Math.floor(c / 3) * GAP; }
function cellY(i) { const r = rowOf(i); return BY + r * CS + Math.floor(r / 3) * GAP; }
function cellCx(i) { return cellX(i) + CS / 2; }
function cellCy(i) { return cellY(i) + CS / 2; }
function cellAt(x, y) {
let c = -1;
let r = -1;
for (let k = 0; k < 9; k += 1) {
const x0 = BX + k * CS + Math.floor(k / 3) * GAP;
const y0 = BY + k * CS + Math.floor(k / 3) * GAP;
if (x >= x0 && x < x0 + CS) {
c = k;
}
if (y >= y0 && y < y0 + CS) {
r = k;
}
}
return c < 0 || r < 0 ? -1 : r * 9 + c;
}
function setBoard(puz, solution) {
g = puz.slice();
sol = solution.slice();
giv = [];
pt = [];
for (let i = 0; i < 81; i += 1) {
giv.push(puz[i] !== 0);
pt.push(-9);
}
solvedT = -1;
lastCell = -1;
rip = { i: -1, t: -9, list: [], d: 0 };
}
function solvedNow() { return filledOf(g) === 81 && conflicts(g).length === 0; }
function checkSolved() {
if (solvedNow()) {
if (solvedT < 0) {
solvedT = sceneT;
}
}
else {
solvedT = -1;
}
}
// 숫자를 놓는다 — 놓기 전에 그 숫자가 후보에 있던 이웃 칸을 기억해 두면 소거가 번지는 모습을 그릴 수 있다
function putDigit(i, d) {
if (giv[i] || d < 1 || d > 9) {
return false;
}
const list = [];
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (g[p] === 0 && (candMask(g, p) & bit(d)) !== 0) {
list.push(p);
}
}
g[i] = d;
pt[i] = sceneT;
rip = { i: i, t: sceneT, list: list, d: d };
lastCell = i;
checkSolved();
return true;
}
function clearDigit(i) {
if (giv[i] || g[i] === 0) {
return;
}
g[i] = 0;
pt[i] = -9;
checkSolved();
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// ── 그리기 도구
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 doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 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 disc(x, y, r, c, a) {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, c, a, w) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s, x, y, c, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x, y, w, h) { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name, val, frac) {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// ── 판 그리기 — 칸 색은 장면이 정하고, 숫자는 판이 그린다
const EMPTY = [58, 53, 88];
const PLACED = [190, 214, 246];
const PLACED_INK = [28, 54, 118];
function ring(x, y, w, h, r, c, a, lw) {
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = lw;
ctx.stroke();
}
function drawBoard(fillOf, extra) {
const s = 9 * CS + 2 * GAP;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < 81; i += 1) {
const x = cellX(i);
const y = cellY(i);
let f = fillOf(i);
if (f === null) {
f = g[i] === 0 ? EMPTY : (giv[i] ? NEU : PLACED);
}
box(x + 1, y + 1, CS - 2, CS - 2, 4, f, 1);
if (g[i] !== 0) {
const a = still ? 99 : sceneT - pt[i];
const k = a < 0.3 ? 0.7 + 0.3 * easeOut(a / 0.3) : 1;
text(String(g[i]), x + CS / 2, y + CS / 2 + 1, giv[i] ? INK : PLACED_INK, Math.round(14 * k), giv[i] ? 800 : 700, 'center');
}
else if (extra !== null) {
extra(i, x, y);
}
}
if (!demo) {
ring(cellX(cur) + 0.5, cellY(cur) + 0.5, CS - 1, CS - 1, 4, PAPER, 1, 2);
}
}
// 묶음 하나를 옅게 물들인다
function tintUnit(u, c, a) {
for (let k = 0; k < 9; k += 1) {
const i = UNITS[u][k];
box(cellX(i) + 1, cellY(i) + 1, CS - 2, CS - 2, 4, c, a);
// 물들임이 숫자를 흐리지 않도록 숫자를 다시 얹는다
if (g[i] !== 0) {
text(String(g[i]), cellCx(i), cellCy(i) + 1, giv[i] ? INK : PLACED_INK, 14, giv[i] ? 800 : 700, 'center');
}
}
}
// 候補 비트 줄 — 숫자 1~9 칸 아홉 개, 켜진 비트만 밝다
function bitRow(x, y, m, on, off) {
for (let d = 1; d <= 9; d += 1) {
const lit = (m & bit(d)) !== 0;
const bx = x + (d - 1) * 18;
box(bx, y, 16, 16, 4, lit ? on : off, 1);
text(String(d), bx + 8, y + 8.5, lit ? INK : DIM, 10, 700, 'center');
}
}
// ── 상태 줄·낭독 문장
const SPK_FILL = '{a} マスのうち {b} マスが埋まっている';
const SPK_DONE = '盤が完成、{a} マスすべてが埋まった';
function speakBoard() {
return solvedNow() ? fill(SPK_DONE, '81') : fill(SPK_FILL, '81', String(filledOf(g)));
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 100 + 채운 칸 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode) {
if (solvedNow()) {
return 1000;
}
return manual ? 100 + filledOf(g) : sceneMode;
}
// 방금 놓은 칸의 줄·세로줄·箱 세 묶음을 한 번 물들였다 서서히 빼는 번짐 — 숫자 하나가 어디까지 영향을 주는지 보인다
function unitPulse(c, a, dur) {
if (rip.i < 0 || still) {
return;
}
const k = (sceneT - rip.t) / dur;
if (k < 0 || k >= 1) {
return;
}
for (let kind = 0; kind < 3; kind += 1) {
tintUnit(unitIndex(kind, rip.i), c, a * (1 - k));
}
}
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 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
}
function takeOver() {
if (manual) {
return;
}
manual = true;
wake();
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say() {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
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);
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow() {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) {
say();
}
}
}
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;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest() {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak) {
if (paused || still) {
sceneT += 1;
}
update(0);
showNow();
if (speak) {
say();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
say();
}
function restart() {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// 새 판 — 씨앗이 달라진 판으로 시간표를 처음부터. 손으로 하던 중이어도 자동 시연으로 돌아간다
function newBoard() {
manual = false;
lap += 1;
begin();
update(0);
showNow();
say();
}
function moveCur(dx, dy) {
takeOver();
cur = clamp(rowOf(cur) + dy, 0, 8) * 9 + clamp(colOf(cur) + dx, 0, 8);
afterInput(true);
}
// 힌트 기본 — 고른 빈칸에 정답 숫자를 놓는다. 장면이 hintHook 을 걸어 두면 그쪽이 먼저
function hintDefault() {
if (!giv[cur] && g[cur] === 0) {
putDigit(cur, sol[cur]);
}
}
function toCanvas(e) {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 칸 이동, 1~9 = 숫자 입력, Delete·Backspace·0 = 지우기, H = 힌트,
// N = 새 판, R = 처음부터, Enter·Space = 一時停止
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'r' || k === 'R') {
restart();
}
else if (k === 'n' || k === 'N') {
e.preventDefault();
newBoard();
}
else if (k === 'ArrowLeft') {
e.preventDefault();
moveCur(-1, 0);
}
else if (k === 'ArrowRight') {
e.preventDefault();
moveCur(1, 0);
}
else if (k === 'ArrowUp') {
e.preventDefault();
moveCur(0, -1);
}
else if (k === 'ArrowDown') {
e.preventDefault();
moveCur(0, 1);
}
else if (k.length === 1 && k >= '1' && k <= '9') {
e.preventDefault();
takeOver();
selDigit = Number(k);
putDigit(cur, selDigit);
afterInput(true);
}
else if (k === 'Delete' || k === 'Backspace' || k === '0') {
e.preventDefault();
takeOver();
clearDigit(cur);
afterInput(true);
}
else if (k === 'h' || k === 'H') {
e.preventDefault();
takeOver();
if (hintHook !== null) {
hintHook();
}
else {
hintDefault();
}
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
pad.addEventListener('pointerdown', function (e) {
takeOver();
ptrDown = true;
try {
pad.setPointerCapture(e.pointerId);
}
catch (err) {
ptrDown = true;
}
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) {
cur = c;
}
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (!ptrDown) {
return;
}
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) {
cur = c;
}
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e) {
if (!ptrDown) {
return;
}
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { 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 — 1~9 키마다 아직 놓지 않은 개수를 링 호로 그리고, 9개를 다 놓으면 키가 흐려진다. 숫자를 놓으면 호가 짧아지고 고른 키와 같은 숫자 칸이 한꺼번에 밝아진다
const CLUES_07 = 44;
const PUT_GAP = 0.28;
const ARC_RATE = 9;
let LAP_S = 7;
let REST_S = 4.6;
let ak = [];
let plan07 = [];
let putN = 0;
const SPK_KEY = '数字 {a} のキー、残り {b} 個';
function leftOf(d) {
let c = 0;
for (let i = 0; i < 81; i += 1) {
if (g[i] === d) {
c += 1;
}
}
return 9 - c;
}
function putStep(k) {
return function () {
selDigit = plan07[k][1];
if (putDigit(plan07[k][0], plan07[k][1])) {
putN += 1;
}
};
}
function build() {
putN = 0;
const s = fullBoard(91 + lap * 2);
const d = digBoard(s, 101 + lap, CLUES_07, false);
setBoard(d.puz, s);
selDigit = 0;
ak = [];
for (let k = 1; k <= 9; k += 1) {
ak.push(leftOf(k) / 9);
}
// 남은 개수가 가장 적은 숫자 셋을 차례로 끝까지 놓는다 — 키가 하나씩 흐려지는 모습
const ds = [];
for (let k = 1; k <= 9; k += 1) {
if (leftOf(k) > 0) {
ds.push(k);
}
}
ds.sort(function (a, b) { return leftOf(a) - leftOf(b) || a - b; });
plan07 = [];
for (let q = 0; q < 3 && q < ds.length; q += 1) {
for (let i = 0; i < 81; i += 1) {
if (g[i] === 0 && sol[i] === ds[q] && plan07.length < 12) {
plan07.push([i, ds[q]]);
}
}
}
for (let k = 0; k < plan07.length; k += 1) {
script.push({ t: 0.25 + k * PUT_GAP, f: putStep(k) });
}
LAP_S = 0.25 + plan07.length * PUT_GAP + 1.8;
REST_S = 0.25 + plan07.length * PUT_GAP + 0.2;
}
function update(dt) {
if (dt < 0) {
return;
}
for (let k = 1; k <= 9; k += 1) {
const target = leftOf(k) / 9;
ak[k - 1] = still ? target : ak[k - 1] + (target - ak[k - 1]) * (1 - Math.exp(-dt * ARC_RATE));
}
}
function keyX(d) { return PX + 16 + ((d - 1) % 3) * 62 + 27; }
function keyY(d) { return BY + 14 + Math.floor((d - 1) / 3) * 64 + 27; }
function draw() {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoard(function (i) { return selDigit > 0 && g[i] === selDigit ? SUN : null; }, null);
unitPulse(SKY, 0.24, 0.7);
panel(PX, BY - 5, PW, 223);
for (let d = 1; d <= 9; d += 1) {
const left = leftOf(d);
const a = left === 0 ? 0.38 : 1;
const x = keyX(d);
const y = keyY(d);
disc(x, y, 25, [48, 44, 74], a);
if (ak[d - 1] > 0.03) {
ctx.beginPath();
ctx.arc(x, y, 21, -Math.PI / 2, -Math.PI / 2 + TAU * clamp(ak[d - 1], 0, 1), false);
ctx.strokeStyle = rgba(MINT, a);
ctx.lineWidth = 4;
ctx.lineCap = 'round';
ctx.stroke();
}
if (selDigit === d) {
ctx.beginPath();
ctx.arc(x, y, 27.5, 0, TAU);
ctx.strokeStyle = rgba(SUN, 1);
ctx.lineWidth = 2;
ctx.stroke();
}
text(String(d), x, y - 3, PAPER, 16, 800, 'center');
text(String(left), x, y + 12, DIM, 9, 700, 'center');
}
const sd = selDigit > 0 ? selDigit : 9;
hud('left[' + sd + ']', String(leftOf(sd)), leftOf(sd) / 9);
}
function statusText() {
let sum = 0;
for (let d = 1; d <= 9; d += 1) {
sum += leftOf(d);
}
return selDigit > 0 ? fill('残りの合計 {a}、選んだ数字 {b}', String(sum), String(selDigit)) : fill('残りの合計 {a}', String(sum));
}
function modeId() { return modeBase(Math.min(3, Math.floor(putN / 4))); }
function speakText() {
const m = selDigit > 0 ? fill(SPK_KEY, String(selDigit), String(leftOf(selDigit))) + '. ' : '';
return m + speakBoard();
}
// 키패드를 누르면 그 숫자를 고른다 — 같은 숫자 칸이 밝아진다
function onPtr(kind, x, y) {
if (kind !== 0) {
return;
}
for (let d = 1; d <= 9; d += 1) {
const dx = x - keyX(d);
const dy = y - keyY(d);
if (dx * dx + dy * dy <= 26 * 26) {
selDigit = d;
}
}
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// DigitLeftRing - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./DigitLeftRing.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function DigitLeftRing() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [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 easeOut(k: number): number { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const SKY: RGB = [150, 192, 244];
const SUN: RGB = [250, 218, 146];
const NEU: RGB = [236, 230, 250];
const BGC: RGB = [46, 42, 68];
const PANEL: RGB = [62, 57, 92];
const DIM: RGB = [196, 190, 224];
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) + ')';
}
function hsl(h: number, s: number, l: number): RGB {
const a = s * Math.min(l, 1 - l);
const f = function (n: number): number {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a: number, b: number): number {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s: string, a: string, b?: string, c?: string): string {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v: number): string { return String(Math.round(v * 10) / 10); }
function group(v: number): string {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { o += ','; }
o += s.charAt(i);
}
return o;
}
// ── 스도쿠 모델 — 판은 81칸 숫자 배열(0 은 빈칸). 후보는 9비트 한 수: 숫자 d 는 (d-1) 번째 비트
const ALL = 511;
function bit(d: number): number { return 1 << (d - 1); }
function popcount(m: number): number {
let c = 0;
let v = m;
while (v) { v &= v - 1; c += 1; }
return c;
}
// 가장 낮은 비트 하나가 가리키는 숫자
function lowDigit(m: number): number { return 32 - Math.clz32(m & -m); }
function bin9(m: number): string {
let s = '';
for (let d = 9; d >= 1; d -= 1) { s += (m & bit(d)) ? '1' : '0'; }
return s;
}
function rowOf(i: number): number { return Math.floor(i / 9); }
function colOf(i: number): number { return i % 9; }
function boxOf(i: number): number { return Math.floor(i / 27) * 3 + Math.floor((i % 9) / 3); }
// 묶음 27개 — 줄 9 · 세로줄 9 · 상자 9. 칸의 이웃은 같은 묶음 세 곳의 다른 칸 20개
const UNITS: number[][] = [];
const PEERS: number[][] = [];
for (let k = 0; k < 27; k += 1) { UNITS.push([]); }
for (let i = 0; i < 81; i += 1) {
UNITS[rowOf(i)].push(i);
UNITS[9 + colOf(i)].push(i);
UNITS[18 + boxOf(i)].push(i);
}
for (let i = 0; i < 81; i += 1) {
const ps: number[] = [];
for (let j = 0; j < 81; j += 1) {
if (j !== i && (rowOf(j) === rowOf(i) || colOf(j) === colOf(i) || boxOf(j) === boxOf(i))) { ps.push(j); }
}
PEERS.push(ps);
}
// 묶음 종류 kind: 0 줄 · 1 세로줄 · 2 상자
function unitIndex(kind: number, i: number): number { return kind === 0 ? rowOf(i) : (kind === 1 ? 9 + colOf(i) : 18 + boxOf(i)); }
function sharedKind(i: number, j: number): number {
if (rowOf(i) === rowOf(j)) { return 0; }
if (colOf(i) === colOf(j)) { return 1; }
return boxOf(i) === boxOf(j) ? 2 : -1;
}
// 칸 i 가 속한 kind 묶음에서 i 를 뺀 칸들이 이미 쓴 숫자의 비트 합(OR)
function usedMask(b: number[], i: number, kind: number): number {
const u = UNITS[unitIndex(kind, i)];
let m = 0;
for (let k = 0; k < 9; k += 1) { if (u[k] !== i && b[u[k]] !== 0) { m |= bit(b[u[k]]); } }
return m;
}
// 후보 = 9비트 전체에서 세 묶음이 쓴 비트를 뺀 것. 채워진 칸의 후보는 0
function candMask(b: number[], i: number): number {
if (b[i] !== 0) { return 0; }
return ALL & ~(usedMask(b, i, 0) | usedMask(b, i, 1) | usedMask(b, i, 2));
}
function filledOf(b: number[]): number {
let c = 0;
for (let i = 0; i < 81; i += 1) { if (b[i] !== 0) { c += 1; } }
return c;
}
function perm(n: number, seed: number): number[] {
const a: number[] = [];
for (let i = 0; i < n; i += 1) { a.push(i); }
for (let i = n - 1; i > 0; i -= 1) {
const j = Math.floor(hash2(i, seed) * (i + 1));
const t = a[i];
a[i] = a[j];
a[j] = t;
}
return a;
}
// 완성판 — 기본 무늬를 숫자·줄·상자 묶음 순서로 섞는다. 같은 씨앗이면 같은 판
function fullBoard(seed: number): number[] {
const dp = perm(9, seed * 7 + 1);
const bp = perm(3, seed * 7 + 2);
const sp = perm(3, seed * 7 + 3);
const rows: number[] = [];
const cols: number[] = [];
for (let k = 0; k < 3; k += 1) {
const rp = perm(3, seed * 13 + k * 5 + 4);
const cp = perm(3, seed * 13 + k * 5 + 9);
for (let j = 0; j < 3; j += 1) {
rows.push(bp[k] * 3 + rp[j]);
cols.push(sp[k] * 3 + cp[j]);
}
}
const b: number[] = [];
for (let r = 0; r < 9; r += 1) {
for (let c = 0; c < 9; c += 1) {
b.push(dp[(3 * (rows[r] % 3) + Math.floor(rows[r] / 3) + cols[c]) % 9] + 1);
}
}
return b;
}
// 풀이 탐색 — 가장 후보가 적은 칸(plain 이면 앞쪽 빈칸)부터 숫자를 걸고, 막히면 되돌린다.
// count 가 limit 에 닿으면 멈추고(풀이 개수는 limit 까지만 센다), 걸어 본 숫자가 cap 을 넘으면 멈춘다.
// trace 에는 (종류 << 16) | (칸 << 4) | 숫자 — 종류 0 은 걸기, 1 은 되돌리기
type Srch = { count: number; nodes: number; dead: number; branch: number; capHit: boolean; first: number[]; trace: number[] };
function search(start: number[], limit: number, cap: number, plain: boolean, wantTrace: boolean): Srch {
const b = start.slice();
const rm: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const cm: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const xm: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
rm[rowOf(i)] |= bit(b[i]);
cm[colOf(i)] |= bit(b[i]);
xm[boxOf(i)] |= bit(b[i]);
}
}
const r: Srch = { count: 0, nodes: 0, dead: 0, branch: 0, capHit: false, first: [], trace: [] };
function rec(): void {
let best = -1;
let bm = 0;
let bc = 10;
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) { continue; }
const m = ALL & ~(rm[rowOf(i)] | cm[colOf(i)] | xm[boxOf(i)]);
const pc = popcount(m);
if (plain) { best = i; bm = m; bc = pc; break; }
if (pc < bc) { best = i; bm = m; bc = pc; if (pc <= 1) { break; } }
}
if (best < 0) {
r.count += 1;
if (r.count === 1) { r.first = b.slice(); }
return;
}
if (bc === 0) { r.dead += 1; return; }
if (bc > 1) { r.branch += 1; }
let mm = bm;
while (mm) {
const lo = mm & -mm;
mm ^= lo;
const d = 32 - Math.clz32(lo);
r.nodes += 1;
if (r.nodes > cap) { r.capHit = true; return; }
b[best] = d;
rm[rowOf(best)] |= lo;
cm[colOf(best)] |= lo;
xm[boxOf(best)] |= lo;
if (wantTrace) { r.trace.push((best << 4) | d); }
rec();
if (r.count >= limit || r.capHit) { return; }
b[best] = 0;
rm[rowOf(best)] &= ~lo;
cm[colOf(best)] &= ~lo;
xm[boxOf(best)] &= ~lo;
if (wantTrace) { r.trace.push((1 << 16) | (best << 4) | d); }
}
}
rec();
return r;
}
// 하나뿐인 후보 채우기 — 후보가 하나인 칸(번호가 가장 앞선 칸)을 채우고 다시 센다. 더 없으면 멈춘다
type Singles = { order: number[]; digits: number[]; avail: number[]; solved: boolean; board: number[] };
function singlesFill(start: number[]): Singles {
const b = start.slice();
const order: number[] = [];
const digits: number[] = [];
const avail: number[] = [];
for (;;) {
let first = -1;
let n = 0;
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0 && popcount(candMask(b, i)) === 1) {
if (first < 0) { first = i; }
n += 1;
}
}
if (first < 0) { break; }
const d = lowDigit(candMask(b, first));
b[first] = d;
order.push(first);
digits.push(d);
avail.push(n);
}
let solved = true;
for (let i = 0; i < 81; i += 1) { if (b[i] === 0) { solved = false; } }
return { order: order, digits: digits, avail: avail, solved: solved, board: b };
}
// 구멍 파기 — 완성판에서 칸을 해시 순서로 하나씩 비우고 풀이 개수를 2 까지만 센다.
// 하나뿐이면 비운 채로 두고, 둘 이상이면 되돌린다. singlesOnly 이면 하나뿐인 후보만으로 풀리는 판만 남긴다
type DigStep = { cell: number; cnt: number; ok: boolean; nodes: number };
type Dug = { puz: number[]; steps: DigStep[]; clues: number };
function digBoard(sol: number[], seed: number, target: number, singlesOnly: boolean): Dug {
const puz = sol.slice();
const order = perm(81, seed * 31 + 5);
const steps: DigStep[] = [];
let clues = 81;
for (let k = 0; k < 81; k += 1) {
if (clues <= target) { break; }
const c = order[k];
const d = puz[c];
puz[c] = 0;
const s = search(puz, 2, 6000, false, false);
const cnt = s.capHit ? 2 : Math.min(2, s.count);
const ok = cnt === 1 && (!singlesOnly || singlesFill(puz).solved);
steps.push({ cell: c, cnt: cnt, ok: ok, nodes: s.nodes });
if (ok) { clues -= 1; } else { puz[c] = d; }
}
return { puz: puz, steps: steps, clues: clues };
}
// 놓은 숫자가 겹치는 쌍 — [칸, 칸, 묶음 종류]
function conflicts(b: number[]): number[][] {
const out: number[][] = [];
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0) { continue; }
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (p > i && b[p] === b[i]) { out.push([i, p, sharedKind(i, p)]); }
}
}
return out;
}
// ── 판 상태 — 장면이 아니라 판이 들고 있는 것. 화면은 이 값을 읽어 그린다
const BX = 22;
const BY = 44;
const CS = 23;
const GAP = 3;
const PX = 258;
const PW = 210;
let g: number[] = [];
let giv: boolean[] = [];
let sol: number[] = [];
let pt: number[] = [];
let cur = 40;
let solvedT = -1;
let lastCell = -1;
type Rip = { i: number; t: number; list: number[]; d: number };
let rip: Rip = { i: -1, t: -9, list: [], d: 0 };
let selDigit = 0;
let still = false;
let hintHook = null as (() => void) | null;
function cellX(i: number): number { const c = colOf(i); return BX + c * CS + Math.floor(c / 3) * GAP; }
function cellY(i: number): number { const r = rowOf(i); return BY + r * CS + Math.floor(r / 3) * GAP; }
function cellCx(i: number): number { return cellX(i) + CS / 2; }
function cellCy(i: number): number { return cellY(i) + CS / 2; }
function cellAt(x: number, y: number): number {
let c = -1;
let r = -1;
for (let k = 0; k < 9; k += 1) {
const x0 = BX + k * CS + Math.floor(k / 3) * GAP;
const y0 = BY + k * CS + Math.floor(k / 3) * GAP;
if (x >= x0 && x < x0 + CS) { c = k; }
if (y >= y0 && y < y0 + CS) { r = k; }
}
return c < 0 || r < 0 ? -1 : r * 9 + c;
}
function setBoard(puz: number[], solution: number[]): void {
g = puz.slice();
sol = solution.slice();
giv = [];
pt = [];
for (let i = 0; i < 81; i += 1) { giv.push(puz[i] !== 0); pt.push(-9); }
solvedT = -1;
lastCell = -1;
rip = { i: -1, t: -9, list: [], d: 0 };
}
function solvedNow(): boolean { return filledOf(g) === 81 && conflicts(g).length === 0; }
function checkSolved(): void {
if (solvedNow()) { if (solvedT < 0) { solvedT = sceneT; } } else { solvedT = -1; }
}
// 숫자를 놓는다 — 놓기 전에 그 숫자가 후보에 있던 이웃 칸을 기억해 두면 소거가 번지는 모습을 그릴 수 있다
function putDigit(i: number, d: number): boolean {
if (giv[i] || d < 1 || d > 9) { return false; }
const list: number[] = [];
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (g[p] === 0 && (candMask(g, p) & bit(d)) !== 0) { list.push(p); }
}
g[i] = d;
pt[i] = sceneT;
rip = { i: i, t: sceneT, list: list, d: d };
lastCell = i;
checkSolved();
return true;
}
function clearDigit(i: number): void {
if (giv[i] || g[i] === 0) { return; }
g[i] = 0;
pt[i] = -9;
checkSolved();
}
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)]; }
// ── 그리기 도구
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 doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 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 disc(x: number, y: number, r: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, c: RGB, a: number, w: number): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x: number, y: number, w: number, h: number): void { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name: string, val: string, frac: number): void {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// ── 판 그리기 — 칸 색은 장면이 정하고, 숫자는 판이 그린다
const EMPTY: RGB = [58, 53, 88];
const PLACED: RGB = [190, 214, 246];
const PLACED_INK: RGB = [28, 54, 118];
function ring(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number, lw: number): void {
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = lw;
ctx.stroke();
}
function drawBoard(fillOf: (i: number) => RGB | null, extra: ((i: number, x: number, y: number) => void) | null): void {
const s = 9 * CS + 2 * GAP;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < 81; i += 1) {
const x = cellX(i);
const y = cellY(i);
let f: RGB | null = fillOf(i);
if (f === null) { f = g[i] === 0 ? EMPTY : (giv[i] ? NEU : PLACED); }
box(x + 1, y + 1, CS - 2, CS - 2, 4, f, 1);
if (g[i] !== 0) {
const a = still ? 99 : sceneT - pt[i];
const k = a < 0.3 ? 0.7 + 0.3 * easeOut(a / 0.3) : 1;
text(String(g[i]), x + CS / 2, y + CS / 2 + 1, giv[i] ? INK : PLACED_INK, Math.round(14 * k), giv[i] ? 800 : 700, 'center');
} else if (extra !== null) {
extra(i, x, y);
}
}
if (!demo) { ring(cellX(cur) + 0.5, cellY(cur) + 0.5, CS - 1, CS - 1, 4, PAPER, 1, 2); }
}
// 묶음 하나를 옅게 물들인다
function tintUnit(u: number, c: RGB, a: number): void {
for (let k = 0; k < 9; k += 1) {
const i = UNITS[u][k];
box(cellX(i) + 1, cellY(i) + 1, CS - 2, CS - 2, 4, c, a);
// 물들임이 숫자를 흐리지 않도록 숫자를 다시 얹는다
if (g[i] !== 0) { text(String(g[i]), cellCx(i), cellCy(i) + 1, giv[i] ? INK : PLACED_INK, 14, giv[i] ? 800 : 700, 'center'); }
}
}
// 후보 비트 줄 — 숫자 1~9 칸 아홉 개, 켜진 비트만 밝다
function bitRow(x: number, y: number, m: number, on: RGB, off: RGB): void {
for (let d = 1; d <= 9; d += 1) {
const lit = (m & bit(d)) !== 0;
const bx = x + (d - 1) * 18;
box(bx, y, 16, 16, 4, lit ? on : off, 1);
text(String(d), bx + 8, y + 8.5, lit ? INK : DIM, 10, 700, 'center');
}
}
// ── 상태 줄·낭독 문장
const SPK_FILL = '{a}칸 중 {b}칸이 채워져 있다';
const SPK_DONE = '판 완성, {a}칸이 모두 채워졌다';
function speakBoard(): string {
return solvedNow() ? fill(SPK_DONE, '81') : fill(SPK_FILL, '81', String(filledOf(g)));
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 100 + 채운 칸 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode: number): number {
if (solvedNow()) { return 1000; }
return manual ? 100 + filledOf(g) : sceneMode;
}
// 방금 놓은 칸의 줄·세로줄·상자 세 묶음을 한 번 물들였다 서서히 빼는 번짐 — 숫자 하나가 어디까지 영향을 주는지 보인다
function unitPulse(c: RGB, a: number, dur: number): void {
if (rip.i < 0 || still) { return; }
const k = (sceneT - rip.t) / dur;
if (k < 0 || k >= 1) { return; }
for (let kind = 0; kind < 3; kind += 1) { tintUnit(unitIndex(kind, rip.i), c, a * (1 - k)); }
}
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 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
}
function takeOver(): void {
if (manual) { return; }
manual = true;
wake();
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say(): void {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
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);
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow(): void {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) { say(); }
}
}
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;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest(): void {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak: boolean): void {
if (paused || still) { sceneT += 1; }
update(0);
showNow();
if (speak) { say(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
say();
}
function restart(): void {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// 새 판 — 씨앗이 달라진 판으로 시간표를 처음부터. 손으로 하던 중이어도 자동 시연으로 돌아간다
function newBoard(): void {
manual = false;
lap += 1;
begin();
update(0);
showNow();
say();
}
function moveCur(dx: number, dy: number): void {
takeOver();
cur = clamp(rowOf(cur) + dy, 0, 8) * 9 + clamp(colOf(cur) + dx, 0, 8);
afterInput(true);
}
// 힌트 기본 — 고른 빈칸에 정답 숫자를 놓는다. 장면이 hintHook 을 걸어 두면 그쪽이 먼저
function hintDefault(): void {
if (!giv[cur] && g[cur] === 0) { putDigit(cur, sol[cur]); }
}
function toCanvas(e: PointerEvent): number[] {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 칸 이동, 1~9 = 숫자 입력, Delete·Backspace·0 = 지우기, H = 힌트,
// N = 새 판, R = 처음부터, Enter·Space = 일시정지
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'r' || k === 'R') {
restart();
} else if (k === 'n' || k === 'N') {
e.preventDefault();
newBoard();
} else if (k === 'ArrowLeft') {
e.preventDefault();
moveCur(-1, 0);
} else if (k === 'ArrowRight') {
e.preventDefault();
moveCur(1, 0);
} else if (k === 'ArrowUp') {
e.preventDefault();
moveCur(0, -1);
} else if (k === 'ArrowDown') {
e.preventDefault();
moveCur(0, 1);
} else if (k.length === 1 && k >= '1' && k <= '9') {
e.preventDefault();
takeOver();
selDigit = Number(k);
putDigit(cur, selDigit);
afterInput(true);
} else if (k === 'Delete' || k === 'Backspace' || k === '0') {
e.preventDefault();
takeOver();
clearDigit(cur);
afterInput(true);
} else if (k === 'h' || k === 'H') {
e.preventDefault();
takeOver();
if (hintHook !== null) { hintHook(); } else { hintDefault(); }
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
ptrDown = true;
try { pad.setPointerCapture(e.pointerId); } catch (err) { ptrDown = true; }
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) { cur = c; }
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (!ptrDown) { return; }
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) { cur = c; }
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e: PointerEvent): void {
if (!ptrDown) { return; }
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { 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 — 1~9 키마다 아직 놓지 않은 개수를 링 호로 그리고, 9개를 다 놓으면 키가 흐려진다. 숫자를 놓으면 호가 짧아지고 고른 키와 같은 숫자 칸이 한꺼번에 밝아진다
const CLUES_07 = 44;
const PUT_GAP = 0.28;
const ARC_RATE = 9;
let LAP_S = 7;
let REST_S = 4.6;
let ak: number[] = [];
let plan07: number[][] = [];
let putN = 0;
const SPK_KEY = '숫자 {a} 키, 남은 개수 {b}';
function leftOf(d: number): number {
let c = 0;
for (let i = 0; i < 81; i += 1) { if (g[i] === d) { c += 1; } }
return 9 - c;
}
function putStep(k: number): () => void {
return function (): void {
selDigit = plan07[k][1];
if (putDigit(plan07[k][0], plan07[k][1])) { putN += 1; }
};
}
function build(): void {
putN = 0;
const s = fullBoard(91 + lap * 2);
const d = digBoard(s, 101 + lap, CLUES_07, false);
setBoard(d.puz, s);
selDigit = 0;
ak = [];
for (let k = 1; k <= 9; k += 1) { ak.push(leftOf(k) / 9); }
// 남은 개수가 가장 적은 숫자 셋을 차례로 끝까지 놓는다 — 키가 하나씩 흐려지는 모습
const ds: number[] = [];
for (let k = 1; k <= 9; k += 1) { if (leftOf(k) > 0) { ds.push(k); } }
ds.sort(function (a: number, b: number): number { return leftOf(a) - leftOf(b) || a - b; });
plan07 = [];
for (let q = 0; q < 3 && q < ds.length; q += 1) {
for (let i = 0; i < 81; i += 1) { if (g[i] === 0 && sol[i] === ds[q] && plan07.length < 12) { plan07.push([i, ds[q]]); } }
}
for (let k = 0; k < plan07.length; k += 1) { script.push({ t: 0.25 + k * PUT_GAP, f: putStep(k) }); }
LAP_S = 0.25 + plan07.length * PUT_GAP + 1.8;
REST_S = 0.25 + plan07.length * PUT_GAP + 0.2;
}
function update(dt: number): void {
if (dt < 0) { return; }
for (let k = 1; k <= 9; k += 1) {
const target = leftOf(k) / 9;
ak[k - 1] = still ? target : ak[k - 1] + (target - ak[k - 1]) * (1 - Math.exp(-dt * ARC_RATE));
}
}
function keyX(d: number): number { return PX + 16 + ((d - 1) % 3) * 62 + 27; }
function keyY(d: number): number { return BY + 14 + Math.floor((d - 1) / 3) * 64 + 27; }
function draw(): void {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoard(function (i: number): RGB | null { return selDigit > 0 && g[i] === selDigit ? SUN : null; }, null);
unitPulse(SKY, 0.24, 0.7);
panel(PX, BY - 5, PW, 223);
for (let d = 1; d <= 9; d += 1) {
const left = leftOf(d);
const a = left === 0 ? 0.38 : 1;
const x = keyX(d);
const y = keyY(d);
disc(x, y, 25, [48, 44, 74], a);
if (ak[d - 1] > 0.03) {
ctx.beginPath();
ctx.arc(x, y, 21, -Math.PI / 2, -Math.PI / 2 + TAU * clamp(ak[d - 1], 0, 1), false);
ctx.strokeStyle = rgba(MINT, a);
ctx.lineWidth = 4;
ctx.lineCap = 'round';
ctx.stroke();
}
if (selDigit === d) {
ctx.beginPath();
ctx.arc(x, y, 27.5, 0, TAU);
ctx.strokeStyle = rgba(SUN, 1);
ctx.lineWidth = 2;
ctx.stroke();
}
text(String(d), x, y - 3, PAPER, 16, 800, 'center');
text(String(left), x, y + 12, DIM, 9, 700, 'center');
}
const sd = selDigit > 0 ? selDigit : 9;
hud('left[' + sd + ']', String(leftOf(sd)), leftOf(sd) / 9);
}
function statusText(): string {
let sum = 0;
for (let d = 1; d <= 9; d += 1) { sum += leftOf(d); }
return selDigit > 0 ? fill('남은 개수 합 {a}, 고른 숫자 {b}', String(sum), String(selDigit)) : fill('남은 개수 합 {a}', String(sum));
}
function modeId(): number { return modeBase(Math.min(3, Math.floor(putN / 4))); }
function speakText(): string {
const m = selDigit > 0 ? fill(SPK_KEY, String(selDigit), String(leftOf(selDigit))) + '. ' : '';
return m + speakBoard();
}
// 키패드를 누르면 그 숫자를 고른다 — 같은 숫자 칸이 밝아진다
function onPtr(kind: number, x: number, y: number): void {
if (kind !== 0) { return; }
for (let d = 1; d <= 9; d += 1) {
const dx = x - keyX(d);
const dy = y - keyY(d);
if (dx * dx + dy * dy <= 26 * 26) { selDigit = d; }
}
}
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={'스도쿠 시연 무대 — 방향키로 칸 이동, 1부터 9 숫자키로 입력, Delete 로 지우기, H 로 힌트, N 으로 새 판, R 로 처음부터, Enter 또는 Space 로 일시정지'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'9×9 판 옆에 숫자 1부터 9까지의 키패드가 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>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}07 · 数字ごとの残り数をリングで縮めるキーパッド — 計測 累積面積 20.815% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function leftOf(d) {
let c = 0;
for (let i = 0; i < 81; i += 1) {
if (g[i] === d) {
c += 1;
}
}
return 9 - c;
}
弧は一気に縮まず ARC_RATE の9で目標の長さを追い、最初の盤の残り数は[1,4,2,5,4,7,5,5,4]で合計37です。場面は残りがいちばん少ない三つの数字の空きを埋め、三つの盤で7・9・7マスを置きます。
08完成するとマスごとに時間差で広がる波
盤が埋まって重なりがなければ、最後のマスからマンハッタン距離が遠いマスほど遅く明るくなる波が広がり、解けたときのごほうびを出す仕上げの画面になります。時間差は WAVE_MS に距離を掛けた値です。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="数独の舞台 — 矢印キーでマス移動、数字キー1〜9で入力、Deleteで消去、Hでヒント、Nで新しい盤、Rで最初から、EnterかSpaceで一時停止">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="ほぼ埋まった9×9の盤の残りの空きマスが順に埋まり、最後のマスが埋まるとそこから波が広がる。街区距離が遠いマスほど遅れて色が付き、大きくなって落ち着く。マスごとの遅れはWAVE_MS 40に距離を掛けたもので、波が通ったマスにはうすい色が残る。右のパネルには距離0から16までの遅れの棒十七本と、いまの時刻を指す縦線が出る。下の目盛りは定数名と今の値を示し、動きを減らす設定では数字がすぐ埋まった盤一枚だけを描きます"></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>
</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-ink;
}
// 무대 상자 — 눌러서 한 번 깨뜨리는 곳(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-cream;
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 {
padding: $sp-1 $sp-3;
border: 2px solid $subject-cream;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
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__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-ink;
color: $subject-cream;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면에만 있고 aria-live 는 늘 off
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 100px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
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__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 글리치 없는 원문 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause: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: #17141a;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #fff7e6;
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 {
padding: 4px 12px;
border: 2px solid #fff7e6;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
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__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 100px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
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__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause: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 easeOut(k) { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const SKY = [150, 192, 244];
const SUN = [250, 218, 146];
const NEU = [236, 230, 250];
const BGC = [46, 42, 68];
const PANEL = [62, 57, 92];
const DIM = [196, 190, 224];
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) + ')';
}
function hsl(h, s, l) {
const a = s * Math.min(l, 1 - l);
const f = function (n) {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a, b) {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s, a, b, c) {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v) { return String(Math.round(v * 10) / 10); }
function group(v) {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
o += ',';
}
o += s.charAt(i);
}
return o;
}
// ── 스도쿠 모델 — 판은 81칸 숫자 배열(0 은 빈칸). 후보는 9비트 한 수: 숫자 d 는 (d-1) 번째 비트
const ALL = 511;
function bit(d) { return 1 << (d - 1); }
function popcount(m) {
let c = 0;
let v = m;
while (v) {
v &= v - 1;
c += 1;
}
return c;
}
// 가장 낮은 비트 하나가 가리키는 숫자
function lowDigit(m) { return 32 - Math.clz32(m & -m); }
function bin9(m) {
let s = '';
for (let d = 9; d >= 1; d -= 1) {
s += (m & bit(d)) ? '1' : '0';
}
return s;
}
function rowOf(i) { return Math.floor(i / 9); }
function colOf(i) { return i % 9; }
function boxOf(i) { return Math.floor(i / 27) * 3 + Math.floor((i % 9) / 3); }
// 묶음 27개 — 줄 9 · 세로줄 9 · 箱 9. 칸의 이웃은 같은 묶음 세 곳의 다른 칸 20개
const UNITS = [];
const PEERS = [];
for (let k = 0; k < 27; k += 1) {
UNITS.push([]);
}
for (let i = 0; i < 81; i += 1) {
UNITS[rowOf(i)].push(i);
UNITS[9 + colOf(i)].push(i);
UNITS[18 + boxOf(i)].push(i);
}
for (let i = 0; i < 81; i += 1) {
const ps = [];
for (let j = 0; j < 81; j += 1) {
if (j !== i && (rowOf(j) === rowOf(i) || colOf(j) === colOf(i) || boxOf(j) === boxOf(i))) {
ps.push(j);
}
}
PEERS.push(ps);
}
// 묶음 종류 kind: 0 줄 · 1 세로줄 · 2 箱
function unitIndex(kind, i) { return kind === 0 ? rowOf(i) : (kind === 1 ? 9 + colOf(i) : 18 + boxOf(i)); }
function sharedKind(i, j) {
if (rowOf(i) === rowOf(j)) {
return 0;
}
if (colOf(i) === colOf(j)) {
return 1;
}
return boxOf(i) === boxOf(j) ? 2 : -1;
}
// 칸 i 가 속한 kind 묶음에서 i 를 뺀 칸들이 이미 쓴 숫자의 비트 合計(OR)
function usedMask(b, i, kind) {
const u = UNITS[unitIndex(kind, i)];
let m = 0;
for (let k = 0; k < 9; k += 1) {
if (u[k] !== i && b[u[k]] !== 0) {
m |= bit(b[u[k]]);
}
}
return m;
}
// 候補 = 9비트 전체에서 세 묶음이 쓴 비트를 뺀 것. 채워진 칸의 후보는 0
function candMask(b, i) {
if (b[i] !== 0) {
return 0;
}
return ALL & ~(usedMask(b, i, 0) | usedMask(b, i, 1) | usedMask(b, i, 2));
}
function filledOf(b) {
let c = 0;
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
c += 1;
}
}
return c;
}
function perm(n, seed) {
const a = [];
for (let i = 0; i < n; i += 1) {
a.push(i);
}
for (let i = n - 1; i > 0; i -= 1) {
const j = Math.floor(hash2(i, seed) * (i + 1));
const t = a[i];
a[i] = a[j];
a[j] = t;
}
return a;
}
// 완성판 — 기본 무늬를 숫자·줄·箱 묶음 순서로 섞는다. 같은 씨앗이면 같은 판
function fullBoard(seed) {
const dp = perm(9, seed * 7 + 1);
const bp = perm(3, seed * 7 + 2);
const sp = perm(3, seed * 7 + 3);
const rows = [];
const cols = [];
for (let k = 0; k < 3; k += 1) {
const rp = perm(3, seed * 13 + k * 5 + 4);
const cp = perm(3, seed * 13 + k * 5 + 9);
for (let j = 0; j < 3; j += 1) {
rows.push(bp[k] * 3 + rp[j]);
cols.push(sp[k] * 3 + cp[j]);
}
}
const b = [];
for (let r = 0; r < 9; r += 1) {
for (let c = 0; c < 9; c += 1) {
b.push(dp[(3 * (rows[r] % 3) + Math.floor(rows[r] / 3) + cols[c]) % 9] + 1);
}
}
return b;
}
function search(start, limit, cap, plain, wantTrace) {
const b = start.slice();
const rm = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const cm = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const xm = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
rm[rowOf(i)] |= bit(b[i]);
cm[colOf(i)] |= bit(b[i]);
xm[boxOf(i)] |= bit(b[i]);
}
}
const r = { count: 0, nodes: 0, dead: 0, branch: 0, capHit: false, first: [], trace: [] };
function rec() {
let best = -1;
let bm = 0;
let bc = 10;
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
continue;
}
const m = ALL & ~(rm[rowOf(i)] | cm[colOf(i)] | xm[boxOf(i)]);
const pc = popcount(m);
if (plain) {
best = i;
bm = m;
bc = pc;
break;
}
if (pc < bc) {
best = i;
bm = m;
bc = pc;
if (pc <= 1) {
break;
}
}
}
if (best < 0) {
r.count += 1;
if (r.count === 1) {
r.first = b.slice();
}
return;
}
if (bc === 0) {
r.dead += 1;
return;
}
if (bc > 1) {
r.branch += 1;
}
let mm = bm;
while (mm) {
const lo = mm & -mm;
mm ^= lo;
const d = 32 - Math.clz32(lo);
r.nodes += 1;
if (r.nodes > cap) {
r.capHit = true;
return;
}
b[best] = d;
rm[rowOf(best)] |= lo;
cm[colOf(best)] |= lo;
xm[boxOf(best)] |= lo;
if (wantTrace) {
r.trace.push((best << 4) | d);
}
rec();
if (r.count >= limit || r.capHit) {
return;
}
b[best] = 0;
rm[rowOf(best)] &= ~lo;
cm[colOf(best)] &= ~lo;
xm[boxOf(best)] &= ~lo;
if (wantTrace) {
r.trace.push((1 << 16) | (best << 4) | d);
}
}
}
rec();
return r;
}
function singlesFill(start) {
const b = start.slice();
const order = [];
const digits = [];
const avail = [];
for (;;) {
let first = -1;
let n = 0;
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0 && popcount(candMask(b, i)) === 1) {
if (first < 0) {
first = i;
}
n += 1;
}
}
if (first < 0) {
break;
}
const d = lowDigit(candMask(b, first));
b[first] = d;
order.push(first);
digits.push(d);
avail.push(n);
}
let solved = true;
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0) {
solved = false;
}
}
return { order: order, digits: digits, avail: avail, solved: solved, board: b };
}
function digBoard(sol, seed, target, singlesOnly) {
const puz = sol.slice();
const order = perm(81, seed * 31 + 5);
const steps = [];
let clues = 81;
for (let k = 0; k < 81; k += 1) {
if (clues <= target) {
break;
}
const c = order[k];
const d = puz[c];
puz[c] = 0;
const s = search(puz, 2, 6000, false, false);
const cnt = s.capHit ? 2 : Math.min(2, s.count);
const ok = cnt === 1 && (!singlesOnly || singlesFill(puz).solved);
steps.push({ cell: c, cnt: cnt, ok: ok, nodes: s.nodes });
if (ok) {
clues -= 1;
}
else {
puz[c] = d;
}
}
return { puz: puz, steps: steps, clues: clues };
}
// 놓은 숫자가 겹치는 쌍 — [칸, 칸, 묶음 종류]
function conflicts(b) {
const out = [];
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0) {
continue;
}
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (p > i && b[p] === b[i]) {
out.push([i, p, sharedKind(i, p)]);
}
}
}
return out;
}
// ── 판 상태 — 장면이 아니라 판이 들고 있는 것. 화면은 이 값을 읽어 그린다
const BX = 22;
const BY = 44;
const CS = 23;
const GAP = 3;
const PX = 258;
const PW = 210;
let g = [];
let giv = [];
let sol = [];
let pt = [];
let cur = 40;
let solvedT = -1;
let lastCell = -1;
let rip = { i: -1, t: -9, list: [], d: 0 };
let selDigit = 0;
let still = false;
let hintHook = null;
function cellX(i) { const c = colOf(i); return BX + c * CS + Math.floor(c / 3) * GAP; }
function cellY(i) { const r = rowOf(i); return BY + r * CS + Math.floor(r / 3) * GAP; }
function cellCx(i) { return cellX(i) + CS / 2; }
function cellCy(i) { return cellY(i) + CS / 2; }
function cellAt(x, y) {
let c = -1;
let r = -1;
for (let k = 0; k < 9; k += 1) {
const x0 = BX + k * CS + Math.floor(k / 3) * GAP;
const y0 = BY + k * CS + Math.floor(k / 3) * GAP;
if (x >= x0 && x < x0 + CS) {
c = k;
}
if (y >= y0 && y < y0 + CS) {
r = k;
}
}
return c < 0 || r < 0 ? -1 : r * 9 + c;
}
function setBoard(puz, solution) {
g = puz.slice();
sol = solution.slice();
giv = [];
pt = [];
for (let i = 0; i < 81; i += 1) {
giv.push(puz[i] !== 0);
pt.push(-9);
}
solvedT = -1;
lastCell = -1;
rip = { i: -1, t: -9, list: [], d: 0 };
}
function solvedNow() { return filledOf(g) === 81 && conflicts(g).length === 0; }
function checkSolved() {
if (solvedNow()) {
if (solvedT < 0) {
solvedT = sceneT;
}
}
else {
solvedT = -1;
}
}
// 숫자를 놓는다 — 놓기 전에 그 숫자가 후보에 있던 이웃 칸을 기억해 두면 소거가 번지는 모습을 그릴 수 있다
function putDigit(i, d) {
if (giv[i] || d < 1 || d > 9) {
return false;
}
const list = [];
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (g[p] === 0 && (candMask(g, p) & bit(d)) !== 0) {
list.push(p);
}
}
g[i] = d;
pt[i] = sceneT;
rip = { i: i, t: sceneT, list: list, d: d };
lastCell = i;
checkSolved();
return true;
}
function clearDigit(i) {
if (giv[i] || g[i] === 0) {
return;
}
g[i] = 0;
pt[i] = -9;
checkSolved();
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// ── 그리기 도구
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 doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 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 disc(x, y, r, c, a) {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, c, a, w) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s, x, y, c, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x, y, w, h) { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name, val, frac) {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// ── 판 그리기 — 칸 색은 장면이 정하고, 숫자는 판이 그린다
const EMPTY = [58, 53, 88];
const PLACED = [190, 214, 246];
const PLACED_INK = [28, 54, 118];
function ring(x, y, w, h, r, c, a, lw) {
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = lw;
ctx.stroke();
}
function drawBoard(fillOf, extra) {
const s = 9 * CS + 2 * GAP;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < 81; i += 1) {
const x = cellX(i);
const y = cellY(i);
let f = fillOf(i);
if (f === null) {
f = g[i] === 0 ? EMPTY : (giv[i] ? NEU : PLACED);
}
box(x + 1, y + 1, CS - 2, CS - 2, 4, f, 1);
if (g[i] !== 0) {
const a = still ? 99 : sceneT - pt[i];
const k = a < 0.3 ? 0.7 + 0.3 * easeOut(a / 0.3) : 1;
text(String(g[i]), x + CS / 2, y + CS / 2 + 1, giv[i] ? INK : PLACED_INK, Math.round(14 * k), giv[i] ? 800 : 700, 'center');
}
else if (extra !== null) {
extra(i, x, y);
}
}
if (!demo) {
ring(cellX(cur) + 0.5, cellY(cur) + 0.5, CS - 1, CS - 1, 4, PAPER, 1, 2);
}
}
// 묶음 하나를 옅게 물들인다
function tintUnit(u, c, a) {
for (let k = 0; k < 9; k += 1) {
const i = UNITS[u][k];
box(cellX(i) + 1, cellY(i) + 1, CS - 2, CS - 2, 4, c, a);
// 물들임이 숫자를 흐리지 않도록 숫자를 다시 얹는다
if (g[i] !== 0) {
text(String(g[i]), cellCx(i), cellCy(i) + 1, giv[i] ? INK : PLACED_INK, 14, giv[i] ? 800 : 700, 'center');
}
}
}
// 候補 비트 줄 — 숫자 1~9 칸 아홉 개, 켜진 비트만 밝다
function bitRow(x, y, m, on, off) {
for (let d = 1; d <= 9; d += 1) {
const lit = (m & bit(d)) !== 0;
const bx = x + (d - 1) * 18;
box(bx, y, 16, 16, 4, lit ? on : off, 1);
text(String(d), bx + 8, y + 8.5, lit ? INK : DIM, 10, 700, 'center');
}
}
// ── 상태 줄·낭독 문장
const SPK_FILL = '{a} マスのうち {b} マスが埋まっている';
const SPK_DONE = '盤が完成、{a} マスすべてが埋まった';
function speakBoard() {
return solvedNow() ? fill(SPK_DONE, '81') : fill(SPK_FILL, '81', String(filledOf(g)));
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 100 + 채운 칸 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode) {
if (solvedNow()) {
return 1000;
}
return manual ? 100 + filledOf(g) : sceneMode;
}
// 방금 놓은 칸의 줄·세로줄·箱 세 묶음을 한 번 물들였다 서서히 빼는 번짐 — 숫자 하나가 어디까지 영향을 주는지 보인다
function unitPulse(c, a, dur) {
if (rip.i < 0 || still) {
return;
}
const k = (sceneT - rip.t) / dur;
if (k < 0 || k >= 1) {
return;
}
for (let kind = 0; kind < 3; kind += 1) {
tintUnit(unitIndex(kind, rip.i), c, a * (1 - k));
}
}
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 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
}
function takeOver() {
if (manual) {
return;
}
manual = true;
wake();
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say() {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
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);
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow() {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) {
say();
}
}
}
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;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest() {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak) {
if (paused || still) {
sceneT += 1;
}
update(0);
showNow();
if (speak) {
say();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
say();
}
function restart() {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// 새 판 — 씨앗이 달라진 판으로 시간표를 처음부터. 손으로 하던 중이어도 자동 시연으로 돌아간다
function newBoard() {
manual = false;
lap += 1;
begin();
update(0);
showNow();
say();
}
function moveCur(dx, dy) {
takeOver();
cur = clamp(rowOf(cur) + dy, 0, 8) * 9 + clamp(colOf(cur) + dx, 0, 8);
afterInput(true);
}
// 힌트 기본 — 고른 빈칸에 정답 숫자를 놓는다. 장면이 hintHook 을 걸어 두면 그쪽이 먼저
function hintDefault() {
if (!giv[cur] && g[cur] === 0) {
putDigit(cur, sol[cur]);
}
}
function toCanvas(e) {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 칸 이동, 1~9 = 숫자 입력, Delete·Backspace·0 = 지우기, H = 힌트,
// N = 새 판, R = 처음부터, Enter·Space = 一時停止
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'r' || k === 'R') {
restart();
}
else if (k === 'n' || k === 'N') {
e.preventDefault();
newBoard();
}
else if (k === 'ArrowLeft') {
e.preventDefault();
moveCur(-1, 0);
}
else if (k === 'ArrowRight') {
e.preventDefault();
moveCur(1, 0);
}
else if (k === 'ArrowUp') {
e.preventDefault();
moveCur(0, -1);
}
else if (k === 'ArrowDown') {
e.preventDefault();
moveCur(0, 1);
}
else if (k.length === 1 && k >= '1' && k <= '9') {
e.preventDefault();
takeOver();
selDigit = Number(k);
putDigit(cur, selDigit);
afterInput(true);
}
else if (k === 'Delete' || k === 'Backspace' || k === '0') {
e.preventDefault();
takeOver();
clearDigit(cur);
afterInput(true);
}
else if (k === 'h' || k === 'H') {
e.preventDefault();
takeOver();
if (hintHook !== null) {
hintHook();
}
else {
hintDefault();
}
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
pad.addEventListener('pointerdown', function (e) {
takeOver();
ptrDown = true;
try {
pad.setPointerCapture(e.pointerId);
}
catch (err) {
ptrDown = true;
}
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) {
cur = c;
}
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (!ptrDown) {
return;
}
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) {
cur = c;
}
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e) {
if (!ptrDown) {
return;
}
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { 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 — 판이 다 차면 마지막 칸에서 맨해튼 거리가 먼 칸일수록 늦게 밝아지는 물결이 번진다. 칸마다 WAVE_MS 곱하기 거리의 시간차로 색과 크기가 한 번 솟았다 가라앉는다
const WAVE_MS = 40;
const PULSE_S = 0.55;
const CLUES_08 = 70;
const FILL_GAP_08 = 0.07;
let LAP_S = 7;
let REST_S = 4.2;
let plan08 = [];
let filledStep = 0;
const SPK_WAVE = '盤が埋まって波が広がる';
function distOf(i) {
return lastCell < 0 ? 0 : Math.abs(rowOf(i) - rowOf(lastCell)) + Math.abs(colOf(i) - colOf(lastCell));
}
function fillStep(k) {
return function () {
if (putDigit(plan08[k], sol[plan08[k]])) {
filledStep += 1;
}
};
}
function build() {
filledStep = 0;
const s = fullBoard(111 + lap * 2);
const d = digBoard(s, 121 + lap, CLUES_08, false);
setBoard(d.puz, s);
plan08 = [];
const order = perm(81, 7 + lap);
for (let k = 0; k < 81; k += 1) {
if (g[order[k]] === 0) {
plan08.push(order[k]);
}
}
for (let k = 0; k < plan08.length; k += 1) {
script.push({ t: 0.2 + k * FILL_GAP_08, f: fillStep(k) });
}
LAP_S = 0.2 + plan08.length * FILL_GAP_08 + 16 * WAVE_MS / 1000 + PULSE_S + 2.2;
REST_S = 0.2 + plan08.length * FILL_GAP_08 + 16 * WAVE_MS / 1000 + PULSE_S + 0.2;
}
function update(dt) { if (dt < 0) {
return;
} }
// 한 칸의 물결 진행도 — 0 아래는 아직, 1 위는 지나감
function waveP(i) {
if (solvedT < 0) {
return -1;
}
return (sceneT - solvedT - distOf(i) * WAVE_MS / 1000) / PULSE_S;
}
function waveColor(i) { return hsl(150 + distOf(i) * 12, 0.62, 0.82); }
function draw() {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoard(function (i) {
if (solvedT < 0) {
return null;
}
const p = still ? 2 : waveP(i);
const base = g[i] === 0 ? EMPTY : (giv[i] ? NEU : PLACED);
if (p < 0) {
return base;
}
if (p >= 1) {
return mix(base, waveColor(i), 0.45);
}
return mix(base, waveColor(i), 0.45 + 0.55 * Math.sin(Math.PI * p));
}, null);
// 솟는 크기 — 지나가는 칸은 한 번 커졌다 가라앉으며 숫자를 다시 얹는다
if (solvedT >= 0 && !still) {
for (let i = 0; i < 81; i += 1) {
const p = waveP(i);
if (p > 0 && p < 1) {
const k = 1 + 0.2 * Math.sin(Math.PI * p);
const sz = (CS - 2) * k;
box(cellCx(i) - sz / 2, cellCy(i) - sz / 2, sz, sz, 5, waveColor(i), 1);
text(String(g[i]), cellCx(i), cellCy(i) + 1, INK, Math.round(14 * k), 800, 'center');
}
}
}
panel(PX, BY - 5, PW, 223);
text('距離ごとの遅れ', PX + 12, BY + 8, DIM, 11, 700, 'left');
const sc = 150 / (16 * 120);
const el = solvedT >= 0 ? (still ? 99 : sceneT - solvedT) : -1;
for (let k = 0; k <= 16; k += 1) {
const y = BY + 22 + k * 11.5;
const lit = el >= 0 && el >= k * WAVE_MS / 1000;
text(String(k), PX + 12, y + 4, lit ? NEU : DIM, 9, 700, 'center');
box(PX + 22, y, 150, 8, 3, [48, 44, 74], 1);
box(PX + 22, y, Math.max(2, k * WAVE_MS * sc), 8, 3, lit ? MINT : [84, 78, 116], 1);
}
if (el >= 0 && !still && el < 16 * WAVE_MS / 1000 + PULSE_S) {
const x = PX + 22 + el * 1000 * sc;
seg(x, BY + 20, x, BY + 22 + 17 * 11.5, PAPER, 0.9, 2);
}
hud('WAVE_MS', String(WAVE_MS), WAVE_MS / 120);
}
function lit() {
if (solvedT < 0) {
return 0;
}
let n = 0;
for (let i = 0; i < 81; i += 1) {
if (still || waveP(i) >= 0) {
n += 1;
}
}
return n;
}
function statusText() {
return solvedT < 0 ? fill('空きマス残り {a} 個', String(81 - filledOf(g))) : fill('波が点いたマス {a} 個、最後のマス基準', String(lit()));
}
function modeId() { return modeBase(solvedT >= 0 ? 3 : (filledStep > 0 ? 1 : 0)); }
function speakText() {
return (solvedT >= 0 ? SPK_WAVE + '. ' : '') + speakBoard();
}
function onPtr(kind, x, y) { if (kind < 0 || x < 0 || y < 0) {
return;
} }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// SolvedWaveFlourish - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./SolvedWaveFlourish.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SolvedWaveFlourish() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [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 easeOut(k: number): number { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const SKY: RGB = [150, 192, 244];
const SUN: RGB = [250, 218, 146];
const NEU: RGB = [236, 230, 250];
const BGC: RGB = [46, 42, 68];
const PANEL: RGB = [62, 57, 92];
const DIM: RGB = [196, 190, 224];
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) + ')';
}
function hsl(h: number, s: number, l: number): RGB {
const a = s * Math.min(l, 1 - l);
const f = function (n: number): number {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a: number, b: number): number {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s: string, a: string, b?: string, c?: string): string {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v: number): string { return String(Math.round(v * 10) / 10); }
function group(v: number): string {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { o += ','; }
o += s.charAt(i);
}
return o;
}
// ── 스도쿠 모델 — 판은 81칸 숫자 배열(0 은 빈칸). 후보는 9비트 한 수: 숫자 d 는 (d-1) 번째 비트
const ALL = 511;
function bit(d: number): number { return 1 << (d - 1); }
function popcount(m: number): number {
let c = 0;
let v = m;
while (v) { v &= v - 1; c += 1; }
return c;
}
// 가장 낮은 비트 하나가 가리키는 숫자
function lowDigit(m: number): number { return 32 - Math.clz32(m & -m); }
function bin9(m: number): string {
let s = '';
for (let d = 9; d >= 1; d -= 1) { s += (m & bit(d)) ? '1' : '0'; }
return s;
}
function rowOf(i: number): number { return Math.floor(i / 9); }
function colOf(i: number): number { return i % 9; }
function boxOf(i: number): number { return Math.floor(i / 27) * 3 + Math.floor((i % 9) / 3); }
// 묶음 27개 — 줄 9 · 세로줄 9 · 상자 9. 칸의 이웃은 같은 묶음 세 곳의 다른 칸 20개
const UNITS: number[][] = [];
const PEERS: number[][] = [];
for (let k = 0; k < 27; k += 1) { UNITS.push([]); }
for (let i = 0; i < 81; i += 1) {
UNITS[rowOf(i)].push(i);
UNITS[9 + colOf(i)].push(i);
UNITS[18 + boxOf(i)].push(i);
}
for (let i = 0; i < 81; i += 1) {
const ps: number[] = [];
for (let j = 0; j < 81; j += 1) {
if (j !== i && (rowOf(j) === rowOf(i) || colOf(j) === colOf(i) || boxOf(j) === boxOf(i))) { ps.push(j); }
}
PEERS.push(ps);
}
// 묶음 종류 kind: 0 줄 · 1 세로줄 · 2 상자
function unitIndex(kind: number, i: number): number { return kind === 0 ? rowOf(i) : (kind === 1 ? 9 + colOf(i) : 18 + boxOf(i)); }
function sharedKind(i: number, j: number): number {
if (rowOf(i) === rowOf(j)) { return 0; }
if (colOf(i) === colOf(j)) { return 1; }
return boxOf(i) === boxOf(j) ? 2 : -1;
}
// 칸 i 가 속한 kind 묶음에서 i 를 뺀 칸들이 이미 쓴 숫자의 비트 합(OR)
function usedMask(b: number[], i: number, kind: number): number {
const u = UNITS[unitIndex(kind, i)];
let m = 0;
for (let k = 0; k < 9; k += 1) { if (u[k] !== i && b[u[k]] !== 0) { m |= bit(b[u[k]]); } }
return m;
}
// 후보 = 9비트 전체에서 세 묶음이 쓴 비트를 뺀 것. 채워진 칸의 후보는 0
function candMask(b: number[], i: number): number {
if (b[i] !== 0) { return 0; }
return ALL & ~(usedMask(b, i, 0) | usedMask(b, i, 1) | usedMask(b, i, 2));
}
function filledOf(b: number[]): number {
let c = 0;
for (let i = 0; i < 81; i += 1) { if (b[i] !== 0) { c += 1; } }
return c;
}
function perm(n: number, seed: number): number[] {
const a: number[] = [];
for (let i = 0; i < n; i += 1) { a.push(i); }
for (let i = n - 1; i > 0; i -= 1) {
const j = Math.floor(hash2(i, seed) * (i + 1));
const t = a[i];
a[i] = a[j];
a[j] = t;
}
return a;
}
// 완성판 — 기본 무늬를 숫자·줄·상자 묶음 순서로 섞는다. 같은 씨앗이면 같은 판
function fullBoard(seed: number): number[] {
const dp = perm(9, seed * 7 + 1);
const bp = perm(3, seed * 7 + 2);
const sp = perm(3, seed * 7 + 3);
const rows: number[] = [];
const cols: number[] = [];
for (let k = 0; k < 3; k += 1) {
const rp = perm(3, seed * 13 + k * 5 + 4);
const cp = perm(3, seed * 13 + k * 5 + 9);
for (let j = 0; j < 3; j += 1) {
rows.push(bp[k] * 3 + rp[j]);
cols.push(sp[k] * 3 + cp[j]);
}
}
const b: number[] = [];
for (let r = 0; r < 9; r += 1) {
for (let c = 0; c < 9; c += 1) {
b.push(dp[(3 * (rows[r] % 3) + Math.floor(rows[r] / 3) + cols[c]) % 9] + 1);
}
}
return b;
}
// 풀이 탐색 — 가장 후보가 적은 칸(plain 이면 앞쪽 빈칸)부터 숫자를 걸고, 막히면 되돌린다.
// count 가 limit 에 닿으면 멈추고(풀이 개수는 limit 까지만 센다), 걸어 본 숫자가 cap 을 넘으면 멈춘다.
// trace 에는 (종류 << 16) | (칸 << 4) | 숫자 — 종류 0 은 걸기, 1 은 되돌리기
type Srch = { count: number; nodes: number; dead: number; branch: number; capHit: boolean; first: number[]; trace: number[] };
function search(start: number[], limit: number, cap: number, plain: boolean, wantTrace: boolean): Srch {
const b = start.slice();
const rm: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const cm: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const xm: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
rm[rowOf(i)] |= bit(b[i]);
cm[colOf(i)] |= bit(b[i]);
xm[boxOf(i)] |= bit(b[i]);
}
}
const r: Srch = { count: 0, nodes: 0, dead: 0, branch: 0, capHit: false, first: [], trace: [] };
function rec(): void {
let best = -1;
let bm = 0;
let bc = 10;
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) { continue; }
const m = ALL & ~(rm[rowOf(i)] | cm[colOf(i)] | xm[boxOf(i)]);
const pc = popcount(m);
if (plain) { best = i; bm = m; bc = pc; break; }
if (pc < bc) { best = i; bm = m; bc = pc; if (pc <= 1) { break; } }
}
if (best < 0) {
r.count += 1;
if (r.count === 1) { r.first = b.slice(); }
return;
}
if (bc === 0) { r.dead += 1; return; }
if (bc > 1) { r.branch += 1; }
let mm = bm;
while (mm) {
const lo = mm & -mm;
mm ^= lo;
const d = 32 - Math.clz32(lo);
r.nodes += 1;
if (r.nodes > cap) { r.capHit = true; return; }
b[best] = d;
rm[rowOf(best)] |= lo;
cm[colOf(best)] |= lo;
xm[boxOf(best)] |= lo;
if (wantTrace) { r.trace.push((best << 4) | d); }
rec();
if (r.count >= limit || r.capHit) { return; }
b[best] = 0;
rm[rowOf(best)] &= ~lo;
cm[colOf(best)] &= ~lo;
xm[boxOf(best)] &= ~lo;
if (wantTrace) { r.trace.push((1 << 16) | (best << 4) | d); }
}
}
rec();
return r;
}
// 하나뿐인 후보 채우기 — 후보가 하나인 칸(번호가 가장 앞선 칸)을 채우고 다시 센다. 더 없으면 멈춘다
type Singles = { order: number[]; digits: number[]; avail: number[]; solved: boolean; board: number[] };
function singlesFill(start: number[]): Singles {
const b = start.slice();
const order: number[] = [];
const digits: number[] = [];
const avail: number[] = [];
for (;;) {
let first = -1;
let n = 0;
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0 && popcount(candMask(b, i)) === 1) {
if (first < 0) { first = i; }
n += 1;
}
}
if (first < 0) { break; }
const d = lowDigit(candMask(b, first));
b[first] = d;
order.push(first);
digits.push(d);
avail.push(n);
}
let solved = true;
for (let i = 0; i < 81; i += 1) { if (b[i] === 0) { solved = false; } }
return { order: order, digits: digits, avail: avail, solved: solved, board: b };
}
// 구멍 파기 — 완성판에서 칸을 해시 순서로 하나씩 비우고 풀이 개수를 2 까지만 센다.
// 하나뿐이면 비운 채로 두고, 둘 이상이면 되돌린다. singlesOnly 이면 하나뿐인 후보만으로 풀리는 판만 남긴다
type DigStep = { cell: number; cnt: number; ok: boolean; nodes: number };
type Dug = { puz: number[]; steps: DigStep[]; clues: number };
function digBoard(sol: number[], seed: number, target: number, singlesOnly: boolean): Dug {
const puz = sol.slice();
const order = perm(81, seed * 31 + 5);
const steps: DigStep[] = [];
let clues = 81;
for (let k = 0; k < 81; k += 1) {
if (clues <= target) { break; }
const c = order[k];
const d = puz[c];
puz[c] = 0;
const s = search(puz, 2, 6000, false, false);
const cnt = s.capHit ? 2 : Math.min(2, s.count);
const ok = cnt === 1 && (!singlesOnly || singlesFill(puz).solved);
steps.push({ cell: c, cnt: cnt, ok: ok, nodes: s.nodes });
if (ok) { clues -= 1; } else { puz[c] = d; }
}
return { puz: puz, steps: steps, clues: clues };
}
// 놓은 숫자가 겹치는 쌍 — [칸, 칸, 묶음 종류]
function conflicts(b: number[]): number[][] {
const out: number[][] = [];
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0) { continue; }
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (p > i && b[p] === b[i]) { out.push([i, p, sharedKind(i, p)]); }
}
}
return out;
}
// ── 판 상태 — 장면이 아니라 판이 들고 있는 것. 화면은 이 값을 읽어 그린다
const BX = 22;
const BY = 44;
const CS = 23;
const GAP = 3;
const PX = 258;
const PW = 210;
let g: number[] = [];
let giv: boolean[] = [];
let sol: number[] = [];
let pt: number[] = [];
let cur = 40;
let solvedT = -1;
let lastCell = -1;
type Rip = { i: number; t: number; list: number[]; d: number };
let rip: Rip = { i: -1, t: -9, list: [], d: 0 };
let selDigit = 0;
let still = false;
let hintHook = null as (() => void) | null;
function cellX(i: number): number { const c = colOf(i); return BX + c * CS + Math.floor(c / 3) * GAP; }
function cellY(i: number): number { const r = rowOf(i); return BY + r * CS + Math.floor(r / 3) * GAP; }
function cellCx(i: number): number { return cellX(i) + CS / 2; }
function cellCy(i: number): number { return cellY(i) + CS / 2; }
function cellAt(x: number, y: number): number {
let c = -1;
let r = -1;
for (let k = 0; k < 9; k += 1) {
const x0 = BX + k * CS + Math.floor(k / 3) * GAP;
const y0 = BY + k * CS + Math.floor(k / 3) * GAP;
if (x >= x0 && x < x0 + CS) { c = k; }
if (y >= y0 && y < y0 + CS) { r = k; }
}
return c < 0 || r < 0 ? -1 : r * 9 + c;
}
function setBoard(puz: number[], solution: number[]): void {
g = puz.slice();
sol = solution.slice();
giv = [];
pt = [];
for (let i = 0; i < 81; i += 1) { giv.push(puz[i] !== 0); pt.push(-9); }
solvedT = -1;
lastCell = -1;
rip = { i: -1, t: -9, list: [], d: 0 };
}
function solvedNow(): boolean { return filledOf(g) === 81 && conflicts(g).length === 0; }
function checkSolved(): void {
if (solvedNow()) { if (solvedT < 0) { solvedT = sceneT; } } else { solvedT = -1; }
}
// 숫자를 놓는다 — 놓기 전에 그 숫자가 후보에 있던 이웃 칸을 기억해 두면 소거가 번지는 모습을 그릴 수 있다
function putDigit(i: number, d: number): boolean {
if (giv[i] || d < 1 || d > 9) { return false; }
const list: number[] = [];
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (g[p] === 0 && (candMask(g, p) & bit(d)) !== 0) { list.push(p); }
}
g[i] = d;
pt[i] = sceneT;
rip = { i: i, t: sceneT, list: list, d: d };
lastCell = i;
checkSolved();
return true;
}
function clearDigit(i: number): void {
if (giv[i] || g[i] === 0) { return; }
g[i] = 0;
pt[i] = -9;
checkSolved();
}
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)]; }
// ── 그리기 도구
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 doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 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 disc(x: number, y: number, r: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, c: RGB, a: number, w: number): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x: number, y: number, w: number, h: number): void { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name: string, val: string, frac: number): void {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// ── 판 그리기 — 칸 색은 장면이 정하고, 숫자는 판이 그린다
const EMPTY: RGB = [58, 53, 88];
const PLACED: RGB = [190, 214, 246];
const PLACED_INK: RGB = [28, 54, 118];
function ring(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number, lw: number): void {
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = lw;
ctx.stroke();
}
function drawBoard(fillOf: (i: number) => RGB | null, extra: ((i: number, x: number, y: number) => void) | null): void {
const s = 9 * CS + 2 * GAP;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < 81; i += 1) {
const x = cellX(i);
const y = cellY(i);
let f: RGB | null = fillOf(i);
if (f === null) { f = g[i] === 0 ? EMPTY : (giv[i] ? NEU : PLACED); }
box(x + 1, y + 1, CS - 2, CS - 2, 4, f, 1);
if (g[i] !== 0) {
const a = still ? 99 : sceneT - pt[i];
const k = a < 0.3 ? 0.7 + 0.3 * easeOut(a / 0.3) : 1;
text(String(g[i]), x + CS / 2, y + CS / 2 + 1, giv[i] ? INK : PLACED_INK, Math.round(14 * k), giv[i] ? 800 : 700, 'center');
} else if (extra !== null) {
extra(i, x, y);
}
}
if (!demo) { ring(cellX(cur) + 0.5, cellY(cur) + 0.5, CS - 1, CS - 1, 4, PAPER, 1, 2); }
}
// 묶음 하나를 옅게 물들인다
function tintUnit(u: number, c: RGB, a: number): void {
for (let k = 0; k < 9; k += 1) {
const i = UNITS[u][k];
box(cellX(i) + 1, cellY(i) + 1, CS - 2, CS - 2, 4, c, a);
// 물들임이 숫자를 흐리지 않도록 숫자를 다시 얹는다
if (g[i] !== 0) { text(String(g[i]), cellCx(i), cellCy(i) + 1, giv[i] ? INK : PLACED_INK, 14, giv[i] ? 800 : 700, 'center'); }
}
}
// 후보 비트 줄 — 숫자 1~9 칸 아홉 개, 켜진 비트만 밝다
function bitRow(x: number, y: number, m: number, on: RGB, off: RGB): void {
for (let d = 1; d <= 9; d += 1) {
const lit = (m & bit(d)) !== 0;
const bx = x + (d - 1) * 18;
box(bx, y, 16, 16, 4, lit ? on : off, 1);
text(String(d), bx + 8, y + 8.5, lit ? INK : DIM, 10, 700, 'center');
}
}
// ── 상태 줄·낭독 문장
const SPK_FILL = '{a}칸 중 {b}칸이 채워져 있다';
const SPK_DONE = '판 완성, {a}칸이 모두 채워졌다';
function speakBoard(): string {
return solvedNow() ? fill(SPK_DONE, '81') : fill(SPK_FILL, '81', String(filledOf(g)));
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 100 + 채운 칸 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode: number): number {
if (solvedNow()) { return 1000; }
return manual ? 100 + filledOf(g) : sceneMode;
}
// 방금 놓은 칸의 줄·세로줄·상자 세 묶음을 한 번 물들였다 서서히 빼는 번짐 — 숫자 하나가 어디까지 영향을 주는지 보인다
function unitPulse(c: RGB, a: number, dur: number): void {
if (rip.i < 0 || still) { return; }
const k = (sceneT - rip.t) / dur;
if (k < 0 || k >= 1) { return; }
for (let kind = 0; kind < 3; kind += 1) { tintUnit(unitIndex(kind, rip.i), c, a * (1 - k)); }
}
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 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
}
function takeOver(): void {
if (manual) { return; }
manual = true;
wake();
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say(): void {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
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);
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow(): void {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) { say(); }
}
}
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;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest(): void {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak: boolean): void {
if (paused || still) { sceneT += 1; }
update(0);
showNow();
if (speak) { say(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
say();
}
function restart(): void {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// 새 판 — 씨앗이 달라진 판으로 시간표를 처음부터. 손으로 하던 중이어도 자동 시연으로 돌아간다
function newBoard(): void {
manual = false;
lap += 1;
begin();
update(0);
showNow();
say();
}
function moveCur(dx: number, dy: number): void {
takeOver();
cur = clamp(rowOf(cur) + dy, 0, 8) * 9 + clamp(colOf(cur) + dx, 0, 8);
afterInput(true);
}
// 힌트 기본 — 고른 빈칸에 정답 숫자를 놓는다. 장면이 hintHook 을 걸어 두면 그쪽이 먼저
function hintDefault(): void {
if (!giv[cur] && g[cur] === 0) { putDigit(cur, sol[cur]); }
}
function toCanvas(e: PointerEvent): number[] {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 칸 이동, 1~9 = 숫자 입력, Delete·Backspace·0 = 지우기, H = 힌트,
// N = 새 판, R = 처음부터, Enter·Space = 일시정지
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'r' || k === 'R') {
restart();
} else if (k === 'n' || k === 'N') {
e.preventDefault();
newBoard();
} else if (k === 'ArrowLeft') {
e.preventDefault();
moveCur(-1, 0);
} else if (k === 'ArrowRight') {
e.preventDefault();
moveCur(1, 0);
} else if (k === 'ArrowUp') {
e.preventDefault();
moveCur(0, -1);
} else if (k === 'ArrowDown') {
e.preventDefault();
moveCur(0, 1);
} else if (k.length === 1 && k >= '1' && k <= '9') {
e.preventDefault();
takeOver();
selDigit = Number(k);
putDigit(cur, selDigit);
afterInput(true);
} else if (k === 'Delete' || k === 'Backspace' || k === '0') {
e.preventDefault();
takeOver();
clearDigit(cur);
afterInput(true);
} else if (k === 'h' || k === 'H') {
e.preventDefault();
takeOver();
if (hintHook !== null) { hintHook(); } else { hintDefault(); }
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
ptrDown = true;
try { pad.setPointerCapture(e.pointerId); } catch (err) { ptrDown = true; }
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) { cur = c; }
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (!ptrDown) { return; }
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) { cur = c; }
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e: PointerEvent): void {
if (!ptrDown) { return; }
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { 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 — 판이 다 차면 마지막 칸에서 맨해튼 거리가 먼 칸일수록 늦게 밝아지는 물결이 번진다. 칸마다 WAVE_MS 곱하기 거리의 시간차로 색과 크기가 한 번 솟았다 가라앉는다
const WAVE_MS = 40;
const PULSE_S = 0.55;
const CLUES_08 = 70;
const FILL_GAP_08 = 0.07;
let LAP_S = 7;
let REST_S = 4.2;
let plan08: number[] = [];
let filledStep = 0;
const SPK_WAVE = '판이 다 차서 물결이 번진다';
function distOf(i: number): number {
return lastCell < 0 ? 0 : Math.abs(rowOf(i) - rowOf(lastCell)) + Math.abs(colOf(i) - colOf(lastCell));
}
function fillStep(k: number): () => void {
return function (): void {
if (putDigit(plan08[k], sol[plan08[k]])) { filledStep += 1; }
};
}
function build(): void {
filledStep = 0;
const s = fullBoard(111 + lap * 2);
const d = digBoard(s, 121 + lap, CLUES_08, false);
setBoard(d.puz, s);
plan08 = [];
const order = perm(81, 7 + lap);
for (let k = 0; k < 81; k += 1) { if (g[order[k]] === 0) { plan08.push(order[k]); } }
for (let k = 0; k < plan08.length; k += 1) { script.push({ t: 0.2 + k * FILL_GAP_08, f: fillStep(k) }); }
LAP_S = 0.2 + plan08.length * FILL_GAP_08 + 16 * WAVE_MS / 1000 + PULSE_S + 2.2;
REST_S = 0.2 + plan08.length * FILL_GAP_08 + 16 * WAVE_MS / 1000 + PULSE_S + 0.2;
}
function update(dt: number): void { if (dt < 0) { return; } }
// 한 칸의 물결 진행도 — 0 아래는 아직, 1 위는 지나감
function waveP(i: number): number {
if (solvedT < 0) { return -1; }
return (sceneT - solvedT - distOf(i) * WAVE_MS / 1000) / PULSE_S;
}
function waveColor(i: number): RGB { return hsl(150 + distOf(i) * 12, 0.62, 0.82); }
function draw(): void {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoard(function (i: number): RGB | null {
if (solvedT < 0) { return null; }
const p = still ? 2 : waveP(i);
const base: RGB = g[i] === 0 ? EMPTY : (giv[i] ? NEU : PLACED);
if (p < 0) { return base; }
if (p >= 1) { return mix(base, waveColor(i), 0.45); }
return mix(base, waveColor(i), 0.45 + 0.55 * Math.sin(Math.PI * p));
}, null);
// 솟는 크기 — 지나가는 칸은 한 번 커졌다 가라앉으며 숫자를 다시 얹는다
if (solvedT >= 0 && !still) {
for (let i = 0; i < 81; i += 1) {
const p = waveP(i);
if (p > 0 && p < 1) {
const k = 1 + 0.2 * Math.sin(Math.PI * p);
const sz = (CS - 2) * k;
box(cellCx(i) - sz / 2, cellCy(i) - sz / 2, sz, sz, 5, waveColor(i), 1);
text(String(g[i]), cellCx(i), cellCy(i) + 1, INK, Math.round(14 * k), 800, 'center');
}
}
}
panel(PX, BY - 5, PW, 223);
text('거리별 지연', PX + 12, BY + 8, DIM, 11, 700, 'left');
const sc = 150 / (16 * 120);
const el = solvedT >= 0 ? (still ? 99 : sceneT - solvedT) : -1;
for (let k = 0; k <= 16; k += 1) {
const y = BY + 22 + k * 11.5;
const lit = el >= 0 && el >= k * WAVE_MS / 1000;
text(String(k), PX + 12, y + 4, lit ? NEU : DIM, 9, 700, 'center');
box(PX + 22, y, 150, 8, 3, [48, 44, 74], 1);
box(PX + 22, y, Math.max(2, k * WAVE_MS * sc), 8, 3, lit ? MINT : [84, 78, 116], 1);
}
if (el >= 0 && !still && el < 16 * WAVE_MS / 1000 + PULSE_S) {
const x = PX + 22 + el * 1000 * sc;
seg(x, BY + 20, x, BY + 22 + 17 * 11.5, PAPER, 0.9, 2);
}
hud('WAVE_MS', String(WAVE_MS), WAVE_MS / 120);
}
function lit(): number {
if (solvedT < 0) { return 0; }
let n = 0;
for (let i = 0; i < 81; i += 1) { if (still || waveP(i) >= 0) { n += 1; } }
return n;
}
function statusText(): string {
return solvedT < 0 ? fill('빈칸 {a}개 남음', String(81 - filledOf(g))) : fill('물결 {a}칸 켜짐, 마지막 칸 기준', String(lit()));
}
function modeId(): number { return modeBase(solvedT >= 0 ? 3 : (filledStep > 0 ? 1 : 0)); }
function speakText(): string {
return (solvedT >= 0 ? SPK_WAVE + '. ' : '') + speakBoard();
}
function onPtr(kind: number, x: number, y: number): void { if (kind < 0 || x < 0 || y < 0) { 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={'스도쿠 시연 무대 — 방향키로 칸 이동, 1부터 9 숫자키로 입력, Delete 로 지우기, H 로 힌트, N 으로 새 판, R 로 처음부터, Enter 또는 Space 로 일시정지'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'거의 채워진 9×9 판의 남은 빈칸이 차례로 채워지고, 마지막 칸이 채워지면 그 칸에서 맨해튼 거리가 먼 칸일수록 늦게 색이 오르며 커졌다 가라앉는 물결이 번진다. 칸마다 지연은 WAVE_MS 40 곱하기 거리이고 물결이 지나간 칸은 옅은 색이 남는다. 오른쪽 패널에는 거리 0부터 16까지 지연 길이 막대 열일곱 줄과 지금 시각을 가리키는 세로선이 나온다. 아래 눈금은 상수 이름과 지금 값을 보이고, 움직임 줄이기에서는 숫자가 바로 채워진 판 한 장만 그려진다'}></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>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}08 · 完成するとマスごとに時間差で広がる波 — 計測 累積面積 31.885% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function waveP(i) {
if (solvedT < 0) {
return -1;
}
return (sceneT - solvedT - distOf(i) * WAVE_MS / 1000) / PULSE_S;
}
三つの盤で最後のマスからいちばん遠いマスの距離は12・12・11で、WAVE_MS が40ならその遅れは480・480・440msで、波ひとつの0.55秒を足すと約1.03秒で終わります。
09必要な手筋で測る難易度の点数バー
ひとつだけの候補で解ければやさしい、仮置きが要れば普通、行き止まりが3回を超えれば難しいと点数を付け、盤ごとに難易度の名前を決めるのに使います。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="数独の舞台 — 矢印キーでマス移動、数字キー1〜9で入力、Deleteで消去、Hでヒント、Nで新しい盤、Rで最初から、EnterかSpaceで一時停止">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="9×9の盤四つが順に現れ、ひとつだけの候補で埋まるところまで埋められる。右のパネルの四行には、盤ごとに必要な手筋で測った難易度の点数の棒(やさしいはミント、普通は黄色、難しいはバラ色)と、ヒントの数だけで測った点数の細い棒が並んで伸び、二つの判定が違うとヒント点数と食い違いと表示される。いまの盤の行には黄色い枠が付く。下の目盛りは定数名と今の値を示し、動きを減らす設定では数字がすぐ埋まった盤一枚だけを描きます"></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>
</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-ink;
}
// 무대 상자 — 눌러서 한 번 깨뜨리는 곳(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-cream;
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 {
padding: $sp-1 $sp-3;
border: 2px solid $subject-cream;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
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__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-ink;
color: $subject-cream;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면에만 있고 aria-live 는 늘 off
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 100px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
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__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 글리치 없는 원문 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause: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: #17141a;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #fff7e6;
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 {
padding: 4px 12px;
border: 2px solid #fff7e6;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
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__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 100px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
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__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause: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 easeOut(k) { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const SKY = [150, 192, 244];
const SUN = [250, 218, 146];
const NEU = [236, 230, 250];
const BGC = [46, 42, 68];
const PANEL = [62, 57, 92];
const DIM = [196, 190, 224];
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) + ')';
}
function hsl(h, s, l) {
const a = s * Math.min(l, 1 - l);
const f = function (n) {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a, b) {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s, a, b, c) {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v) { return String(Math.round(v * 10) / 10); }
function group(v) {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
o += ',';
}
o += s.charAt(i);
}
return o;
}
// ── 스도쿠 모델 — 판은 81칸 숫자 배열(0 은 빈칸). 후보는 9비트 한 수: 숫자 d 는 (d-1) 번째 비트
const ALL = 511;
function bit(d) { return 1 << (d - 1); }
function popcount(m) {
let c = 0;
let v = m;
while (v) {
v &= v - 1;
c += 1;
}
return c;
}
// 가장 낮은 비트 하나가 가리키는 숫자
function lowDigit(m) { return 32 - Math.clz32(m & -m); }
function bin9(m) {
let s = '';
for (let d = 9; d >= 1; d -= 1) {
s += (m & bit(d)) ? '1' : '0';
}
return s;
}
function rowOf(i) { return Math.floor(i / 9); }
function colOf(i) { return i % 9; }
function boxOf(i) { return Math.floor(i / 27) * 3 + Math.floor((i % 9) / 3); }
// 묶음 27개 — 줄 9 · 세로줄 9 · 箱 9. 칸의 이웃은 같은 묶음 세 곳의 다른 칸 20개
const UNITS = [];
const PEERS = [];
for (let k = 0; k < 27; k += 1) {
UNITS.push([]);
}
for (let i = 0; i < 81; i += 1) {
UNITS[rowOf(i)].push(i);
UNITS[9 + colOf(i)].push(i);
UNITS[18 + boxOf(i)].push(i);
}
for (let i = 0; i < 81; i += 1) {
const ps = [];
for (let j = 0; j < 81; j += 1) {
if (j !== i && (rowOf(j) === rowOf(i) || colOf(j) === colOf(i) || boxOf(j) === boxOf(i))) {
ps.push(j);
}
}
PEERS.push(ps);
}
// 묶음 종류 kind: 0 줄 · 1 세로줄 · 2 箱
function unitIndex(kind, i) { return kind === 0 ? rowOf(i) : (kind === 1 ? 9 + colOf(i) : 18 + boxOf(i)); }
function sharedKind(i, j) {
if (rowOf(i) === rowOf(j)) {
return 0;
}
if (colOf(i) === colOf(j)) {
return 1;
}
return boxOf(i) === boxOf(j) ? 2 : -1;
}
// 칸 i 가 속한 kind 묶음에서 i 를 뺀 칸들이 이미 쓴 숫자의 비트 合計(OR)
function usedMask(b, i, kind) {
const u = UNITS[unitIndex(kind, i)];
let m = 0;
for (let k = 0; k < 9; k += 1) {
if (u[k] !== i && b[u[k]] !== 0) {
m |= bit(b[u[k]]);
}
}
return m;
}
// 候補 = 9비트 전체에서 세 묶음이 쓴 비트를 뺀 것. 채워진 칸의 후보는 0
function candMask(b, i) {
if (b[i] !== 0) {
return 0;
}
return ALL & ~(usedMask(b, i, 0) | usedMask(b, i, 1) | usedMask(b, i, 2));
}
function filledOf(b) {
let c = 0;
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
c += 1;
}
}
return c;
}
function perm(n, seed) {
const a = [];
for (let i = 0; i < n; i += 1) {
a.push(i);
}
for (let i = n - 1; i > 0; i -= 1) {
const j = Math.floor(hash2(i, seed) * (i + 1));
const t = a[i];
a[i] = a[j];
a[j] = t;
}
return a;
}
// 완성판 — 기본 무늬를 숫자·줄·箱 묶음 순서로 섞는다. 같은 씨앗이면 같은 판
function fullBoard(seed) {
const dp = perm(9, seed * 7 + 1);
const bp = perm(3, seed * 7 + 2);
const sp = perm(3, seed * 7 + 3);
const rows = [];
const cols = [];
for (let k = 0; k < 3; k += 1) {
const rp = perm(3, seed * 13 + k * 5 + 4);
const cp = perm(3, seed * 13 + k * 5 + 9);
for (let j = 0; j < 3; j += 1) {
rows.push(bp[k] * 3 + rp[j]);
cols.push(sp[k] * 3 + cp[j]);
}
}
const b = [];
for (let r = 0; r < 9; r += 1) {
for (let c = 0; c < 9; c += 1) {
b.push(dp[(3 * (rows[r] % 3) + Math.floor(rows[r] / 3) + cols[c]) % 9] + 1);
}
}
return b;
}
function search(start, limit, cap, plain, wantTrace) {
const b = start.slice();
const rm = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const cm = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const xm = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
rm[rowOf(i)] |= bit(b[i]);
cm[colOf(i)] |= bit(b[i]);
xm[boxOf(i)] |= bit(b[i]);
}
}
const r = { count: 0, nodes: 0, dead: 0, branch: 0, capHit: false, first: [], trace: [] };
function rec() {
let best = -1;
let bm = 0;
let bc = 10;
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
continue;
}
const m = ALL & ~(rm[rowOf(i)] | cm[colOf(i)] | xm[boxOf(i)]);
const pc = popcount(m);
if (plain) {
best = i;
bm = m;
bc = pc;
break;
}
if (pc < bc) {
best = i;
bm = m;
bc = pc;
if (pc <= 1) {
break;
}
}
}
if (best < 0) {
r.count += 1;
if (r.count === 1) {
r.first = b.slice();
}
return;
}
if (bc === 0) {
r.dead += 1;
return;
}
if (bc > 1) {
r.branch += 1;
}
let mm = bm;
while (mm) {
const lo = mm & -mm;
mm ^= lo;
const d = 32 - Math.clz32(lo);
r.nodes += 1;
if (r.nodes > cap) {
r.capHit = true;
return;
}
b[best] = d;
rm[rowOf(best)] |= lo;
cm[colOf(best)] |= lo;
xm[boxOf(best)] |= lo;
if (wantTrace) {
r.trace.push((best << 4) | d);
}
rec();
if (r.count >= limit || r.capHit) {
return;
}
b[best] = 0;
rm[rowOf(best)] &= ~lo;
cm[colOf(best)] &= ~lo;
xm[boxOf(best)] &= ~lo;
if (wantTrace) {
r.trace.push((1 << 16) | (best << 4) | d);
}
}
}
rec();
return r;
}
function singlesFill(start) {
const b = start.slice();
const order = [];
const digits = [];
const avail = [];
for (;;) {
let first = -1;
let n = 0;
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0 && popcount(candMask(b, i)) === 1) {
if (first < 0) {
first = i;
}
n += 1;
}
}
if (first < 0) {
break;
}
const d = lowDigit(candMask(b, first));
b[first] = d;
order.push(first);
digits.push(d);
avail.push(n);
}
let solved = true;
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0) {
solved = false;
}
}
return { order: order, digits: digits, avail: avail, solved: solved, board: b };
}
function digBoard(sol, seed, target, singlesOnly) {
const puz = sol.slice();
const order = perm(81, seed * 31 + 5);
const steps = [];
let clues = 81;
for (let k = 0; k < 81; k += 1) {
if (clues <= target) {
break;
}
const c = order[k];
const d = puz[c];
puz[c] = 0;
const s = search(puz, 2, 6000, false, false);
const cnt = s.capHit ? 2 : Math.min(2, s.count);
const ok = cnt === 1 && (!singlesOnly || singlesFill(puz).solved);
steps.push({ cell: c, cnt: cnt, ok: ok, nodes: s.nodes });
if (ok) {
clues -= 1;
}
else {
puz[c] = d;
}
}
return { puz: puz, steps: steps, clues: clues };
}
// 놓은 숫자가 겹치는 쌍 — [칸, 칸, 묶음 종류]
function conflicts(b) {
const out = [];
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0) {
continue;
}
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (p > i && b[p] === b[i]) {
out.push([i, p, sharedKind(i, p)]);
}
}
}
return out;
}
// ── 판 상태 — 장면이 아니라 판이 들고 있는 것. 화면은 이 값을 읽어 그린다
const BX = 22;
const BY = 44;
const CS = 23;
const GAP = 3;
const PX = 258;
const PW = 210;
let g = [];
let giv = [];
let sol = [];
let pt = [];
let cur = 40;
let solvedT = -1;
let lastCell = -1;
let rip = { i: -1, t: -9, list: [], d: 0 };
let selDigit = 0;
let still = false;
let hintHook = null;
function cellX(i) { const c = colOf(i); return BX + c * CS + Math.floor(c / 3) * GAP; }
function cellY(i) { const r = rowOf(i); return BY + r * CS + Math.floor(r / 3) * GAP; }
function cellCx(i) { return cellX(i) + CS / 2; }
function cellCy(i) { return cellY(i) + CS / 2; }
function cellAt(x, y) {
let c = -1;
let r = -1;
for (let k = 0; k < 9; k += 1) {
const x0 = BX + k * CS + Math.floor(k / 3) * GAP;
const y0 = BY + k * CS + Math.floor(k / 3) * GAP;
if (x >= x0 && x < x0 + CS) {
c = k;
}
if (y >= y0 && y < y0 + CS) {
r = k;
}
}
return c < 0 || r < 0 ? -1 : r * 9 + c;
}
function setBoard(puz, solution) {
g = puz.slice();
sol = solution.slice();
giv = [];
pt = [];
for (let i = 0; i < 81; i += 1) {
giv.push(puz[i] !== 0);
pt.push(-9);
}
solvedT = -1;
lastCell = -1;
rip = { i: -1, t: -9, list: [], d: 0 };
}
function solvedNow() { return filledOf(g) === 81 && conflicts(g).length === 0; }
function checkSolved() {
if (solvedNow()) {
if (solvedT < 0) {
solvedT = sceneT;
}
}
else {
solvedT = -1;
}
}
// 숫자를 놓는다 — 놓기 전에 그 숫자가 후보에 있던 이웃 칸을 기억해 두면 소거가 번지는 모습을 그릴 수 있다
function putDigit(i, d) {
if (giv[i] || d < 1 || d > 9) {
return false;
}
const list = [];
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (g[p] === 0 && (candMask(g, p) & bit(d)) !== 0) {
list.push(p);
}
}
g[i] = d;
pt[i] = sceneT;
rip = { i: i, t: sceneT, list: list, d: d };
lastCell = i;
checkSolved();
return true;
}
function clearDigit(i) {
if (giv[i] || g[i] === 0) {
return;
}
g[i] = 0;
pt[i] = -9;
checkSolved();
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// ── 그리기 도구
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 doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 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 disc(x, y, r, c, a) {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, c, a, w) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s, x, y, c, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x, y, w, h) { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name, val, frac) {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// ── 판 그리기 — 칸 색은 장면이 정하고, 숫자는 판이 그린다
const EMPTY = [58, 53, 88];
const PLACED = [190, 214, 246];
const PLACED_INK = [28, 54, 118];
function ring(x, y, w, h, r, c, a, lw) {
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = lw;
ctx.stroke();
}
function drawBoard(fillOf, extra) {
const s = 9 * CS + 2 * GAP;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < 81; i += 1) {
const x = cellX(i);
const y = cellY(i);
let f = fillOf(i);
if (f === null) {
f = g[i] === 0 ? EMPTY : (giv[i] ? NEU : PLACED);
}
box(x + 1, y + 1, CS - 2, CS - 2, 4, f, 1);
if (g[i] !== 0) {
const a = still ? 99 : sceneT - pt[i];
const k = a < 0.3 ? 0.7 + 0.3 * easeOut(a / 0.3) : 1;
text(String(g[i]), x + CS / 2, y + CS / 2 + 1, giv[i] ? INK : PLACED_INK, Math.round(14 * k), giv[i] ? 800 : 700, 'center');
}
else if (extra !== null) {
extra(i, x, y);
}
}
if (!demo) {
ring(cellX(cur) + 0.5, cellY(cur) + 0.5, CS - 1, CS - 1, 4, PAPER, 1, 2);
}
}
// 묶음 하나를 옅게 물들인다
function tintUnit(u, c, a) {
for (let k = 0; k < 9; k += 1) {
const i = UNITS[u][k];
box(cellX(i) + 1, cellY(i) + 1, CS - 2, CS - 2, 4, c, a);
// 물들임이 숫자를 흐리지 않도록 숫자를 다시 얹는다
if (g[i] !== 0) {
text(String(g[i]), cellCx(i), cellCy(i) + 1, giv[i] ? INK : PLACED_INK, 14, giv[i] ? 800 : 700, 'center');
}
}
}
// 候補 비트 줄 — 숫자 1~9 칸 아홉 개, 켜진 비트만 밝다
function bitRow(x, y, m, on, off) {
for (let d = 1; d <= 9; d += 1) {
const lit = (m & bit(d)) !== 0;
const bx = x + (d - 1) * 18;
box(bx, y, 16, 16, 4, lit ? on : off, 1);
text(String(d), bx + 8, y + 8.5, lit ? INK : DIM, 10, 700, 'center');
}
}
// ── 상태 줄·낭독 문장
const SPK_FILL = '{a} マスのうち {b} マスが埋まっている';
const SPK_DONE = '盤が完成、{a} マスすべてが埋まった';
function speakBoard() {
return solvedNow() ? fill(SPK_DONE, '81') : fill(SPK_FILL, '81', String(filledOf(g)));
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 100 + 채운 칸 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode) {
if (solvedNow()) {
return 1000;
}
return manual ? 100 + filledOf(g) : sceneMode;
}
// 방금 놓은 칸의 줄·세로줄·箱 세 묶음을 한 번 물들였다 서서히 빼는 번짐 — 숫자 하나가 어디까지 영향을 주는지 보인다
function unitPulse(c, a, dur) {
if (rip.i < 0 || still) {
return;
}
const k = (sceneT - rip.t) / dur;
if (k < 0 || k >= 1) {
return;
}
for (let kind = 0; kind < 3; kind += 1) {
tintUnit(unitIndex(kind, rip.i), c, a * (1 - k));
}
}
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 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
}
function takeOver() {
if (manual) {
return;
}
manual = true;
wake();
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say() {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
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);
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow() {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) {
say();
}
}
}
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;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest() {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak) {
if (paused || still) {
sceneT += 1;
}
update(0);
showNow();
if (speak) {
say();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
say();
}
function restart() {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// 새 판 — 씨앗이 달라진 판으로 시간표를 처음부터. 손으로 하던 중이어도 자동 시연으로 돌아간다
function newBoard() {
manual = false;
lap += 1;
begin();
update(0);
showNow();
say();
}
function moveCur(dx, dy) {
takeOver();
cur = clamp(rowOf(cur) + dy, 0, 8) * 9 + clamp(colOf(cur) + dx, 0, 8);
afterInput(true);
}
// 힌트 기본 — 고른 빈칸에 정답 숫자를 놓는다. 장면이 hintHook 을 걸어 두면 그쪽이 먼저
function hintDefault() {
if (!giv[cur] && g[cur] === 0) {
putDigit(cur, sol[cur]);
}
}
function toCanvas(e) {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 칸 이동, 1~9 = 숫자 입력, Delete·Backspace·0 = 지우기, H = 힌트,
// N = 새 판, R = 처음부터, Enter·Space = 一時停止
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'r' || k === 'R') {
restart();
}
else if (k === 'n' || k === 'N') {
e.preventDefault();
newBoard();
}
else if (k === 'ArrowLeft') {
e.preventDefault();
moveCur(-1, 0);
}
else if (k === 'ArrowRight') {
e.preventDefault();
moveCur(1, 0);
}
else if (k === 'ArrowUp') {
e.preventDefault();
moveCur(0, -1);
}
else if (k === 'ArrowDown') {
e.preventDefault();
moveCur(0, 1);
}
else if (k.length === 1 && k >= '1' && k <= '9') {
e.preventDefault();
takeOver();
selDigit = Number(k);
putDigit(cur, selDigit);
afterInput(true);
}
else if (k === 'Delete' || k === 'Backspace' || k === '0') {
e.preventDefault();
takeOver();
clearDigit(cur);
afterInput(true);
}
else if (k === 'h' || k === 'H') {
e.preventDefault();
takeOver();
if (hintHook !== null) {
hintHook();
}
else {
hintDefault();
}
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
pad.addEventListener('pointerdown', function (e) {
takeOver();
ptrDown = true;
try {
pad.setPointerCapture(e.pointerId);
}
catch (err) {
ptrDown = true;
}
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) {
cur = c;
}
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (!ptrDown) {
return;
}
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) {
cur = c;
}
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e) {
if (!ptrDown) {
return;
}
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { 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 SHOW_S = 1.5;
const T0_09 = 0.3;
const RATE_AT = 0.9;
const BAR_S = 0.4;
const PUZ_N = 4;
let LAP_S = 7.5;
let REST_S = 6.3;
let rates = [];
let curP = -1;
const LV_NAME = ['やさしい', '普通', '難しい'];
const LV_COLOR = [MINT, SUN, ROSE];
const SPK_RATE = '盤 {a} の難易度 {b}、点数 {c}';
function levelOf(score) { return score < 40 ? 0 : (score < 60 ? 1 : 2); }
// 점수 — 하나뿐인 후보만으로 풀리면 0~39, 걸어 보아야 하면 40~59, 되짚기 막다른 길이 3번 넘으면 60 이상
function rateOf(puz, solution) {
const chain = singlesFill(puz);
const clues = filledOf(puz);
let score = 0;
let dead = 0;
let branch = 0;
if (chain.solved) {
score = Math.min(39, 8 + Math.floor((81 - clues) / 1.6));
}
else {
const s = search(puz, 1, 3000, false, false);
dead = s.dead;
branch = s.branch;
score = dead <= 2 ? 40 + Math.min(19, branch * 2 + dead * 4) : 60 + Math.min(39, dead);
}
// ヒント 수만 본 점수 — 단서가 적을수록 어렵다고 매긴다
const clueScore = clamp(Math.round((46 - clues) * 5), 0, 99);
return { puz: puz, sol: solution, clues: clues, chain: chain, level: levelOf(score), score: score, clueScore: clueScore, clueLevel: levelOf(clueScore), dead: dead, branch: branch };
}
function showPuz(k) {
return function () {
curP = k;
setBoard(rates[k].puz, rates[k].sol);
};
}
function fillStep(k, j) {
return function () { putDigit(rates[k].chain.order[j], rates[k].chain.digits[j]); };
}
function build() {
curP = -1;
rates = [];
for (let k = 0; k < PUZ_N; k += 1) {
const seed = 131 + lap * 4 + k * 7;
const s = fullBoard(seed);
const d = k === 0 ? digBoard(s, seed + 3, 26, true) : digBoard(s, seed + 3, k === 1 ? 40 : (k === 2 ? 32 : 24), false);
rates.push(rateOf(d.puz, s));
}
setBoard(rates[0].puz, rates[0].sol);
for (let k = 0; k < PUZ_N; k += 1) {
const t0 = T0_09 + k * SHOW_S;
script.push({ t: t0, f: showPuz(k) });
const n = rates[k].chain.order.length;
const gap = Math.min(0.03, 0.6 / Math.max(1, n));
for (let j = 0; j < n; j += 1) {
script.push({ t: t0 + 0.15 + j * gap, f: fillStep(k, j) });
}
}
LAP_S = T0_09 + PUZ_N * SHOW_S + 1.4;
REST_S = T0_09 + (PUZ_N - 1) * SHOW_S + RATE_AT + BAR_S + 0.2;
}
function update(dt) { if (dt < 0) {
return;
} }
function barP(k) {
if (rates.length === 0) {
return 0;
}
if (still) {
return 1;
}
return clamp((sceneT - (T0_09 + k * SHOW_S + RATE_AT)) / BAR_S, 0, 1);
}
function ratedN() {
let n = 0;
for (let k = 0; k < PUZ_N; k += 1) {
if (barP(k) > 0) {
n += 1;
}
}
return n;
}
function draw() {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoard(function (i) {
if (g[i] !== 0 && !giv[i] && curP >= 0 && rates[curP].chain.solved === false) {
return [150, 192, 224];
}
return null;
}, null);
panel(PX, BY - 5, PW, 223);
for (let k = 0; k < rates.length; k += 1) {
const r = rates[k];
const y = BY + 6 + k * 53;
const p = easeOut(barP(k));
if (curP === k) {
ring(PX + 5, y - 3, PW - 10, 49, 8, SUN, 0.8, 1.5);
}
text(fill('盤 {a}', String(k + 1)), PX + 12, y + 7, NEU, 11, 700, 'left');
box(PX + 48, y, 100, 12, 4, [48, 44, 74], 1);
box(PX + 48, y, 100 * r.score / 100 * p, 12, 4, LV_COLOR[r.level], 1);
box(PX + 48, y + 17, 100, 6, 3, [48, 44, 74], 1);
box(PX + 48, y + 17, 100 * r.clueScore / 100 * p, 6, 3, SKY, 1);
text(String(r.clues), PX + 154, y + 20, DIM, 9, 700, 'left');
if (p > 0.99) {
text(LV_NAME[r.level], PX + 154, y + 6, LV_COLOR[r.level], 11, 800, 'left');
text(String(r.score), PX + 190, y + 20, NEU, 9, 700, 'center');
if (r.level !== r.clueLevel) {
text('ヒント点数と食い違い', PX + 48, y + 36, ROSE, 10, 700, 'left');
}
}
}
const c = curP >= 0 ? rates[curP] : null;
hud('rate', c !== null && barP(curP) > 0.99 ? String(c.score) : '0', c !== null && barP(curP) > 0.99 ? c.score / 100 : 0);
}
function statusText() {
if (curP < 0 || rates.length === 0) {
return fill('盤 {a} を解いている', '1');
}
const r = rates[curP];
return barP(curP) > 0.99 ? fill('盤 {a}: {b}、点数 {c}', String(curP + 1), LV_NAME[r.level], String(r.score)) : fill('盤 {a} を解いている', String(curP + 1));
}
function modeId() { return modeBase(ratedN()); }
function speakText() {
const r = curP >= 0 && rates.length > 0 ? rates[curP] : null;
const m = r !== null && barP(curP) > 0.99 ? fill(SPK_RATE, String(curP + 1), LV_NAME[r.level], String(r.score)) + '. ' : '';
return m + speakBoard();
}
function onPtr(kind, x, y) { if (kind < 0 || x < 0 || y < 0) {
return;
} }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// DifficultyScoreBar - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./DifficultyScoreBar.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function DifficultyScoreBar() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [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 easeOut(k: number): number { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const SKY: RGB = [150, 192, 244];
const SUN: RGB = [250, 218, 146];
const NEU: RGB = [236, 230, 250];
const BGC: RGB = [46, 42, 68];
const PANEL: RGB = [62, 57, 92];
const DIM: RGB = [196, 190, 224];
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) + ')';
}
function hsl(h: number, s: number, l: number): RGB {
const a = s * Math.min(l, 1 - l);
const f = function (n: number): number {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a: number, b: number): number {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s: string, a: string, b?: string, c?: string): string {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v: number): string { return String(Math.round(v * 10) / 10); }
function group(v: number): string {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { o += ','; }
o += s.charAt(i);
}
return o;
}
// ── 스도쿠 모델 — 판은 81칸 숫자 배열(0 은 빈칸). 후보는 9비트 한 수: 숫자 d 는 (d-1) 번째 비트
const ALL = 511;
function bit(d: number): number { return 1 << (d - 1); }
function popcount(m: number): number {
let c = 0;
let v = m;
while (v) { v &= v - 1; c += 1; }
return c;
}
// 가장 낮은 비트 하나가 가리키는 숫자
function lowDigit(m: number): number { return 32 - Math.clz32(m & -m); }
function bin9(m: number): string {
let s = '';
for (let d = 9; d >= 1; d -= 1) { s += (m & bit(d)) ? '1' : '0'; }
return s;
}
function rowOf(i: number): number { return Math.floor(i / 9); }
function colOf(i: number): number { return i % 9; }
function boxOf(i: number): number { return Math.floor(i / 27) * 3 + Math.floor((i % 9) / 3); }
// 묶음 27개 — 줄 9 · 세로줄 9 · 상자 9. 칸의 이웃은 같은 묶음 세 곳의 다른 칸 20개
const UNITS: number[][] = [];
const PEERS: number[][] = [];
for (let k = 0; k < 27; k += 1) { UNITS.push([]); }
for (let i = 0; i < 81; i += 1) {
UNITS[rowOf(i)].push(i);
UNITS[9 + colOf(i)].push(i);
UNITS[18 + boxOf(i)].push(i);
}
for (let i = 0; i < 81; i += 1) {
const ps: number[] = [];
for (let j = 0; j < 81; j += 1) {
if (j !== i && (rowOf(j) === rowOf(i) || colOf(j) === colOf(i) || boxOf(j) === boxOf(i))) { ps.push(j); }
}
PEERS.push(ps);
}
// 묶음 종류 kind: 0 줄 · 1 세로줄 · 2 상자
function unitIndex(kind: number, i: number): number { return kind === 0 ? rowOf(i) : (kind === 1 ? 9 + colOf(i) : 18 + boxOf(i)); }
function sharedKind(i: number, j: number): number {
if (rowOf(i) === rowOf(j)) { return 0; }
if (colOf(i) === colOf(j)) { return 1; }
return boxOf(i) === boxOf(j) ? 2 : -1;
}
// 칸 i 가 속한 kind 묶음에서 i 를 뺀 칸들이 이미 쓴 숫자의 비트 합(OR)
function usedMask(b: number[], i: number, kind: number): number {
const u = UNITS[unitIndex(kind, i)];
let m = 0;
for (let k = 0; k < 9; k += 1) { if (u[k] !== i && b[u[k]] !== 0) { m |= bit(b[u[k]]); } }
return m;
}
// 후보 = 9비트 전체에서 세 묶음이 쓴 비트를 뺀 것. 채워진 칸의 후보는 0
function candMask(b: number[], i: number): number {
if (b[i] !== 0) { return 0; }
return ALL & ~(usedMask(b, i, 0) | usedMask(b, i, 1) | usedMask(b, i, 2));
}
function filledOf(b: number[]): number {
let c = 0;
for (let i = 0; i < 81; i += 1) { if (b[i] !== 0) { c += 1; } }
return c;
}
function perm(n: number, seed: number): number[] {
const a: number[] = [];
for (let i = 0; i < n; i += 1) { a.push(i); }
for (let i = n - 1; i > 0; i -= 1) {
const j = Math.floor(hash2(i, seed) * (i + 1));
const t = a[i];
a[i] = a[j];
a[j] = t;
}
return a;
}
// 완성판 — 기본 무늬를 숫자·줄·상자 묶음 순서로 섞는다. 같은 씨앗이면 같은 판
function fullBoard(seed: number): number[] {
const dp = perm(9, seed * 7 + 1);
const bp = perm(3, seed * 7 + 2);
const sp = perm(3, seed * 7 + 3);
const rows: number[] = [];
const cols: number[] = [];
for (let k = 0; k < 3; k += 1) {
const rp = perm(3, seed * 13 + k * 5 + 4);
const cp = perm(3, seed * 13 + k * 5 + 9);
for (let j = 0; j < 3; j += 1) {
rows.push(bp[k] * 3 + rp[j]);
cols.push(sp[k] * 3 + cp[j]);
}
}
const b: number[] = [];
for (let r = 0; r < 9; r += 1) {
for (let c = 0; c < 9; c += 1) {
b.push(dp[(3 * (rows[r] % 3) + Math.floor(rows[r] / 3) + cols[c]) % 9] + 1);
}
}
return b;
}
// 풀이 탐색 — 가장 후보가 적은 칸(plain 이면 앞쪽 빈칸)부터 숫자를 걸고, 막히면 되돌린다.
// count 가 limit 에 닿으면 멈추고(풀이 개수는 limit 까지만 센다), 걸어 본 숫자가 cap 을 넘으면 멈춘다.
// trace 에는 (종류 << 16) | (칸 << 4) | 숫자 — 종류 0 은 걸기, 1 은 되돌리기
type Srch = { count: number; nodes: number; dead: number; branch: number; capHit: boolean; first: number[]; trace: number[] };
function search(start: number[], limit: number, cap: number, plain: boolean, wantTrace: boolean): Srch {
const b = start.slice();
const rm: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const cm: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const xm: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) {
rm[rowOf(i)] |= bit(b[i]);
cm[colOf(i)] |= bit(b[i]);
xm[boxOf(i)] |= bit(b[i]);
}
}
const r: Srch = { count: 0, nodes: 0, dead: 0, branch: 0, capHit: false, first: [], trace: [] };
function rec(): void {
let best = -1;
let bm = 0;
let bc = 10;
for (let i = 0; i < 81; i += 1) {
if (b[i] !== 0) { continue; }
const m = ALL & ~(rm[rowOf(i)] | cm[colOf(i)] | xm[boxOf(i)]);
const pc = popcount(m);
if (plain) { best = i; bm = m; bc = pc; break; }
if (pc < bc) { best = i; bm = m; bc = pc; if (pc <= 1) { break; } }
}
if (best < 0) {
r.count += 1;
if (r.count === 1) { r.first = b.slice(); }
return;
}
if (bc === 0) { r.dead += 1; return; }
if (bc > 1) { r.branch += 1; }
let mm = bm;
while (mm) {
const lo = mm & -mm;
mm ^= lo;
const d = 32 - Math.clz32(lo);
r.nodes += 1;
if (r.nodes > cap) { r.capHit = true; return; }
b[best] = d;
rm[rowOf(best)] |= lo;
cm[colOf(best)] |= lo;
xm[boxOf(best)] |= lo;
if (wantTrace) { r.trace.push((best << 4) | d); }
rec();
if (r.count >= limit || r.capHit) { return; }
b[best] = 0;
rm[rowOf(best)] &= ~lo;
cm[colOf(best)] &= ~lo;
xm[boxOf(best)] &= ~lo;
if (wantTrace) { r.trace.push((1 << 16) | (best << 4) | d); }
}
}
rec();
return r;
}
// 하나뿐인 후보 채우기 — 후보가 하나인 칸(번호가 가장 앞선 칸)을 채우고 다시 센다. 더 없으면 멈춘다
type Singles = { order: number[]; digits: number[]; avail: number[]; solved: boolean; board: number[] };
function singlesFill(start: number[]): Singles {
const b = start.slice();
const order: number[] = [];
const digits: number[] = [];
const avail: number[] = [];
for (;;) {
let first = -1;
let n = 0;
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0 && popcount(candMask(b, i)) === 1) {
if (first < 0) { first = i; }
n += 1;
}
}
if (first < 0) { break; }
const d = lowDigit(candMask(b, first));
b[first] = d;
order.push(first);
digits.push(d);
avail.push(n);
}
let solved = true;
for (let i = 0; i < 81; i += 1) { if (b[i] === 0) { solved = false; } }
return { order: order, digits: digits, avail: avail, solved: solved, board: b };
}
// 구멍 파기 — 완성판에서 칸을 해시 순서로 하나씩 비우고 풀이 개수를 2 까지만 센다.
// 하나뿐이면 비운 채로 두고, 둘 이상이면 되돌린다. singlesOnly 이면 하나뿐인 후보만으로 풀리는 판만 남긴다
type DigStep = { cell: number; cnt: number; ok: boolean; nodes: number };
type Dug = { puz: number[]; steps: DigStep[]; clues: number };
function digBoard(sol: number[], seed: number, target: number, singlesOnly: boolean): Dug {
const puz = sol.slice();
const order = perm(81, seed * 31 + 5);
const steps: DigStep[] = [];
let clues = 81;
for (let k = 0; k < 81; k += 1) {
if (clues <= target) { break; }
const c = order[k];
const d = puz[c];
puz[c] = 0;
const s = search(puz, 2, 6000, false, false);
const cnt = s.capHit ? 2 : Math.min(2, s.count);
const ok = cnt === 1 && (!singlesOnly || singlesFill(puz).solved);
steps.push({ cell: c, cnt: cnt, ok: ok, nodes: s.nodes });
if (ok) { clues -= 1; } else { puz[c] = d; }
}
return { puz: puz, steps: steps, clues: clues };
}
// 놓은 숫자가 겹치는 쌍 — [칸, 칸, 묶음 종류]
function conflicts(b: number[]): number[][] {
const out: number[][] = [];
for (let i = 0; i < 81; i += 1) {
if (b[i] === 0) { continue; }
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (p > i && b[p] === b[i]) { out.push([i, p, sharedKind(i, p)]); }
}
}
return out;
}
// ── 판 상태 — 장면이 아니라 판이 들고 있는 것. 화면은 이 값을 읽어 그린다
const BX = 22;
const BY = 44;
const CS = 23;
const GAP = 3;
const PX = 258;
const PW = 210;
let g: number[] = [];
let giv: boolean[] = [];
let sol: number[] = [];
let pt: number[] = [];
let cur = 40;
let solvedT = -1;
let lastCell = -1;
type Rip = { i: number; t: number; list: number[]; d: number };
let rip: Rip = { i: -1, t: -9, list: [], d: 0 };
let selDigit = 0;
let still = false;
let hintHook = null as (() => void) | null;
function cellX(i: number): number { const c = colOf(i); return BX + c * CS + Math.floor(c / 3) * GAP; }
function cellY(i: number): number { const r = rowOf(i); return BY + r * CS + Math.floor(r / 3) * GAP; }
function cellCx(i: number): number { return cellX(i) + CS / 2; }
function cellCy(i: number): number { return cellY(i) + CS / 2; }
function cellAt(x: number, y: number): number {
let c = -1;
let r = -1;
for (let k = 0; k < 9; k += 1) {
const x0 = BX + k * CS + Math.floor(k / 3) * GAP;
const y0 = BY + k * CS + Math.floor(k / 3) * GAP;
if (x >= x0 && x < x0 + CS) { c = k; }
if (y >= y0 && y < y0 + CS) { r = k; }
}
return c < 0 || r < 0 ? -1 : r * 9 + c;
}
function setBoard(puz: number[], solution: number[]): void {
g = puz.slice();
sol = solution.slice();
giv = [];
pt = [];
for (let i = 0; i < 81; i += 1) { giv.push(puz[i] !== 0); pt.push(-9); }
solvedT = -1;
lastCell = -1;
rip = { i: -1, t: -9, list: [], d: 0 };
}
function solvedNow(): boolean { return filledOf(g) === 81 && conflicts(g).length === 0; }
function checkSolved(): void {
if (solvedNow()) { if (solvedT < 0) { solvedT = sceneT; } } else { solvedT = -1; }
}
// 숫자를 놓는다 — 놓기 전에 그 숫자가 후보에 있던 이웃 칸을 기억해 두면 소거가 번지는 모습을 그릴 수 있다
function putDigit(i: number, d: number): boolean {
if (giv[i] || d < 1 || d > 9) { return false; }
const list: number[] = [];
for (let k = 0; k < 20; k += 1) {
const p = PEERS[i][k];
if (g[p] === 0 && (candMask(g, p) & bit(d)) !== 0) { list.push(p); }
}
g[i] = d;
pt[i] = sceneT;
rip = { i: i, t: sceneT, list: list, d: d };
lastCell = i;
checkSolved();
return true;
}
function clearDigit(i: number): void {
if (giv[i] || g[i] === 0) { return; }
g[i] = 0;
pt[i] = -9;
checkSolved();
}
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)]; }
// ── 그리기 도구
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 doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 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 disc(x: number, y: number, r: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, c: RGB, a: number, w: number): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x: number, y: number, w: number, h: number): void { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name: string, val: string, frac: number): void {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// ── 판 그리기 — 칸 색은 장면이 정하고, 숫자는 판이 그린다
const EMPTY: RGB = [58, 53, 88];
const PLACED: RGB = [190, 214, 246];
const PLACED_INK: RGB = [28, 54, 118];
function ring(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number, lw: number): void {
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = lw;
ctx.stroke();
}
function drawBoard(fillOf: (i: number) => RGB | null, extra: ((i: number, x: number, y: number) => void) | null): void {
const s = 9 * CS + 2 * GAP;
box(BX - 5, BY - 5, s + 10, s + 10, 12, PANEL, 1);
for (let i = 0; i < 81; i += 1) {
const x = cellX(i);
const y = cellY(i);
let f: RGB | null = fillOf(i);
if (f === null) { f = g[i] === 0 ? EMPTY : (giv[i] ? NEU : PLACED); }
box(x + 1, y + 1, CS - 2, CS - 2, 4, f, 1);
if (g[i] !== 0) {
const a = still ? 99 : sceneT - pt[i];
const k = a < 0.3 ? 0.7 + 0.3 * easeOut(a / 0.3) : 1;
text(String(g[i]), x + CS / 2, y + CS / 2 + 1, giv[i] ? INK : PLACED_INK, Math.round(14 * k), giv[i] ? 800 : 700, 'center');
} else if (extra !== null) {
extra(i, x, y);
}
}
if (!demo) { ring(cellX(cur) + 0.5, cellY(cur) + 0.5, CS - 1, CS - 1, 4, PAPER, 1, 2); }
}
// 묶음 하나를 옅게 물들인다
function tintUnit(u: number, c: RGB, a: number): void {
for (let k = 0; k < 9; k += 1) {
const i = UNITS[u][k];
box(cellX(i) + 1, cellY(i) + 1, CS - 2, CS - 2, 4, c, a);
// 물들임이 숫자를 흐리지 않도록 숫자를 다시 얹는다
if (g[i] !== 0) { text(String(g[i]), cellCx(i), cellCy(i) + 1, giv[i] ? INK : PLACED_INK, 14, giv[i] ? 800 : 700, 'center'); }
}
}
// 후보 비트 줄 — 숫자 1~9 칸 아홉 개, 켜진 비트만 밝다
function bitRow(x: number, y: number, m: number, on: RGB, off: RGB): void {
for (let d = 1; d <= 9; d += 1) {
const lit = (m & bit(d)) !== 0;
const bx = x + (d - 1) * 18;
box(bx, y, 16, 16, 4, lit ? on : off, 1);
text(String(d), bx + 8, y + 8.5, lit ? INK : DIM, 10, 700, 'center');
}
}
// ── 상태 줄·낭독 문장
const SPK_FILL = '{a}칸 중 {b}칸이 채워져 있다';
const SPK_DONE = '판 완성, {a}칸이 모두 채워졌다';
function speakBoard(): string {
return solvedNow() ? fill(SPK_DONE, '81') : fill(SPK_FILL, '81', String(filledOf(g)));
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 100 + 채운 칸 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode: number): number {
if (solvedNow()) { return 1000; }
return manual ? 100 + filledOf(g) : sceneMode;
}
// 방금 놓은 칸의 줄·세로줄·상자 세 묶음을 한 번 물들였다 서서히 빼는 번짐 — 숫자 하나가 어디까지 영향을 주는지 보인다
function unitPulse(c: RGB, a: number, dur: number): void {
if (rip.i < 0 || still) { return; }
const k = (sceneT - rip.t) / dur;
if (k < 0 || k >= 1) { return; }
for (let kind = 0; kind < 3; kind += 1) { tintUnit(unitIndex(kind, rip.i), c, a * (1 - k)); }
}
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 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
}
function takeOver(): void {
if (manual) { return; }
manual = true;
wake();
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say(): void {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
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);
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow(): void {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) { say(); }
}
}
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;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 판 한 장. 타일은 보간 없이 바로 제자리
function showRest(): void {
stopLoop();
lap = 0;
begin();
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak: boolean): void {
if (paused || still) { sceneT += 1; }
update(0);
showNow();
if (speak) { say(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
say();
}
function restart(): void {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// 새 판 — 씨앗이 달라진 판으로 시간표를 처음부터. 손으로 하던 중이어도 자동 시연으로 돌아간다
function newBoard(): void {
manual = false;
lap += 1;
begin();
update(0);
showNow();
say();
}
function moveCur(dx: number, dy: number): void {
takeOver();
cur = clamp(rowOf(cur) + dy, 0, 8) * 9 + clamp(colOf(cur) + dx, 0, 8);
afterInput(true);
}
// 힌트 기본 — 고른 빈칸에 정답 숫자를 놓는다. 장면이 hintHook 을 걸어 두면 그쪽이 먼저
function hintDefault(): void {
if (!giv[cur] && g[cur] === 0) { putDigit(cur, sol[cur]); }
}
function toCanvas(e: PointerEvent): number[] {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 칸 이동, 1~9 = 숫자 입력, Delete·Backspace·0 = 지우기, H = 힌트,
// N = 새 판, R = 처음부터, Enter·Space = 일시정지
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'r' || k === 'R') {
restart();
} else if (k === 'n' || k === 'N') {
e.preventDefault();
newBoard();
} else if (k === 'ArrowLeft') {
e.preventDefault();
moveCur(-1, 0);
} else if (k === 'ArrowRight') {
e.preventDefault();
moveCur(1, 0);
} else if (k === 'ArrowUp') {
e.preventDefault();
moveCur(0, -1);
} else if (k === 'ArrowDown') {
e.preventDefault();
moveCur(0, 1);
} else if (k.length === 1 && k >= '1' && k <= '9') {
e.preventDefault();
takeOver();
selDigit = Number(k);
putDigit(cur, selDigit);
afterInput(true);
} else if (k === 'Delete' || k === 'Backspace' || k === '0') {
e.preventDefault();
takeOver();
clearDigit(cur);
afterInput(true);
} else if (k === 'h' || k === 'H') {
e.preventDefault();
takeOver();
if (hintHook !== null) { hintHook(); } else { hintDefault(); }
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
ptrDown = true;
try { pad.setPointerCapture(e.pointerId); } catch (err) { ptrDown = true; }
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) { cur = c; }
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (!ptrDown) { return; }
const p = toCanvas(e);
const c = cellAt(p[0], p[1]);
if (c >= 0) { cur = c; }
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e: PointerEvent): void {
if (!ptrDown) { return; }
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { 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 SHOW_S = 1.5;
const T0_09 = 0.3;
const RATE_AT = 0.9;
const BAR_S = 0.4;
const PUZ_N = 4;
let LAP_S = 7.5;
let REST_S = 6.3;
type Rate = { puz: number[]; sol: number[]; clues: number; chain: Singles; level: number; score: number; clueScore: number; clueLevel: number; dead: number; branch: number };
let rates: Rate[] = [];
let curP = -1;
const LV_NAME = ['쉬움', '보통', '어려움'];
const LV_COLOR: RGB[] = [MINT, SUN, ROSE];
const SPK_RATE = '판 {a} 난이도 {b}, 점수 {c}';
function levelOf(score: number): number { return score < 40 ? 0 : (score < 60 ? 1 : 2); }
// 점수 — 하나뿐인 후보만으로 풀리면 0~39, 걸어 보아야 하면 40~59, 되짚기 막다른 길이 3번 넘으면 60 이상
function rateOf(puz: number[], solution: number[]): Rate {
const chain = singlesFill(puz);
const clues = filledOf(puz);
let score = 0;
let dead = 0;
let branch = 0;
if (chain.solved) {
score = Math.min(39, 8 + Math.floor((81 - clues) / 1.6));
} else {
const s = search(puz, 1, 3000, false, false);
dead = s.dead;
branch = s.branch;
score = dead <= 2 ? 40 + Math.min(19, branch * 2 + dead * 4) : 60 + Math.min(39, dead);
}
// 단서 수만 본 점수 — 단서가 적을수록 어렵다고 매긴다
const clueScore = clamp(Math.round((46 - clues) * 5), 0, 99);
return { puz: puz, sol: solution, clues: clues, chain: chain, level: levelOf(score), score: score, clueScore: clueScore, clueLevel: levelOf(clueScore), dead: dead, branch: branch };
}
function showPuz(k: number): () => void {
return function (): void {
curP = k;
setBoard(rates[k].puz, rates[k].sol);
};
}
function fillStep(k: number, j: number): () => void {
return function (): void { putDigit(rates[k].chain.order[j], rates[k].chain.digits[j]); };
}
function build(): void {
curP = -1;
rates = [];
for (let k = 0; k < PUZ_N; k += 1) {
const seed = 131 + lap * 4 + k * 7;
const s = fullBoard(seed);
const d = k === 0 ? digBoard(s, seed + 3, 26, true) : digBoard(s, seed + 3, k === 1 ? 40 : (k === 2 ? 32 : 24), false);
rates.push(rateOf(d.puz, s));
}
setBoard(rates[0].puz, rates[0].sol);
for (let k = 0; k < PUZ_N; k += 1) {
const t0 = T0_09 + k * SHOW_S;
script.push({ t: t0, f: showPuz(k) });
const n = rates[k].chain.order.length;
const gap = Math.min(0.03, 0.6 / Math.max(1, n));
for (let j = 0; j < n; j += 1) { script.push({ t: t0 + 0.15 + j * gap, f: fillStep(k, j) }); }
}
LAP_S = T0_09 + PUZ_N * SHOW_S + 1.4;
REST_S = T0_09 + (PUZ_N - 1) * SHOW_S + RATE_AT + BAR_S + 0.2;
}
function update(dt: number): void { if (dt < 0) { return; } }
function barP(k: number): number {
if (rates.length === 0) { return 0; }
if (still) { return 1; }
return clamp((sceneT - (T0_09 + k * SHOW_S + RATE_AT)) / BAR_S, 0, 1);
}
function ratedN(): number {
let n = 0;
for (let k = 0; k < PUZ_N; k += 1) { if (barP(k) > 0) { n += 1; } }
return n;
}
function draw(): void {
ctx.fillStyle = rgba(BGC, 1);
ctx.fillRect(0, 0, W, H);
drawBoard(function (i: number): RGB | null {
if (g[i] !== 0 && !giv[i] && curP >= 0 && rates[curP].chain.solved === false) { return [150, 192, 224]; }
return null;
}, null);
panel(PX, BY - 5, PW, 223);
for (let k = 0; k < rates.length; k += 1) {
const r = rates[k];
const y = BY + 6 + k * 53;
const p = easeOut(barP(k));
if (curP === k) { ring(PX + 5, y - 3, PW - 10, 49, 8, SUN, 0.8, 1.5); }
text(fill('판 {a}', String(k + 1)), PX + 12, y + 7, NEU, 11, 700, 'left');
box(PX + 48, y, 100, 12, 4, [48, 44, 74], 1);
box(PX + 48, y, 100 * r.score / 100 * p, 12, 4, LV_COLOR[r.level], 1);
box(PX + 48, y + 17, 100, 6, 3, [48, 44, 74], 1);
box(PX + 48, y + 17, 100 * r.clueScore / 100 * p, 6, 3, SKY, 1);
text(String(r.clues), PX + 154, y + 20, DIM, 9, 700, 'left');
if (p > 0.99) {
text(LV_NAME[r.level], PX + 154, y + 6, LV_COLOR[r.level], 11, 800, 'left');
text(String(r.score), PX + 190, y + 20, NEU, 9, 700, 'center');
if (r.level !== r.clueLevel) { text('단서 점수와 어긋남', PX + 48, y + 36, ROSE, 10, 700, 'left'); }
}
}
const c = curP >= 0 ? rates[curP] : null;
hud('rate', c !== null && barP(curP) > 0.99 ? String(c.score) : '0', c !== null && barP(curP) > 0.99 ? c.score / 100 : 0);
}
function statusText(): string {
if (curP < 0 || rates.length === 0) { return fill('판 {a} 풀이 중', '1'); }
const r = rates[curP];
return barP(curP) > 0.99 ? fill('판 {a}: {b}, 점수 {c}', String(curP + 1), LV_NAME[r.level], String(r.score)) : fill('판 {a} 풀이 중', String(curP + 1));
}
function modeId(): number { return modeBase(ratedN()); }
function speakText(): string {
const r = curP >= 0 && rates.length > 0 ? rates[curP] : null;
const m = r !== null && barP(curP) > 0.99 ? fill(SPK_RATE, String(curP + 1), LV_NAME[r.level], String(r.score)) + '. ' : '';
return m + speakBoard();
}
function onPtr(kind: number, x: number, y: number): void { if (kind < 0 || x < 0 || y < 0) { 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={'스도쿠 시연 무대 — 방향키로 칸 이동, 1부터 9 숫자키로 입력, Delete 로 지우기, H 로 힌트, N 으로 새 판, R 로 처음부터, Enter 또는 Space 로 일시정지'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'9×9 판 네 개가 차례로 나타나 하나뿐인 후보로 채워지는 데까지 채워진다. 오른쪽 패널의 네 줄에는 판마다 필요한 기법으로 잰 난이도 점수 막대(쉬움은 민트, 보통은 노랑, 어려움은 장미)와 단서 수만 보고 잰 점수의 가는 막대가 나란히 차오르고, 두 판정이 다르면 단서 점수와 어긋남이라고 표시된다. 현재 판의 줄에는 노란 테가 붙는다. 아래 눈금은 상수 이름과 지금 값을 보이고, 움직임 줄이기에서는 숫자가 바로 채워진 판 한 장만 그려진다'}></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>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}09 · 必要な手筋で測る難易度の点数バー — 計測 累積面積 20.439% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 点数 — ひとつだけの候補で解ければ0〜39、仮置きが要れば40〜59、戻る探索の行き止まりが3回を超えれば60以上
function rateOf(puz, solution) {
const chain = singlesFill(puz);
const clues = filledOf(puz);
let score = 0;
let dead = 0;
let branch = 0;
if (chain.solved) {
score = Math.min(39, 8 + Math.floor((81 - clues) / 1.6));
}
else {
const s = search(puz, 1, 3000, false, false);
dead = s.dead;
branch = s.branch;
score = dead <= 2 ? 40 + Math.min(19, branch * 2 + dead * 4) : 60 + Math.min(39, dead);
}
// ヒントの数だけで見た点数 — ヒントが少ないほど難しいと付ける
const clueScore = clamp(Math.round((46 - clues) * 5), 0, 99);
return { puz: puz, sol: solution, clues: clues, chain: chain, level: levelOf(score), score: score, clueScore: clueScore, clueLevel: levelOf(clueScore), dead: dead, branch: branch };
}
ヒント28・40・32・24の四つの盤で、手筋の点数は39・33・46・85、ヒントの数だけで付けた点数は90・30・70・99です。盤1と盤3はヒントの数では難しいのに手筋ではやさしい・普通なので、二つの判定が食い違います。
数独のルールをコードにすると、マスひとつの候補はどんな値がいくつで決まるのでしょうか。
盤の配列と27個の組がすべてです
マスひとつの候補は、そのマスが属する行・列・箱の三つの組が使った数字のビットの合計で決まります。組は行9・列9・箱9の27個で、ひとつのマスの隣は同じ組の別のマス20個です。
| 値 | 意味 | 変える場所 |
|---|---|---|
g |
盤の状態(0は空きマス) | putDigit |
PEERS[i] |
マスiの隣の20マス | 絞り込み・重なりの検査 |
candMask |
マスの候補のビット | 01・02・03・06 |
ひとつの盤からいくつもの計算が生まれます
数独 ルールを一度コードにすれば、同じ盤の配列を使って、singlesFill は連鎖を、search は戻る探索を、conflicts は重なりを計算します。場面が九つあっても候補を作る規則はひとつなので、どの場面の盤でも別の場面の関数で検査できます。
盤を81個の数字の配列で持ち、行・列・箱で使った数字を集めて候補を消し、ひとつだけ残ったマスを埋めてまた消す — 数独が解けていく順番
配列が先で、画面はあとからついてきます
数字が入ると盤の配列が先に変わり、候補と重なりはその配列から計算し直されます。画面の色や棒は計算された値を読んで描くだけなので、補間の途中でまた入力しても盤の状態はずれません。
詰まったら仮置きします
ひとつだけの候補が尽きたら、候補がいちばん少ないマスに数字を置いて進み、候補が0のマスが出たら一段戻します。仮置きの数は nodes、行き止まりは dead で数えるので、場面ごとにどれだけ難しい盤だったかが数字で読めます。
数独のサイトのように実際の画面に付けるとき — ライブラリなしの作り方・タッチとキーボードの入力・盤を作る時点
ライブラリなしの作り方
九つの場面はライブラリ・WebGL・CDNを使わず、ソースの一ファイルに計算の関数と自動デモを一緒に入れています。
タッチとキーボードの入力
ステージの箱は role="application" で tabindex が0なのでフォーカスを受け、矢印キーでマスを移り、1〜9が数字、Delete・Backspace・0が消去、Hがヒントです。ポインターは pointerdown・pointermove・pointerup・pointercancel と setPointerCapture で受け、ステージに touch-action: none を付けて押している間にページが一緒にスクロールしないようにします。
盤を作る時点
数独パズルとして出すなら、利用者が盤を受け取る前に、04番の方法で穴を開けて解の数が1か確かめておくほうが安全です。盤づくりが重いなら上限を置くか、ウェブワーカーに移します。
地雷探しやスライドパズルの編との違い
スライドパズルの編は解ける盤を選り分ける偶奇の判定と街区距離の探索を扱い、この編はマスごとに候補の集合を消して解がひとつか数えることを扱います。格子の上で隣のマスへ広がる計算が気になるなら、地雷探しの編も同じ枠組みを使っています。
数独が壊れる落とし穴 — 解が二つ以上ある盤ができること・戻る探索が深くなりすぎて画面が止まること・メモの候補が入力のあとも古いまま残ること
解が二つ以上ある盤ができます
穴をどのマスにでも開けると解が二つ以上ある盤になり、どの数字が正解か決められません。04番は穴ごとに解の数を2まで数えて二つ以上なら戻すので、四つの盤とも CLUES が36でも26でも解の数は1で終わりました。
戻る探索が深くなると画面が止まります
最初の空き順で解く方法はヒント24〜25の四つの盤すべてで20,000ノードの上限に届きましたが、候補の少ないマスから解くと150〜244ノードで解けました。それでも難しい盤が来ることがあるので、03番の NODE_CAP の400のような上限を置き、届いたら解が見つからなかったと表示して打ち切ります。
メモの候補が入力のあとも古いまま残ります
メモの候補を別に保存すると、数字を置いたあとも消えた候補が残ります。01番のように候補を盤の配列から candMask でそのつど計算すれば、同じ行・列・箱の候補は入力の直後に一致します。
九つの場面のソース一式は下の箱のzipで受け取れ、圧縮を開くパスワードは 6r5snyxx です。
ヒントの数と探索の上限と完成の波の速さを変えると感じはどれだけ変わるか — CLUES・NODE_CAP・WAVE_MSを変えた違い
| 名前 | 変えた値 | 感じ |
|---|---|---|
| ヒントをもっと少なく | 04 CLUES 36 → 26 | CLUESを36から26に減らすと、種4の盤では空きマスが45から55に増え、ひとつだけの候補で埋まるマスが45から4に減って、探索のノードが45から653に増えます。ヒントを増やすと盤がやさしくなります |
| 探索の上限を低く | 03 NODE_CAP 400 → 60 | NODE_CAPを400から60に下げると、四つの盤すべて(必要なノードは150・167・173・244)が上限に届いて解を見つけられず止まり、400のままであれば四つとも解が見つかります(ソースの関数を動かして数えた値) |
| 完成の波をゆっくり | 08 WAVE_MS 40 → 120 | WAVE_MSを40から120に伸ばすと、最後のマスからいちばん遠いマス(距離12)の遅れが480msから1,440msに開いて波がゆっくり広がり、縮めるとマスの遅れの間隔が狭まってほとんど一度に点きます |
三つの値は別々の段階に掛かっています。CLUES は盤の難しさ、NODE_CAP は探索が止まる地点、WAVE_MS は完成後の画面だけを変えるので、ひとつずつ変えてどの値が感じを変えたか見るのがよいでしょう。ここに挙げたヒント・ノードの数は盤の計算で出した値で、描画を測った値ではありません。
動きを減らす設定では数字がすぐ埋まる盤一枚 — 場面名と埋まったマスの数を文で読み上げるアクセシビリティ
矢印キーと数字キーで自分でも解けます
ステージに触れると時間表が止まり、計算だけが続きます。Nは新しい盤、Rは最初から、Enter・Space・Pは一時停止です。
読み上げは触った直後とモードが変わるときだけです
画面の状態の行は aria-live="off" なので自動デモの間は読まれず、role="status" の隠した段落に場面名と埋まったマスの数を文で書きますが、触った直後とモードが変わるときだけです。自動デモの最中は読み上げが流れません。
動きを減らす設定と隠れたタブ
prefers-reduced-motion がオンなら、ループなしで時間表の停止の時刻までを一度に進めた盤一枚だけを描き、数字が現れる補間と波の模様は描きません。タブが隠れるとループを止め、戻ったらたまった時間を捨てて続けます。設定の名前と動きは prefers-reduced-motion の文書にまとまっています。
// タブが隠れたら止め、戻ったら時計を合わせ直して始める
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 });
数独のFAQ — 数独の上級向けの考え方・解がひとつだけの盤の条件・ウェブに載せるときに必要なもの
数独の上級向けの考え方は、コードでどう始めますか
02番のひとつだけの候補を埋める方法がいちばんの基本で、それだけで詰まったら03番のように候補がいちばん少ないマスを選んで仮置きするのが、この編が扱うもう一歩です。06番の絞り込みの根拠を一緒に見れば、どの行が候補を消したか読めます。
解がひとつだけの盤の条件は何ですか
穴を開けて解の数を数えた値がちょうど1であることです。04番は2までだけ数え、二つ以上なら戻してこの条件を守ります。
ウェブに載せるときは何が必要ですか
サーバーなしでHTMLファイルひと組を置くだけで動きます。画面の倍率は devicePixelRatio の値を最大2までで使って切り、外部のフォントやサーバー呼び出しもないので、ファイルを置く場所さえあれば足ります。
出典の文書はMDNの Pointer events の項目です。









