css tic tac toe 9パターン — 盤が自分で数えて線を引く三目並べ
css tic tac toe の盤に駒を置く前に、コンピューターは残る終局を数えている — 03番の点数ヒートと04番の勝敗リングがその計算をマスの上に描く。九つの盤はどれも、勝ち負けの読みを数字ではなく色と線で見せます。
九つの三目並べの盤を画面でそのまま打ってみる
01 ttt-stroke-mark-spring-draw
02 ttt-pointer-tilt-board-shadow
03 ttt-minimax-cell-score-heat
04 ttt-hover-outcome-odds-ring
05 ttt-win-line-sweep-glow
06 ttt-nested-board-zoom-focus
07 ttt-symmetry-fold-canonical
08 ttt-game-tree-branch-expand
09 ttt-chalkboard-selfplay-dust-hero
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 マスを押すとXとOが筆順どおりに描かれ、マスがバネで沈んで戻る置き方
- 02 ポインターに合わせて遠近で傾き、駒が落ちて収まる間は高さに応じて影が長くぼやける立体盤
- 03 空きマスごとに最後まで読み切った点数が熱の色で敷かれ、最善のマスが灯る盤
- 04 マスに乗せると、そこへ置いた後に起こりうる終局の勝・分・負の割合が半透明の駒の周りにリングで広がる盤
- 05 三つが揃うと線がマスの中心を結ぶように引かれ、光がにじんで静まる勝利演出
- 06 盤の中に盤があり、置いたマスが次の盤を指定して、画面がその盤へバネで拡大される入れ子の盤
- 07 回しても裏返しても同じ局面を一つの盤に折り重ねる対称の折りたたみ表示
- 08 現在の局面から分かれる手を枝に広げ、その先の終局の数で枝の太さが変わる木の表示
- 09 チョーク質感の黒板で二つのAIが自ら打ち、粉が舞い、黒板消しで消されていくヒーロー
九つの盤は、駒が一つ置かれる瞬間から始まり、盤全体を読む計算へ広がっていく順に並んでいます。01は駒が筆順どおりに描かれてマスが沈んで戻り、02はその盤がポインターに合わせて傾き、03と04はマスの上に点数と勝敗の割合を重ねます。05は終局の瞬間の線、06は盤の中の盤で、07と08は同じ局面を折りたたんだり木に広げたりして見せ、09は二つのコンピューターが自ら打つ盤をチョークの質感で締めくくります。
css tic tac toe 9パターン — 計算がマスの上に描かれる三目並べ
盤はキャンバス一枚に描きます。駒とマスと点数の色を塗り、次の一手を決める仕事は index.html の JavaScript が受け持ち、CSS が決めるのは舞台の大きさと、その上に重なる状態表示・ボタン、キーボードフォーカスの輪郭線だけです。舞台 .tt の幅は min(92vw, 160vh - 12.8vw) で、480:300 の比率を保ちます。キャンバスの内部解像度は480×300に devicePixelRatio(最大2)を掛けた大きさなので、ウィンドウが縮んでもマスの座標は変わりません。ポインターの位置は getBoundingClientRect の比率で480×300の座標に直して使います。
対局の進み方は九つの盤で共通です
人がX、コンピューターがOを持ちます。人がマスを押すと駒が置かれ、420ms後に bestCell が全探索の点数が最も高いマス(同点なら番号の小さいほう)へOを置きます。その420msの間に来た入力と、すでに埋まったマスへの入力は捨てられます。
人の入力が2.4秒(150ティック)途切れると、両方をコンピューターが打ちます。Xは空きマスが6個を超えるか30%の確率のときに、種を固定した乱数でマスを選び、残りは bestCell です。05は八本の線を決まった順に一局ずつ完成させる台本の対局で、06は小さい盤の規則が別にあるので chooseMove の加点式で打ちます。終局のあとに人が押すと新しい対局が始まります。
入力はマスごとのボタンではなく、キャンバスの上のカーソルです
キャンバスが tabindex="0" と aria-label を持ち、矢印キーでカーソル(3×3、06は9×9)を動かして、EnterかSpaceで置きます。マスごとに <button> を置かないので、<button> は停止・再生の一つだけで、カーソルのマスはキャンバスにフォーカスがあるときだけ白い2.5pxの枠で描かれます。
状態行の aria-live="polite" は「Xの番」「Oの勝ち」のような文言が変わったときだけ更新し、キーボードのカーソルがあるときは末尾に「マス 行-列」の位置が付きます。
時間と乱数は固定されています
ハーネスが16msの固定ステップ(FIXED_DT)で scene.step() を呼び、描画は requestAnimationFrame の一回につき一度です。乱数は種を固定した線形合同法の lcg(seed) だけで作り(場面ごとに種は別で、01は makeMatch に11を渡し、03は lcg(31)、06は lcg(61)、07は lcg(71)、09は lcg(91) で、09は背景用に lcg(5) も別に持ちます。05は台本の対局なので種では変わりません)、Math.random・new Date・Date.now・fetch・localStorage は九つのファイルのどれにも出てきません。
この盤をどんな画面に置くか
01はモバイルの対戦アプリやボードゲームの紹介ページ、02はボードゲームカフェのランディングやおもちゃ店のヒーローに似合います。03は学習用のゲーム解説やAI相手の難易度案内、04は戦略ゲームのチュートリアルや手を読む練習ページに向いています。
05は勝利画面や結果の共有カード、06はボードゲームのポートフォリオや対戦モードの紹介に合います。07は数学の教育コンテンツやパズルの解説、08はアルゴリズムの講義資料や開発ブログの図解、09は教育サービスのランディングや開発者イベントのポスターに入れやすい盤です。
html css tic tac toe を技法別のコードで見る
各断片は index.html の <script> から連続した行をそのまま切り出したもので、25行を超えません。ファイルの前半には駒の描画・ハーネス・進行役の共通コードが約520行あり、場面ごとのコードは528行目あたりから始まります。tic tac toe html css code を探す人が思い浮かべるマスのクリック処理より一段内側、盤が値を計算して描く場所を選んで載せました。
01マスを押すとXとOが筆順どおりに描かれ、マスがバネで沈んで戻る置き方
Xは二画を順に、Oは12時の位置から時計回りに一周、setLineDash の長さを lineDashOffset で縮めながら引き、マスの大きさは a = -SPRING_K * (pos[i] - 1) - damp * vel[i] という減衰バネの式で、一度行き過ぎてから落ち着きます。下の断片は、駒の置かれたマスごとに16msの一歩を四つに割ってその式を積分する場所です。このマスの累積面積は19.3%、平均強度は45.1、動いたフレームは21/23でした。

<main class="stage">
<div class="tt is-demo" id="tt">
<canvas class="tt__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="マスを押すとXとOが筆順どおりに描かれ、マスがバネで沈んで戻る置き方"></canvas>
<p class="tt__status" data-role="status" aria-live="polite">Xの番</p>
<button class="tt__btn" data-role="btn" type="button">一時停止</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.tt {
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;
}
.tt__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.tt__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.tt__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.tt__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.tt__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.tt__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.tt__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.tt { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tt {
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;
}
.tt__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.tt__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.tt__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.tt__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.tt__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.tt__btn {
font-size: 10px;
padding: 4px 8px;
}
.tt__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.tt {
cursor: default;
}
}(function () {
const root = document.getElementById("tt");
if (!root) return;
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const IDLE_TICKS = 150;
const BTN_PAUSE = '一時停止';
const BTN_PLAY = '再生';
const BTN_NEXT = '次の場面';
const TXT_CELL = 'マス';
// 파스텔 팔레트 — 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG = [29, 26, 44];
const TILE = [40, 36, 62];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
const CREAM = [255, 244, 224];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 두 개로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash2(x, y, seed) {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 격자점 해시 네 개를 부드럽게 섞는다
function noise2(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = hash2(xi, yi, seed);
const b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed);
const d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
function rrect(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 말 하나를 (cx, cy) 에 그린다. p(0~1) 만큼 대시 오프셋으로 획을 긋는다 — who 1 은 X(두 획), 2 는 O(원)
function drawMark(ctx, who, cx, cy, r, p, w, col) {
ctx.save();
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.strokeStyle = col;
if (who === 1) {
const d = r * 2 * Math.SQRT2;
for (let k = 0; k < 2; k += 1) {
const pk = clamp(p * 2 - k, 0, 1);
if (pk <= 0.001) {
continue;
}
ctx.setLineDash([d, d]);
ctx.lineDashOffset = d * (1 - pk);
ctx.beginPath();
if (k === 0) {
ctx.moveTo(cx - r, cy - r);
ctx.lineTo(cx + r, cy + r);
}
else {
ctx.moveTo(cx + r, cy - r);
ctx.lineTo(cx - r, cy + r);
}
ctx.stroke();
}
}
else {
const L = PI2 * r;
if (p > 0.001) {
ctx.setLineDash([L, L]);
ctx.lineDashOffset = L * (1 - clamp(p, 0, 1));
ctx.beginPath();
ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + PI2);
ctx.stroke();
}
}
ctx.restore();
}
function mount(root, make) {
const canvas = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const btnEl = root.querySelector('[data-role="btn"]');
if (!canvas || !statusEl || !btnEl) {
return function () { return; };
}
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: -1, y: -1, cur: -1, key: false, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let focused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw(ctx);
if (inp.key && focused && inp.cur >= 0) {
const r = scene.rect(inp.cur);
ctx.save();
ctx.lineWidth = 2.5;
ctx.strokeStyle = 'rgba(255,255,255,0.92)';
rrect(ctx, r[0] + 2, r[1] + 2, r[2] - 4, r[3] - 4, 8);
ctx.stroke();
ctx.restore();
}
let t = scene.label();
if (inp.key && inp.cur >= 0) {
const n = scene.n();
t += ' · ' + TXT_CELL + ' ' + (Math.floor(inp.cur / n) + 1) + '-' + ((inp.cur % n) + 1);
}
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function localPoint(e) {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
inp.key = false;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
scene.auto();
scene.auto();
scene.auto();
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
scene.auto();
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'focus', function () { focused = true; });
listen(canvas, 'blur', function () { focused = false; if (reduce) {
show();
} });
listen(canvas, 'pointermove', function (e) {
localPoint(e);
scene.hover(scene.hit(inp.x, inp.y));
if (reduce) {
show();
}
});
listen(canvas, 'pointerleave', function () { scene.hover(-1); if (reduce) {
show();
} });
listen(canvas, 'pointerdown', function (e) {
localPoint(e);
const i = scene.hit(inp.x, inp.y);
if (i >= 0) {
scene.tap(i);
}
if (reduce) {
show();
}
});
listen(canvas, 'keydown', function (e) {
const ev = e;
const k = ev.key;
const n = scene.n();
const mid = Math.floor((n * n) / 2);
if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowUp' || k === 'ArrowDown') {
ev.preventDefault();
if (inp.cur < 0) {
inp.cur = mid;
}
else {
let r = Math.floor(inp.cur / n);
let c = inp.cur % n;
if (k === 'ArrowLeft') {
c = Math.max(0, c - 1);
}
if (k === 'ArrowRight') {
c = Math.min(n - 1, c + 1);
}
if (k === 'ArrowUp') {
r = Math.max(0, r - 1);
}
if (k === 'ArrowDown') {
r = Math.min(n - 1, r + 1);
}
inp.cur = r * n + c;
}
inp.key = true;
inp.idle = 0;
scene.hover(inp.cur);
if (reduce) {
show();
}
return;
}
if (k === 'Enter' || k === ' ') {
ev.preventDefault();
if (inp.cur < 0) {
inp.cur = mid;
}
inp.key = true;
inp.idle = 0;
scene.tap(inp.cur);
if (reduce) {
show();
}
}
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const LINES = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]];
const SCORE_WIN = 10;
const TXT_X_TURN = 'Xの番';
const TXT_O_TURN = 'Oの番';
const TXT_X_WIN = 'Xの勝ち';
const TXT_O_WIN = 'Oの勝ち';
const TXT_DRAW = '引き分け';
function newGame() { return { b: [0, 0, 0, 0, 0, 0, 0, 0, 0], turn: 1, over: false, line: -1 }; }
// 완성된 줄의 번호(LINES 안의 위치). 없으면 -1
function lineOf(b) {
for (let k = 0; k < LINES.length; k += 1) {
const l = LINES[k];
if (b[l[0]] !== 0 && b[l[0]] === b[l[1]] && b[l[1]] === b[l[2]]) {
return k;
}
}
return -1;
}
function emptyCells(b) {
const out = [];
for (let i = 0; i < 9; i += 1) {
if (b[i] === 0) {
out.push(i);
}
}
return out;
}
function place(g, i) {
if (g.over || g.b[i] !== 0) {
return false;
}
g.b[i] = g.turn;
g.line = lineOf(g.b);
if (g.line >= 0 || emptyCells(g.b).length === 0) {
g.over = true;
}
else {
g.turn = 3 - g.turn;
}
return true;
}
function gameLabel(g) {
if (g.over) {
return g.line >= 0 ? (g.b[LINES[g.line][0]] === 1 ? TXT_X_WIN : TXT_O_WIN) : TXT_DRAW;
}
return g.turn === 1 ? TXT_X_TURN : TXT_O_TURN;
}
// 전수 탐색 — X 시점 점수. depth 가 0 이 되면 더 보지 않고 0 으로 친다. cnt.n 은 방문한 국면 수
function solve(b, turn, ply, depth, cnt) {
cnt.n += 1;
const w = lineOf(b);
if (w >= 0) {
const s = SCORE_WIN - ply;
if (b[LINES[w][0]] === 1) {
cnt.x += 1;
return s;
}
cnt.o += 1;
return -s;
}
if (depth <= 0) {
return 0;
}
let best = turn === 1 ? -1000 : 1000;
let any = false;
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) {
continue;
}
b[i] = turn;
const v = solve(b, 3 - turn, ply + 1, depth - 1, cnt);
b[i] = 0;
any = true;
if (turn === 1 ? v > best : v < best) {
best = v;
}
}
return any ? best : 0;
}
// 둘 곳마다 점수. 이미 찬 칸은 NaN. 둔 사람(turn) 시점이 아니라 X 시점 값이다
function cellScores(b, turn, depth, cnt, per) {
const out = [];
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) {
out.push(NaN);
if (per) {
per.push({ n: 0, x: 0, o: 0 });
}
continue;
}
b[i] = turn;
const c = { n: 0, x: 0, o: 0 };
out.push(solve(b, 3 - turn, 1, depth - 1, c));
b[i] = 0;
cnt.n += c.n;
cnt.x += c.x;
cnt.o += c.o;
if (per) {
per.push(c);
}
}
return out;
}
function bestCell(b, turn, depth) {
const cnt = { n: 0, x: 0, o: 0 };
const sc = cellScores(b, turn, depth, cnt);
let best = -1;
for (let i = 0; i < 9; i += 1) {
if (isNaN(sc[i])) {
continue;
}
if (best < 0 || (turn === 1 ? sc[i] > sc[best] : sc[i] < sc[best])) {
best = i;
}
}
return best;
}
function gridRect(i, ox, oy, cs) {
return [ox + (i % 3) * cs, oy + Math.floor(i / 3) * cs, cs, cs];
}
function gridHit(x, y, ox, oy, cs) {
const c = Math.floor((x - ox) / cs);
const r = Math.floor((y - oy) / cs);
return c >= 0 && c < 3 && r >= 0 && r < 3 ? r * 3 + c : -1;
}
const MATCH_DEPTH = 9;
const REPLY_MS = 420;
// 한 판 진행기 — 사람은 X, 컴퓨터는 O. 사람이 가만히 있으면(idle) 둘 다 컴퓨터가 두어 루프를 만든다.
// pick 이 있으면 자동 대국의 수를 그 함수가 정한다. 판이 끝나면 holdMs 뒤에 새 판
function makeMatch(inp, reduce, turnMs, holdMs, seed, depth, onPlace, onReset, pick) {
const rng = lcg(seed);
let t = 0;
let reply = -1;
let hold = 0;
const m = {
g: newGame(),
since: 0,
move(i) {
const who = m.g.turn;
if (place(m.g, i)) {
m.since = 0;
onPlace(i, who);
}
},
tap(i) {
if (m.g.over) {
reset();
return;
}
if (m.g.turn !== 1 || m.g.b[i] !== 0) {
return;
}
m.move(i);
if (!m.g.over) {
if (reduce) {
m.move(bestCell(m.g.b, 2, depth));
}
else {
reply = REPLY_MS;
}
}
},
step() {
m.since += FIXED_DT;
if (m.g.over) {
hold += FIXED_DT;
if (hold >= holdMs) {
reset();
}
return;
}
if (reply >= 0) {
reply -= FIXED_DT;
if (reply < 0) {
m.move(bestCell(m.g.b, 2, depth));
}
return;
}
if (inp.idle > IDLE_TICKS) {
t += FIXED_DT;
if (t >= turnMs) {
t = 0;
autoMove();
}
}
},
auto() {
if (m.g.over) {
reset();
}
else {
autoMove();
}
},
};
function reset() {
m.g = newGame();
t = 0;
reply = -1;
hold = 0;
onReset();
}
function autoMove() {
if (pick) {
const p = pick();
if (p >= 0) {
m.move(p);
}
return;
}
const e = emptyCells(m.g.b);
if (m.g.turn === 1 && (e.length > 6 || rng() < 0.3)) {
m.move(e[Math.floor(rng() * e.length)]);
return;
}
m.move(bestCell(m.g.b, m.g.turn, depth));
}
return m;
}
const MARK_MS = 360;
const OVERSHOOT = 1.1;
const STROKE_W = 7;
const SPRING_K = 260;
const TURN_MS = 560;
const HOLD_MS = 1500;
const CELL = 88;
const OX = 108;
const OY = 18;
const RING_MS = 520;
const make = function (inp, reduce) {
// 감쇠 스프링 — 처음 값 0 에서 1 로 갈 때 최대 OVERSHOOT 까지 넘치도록 감쇠비를 거꾸로 푼다
const ov = Math.max(OVERSHOOT - 1, 0.001);
const ln = Math.log(ov);
const zeta = -ln / Math.sqrt(Math.PI * Math.PI + ln * ln);
const damp = 2 * zeta * Math.sqrt(SPRING_K);
const pos = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const vel = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const age = [-1, -1, -1, -1, -1, -1, -1, -1, -1];
const who = [0, 0, 0, 0, 0, 0, 0, 0, 0];
let hov = -1;
let clock = 0;
const m = makeMatch(inp, reduce, TURN_MS, HOLD_MS, 11, MATCH_DEPTH, function (i, w) {
who[i] = w;
if (reduce) {
age[i] = 99999;
pos[i] = 1;
vel[i] = 0;
return;
}
age[i] = 0;
pos[i] = 0;
vel[i] = 0;
}, function () {
for (let i = 0; i < 9; i += 1) {
age[i] = -1;
pos[i] = 0;
vel[i] = 0;
who[i] = 0;
}
}, null);
return {
step() {
clock += FIXED_DT;
m.step();
const dt = FIXED_DT / 1000;
for (let i = 0; i < 9; i += 1) {
if (age[i] < 0) {
continue;
}
age[i] += FIXED_DT;
// 16ms 한 걸음을 네 번으로 쪼개 적분 — 한 번에 가면 식이 목표로 한 넘침보다 작게 나온다
for (let q = 0; q < 4; q += 1) {
const a = -SPRING_K * (pos[i] - 1) - damp * vel[i];
vel[i] += (a * dt) / 4;
pos[i] += (vel[i] * dt) / 4;
}
}
},
draw(ctx) {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
const wl = m.g.line >= 0 ? LINES[m.g.line] : [];
for (let i = 0; i < 9; i += 1) {
const r = gridRect(i, OX, OY, CELL);
const cx = r[0] + CELL / 2;
const cy = r[1] + CELL / 2;
const x = age[i] >= 0 ? pos[i] : 1;
const pad = (CELL - 8) * (age[i] >= 0 ? 0.86 + 0.14 * x : 1);
let col = TILE;
if (age[i] >= 0) {
col = mix(TILE, who[i] === 1 ? SKY : PINK, 0.2);
}
if (wl.indexOf(i) >= 0) {
col = mix(col, MINT, 0.35 + 0.15 * Math.sin(clock / 160));
}
if (hov === i && age[i] < 0 && !m.g.over) {
col = mix(TILE, CREAM, 0.12);
}
ctx.fillStyle = rgba(col, 1);
rrect(ctx, cx - pad / 2, cy - pad / 2, pad, pad, 12);
ctx.fill();
if (age[i] < 0) {
continue;
}
const q = age[i] / RING_MS;
if (q < 1) {
ctx.lineWidth = 3;
ctx.strokeStyle = rgba(who[i] === 1 ? SKY : PINK, (1 - q) * 0.55);
ctx.beginPath();
ctx.arc(cx, cy, 20 + 38 * smooth(q), 0, PI2);
ctx.stroke();
}
const p = clamp(age[i] / MARK_MS, 0, 1);
ctx.save();
ctx.translate(cx, cy);
ctx.scale(Math.max(x, 0), Math.max(x, 0));
drawMark(ctx, who[i], 0, 0, 22, p, STROKE_W, rgba(who[i] === 1 ? SKY : PINK, 1));
ctx.restore();
}
},
label() { return gameLabel(m.g); },
hit(x, y) { return gridHit(x, y, OX, OY, CELL); },
hover(i) { hov = i; },
tap(i) { m.tap(i); },
auto() { m.auto(); },
rect(i) { return gridRect(i, OX, OY, CELL); },
n() { return 3; },
};
};
return mount(root, make);
})();// TttStrokeMarkSpringDraw - vanilla 판(01-ttt-stroke-mark-spring-draw) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./TttStrokeMarkSpringDraw.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function TttStrokeMarkSpringDraw() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
const TXT_CELL = '칸';
type RGB = [number, number, number];
interface Input { x: number; y: number; cur: number; key: boolean; idle: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
hit(x: number, y: number): number;
hover(i: number): void;
tap(i: number): void;
auto(): void;
rect(i: number): [number, number, number, number];
n(): number;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 팔레트 — 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG: RGB = [29, 26, 44];
const TILE: RGB = [40, 36, 62];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
const CREAM: RGB = [255, 244, 224];
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 smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 두 개로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash2(x: number, y: number, seed: number): number {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 격자점 해시 네 개를 부드럽게 섞는다
function noise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = hash2(xi, yi, seed);
const b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed);
const d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 말 하나를 (cx, cy) 에 그린다. p(0~1) 만큼 대시 오프셋으로 획을 긋는다 — who 1 은 X(두 획), 2 는 O(원)
function drawMark(ctx: CanvasRenderingContext2D, who: number, cx: number, cy: number, r: number, p: number, w: number, col: string): void {
ctx.save();
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.strokeStyle = col;
if (who === 1) {
const d = r * 2 * Math.SQRT2;
for (let k = 0; k < 2; k += 1) {
const pk = clamp(p * 2 - k, 0, 1);
if (pk <= 0.001) { continue; }
ctx.setLineDash([d, d]);
ctx.lineDashOffset = d * (1 - pk);
ctx.beginPath();
if (k === 0) { ctx.moveTo(cx - r, cy - r); ctx.lineTo(cx + r, cy + r); }
else { ctx.moveTo(cx + r, cy - r); ctx.lineTo(cx - r, cy + r); }
ctx.stroke();
}
} else {
const L = PI2 * r;
if (p > 0.001) {
ctx.setLineDash([L, L]);
ctx.lineDashOffset = L * (1 - clamp(p, 0, 1));
ctx.beginPath();
ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + PI2);
ctx.stroke();
}
}
ctx.restore();
}
function mount(root: HTMLElement, make: Maker): () => void {
const canvas = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const btnEl = root.querySelector('[data-role="btn"]') as HTMLButtonElement | null;
if (!canvas || !statusEl || !btnEl) { return function (): void { return; }; }
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
const ctx: CanvasRenderingContext2D = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: -1, y: -1, cur: -1, key: false, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let focused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw(ctx);
if (inp.key && focused && inp.cur >= 0) {
const r = scene.rect(inp.cur);
ctx.save();
ctx.lineWidth = 2.5;
ctx.strokeStyle = 'rgba(255,255,255,0.92)';
rrect(ctx, r[0] + 2, r[1] + 2, r[2] - 4, r[3] - 4, 8);
ctx.stroke();
ctx.restore();
}
let t = scene.label();
if (inp.key && inp.cur >= 0) {
const n = scene.n();
t += ' · ' + TXT_CELL + ' ' + (Math.floor(inp.cur / n) + 1) + '-' + ((inp.cur % n) + 1);
}
if (t !== lastLabel) {
lastLabel = t;
statusEl!.textContent = t;
}
}
function localPoint(e: PointerEvent): void {
const r = canvas!.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
inp.key = false;
}
function tick(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
scene.auto();
scene.auto();
scene.auto();
show();
}
listen(btnEl, 'click', function (): void {
if (reduce) { scene.auto(); show(); return; }
paused = !paused;
if (paused) { root.classList.remove('is-demo'); } else { root.classList.add('is-demo'); }
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'focus', function (): void { focused = true; });
listen(canvas, 'blur', function (): void { focused = false; if (reduce) { show(); } });
listen(canvas, 'pointermove', function (e: Event): void {
localPoint(e as PointerEvent);
scene.hover(scene.hit(inp.x, inp.y));
if (reduce) { show(); }
});
listen(canvas, 'pointerleave', function (): void { scene.hover(-1); if (reduce) { show(); } });
listen(canvas, 'pointerdown', function (e: Event): void {
localPoint(e as PointerEvent);
const i = scene.hit(inp.x, inp.y);
if (i >= 0) { scene.tap(i); }
if (reduce) { show(); }
});
listen(canvas, 'keydown', function (e: Event): void {
const ev = e as KeyboardEvent;
const k = ev.key;
const n = scene.n();
const mid = Math.floor((n * n) / 2);
if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowUp' || k === 'ArrowDown') {
ev.preventDefault();
if (inp.cur < 0) { inp.cur = mid; }
else {
let r = Math.floor(inp.cur / n);
let c = inp.cur % n;
if (k === 'ArrowLeft') { c = Math.max(0, c - 1); }
if (k === 'ArrowRight') { c = Math.min(n - 1, c + 1); }
if (k === 'ArrowUp') { r = Math.max(0, r - 1); }
if (k === 'ArrowDown') { r = Math.min(n - 1, r + 1); }
inp.cur = r * n + c;
}
inp.key = true;
inp.idle = 0;
scene.hover(inp.cur);
if (reduce) { show(); }
return;
}
if (k === 'Enter' || k === ' ') {
ev.preventDefault();
if (inp.cur < 0) { inp.cur = mid; }
inp.key = true;
inp.idle = 0;
scene.tap(inp.cur);
if (reduce) { show(); }
}
});
if (!reduce) { rafId = requestAnimationFrame(tick); }
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
};
}
const LINES: number[][] = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]];
const SCORE_WIN = 10;
const TXT_X_TURN = 'X 차례';
const TXT_O_TURN = 'O 차례';
const TXT_X_WIN = 'X 승';
const TXT_O_WIN = 'O 승';
const TXT_DRAW = '무승부';
interface Game { b: number[]; turn: number; over: boolean; line: number; }
interface Counter { n: number; x: number; o: number; }
function newGame(): Game { return { b: [0, 0, 0, 0, 0, 0, 0, 0, 0], turn: 1, over: false, line: -1 }; }
// 완성된 줄의 번호(LINES 안의 위치). 없으면 -1
function lineOf(b: number[]): number {
for (let k = 0; k < LINES.length; k += 1) {
const l = LINES[k];
if (b[l[0]] !== 0 && b[l[0]] === b[l[1]] && b[l[1]] === b[l[2]]) { return k; }
}
return -1;
}
function emptyCells(b: number[]): number[] {
const out: number[] = [];
for (let i = 0; i < 9; i += 1) { if (b[i] === 0) { out.push(i); } }
return out;
}
function place(g: Game, i: number): boolean {
if (g.over || g.b[i] !== 0) { return false; }
g.b[i] = g.turn;
g.line = lineOf(g.b);
if (g.line >= 0 || emptyCells(g.b).length === 0) { g.over = true; } else { g.turn = 3 - g.turn; }
return true;
}
function gameLabel(g: Game): string {
if (g.over) { return g.line >= 0 ? (g.b[LINES[g.line][0]] === 1 ? TXT_X_WIN : TXT_O_WIN) : TXT_DRAW; }
return g.turn === 1 ? TXT_X_TURN : TXT_O_TURN;
}
// 전수 탐색 — X 시점 점수. depth 가 0 이 되면 더 보지 않고 0 으로 친다. cnt.n 은 방문한 국면 수
function solve(b: number[], turn: number, ply: number, depth: number, cnt: Counter): number {
cnt.n += 1;
const w = lineOf(b);
if (w >= 0) {
const s = SCORE_WIN - ply;
if (b[LINES[w][0]] === 1) { cnt.x += 1; return s; }
cnt.o += 1;
return -s;
}
if (depth <= 0) { return 0; }
let best = turn === 1 ? -1000 : 1000;
let any = false;
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) { continue; }
b[i] = turn;
const v = solve(b, 3 - turn, ply + 1, depth - 1, cnt);
b[i] = 0;
any = true;
if (turn === 1 ? v > best : v < best) { best = v; }
}
return any ? best : 0;
}
// 둘 곳마다 점수. 이미 찬 칸은 NaN. 둔 사람(turn) 시점이 아니라 X 시점 값이다
function cellScores(b: number[], turn: number, depth: number, cnt: Counter, per?: Counter[]): number[] {
const out: number[] = [];
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) { out.push(NaN); if (per) { per.push({ n: 0, x: 0, o: 0 }); } continue; }
b[i] = turn;
const c: Counter = { n: 0, x: 0, o: 0 };
out.push(solve(b, 3 - turn, 1, depth - 1, c));
b[i] = 0;
cnt.n += c.n; cnt.x += c.x; cnt.o += c.o;
if (per) { per.push(c); }
}
return out;
}
function bestCell(b: number[], turn: number, depth: number): number {
const cnt: Counter = { n: 0, x: 0, o: 0 };
const sc = cellScores(b, turn, depth, cnt);
let best = -1;
for (let i = 0; i < 9; i += 1) {
if (isNaN(sc[i])) { continue; }
if (best < 0 || (turn === 1 ? sc[i] > sc[best] : sc[i] < sc[best])) { best = i; }
}
return best;
}
function gridRect(i: number, ox: number, oy: number, cs: number): [number, number, number, number] {
return [ox + (i % 3) * cs, oy + Math.floor(i / 3) * cs, cs, cs];
}
function gridHit(x: number, y: number, ox: number, oy: number, cs: number): number {
const c = Math.floor((x - ox) / cs);
const r = Math.floor((y - oy) / cs);
return c >= 0 && c < 3 && r >= 0 && r < 3 ? r * 3 + c : -1;
}
interface Match {
g: Game;
since: number;
move(i: number): void;
tap(i: number): void;
step(): void;
auto(): void;
}
const MATCH_DEPTH = 9;
const REPLY_MS = 420;
// 한 판 진행기 — 사람은 X, 컴퓨터는 O. 사람이 가만히 있으면(idle) 둘 다 컴퓨터가 두어 루프를 만든다.
// pick 이 있으면 자동 대국의 수를 그 함수가 정한다. 판이 끝나면 holdMs 뒤에 새 판
function makeMatch(inp: Input, reduce: boolean, turnMs: number, holdMs: number, seed: number, depth: number, onPlace: (i: number, who: number) => void, onReset: () => void, pick: (() => number) | null): Match {
const rng = lcg(seed);
let t = 0;
let reply = -1;
let hold = 0;
const m: Match = {
g: newGame(),
since: 0,
move(i: number): void {
const who = m.g.turn;
if (place(m.g, i)) { m.since = 0; onPlace(i, who); }
},
tap(i: number): void {
if (m.g.over) { reset(); return; }
if (m.g.turn !== 1 || m.g.b[i] !== 0) { return; }
m.move(i);
if (!m.g.over) {
if (reduce) { m.move(bestCell(m.g.b, 2, depth)); } else { reply = REPLY_MS; }
}
},
step(): void {
m.since += FIXED_DT;
if (m.g.over) {
hold += FIXED_DT;
if (hold >= holdMs) { reset(); }
return;
}
if (reply >= 0) {
reply -= FIXED_DT;
if (reply < 0) { m.move(bestCell(m.g.b, 2, depth)); }
return;
}
if (inp.idle > IDLE_TICKS) {
t += FIXED_DT;
if (t >= turnMs) { t = 0; autoMove(); }
}
},
auto(): void {
if (m.g.over) { reset(); } else { autoMove(); }
},
};
function reset(): void {
m.g = newGame();
t = 0;
reply = -1;
hold = 0;
onReset();
}
function autoMove(): void {
if (pick) { const p = pick(); if (p >= 0) { m.move(p); } return; }
const e = emptyCells(m.g.b);
if (m.g.turn === 1 && (e.length > 6 || rng() < 0.3)) { m.move(e[Math.floor(rng() * e.length)]); return; }
m.move(bestCell(m.g.b, m.g.turn, depth));
}
return m;
}
const MARK_MS = 360;
const OVERSHOOT = 1.1;
const STROKE_W = 7;
const SPRING_K = 260;
const TURN_MS = 560;
const HOLD_MS = 1500;
const CELL = 88;
const OX = 108;
const OY = 18;
const RING_MS = 520;
const make: Maker = function (inp: Input, reduce: boolean): Scene {
// 감쇠 스프링 — 처음 값 0 에서 1 로 갈 때 최대 OVERSHOOT 까지 넘치도록 감쇠비를 거꾸로 푼다
const ov = Math.max(OVERSHOOT - 1, 0.001);
const ln = Math.log(ov);
const zeta = -ln / Math.sqrt(Math.PI * Math.PI + ln * ln);
const damp = 2 * zeta * Math.sqrt(SPRING_K);
const pos: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const vel: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const age: number[] = [-1, -1, -1, -1, -1, -1, -1, -1, -1];
const who: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
let hov = -1;
let clock = 0;
const m = makeMatch(inp, reduce, TURN_MS, HOLD_MS, 11, MATCH_DEPTH, function (i: number, w: number): void {
who[i] = w;
if (reduce) { age[i] = 99999; pos[i] = 1; vel[i] = 0; return; }
age[i] = 0;
pos[i] = 0;
vel[i] = 0;
}, function (): void {
for (let i = 0; i < 9; i += 1) { age[i] = -1; pos[i] = 0; vel[i] = 0; who[i] = 0; }
}, null);
return {
step(): void {
clock += FIXED_DT;
m.step();
const dt = FIXED_DT / 1000;
for (let i = 0; i < 9; i += 1) {
if (age[i] < 0) { continue; }
age[i] += FIXED_DT;
// 16ms 한 걸음을 네 번으로 쪼개 적분 — 한 번에 가면 식이 목표로 한 넘침보다 작게 나온다
for (let q = 0; q < 4; q += 1) {
const a = -SPRING_K * (pos[i] - 1) - damp * vel[i];
vel[i] += (a * dt) / 4;
pos[i] += (vel[i] * dt) / 4;
}
}
},
draw(ctx: CanvasRenderingContext2D): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
const wl = m.g.line >= 0 ? LINES[m.g.line] : [];
for (let i = 0; i < 9; i += 1) {
const r = gridRect(i, OX, OY, CELL);
const cx = r[0] + CELL / 2;
const cy = r[1] + CELL / 2;
const x = age[i] >= 0 ? pos[i] : 1;
const pad = (CELL - 8) * (age[i] >= 0 ? 0.86 + 0.14 * x : 1);
let col: RGB = TILE;
if (age[i] >= 0) { col = mix(TILE, who[i] === 1 ? SKY : PINK, 0.2); }
if (wl.indexOf(i) >= 0) { col = mix(col, MINT, 0.35 + 0.15 * Math.sin(clock / 160)); }
if (hov === i && age[i] < 0 && !m.g.over) { col = mix(TILE, CREAM, 0.12); }
ctx.fillStyle = rgba(col, 1);
rrect(ctx, cx - pad / 2, cy - pad / 2, pad, pad, 12);
ctx.fill();
if (age[i] < 0) { continue; }
const q = age[i] / RING_MS;
if (q < 1) {
ctx.lineWidth = 3;
ctx.strokeStyle = rgba(who[i] === 1 ? SKY : PINK, (1 - q) * 0.55);
ctx.beginPath();
ctx.arc(cx, cy, 20 + 38 * smooth(q), 0, PI2);
ctx.stroke();
}
const p = clamp(age[i] / MARK_MS, 0, 1);
ctx.save();
ctx.translate(cx, cy);
ctx.scale(Math.max(x, 0), Math.max(x, 0));
drawMark(ctx, who[i], 0, 0, 22, p, STROKE_W, rgba(who[i] === 1 ? SKY : PINK, 1));
ctx.restore();
}
},
label(): string { return gameLabel(m.g); },
hit(x: number, y: number): number { return gridHit(x, y, OX, OY, CELL); },
hover(i: number): void { hov = i; },
tap(i: number): void { m.tap(i); },
auto(): void { m.auto(); },
rect(i: number): [number, number, number, number] { return gridRect(i, OX, OY, CELL); },
n(): number { return 3; },
};
};
return mount(root, make);
}, []);
return (
<div className={cx("tt") + " is-demo"} id="tt" ref={rootRef}>
<canvas className={cx("tt__canvas")} data-role="canvas" width="480" height="300" tabIndex={0} aria-label="칸을 누르면 X·O 가 획 순서대로 그려지고 칸이 스프링으로 눌렸다 돌아오는 말 놓기"></canvas>
<p className={cx("tt__status")} data-role="status" aria-live="polite">X 차례</p>
<button className={cx("tt__btn")} data-role="btn" type="button">멈춤</button>
</div>
);
}01 · マスを押すとXとOが筆順どおりに描かれ、マスがバネで沈んで戻る置き方 — 計測 累積面積 19.281% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
const dt = FIXED_DT / 1000;
for (let i = 0; i < 9; i += 1) {
if (age[i] < 0) {
continue;
}
age[i] += FIXED_DT;
// 16msの一歩を四つに割って積分する — 一度に進むと、式が狙った行き過ぎより小さく出る
for (let q = 0; q < 4; q += 1) {
const a = -SPRING_K * (pos[i] - 1) - damp * vel[i];
vel[i] += (a * dt) / 4;
pos[i] += (vel[i] * dt) / 4;
}
}
02ポインターに合わせて遠近で傾き、駒が落ちて収まる間は高さに応じて影が長くぼやける立体盤
盤は252pxの正方形で、ポインターの位置は lerp(ax, tx, LERP_K) でティックごとに傾きへ移され、目標の95%に届くまで32ティック、512msかかります。影は駒の高さに比例して長く太くなりますが、ぼかしは本物のブラーではなく太さの違う四本を重ねた線で、座った駒は長さ9.9px・外側の太さ約17.5px、高さ90pxから落ちる瞬間は長さ59.4px・太さ約56pxです。レンダーで測った値は累積面積26.7%、平均強度45.6、動いたフレーム23/23でした。

<main class="stage">
<div class="tt is-demo" id="tt">
<canvas class="tt__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="ポインターに合わせて遠近で傾き、駒が落ちて収まる間は高さに応じて影が長くぼやける立体盤"></canvas>
<p class="tt__status" data-role="status" aria-live="polite">Xの番</p>
<button class="tt__btn" data-role="btn" type="button">一時停止</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.tt {
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;
}
.tt__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.tt__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.tt__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.tt__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.tt__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.tt__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.tt__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.tt { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tt {
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;
}
.tt__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.tt__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.tt__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.tt__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.tt__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.tt__btn {
font-size: 10px;
padding: 4px 8px;
}
.tt__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.tt {
cursor: default;
}
}(function () {
const root = document.getElementById("tt");
if (!root) return;
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const IDLE_TICKS = 150;
const BTN_PAUSE = '一時停止';
const BTN_PLAY = '再生';
const BTN_NEXT = '次の場面';
const TXT_CELL = 'マス';
// 파스텔 팔레트 — 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG = [29, 26, 44];
const TILE = [40, 36, 62];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
const CREAM = [255, 244, 224];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 두 개로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash2(x, y, seed) {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 격자점 해시 네 개를 부드럽게 섞는다
function noise2(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = hash2(xi, yi, seed);
const b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed);
const d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
function rrect(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 말 하나를 (cx, cy) 에 그린다. p(0~1) 만큼 대시 오프셋으로 획을 긋는다 — who 1 은 X(두 획), 2 는 O(원)
function drawMark(ctx, who, cx, cy, r, p, w, col) {
ctx.save();
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.strokeStyle = col;
if (who === 1) {
const d = r * 2 * Math.SQRT2;
for (let k = 0; k < 2; k += 1) {
const pk = clamp(p * 2 - k, 0, 1);
if (pk <= 0.001) {
continue;
}
ctx.setLineDash([d, d]);
ctx.lineDashOffset = d * (1 - pk);
ctx.beginPath();
if (k === 0) {
ctx.moveTo(cx - r, cy - r);
ctx.lineTo(cx + r, cy + r);
}
else {
ctx.moveTo(cx + r, cy - r);
ctx.lineTo(cx - r, cy + r);
}
ctx.stroke();
}
}
else {
const L = PI2 * r;
if (p > 0.001) {
ctx.setLineDash([L, L]);
ctx.lineDashOffset = L * (1 - clamp(p, 0, 1));
ctx.beginPath();
ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + PI2);
ctx.stroke();
}
}
ctx.restore();
}
function mount(root, make) {
const canvas = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const btnEl = root.querySelector('[data-role="btn"]');
if (!canvas || !statusEl || !btnEl) {
return function () { return; };
}
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: -1, y: -1, cur: -1, key: false, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let focused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw(ctx);
if (inp.key && focused && inp.cur >= 0) {
const r = scene.rect(inp.cur);
ctx.save();
ctx.lineWidth = 2.5;
ctx.strokeStyle = 'rgba(255,255,255,0.92)';
rrect(ctx, r[0] + 2, r[1] + 2, r[2] - 4, r[3] - 4, 8);
ctx.stroke();
ctx.restore();
}
let t = scene.label();
if (inp.key && inp.cur >= 0) {
const n = scene.n();
t += ' · ' + TXT_CELL + ' ' + (Math.floor(inp.cur / n) + 1) + '-' + ((inp.cur % n) + 1);
}
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function localPoint(e) {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
inp.key = false;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
scene.auto();
scene.auto();
scene.auto();
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
scene.auto();
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'focus', function () { focused = true; });
listen(canvas, 'blur', function () { focused = false; if (reduce) {
show();
} });
listen(canvas, 'pointermove', function (e) {
localPoint(e);
scene.hover(scene.hit(inp.x, inp.y));
if (reduce) {
show();
}
});
listen(canvas, 'pointerleave', function () { scene.hover(-1); if (reduce) {
show();
} });
listen(canvas, 'pointerdown', function (e) {
localPoint(e);
const i = scene.hit(inp.x, inp.y);
if (i >= 0) {
scene.tap(i);
}
if (reduce) {
show();
}
});
listen(canvas, 'keydown', function (e) {
const ev = e;
const k = ev.key;
const n = scene.n();
const mid = Math.floor((n * n) / 2);
if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowUp' || k === 'ArrowDown') {
ev.preventDefault();
if (inp.cur < 0) {
inp.cur = mid;
}
else {
let r = Math.floor(inp.cur / n);
let c = inp.cur % n;
if (k === 'ArrowLeft') {
c = Math.max(0, c - 1);
}
if (k === 'ArrowRight') {
c = Math.min(n - 1, c + 1);
}
if (k === 'ArrowUp') {
r = Math.max(0, r - 1);
}
if (k === 'ArrowDown') {
r = Math.min(n - 1, r + 1);
}
inp.cur = r * n + c;
}
inp.key = true;
inp.idle = 0;
scene.hover(inp.cur);
if (reduce) {
show();
}
return;
}
if (k === 'Enter' || k === ' ') {
ev.preventDefault();
if (inp.cur < 0) {
inp.cur = mid;
}
inp.key = true;
inp.idle = 0;
scene.tap(inp.cur);
if (reduce) {
show();
}
}
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const LINES = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]];
const SCORE_WIN = 10;
const TXT_X_TURN = 'Xの番';
const TXT_O_TURN = 'Oの番';
const TXT_X_WIN = 'Xの勝ち';
const TXT_O_WIN = 'Oの勝ち';
const TXT_DRAW = '引き分け';
function newGame() { return { b: [0, 0, 0, 0, 0, 0, 0, 0, 0], turn: 1, over: false, line: -1 }; }
// 완성된 줄의 번호(LINES 안의 위치). 없으면 -1
function lineOf(b) {
for (let k = 0; k < LINES.length; k += 1) {
const l = LINES[k];
if (b[l[0]] !== 0 && b[l[0]] === b[l[1]] && b[l[1]] === b[l[2]]) {
return k;
}
}
return -1;
}
function emptyCells(b) {
const out = [];
for (let i = 0; i < 9; i += 1) {
if (b[i] === 0) {
out.push(i);
}
}
return out;
}
function place(g, i) {
if (g.over || g.b[i] !== 0) {
return false;
}
g.b[i] = g.turn;
g.line = lineOf(g.b);
if (g.line >= 0 || emptyCells(g.b).length === 0) {
g.over = true;
}
else {
g.turn = 3 - g.turn;
}
return true;
}
function gameLabel(g) {
if (g.over) {
return g.line >= 0 ? (g.b[LINES[g.line][0]] === 1 ? TXT_X_WIN : TXT_O_WIN) : TXT_DRAW;
}
return g.turn === 1 ? TXT_X_TURN : TXT_O_TURN;
}
// 전수 탐색 — X 시점 점수. depth 가 0 이 되면 더 보지 않고 0 으로 친다. cnt.n 은 방문한 국면 수
function solve(b, turn, ply, depth, cnt) {
cnt.n += 1;
const w = lineOf(b);
if (w >= 0) {
const s = SCORE_WIN - ply;
if (b[LINES[w][0]] === 1) {
cnt.x += 1;
return s;
}
cnt.o += 1;
return -s;
}
if (depth <= 0) {
return 0;
}
let best = turn === 1 ? -1000 : 1000;
let any = false;
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) {
continue;
}
b[i] = turn;
const v = solve(b, 3 - turn, ply + 1, depth - 1, cnt);
b[i] = 0;
any = true;
if (turn === 1 ? v > best : v < best) {
best = v;
}
}
return any ? best : 0;
}
// 둘 곳마다 점수. 이미 찬 칸은 NaN. 둔 사람(turn) 시점이 아니라 X 시점 값이다
function cellScores(b, turn, depth, cnt, per) {
const out = [];
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) {
out.push(NaN);
if (per) {
per.push({ n: 0, x: 0, o: 0 });
}
continue;
}
b[i] = turn;
const c = { n: 0, x: 0, o: 0 };
out.push(solve(b, 3 - turn, 1, depth - 1, c));
b[i] = 0;
cnt.n += c.n;
cnt.x += c.x;
cnt.o += c.o;
if (per) {
per.push(c);
}
}
return out;
}
function bestCell(b, turn, depth) {
const cnt = { n: 0, x: 0, o: 0 };
const sc = cellScores(b, turn, depth, cnt);
let best = -1;
for (let i = 0; i < 9; i += 1) {
if (isNaN(sc[i])) {
continue;
}
if (best < 0 || (turn === 1 ? sc[i] > sc[best] : sc[i] < sc[best])) {
best = i;
}
}
return best;
}
function gridRect(i, ox, oy, cs) {
return [ox + (i % 3) * cs, oy + Math.floor(i / 3) * cs, cs, cs];
}
function gridHit(x, y, ox, oy, cs) {
const c = Math.floor((x - ox) / cs);
const r = Math.floor((y - oy) / cs);
return c >= 0 && c < 3 && r >= 0 && r < 3 ? r * 3 + c : -1;
}
const MATCH_DEPTH = 9;
const REPLY_MS = 420;
// 한 판 진행기 — 사람은 X, 컴퓨터는 O. 사람이 가만히 있으면(idle) 둘 다 컴퓨터가 두어 루프를 만든다.
// pick 이 있으면 자동 대국의 수를 그 함수가 정한다. 판이 끝나면 holdMs 뒤에 새 판
function makeMatch(inp, reduce, turnMs, holdMs, seed, depth, onPlace, onReset, pick) {
const rng = lcg(seed);
let t = 0;
let reply = -1;
let hold = 0;
const m = {
g: newGame(),
since: 0,
move(i) {
const who = m.g.turn;
if (place(m.g, i)) {
m.since = 0;
onPlace(i, who);
}
},
tap(i) {
if (m.g.over) {
reset();
return;
}
if (m.g.turn !== 1 || m.g.b[i] !== 0) {
return;
}
m.move(i);
if (!m.g.over) {
if (reduce) {
m.move(bestCell(m.g.b, 2, depth));
}
else {
reply = REPLY_MS;
}
}
},
step() {
m.since += FIXED_DT;
if (m.g.over) {
hold += FIXED_DT;
if (hold >= holdMs) {
reset();
}
return;
}
if (reply >= 0) {
reply -= FIXED_DT;
if (reply < 0) {
m.move(bestCell(m.g.b, 2, depth));
}
return;
}
if (inp.idle > IDLE_TICKS) {
t += FIXED_DT;
if (t >= turnMs) {
t = 0;
autoMove();
}
}
},
auto() {
if (m.g.over) {
reset();
}
else {
autoMove();
}
},
};
function reset() {
m.g = newGame();
t = 0;
reply = -1;
hold = 0;
onReset();
}
function autoMove() {
if (pick) {
const p = pick();
if (p >= 0) {
m.move(p);
}
return;
}
const e = emptyCells(m.g.b);
if (m.g.turn === 1 && (e.length > 6 || rng() < 0.3)) {
m.move(e[Math.floor(rng() * e.length)]);
return;
}
m.move(bestCell(m.g.b, m.g.turn, depth));
}
return m;
}
const TILT_MAX = 0.42;
const DEPTH_PX = 18;
const SHADOW_LEN = 0.55;
const LERP_K = 0.09;
const CAM_Z = 560;
const CELL = 84;
const TURN_MS = 1000;
const HOLD_MS = 1500;
const DROP_H = 90;
const GRAVITY = 1100;
const BOUNCE = 0.38;
const MARK_R = 25;
const make = function (inp, reduce) {
let ax = 0;
let ay = 0;
let hov = -1;
let clock = 0;
const zb = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const vz = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const who = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const m = makeMatch(inp, reduce, TURN_MS, HOLD_MS, 21, MATCH_DEPTH, function (i, w) {
who[i] = w;
zb[i] = reduce ? 0 : DROP_H;
vz[i] = 0;
}, function () {
for (let i = 0; i < 9; i += 1) {
who[i] = 0;
zb[i] = 0;
vz[i] = 0;
}
}, null);
// 판 평면의 점 (px, py) 과 높이 pz 를 화면으로 — 두 축 회전 뒤 원근 나눗셈
function proj(px, py, pz) {
const cyy = Math.cos(ay);
const syy = Math.sin(ay);
const cxx = Math.cos(ax);
const sxx = Math.sin(ax);
const x1 = px * cyy + pz * syy;
const z1 = -px * syy + pz * cyy;
const y2 = py * cxx - z1 * sxx;
const z2 = py * sxx + z1 * cxx;
const f = CAM_Z / (CAM_Z - z2);
return [W / 2 + x1 * f, H / 2 + 2 + y2 * f, f];
}
function center(i) {
return [((i % 3) - 1) * CELL, (Math.floor(i / 3) - 1) * CELL];
}
function quad(i) {
const c = center(i);
const h = CELL / 2;
return [proj(c[0] - h, c[1] - h, 0), proj(c[0] + h, c[1] - h, 0), proj(c[0] + h, c[1] + h, 0), proj(c[0] - h, c[1] + h, 0)];
}
function markPoints(who1, cx, cy) {
const r = MARK_R;
if (who1 === 1) {
return [[[cx - r, cy - r], [cx + r, cy + r]], [[cx + r, cy - r], [cx - r, cy + r]]];
}
const pts = [];
for (let k = 0; k <= 24; k += 1) {
pts.push([cx + Math.cos((k / 24) * PI2) * r, cy + Math.sin((k / 24) * PI2) * r]);
}
return [pts];
}
function strokeMark(ctx, w, i, z, col, lw, dx, dy) {
const c = center(i);
const shapes = markPoints(w, c[0] + dx, c[1] + dy);
ctx.strokeStyle = col;
ctx.lineWidth = lw;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
for (let s = 0; s < shapes.length; s += 1) {
ctx.beginPath();
for (let k = 0; k < shapes[s].length; k += 1) {
const p = proj(shapes[s][k][0], shapes[s][k][1], z);
if (k === 0) {
ctx.moveTo(p[0], p[1]);
}
else {
ctx.lineTo(p[0], p[1]);
}
}
ctx.stroke();
}
}
return {
step() {
clock += FIXED_DT;
m.step();
let tx = 0;
let ty = 0;
if (inp.idle <= IDLE_TICKS && inp.x >= 0 && !inp.key) {
tx = ((inp.y - H / 2) / (H / 2)) * TILT_MAX;
ty = ((inp.x - W / 2) / (W / 2)) * TILT_MAX;
}
else {
tx = TILT_MAX * 0.8 * Math.cos(clock / 520);
ty = TILT_MAX * Math.sin(clock / 410);
}
if (reduce) {
tx = 0;
ty = 0;
}
ax = lerp(ax, tx, LERP_K);
ay = lerp(ay, ty, LERP_K);
const dt = FIXED_DT / 1000;
for (let i = 0; i < 9; i += 1) {
if (who[i] === 0) {
continue;
}
if (zb[i] > 0 || vz[i] !== 0) {
vz[i] -= GRAVITY * dt;
zb[i] += vz[i] * dt;
if (zb[i] <= 0) {
zb[i] = 0;
vz[i] = Math.abs(vz[i]) > 60 ? -vz[i] * BOUNCE : 0;
}
}
}
},
draw(ctx) {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
// 판 받침
const hb = CELL * 1.5 + 8;
const base = [proj(-hb, -hb, -6), proj(hb, -hb, -6), proj(hb, hb, -6), proj(-hb, hb, -6)];
ctx.fillStyle = rgba(mix(BG, LILAC, 0.16), 1);
ctx.beginPath();
for (let k = 0; k < 4; k += 1) {
if (k === 0) {
ctx.moveTo(base[k][0], base[k][1]);
}
else {
ctx.lineTo(base[k][0], base[k][1]);
}
}
ctx.closePath();
ctx.fill();
const wl = m.g.line >= 0 ? LINES[m.g.line] : [];
for (let i = 0; i < 9; i += 1) {
const q = quad(i);
let col = TILE;
if (wl.indexOf(i) >= 0) {
col = mix(TILE, MINT, 0.4);
}
if (hov === i && who[i] === 0 && !m.g.over) {
col = mix(TILE, CREAM, 0.16);
}
ctx.fillStyle = rgba(col, 1);
ctx.beginPath();
for (let k = 0; k < 4; k += 1) {
// マス 사이 틈 — 중심 쪽으로 6% 당긴다
const c = proj(center(i)[0], center(i)[1], 0);
const x = lerp(q[k][0], c[0], 0.07);
const y = lerp(q[k][1], c[1], 0.07);
if (k === 0) {
ctx.moveTo(x, y);
}
else {
ctx.lineTo(x, y);
}
}
ctx.closePath();
ctx.fill();
}
// 그림자 — 높이(받침 위 떠 있는 높이 + 두께)에 비례해 멀리, 더 번지게
for (let i = 0; i < 9; i += 1) {
if (who[i] === 0) {
continue;
}
const hgt = zb[i] + DEPTH_PX;
const off = hgt * SHADOW_LEN;
const blur = 1 + hgt * 0.16;
const alpha = clamp(0.5 - hgt * 0.0016, 0.18, 0.5);
for (let pass = 3; pass >= 0; pass -= 1) {
strokeMark(ctx, who[i], i, 0.5, 'rgba(10,8,22,' + (alpha / 4).toFixed(3) + ')', 7 + blur * pass * 0.9, off * 0.5, off * 0.8);
}
}
// 말 — 뒤쪽 줄부터 그린다. 두께는 높이를 여러 층으로 쌓아 표현
const order = [0, 1, 2, 3, 4, 5, 6, 7, 8].sort(function (a, b) { return center(a)[1] - center(b)[1]; });
for (let o = 0; o < 9; o += 1) {
const i = order[o];
if (who[i] === 0) {
continue;
}
const base0 = who[i] === 1 ? SKY : PINK;
const layers = 6;
for (let s = 0; s < layers; s += 1) {
const z = zb[i] + (DEPTH_PX * s) / (layers - 1);
const top = s === layers - 1;
const col = top ? mix(base0, CREAM, 0.25) : mix(BG, base0, 0.35 + 0.4 * (s / layers));
strokeMark(ctx, who[i], i, z, rgba(col, 1), top ? 8 : 9, 0, 0);
}
}
},
label() { return gameLabel(m.g); },
hit(x, y) {
for (let i = 0; i < 9; i += 1) {
const q = quad(i);
let pos = 0;
let neg = 0;
for (let k = 0; k < 4; k += 1) {
const a = q[k];
const b = q[(k + 1) % 4];
const cr = (b[0] - a[0]) * (y - a[1]) - (b[1] - a[1]) * (x - a[0]);
if (cr > 0) {
pos += 1;
}
else {
neg += 1;
}
}
if (pos === 4 || neg === 4) {
return i;
}
}
return -1;
},
hover(i) { hov = i; },
tap(i) { m.tap(i); },
auto() { m.auto(); },
rect(i) {
const q = quad(i);
const xs = q.map(function (p) { return p[0]; });
const ys = q.map(function (p) { return p[1]; });
const x0 = Math.min(xs[0], xs[1], xs[2], xs[3]);
const y0 = Math.min(ys[0], ys[1], ys[2], ys[3]);
return [x0, y0, Math.max(xs[0], xs[1], xs[2], xs[3]) - x0, Math.max(ys[0], ys[1], ys[2], ys[3]) - y0];
},
n() { return 3; },
};
};
return mount(root, make);
})();// TttPointerTiltBoardShadow - vanilla 판(02-ttt-pointer-tilt-board-shadow) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./TttPointerTiltBoardShadow.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function TttPointerTiltBoardShadow() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
const TXT_CELL = '칸';
type RGB = [number, number, number];
interface Input { x: number; y: number; cur: number; key: boolean; idle: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
hit(x: number, y: number): number;
hover(i: number): void;
tap(i: number): void;
auto(): void;
rect(i: number): [number, number, number, number];
n(): number;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 팔레트 — 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG: RGB = [29, 26, 44];
const TILE: RGB = [40, 36, 62];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
const CREAM: RGB = [255, 244, 224];
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 smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 두 개로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash2(x: number, y: number, seed: number): number {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 격자점 해시 네 개를 부드럽게 섞는다
function noise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = hash2(xi, yi, seed);
const b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed);
const d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 말 하나를 (cx, cy) 에 그린다. p(0~1) 만큼 대시 오프셋으로 획을 긋는다 — who 1 은 X(두 획), 2 는 O(원)
function drawMark(ctx: CanvasRenderingContext2D, who: number, cx: number, cy: number, r: number, p: number, w: number, col: string): void {
ctx.save();
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.strokeStyle = col;
if (who === 1) {
const d = r * 2 * Math.SQRT2;
for (let k = 0; k < 2; k += 1) {
const pk = clamp(p * 2 - k, 0, 1);
if (pk <= 0.001) { continue; }
ctx.setLineDash([d, d]);
ctx.lineDashOffset = d * (1 - pk);
ctx.beginPath();
if (k === 0) { ctx.moveTo(cx - r, cy - r); ctx.lineTo(cx + r, cy + r); }
else { ctx.moveTo(cx + r, cy - r); ctx.lineTo(cx - r, cy + r); }
ctx.stroke();
}
} else {
const L = PI2 * r;
if (p > 0.001) {
ctx.setLineDash([L, L]);
ctx.lineDashOffset = L * (1 - clamp(p, 0, 1));
ctx.beginPath();
ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + PI2);
ctx.stroke();
}
}
ctx.restore();
}
function mount(root: HTMLElement, make: Maker): () => void {
const canvas = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const btnEl = root.querySelector('[data-role="btn"]') as HTMLButtonElement | null;
if (!canvas || !statusEl || !btnEl) { return function (): void { return; }; }
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
const ctx: CanvasRenderingContext2D = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: -1, y: -1, cur: -1, key: false, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let focused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw(ctx);
if (inp.key && focused && inp.cur >= 0) {
const r = scene.rect(inp.cur);
ctx.save();
ctx.lineWidth = 2.5;
ctx.strokeStyle = 'rgba(255,255,255,0.92)';
rrect(ctx, r[0] + 2, r[1] + 2, r[2] - 4, r[3] - 4, 8);
ctx.stroke();
ctx.restore();
}
let t = scene.label();
if (inp.key && inp.cur >= 0) {
const n = scene.n();
t += ' · ' + TXT_CELL + ' ' + (Math.floor(inp.cur / n) + 1) + '-' + ((inp.cur % n) + 1);
}
if (t !== lastLabel) {
lastLabel = t;
statusEl!.textContent = t;
}
}
function localPoint(e: PointerEvent): void {
const r = canvas!.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
inp.key = false;
}
function tick(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
scene.auto();
scene.auto();
scene.auto();
show();
}
listen(btnEl, 'click', function (): void {
if (reduce) { scene.auto(); show(); return; }
paused = !paused;
if (paused) { root.classList.remove('is-demo'); } else { root.classList.add('is-demo'); }
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'focus', function (): void { focused = true; });
listen(canvas, 'blur', function (): void { focused = false; if (reduce) { show(); } });
listen(canvas, 'pointermove', function (e: Event): void {
localPoint(e as PointerEvent);
scene.hover(scene.hit(inp.x, inp.y));
if (reduce) { show(); }
});
listen(canvas, 'pointerleave', function (): void { scene.hover(-1); if (reduce) { show(); } });
listen(canvas, 'pointerdown', function (e: Event): void {
localPoint(e as PointerEvent);
const i = scene.hit(inp.x, inp.y);
if (i >= 0) { scene.tap(i); }
if (reduce) { show(); }
});
listen(canvas, 'keydown', function (e: Event): void {
const ev = e as KeyboardEvent;
const k = ev.key;
const n = scene.n();
const mid = Math.floor((n * n) / 2);
if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowUp' || k === 'ArrowDown') {
ev.preventDefault();
if (inp.cur < 0) { inp.cur = mid; }
else {
let r = Math.floor(inp.cur / n);
let c = inp.cur % n;
if (k === 'ArrowLeft') { c = Math.max(0, c - 1); }
if (k === 'ArrowRight') { c = Math.min(n - 1, c + 1); }
if (k === 'ArrowUp') { r = Math.max(0, r - 1); }
if (k === 'ArrowDown') { r = Math.min(n - 1, r + 1); }
inp.cur = r * n + c;
}
inp.key = true;
inp.idle = 0;
scene.hover(inp.cur);
if (reduce) { show(); }
return;
}
if (k === 'Enter' || k === ' ') {
ev.preventDefault();
if (inp.cur < 0) { inp.cur = mid; }
inp.key = true;
inp.idle = 0;
scene.tap(inp.cur);
if (reduce) { show(); }
}
});
if (!reduce) { rafId = requestAnimationFrame(tick); }
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
};
}
const LINES: number[][] = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]];
const SCORE_WIN = 10;
const TXT_X_TURN = 'X 차례';
const TXT_O_TURN = 'O 차례';
const TXT_X_WIN = 'X 승';
const TXT_O_WIN = 'O 승';
const TXT_DRAW = '무승부';
interface Game { b: number[]; turn: number; over: boolean; line: number; }
interface Counter { n: number; x: number; o: number; }
function newGame(): Game { return { b: [0, 0, 0, 0, 0, 0, 0, 0, 0], turn: 1, over: false, line: -1 }; }
// 완성된 줄의 번호(LINES 안의 위치). 없으면 -1
function lineOf(b: number[]): number {
for (let k = 0; k < LINES.length; k += 1) {
const l = LINES[k];
if (b[l[0]] !== 0 && b[l[0]] === b[l[1]] && b[l[1]] === b[l[2]]) { return k; }
}
return -1;
}
function emptyCells(b: number[]): number[] {
const out: number[] = [];
for (let i = 0; i < 9; i += 1) { if (b[i] === 0) { out.push(i); } }
return out;
}
function place(g: Game, i: number): boolean {
if (g.over || g.b[i] !== 0) { return false; }
g.b[i] = g.turn;
g.line = lineOf(g.b);
if (g.line >= 0 || emptyCells(g.b).length === 0) { g.over = true; } else { g.turn = 3 - g.turn; }
return true;
}
function gameLabel(g: Game): string {
if (g.over) { return g.line >= 0 ? (g.b[LINES[g.line][0]] === 1 ? TXT_X_WIN : TXT_O_WIN) : TXT_DRAW; }
return g.turn === 1 ? TXT_X_TURN : TXT_O_TURN;
}
// 전수 탐색 — X 시점 점수. depth 가 0 이 되면 더 보지 않고 0 으로 친다. cnt.n 은 방문한 국면 수
function solve(b: number[], turn: number, ply: number, depth: number, cnt: Counter): number {
cnt.n += 1;
const w = lineOf(b);
if (w >= 0) {
const s = SCORE_WIN - ply;
if (b[LINES[w][0]] === 1) { cnt.x += 1; return s; }
cnt.o += 1;
return -s;
}
if (depth <= 0) { return 0; }
let best = turn === 1 ? -1000 : 1000;
let any = false;
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) { continue; }
b[i] = turn;
const v = solve(b, 3 - turn, ply + 1, depth - 1, cnt);
b[i] = 0;
any = true;
if (turn === 1 ? v > best : v < best) { best = v; }
}
return any ? best : 0;
}
// 둘 곳마다 점수. 이미 찬 칸은 NaN. 둔 사람(turn) 시점이 아니라 X 시점 값이다
function cellScores(b: number[], turn: number, depth: number, cnt: Counter, per?: Counter[]): number[] {
const out: number[] = [];
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) { out.push(NaN); if (per) { per.push({ n: 0, x: 0, o: 0 }); } continue; }
b[i] = turn;
const c: Counter = { n: 0, x: 0, o: 0 };
out.push(solve(b, 3 - turn, 1, depth - 1, c));
b[i] = 0;
cnt.n += c.n; cnt.x += c.x; cnt.o += c.o;
if (per) { per.push(c); }
}
return out;
}
function bestCell(b: number[], turn: number, depth: number): number {
const cnt: Counter = { n: 0, x: 0, o: 0 };
const sc = cellScores(b, turn, depth, cnt);
let best = -1;
for (let i = 0; i < 9; i += 1) {
if (isNaN(sc[i])) { continue; }
if (best < 0 || (turn === 1 ? sc[i] > sc[best] : sc[i] < sc[best])) { best = i; }
}
return best;
}
function gridRect(i: number, ox: number, oy: number, cs: number): [number, number, number, number] {
return [ox + (i % 3) * cs, oy + Math.floor(i / 3) * cs, cs, cs];
}
function gridHit(x: number, y: number, ox: number, oy: number, cs: number): number {
const c = Math.floor((x - ox) / cs);
const r = Math.floor((y - oy) / cs);
return c >= 0 && c < 3 && r >= 0 && r < 3 ? r * 3 + c : -1;
}
interface Match {
g: Game;
since: number;
move(i: number): void;
tap(i: number): void;
step(): void;
auto(): void;
}
const MATCH_DEPTH = 9;
const REPLY_MS = 420;
// 한 판 진행기 — 사람은 X, 컴퓨터는 O. 사람이 가만히 있으면(idle) 둘 다 컴퓨터가 두어 루프를 만든다.
// pick 이 있으면 자동 대국의 수를 그 함수가 정한다. 판이 끝나면 holdMs 뒤에 새 판
function makeMatch(inp: Input, reduce: boolean, turnMs: number, holdMs: number, seed: number, depth: number, onPlace: (i: number, who: number) => void, onReset: () => void, pick: (() => number) | null): Match {
const rng = lcg(seed);
let t = 0;
let reply = -1;
let hold = 0;
const m: Match = {
g: newGame(),
since: 0,
move(i: number): void {
const who = m.g.turn;
if (place(m.g, i)) { m.since = 0; onPlace(i, who); }
},
tap(i: number): void {
if (m.g.over) { reset(); return; }
if (m.g.turn !== 1 || m.g.b[i] !== 0) { return; }
m.move(i);
if (!m.g.over) {
if (reduce) { m.move(bestCell(m.g.b, 2, depth)); } else { reply = REPLY_MS; }
}
},
step(): void {
m.since += FIXED_DT;
if (m.g.over) {
hold += FIXED_DT;
if (hold >= holdMs) { reset(); }
return;
}
if (reply >= 0) {
reply -= FIXED_DT;
if (reply < 0) { m.move(bestCell(m.g.b, 2, depth)); }
return;
}
if (inp.idle > IDLE_TICKS) {
t += FIXED_DT;
if (t >= turnMs) { t = 0; autoMove(); }
}
},
auto(): void {
if (m.g.over) { reset(); } else { autoMove(); }
},
};
function reset(): void {
m.g = newGame();
t = 0;
reply = -1;
hold = 0;
onReset();
}
function autoMove(): void {
if (pick) { const p = pick(); if (p >= 0) { m.move(p); } return; }
const e = emptyCells(m.g.b);
if (m.g.turn === 1 && (e.length > 6 || rng() < 0.3)) { m.move(e[Math.floor(rng() * e.length)]); return; }
m.move(bestCell(m.g.b, m.g.turn, depth));
}
return m;
}
const TILT_MAX = 0.42;
const DEPTH_PX = 18;
const SHADOW_LEN = 0.55;
const LERP_K = 0.09;
const CAM_Z = 560;
const CELL = 84;
const TURN_MS = 1000;
const HOLD_MS = 1500;
const DROP_H = 90;
const GRAVITY = 1100;
const BOUNCE = 0.38;
const MARK_R = 25;
const make: Maker = function (inp: Input, reduce: boolean): Scene {
let ax = 0;
let ay = 0;
let hov = -1;
let clock = 0;
const zb: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const vz: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const who: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const m = makeMatch(inp, reduce, TURN_MS, HOLD_MS, 21, MATCH_DEPTH, function (i: number, w: number): void {
who[i] = w;
zb[i] = reduce ? 0 : DROP_H;
vz[i] = 0;
}, function (): void {
for (let i = 0; i < 9; i += 1) { who[i] = 0; zb[i] = 0; vz[i] = 0; }
}, null);
// 판 평면의 점 (px, py) 과 높이 pz 를 화면으로 — 두 축 회전 뒤 원근 나눗셈
function proj(px: number, py: number, pz: number): [number, number, number] {
const cyy = Math.cos(ay);
const syy = Math.sin(ay);
const cxx = Math.cos(ax);
const sxx = Math.sin(ax);
const x1 = px * cyy + pz * syy;
const z1 = -px * syy + pz * cyy;
const y2 = py * cxx - z1 * sxx;
const z2 = py * sxx + z1 * cxx;
const f = CAM_Z / (CAM_Z - z2);
return [W / 2 + x1 * f, H / 2 + 2 + y2 * f, f];
}
function center(i: number): [number, number] {
return [((i % 3) - 1) * CELL, (Math.floor(i / 3) - 1) * CELL];
}
function quad(i: number): Array<[number, number, number]> {
const c = center(i);
const h = CELL / 2;
return [proj(c[0] - h, c[1] - h, 0), proj(c[0] + h, c[1] - h, 0), proj(c[0] + h, c[1] + h, 0), proj(c[0] - h, c[1] + h, 0)];
}
function markPoints(who1: number, cx: number, cy: number): number[][][] {
const r = MARK_R;
if (who1 === 1) { return [[[cx - r, cy - r], [cx + r, cy + r]], [[cx + r, cy - r], [cx - r, cy + r]]]; }
const pts: number[][] = [];
for (let k = 0; k <= 24; k += 1) { pts.push([cx + Math.cos((k / 24) * PI2) * r, cy + Math.sin((k / 24) * PI2) * r]); }
return [pts];
}
function strokeMark(ctx: CanvasRenderingContext2D, w: number, i: number, z: number, col: string, lw: number, dx: number, dy: number): void {
const c = center(i);
const shapes = markPoints(w, c[0] + dx, c[1] + dy);
ctx.strokeStyle = col;
ctx.lineWidth = lw;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
for (let s = 0; s < shapes.length; s += 1) {
ctx.beginPath();
for (let k = 0; k < shapes[s].length; k += 1) {
const p = proj(shapes[s][k][0], shapes[s][k][1], z);
if (k === 0) { ctx.moveTo(p[0], p[1]); } else { ctx.lineTo(p[0], p[1]); }
}
ctx.stroke();
}
}
return {
step(): void {
clock += FIXED_DT;
m.step();
let tx = 0;
let ty = 0;
if (inp.idle <= IDLE_TICKS && inp.x >= 0 && !inp.key) {
tx = ((inp.y - H / 2) / (H / 2)) * TILT_MAX;
ty = ((inp.x - W / 2) / (W / 2)) * TILT_MAX;
} else {
tx = TILT_MAX * 0.8 * Math.cos(clock / 520);
ty = TILT_MAX * Math.sin(clock / 410);
}
if (reduce) { tx = 0; ty = 0; }
ax = lerp(ax, tx, LERP_K);
ay = lerp(ay, ty, LERP_K);
const dt = FIXED_DT / 1000;
for (let i = 0; i < 9; i += 1) {
if (who[i] === 0) { continue; }
if (zb[i] > 0 || vz[i] !== 0) {
vz[i] -= GRAVITY * dt;
zb[i] += vz[i] * dt;
if (zb[i] <= 0) {
zb[i] = 0;
vz[i] = Math.abs(vz[i]) > 60 ? -vz[i] * BOUNCE : 0;
}
}
}
},
draw(ctx: CanvasRenderingContext2D): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
// 판 받침
const hb = CELL * 1.5 + 8;
const base = [proj(-hb, -hb, -6), proj(hb, -hb, -6), proj(hb, hb, -6), proj(-hb, hb, -6)];
ctx.fillStyle = rgba(mix(BG, LILAC, 0.16), 1);
ctx.beginPath();
for (let k = 0; k < 4; k += 1) { if (k === 0) { ctx.moveTo(base[k][0], base[k][1]); } else { ctx.lineTo(base[k][0], base[k][1]); } }
ctx.closePath();
ctx.fill();
const wl = m.g.line >= 0 ? LINES[m.g.line] : [];
for (let i = 0; i < 9; i += 1) {
const q = quad(i);
let col: RGB = TILE;
if (wl.indexOf(i) >= 0) { col = mix(TILE, MINT, 0.4); }
if (hov === i && who[i] === 0 && !m.g.over) { col = mix(TILE, CREAM, 0.16); }
ctx.fillStyle = rgba(col, 1);
ctx.beginPath();
for (let k = 0; k < 4; k += 1) {
// 칸 사이 틈 — 중심 쪽으로 6% 당긴다
const c = proj(center(i)[0], center(i)[1], 0);
const x = lerp(q[k][0], c[0], 0.07);
const y = lerp(q[k][1], c[1], 0.07);
if (k === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); }
}
ctx.closePath();
ctx.fill();
}
// 그림자 — 높이(받침 위 떠 있는 높이 + 두께)에 비례해 멀리, 더 번지게
for (let i = 0; i < 9; i += 1) {
if (who[i] === 0) { continue; }
const hgt = zb[i] + DEPTH_PX;
const off = hgt * SHADOW_LEN;
const blur = 1 + hgt * 0.16;
const alpha = clamp(0.5 - hgt * 0.0016, 0.18, 0.5);
for (let pass = 3; pass >= 0; pass -= 1) {
strokeMark(ctx, who[i], i, 0.5, 'rgba(10,8,22,' + (alpha / 4).toFixed(3) + ')', 7 + blur * pass * 0.9, off * 0.5, off * 0.8);
}
}
// 말 — 뒤쪽 줄부터 그린다. 두께는 높이를 여러 층으로 쌓아 표현
const order = [0, 1, 2, 3, 4, 5, 6, 7, 8].sort(function (a: number, b: number): number { return center(a)[1] - center(b)[1]; });
for (let o = 0; o < 9; o += 1) {
const i = order[o];
if (who[i] === 0) { continue; }
const base0 = who[i] === 1 ? SKY : PINK;
const layers = 6;
for (let s = 0; s < layers; s += 1) {
const z = zb[i] + (DEPTH_PX * s) / (layers - 1);
const top = s === layers - 1;
const col: RGB = top ? mix(base0, CREAM, 0.25) : mix(BG, base0, 0.35 + 0.4 * (s / layers));
strokeMark(ctx, who[i], i, z, rgba(col, 1), top ? 8 : 9, 0, 0);
}
}
},
label(): string { return gameLabel(m.g); },
hit(x: number, y: number): number {
for (let i = 0; i < 9; i += 1) {
const q = quad(i);
let pos = 0;
let neg = 0;
for (let k = 0; k < 4; k += 1) {
const a = q[k];
const b = q[(k + 1) % 4];
const cr = (b[0] - a[0]) * (y - a[1]) - (b[1] - a[1]) * (x - a[0]);
if (cr > 0) { pos += 1; } else { neg += 1; }
}
if (pos === 4 || neg === 4) { return i; }
}
return -1;
},
hover(i: number): void { hov = i; },
tap(i: number): void { m.tap(i); },
auto(): void { m.auto(); },
rect(i: number): [number, number, number, number] {
const q = quad(i);
const xs = q.map(function (p: [number, number, number]): number { return p[0]; });
const ys = q.map(function (p: [number, number, number]): number { return p[1]; });
const x0 = Math.min(xs[0], xs[1], xs[2], xs[3]);
const y0 = Math.min(ys[0], ys[1], ys[2], ys[3]);
return [x0, y0, Math.max(xs[0], xs[1], xs[2], xs[3]) - x0, Math.max(ys[0], ys[1], ys[2], ys[3]) - y0];
},
n(): number { return 3; },
};
};
return mount(root, make);
}, []);
return (
<div className={cx("tt") + " is-demo"} id="tt" ref={rootRef}>
<canvas className={cx("tt__canvas")} data-role="canvas" width="480" height="300" tabIndex={0} aria-label="포인터를 따라 판이 원근으로 기울고 말이 떨어져 앉을 때 높이만큼 그림자가 길고 흐려지는 입체 판"></canvas>
<p className={cx("tt__status")} data-role="status" aria-live="polite">X 차례</p>
<button className={cx("tt__btn")} data-role="btn" type="button">멈춤</button>
</div>
);
}02 · ポインターに合わせて遠近で傾き、駒が落ちて収まる間は高さに応じて影が長くぼやける立体盤 — 計測 累積面積 26.728% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
for (let i = 0; i < 9; i += 1) {
if (who[i] === 0) {
continue;
}
const hgt = zb[i] + DEPTH_PX;
const off = hgt * SHADOW_LEN;
const blur = 1 + hgt * 0.16;
const alpha = clamp(0.5 - hgt * 0.0016, 0.18, 0.5);
for (let pass = 3; pass >= 0; pass -= 1) {
strokeMark(ctx, who[i], i, 0.5, 'rgba(10,8,22,' + (alpha / 4).toFixed(3) + ')', 7 + blur * pass * 0.9, off * 0.5, off * 0.8);
}
}
03空きマスごとに最後まで読み切った点数が熱の色で敷かれ、最善のマスが灯る盤
空きマスごとに、そこへ置いた後の盤を solve が終局まで展開し、Xが勝てば SCORE_WIN - ply、負ければその負の値、引き分けなら0を上へ返します。点数はミント(正)とピンク(負)の濃さで塗り、空の盤ではどの初手も0なので数字は出さず、そのマスの後に打つ側が勝つ手と負ける手の差を終局の数で割った比率だけで淡い色が分かれ、最善のマスは700ms後から枠が脈打ちます。この盤は累積面積37.2%、平均強度30.2、動いたフレーム21/23でした。

<main class="stage">
<div class="tt is-demo" id="tt">
<canvas class="tt__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="空きマスごとに最後まで読み切った点数が熱の色で敷かれ、最善のマスが灯る盤"></canvas>
<p class="tt__status" data-role="status" aria-live="polite">Xの番</p>
<button class="tt__btn" data-role="btn" type="button">一時停止</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.tt {
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;
}
.tt__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.tt__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.tt__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.tt__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.tt__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.tt__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.tt__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.tt { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tt {
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;
}
.tt__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.tt__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.tt__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.tt__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.tt__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.tt__btn {
font-size: 10px;
padding: 4px 8px;
}
.tt__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.tt {
cursor: default;
}
}(function () {
const root = document.getElementById("tt");
if (!root) return;
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const IDLE_TICKS = 150;
const BTN_PAUSE = '一時停止';
const BTN_PLAY = '再生';
const BTN_NEXT = '次の場面';
const TXT_CELL = 'マス';
// 파스텔 팔레트 — 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG = [29, 26, 44];
const TILE = [40, 36, 62];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
const CREAM = [255, 244, 224];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 두 개로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash2(x, y, seed) {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 격자점 해시 네 개를 부드럽게 섞는다
function noise2(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = hash2(xi, yi, seed);
const b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed);
const d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
function rrect(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 말 하나를 (cx, cy) 에 그린다. p(0~1) 만큼 대시 오프셋으로 획을 긋는다 — who 1 은 X(두 획), 2 는 O(원)
function drawMark(ctx, who, cx, cy, r, p, w, col) {
ctx.save();
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.strokeStyle = col;
if (who === 1) {
const d = r * 2 * Math.SQRT2;
for (let k = 0; k < 2; k += 1) {
const pk = clamp(p * 2 - k, 0, 1);
if (pk <= 0.001) {
continue;
}
ctx.setLineDash([d, d]);
ctx.lineDashOffset = d * (1 - pk);
ctx.beginPath();
if (k === 0) {
ctx.moveTo(cx - r, cy - r);
ctx.lineTo(cx + r, cy + r);
}
else {
ctx.moveTo(cx + r, cy - r);
ctx.lineTo(cx - r, cy + r);
}
ctx.stroke();
}
}
else {
const L = PI2 * r;
if (p > 0.001) {
ctx.setLineDash([L, L]);
ctx.lineDashOffset = L * (1 - clamp(p, 0, 1));
ctx.beginPath();
ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + PI2);
ctx.stroke();
}
}
ctx.restore();
}
function mount(root, make) {
const canvas = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const btnEl = root.querySelector('[data-role="btn"]');
if (!canvas || !statusEl || !btnEl) {
return function () { return; };
}
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: -1, y: -1, cur: -1, key: false, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let focused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw(ctx);
if (inp.key && focused && inp.cur >= 0) {
const r = scene.rect(inp.cur);
ctx.save();
ctx.lineWidth = 2.5;
ctx.strokeStyle = 'rgba(255,255,255,0.92)';
rrect(ctx, r[0] + 2, r[1] + 2, r[2] - 4, r[3] - 4, 8);
ctx.stroke();
ctx.restore();
}
let t = scene.label();
if (inp.key && inp.cur >= 0) {
const n = scene.n();
t += ' · ' + TXT_CELL + ' ' + (Math.floor(inp.cur / n) + 1) + '-' + ((inp.cur % n) + 1);
}
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function localPoint(e) {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
inp.key = false;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
scene.auto();
scene.auto();
scene.auto();
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
scene.auto();
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'focus', function () { focused = true; });
listen(canvas, 'blur', function () { focused = false; if (reduce) {
show();
} });
listen(canvas, 'pointermove', function (e) {
localPoint(e);
scene.hover(scene.hit(inp.x, inp.y));
if (reduce) {
show();
}
});
listen(canvas, 'pointerleave', function () { scene.hover(-1); if (reduce) {
show();
} });
listen(canvas, 'pointerdown', function (e) {
localPoint(e);
const i = scene.hit(inp.x, inp.y);
if (i >= 0) {
scene.tap(i);
}
if (reduce) {
show();
}
});
listen(canvas, 'keydown', function (e) {
const ev = e;
const k = ev.key;
const n = scene.n();
const mid = Math.floor((n * n) / 2);
if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowUp' || k === 'ArrowDown') {
ev.preventDefault();
if (inp.cur < 0) {
inp.cur = mid;
}
else {
let r = Math.floor(inp.cur / n);
let c = inp.cur % n;
if (k === 'ArrowLeft') {
c = Math.max(0, c - 1);
}
if (k === 'ArrowRight') {
c = Math.min(n - 1, c + 1);
}
if (k === 'ArrowUp') {
r = Math.max(0, r - 1);
}
if (k === 'ArrowDown') {
r = Math.min(n - 1, r + 1);
}
inp.cur = r * n + c;
}
inp.key = true;
inp.idle = 0;
scene.hover(inp.cur);
if (reduce) {
show();
}
return;
}
if (k === 'Enter' || k === ' ') {
ev.preventDefault();
if (inp.cur < 0) {
inp.cur = mid;
}
inp.key = true;
inp.idle = 0;
scene.tap(inp.cur);
if (reduce) {
show();
}
}
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const LINES = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]];
const SCORE_WIN = 10;
const TXT_X_TURN = 'Xの番';
const TXT_O_TURN = 'Oの番';
const TXT_X_WIN = 'Xの勝ち';
const TXT_O_WIN = 'Oの勝ち';
const TXT_DRAW = '引き分け';
function newGame() { return { b: [0, 0, 0, 0, 0, 0, 0, 0, 0], turn: 1, over: false, line: -1 }; }
// 완성된 줄의 번호(LINES 안의 위치). 없으면 -1
function lineOf(b) {
for (let k = 0; k < LINES.length; k += 1) {
const l = LINES[k];
if (b[l[0]] !== 0 && b[l[0]] === b[l[1]] && b[l[1]] === b[l[2]]) {
return k;
}
}
return -1;
}
function emptyCells(b) {
const out = [];
for (let i = 0; i < 9; i += 1) {
if (b[i] === 0) {
out.push(i);
}
}
return out;
}
function place(g, i) {
if (g.over || g.b[i] !== 0) {
return false;
}
g.b[i] = g.turn;
g.line = lineOf(g.b);
if (g.line >= 0 || emptyCells(g.b).length === 0) {
g.over = true;
}
else {
g.turn = 3 - g.turn;
}
return true;
}
function gameLabel(g) {
if (g.over) {
return g.line >= 0 ? (g.b[LINES[g.line][0]] === 1 ? TXT_X_WIN : TXT_O_WIN) : TXT_DRAW;
}
return g.turn === 1 ? TXT_X_TURN : TXT_O_TURN;
}
// 전수 탐색 — X 시점 점수. depth 가 0 이 되면 더 보지 않고 0 으로 친다. cnt.n 은 방문한 국면 수
function solve(b, turn, ply, depth, cnt) {
cnt.n += 1;
const w = lineOf(b);
if (w >= 0) {
const s = SCORE_WIN - ply;
if (b[LINES[w][0]] === 1) {
cnt.x += 1;
return s;
}
cnt.o += 1;
return -s;
}
if (depth <= 0) {
return 0;
}
let best = turn === 1 ? -1000 : 1000;
let any = false;
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) {
continue;
}
b[i] = turn;
const v = solve(b, 3 - turn, ply + 1, depth - 1, cnt);
b[i] = 0;
any = true;
if (turn === 1 ? v > best : v < best) {
best = v;
}
}
return any ? best : 0;
}
// 둘 곳마다 점수. 이미 찬 칸은 NaN. 둔 사람(turn) 시점이 아니라 X 시점 값이다
function cellScores(b, turn, depth, cnt, per) {
const out = [];
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) {
out.push(NaN);
if (per) {
per.push({ n: 0, x: 0, o: 0 });
}
continue;
}
b[i] = turn;
const c = { n: 0, x: 0, o: 0 };
out.push(solve(b, 3 - turn, 1, depth - 1, c));
b[i] = 0;
cnt.n += c.n;
cnt.x += c.x;
cnt.o += c.o;
if (per) {
per.push(c);
}
}
return out;
}
function bestCell(b, turn, depth) {
const cnt = { n: 0, x: 0, o: 0 };
const sc = cellScores(b, turn, depth, cnt);
let best = -1;
for (let i = 0; i < 9; i += 1) {
if (isNaN(sc[i])) {
continue;
}
if (best < 0 || (turn === 1 ? sc[i] > sc[best] : sc[i] < sc[best])) {
best = i;
}
}
return best;
}
function gridRect(i, ox, oy, cs) {
return [ox + (i % 3) * cs, oy + Math.floor(i / 3) * cs, cs, cs];
}
function gridHit(x, y, ox, oy, cs) {
const c = Math.floor((x - ox) / cs);
const r = Math.floor((y - oy) / cs);
return c >= 0 && c < 3 && r >= 0 && r < 3 ? r * 3 + c : -1;
}
const MATCH_DEPTH = 9;
const REPLY_MS = 420;
// 한 판 진행기 — 사람은 X, 컴퓨터는 O. 사람이 가만히 있으면(idle) 둘 다 컴퓨터가 두어 루프를 만든다.
// pick 이 있으면 자동 대국의 수를 그 함수가 정한다. 판이 끝나면 holdMs 뒤에 새 판
function makeMatch(inp, reduce, turnMs, holdMs, seed, depth, onPlace, onReset, pick) {
const rng = lcg(seed);
let t = 0;
let reply = -1;
let hold = 0;
const m = {
g: newGame(),
since: 0,
move(i) {
const who = m.g.turn;
if (place(m.g, i)) {
m.since = 0;
onPlace(i, who);
}
},
tap(i) {
if (m.g.over) {
reset();
return;
}
if (m.g.turn !== 1 || m.g.b[i] !== 0) {
return;
}
m.move(i);
if (!m.g.over) {
if (reduce) {
m.move(bestCell(m.g.b, 2, depth));
}
else {
reply = REPLY_MS;
}
}
},
step() {
m.since += FIXED_DT;
if (m.g.over) {
hold += FIXED_DT;
if (hold >= holdMs) {
reset();
}
return;
}
if (reply >= 0) {
reply -= FIXED_DT;
if (reply < 0) {
m.move(bestCell(m.g.b, 2, depth));
}
return;
}
if (inp.idle > IDLE_TICKS) {
t += FIXED_DT;
if (t >= turnMs) {
t = 0;
autoMove();
}
}
},
auto() {
if (m.g.over) {
reset();
}
else {
autoMove();
}
},
};
function reset() {
m.g = newGame();
t = 0;
reply = -1;
hold = 0;
onReset();
}
function autoMove() {
if (pick) {
const p = pick();
if (p >= 0) {
m.move(p);
}
return;
}
const e = emptyCells(m.g.b);
if (m.g.turn === 1 && (e.length > 6 || rng() < 0.3)) {
m.move(e[Math.floor(rng() * e.length)]);
return;
}
m.move(bestCell(m.g.b, m.g.turn, depth));
}
return m;
}
const SEARCH_DEPTH = 9;
const HEAT_ALPHA = 0.85;
const THINK_MS = 700;
const TURN_MS = 1500;
const HOLD_MS = 1600;
const CELL = 88;
const OX = 108;
const OY = 18;
const TXT_NODES = '探索した局面';
const make = function (inp, reduce) {
const rng = lcg(31);
let scores = [];
let ratio = [];
let bestI = -1;
let nodes = 0;
let thinkT = 0;
let hov = -1;
let clock = 0;
const marks = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const m = makeMatch(inp, reduce, TURN_MS, HOLD_MS, 31, SEARCH_DEPTH, function (i, w) {
marks[i] = w;
analyze();
}, function () {
for (let i = 0; i < 9; i += 1) {
marks[i] = 0;
}
analyze();
}, function () {
const e = emptyCells(m.g.b);
if (m.g.turn === 1 && (e.length > 6 || rng() < 0.3)) {
return e[Math.floor(rng() * e.length)];
}
let best = -1;
for (let i = 0; i < 9; i += 1) {
if (isNaN(scores[i])) {
continue;
}
if (best < 0 || mover(scores[i]) + ratio[i] * 0.01 > mover(scores[best]) + ratio[best] * 0.01) {
best = i;
}
}
return best;
});
// 둘 사람 시점으로 뒤집은 점수 — 양수면 둘 사람에게 좋은 マス
function mover(s) { return m.g.turn === 1 ? s : -s; }
function analyze() {
const cnt = { n: 0, x: 0, o: 0 };
const per = [];
scores = m.g.over ? [NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN] : cellScores(m.g.b.slice(), m.g.turn, SEARCH_DEPTH, cnt, per);
// 점수가 0 인 칸도 색이 갈리게 — 그 マス 뒤에서 탐색이 만난 끝판 중 둘 사람이 이긴 몫 − 진 몫
ratio = per.map(function (c) {
const tot = c.x + c.o;
return tot === 0 ? 0 : (m.g.turn === 1 ? c.x - c.o : c.o - c.x) / tot;
});
nodes = cnt.n;
bestI = -1;
for (let i = 0; i < 9; i += 1) {
if (isNaN(scores[i])) {
continue;
}
if (bestI < 0 || mover(scores[i]) + ratio[i] * 0.01 > mover(scores[bestI]) + ratio[bestI] * 0.01) {
bestI = i;
}
}
thinkT = reduce ? THINK_MS * 2 : 0;
}
analyze();
return {
step() {
clock += FIXED_DT;
thinkT += FIXED_DT;
m.step();
},
draw(ctx) {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
const wl = m.g.line >= 0 ? LINES[m.g.line] : [];
let rank = 0;
for (let i = 0; i < 9; i += 1) {
const r = gridRect(i, OX, OY, CELL);
const cx = r[0] + CELL / 2;
const cy = r[1] + CELL / 2;
let col = TILE;
let txt = '';
if (marks[i] === 0 && !isNaN(scores[i])) {
const reveal = smooth((thinkT - rank * (THINK_MS / 10)) / (THINK_MS * 0.4));
rank += 1;
const ms = mover(scores[i]);
const raw = ms !== 0 ? clamp(ms / (SCORE_WIN - 1), -1, 1) : ratio[i] * 0.45;
const v = raw * reveal;
const target = v >= 0 ? MINT : PINK;
col = mix(TILE, target, HEAT_ALPHA * Math.abs(v));
if (reveal > 0.6 && ms !== 0) {
txt = (ms > 0 ? '+' : '') + ms;
}
}
if (wl.indexOf(i) >= 0) {
col = mix(col, CREAM, 0.3 + 0.12 * Math.sin(clock / 150));
}
if (i === bestI && thinkT > THINK_MS && !m.g.over) {
col = mix(col, CREAM, 0.16 + 0.1 * Math.sin(clock / 170));
}
if (hov === i && marks[i] === 0 && !m.g.over) {
col = mix(col, CREAM, 0.14);
}
ctx.fillStyle = rgba(col, 1);
rrect(ctx, r[0] + 4, r[1] + 4, CELL - 8, CELL - 8, 12);
ctx.fill();
if (i === bestI && thinkT > THINK_MS && !m.g.over) {
ctx.strokeStyle = rgba(CREAM, 0.55 + 0.35 * Math.sin(clock / 170));
ctx.lineWidth = 2.5;
rrect(ctx, r[0] + 4, r[1] + 4, CELL - 8, CELL - 8, 12);
ctx.stroke();
}
if (marks[i] !== 0) {
drawMark(ctx, marks[i], cx, cy, 22, 1, 7, rgba(marks[i] === 1 ? SKY : PINK, 1));
}
else if (txt) {
ctx.fillStyle = rgba(BG, 0.82);
ctx.font = '600 19px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(txt, cx, cy);
}
}
// 탐색 스캔 막대 — 생각하는 동안 マス 위를 지나간다
const prog = clamp(thinkT / THINK_MS, 0, 1);
if (prog < 1 && !m.g.over) {
const sx = OX + prog * CELL * 3;
const g = ctx.createLinearGradient(sx - 30, 0, sx, 0);
g.addColorStop(0, rgba(CREAM, 0));
g.addColorStop(1, rgba(CREAM, 0.28));
ctx.fillStyle = g;
ctx.fillRect(sx - 30, OY, 30, CELL * 3);
}
// 探索した局面 수 — THINK_MS 동안 올라가 최종값에 닿는다
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(CREAM, 0.78);
ctx.font = '500 11px sans-serif';
ctx.fillText(TXT_NODES, 426, 138);
ctx.fillStyle = rgba(MINT, 1);
ctx.font = '600 22px sans-serif';
ctx.fillText(String(Math.round(nodes * smooth(prog))), 426, 164);
},
label() { return gameLabel(m.g); },
hit(x, y) { return gridHit(x, y, OX, OY, CELL); },
hover(i) { hov = i; },
tap(i) { m.tap(i); },
auto() { m.auto(); },
rect(i) { return gridRect(i, OX, OY, CELL); },
n() { return 3; },
};
};
return mount(root, make);
})();// TttMinimaxCellScoreHeat - vanilla 판(03-ttt-minimax-cell-score-heat) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./TttMinimaxCellScoreHeat.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function TttMinimaxCellScoreHeat() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
const TXT_CELL = '칸';
type RGB = [number, number, number];
interface Input { x: number; y: number; cur: number; key: boolean; idle: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
hit(x: number, y: number): number;
hover(i: number): void;
tap(i: number): void;
auto(): void;
rect(i: number): [number, number, number, number];
n(): number;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 팔레트 — 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG: RGB = [29, 26, 44];
const TILE: RGB = [40, 36, 62];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
const CREAM: RGB = [255, 244, 224];
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 smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 두 개로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash2(x: number, y: number, seed: number): number {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 격자점 해시 네 개를 부드럽게 섞는다
function noise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = hash2(xi, yi, seed);
const b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed);
const d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 말 하나를 (cx, cy) 에 그린다. p(0~1) 만큼 대시 오프셋으로 획을 긋는다 — who 1 은 X(두 획), 2 는 O(원)
function drawMark(ctx: CanvasRenderingContext2D, who: number, cx: number, cy: number, r: number, p: number, w: number, col: string): void {
ctx.save();
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.strokeStyle = col;
if (who === 1) {
const d = r * 2 * Math.SQRT2;
for (let k = 0; k < 2; k += 1) {
const pk = clamp(p * 2 - k, 0, 1);
if (pk <= 0.001) { continue; }
ctx.setLineDash([d, d]);
ctx.lineDashOffset = d * (1 - pk);
ctx.beginPath();
if (k === 0) { ctx.moveTo(cx - r, cy - r); ctx.lineTo(cx + r, cy + r); }
else { ctx.moveTo(cx + r, cy - r); ctx.lineTo(cx - r, cy + r); }
ctx.stroke();
}
} else {
const L = PI2 * r;
if (p > 0.001) {
ctx.setLineDash([L, L]);
ctx.lineDashOffset = L * (1 - clamp(p, 0, 1));
ctx.beginPath();
ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + PI2);
ctx.stroke();
}
}
ctx.restore();
}
function mount(root: HTMLElement, make: Maker): () => void {
const canvas = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const btnEl = root.querySelector('[data-role="btn"]') as HTMLButtonElement | null;
if (!canvas || !statusEl || !btnEl) { return function (): void { return; }; }
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
const ctx: CanvasRenderingContext2D = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: -1, y: -1, cur: -1, key: false, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let focused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw(ctx);
if (inp.key && focused && inp.cur >= 0) {
const r = scene.rect(inp.cur);
ctx.save();
ctx.lineWidth = 2.5;
ctx.strokeStyle = 'rgba(255,255,255,0.92)';
rrect(ctx, r[0] + 2, r[1] + 2, r[2] - 4, r[3] - 4, 8);
ctx.stroke();
ctx.restore();
}
let t = scene.label();
if (inp.key && inp.cur >= 0) {
const n = scene.n();
t += ' · ' + TXT_CELL + ' ' + (Math.floor(inp.cur / n) + 1) + '-' + ((inp.cur % n) + 1);
}
if (t !== lastLabel) {
lastLabel = t;
statusEl!.textContent = t;
}
}
function localPoint(e: PointerEvent): void {
const r = canvas!.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
inp.key = false;
}
function tick(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
scene.auto();
scene.auto();
scene.auto();
show();
}
listen(btnEl, 'click', function (): void {
if (reduce) { scene.auto(); show(); return; }
paused = !paused;
if (paused) { root.classList.remove('is-demo'); } else { root.classList.add('is-demo'); }
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'focus', function (): void { focused = true; });
listen(canvas, 'blur', function (): void { focused = false; if (reduce) { show(); } });
listen(canvas, 'pointermove', function (e: Event): void {
localPoint(e as PointerEvent);
scene.hover(scene.hit(inp.x, inp.y));
if (reduce) { show(); }
});
listen(canvas, 'pointerleave', function (): void { scene.hover(-1); if (reduce) { show(); } });
listen(canvas, 'pointerdown', function (e: Event): void {
localPoint(e as PointerEvent);
const i = scene.hit(inp.x, inp.y);
if (i >= 0) { scene.tap(i); }
if (reduce) { show(); }
});
listen(canvas, 'keydown', function (e: Event): void {
const ev = e as KeyboardEvent;
const k = ev.key;
const n = scene.n();
const mid = Math.floor((n * n) / 2);
if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowUp' || k === 'ArrowDown') {
ev.preventDefault();
if (inp.cur < 0) { inp.cur = mid; }
else {
let r = Math.floor(inp.cur / n);
let c = inp.cur % n;
if (k === 'ArrowLeft') { c = Math.max(0, c - 1); }
if (k === 'ArrowRight') { c = Math.min(n - 1, c + 1); }
if (k === 'ArrowUp') { r = Math.max(0, r - 1); }
if (k === 'ArrowDown') { r = Math.min(n - 1, r + 1); }
inp.cur = r * n + c;
}
inp.key = true;
inp.idle = 0;
scene.hover(inp.cur);
if (reduce) { show(); }
return;
}
if (k === 'Enter' || k === ' ') {
ev.preventDefault();
if (inp.cur < 0) { inp.cur = mid; }
inp.key = true;
inp.idle = 0;
scene.tap(inp.cur);
if (reduce) { show(); }
}
});
if (!reduce) { rafId = requestAnimationFrame(tick); }
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
};
}
const LINES: number[][] = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]];
const SCORE_WIN = 10;
const TXT_X_TURN = 'X 차례';
const TXT_O_TURN = 'O 차례';
const TXT_X_WIN = 'X 승';
const TXT_O_WIN = 'O 승';
const TXT_DRAW = '무승부';
interface Game { b: number[]; turn: number; over: boolean; line: number; }
interface Counter { n: number; x: number; o: number; }
function newGame(): Game { return { b: [0, 0, 0, 0, 0, 0, 0, 0, 0], turn: 1, over: false, line: -1 }; }
// 완성된 줄의 번호(LINES 안의 위치). 없으면 -1
function lineOf(b: number[]): number {
for (let k = 0; k < LINES.length; k += 1) {
const l = LINES[k];
if (b[l[0]] !== 0 && b[l[0]] === b[l[1]] && b[l[1]] === b[l[2]]) { return k; }
}
return -1;
}
function emptyCells(b: number[]): number[] {
const out: number[] = [];
for (let i = 0; i < 9; i += 1) { if (b[i] === 0) { out.push(i); } }
return out;
}
function place(g: Game, i: number): boolean {
if (g.over || g.b[i] !== 0) { return false; }
g.b[i] = g.turn;
g.line = lineOf(g.b);
if (g.line >= 0 || emptyCells(g.b).length === 0) { g.over = true; } else { g.turn = 3 - g.turn; }
return true;
}
function gameLabel(g: Game): string {
if (g.over) { return g.line >= 0 ? (g.b[LINES[g.line][0]] === 1 ? TXT_X_WIN : TXT_O_WIN) : TXT_DRAW; }
return g.turn === 1 ? TXT_X_TURN : TXT_O_TURN;
}
// 전수 탐색 — X 시점 점수. depth 가 0 이 되면 더 보지 않고 0 으로 친다. cnt.n 은 방문한 국면 수
function solve(b: number[], turn: number, ply: number, depth: number, cnt: Counter): number {
cnt.n += 1;
const w = lineOf(b);
if (w >= 0) {
const s = SCORE_WIN - ply;
if (b[LINES[w][0]] === 1) { cnt.x += 1; return s; }
cnt.o += 1;
return -s;
}
if (depth <= 0) { return 0; }
let best = turn === 1 ? -1000 : 1000;
let any = false;
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) { continue; }
b[i] = turn;
const v = solve(b, 3 - turn, ply + 1, depth - 1, cnt);
b[i] = 0;
any = true;
if (turn === 1 ? v > best : v < best) { best = v; }
}
return any ? best : 0;
}
// 둘 곳마다 점수. 이미 찬 칸은 NaN. 둔 사람(turn) 시점이 아니라 X 시점 값이다
function cellScores(b: number[], turn: number, depth: number, cnt: Counter, per?: Counter[]): number[] {
const out: number[] = [];
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) { out.push(NaN); if (per) { per.push({ n: 0, x: 0, o: 0 }); } continue; }
b[i] = turn;
const c: Counter = { n: 0, x: 0, o: 0 };
out.push(solve(b, 3 - turn, 1, depth - 1, c));
b[i] = 0;
cnt.n += c.n; cnt.x += c.x; cnt.o += c.o;
if (per) { per.push(c); }
}
return out;
}
function bestCell(b: number[], turn: number, depth: number): number {
const cnt: Counter = { n: 0, x: 0, o: 0 };
const sc = cellScores(b, turn, depth, cnt);
let best = -1;
for (let i = 0; i < 9; i += 1) {
if (isNaN(sc[i])) { continue; }
if (best < 0 || (turn === 1 ? sc[i] > sc[best] : sc[i] < sc[best])) { best = i; }
}
return best;
}
function gridRect(i: number, ox: number, oy: number, cs: number): [number, number, number, number] {
return [ox + (i % 3) * cs, oy + Math.floor(i / 3) * cs, cs, cs];
}
function gridHit(x: number, y: number, ox: number, oy: number, cs: number): number {
const c = Math.floor((x - ox) / cs);
const r = Math.floor((y - oy) / cs);
return c >= 0 && c < 3 && r >= 0 && r < 3 ? r * 3 + c : -1;
}
interface Match {
g: Game;
since: number;
move(i: number): void;
tap(i: number): void;
step(): void;
auto(): void;
}
const MATCH_DEPTH = 9;
const REPLY_MS = 420;
// 한 판 진행기 — 사람은 X, 컴퓨터는 O. 사람이 가만히 있으면(idle) 둘 다 컴퓨터가 두어 루프를 만든다.
// pick 이 있으면 자동 대국의 수를 그 함수가 정한다. 판이 끝나면 holdMs 뒤에 새 판
function makeMatch(inp: Input, reduce: boolean, turnMs: number, holdMs: number, seed: number, depth: number, onPlace: (i: number, who: number) => void, onReset: () => void, pick: (() => number) | null): Match {
const rng = lcg(seed);
let t = 0;
let reply = -1;
let hold = 0;
const m: Match = {
g: newGame(),
since: 0,
move(i: number): void {
const who = m.g.turn;
if (place(m.g, i)) { m.since = 0; onPlace(i, who); }
},
tap(i: number): void {
if (m.g.over) { reset(); return; }
if (m.g.turn !== 1 || m.g.b[i] !== 0) { return; }
m.move(i);
if (!m.g.over) {
if (reduce) { m.move(bestCell(m.g.b, 2, depth)); } else { reply = REPLY_MS; }
}
},
step(): void {
m.since += FIXED_DT;
if (m.g.over) {
hold += FIXED_DT;
if (hold >= holdMs) { reset(); }
return;
}
if (reply >= 0) {
reply -= FIXED_DT;
if (reply < 0) { m.move(bestCell(m.g.b, 2, depth)); }
return;
}
if (inp.idle > IDLE_TICKS) {
t += FIXED_DT;
if (t >= turnMs) { t = 0; autoMove(); }
}
},
auto(): void {
if (m.g.over) { reset(); } else { autoMove(); }
},
};
function reset(): void {
m.g = newGame();
t = 0;
reply = -1;
hold = 0;
onReset();
}
function autoMove(): void {
if (pick) { const p = pick(); if (p >= 0) { m.move(p); } return; }
const e = emptyCells(m.g.b);
if (m.g.turn === 1 && (e.length > 6 || rng() < 0.3)) { m.move(e[Math.floor(rng() * e.length)]); return; }
m.move(bestCell(m.g.b, m.g.turn, depth));
}
return m;
}
const SEARCH_DEPTH = 9;
const HEAT_ALPHA = 0.85;
const THINK_MS = 700;
const TURN_MS = 1500;
const HOLD_MS = 1600;
const CELL = 88;
const OX = 108;
const OY = 18;
const TXT_NODES = '탐색한 국면';
const make: Maker = function (inp: Input, reduce: boolean): Scene {
const rng = lcg(31);
let scores: number[] = [];
let ratio: number[] = [];
let bestI = -1;
let nodes = 0;
let thinkT = 0;
let hov = -1;
let clock = 0;
const marks: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const m: Match = makeMatch(inp, reduce, TURN_MS, HOLD_MS, 31, SEARCH_DEPTH, function (i: number, w: number): void {
marks[i] = w;
analyze();
}, function (): void {
for (let i = 0; i < 9; i += 1) { marks[i] = 0; }
analyze();
}, function (): number {
const e = emptyCells(m.g.b);
if (m.g.turn === 1 && (e.length > 6 || rng() < 0.3)) { return e[Math.floor(rng() * e.length)]; }
let best = -1;
for (let i = 0; i < 9; i += 1) {
if (isNaN(scores[i])) { continue; }
if (best < 0 || mover(scores[i]) + ratio[i] * 0.01 > mover(scores[best]) + ratio[best] * 0.01) { best = i; }
}
return best;
});
// 둘 사람 시점으로 뒤집은 점수 — 양수면 둘 사람에게 좋은 칸
function mover(s: number): number { return m.g.turn === 1 ? s : -s; }
function analyze(): void {
const cnt: Counter = { n: 0, x: 0, o: 0 };
const per: Counter[] = [];
scores = m.g.over ? [NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN] : cellScores(m.g.b.slice(), m.g.turn, SEARCH_DEPTH, cnt, per);
// 점수가 0 인 칸도 색이 갈리게 — 그 칸 뒤에서 탐색이 만난 끝판 중 둘 사람이 이긴 몫 − 진 몫
ratio = per.map(function (c: Counter): number {
const tot = c.x + c.o;
return tot === 0 ? 0 : (m.g.turn === 1 ? c.x - c.o : c.o - c.x) / tot;
});
nodes = cnt.n;
bestI = -1;
for (let i = 0; i < 9; i += 1) {
if (isNaN(scores[i])) { continue; }
if (bestI < 0 || mover(scores[i]) + ratio[i] * 0.01 > mover(scores[bestI]) + ratio[bestI] * 0.01) { bestI = i; }
}
thinkT = reduce ? THINK_MS * 2 : 0;
}
analyze();
return {
step(): void {
clock += FIXED_DT;
thinkT += FIXED_DT;
m.step();
},
draw(ctx: CanvasRenderingContext2D): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
const wl = m.g.line >= 0 ? LINES[m.g.line] : [];
let rank = 0;
for (let i = 0; i < 9; i += 1) {
const r = gridRect(i, OX, OY, CELL);
const cx = r[0] + CELL / 2;
const cy = r[1] + CELL / 2;
let col: RGB = TILE;
let txt = '';
if (marks[i] === 0 && !isNaN(scores[i])) {
const reveal = smooth((thinkT - rank * (THINK_MS / 10)) / (THINK_MS * 0.4));
rank += 1;
const ms = mover(scores[i]);
const raw = ms !== 0 ? clamp(ms / (SCORE_WIN - 1), -1, 1) : ratio[i] * 0.45;
const v = raw * reveal;
const target: RGB = v >= 0 ? MINT : PINK;
col = mix(TILE, target, HEAT_ALPHA * Math.abs(v));
if (reveal > 0.6 && ms !== 0) { txt = (ms > 0 ? '+' : '') + ms; }
}
if (wl.indexOf(i) >= 0) { col = mix(col, CREAM, 0.3 + 0.12 * Math.sin(clock / 150)); }
if (i === bestI && thinkT > THINK_MS && !m.g.over) { col = mix(col, CREAM, 0.16 + 0.1 * Math.sin(clock / 170)); }
if (hov === i && marks[i] === 0 && !m.g.over) { col = mix(col, CREAM, 0.14); }
ctx.fillStyle = rgba(col, 1);
rrect(ctx, r[0] + 4, r[1] + 4, CELL - 8, CELL - 8, 12);
ctx.fill();
if (i === bestI && thinkT > THINK_MS && !m.g.over) {
ctx.strokeStyle = rgba(CREAM, 0.55 + 0.35 * Math.sin(clock / 170));
ctx.lineWidth = 2.5;
rrect(ctx, r[0] + 4, r[1] + 4, CELL - 8, CELL - 8, 12);
ctx.stroke();
}
if (marks[i] !== 0) {
drawMark(ctx, marks[i], cx, cy, 22, 1, 7, rgba(marks[i] === 1 ? SKY : PINK, 1));
} else if (txt) {
ctx.fillStyle = rgba(BG, 0.82);
ctx.font = '600 19px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(txt, cx, cy);
}
}
// 탐색 스캔 막대 — 생각하는 동안 칸 위를 지나간다
const prog = clamp(thinkT / THINK_MS, 0, 1);
if (prog < 1 && !m.g.over) {
const sx = OX + prog * CELL * 3;
const g = ctx.createLinearGradient(sx - 30, 0, sx, 0);
g.addColorStop(0, rgba(CREAM, 0));
g.addColorStop(1, rgba(CREAM, 0.28));
ctx.fillStyle = g;
ctx.fillRect(sx - 30, OY, 30, CELL * 3);
}
// 탐색한 국면 수 — THINK_MS 동안 올라가 최종값에 닿는다
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(CREAM, 0.78);
ctx.font = '500 11px sans-serif';
ctx.fillText(TXT_NODES, 426, 138);
ctx.fillStyle = rgba(MINT, 1);
ctx.font = '600 22px sans-serif';
ctx.fillText(String(Math.round(nodes * smooth(prog))), 426, 164);
},
label(): string { return gameLabel(m.g); },
hit(x: number, y: number): number { return gridHit(x, y, OX, OY, CELL); },
hover(i: number): void { hov = i; },
tap(i: number): void { m.tap(i); },
auto(): void { m.auto(); },
rect(i: number): [number, number, number, number] { return gridRect(i, OX, OY, CELL); },
n(): number { return 3; },
};
};
return mount(root, make);
}, []);
return (
<div className={cx("tt") + " is-demo"} id="tt" ref={rootRef}>
<canvas className={cx("tt__canvas")} data-role="canvas" width="480" height="300" tabIndex={0} aria-label="빈 칸마다 끝까지 수를 읽은 점수가 열지도 색으로 깔리고 가장 좋은 칸이 켜지는 판"></canvas>
<p className={cx("tt__status")} data-role="status" aria-live="polite">X 차례</p>
<button className={cx("tt__btn")} data-role="btn" type="button">멈춤</button>
</div>
);
}03 · 空きマスごとに最後まで読み切った点数が熱の色で敷かれ、最善のマスが灯る盤 — 計測 累積面積 37.219% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
let best = turn === 1 ? -1000 : 1000;
let any = false;
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) {
continue;
}
b[i] = turn;
const v = solve(b, 3 - turn, ply + 1, depth - 1, cnt);
b[i] = 0;
any = true;
if (turn === 1 ? v > best : v < best) {
best = v;
}
}
return any ? best : 0;
04マスに乗せると、そこへ置いた後に起こりうる終局の勝・分・負の割合が半透明の駒の周りにリングで広がる盤
指したマスに駒を置いたことにして、enumerate が終局を深さ優先で数え、X勝・引き分け・O勝の割合をリングの弧の長さに変えます。空の盤で角のマスを指すと訪れる局面が59,705個で ENUM_CAP の20,000を超え、終局27,732のうち9,074個だけを数え、そのときリングの外に点線のリングが出て状態行に「上限まで集計」と付きます。レンダーの値は累積面積22.5%、平均強度115.8、動いたフレーム21/23でした。

<main class="stage">
<div class="tt is-demo" id="tt">
<canvas class="tt__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="マスに乗せると、そこへ置いた後に起こりうる終局の勝・分・負の割合が半透明の駒の周りにリングで広がる盤"></canvas>
<p class="tt__status" data-role="status" aria-live="polite">Xの番</p>
<button class="tt__btn" data-role="btn" type="button">一時停止</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.tt {
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;
}
.tt__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.tt__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.tt__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.tt__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.tt__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.tt__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.tt__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.tt { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tt {
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;
}
.tt__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.tt__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.tt__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.tt__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.tt__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.tt__btn {
font-size: 10px;
padding: 4px 8px;
}
.tt__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.tt {
cursor: default;
}
}(function () {
const root = document.getElementById("tt");
if (!root) return;
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const IDLE_TICKS = 150;
const BTN_PAUSE = '一時停止';
const BTN_PLAY = '再生';
const BTN_NEXT = '次の場面';
const TXT_CELL = 'マス';
// 파스텔 팔레트 — 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG = [29, 26, 44];
const TILE = [40, 36, 62];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
const CREAM = [255, 244, 224];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 두 개로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash2(x, y, seed) {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 격자점 해시 네 개를 부드럽게 섞는다
function noise2(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = hash2(xi, yi, seed);
const b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed);
const d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
function rrect(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 말 하나를 (cx, cy) 에 그린다. p(0~1) 만큼 대시 오프셋으로 획을 긋는다 — who 1 은 X(두 획), 2 는 O(원)
function drawMark(ctx, who, cx, cy, r, p, w, col) {
ctx.save();
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.strokeStyle = col;
if (who === 1) {
const d = r * 2 * Math.SQRT2;
for (let k = 0; k < 2; k += 1) {
const pk = clamp(p * 2 - k, 0, 1);
if (pk <= 0.001) {
continue;
}
ctx.setLineDash([d, d]);
ctx.lineDashOffset = d * (1 - pk);
ctx.beginPath();
if (k === 0) {
ctx.moveTo(cx - r, cy - r);
ctx.lineTo(cx + r, cy + r);
}
else {
ctx.moveTo(cx + r, cy - r);
ctx.lineTo(cx - r, cy + r);
}
ctx.stroke();
}
}
else {
const L = PI2 * r;
if (p > 0.001) {
ctx.setLineDash([L, L]);
ctx.lineDashOffset = L * (1 - clamp(p, 0, 1));
ctx.beginPath();
ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + PI2);
ctx.stroke();
}
}
ctx.restore();
}
function mount(root, make) {
const canvas = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const btnEl = root.querySelector('[data-role="btn"]');
if (!canvas || !statusEl || !btnEl) {
return function () { return; };
}
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: -1, y: -1, cur: -1, key: false, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let focused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw(ctx);
if (inp.key && focused && inp.cur >= 0) {
const r = scene.rect(inp.cur);
ctx.save();
ctx.lineWidth = 2.5;
ctx.strokeStyle = 'rgba(255,255,255,0.92)';
rrect(ctx, r[0] + 2, r[1] + 2, r[2] - 4, r[3] - 4, 8);
ctx.stroke();
ctx.restore();
}
let t = scene.label();
if (inp.key && inp.cur >= 0) {
const n = scene.n();
t += ' · ' + TXT_CELL + ' ' + (Math.floor(inp.cur / n) + 1) + '-' + ((inp.cur % n) + 1);
}
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function localPoint(e) {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
inp.key = false;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
scene.auto();
scene.auto();
scene.auto();
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
scene.auto();
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'focus', function () { focused = true; });
listen(canvas, 'blur', function () { focused = false; if (reduce) {
show();
} });
listen(canvas, 'pointermove', function (e) {
localPoint(e);
scene.hover(scene.hit(inp.x, inp.y));
if (reduce) {
show();
}
});
listen(canvas, 'pointerleave', function () { scene.hover(-1); if (reduce) {
show();
} });
listen(canvas, 'pointerdown', function (e) {
localPoint(e);
const i = scene.hit(inp.x, inp.y);
if (i >= 0) {
scene.tap(i);
}
if (reduce) {
show();
}
});
listen(canvas, 'keydown', function (e) {
const ev = e;
const k = ev.key;
const n = scene.n();
const mid = Math.floor((n * n) / 2);
if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowUp' || k === 'ArrowDown') {
ev.preventDefault();
if (inp.cur < 0) {
inp.cur = mid;
}
else {
let r = Math.floor(inp.cur / n);
let c = inp.cur % n;
if (k === 'ArrowLeft') {
c = Math.max(0, c - 1);
}
if (k === 'ArrowRight') {
c = Math.min(n - 1, c + 1);
}
if (k === 'ArrowUp') {
r = Math.max(0, r - 1);
}
if (k === 'ArrowDown') {
r = Math.min(n - 1, r + 1);
}
inp.cur = r * n + c;
}
inp.key = true;
inp.idle = 0;
scene.hover(inp.cur);
if (reduce) {
show();
}
return;
}
if (k === 'Enter' || k === ' ') {
ev.preventDefault();
if (inp.cur < 0) {
inp.cur = mid;
}
inp.key = true;
inp.idle = 0;
scene.tap(inp.cur);
if (reduce) {
show();
}
}
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const LINES = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]];
const SCORE_WIN = 10;
const TXT_X_TURN = 'Xの番';
const TXT_O_TURN = 'Oの番';
const TXT_X_WIN = 'Xの勝ち';
const TXT_O_WIN = 'Oの勝ち';
const TXT_DRAW = '引き分け';
function newGame() { return { b: [0, 0, 0, 0, 0, 0, 0, 0, 0], turn: 1, over: false, line: -1 }; }
// 완성된 줄의 번호(LINES 안의 위치). 없으면 -1
function lineOf(b) {
for (let k = 0; k < LINES.length; k += 1) {
const l = LINES[k];
if (b[l[0]] !== 0 && b[l[0]] === b[l[1]] && b[l[1]] === b[l[2]]) {
return k;
}
}
return -1;
}
function emptyCells(b) {
const out = [];
for (let i = 0; i < 9; i += 1) {
if (b[i] === 0) {
out.push(i);
}
}
return out;
}
function place(g, i) {
if (g.over || g.b[i] !== 0) {
return false;
}
g.b[i] = g.turn;
g.line = lineOf(g.b);
if (g.line >= 0 || emptyCells(g.b).length === 0) {
g.over = true;
}
else {
g.turn = 3 - g.turn;
}
return true;
}
function gameLabel(g) {
if (g.over) {
return g.line >= 0 ? (g.b[LINES[g.line][0]] === 1 ? TXT_X_WIN : TXT_O_WIN) : TXT_DRAW;
}
return g.turn === 1 ? TXT_X_TURN : TXT_O_TURN;
}
// 전수 탐색 — X 시점 점수. depth 가 0 이 되면 더 보지 않고 0 으로 친다. cnt.n 은 방문한 국면 수
function solve(b, turn, ply, depth, cnt) {
cnt.n += 1;
const w = lineOf(b);
if (w >= 0) {
const s = SCORE_WIN - ply;
if (b[LINES[w][0]] === 1) {
cnt.x += 1;
return s;
}
cnt.o += 1;
return -s;
}
if (depth <= 0) {
return 0;
}
let best = turn === 1 ? -1000 : 1000;
let any = false;
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) {
continue;
}
b[i] = turn;
const v = solve(b, 3 - turn, ply + 1, depth - 1, cnt);
b[i] = 0;
any = true;
if (turn === 1 ? v > best : v < best) {
best = v;
}
}
return any ? best : 0;
}
// 둘 곳마다 점수. 이미 찬 칸은 NaN. 둔 사람(turn) 시점이 아니라 X 시점 값이다
function cellScores(b, turn, depth, cnt, per) {
const out = [];
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) {
out.push(NaN);
if (per) {
per.push({ n: 0, x: 0, o: 0 });
}
continue;
}
b[i] = turn;
const c = { n: 0, x: 0, o: 0 };
out.push(solve(b, 3 - turn, 1, depth - 1, c));
b[i] = 0;
cnt.n += c.n;
cnt.x += c.x;
cnt.o += c.o;
if (per) {
per.push(c);
}
}
return out;
}
function bestCell(b, turn, depth) {
const cnt = { n: 0, x: 0, o: 0 };
const sc = cellScores(b, turn, depth, cnt);
let best = -1;
for (let i = 0; i < 9; i += 1) {
if (isNaN(sc[i])) {
continue;
}
if (best < 0 || (turn === 1 ? sc[i] > sc[best] : sc[i] < sc[best])) {
best = i;
}
}
return best;
}
function gridRect(i, ox, oy, cs) {
return [ox + (i % 3) * cs, oy + Math.floor(i / 3) * cs, cs, cs];
}
function gridHit(x, y, ox, oy, cs) {
const c = Math.floor((x - ox) / cs);
const r = Math.floor((y - oy) / cs);
return c >= 0 && c < 3 && r >= 0 && r < 3 ? r * 3 + c : -1;
}
const MATCH_DEPTH = 9;
const REPLY_MS = 420;
// 한 판 진행기 — 사람은 X, 컴퓨터는 O. 사람이 가만히 있으면(idle) 둘 다 컴퓨터가 두어 루프를 만든다.
// pick 이 있으면 자동 대국의 수를 그 함수가 정한다. 판이 끝나면 holdMs 뒤에 새 판
function makeMatch(inp, reduce, turnMs, holdMs, seed, depth, onPlace, onReset, pick) {
const rng = lcg(seed);
let t = 0;
let reply = -1;
let hold = 0;
const m = {
g: newGame(),
since: 0,
move(i) {
const who = m.g.turn;
if (place(m.g, i)) {
m.since = 0;
onPlace(i, who);
}
},
tap(i) {
if (m.g.over) {
reset();
return;
}
if (m.g.turn !== 1 || m.g.b[i] !== 0) {
return;
}
m.move(i);
if (!m.g.over) {
if (reduce) {
m.move(bestCell(m.g.b, 2, depth));
}
else {
reply = REPLY_MS;
}
}
},
step() {
m.since += FIXED_DT;
if (m.g.over) {
hold += FIXED_DT;
if (hold >= holdMs) {
reset();
}
return;
}
if (reply >= 0) {
reply -= FIXED_DT;
if (reply < 0) {
m.move(bestCell(m.g.b, 2, depth));
}
return;
}
if (inp.idle > IDLE_TICKS) {
t += FIXED_DT;
if (t >= turnMs) {
t = 0;
autoMove();
}
}
},
auto() {
if (m.g.over) {
reset();
}
else {
autoMove();
}
},
};
function reset() {
m.g = newGame();
t = 0;
reply = -1;
hold = 0;
onReset();
}
function autoMove() {
if (pick) {
const p = pick();
if (p >= 0) {
m.move(p);
}
return;
}
const e = emptyCells(m.g.b);
if (m.g.turn === 1 && (e.length > 6 || rng() < 0.3)) {
m.move(e[Math.floor(rng() * e.length)]);
return;
}
m.move(bestCell(m.g.b, m.g.turn, depth));
}
return m;
}
const ODDS_RADIUS = 36;
const HOVER_LERP = 0.2;
const GHOST_ALPHA = 0.45;
const ENUM_CAP = 20000;
const SWEEP_MS = 420;
const TURN_MS = 2600;
const HOLD_MS = 1500;
const CELL = 88;
const OX = 108;
const OY = 18;
const TXT_ENUM = '数えた終局';
const TXT_CAPPED = '上限まで集計';
// 둔 뒤의 국면에서 가능한 끝판을 깊이 우선으로 센다. 방문 국면이 cap 을 넘으면 거기서 멈춘다
function enumerate(b, turn, t, cap) {
if (t.capped) {
return;
}
t.nodes += 1;
if (t.nodes > cap) {
t.capped = true;
return;
}
const w = lineOf(b);
if (w >= 0) {
if (b[LINES[w][0]] === 1) {
t.x += 1;
}
else {
t.o += 1;
}
return;
}
let any = false;
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) {
continue;
}
any = true;
b[i] = turn;
enumerate(b, 3 - turn, t, cap);
b[i] = 0;
if (t.capped) {
return;
}
}
if (!any) {
t.d += 1;
}
}
const make = function (inp, reduce) {
let hv = -1;
let sweep = 0;
let sweepT = 0;
let hovKey = '';
let tally = { x: 0, d: 0, o: 0, nodes: 0, capped: false };
let fx = 0;
let fd = 0;
let fo = 0;
let rx = OX + CELL * 1.5;
let ry = OY + CELL * 1.5;
const marks = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const m = makeMatch(inp, reduce, TURN_MS, HOLD_MS, 41, MATCH_DEPTH, function (i, w) {
marks[i] = w;
hovKey = '';
}, function () {
for (let i = 0; i < 9; i += 1) {
marks[i] = 0;
}
hovKey = '';
}, null);
function target() {
if (m.g.over) {
return -1;
}
if (hv >= 0 && marks[hv] === 0) {
return hv;
}
return -1;
}
function refreshTally(t) {
const key = t + ':' + m.g.b.join('');
if (key === hovKey) {
return;
}
hovKey = key;
const nb = m.g.b.slice();
const next = { x: 0, d: 0, o: 0, nodes: 0, capped: false };
if (t >= 0) {
nb[t] = m.g.turn;
enumerate(nb, 3 - m.g.turn, next, ENUM_CAP);
}
tally = next;
}
function frac() {
const s = Math.max(tally.x + tally.d + tally.o, 1);
return [tally.x / s, tally.d / s, tally.o / s];
}
function settle() {
if (hv >= 0 && marks[hv] !== 0) {
hv = -1;
}
const t = target();
refreshTally(t);
const f = frac();
const k = reduce ? 1 : 0.16;
fx = lerp(fx, f[0], k);
fd = lerp(fd, f[1], k);
fo = lerp(fo, f[2], k);
if (t >= 0) {
const r = gridRect(t, OX, OY, CELL);
rx = lerp(rx, r[0] + CELL / 2, reduce ? 1 : HOVER_LERP);
ry = lerp(ry, r[1] + CELL / 2, reduce ? 1 : HOVER_LERP);
}
}
// 첫 수가 이미 놓인 국면으로 시작 — 집계가 상한 안에서 끝나는 호버부터 보인다 (빈 판에서는 상한에 닿는다)
if (!reduce) {
m.move(0);
}
return {
step() {
m.step();
// 사람이 가리키지 않으면 빈 칸을 순서대로 훑으며 가리킨다
if (inp.idle > IDLE_TICKS && !m.g.over) {
sweepT += FIXED_DT;
if (sweepT >= SWEEP_MS) {
sweepT = 0;
const e = emptyCells(m.g.b);
sweep = (sweep + 1) % Math.max(e.length, 1);
hv = e[sweep];
}
}
settle();
},
draw(ctx) {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
const wl = m.g.line >= 0 ? LINES[m.g.line] : [];
for (let i = 0; i < 9; i += 1) {
const r = gridRect(i, OX, OY, CELL);
const hot = i === target();
ctx.fillStyle = rgba(wl.indexOf(i) >= 0 ? mix(TILE, MINT, 0.4) : (hot ? mix(TILE, CREAM, 0.1) : TILE), 1);
rrect(ctx, r[0] + 4, r[1] + 4, CELL - 8, CELL - 8, 12);
ctx.fill();
if (marks[i] !== 0) {
drawMark(ctx, marks[i], r[0] + CELL / 2, r[1] + CELL / 2, 20, 1, 6, rgba(marks[i] === 1 ? SKY : PINK, 1));
}
}
const t = target();
if (t >= 0) {
const r = gridRect(t, OX, OY, CELL);
drawMark(ctx, m.g.turn, r[0] + CELL / 2, r[1] + CELL / 2, 20, 1, 6, rgba(m.g.turn === 1 ? SKY : PINK, GHOST_ALPHA));
}
// 승·무·패 링 — 호의 길이가 끝판 비율
if (t >= 0 || fx + fd + fo > 0.01) {
const cols = [SKY, CREAM, PINK];
const fr = [fx, fd, fo];
let a0 = -Math.PI / 2;
ctx.lineCap = 'butt';
ctx.lineWidth = 9;
for (let k = 0; k < 3; k += 1) {
const span = fr[k] * PI2;
if (span < 0.02) {
continue;
}
ctx.strokeStyle = rgba(cols[k], 0.95);
ctx.beginPath();
ctx.arc(rx, ry, ODDS_RADIUS, a0 + 0.02, a0 + span - 0.02);
ctx.stroke();
a0 += span;
}
if (tally.capped) {
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 5]);
ctx.strokeStyle = rgba(CREAM, 0.7);
ctx.beginPath();
ctx.arc(rx, ry, ODDS_RADIUS + 9, 0, PI2);
ctx.stroke();
ctx.setLineDash([]);
}
}
// 오른쪽 읽는 マス
const cols2 = [SKY, CREAM, PINK];
const names = ['X', TXT_DRAW, 'O'];
const fr2 = frac();
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (let k = 0; k < 3; k += 1) {
ctx.fillStyle = rgba(cols2[k], 1);
ctx.beginPath();
ctx.arc(386, 120 + k * 22, 5, 0, PI2);
ctx.fill();
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.font = '500 11px sans-serif';
ctx.fillText(names[k] + ' ' + Math.round(fr2[k] * 100) + '%', 396, 120 + k * 22);
}
ctx.fillStyle = rgba(CREAM, 0.7);
ctx.font = '500 10px sans-serif';
ctx.fillText(TXT_ENUM + ' ' + (tally.x + tally.d + tally.o), 380, 192);
},
label() { return gameLabel(m.g) + (tally.capped && target() >= 0 ? ' · ' + TXT_CAPPED : ''); },
hit(x, y) { return gridHit(x, y, OX, OY, CELL); },
hover(i) { hv = i; if (reduce) {
settle();
} },
tap(i) { m.tap(i); if (reduce) {
settle();
} },
auto() {
m.auto();
if (reduce) {
const e = emptyCells(m.g.b);
hv = e.length > 0 ? e[0] : -1;
settle();
}
},
rect(i) { return gridRect(i, OX, OY, CELL); },
n() { return 3; },
};
};
return mount(root, make);
})();// TttHoverOutcomeOddsRing - vanilla 판(04-ttt-hover-outcome-odds-ring) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./TttHoverOutcomeOddsRing.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function TttHoverOutcomeOddsRing() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
const TXT_CELL = '칸';
type RGB = [number, number, number];
interface Input { x: number; y: number; cur: number; key: boolean; idle: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
hit(x: number, y: number): number;
hover(i: number): void;
tap(i: number): void;
auto(): void;
rect(i: number): [number, number, number, number];
n(): number;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 팔레트 — 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG: RGB = [29, 26, 44];
const TILE: RGB = [40, 36, 62];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
const CREAM: RGB = [255, 244, 224];
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 smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 두 개로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash2(x: number, y: number, seed: number): number {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 격자점 해시 네 개를 부드럽게 섞는다
function noise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = hash2(xi, yi, seed);
const b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed);
const d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 말 하나를 (cx, cy) 에 그린다. p(0~1) 만큼 대시 오프셋으로 획을 긋는다 — who 1 은 X(두 획), 2 는 O(원)
function drawMark(ctx: CanvasRenderingContext2D, who: number, cx: number, cy: number, r: number, p: number, w: number, col: string): void {
ctx.save();
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.strokeStyle = col;
if (who === 1) {
const d = r * 2 * Math.SQRT2;
for (let k = 0; k < 2; k += 1) {
const pk = clamp(p * 2 - k, 0, 1);
if (pk <= 0.001) { continue; }
ctx.setLineDash([d, d]);
ctx.lineDashOffset = d * (1 - pk);
ctx.beginPath();
if (k === 0) { ctx.moveTo(cx - r, cy - r); ctx.lineTo(cx + r, cy + r); }
else { ctx.moveTo(cx + r, cy - r); ctx.lineTo(cx - r, cy + r); }
ctx.stroke();
}
} else {
const L = PI2 * r;
if (p > 0.001) {
ctx.setLineDash([L, L]);
ctx.lineDashOffset = L * (1 - clamp(p, 0, 1));
ctx.beginPath();
ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + PI2);
ctx.stroke();
}
}
ctx.restore();
}
function mount(root: HTMLElement, make: Maker): () => void {
const canvas = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const btnEl = root.querySelector('[data-role="btn"]') as HTMLButtonElement | null;
if (!canvas || !statusEl || !btnEl) { return function (): void { return; }; }
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
const ctx: CanvasRenderingContext2D = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: -1, y: -1, cur: -1, key: false, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let focused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw(ctx);
if (inp.key && focused && inp.cur >= 0) {
const r = scene.rect(inp.cur);
ctx.save();
ctx.lineWidth = 2.5;
ctx.strokeStyle = 'rgba(255,255,255,0.92)';
rrect(ctx, r[0] + 2, r[1] + 2, r[2] - 4, r[3] - 4, 8);
ctx.stroke();
ctx.restore();
}
let t = scene.label();
if (inp.key && inp.cur >= 0) {
const n = scene.n();
t += ' · ' + TXT_CELL + ' ' + (Math.floor(inp.cur / n) + 1) + '-' + ((inp.cur % n) + 1);
}
if (t !== lastLabel) {
lastLabel = t;
statusEl!.textContent = t;
}
}
function localPoint(e: PointerEvent): void {
const r = canvas!.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
inp.key = false;
}
function tick(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
scene.auto();
scene.auto();
scene.auto();
show();
}
listen(btnEl, 'click', function (): void {
if (reduce) { scene.auto(); show(); return; }
paused = !paused;
if (paused) { root.classList.remove('is-demo'); } else { root.classList.add('is-demo'); }
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'focus', function (): void { focused = true; });
listen(canvas, 'blur', function (): void { focused = false; if (reduce) { show(); } });
listen(canvas, 'pointermove', function (e: Event): void {
localPoint(e as PointerEvent);
scene.hover(scene.hit(inp.x, inp.y));
if (reduce) { show(); }
});
listen(canvas, 'pointerleave', function (): void { scene.hover(-1); if (reduce) { show(); } });
listen(canvas, 'pointerdown', function (e: Event): void {
localPoint(e as PointerEvent);
const i = scene.hit(inp.x, inp.y);
if (i >= 0) { scene.tap(i); }
if (reduce) { show(); }
});
listen(canvas, 'keydown', function (e: Event): void {
const ev = e as KeyboardEvent;
const k = ev.key;
const n = scene.n();
const mid = Math.floor((n * n) / 2);
if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowUp' || k === 'ArrowDown') {
ev.preventDefault();
if (inp.cur < 0) { inp.cur = mid; }
else {
let r = Math.floor(inp.cur / n);
let c = inp.cur % n;
if (k === 'ArrowLeft') { c = Math.max(0, c - 1); }
if (k === 'ArrowRight') { c = Math.min(n - 1, c + 1); }
if (k === 'ArrowUp') { r = Math.max(0, r - 1); }
if (k === 'ArrowDown') { r = Math.min(n - 1, r + 1); }
inp.cur = r * n + c;
}
inp.key = true;
inp.idle = 0;
scene.hover(inp.cur);
if (reduce) { show(); }
return;
}
if (k === 'Enter' || k === ' ') {
ev.preventDefault();
if (inp.cur < 0) { inp.cur = mid; }
inp.key = true;
inp.idle = 0;
scene.tap(inp.cur);
if (reduce) { show(); }
}
});
if (!reduce) { rafId = requestAnimationFrame(tick); }
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
};
}
const LINES: number[][] = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]];
const SCORE_WIN = 10;
const TXT_X_TURN = 'X 차례';
const TXT_O_TURN = 'O 차례';
const TXT_X_WIN = 'X 승';
const TXT_O_WIN = 'O 승';
const TXT_DRAW = '무승부';
interface Game { b: number[]; turn: number; over: boolean; line: number; }
interface Counter { n: number; x: number; o: number; }
function newGame(): Game { return { b: [0, 0, 0, 0, 0, 0, 0, 0, 0], turn: 1, over: false, line: -1 }; }
// 완성된 줄의 번호(LINES 안의 위치). 없으면 -1
function lineOf(b: number[]): number {
for (let k = 0; k < LINES.length; k += 1) {
const l = LINES[k];
if (b[l[0]] !== 0 && b[l[0]] === b[l[1]] && b[l[1]] === b[l[2]]) { return k; }
}
return -1;
}
function emptyCells(b: number[]): number[] {
const out: number[] = [];
for (let i = 0; i < 9; i += 1) { if (b[i] === 0) { out.push(i); } }
return out;
}
function place(g: Game, i: number): boolean {
if (g.over || g.b[i] !== 0) { return false; }
g.b[i] = g.turn;
g.line = lineOf(g.b);
if (g.line >= 0 || emptyCells(g.b).length === 0) { g.over = true; } else { g.turn = 3 - g.turn; }
return true;
}
function gameLabel(g: Game): string {
if (g.over) { return g.line >= 0 ? (g.b[LINES[g.line][0]] === 1 ? TXT_X_WIN : TXT_O_WIN) : TXT_DRAW; }
return g.turn === 1 ? TXT_X_TURN : TXT_O_TURN;
}
// 전수 탐색 — X 시점 점수. depth 가 0 이 되면 더 보지 않고 0 으로 친다. cnt.n 은 방문한 국면 수
function solve(b: number[], turn: number, ply: number, depth: number, cnt: Counter): number {
cnt.n += 1;
const w = lineOf(b);
if (w >= 0) {
const s = SCORE_WIN - ply;
if (b[LINES[w][0]] === 1) { cnt.x += 1; return s; }
cnt.o += 1;
return -s;
}
if (depth <= 0) { return 0; }
let best = turn === 1 ? -1000 : 1000;
let any = false;
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) { continue; }
b[i] = turn;
const v = solve(b, 3 - turn, ply + 1, depth - 1, cnt);
b[i] = 0;
any = true;
if (turn === 1 ? v > best : v < best) { best = v; }
}
return any ? best : 0;
}
// 둘 곳마다 점수. 이미 찬 칸은 NaN. 둔 사람(turn) 시점이 아니라 X 시점 값이다
function cellScores(b: number[], turn: number, depth: number, cnt: Counter, per?: Counter[]): number[] {
const out: number[] = [];
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) { out.push(NaN); if (per) { per.push({ n: 0, x: 0, o: 0 }); } continue; }
b[i] = turn;
const c: Counter = { n: 0, x: 0, o: 0 };
out.push(solve(b, 3 - turn, 1, depth - 1, c));
b[i] = 0;
cnt.n += c.n; cnt.x += c.x; cnt.o += c.o;
if (per) { per.push(c); }
}
return out;
}
function bestCell(b: number[], turn: number, depth: number): number {
const cnt: Counter = { n: 0, x: 0, o: 0 };
const sc = cellScores(b, turn, depth, cnt);
let best = -1;
for (let i = 0; i < 9; i += 1) {
if (isNaN(sc[i])) { continue; }
if (best < 0 || (turn === 1 ? sc[i] > sc[best] : sc[i] < sc[best])) { best = i; }
}
return best;
}
function gridRect(i: number, ox: number, oy: number, cs: number): [number, number, number, number] {
return [ox + (i % 3) * cs, oy + Math.floor(i / 3) * cs, cs, cs];
}
function gridHit(x: number, y: number, ox: number, oy: number, cs: number): number {
const c = Math.floor((x - ox) / cs);
const r = Math.floor((y - oy) / cs);
return c >= 0 && c < 3 && r >= 0 && r < 3 ? r * 3 + c : -1;
}
interface Match {
g: Game;
since: number;
move(i: number): void;
tap(i: number): void;
step(): void;
auto(): void;
}
const MATCH_DEPTH = 9;
const REPLY_MS = 420;
// 한 판 진행기 — 사람은 X, 컴퓨터는 O. 사람이 가만히 있으면(idle) 둘 다 컴퓨터가 두어 루프를 만든다.
// pick 이 있으면 자동 대국의 수를 그 함수가 정한다. 판이 끝나면 holdMs 뒤에 새 판
function makeMatch(inp: Input, reduce: boolean, turnMs: number, holdMs: number, seed: number, depth: number, onPlace: (i: number, who: number) => void, onReset: () => void, pick: (() => number) | null): Match {
const rng = lcg(seed);
let t = 0;
let reply = -1;
let hold = 0;
const m: Match = {
g: newGame(),
since: 0,
move(i: number): void {
const who = m.g.turn;
if (place(m.g, i)) { m.since = 0; onPlace(i, who); }
},
tap(i: number): void {
if (m.g.over) { reset(); return; }
if (m.g.turn !== 1 || m.g.b[i] !== 0) { return; }
m.move(i);
if (!m.g.over) {
if (reduce) { m.move(bestCell(m.g.b, 2, depth)); } else { reply = REPLY_MS; }
}
},
step(): void {
m.since += FIXED_DT;
if (m.g.over) {
hold += FIXED_DT;
if (hold >= holdMs) { reset(); }
return;
}
if (reply >= 0) {
reply -= FIXED_DT;
if (reply < 0) { m.move(bestCell(m.g.b, 2, depth)); }
return;
}
if (inp.idle > IDLE_TICKS) {
t += FIXED_DT;
if (t >= turnMs) { t = 0; autoMove(); }
}
},
auto(): void {
if (m.g.over) { reset(); } else { autoMove(); }
},
};
function reset(): void {
m.g = newGame();
t = 0;
reply = -1;
hold = 0;
onReset();
}
function autoMove(): void {
if (pick) { const p = pick(); if (p >= 0) { m.move(p); } return; }
const e = emptyCells(m.g.b);
if (m.g.turn === 1 && (e.length > 6 || rng() < 0.3)) { m.move(e[Math.floor(rng() * e.length)]); return; }
m.move(bestCell(m.g.b, m.g.turn, depth));
}
return m;
}
const ODDS_RADIUS = 36;
const HOVER_LERP = 0.2;
const GHOST_ALPHA = 0.45;
const ENUM_CAP = 20000;
const SWEEP_MS = 420;
const TURN_MS = 2600;
const HOLD_MS = 1500;
const CELL = 88;
const OX = 108;
const OY = 18;
const TXT_ENUM = '집계한 끝판';
const TXT_CAPPED = '집계 상한까지만';
interface Tally { x: number; d: number; o: number; nodes: number; capped: boolean; }
// 둔 뒤의 국면에서 가능한 끝판을 깊이 우선으로 센다. 방문 국면이 cap 을 넘으면 거기서 멈춘다
function enumerate(b: number[], turn: number, t: Tally, cap: number): void {
if (t.capped) { return; }
t.nodes += 1;
if (t.nodes > cap) { t.capped = true; return; }
const w = lineOf(b);
if (w >= 0) { if (b[LINES[w][0]] === 1) { t.x += 1; } else { t.o += 1; } return; }
let any = false;
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) { continue; }
any = true;
b[i] = turn;
enumerate(b, 3 - turn, t, cap);
b[i] = 0;
if (t.capped) { return; }
}
if (!any) { t.d += 1; }
}
const make: Maker = function (inp: Input, reduce: boolean): Scene {
let hv = -1;
let sweep = 0;
let sweepT = 0;
let hovKey = '';
let tally: Tally = { x: 0, d: 0, o: 0, nodes: 0, capped: false };
let fx = 0;
let fd = 0;
let fo = 0;
let rx = OX + CELL * 1.5;
let ry = OY + CELL * 1.5;
const marks: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const m: Match = makeMatch(inp, reduce, TURN_MS, HOLD_MS, 41, MATCH_DEPTH, function (i: number, w: number): void {
marks[i] = w;
hovKey = '';
}, function (): void {
for (let i = 0; i < 9; i += 1) { marks[i] = 0; }
hovKey = '';
}, null);
function target(): number {
if (m.g.over) { return -1; }
if (hv >= 0 && marks[hv] === 0) { return hv; }
return -1;
}
function refreshTally(t: number): void {
const key = t + ':' + m.g.b.join('');
if (key === hovKey) { return; }
hovKey = key;
const nb = m.g.b.slice();
const next: Tally = { x: 0, d: 0, o: 0, nodes: 0, capped: false };
if (t >= 0) { nb[t] = m.g.turn; enumerate(nb, 3 - m.g.turn, next, ENUM_CAP); }
tally = next;
}
function frac(): [number, number, number] {
const s = Math.max(tally.x + tally.d + tally.o, 1);
return [tally.x / s, tally.d / s, tally.o / s];
}
function settle(): void {
if (hv >= 0 && marks[hv] !== 0) { hv = -1; }
const t = target();
refreshTally(t);
const f = frac();
const k = reduce ? 1 : 0.16;
fx = lerp(fx, f[0], k);
fd = lerp(fd, f[1], k);
fo = lerp(fo, f[2], k);
if (t >= 0) {
const r = gridRect(t, OX, OY, CELL);
rx = lerp(rx, r[0] + CELL / 2, reduce ? 1 : HOVER_LERP);
ry = lerp(ry, r[1] + CELL / 2, reduce ? 1 : HOVER_LERP);
}
}
// 첫 수가 이미 놓인 국면으로 시작 — 집계가 상한 안에서 끝나는 호버부터 보인다 (빈 판에서는 상한에 닿는다)
if (!reduce) { m.move(0); }
return {
step(): void {
m.step();
// 사람이 가리키지 않으면 빈 칸을 순서대로 훑으며 가리킨다
if (inp.idle > IDLE_TICKS && !m.g.over) {
sweepT += FIXED_DT;
if (sweepT >= SWEEP_MS) {
sweepT = 0;
const e = emptyCells(m.g.b);
sweep = (sweep + 1) % Math.max(e.length, 1);
hv = e[sweep];
}
}
settle();
},
draw(ctx: CanvasRenderingContext2D): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
const wl = m.g.line >= 0 ? LINES[m.g.line] : [];
for (let i = 0; i < 9; i += 1) {
const r = gridRect(i, OX, OY, CELL);
const hot = i === target();
ctx.fillStyle = rgba(wl.indexOf(i) >= 0 ? mix(TILE, MINT, 0.4) : (hot ? mix(TILE, CREAM, 0.1) : TILE), 1);
rrect(ctx, r[0] + 4, r[1] + 4, CELL - 8, CELL - 8, 12);
ctx.fill();
if (marks[i] !== 0) { drawMark(ctx, marks[i], r[0] + CELL / 2, r[1] + CELL / 2, 20, 1, 6, rgba(marks[i] === 1 ? SKY : PINK, 1)); }
}
const t = target();
if (t >= 0) {
const r = gridRect(t, OX, OY, CELL);
drawMark(ctx, m.g.turn, r[0] + CELL / 2, r[1] + CELL / 2, 20, 1, 6, rgba(m.g.turn === 1 ? SKY : PINK, GHOST_ALPHA));
}
// 승·무·패 링 — 호의 길이가 끝판 비율
if (t >= 0 || fx + fd + fo > 0.01) {
const cols: RGB[] = [SKY, CREAM, PINK];
const fr = [fx, fd, fo];
let a0 = -Math.PI / 2;
ctx.lineCap = 'butt';
ctx.lineWidth = 9;
for (let k = 0; k < 3; k += 1) {
const span = fr[k] * PI2;
if (span < 0.02) { continue; }
ctx.strokeStyle = rgba(cols[k], 0.95);
ctx.beginPath();
ctx.arc(rx, ry, ODDS_RADIUS, a0 + 0.02, a0 + span - 0.02);
ctx.stroke();
a0 += span;
}
if (tally.capped) {
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 5]);
ctx.strokeStyle = rgba(CREAM, 0.7);
ctx.beginPath();
ctx.arc(rx, ry, ODDS_RADIUS + 9, 0, PI2);
ctx.stroke();
ctx.setLineDash([]);
}
}
// 오른쪽 읽는 칸
const cols2: RGB[] = [SKY, CREAM, PINK];
const names = ['X', TXT_DRAW, 'O'];
const fr2 = frac();
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (let k = 0; k < 3; k += 1) {
ctx.fillStyle = rgba(cols2[k], 1);
ctx.beginPath();
ctx.arc(386, 120 + k * 22, 5, 0, PI2);
ctx.fill();
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.font = '500 11px sans-serif';
ctx.fillText(names[k] + ' ' + Math.round(fr2[k] * 100) + '%', 396, 120 + k * 22);
}
ctx.fillStyle = rgba(CREAM, 0.7);
ctx.font = '500 10px sans-serif';
ctx.fillText(TXT_ENUM + ' ' + (tally.x + tally.d + tally.o), 380, 192);
},
label(): string { return gameLabel(m.g) + (tally.capped && target() >= 0 ? ' · ' + TXT_CAPPED : ''); },
hit(x: number, y: number): number { return gridHit(x, y, OX, OY, CELL); },
hover(i: number): void { hv = i; if (reduce) { settle(); } },
tap(i: number): void { m.tap(i); if (reduce) { settle(); } },
auto(): void {
m.auto();
if (reduce) { const e = emptyCells(m.g.b); hv = e.length > 0 ? e[0] : -1; settle(); }
},
rect(i: number): [number, number, number, number] { return gridRect(i, OX, OY, CELL); },
n(): number { return 3; },
};
};
return mount(root, make);
}, []);
return (
<div className={cx("tt") + " is-demo"} id="tt" ref={rootRef}>
<canvas className={cx("tt__canvas")} data-role="canvas" width="480" height="300" tabIndex={0} aria-label="칸에 올리면 거기 놓은 뒤 가능한 끝판의 승·무·패 비율이 반투명 말 둘레에 링으로 번지는 판"></canvas>
<p className={cx("tt__status")} data-role="status" aria-live="polite">X 차례</p>
<button className={cx("tt__btn")} data-role="btn" type="button">멈춤</button>
</div>
);
}04 · マスに乗せると、そこへ置いた後に起こりうる終局の勝・分・負の割合が半透明の駒の周りにリングで広がる盤 — 計測 累積面積 22.503% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function enumerate(b, turn, t, cap) {
if (t.capped) {
return;
}
t.nodes += 1;
if (t.nodes > cap) {
t.capped = true;
return;
}
const w = lineOf(b);
if (w >= 0) {
if (b[LINES[w][0]] === 1) {
t.x += 1;
}
else {
t.o += 1;
}
return;
}
05三つが揃うと線がマスの中心を結ぶように引かれ、光がにじんで静まる勝利演出
八通りの線の組み合わせを手番ごとに調べ、揃った線が見つかると、両端のマスの中心からマスの大きさの0.3倍だけ外へ伸ばした線を LINE_MS の420msで引きます。にじみは shadowBlur に GLOW_BLUR × 進行を与え、引き終わったあとは Math.cos(fade * Math.PI / 2) で900msかけて0まで減らしますが、この値は変換で拡大されないため devicePixelRatio を掛けて補正しています。測定は累積面積11.4%、平均強度97.6、動いたフレーム14/23でした。

<main class="stage">
<div class="tt is-demo" id="tt">
<canvas class="tt__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="三つが揃うと線がマスの中心を結ぶように引かれ、光がにじんで静まる勝利演出"></canvas>
<p class="tt__status" data-role="status" aria-live="polite">Xの番</p>
<button class="tt__btn" data-role="btn" type="button">一時停止</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.tt {
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;
}
.tt__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.tt__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.tt__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.tt__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.tt__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.tt__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.tt__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.tt { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tt {
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;
}
.tt__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.tt__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.tt__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.tt__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.tt__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.tt__btn {
font-size: 10px;
padding: 4px 8px;
}
.tt__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.tt {
cursor: default;
}
}(function () {
const root = document.getElementById("tt");
if (!root) return;
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const IDLE_TICKS = 150;
const BTN_PAUSE = '一時停止';
const BTN_PLAY = '再生';
const BTN_NEXT = '次の場面';
const TXT_CELL = 'マス';
// 파스텔 팔레트 — 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG = [29, 26, 44];
const TILE = [40, 36, 62];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
const CREAM = [255, 244, 224];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 두 개로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash2(x, y, seed) {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 격자점 해시 네 개를 부드럽게 섞는다
function noise2(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = hash2(xi, yi, seed);
const b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed);
const d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
function rrect(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 말 하나를 (cx, cy) 에 그린다. p(0~1) 만큼 대시 오프셋으로 획을 긋는다 — who 1 은 X(두 획), 2 는 O(원)
function drawMark(ctx, who, cx, cy, r, p, w, col) {
ctx.save();
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.strokeStyle = col;
if (who === 1) {
const d = r * 2 * Math.SQRT2;
for (let k = 0; k < 2; k += 1) {
const pk = clamp(p * 2 - k, 0, 1);
if (pk <= 0.001) {
continue;
}
ctx.setLineDash([d, d]);
ctx.lineDashOffset = d * (1 - pk);
ctx.beginPath();
if (k === 0) {
ctx.moveTo(cx - r, cy - r);
ctx.lineTo(cx + r, cy + r);
}
else {
ctx.moveTo(cx + r, cy - r);
ctx.lineTo(cx - r, cy + r);
}
ctx.stroke();
}
}
else {
const L = PI2 * r;
if (p > 0.001) {
ctx.setLineDash([L, L]);
ctx.lineDashOffset = L * (1 - clamp(p, 0, 1));
ctx.beginPath();
ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + PI2);
ctx.stroke();
}
}
ctx.restore();
}
function mount(root, make) {
const canvas = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const btnEl = root.querySelector('[data-role="btn"]');
if (!canvas || !statusEl || !btnEl) {
return function () { return; };
}
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: -1, y: -1, cur: -1, key: false, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let focused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw(ctx);
if (inp.key && focused && inp.cur >= 0) {
const r = scene.rect(inp.cur);
ctx.save();
ctx.lineWidth = 2.5;
ctx.strokeStyle = 'rgba(255,255,255,0.92)';
rrect(ctx, r[0] + 2, r[1] + 2, r[2] - 4, r[3] - 4, 8);
ctx.stroke();
ctx.restore();
}
let t = scene.label();
if (inp.key && inp.cur >= 0) {
const n = scene.n();
t += ' · ' + TXT_CELL + ' ' + (Math.floor(inp.cur / n) + 1) + '-' + ((inp.cur % n) + 1);
}
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function localPoint(e) {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
inp.key = false;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
scene.auto();
scene.auto();
scene.auto();
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
scene.auto();
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'focus', function () { focused = true; });
listen(canvas, 'blur', function () { focused = false; if (reduce) {
show();
} });
listen(canvas, 'pointermove', function (e) {
localPoint(e);
scene.hover(scene.hit(inp.x, inp.y));
if (reduce) {
show();
}
});
listen(canvas, 'pointerleave', function () { scene.hover(-1); if (reduce) {
show();
} });
listen(canvas, 'pointerdown', function (e) {
localPoint(e);
const i = scene.hit(inp.x, inp.y);
if (i >= 0) {
scene.tap(i);
}
if (reduce) {
show();
}
});
listen(canvas, 'keydown', function (e) {
const ev = e;
const k = ev.key;
const n = scene.n();
const mid = Math.floor((n * n) / 2);
if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowUp' || k === 'ArrowDown') {
ev.preventDefault();
if (inp.cur < 0) {
inp.cur = mid;
}
else {
let r = Math.floor(inp.cur / n);
let c = inp.cur % n;
if (k === 'ArrowLeft') {
c = Math.max(0, c - 1);
}
if (k === 'ArrowRight') {
c = Math.min(n - 1, c + 1);
}
if (k === 'ArrowUp') {
r = Math.max(0, r - 1);
}
if (k === 'ArrowDown') {
r = Math.min(n - 1, r + 1);
}
inp.cur = r * n + c;
}
inp.key = true;
inp.idle = 0;
scene.hover(inp.cur);
if (reduce) {
show();
}
return;
}
if (k === 'Enter' || k === ' ') {
ev.preventDefault();
if (inp.cur < 0) {
inp.cur = mid;
}
inp.key = true;
inp.idle = 0;
scene.tap(inp.cur);
if (reduce) {
show();
}
}
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const LINES = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]];
const SCORE_WIN = 10;
const TXT_X_TURN = 'Xの番';
const TXT_O_TURN = 'Oの番';
const TXT_X_WIN = 'Xの勝ち';
const TXT_O_WIN = 'Oの勝ち';
const TXT_DRAW = '引き分け';
function newGame() { return { b: [0, 0, 0, 0, 0, 0, 0, 0, 0], turn: 1, over: false, line: -1 }; }
// 완성된 줄의 번호(LINES 안의 위치). 없으면 -1
function lineOf(b) {
for (let k = 0; k < LINES.length; k += 1) {
const l = LINES[k];
if (b[l[0]] !== 0 && b[l[0]] === b[l[1]] && b[l[1]] === b[l[2]]) {
return k;
}
}
return -1;
}
function emptyCells(b) {
const out = [];
for (let i = 0; i < 9; i += 1) {
if (b[i] === 0) {
out.push(i);
}
}
return out;
}
function place(g, i) {
if (g.over || g.b[i] !== 0) {
return false;
}
g.b[i] = g.turn;
g.line = lineOf(g.b);
if (g.line >= 0 || emptyCells(g.b).length === 0) {
g.over = true;
}
else {
g.turn = 3 - g.turn;
}
return true;
}
function gameLabel(g) {
if (g.over) {
return g.line >= 0 ? (g.b[LINES[g.line][0]] === 1 ? TXT_X_WIN : TXT_O_WIN) : TXT_DRAW;
}
return g.turn === 1 ? TXT_X_TURN : TXT_O_TURN;
}
// 전수 탐색 — X 시점 점수. depth 가 0 이 되면 더 보지 않고 0 으로 친다. cnt.n 은 방문한 국면 수
function solve(b, turn, ply, depth, cnt) {
cnt.n += 1;
const w = lineOf(b);
if (w >= 0) {
const s = SCORE_WIN - ply;
if (b[LINES[w][0]] === 1) {
cnt.x += 1;
return s;
}
cnt.o += 1;
return -s;
}
if (depth <= 0) {
return 0;
}
let best = turn === 1 ? -1000 : 1000;
let any = false;
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) {
continue;
}
b[i] = turn;
const v = solve(b, 3 - turn, ply + 1, depth - 1, cnt);
b[i] = 0;
any = true;
if (turn === 1 ? v > best : v < best) {
best = v;
}
}
return any ? best : 0;
}
// 둘 곳마다 점수. 이미 찬 칸은 NaN. 둔 사람(turn) 시점이 아니라 X 시점 값이다
function cellScores(b, turn, depth, cnt, per) {
const out = [];
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) {
out.push(NaN);
if (per) {
per.push({ n: 0, x: 0, o: 0 });
}
continue;
}
b[i] = turn;
const c = { n: 0, x: 0, o: 0 };
out.push(solve(b, 3 - turn, 1, depth - 1, c));
b[i] = 0;
cnt.n += c.n;
cnt.x += c.x;
cnt.o += c.o;
if (per) {
per.push(c);
}
}
return out;
}
function bestCell(b, turn, depth) {
const cnt = { n: 0, x: 0, o: 0 };
const sc = cellScores(b, turn, depth, cnt);
let best = -1;
for (let i = 0; i < 9; i += 1) {
if (isNaN(sc[i])) {
continue;
}
if (best < 0 || (turn === 1 ? sc[i] > sc[best] : sc[i] < sc[best])) {
best = i;
}
}
return best;
}
function gridRect(i, ox, oy, cs) {
return [ox + (i % 3) * cs, oy + Math.floor(i / 3) * cs, cs, cs];
}
function gridHit(x, y, ox, oy, cs) {
const c = Math.floor((x - ox) / cs);
const r = Math.floor((y - oy) / cs);
return c >= 0 && c < 3 && r >= 0 && r < 3 ? r * 3 + c : -1;
}
const MATCH_DEPTH = 9;
const REPLY_MS = 420;
// 한 판 진행기 — 사람은 X, 컴퓨터는 O. 사람이 가만히 있으면(idle) 둘 다 컴퓨터가 두어 루프를 만든다.
// pick 이 있으면 자동 대국의 수를 그 함수가 정한다. 판이 끝나면 holdMs 뒤에 새 판
function makeMatch(inp, reduce, turnMs, holdMs, seed, depth, onPlace, onReset, pick) {
const rng = lcg(seed);
let t = 0;
let reply = -1;
let hold = 0;
const m = {
g: newGame(),
since: 0,
move(i) {
const who = m.g.turn;
if (place(m.g, i)) {
m.since = 0;
onPlace(i, who);
}
},
tap(i) {
if (m.g.over) {
reset();
return;
}
if (m.g.turn !== 1 || m.g.b[i] !== 0) {
return;
}
m.move(i);
if (!m.g.over) {
if (reduce) {
m.move(bestCell(m.g.b, 2, depth));
}
else {
reply = REPLY_MS;
}
}
},
step() {
m.since += FIXED_DT;
if (m.g.over) {
hold += FIXED_DT;
if (hold >= holdMs) {
reset();
}
return;
}
if (reply >= 0) {
reply -= FIXED_DT;
if (reply < 0) {
m.move(bestCell(m.g.b, 2, depth));
}
return;
}
if (inp.idle > IDLE_TICKS) {
t += FIXED_DT;
if (t >= turnMs) {
t = 0;
autoMove();
}
}
},
auto() {
if (m.g.over) {
reset();
}
else {
autoMove();
}
},
};
function reset() {
m.g = newGame();
t = 0;
reply = -1;
hold = 0;
onReset();
}
function autoMove() {
if (pick) {
const p = pick();
if (p >= 0) {
m.move(p);
}
return;
}
const e = emptyCells(m.g.b);
if (m.g.turn === 1 && (e.length > 6 || rng() < 0.3)) {
m.move(e[Math.floor(rng() * e.length)]);
return;
}
m.move(bestCell(m.g.b, m.g.turn, depth));
}
return m;
}
const LINE_MS = 420;
const GLOW_BLUR = 6;
const WIN_LINES = LINES.length;
const FADE_ALPHA = 0.35;
const GLOW_FADE_MS = 900;
const MARK_MS = 280;
const TURN_MS = 380;
const HOLD_MS = 2200;
const CELL = 88;
const OX = 108;
const OY = 18;
const ORDER = [0, 4, 3, 7, 1, 6, 5, 2];
const make = function (inp, reduce) {
const dprG = Math.min(window.devicePixelRatio || 1, 2);
const age = [-1, -1, -1, -1, -1, -1, -1, -1, -1];
const who = [0, 0, 0, 0, 0, 0, 0, 0, 0];
let winAge = -1;
let round = 0;
let hov = -1;
// 이번 판에 X 가 완성할 줄(8줄을 차례로) 과, O 가 두는 두 マス — O 가 먼저 이길 수 없는 칸만 쓴다
function plan() {
const xs = LINES[ORDER[round % WIN_LINES]];
const os = [];
for (let i = 0; i < 9 && os.length < 2; i += 1) {
if (xs.indexOf(i) < 0) {
os.push(i);
}
}
return [xs, os];
}
function pick() {
const f = 9 - emptyCells(m.g.b).length;
const p = plan();
const c = f % 2 === 0 ? p[0][f / 2] : p[1][(f - 1) / 2];
return m.g.b[c] === 0 ? c : emptyCells(m.g.b)[0];
}
const m = makeMatch(inp, reduce, TURN_MS, HOLD_MS, 51, MATCH_DEPTH, function (i, w) {
who[i] = w;
age[i] = reduce ? 9999 : 0;
if (m.g.line >= 0) {
winAge = reduce ? 9999 : 0;
}
}, function () {
for (let i = 0; i < 9; i += 1) {
age[i] = -1;
who[i] = 0;
}
winAge = -1;
round += 1;
}, pick);
if (!reduce) {
// 첫 판은 두 수를 이미 둔 상태로 시작 — 시작하자마자 줄이 완성되는 장면이 보이게
m.move(pick());
m.move(pick());
for (let i = 0; i < 9; i += 1) {
if (age[i] >= 0) {
age[i] = 9999;
}
}
}
return {
step() {
m.step();
for (let i = 0; i < 9; i += 1) {
if (age[i] >= 0) {
age[i] += FIXED_DT;
}
}
if (winAge >= 0) {
winAge += FIXED_DT;
}
},
draw(ctx) {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
const wl = m.g.line >= 0 ? LINES[m.g.line] : [];
const dim = wl.length > 0 ? lerp(1, FADE_ALPHA, smooth(winAge / 300)) : 1;
for (let i = 0; i < 9; i += 1) {
const r = gridRect(i, OX, OY, CELL);
const win = wl.indexOf(i) >= 0;
let col = TILE;
if (hov === i && who[i] === 0 && !m.g.over) {
col = mix(TILE, CREAM, 0.12);
}
if (win) {
col = mix(TILE, MINT, 0.22 * smooth(winAge / 300));
}
ctx.fillStyle = rgba(col, 1);
rrect(ctx, r[0] + 4, r[1] + 4, CELL - 8, CELL - 8, 12);
ctx.fill();
if (age[i] >= 0) {
drawMark(ctx, who[i], r[0] + CELL / 2, r[1] + CELL / 2, 21, clamp(age[i] / MARK_MS, 0, 1), 7, rgba(who[i] === 1 ? SKY : PINK, win ? 1 : dim));
}
}
if (m.g.line >= 0 && winAge >= 0) {
const a = gridRect(wl[0], OX, OY, CELL);
const b = gridRect(wl[2], OX, OY, CELL);
let x0 = a[0] + CELL / 2;
let y0 = a[1] + CELL / 2;
let x1 = b[0] + CELL / 2;
let y1 = b[1] + CELL / 2;
const len = Math.hypot(x1 - x0, y1 - y0);
const ux = (x1 - x0) / len;
const uy = (y1 - y0) / len;
x0 -= ux * CELL * 0.3;
y0 -= uy * CELL * 0.3;
x1 += ux * CELL * 0.3;
y1 += uy * CELL * 0.3;
const prog = smooth(winAge / LINE_MS);
// 번짐 반경 — 선이 자라는 동안 커지고, 다 그어진 뒤 사인 곡선을 따라 줄어든다
const fade = clamp((winAge - LINE_MS) / GLOW_FADE_MS, 0, 1);
const glow = GLOW_BLUR * (winAge < LINE_MS ? prog : Math.cos(fade * Math.PI / 2)) * dprG;
ctx.save();
ctx.shadowColor = rgba(MINT, 1);
ctx.shadowBlur = glow;
ctx.strokeStyle = rgba(mix(MINT, CREAM, 0.35), 1);
ctx.lineWidth = 9;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(lerp(x0, x1, prog), lerp(y0, y1, prog));
ctx.stroke();
ctx.restore();
}
},
label() { return gameLabel(m.g); },
hit(x, y) { return gridHit(x, y, OX, OY, CELL); },
hover(i) { hov = i; },
tap(i) { m.tap(i); },
auto() { m.auto(); },
rect(i) { return gridRect(i, OX, OY, CELL); },
n() { return 3; },
};
};
return mount(root, make);
})();// TttWinLineSweepGlow - vanilla 판(05-ttt-win-line-sweep-glow) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./TttWinLineSweepGlow.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function TttWinLineSweepGlow() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
const TXT_CELL = '칸';
type RGB = [number, number, number];
interface Input { x: number; y: number; cur: number; key: boolean; idle: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
hit(x: number, y: number): number;
hover(i: number): void;
tap(i: number): void;
auto(): void;
rect(i: number): [number, number, number, number];
n(): number;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 팔레트 — 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG: RGB = [29, 26, 44];
const TILE: RGB = [40, 36, 62];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
const CREAM: RGB = [255, 244, 224];
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 smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 두 개로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash2(x: number, y: number, seed: number): number {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 격자점 해시 네 개를 부드럽게 섞는다
function noise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = hash2(xi, yi, seed);
const b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed);
const d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 말 하나를 (cx, cy) 에 그린다. p(0~1) 만큼 대시 오프셋으로 획을 긋는다 — who 1 은 X(두 획), 2 는 O(원)
function drawMark(ctx: CanvasRenderingContext2D, who: number, cx: number, cy: number, r: number, p: number, w: number, col: string): void {
ctx.save();
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.strokeStyle = col;
if (who === 1) {
const d = r * 2 * Math.SQRT2;
for (let k = 0; k < 2; k += 1) {
const pk = clamp(p * 2 - k, 0, 1);
if (pk <= 0.001) { continue; }
ctx.setLineDash([d, d]);
ctx.lineDashOffset = d * (1 - pk);
ctx.beginPath();
if (k === 0) { ctx.moveTo(cx - r, cy - r); ctx.lineTo(cx + r, cy + r); }
else { ctx.moveTo(cx + r, cy - r); ctx.lineTo(cx - r, cy + r); }
ctx.stroke();
}
} else {
const L = PI2 * r;
if (p > 0.001) {
ctx.setLineDash([L, L]);
ctx.lineDashOffset = L * (1 - clamp(p, 0, 1));
ctx.beginPath();
ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + PI2);
ctx.stroke();
}
}
ctx.restore();
}
function mount(root: HTMLElement, make: Maker): () => void {
const canvas = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const btnEl = root.querySelector('[data-role="btn"]') as HTMLButtonElement | null;
if (!canvas || !statusEl || !btnEl) { return function (): void { return; }; }
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
const ctx: CanvasRenderingContext2D = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: -1, y: -1, cur: -1, key: false, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let focused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw(ctx);
if (inp.key && focused && inp.cur >= 0) {
const r = scene.rect(inp.cur);
ctx.save();
ctx.lineWidth = 2.5;
ctx.strokeStyle = 'rgba(255,255,255,0.92)';
rrect(ctx, r[0] + 2, r[1] + 2, r[2] - 4, r[3] - 4, 8);
ctx.stroke();
ctx.restore();
}
let t = scene.label();
if (inp.key && inp.cur >= 0) {
const n = scene.n();
t += ' · ' + TXT_CELL + ' ' + (Math.floor(inp.cur / n) + 1) + '-' + ((inp.cur % n) + 1);
}
if (t !== lastLabel) {
lastLabel = t;
statusEl!.textContent = t;
}
}
function localPoint(e: PointerEvent): void {
const r = canvas!.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
inp.key = false;
}
function tick(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
scene.auto();
scene.auto();
scene.auto();
show();
}
listen(btnEl, 'click', function (): void {
if (reduce) { scene.auto(); show(); return; }
paused = !paused;
if (paused) { root.classList.remove('is-demo'); } else { root.classList.add('is-demo'); }
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'focus', function (): void { focused = true; });
listen(canvas, 'blur', function (): void { focused = false; if (reduce) { show(); } });
listen(canvas, 'pointermove', function (e: Event): void {
localPoint(e as PointerEvent);
scene.hover(scene.hit(inp.x, inp.y));
if (reduce) { show(); }
});
listen(canvas, 'pointerleave', function (): void { scene.hover(-1); if (reduce) { show(); } });
listen(canvas, 'pointerdown', function (e: Event): void {
localPoint(e as PointerEvent);
const i = scene.hit(inp.x, inp.y);
if (i >= 0) { scene.tap(i); }
if (reduce) { show(); }
});
listen(canvas, 'keydown', function (e: Event): void {
const ev = e as KeyboardEvent;
const k = ev.key;
const n = scene.n();
const mid = Math.floor((n * n) / 2);
if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowUp' || k === 'ArrowDown') {
ev.preventDefault();
if (inp.cur < 0) { inp.cur = mid; }
else {
let r = Math.floor(inp.cur / n);
let c = inp.cur % n;
if (k === 'ArrowLeft') { c = Math.max(0, c - 1); }
if (k === 'ArrowRight') { c = Math.min(n - 1, c + 1); }
if (k === 'ArrowUp') { r = Math.max(0, r - 1); }
if (k === 'ArrowDown') { r = Math.min(n - 1, r + 1); }
inp.cur = r * n + c;
}
inp.key = true;
inp.idle = 0;
scene.hover(inp.cur);
if (reduce) { show(); }
return;
}
if (k === 'Enter' || k === ' ') {
ev.preventDefault();
if (inp.cur < 0) { inp.cur = mid; }
inp.key = true;
inp.idle = 0;
scene.tap(inp.cur);
if (reduce) { show(); }
}
});
if (!reduce) { rafId = requestAnimationFrame(tick); }
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
};
}
const LINES: number[][] = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]];
const SCORE_WIN = 10;
const TXT_X_TURN = 'X 차례';
const TXT_O_TURN = 'O 차례';
const TXT_X_WIN = 'X 승';
const TXT_O_WIN = 'O 승';
const TXT_DRAW = '무승부';
interface Game { b: number[]; turn: number; over: boolean; line: number; }
interface Counter { n: number; x: number; o: number; }
function newGame(): Game { return { b: [0, 0, 0, 0, 0, 0, 0, 0, 0], turn: 1, over: false, line: -1 }; }
// 완성된 줄의 번호(LINES 안의 위치). 없으면 -1
function lineOf(b: number[]): number {
for (let k = 0; k < LINES.length; k += 1) {
const l = LINES[k];
if (b[l[0]] !== 0 && b[l[0]] === b[l[1]] && b[l[1]] === b[l[2]]) { return k; }
}
return -1;
}
function emptyCells(b: number[]): number[] {
const out: number[] = [];
for (let i = 0; i < 9; i += 1) { if (b[i] === 0) { out.push(i); } }
return out;
}
function place(g: Game, i: number): boolean {
if (g.over || g.b[i] !== 0) { return false; }
g.b[i] = g.turn;
g.line = lineOf(g.b);
if (g.line >= 0 || emptyCells(g.b).length === 0) { g.over = true; } else { g.turn = 3 - g.turn; }
return true;
}
function gameLabel(g: Game): string {
if (g.over) { return g.line >= 0 ? (g.b[LINES[g.line][0]] === 1 ? TXT_X_WIN : TXT_O_WIN) : TXT_DRAW; }
return g.turn === 1 ? TXT_X_TURN : TXT_O_TURN;
}
// 전수 탐색 — X 시점 점수. depth 가 0 이 되면 더 보지 않고 0 으로 친다. cnt.n 은 방문한 국면 수
function solve(b: number[], turn: number, ply: number, depth: number, cnt: Counter): number {
cnt.n += 1;
const w = lineOf(b);
if (w >= 0) {
const s = SCORE_WIN - ply;
if (b[LINES[w][0]] === 1) { cnt.x += 1; return s; }
cnt.o += 1;
return -s;
}
if (depth <= 0) { return 0; }
let best = turn === 1 ? -1000 : 1000;
let any = false;
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) { continue; }
b[i] = turn;
const v = solve(b, 3 - turn, ply + 1, depth - 1, cnt);
b[i] = 0;
any = true;
if (turn === 1 ? v > best : v < best) { best = v; }
}
return any ? best : 0;
}
// 둘 곳마다 점수. 이미 찬 칸은 NaN. 둔 사람(turn) 시점이 아니라 X 시점 값이다
function cellScores(b: number[], turn: number, depth: number, cnt: Counter, per?: Counter[]): number[] {
const out: number[] = [];
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) { out.push(NaN); if (per) { per.push({ n: 0, x: 0, o: 0 }); } continue; }
b[i] = turn;
const c: Counter = { n: 0, x: 0, o: 0 };
out.push(solve(b, 3 - turn, 1, depth - 1, c));
b[i] = 0;
cnt.n += c.n; cnt.x += c.x; cnt.o += c.o;
if (per) { per.push(c); }
}
return out;
}
function bestCell(b: number[], turn: number, depth: number): number {
const cnt: Counter = { n: 0, x: 0, o: 0 };
const sc = cellScores(b, turn, depth, cnt);
let best = -1;
for (let i = 0; i < 9; i += 1) {
if (isNaN(sc[i])) { continue; }
if (best < 0 || (turn === 1 ? sc[i] > sc[best] : sc[i] < sc[best])) { best = i; }
}
return best;
}
function gridRect(i: number, ox: number, oy: number, cs: number): [number, number, number, number] {
return [ox + (i % 3) * cs, oy + Math.floor(i / 3) * cs, cs, cs];
}
function gridHit(x: number, y: number, ox: number, oy: number, cs: number): number {
const c = Math.floor((x - ox) / cs);
const r = Math.floor((y - oy) / cs);
return c >= 0 && c < 3 && r >= 0 && r < 3 ? r * 3 + c : -1;
}
interface Match {
g: Game;
since: number;
move(i: number): void;
tap(i: number): void;
step(): void;
auto(): void;
}
const MATCH_DEPTH = 9;
const REPLY_MS = 420;
// 한 판 진행기 — 사람은 X, 컴퓨터는 O. 사람이 가만히 있으면(idle) 둘 다 컴퓨터가 두어 루프를 만든다.
// pick 이 있으면 자동 대국의 수를 그 함수가 정한다. 판이 끝나면 holdMs 뒤에 새 판
function makeMatch(inp: Input, reduce: boolean, turnMs: number, holdMs: number, seed: number, depth: number, onPlace: (i: number, who: number) => void, onReset: () => void, pick: (() => number) | null): Match {
const rng = lcg(seed);
let t = 0;
let reply = -1;
let hold = 0;
const m: Match = {
g: newGame(),
since: 0,
move(i: number): void {
const who = m.g.turn;
if (place(m.g, i)) { m.since = 0; onPlace(i, who); }
},
tap(i: number): void {
if (m.g.over) { reset(); return; }
if (m.g.turn !== 1 || m.g.b[i] !== 0) { return; }
m.move(i);
if (!m.g.over) {
if (reduce) { m.move(bestCell(m.g.b, 2, depth)); } else { reply = REPLY_MS; }
}
},
step(): void {
m.since += FIXED_DT;
if (m.g.over) {
hold += FIXED_DT;
if (hold >= holdMs) { reset(); }
return;
}
if (reply >= 0) {
reply -= FIXED_DT;
if (reply < 0) { m.move(bestCell(m.g.b, 2, depth)); }
return;
}
if (inp.idle > IDLE_TICKS) {
t += FIXED_DT;
if (t >= turnMs) { t = 0; autoMove(); }
}
},
auto(): void {
if (m.g.over) { reset(); } else { autoMove(); }
},
};
function reset(): void {
m.g = newGame();
t = 0;
reply = -1;
hold = 0;
onReset();
}
function autoMove(): void {
if (pick) { const p = pick(); if (p >= 0) { m.move(p); } return; }
const e = emptyCells(m.g.b);
if (m.g.turn === 1 && (e.length > 6 || rng() < 0.3)) { m.move(e[Math.floor(rng() * e.length)]); return; }
m.move(bestCell(m.g.b, m.g.turn, depth));
}
return m;
}
const LINE_MS = 420;
const GLOW_BLUR = 6;
const WIN_LINES = LINES.length;
const FADE_ALPHA = 0.35;
const GLOW_FADE_MS = 900;
const MARK_MS = 280;
const TURN_MS = 380;
const HOLD_MS = 2200;
const CELL = 88;
const OX = 108;
const OY = 18;
const ORDER = [0, 4, 3, 7, 1, 6, 5, 2];
const make: Maker = function (inp: Input, reduce: boolean): Scene {
const dprG = Math.min(window.devicePixelRatio || 1, 2);
const age: number[] = [-1, -1, -1, -1, -1, -1, -1, -1, -1];
const who: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
let winAge = -1;
let round = 0;
let hov = -1;
// 이번 판에 X 가 완성할 줄(8줄을 차례로) 과, O 가 두는 두 칸 — O 가 먼저 이길 수 없는 칸만 쓴다
function plan(): number[][] {
const xs = LINES[ORDER[round % WIN_LINES]];
const os: number[] = [];
for (let i = 0; i < 9 && os.length < 2; i += 1) { if (xs.indexOf(i) < 0) { os.push(i); } }
return [xs, os];
}
function pick(): number {
const f = 9 - emptyCells(m.g.b).length;
const p = plan();
const c = f % 2 === 0 ? p[0][f / 2] : p[1][(f - 1) / 2];
return m.g.b[c] === 0 ? c : emptyCells(m.g.b)[0];
}
const m: Match = makeMatch(inp, reduce, TURN_MS, HOLD_MS, 51, MATCH_DEPTH, function (i: number, w: number): void {
who[i] = w;
age[i] = reduce ? 9999 : 0;
if (m.g.line >= 0) { winAge = reduce ? 9999 : 0; }
}, function (): void {
for (let i = 0; i < 9; i += 1) { age[i] = -1; who[i] = 0; }
winAge = -1;
round += 1;
}, pick);
if (!reduce) {
// 첫 판은 두 수를 이미 둔 상태로 시작 — 시작하자마자 줄이 완성되는 장면이 보이게
m.move(pick());
m.move(pick());
for (let i = 0; i < 9; i += 1) { if (age[i] >= 0) { age[i] = 9999; } }
}
return {
step(): void {
m.step();
for (let i = 0; i < 9; i += 1) { if (age[i] >= 0) { age[i] += FIXED_DT; } }
if (winAge >= 0) { winAge += FIXED_DT; }
},
draw(ctx: CanvasRenderingContext2D): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
const wl = m.g.line >= 0 ? LINES[m.g.line] : [];
const dim = wl.length > 0 ? lerp(1, FADE_ALPHA, smooth(winAge / 300)) : 1;
for (let i = 0; i < 9; i += 1) {
const r = gridRect(i, OX, OY, CELL);
const win = wl.indexOf(i) >= 0;
let col: RGB = TILE;
if (hov === i && who[i] === 0 && !m.g.over) { col = mix(TILE, CREAM, 0.12); }
if (win) { col = mix(TILE, MINT, 0.22 * smooth(winAge / 300)); }
ctx.fillStyle = rgba(col, 1);
rrect(ctx, r[0] + 4, r[1] + 4, CELL - 8, CELL - 8, 12);
ctx.fill();
if (age[i] >= 0) {
drawMark(ctx, who[i], r[0] + CELL / 2, r[1] + CELL / 2, 21, clamp(age[i] / MARK_MS, 0, 1), 7, rgba(who[i] === 1 ? SKY : PINK, win ? 1 : dim));
}
}
if (m.g.line >= 0 && winAge >= 0) {
const a = gridRect(wl[0], OX, OY, CELL);
const b = gridRect(wl[2], OX, OY, CELL);
let x0 = a[0] + CELL / 2;
let y0 = a[1] + CELL / 2;
let x1 = b[0] + CELL / 2;
let y1 = b[1] + CELL / 2;
const len = Math.hypot(x1 - x0, y1 - y0);
const ux = (x1 - x0) / len;
const uy = (y1 - y0) / len;
x0 -= ux * CELL * 0.3;
y0 -= uy * CELL * 0.3;
x1 += ux * CELL * 0.3;
y1 += uy * CELL * 0.3;
const prog = smooth(winAge / LINE_MS);
// 번짐 반경 — 선이 자라는 동안 커지고, 다 그어진 뒤 사인 곡선을 따라 줄어든다
const fade = clamp((winAge - LINE_MS) / GLOW_FADE_MS, 0, 1);
const glow = GLOW_BLUR * (winAge < LINE_MS ? prog : Math.cos(fade * Math.PI / 2)) * dprG;
ctx.save();
ctx.shadowColor = rgba(MINT, 1);
ctx.shadowBlur = glow;
ctx.strokeStyle = rgba(mix(MINT, CREAM, 0.35), 1);
ctx.lineWidth = 9;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(lerp(x0, x1, prog), lerp(y0, y1, prog));
ctx.stroke();
ctx.restore();
}
},
label(): string { return gameLabel(m.g); },
hit(x: number, y: number): number { return gridHit(x, y, OX, OY, CELL); },
hover(i: number): void { hov = i; },
tap(i: number): void { m.tap(i); },
auto(): void { m.auto(); },
rect(i: number): [number, number, number, number] { return gridRect(i, OX, OY, CELL); },
n(): number { return 3; },
};
};
return mount(root, make);
}, []);
return (
<div className={cx("tt") + " is-demo"} id="tt" ref={rootRef}>
<canvas className={cx("tt__canvas")} data-role="canvas" width="480" height="300" tabIndex={0} aria-label="세 칸이 이어지면 줄이 칸 중심을 잇듯 긋고 빛이 번졌다 가라앉는 승리 연출"></canvas>
<p className={cx("tt__status")} data-role="status" aria-live="polite">X 차례</p>
<button className={cx("tt__btn")} data-role="btn" type="button">멈춤</button>
</div>
);
}05 · 三つが揃うと線がマスの中心を結ぶように引かれ、光がにじんで静まる勝利演出 — 計測 累積面積 11.423% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
x0 -= ux * CELL * 0.3;
y0 -= uy * CELL * 0.3;
x1 += ux * CELL * 0.3;
y1 += uy * CELL * 0.3;
const prog = smooth(winAge / LINE_MS);
// にじみの半径 — 線が伸びる間は大きくなり、引き終わったあとはサイン曲線に沿って小さくなる
const fade = clamp((winAge - LINE_MS) / GLOW_FADE_MS, 0, 1);
const glow = GLOW_BLUR * (winAge < LINE_MS ? prog : Math.cos(fade * Math.PI / 2)) * dprG;
ctx.save();
ctx.shadowColor = rgba(MINT, 1);
ctx.shadowBlur = glow;
ctx.strokeStyle = rgba(mix(MINT, CREAM, 0.35), 1);
ctx.lineWidth = 9;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(lerp(x0, x1, prog), lerp(y0, y1, prog));
ctx.stroke();
ctx.restore();
06盤の中に盤があり、置いたマスが次の盤を指定して、画面がその盤へバネで拡大される入れ子の盤
置いたマスの番号が次に打つ小さい盤の番号になり、その盤がすでに決着しているか埋まっていれば、どの小さい盤でも選べます。goal() が決めた小盤の中心と ZOOM_K の2.6倍を、カメラの位置と倍率がそれぞれバネで追い、倍率は頂点2.725まで行き過ぎてから576msで移動距離の2%以内に落ち着きます。この入れ子の盤は累積面積59.0%、平均強度32.3、動いたフレーム19/23で、九つの中で面積が最も広い盤です。

<main class="stage">
<div class="tt is-demo" id="tt">
<canvas class="tt__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="盤の中に盤があり、置いたマスが次の盤を指定して、画面がその盤へバネで拡大される入れ子の盤"></canvas>
<p class="tt__status" data-role="status" aria-live="polite">Xの番</p>
<button class="tt__btn" data-role="btn" type="button">一時停止</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.tt {
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;
}
.tt__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.tt__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.tt__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.tt__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.tt__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.tt__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.tt__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.tt { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tt {
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;
}
.tt__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.tt__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.tt__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.tt__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.tt__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.tt__btn {
font-size: 10px;
padding: 4px 8px;
}
.tt__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.tt {
cursor: default;
}
}(function () {
const root = document.getElementById("tt");
if (!root) return;
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const IDLE_TICKS = 150;
const BTN_PAUSE = '一時停止';
const BTN_PLAY = '再生';
const BTN_NEXT = '次の場面';
const TXT_CELL = 'マス';
// 파스텔 팔레트 — 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG = [29, 26, 44];
const TILE = [40, 36, 62];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
const CREAM = [255, 244, 224];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 두 개로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash2(x, y, seed) {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 격자점 해시 네 개를 부드럽게 섞는다
function noise2(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = hash2(xi, yi, seed);
const b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed);
const d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
function rrect(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 말 하나를 (cx, cy) 에 그린다. p(0~1) 만큼 대시 오프셋으로 획을 긋는다 — who 1 은 X(두 획), 2 는 O(원)
function drawMark(ctx, who, cx, cy, r, p, w, col) {
ctx.save();
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.strokeStyle = col;
if (who === 1) {
const d = r * 2 * Math.SQRT2;
for (let k = 0; k < 2; k += 1) {
const pk = clamp(p * 2 - k, 0, 1);
if (pk <= 0.001) {
continue;
}
ctx.setLineDash([d, d]);
ctx.lineDashOffset = d * (1 - pk);
ctx.beginPath();
if (k === 0) {
ctx.moveTo(cx - r, cy - r);
ctx.lineTo(cx + r, cy + r);
}
else {
ctx.moveTo(cx + r, cy - r);
ctx.lineTo(cx - r, cy + r);
}
ctx.stroke();
}
}
else {
const L = PI2 * r;
if (p > 0.001) {
ctx.setLineDash([L, L]);
ctx.lineDashOffset = L * (1 - clamp(p, 0, 1));
ctx.beginPath();
ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + PI2);
ctx.stroke();
}
}
ctx.restore();
}
function mount(root, make) {
const canvas = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const btnEl = root.querySelector('[data-role="btn"]');
if (!canvas || !statusEl || !btnEl) {
return function () { return; };
}
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: -1, y: -1, cur: -1, key: false, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let focused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw(ctx);
if (inp.key && focused && inp.cur >= 0) {
const r = scene.rect(inp.cur);
ctx.save();
ctx.lineWidth = 2.5;
ctx.strokeStyle = 'rgba(255,255,255,0.92)';
rrect(ctx, r[0] + 2, r[1] + 2, r[2] - 4, r[3] - 4, 8);
ctx.stroke();
ctx.restore();
}
let t = scene.label();
if (inp.key && inp.cur >= 0) {
const n = scene.n();
t += ' · ' + TXT_CELL + ' ' + (Math.floor(inp.cur / n) + 1) + '-' + ((inp.cur % n) + 1);
}
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function localPoint(e) {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
inp.key = false;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
scene.auto();
scene.auto();
scene.auto();
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
scene.auto();
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'focus', function () { focused = true; });
listen(canvas, 'blur', function () { focused = false; if (reduce) {
show();
} });
listen(canvas, 'pointermove', function (e) {
localPoint(e);
scene.hover(scene.hit(inp.x, inp.y));
if (reduce) {
show();
}
});
listen(canvas, 'pointerleave', function () { scene.hover(-1); if (reduce) {
show();
} });
listen(canvas, 'pointerdown', function (e) {
localPoint(e);
const i = scene.hit(inp.x, inp.y);
if (i >= 0) {
scene.tap(i);
}
if (reduce) {
show();
}
});
listen(canvas, 'keydown', function (e) {
const ev = e;
const k = ev.key;
const n = scene.n();
const mid = Math.floor((n * n) / 2);
if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowUp' || k === 'ArrowDown') {
ev.preventDefault();
if (inp.cur < 0) {
inp.cur = mid;
}
else {
let r = Math.floor(inp.cur / n);
let c = inp.cur % n;
if (k === 'ArrowLeft') {
c = Math.max(0, c - 1);
}
if (k === 'ArrowRight') {
c = Math.min(n - 1, c + 1);
}
if (k === 'ArrowUp') {
r = Math.max(0, r - 1);
}
if (k === 'ArrowDown') {
r = Math.min(n - 1, r + 1);
}
inp.cur = r * n + c;
}
inp.key = true;
inp.idle = 0;
scene.hover(inp.cur);
if (reduce) {
show();
}
return;
}
if (k === 'Enter' || k === ' ') {
ev.preventDefault();
if (inp.cur < 0) {
inp.cur = mid;
}
inp.key = true;
inp.idle = 0;
scene.tap(inp.cur);
if (reduce) {
show();
}
}
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const LINES = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]];
const SCORE_WIN = 10;
const TXT_X_TURN = 'Xの番';
const TXT_O_TURN = 'Oの番';
const TXT_X_WIN = 'Xの勝ち';
const TXT_O_WIN = 'Oの勝ち';
const TXT_DRAW = '引き分け';
function newGame() { return { b: [0, 0, 0, 0, 0, 0, 0, 0, 0], turn: 1, over: false, line: -1 }; }
// 완성된 줄의 번호(LINES 안의 위치). 없으면 -1
function lineOf(b) {
for (let k = 0; k < LINES.length; k += 1) {
const l = LINES[k];
if (b[l[0]] !== 0 && b[l[0]] === b[l[1]] && b[l[1]] === b[l[2]]) {
return k;
}
}
return -1;
}
function emptyCells(b) {
const out = [];
for (let i = 0; i < 9; i += 1) {
if (b[i] === 0) {
out.push(i);
}
}
return out;
}
function place(g, i) {
if (g.over || g.b[i] !== 0) {
return false;
}
g.b[i] = g.turn;
g.line = lineOf(g.b);
if (g.line >= 0 || emptyCells(g.b).length === 0) {
g.over = true;
}
else {
g.turn = 3 - g.turn;
}
return true;
}
function gameLabel(g) {
if (g.over) {
return g.line >= 0 ? (g.b[LINES[g.line][0]] === 1 ? TXT_X_WIN : TXT_O_WIN) : TXT_DRAW;
}
return g.turn === 1 ? TXT_X_TURN : TXT_O_TURN;
}
// 전수 탐색 — X 시점 점수. depth 가 0 이 되면 더 보지 않고 0 으로 친다. cnt.n 은 방문한 국면 수
function solve(b, turn, ply, depth, cnt) {
cnt.n += 1;
const w = lineOf(b);
if (w >= 0) {
const s = SCORE_WIN - ply;
if (b[LINES[w][0]] === 1) {
cnt.x += 1;
return s;
}
cnt.o += 1;
return -s;
}
if (depth <= 0) {
return 0;
}
let best = turn === 1 ? -1000 : 1000;
let any = false;
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) {
continue;
}
b[i] = turn;
const v = solve(b, 3 - turn, ply + 1, depth - 1, cnt);
b[i] = 0;
any = true;
if (turn === 1 ? v > best : v < best) {
best = v;
}
}
return any ? best : 0;
}
// 둘 곳마다 점수. 이미 찬 칸은 NaN. 둔 사람(turn) 시점이 아니라 X 시점 값이다
function cellScores(b, turn, depth, cnt, per) {
const out = [];
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) {
out.push(NaN);
if (per) {
per.push({ n: 0, x: 0, o: 0 });
}
continue;
}
b[i] = turn;
const c = { n: 0, x: 0, o: 0 };
out.push(solve(b, 3 - turn, 1, depth - 1, c));
b[i] = 0;
cnt.n += c.n;
cnt.x += c.x;
cnt.o += c.o;
if (per) {
per.push(c);
}
}
return out;
}
function bestCell(b, turn, depth) {
const cnt = { n: 0, x: 0, o: 0 };
const sc = cellScores(b, turn, depth, cnt);
let best = -1;
for (let i = 0; i < 9; i += 1) {
if (isNaN(sc[i])) {
continue;
}
if (best < 0 || (turn === 1 ? sc[i] > sc[best] : sc[i] < sc[best])) {
best = i;
}
}
return best;
}
function gridRect(i, ox, oy, cs) {
return [ox + (i % 3) * cs, oy + Math.floor(i / 3) * cs, cs, cs];
}
function gridHit(x, y, ox, oy, cs) {
const c = Math.floor((x - ox) / cs);
const r = Math.floor((y - oy) / cs);
return c >= 0 && c < 3 && r >= 0 && r < 3 ? r * 3 + c : -1;
}
const MATCH_DEPTH = 9;
const REPLY_MS = 420;
// 한 판 진행기 — 사람은 X, 컴퓨터는 O. 사람이 가만히 있으면(idle) 둘 다 컴퓨터가 두어 루프를 만든다.
// pick 이 있으면 자동 대국의 수를 그 함수가 정한다. 판이 끝나면 holdMs 뒤에 새 판
function makeMatch(inp, reduce, turnMs, holdMs, seed, depth, onPlace, onReset, pick) {
const rng = lcg(seed);
let t = 0;
let reply = -1;
let hold = 0;
const m = {
g: newGame(),
since: 0,
move(i) {
const who = m.g.turn;
if (place(m.g, i)) {
m.since = 0;
onPlace(i, who);
}
},
tap(i) {
if (m.g.over) {
reset();
return;
}
if (m.g.turn !== 1 || m.g.b[i] !== 0) {
return;
}
m.move(i);
if (!m.g.over) {
if (reduce) {
m.move(bestCell(m.g.b, 2, depth));
}
else {
reply = REPLY_MS;
}
}
},
step() {
m.since += FIXED_DT;
if (m.g.over) {
hold += FIXED_DT;
if (hold >= holdMs) {
reset();
}
return;
}
if (reply >= 0) {
reply -= FIXED_DT;
if (reply < 0) {
m.move(bestCell(m.g.b, 2, depth));
}
return;
}
if (inp.idle > IDLE_TICKS) {
t += FIXED_DT;
if (t >= turnMs) {
t = 0;
autoMove();
}
}
},
auto() {
if (m.g.over) {
reset();
}
else {
autoMove();
}
},
};
function reset() {
m.g = newGame();
t = 0;
reply = -1;
hold = 0;
onReset();
}
function autoMove() {
if (pick) {
const p = pick();
if (p >= 0) {
m.move(p);
}
return;
}
const e = emptyCells(m.g.b);
if (m.g.turn === 1 && (e.length > 6 || rng() < 0.3)) {
m.move(e[Math.floor(rng() * e.length)]);
return;
}
m.move(bestCell(m.g.b, m.g.turn, depth));
}
return m;
}
const ZOOM_K = 2.6;
const FOCUS_MS = 640;
const DIM_ALPHA = 0.32;
const SPRING_ZETA = 0.6;
const TURN_MS = 620;
const HOLD_MS = 1800;
const BIG = 270;
const SMALL = 90;
const UNIT = 30;
const MARK_MS = 260;
const make = function (inp, reduce) {
const rng = lcg(61);
const cells = [];
const age = [];
for (let i = 0; i < 81; i += 1) {
cells.push(0);
age.push(-1);
}
const sw = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const swAge = [0, 0, 0, 0, 0, 0, 0, 0, 0];
let turn = 1;
let fb = -1;
let over = false;
let winLine = -1;
let winner = 0;
let hold = 0;
let t = 0;
let reply = -1;
let hov = -1;
let clock = 0;
let cx = BIG / 2;
let cy = BIG / 2;
let sc = 1;
let vx = 0;
let vy = 0;
let vs = 0;
const omega = PI2 / (FOCUS_MS / 1000);
function boardOf(i) { return Math.floor(Math.floor(i / 9) / 3) * 3 + Math.floor((i % 9) / 3); }
function cellOf(i) { return (Math.floor(i / 9) % 3) * 3 + ((i % 9) % 3); }
function gidx(b, c) { return (Math.floor(b / 3) * 3 + Math.floor(c / 3)) * 9 + (b % 3) * 3 + (c % 3); }
function legal(i) {
const b = boardOf(i);
return !over && cells[i] === 0 && sw[b] === 0 && (fb < 0 || fb === b);
}
function legalList() {
const out = [];
for (let i = 0; i < 81; i += 1) {
if (legal(i)) {
out.push(i);
}
}
return out;
}
function smallLine(b) {
const v = [];
for (let c = 0; c < 9; c += 1) {
v.push(cells[gidx(b, c)]);
}
return lineOf(v);
}
// b 판의 c 칸에 w 가 두면 그 판의 줄이 완성되는가
function completes(b, c, w) {
const v = [];
for (let k = 0; k < 9; k += 1) {
v.push(cells[gidx(b, k)]);
}
v[c] = w;
return lineOf(v) >= 0;
}
function place6(i) {
if (!legal(i)) {
return;
}
const b = boardOf(i);
const c = cellOf(i);
cells[i] = turn;
age[i] = reduce ? 9999 : 0;
const sl = smallLine(b);
if (sl >= 0) {
sw[b] = turn;
swAge[b] = reduce ? 9999 : 0;
}
else {
let full = true;
for (let k = 0; k < 9; k += 1) {
if (cells[gidx(b, k)] === 0) {
full = false;
}
}
if (full) {
sw[b] = 3;
swAge[b] = reduce ? 9999 : 0;
}
}
const bigVals = sw.map(function (v) { return v === 3 ? 0 : v; });
winLine = lineOf(bigVals);
let open = false;
for (let k = 0; k < 9; k += 1) {
if (sw[k] === 0) {
open = true;
}
}
if (winLine >= 0) {
over = true;
winner = bigVals[LINES[winLine][0]];
}
else if (!open) {
over = true;
winner = 0;
}
fb = sw[c] === 0 ? c : -1;
turn = 3 - turn;
snap();
}
function chooseMove() {
const e = legalList();
let best = -1;
let bs = -1000;
for (let k = 0; k < e.length; k += 1) {
const i = e[k];
const b = boardOf(i);
const c = cellOf(i);
let s = rng() * 8;
if (completes(b, c, turn)) {
s += 100;
}
else if (completes(b, c, 3 - turn)) {
s += 40;
}
if (c === 4) {
s += 6;
}
// 상대를 보낼 판에서 상대가 바로 이기면 감점
if (sw[c] === 0) {
for (let q = 0; q < 9; q += 1) {
if (cells[gidx(c, q)] === 0 && completes(c, q, 3 - turn)) {
s -= 30;
break;
}
}
}
if (s > bs) {
bs = s;
best = i;
}
}
return best;
}
function resetAll() {
for (let i = 0; i < 81; i += 1) {
cells[i] = 0;
age[i] = -1;
}
for (let b = 0; b < 9; b += 1) {
sw[b] = 0;
swAge[b] = 0;
}
turn = 1;
fb = -1;
over = false;
winLine = -1;
winner = 0;
hold = 0;
t = 0;
reply = -1;
snap();
}
// 카메라 목표 — 정해진 판이 있으면 그 판으로 줌, 없으면 전체
function goal() {
if (fb >= 0) {
return [(fb % 3) * SMALL + SMALL / 2, Math.floor(fb / 3) * SMALL + SMALL / 2, ZOOM_K];
}
return [BIG / 2, BIG / 2, 1];
}
function snap() {
if (!reduce) {
return;
}
const g = goal();
cx = g[0];
cy = g[1];
sc = g[2];
}
function toScreen(wx, wy) { return [W / 2 + (wx - cx) * sc, H / 2 + (wy - cy) * sc]; }
function cellRect(i) {
const p = toScreen((i % 9) * UNIT, Math.floor(i / 9) * UNIT);
return [p[0], p[1], UNIT * sc, UNIT * sc];
}
snap();
return {
step() {
clock += FIXED_DT;
const dt = FIXED_DT / 1000;
const g = goal();
const k = omega * omega;
const c2 = 2 * SPRING_ZETA * omega;
vx += (-k * (cx - g[0]) - c2 * vx) * dt;
vy += (-k * (cy - g[1]) - c2 * vy) * dt;
vs += (-k * (sc - g[2]) - c2 * vs) * dt;
cx += vx * dt;
cy += vy * dt;
sc += vs * dt;
for (let i = 0; i < 81; i += 1) {
if (age[i] >= 0) {
age[i] += FIXED_DT;
}
}
for (let b = 0; b < 9; b += 1) {
if (sw[b] !== 0) {
swAge[b] += FIXED_DT;
}
}
if (over) {
hold += FIXED_DT;
if (hold >= HOLD_MS) {
resetAll();
}
return;
}
if (reply >= 0) {
reply -= FIXED_DT;
if (reply < 0) {
place6(chooseMove());
}
return;
}
if (inp.idle > IDLE_TICKS) {
t += FIXED_DT;
if (t >= TURN_MS) {
t = 0;
place6(chooseMove());
}
}
},
draw(ctx) {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
ctx.save();
ctx.translate(W / 2, H / 2);
ctx.scale(sc, sc);
ctx.translate(-cx, -cy);
const bigWin = winLine >= 0 ? LINES[winLine] : [];
for (let b = 0; b < 9; b += 1) {
const bx = (b % 3) * SMALL;
const by = Math.floor(b / 3) * SMALL;
const active = !over && sw[b] === 0 && (fb < 0 || fb === b);
const a = active ? 1 : DIM_ALPHA;
ctx.globalAlpha = a;
let col = mix(TILE, CREAM, active && fb === b ? 0.1 : 0.02);
if (bigWin.indexOf(b) >= 0) {
col = mix(TILE, MINT, 0.35);
}
ctx.fillStyle = rgba(col, 1);
rrect(ctx, bx + 2, by + 2, SMALL - 4, SMALL - 4, 6);
ctx.fill();
// 소판 격자선
ctx.strokeStyle = rgba(CREAM, 0.22);
ctx.lineWidth = 0.8;
ctx.beginPath();
for (let q = 1; q < 3; q += 1) {
ctx.moveTo(bx + 8 + q * 24.67, by + 8);
ctx.lineTo(bx + 8 + q * 24.67, by + SMALL - 8);
ctx.moveTo(bx + 8, by + 8 + q * 24.67);
ctx.lineTo(bx + SMALL - 8, by + 8 + q * 24.67);
}
ctx.stroke();
for (let c = 0; c < 9; c += 1) {
const i = gidx(b, c);
if (cells[i] === 0) {
continue;
}
const mx = (i % 9) * UNIT + UNIT / 2;
const my = Math.floor(i / 9) * UNIT + UNIT / 2;
drawMark(ctx, cells[i], mx, my, 7.5, clamp(age[i] / MARK_MS, 0, 1), 2.6, rgba(cells[i] === 1 ? SKY : PINK, 1));
}
if (hov >= 0 && boardOf(hov) === b && legal(hov)) {
const hx = (hov % 9) * UNIT;
const hy = Math.floor(hov / 9) * UNIT;
ctx.fillStyle = rgba(CREAM, 0.14);
rrect(ctx, hx + 3, hy + 3, UNIT - 6, UNIT - 6, 4);
ctx.fill();
}
if (sw[b] === 1 || sw[b] === 2) {
const q = clamp(swAge[b] / 320, 0, 1);
ctx.globalAlpha = a * smooth(q) * 0.92;
ctx.fillStyle = rgba(BG, 0.55);
rrect(ctx, bx + 2, by + 2, SMALL - 4, SMALL - 4, 6);
ctx.fill();
ctx.globalAlpha = 1;
drawMark(ctx, sw[b], bx + SMALL / 2, by + SMALL / 2, 26, q, 7, rgba(sw[b] === 1 ? SKY : PINK, smooth(q)));
}
ctx.globalAlpha = 1;
}
if (winLine >= 0) {
const l = LINES[winLine];
const ax = (l[0] % 3) * SMALL + SMALL / 2;
const ay = Math.floor(l[0] / 3) * SMALL + SMALL / 2;
const bx2 = (l[2] % 3) * SMALL + SMALL / 2;
const by2 = Math.floor(l[2] / 3) * SMALL + SMALL / 2;
ctx.strokeStyle = rgba(CREAM, 0.9);
ctx.lineWidth = 5;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(bx2, by2);
ctx.stroke();
}
ctx.restore();
},
label() {
if (over) {
return winner === 1 ? TXT_X_WIN : (winner === 2 ? TXT_O_WIN : TXT_DRAW);
}
return turn === 1 ? TXT_X_TURN : TXT_O_TURN;
},
hit(x, y) {
const wx = (x - W / 2) / sc + cx;
const wy = (y - H / 2) / sc + cy;
const c = Math.floor(wx / UNIT);
const r = Math.floor(wy / UNIT);
return c >= 0 && c < 9 && r >= 0 && r < 9 ? r * 9 + c : -1;
},
hover(i) { hov = i; },
tap(i) {
if (over) {
resetAll();
return;
}
if (turn !== 1 || !legal(i)) {
return;
}
place6(i);
if (!over) {
if (reduce) {
place6(chooseMove());
}
else {
reply = REPLY_MS;
}
}
},
auto() {
if (over) {
resetAll();
}
else {
place6(chooseMove());
}
},
rect(i) { return cellRect(i); },
n() { return 9; },
};
};
return mount(root, make);
})();// TttNestedBoardZoomFocus - vanilla 판(06-ttt-nested-board-zoom-focus) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./TttNestedBoardZoomFocus.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function TttNestedBoardZoomFocus() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
const TXT_CELL = '칸';
type RGB = [number, number, number];
interface Input { x: number; y: number; cur: number; key: boolean; idle: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
hit(x: number, y: number): number;
hover(i: number): void;
tap(i: number): void;
auto(): void;
rect(i: number): [number, number, number, number];
n(): number;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 팔레트 — 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG: RGB = [29, 26, 44];
const TILE: RGB = [40, 36, 62];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
const CREAM: RGB = [255, 244, 224];
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 smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 두 개로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash2(x: number, y: number, seed: number): number {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 격자점 해시 네 개를 부드럽게 섞는다
function noise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = hash2(xi, yi, seed);
const b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed);
const d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 말 하나를 (cx, cy) 에 그린다. p(0~1) 만큼 대시 오프셋으로 획을 긋는다 — who 1 은 X(두 획), 2 는 O(원)
function drawMark(ctx: CanvasRenderingContext2D, who: number, cx: number, cy: number, r: number, p: number, w: number, col: string): void {
ctx.save();
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.strokeStyle = col;
if (who === 1) {
const d = r * 2 * Math.SQRT2;
for (let k = 0; k < 2; k += 1) {
const pk = clamp(p * 2 - k, 0, 1);
if (pk <= 0.001) { continue; }
ctx.setLineDash([d, d]);
ctx.lineDashOffset = d * (1 - pk);
ctx.beginPath();
if (k === 0) { ctx.moveTo(cx - r, cy - r); ctx.lineTo(cx + r, cy + r); }
else { ctx.moveTo(cx + r, cy - r); ctx.lineTo(cx - r, cy + r); }
ctx.stroke();
}
} else {
const L = PI2 * r;
if (p > 0.001) {
ctx.setLineDash([L, L]);
ctx.lineDashOffset = L * (1 - clamp(p, 0, 1));
ctx.beginPath();
ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + PI2);
ctx.stroke();
}
}
ctx.restore();
}
function mount(root: HTMLElement, make: Maker): () => void {
const canvas = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const btnEl = root.querySelector('[data-role="btn"]') as HTMLButtonElement | null;
if (!canvas || !statusEl || !btnEl) { return function (): void { return; }; }
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
const ctx: CanvasRenderingContext2D = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: -1, y: -1, cur: -1, key: false, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let focused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw(ctx);
if (inp.key && focused && inp.cur >= 0) {
const r = scene.rect(inp.cur);
ctx.save();
ctx.lineWidth = 2.5;
ctx.strokeStyle = 'rgba(255,255,255,0.92)';
rrect(ctx, r[0] + 2, r[1] + 2, r[2] - 4, r[3] - 4, 8);
ctx.stroke();
ctx.restore();
}
let t = scene.label();
if (inp.key && inp.cur >= 0) {
const n = scene.n();
t += ' · ' + TXT_CELL + ' ' + (Math.floor(inp.cur / n) + 1) + '-' + ((inp.cur % n) + 1);
}
if (t !== lastLabel) {
lastLabel = t;
statusEl!.textContent = t;
}
}
function localPoint(e: PointerEvent): void {
const r = canvas!.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
inp.key = false;
}
function tick(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
scene.auto();
scene.auto();
scene.auto();
show();
}
listen(btnEl, 'click', function (): void {
if (reduce) { scene.auto(); show(); return; }
paused = !paused;
if (paused) { root.classList.remove('is-demo'); } else { root.classList.add('is-demo'); }
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'focus', function (): void { focused = true; });
listen(canvas, 'blur', function (): void { focused = false; if (reduce) { show(); } });
listen(canvas, 'pointermove', function (e: Event): void {
localPoint(e as PointerEvent);
scene.hover(scene.hit(inp.x, inp.y));
if (reduce) { show(); }
});
listen(canvas, 'pointerleave', function (): void { scene.hover(-1); if (reduce) { show(); } });
listen(canvas, 'pointerdown', function (e: Event): void {
localPoint(e as PointerEvent);
const i = scene.hit(inp.x, inp.y);
if (i >= 0) { scene.tap(i); }
if (reduce) { show(); }
});
listen(canvas, 'keydown', function (e: Event): void {
const ev = e as KeyboardEvent;
const k = ev.key;
const n = scene.n();
const mid = Math.floor((n * n) / 2);
if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowUp' || k === 'ArrowDown') {
ev.preventDefault();
if (inp.cur < 0) { inp.cur = mid; }
else {
let r = Math.floor(inp.cur / n);
let c = inp.cur % n;
if (k === 'ArrowLeft') { c = Math.max(0, c - 1); }
if (k === 'ArrowRight') { c = Math.min(n - 1, c + 1); }
if (k === 'ArrowUp') { r = Math.max(0, r - 1); }
if (k === 'ArrowDown') { r = Math.min(n - 1, r + 1); }
inp.cur = r * n + c;
}
inp.key = true;
inp.idle = 0;
scene.hover(inp.cur);
if (reduce) { show(); }
return;
}
if (k === 'Enter' || k === ' ') {
ev.preventDefault();
if (inp.cur < 0) { inp.cur = mid; }
inp.key = true;
inp.idle = 0;
scene.tap(inp.cur);
if (reduce) { show(); }
}
});
if (!reduce) { rafId = requestAnimationFrame(tick); }
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
};
}
const LINES: number[][] = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]];
const SCORE_WIN = 10;
const TXT_X_TURN = 'X 차례';
const TXT_O_TURN = 'O 차례';
const TXT_X_WIN = 'X 승';
const TXT_O_WIN = 'O 승';
const TXT_DRAW = '무승부';
interface Game { b: number[]; turn: number; over: boolean; line: number; }
interface Counter { n: number; x: number; o: number; }
function newGame(): Game { return { b: [0, 0, 0, 0, 0, 0, 0, 0, 0], turn: 1, over: false, line: -1 }; }
// 완성된 줄의 번호(LINES 안의 위치). 없으면 -1
function lineOf(b: number[]): number {
for (let k = 0; k < LINES.length; k += 1) {
const l = LINES[k];
if (b[l[0]] !== 0 && b[l[0]] === b[l[1]] && b[l[1]] === b[l[2]]) { return k; }
}
return -1;
}
function emptyCells(b: number[]): number[] {
const out: number[] = [];
for (let i = 0; i < 9; i += 1) { if (b[i] === 0) { out.push(i); } }
return out;
}
function place(g: Game, i: number): boolean {
if (g.over || g.b[i] !== 0) { return false; }
g.b[i] = g.turn;
g.line = lineOf(g.b);
if (g.line >= 0 || emptyCells(g.b).length === 0) { g.over = true; } else { g.turn = 3 - g.turn; }
return true;
}
function gameLabel(g: Game): string {
if (g.over) { return g.line >= 0 ? (g.b[LINES[g.line][0]] === 1 ? TXT_X_WIN : TXT_O_WIN) : TXT_DRAW; }
return g.turn === 1 ? TXT_X_TURN : TXT_O_TURN;
}
// 전수 탐색 — X 시점 점수. depth 가 0 이 되면 더 보지 않고 0 으로 친다. cnt.n 은 방문한 국면 수
function solve(b: number[], turn: number, ply: number, depth: number, cnt: Counter): number {
cnt.n += 1;
const w = lineOf(b);
if (w >= 0) {
const s = SCORE_WIN - ply;
if (b[LINES[w][0]] === 1) { cnt.x += 1; return s; }
cnt.o += 1;
return -s;
}
if (depth <= 0) { return 0; }
let best = turn === 1 ? -1000 : 1000;
let any = false;
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) { continue; }
b[i] = turn;
const v = solve(b, 3 - turn, ply + 1, depth - 1, cnt);
b[i] = 0;
any = true;
if (turn === 1 ? v > best : v < best) { best = v; }
}
return any ? best : 0;
}
// 둘 곳마다 점수. 이미 찬 칸은 NaN. 둔 사람(turn) 시점이 아니라 X 시점 값이다
function cellScores(b: number[], turn: number, depth: number, cnt: Counter, per?: Counter[]): number[] {
const out: number[] = [];
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) { out.push(NaN); if (per) { per.push({ n: 0, x: 0, o: 0 }); } continue; }
b[i] = turn;
const c: Counter = { n: 0, x: 0, o: 0 };
out.push(solve(b, 3 - turn, 1, depth - 1, c));
b[i] = 0;
cnt.n += c.n; cnt.x += c.x; cnt.o += c.o;
if (per) { per.push(c); }
}
return out;
}
function bestCell(b: number[], turn: number, depth: number): number {
const cnt: Counter = { n: 0, x: 0, o: 0 };
const sc = cellScores(b, turn, depth, cnt);
let best = -1;
for (let i = 0; i < 9; i += 1) {
if (isNaN(sc[i])) { continue; }
if (best < 0 || (turn === 1 ? sc[i] > sc[best] : sc[i] < sc[best])) { best = i; }
}
return best;
}
function gridRect(i: number, ox: number, oy: number, cs: number): [number, number, number, number] {
return [ox + (i % 3) * cs, oy + Math.floor(i / 3) * cs, cs, cs];
}
function gridHit(x: number, y: number, ox: number, oy: number, cs: number): number {
const c = Math.floor((x - ox) / cs);
const r = Math.floor((y - oy) / cs);
return c >= 0 && c < 3 && r >= 0 && r < 3 ? r * 3 + c : -1;
}
interface Match {
g: Game;
since: number;
move(i: number): void;
tap(i: number): void;
step(): void;
auto(): void;
}
const MATCH_DEPTH = 9;
const REPLY_MS = 420;
// 한 판 진행기 — 사람은 X, 컴퓨터는 O. 사람이 가만히 있으면(idle) 둘 다 컴퓨터가 두어 루프를 만든다.
// pick 이 있으면 자동 대국의 수를 그 함수가 정한다. 판이 끝나면 holdMs 뒤에 새 판
function makeMatch(inp: Input, reduce: boolean, turnMs: number, holdMs: number, seed: number, depth: number, onPlace: (i: number, who: number) => void, onReset: () => void, pick: (() => number) | null): Match {
const rng = lcg(seed);
let t = 0;
let reply = -1;
let hold = 0;
const m: Match = {
g: newGame(),
since: 0,
move(i: number): void {
const who = m.g.turn;
if (place(m.g, i)) { m.since = 0; onPlace(i, who); }
},
tap(i: number): void {
if (m.g.over) { reset(); return; }
if (m.g.turn !== 1 || m.g.b[i] !== 0) { return; }
m.move(i);
if (!m.g.over) {
if (reduce) { m.move(bestCell(m.g.b, 2, depth)); } else { reply = REPLY_MS; }
}
},
step(): void {
m.since += FIXED_DT;
if (m.g.over) {
hold += FIXED_DT;
if (hold >= holdMs) { reset(); }
return;
}
if (reply >= 0) {
reply -= FIXED_DT;
if (reply < 0) { m.move(bestCell(m.g.b, 2, depth)); }
return;
}
if (inp.idle > IDLE_TICKS) {
t += FIXED_DT;
if (t >= turnMs) { t = 0; autoMove(); }
}
},
auto(): void {
if (m.g.over) { reset(); } else { autoMove(); }
},
};
function reset(): void {
m.g = newGame();
t = 0;
reply = -1;
hold = 0;
onReset();
}
function autoMove(): void {
if (pick) { const p = pick(); if (p >= 0) { m.move(p); } return; }
const e = emptyCells(m.g.b);
if (m.g.turn === 1 && (e.length > 6 || rng() < 0.3)) { m.move(e[Math.floor(rng() * e.length)]); return; }
m.move(bestCell(m.g.b, m.g.turn, depth));
}
return m;
}
const ZOOM_K = 2.6;
const FOCUS_MS = 640;
const DIM_ALPHA = 0.32;
const SPRING_ZETA = 0.6;
const TURN_MS = 620;
const HOLD_MS = 1800;
const BIG = 270;
const SMALL = 90;
const UNIT = 30;
const MARK_MS = 260;
const make: Maker = function (inp: Input, reduce: boolean): Scene {
const rng = lcg(61);
const cells: number[] = [];
const age: number[] = [];
for (let i = 0; i < 81; i += 1) { cells.push(0); age.push(-1); }
const sw: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const swAge: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
let turn = 1;
let fb = -1;
let over = false;
let winLine = -1;
let winner = 0;
let hold = 0;
let t = 0;
let reply = -1;
let hov = -1;
let clock = 0;
let cx = BIG / 2;
let cy = BIG / 2;
let sc = 1;
let vx = 0;
let vy = 0;
let vs = 0;
const omega = PI2 / (FOCUS_MS / 1000);
function boardOf(i: number): number { return Math.floor(Math.floor(i / 9) / 3) * 3 + Math.floor((i % 9) / 3); }
function cellOf(i: number): number { return (Math.floor(i / 9) % 3) * 3 + ((i % 9) % 3); }
function gidx(b: number, c: number): number { return (Math.floor(b / 3) * 3 + Math.floor(c / 3)) * 9 + (b % 3) * 3 + (c % 3); }
function legal(i: number): boolean {
const b = boardOf(i);
return !over && cells[i] === 0 && sw[b] === 0 && (fb < 0 || fb === b);
}
function legalList(): number[] {
const out: number[] = [];
for (let i = 0; i < 81; i += 1) { if (legal(i)) { out.push(i); } }
return out;
}
function smallLine(b: number): number {
const v: number[] = [];
for (let c = 0; c < 9; c += 1) { v.push(cells[gidx(b, c)]); }
return lineOf(v);
}
// b 판의 c 칸에 w 가 두면 그 판의 줄이 완성되는가
function completes(b: number, c: number, w: number): boolean {
const v: number[] = [];
for (let k = 0; k < 9; k += 1) { v.push(cells[gidx(b, k)]); }
v[c] = w;
return lineOf(v) >= 0;
}
function place6(i: number): void {
if (!legal(i)) { return; }
const b = boardOf(i);
const c = cellOf(i);
cells[i] = turn;
age[i] = reduce ? 9999 : 0;
const sl = smallLine(b);
if (sl >= 0) { sw[b] = turn; swAge[b] = reduce ? 9999 : 0; }
else {
let full = true;
for (let k = 0; k < 9; k += 1) { if (cells[gidx(b, k)] === 0) { full = false; } }
if (full) { sw[b] = 3; swAge[b] = reduce ? 9999 : 0; }
}
const bigVals = sw.map(function (v: number): number { return v === 3 ? 0 : v; });
winLine = lineOf(bigVals);
let open = false;
for (let k = 0; k < 9; k += 1) { if (sw[k] === 0) { open = true; } }
if (winLine >= 0) { over = true; winner = bigVals[LINES[winLine][0]]; }
else if (!open) { over = true; winner = 0; }
fb = sw[c] === 0 ? c : -1;
turn = 3 - turn;
snap();
}
function chooseMove(): number {
const e = legalList();
let best = -1;
let bs = -1000;
for (let k = 0; k < e.length; k += 1) {
const i = e[k];
const b = boardOf(i);
const c = cellOf(i);
let s = rng() * 8;
if (completes(b, c, turn)) { s += 100; }
else if (completes(b, c, 3 - turn)) { s += 40; }
if (c === 4) { s += 6; }
// 상대를 보낼 판에서 상대가 바로 이기면 감점
if (sw[c] === 0) {
for (let q = 0; q < 9; q += 1) { if (cells[gidx(c, q)] === 0 && completes(c, q, 3 - turn)) { s -= 30; break; } }
}
if (s > bs) { bs = s; best = i; }
}
return best;
}
function resetAll(): void {
for (let i = 0; i < 81; i += 1) { cells[i] = 0; age[i] = -1; }
for (let b = 0; b < 9; b += 1) { sw[b] = 0; swAge[b] = 0; }
turn = 1; fb = -1; over = false; winLine = -1; winner = 0; hold = 0; t = 0; reply = -1;
snap();
}
// 카메라 목표 — 정해진 판이 있으면 그 판으로 줌, 없으면 전체
function goal(): [number, number, number] {
if (fb >= 0) { return [(fb % 3) * SMALL + SMALL / 2, Math.floor(fb / 3) * SMALL + SMALL / 2, ZOOM_K]; }
return [BIG / 2, BIG / 2, 1];
}
function snap(): void {
if (!reduce) { return; }
const g = goal();
cx = g[0]; cy = g[1]; sc = g[2];
}
function toScreen(wx: number, wy: number): [number, number] { return [W / 2 + (wx - cx) * sc, H / 2 + (wy - cy) * sc]; }
function cellRect(i: number): [number, number, number, number] {
const p = toScreen((i % 9) * UNIT, Math.floor(i / 9) * UNIT);
return [p[0], p[1], UNIT * sc, UNIT * sc];
}
snap();
return {
step(): void {
clock += FIXED_DT;
const dt = FIXED_DT / 1000;
const g = goal();
const k = omega * omega;
const c2 = 2 * SPRING_ZETA * omega;
vx += (-k * (cx - g[0]) - c2 * vx) * dt;
vy += (-k * (cy - g[1]) - c2 * vy) * dt;
vs += (-k * (sc - g[2]) - c2 * vs) * dt;
cx += vx * dt; cy += vy * dt; sc += vs * dt;
for (let i = 0; i < 81; i += 1) { if (age[i] >= 0) { age[i] += FIXED_DT; } }
for (let b = 0; b < 9; b += 1) { if (sw[b] !== 0) { swAge[b] += FIXED_DT; } }
if (over) { hold += FIXED_DT; if (hold >= HOLD_MS) { resetAll(); } return; }
if (reply >= 0) {
reply -= FIXED_DT;
if (reply < 0) { place6(chooseMove()); }
return;
}
if (inp.idle > IDLE_TICKS) {
t += FIXED_DT;
if (t >= TURN_MS) { t = 0; place6(chooseMove()); }
}
},
draw(ctx: CanvasRenderingContext2D): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
ctx.save();
ctx.translate(W / 2, H / 2);
ctx.scale(sc, sc);
ctx.translate(-cx, -cy);
const bigWin = winLine >= 0 ? LINES[winLine] : [];
for (let b = 0; b < 9; b += 1) {
const bx = (b % 3) * SMALL;
const by = Math.floor(b / 3) * SMALL;
const active = !over && sw[b] === 0 && (fb < 0 || fb === b);
const a = active ? 1 : DIM_ALPHA;
ctx.globalAlpha = a;
let col: RGB = mix(TILE, CREAM, active && fb === b ? 0.1 : 0.02);
if (bigWin.indexOf(b) >= 0) { col = mix(TILE, MINT, 0.35); }
ctx.fillStyle = rgba(col, 1);
rrect(ctx, bx + 2, by + 2, SMALL - 4, SMALL - 4, 6);
ctx.fill();
// 소판 격자선
ctx.strokeStyle = rgba(CREAM, 0.22);
ctx.lineWidth = 0.8;
ctx.beginPath();
for (let q = 1; q < 3; q += 1) {
ctx.moveTo(bx + 8 + q * 24.67, by + 8); ctx.lineTo(bx + 8 + q * 24.67, by + SMALL - 8);
ctx.moveTo(bx + 8, by + 8 + q * 24.67); ctx.lineTo(bx + SMALL - 8, by + 8 + q * 24.67);
}
ctx.stroke();
for (let c = 0; c < 9; c += 1) {
const i = gidx(b, c);
if (cells[i] === 0) { continue; }
const mx = (i % 9) * UNIT + UNIT / 2;
const my = Math.floor(i / 9) * UNIT + UNIT / 2;
drawMark(ctx, cells[i], mx, my, 7.5, clamp(age[i] / MARK_MS, 0, 1), 2.6, rgba(cells[i] === 1 ? SKY : PINK, 1));
}
if (hov >= 0 && boardOf(hov) === b && legal(hov)) {
const hx = (hov % 9) * UNIT;
const hy = Math.floor(hov / 9) * UNIT;
ctx.fillStyle = rgba(CREAM, 0.14);
rrect(ctx, hx + 3, hy + 3, UNIT - 6, UNIT - 6, 4);
ctx.fill();
}
if (sw[b] === 1 || sw[b] === 2) {
const q = clamp(swAge[b] / 320, 0, 1);
ctx.globalAlpha = a * smooth(q) * 0.92;
ctx.fillStyle = rgba(BG, 0.55);
rrect(ctx, bx + 2, by + 2, SMALL - 4, SMALL - 4, 6);
ctx.fill();
ctx.globalAlpha = 1;
drawMark(ctx, sw[b], bx + SMALL / 2, by + SMALL / 2, 26, q, 7, rgba(sw[b] === 1 ? SKY : PINK, smooth(q)));
}
ctx.globalAlpha = 1;
}
if (winLine >= 0) {
const l = LINES[winLine];
const ax = (l[0] % 3) * SMALL + SMALL / 2;
const ay = Math.floor(l[0] / 3) * SMALL + SMALL / 2;
const bx2 = (l[2] % 3) * SMALL + SMALL / 2;
const by2 = Math.floor(l[2] / 3) * SMALL + SMALL / 2;
ctx.strokeStyle = rgba(CREAM, 0.9);
ctx.lineWidth = 5;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(bx2, by2);
ctx.stroke();
}
ctx.restore();
},
label(): string {
if (over) { return winner === 1 ? TXT_X_WIN : (winner === 2 ? TXT_O_WIN : TXT_DRAW); }
return turn === 1 ? TXT_X_TURN : TXT_O_TURN;
},
hit(x: number, y: number): number {
const wx = (x - W / 2) / sc + cx;
const wy = (y - H / 2) / sc + cy;
const c = Math.floor(wx / UNIT);
const r = Math.floor(wy / UNIT);
return c >= 0 && c < 9 && r >= 0 && r < 9 ? r * 9 + c : -1;
},
hover(i: number): void { hov = i; },
tap(i: number): void {
if (over) { resetAll(); return; }
if (turn !== 1 || !legal(i)) { return; }
place6(i);
if (!over) { if (reduce) { place6(chooseMove()); } else { reply = REPLY_MS; } }
},
auto(): void {
if (over) { resetAll(); } else { place6(chooseMove()); }
},
rect(i: number): [number, number, number, number] { return cellRect(i); },
n(): number { return 9; },
};
};
return mount(root, make);
}, []);
return (
<div className={cx("tt") + " is-demo"} id="tt" ref={rootRef}>
<canvas className={cx("tt__canvas")} data-role="canvas" width="480" height="300" tabIndex={0} aria-label="판 안에 판이 들어 있어 놓은 칸이 다음 판을 지정하고 화면이 그 판으로 스프링 확대되는 겹판"></canvas>
<p className={cx("tt__status")} data-role="status" aria-live="polite">X 차례</p>
<button className={cx("tt__btn")} data-role="btn" type="button">멈춤</button>
</div>
);
}06 · 盤の中に盤があり、置いたマスが次の盤を指定して、画面がその盤へバネで拡大される入れ子の盤 — 計測 累積面積 59.026% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// カメラの目標 — 指定された盤があればその盤へズーム、なければ全体
function goal() {
if (fb >= 0) {
return [(fb % 3) * SMALL + SMALL / 2, Math.floor(fb / 3) * SMALL + SMALL / 2, ZOOM_K];
}
return [BIG / 2, BIG / 2, 1];
}
07回しても裏返しても同じ局面を一つの盤に折り重ねる対称の折りたたみ表示
回転四通りと反転二通りを掛けた八つの変換を buildSym がマスの入れ替え表にし、局面を八回入れ替えて文字列につなぎ、いちばん小さいものを標準形にします。半透明の八枚の盤が中央から半径104pxの輪に広がり、800msかけて一か所に折りたたまれ、状態行には異なる形 n/8 が出ます。レンダーの値は累積面積29.1%、平均強度70.0、動いたフレーム21/23でした。

<main class="stage">
<div class="tt is-demo" id="tt">
<canvas class="tt__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="回しても裏返しても同じ局面を一つの盤に折り重ねる対称の折りたたみ表示"></canvas>
<p class="tt__status" data-role="status" aria-live="polite">Xの番</p>
<button class="tt__btn" data-role="btn" type="button">一時停止</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.tt {
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;
}
.tt__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.tt__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.tt__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.tt__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.tt__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.tt__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.tt__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.tt { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tt {
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;
}
.tt__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.tt__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.tt__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.tt__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.tt__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.tt__btn {
font-size: 10px;
padding: 4px 8px;
}
.tt__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.tt {
cursor: default;
}
}(function () {
const root = document.getElementById("tt");
if (!root) return;
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const IDLE_TICKS = 150;
const BTN_PAUSE = '一時停止';
const BTN_PLAY = '再生';
const BTN_NEXT = '次の場面';
const TXT_CELL = 'マス';
// 파스텔 팔레트 — 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG = [29, 26, 44];
const TILE = [40, 36, 62];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
const CREAM = [255, 244, 224];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 두 개로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash2(x, y, seed) {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 격자점 해시 네 개를 부드럽게 섞는다
function noise2(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = hash2(xi, yi, seed);
const b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed);
const d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
function rrect(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 말 하나를 (cx, cy) 에 그린다. p(0~1) 만큼 대시 오프셋으로 획을 긋는다 — who 1 은 X(두 획), 2 는 O(원)
function drawMark(ctx, who, cx, cy, r, p, w, col) {
ctx.save();
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.strokeStyle = col;
if (who === 1) {
const d = r * 2 * Math.SQRT2;
for (let k = 0; k < 2; k += 1) {
const pk = clamp(p * 2 - k, 0, 1);
if (pk <= 0.001) {
continue;
}
ctx.setLineDash([d, d]);
ctx.lineDashOffset = d * (1 - pk);
ctx.beginPath();
if (k === 0) {
ctx.moveTo(cx - r, cy - r);
ctx.lineTo(cx + r, cy + r);
}
else {
ctx.moveTo(cx + r, cy - r);
ctx.lineTo(cx - r, cy + r);
}
ctx.stroke();
}
}
else {
const L = PI2 * r;
if (p > 0.001) {
ctx.setLineDash([L, L]);
ctx.lineDashOffset = L * (1 - clamp(p, 0, 1));
ctx.beginPath();
ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + PI2);
ctx.stroke();
}
}
ctx.restore();
}
function mount(root, make) {
const canvas = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const btnEl = root.querySelector('[data-role="btn"]');
if (!canvas || !statusEl || !btnEl) {
return function () { return; };
}
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: -1, y: -1, cur: -1, key: false, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let focused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw(ctx);
if (inp.key && focused && inp.cur >= 0) {
const r = scene.rect(inp.cur);
ctx.save();
ctx.lineWidth = 2.5;
ctx.strokeStyle = 'rgba(255,255,255,0.92)';
rrect(ctx, r[0] + 2, r[1] + 2, r[2] - 4, r[3] - 4, 8);
ctx.stroke();
ctx.restore();
}
let t = scene.label();
if (inp.key && inp.cur >= 0) {
const n = scene.n();
t += ' · ' + TXT_CELL + ' ' + (Math.floor(inp.cur / n) + 1) + '-' + ((inp.cur % n) + 1);
}
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function localPoint(e) {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
inp.key = false;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
scene.auto();
scene.auto();
scene.auto();
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
scene.auto();
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'focus', function () { focused = true; });
listen(canvas, 'blur', function () { focused = false; if (reduce) {
show();
} });
listen(canvas, 'pointermove', function (e) {
localPoint(e);
scene.hover(scene.hit(inp.x, inp.y));
if (reduce) {
show();
}
});
listen(canvas, 'pointerleave', function () { scene.hover(-1); if (reduce) {
show();
} });
listen(canvas, 'pointerdown', function (e) {
localPoint(e);
const i = scene.hit(inp.x, inp.y);
if (i >= 0) {
scene.tap(i);
}
if (reduce) {
show();
}
});
listen(canvas, 'keydown', function (e) {
const ev = e;
const k = ev.key;
const n = scene.n();
const mid = Math.floor((n * n) / 2);
if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowUp' || k === 'ArrowDown') {
ev.preventDefault();
if (inp.cur < 0) {
inp.cur = mid;
}
else {
let r = Math.floor(inp.cur / n);
let c = inp.cur % n;
if (k === 'ArrowLeft') {
c = Math.max(0, c - 1);
}
if (k === 'ArrowRight') {
c = Math.min(n - 1, c + 1);
}
if (k === 'ArrowUp') {
r = Math.max(0, r - 1);
}
if (k === 'ArrowDown') {
r = Math.min(n - 1, r + 1);
}
inp.cur = r * n + c;
}
inp.key = true;
inp.idle = 0;
scene.hover(inp.cur);
if (reduce) {
show();
}
return;
}
if (k === 'Enter' || k === ' ') {
ev.preventDefault();
if (inp.cur < 0) {
inp.cur = mid;
}
inp.key = true;
inp.idle = 0;
scene.tap(inp.cur);
if (reduce) {
show();
}
}
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const LINES = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]];
const SCORE_WIN = 10;
const TXT_X_TURN = 'Xの番';
const TXT_O_TURN = 'Oの番';
const TXT_X_WIN = 'Xの勝ち';
const TXT_O_WIN = 'Oの勝ち';
const TXT_DRAW = '引き分け';
function newGame() { return { b: [0, 0, 0, 0, 0, 0, 0, 0, 0], turn: 1, over: false, line: -1 }; }
// 완성된 줄의 번호(LINES 안의 위치). 없으면 -1
function lineOf(b) {
for (let k = 0; k < LINES.length; k += 1) {
const l = LINES[k];
if (b[l[0]] !== 0 && b[l[0]] === b[l[1]] && b[l[1]] === b[l[2]]) {
return k;
}
}
return -1;
}
function emptyCells(b) {
const out = [];
for (let i = 0; i < 9; i += 1) {
if (b[i] === 0) {
out.push(i);
}
}
return out;
}
function place(g, i) {
if (g.over || g.b[i] !== 0) {
return false;
}
g.b[i] = g.turn;
g.line = lineOf(g.b);
if (g.line >= 0 || emptyCells(g.b).length === 0) {
g.over = true;
}
else {
g.turn = 3 - g.turn;
}
return true;
}
function gameLabel(g) {
if (g.over) {
return g.line >= 0 ? (g.b[LINES[g.line][0]] === 1 ? TXT_X_WIN : TXT_O_WIN) : TXT_DRAW;
}
return g.turn === 1 ? TXT_X_TURN : TXT_O_TURN;
}
// 전수 탐색 — X 시점 점수. depth 가 0 이 되면 더 보지 않고 0 으로 친다. cnt.n 은 방문한 국면 수
function solve(b, turn, ply, depth, cnt) {
cnt.n += 1;
const w = lineOf(b);
if (w >= 0) {
const s = SCORE_WIN - ply;
if (b[LINES[w][0]] === 1) {
cnt.x += 1;
return s;
}
cnt.o += 1;
return -s;
}
if (depth <= 0) {
return 0;
}
let best = turn === 1 ? -1000 : 1000;
let any = false;
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) {
continue;
}
b[i] = turn;
const v = solve(b, 3 - turn, ply + 1, depth - 1, cnt);
b[i] = 0;
any = true;
if (turn === 1 ? v > best : v < best) {
best = v;
}
}
return any ? best : 0;
}
// 둘 곳마다 점수. 이미 찬 칸은 NaN. 둔 사람(turn) 시점이 아니라 X 시점 값이다
function cellScores(b, turn, depth, cnt, per) {
const out = [];
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) {
out.push(NaN);
if (per) {
per.push({ n: 0, x: 0, o: 0 });
}
continue;
}
b[i] = turn;
const c = { n: 0, x: 0, o: 0 };
out.push(solve(b, 3 - turn, 1, depth - 1, c));
b[i] = 0;
cnt.n += c.n;
cnt.x += c.x;
cnt.o += c.o;
if (per) {
per.push(c);
}
}
return out;
}
function bestCell(b, turn, depth) {
const cnt = { n: 0, x: 0, o: 0 };
const sc = cellScores(b, turn, depth, cnt);
let best = -1;
for (let i = 0; i < 9; i += 1) {
if (isNaN(sc[i])) {
continue;
}
if (best < 0 || (turn === 1 ? sc[i] > sc[best] : sc[i] < sc[best])) {
best = i;
}
}
return best;
}
function gridRect(i, ox, oy, cs) {
return [ox + (i % 3) * cs, oy + Math.floor(i / 3) * cs, cs, cs];
}
function gridHit(x, y, ox, oy, cs) {
const c = Math.floor((x - ox) / cs);
const r = Math.floor((y - oy) / cs);
return c >= 0 && c < 3 && r >= 0 && r < 3 ? r * 3 + c : -1;
}
const MATCH_DEPTH = 9;
const REPLY_MS = 420;
// 한 판 진행기 — 사람은 X, 컴퓨터는 O. 사람이 가만히 있으면(idle) 둘 다 컴퓨터가 두어 루프를 만든다.
// pick 이 있으면 자동 대국의 수를 그 함수가 정한다. 판이 끝나면 holdMs 뒤에 새 판
function makeMatch(inp, reduce, turnMs, holdMs, seed, depth, onPlace, onReset, pick) {
const rng = lcg(seed);
let t = 0;
let reply = -1;
let hold = 0;
const m = {
g: newGame(),
since: 0,
move(i) {
const who = m.g.turn;
if (place(m.g, i)) {
m.since = 0;
onPlace(i, who);
}
},
tap(i) {
if (m.g.over) {
reset();
return;
}
if (m.g.turn !== 1 || m.g.b[i] !== 0) {
return;
}
m.move(i);
if (!m.g.over) {
if (reduce) {
m.move(bestCell(m.g.b, 2, depth));
}
else {
reply = REPLY_MS;
}
}
},
step() {
m.since += FIXED_DT;
if (m.g.over) {
hold += FIXED_DT;
if (hold >= holdMs) {
reset();
}
return;
}
if (reply >= 0) {
reply -= FIXED_DT;
if (reply < 0) {
m.move(bestCell(m.g.b, 2, depth));
}
return;
}
if (inp.idle > IDLE_TICKS) {
t += FIXED_DT;
if (t >= turnMs) {
t = 0;
autoMove();
}
}
},
auto() {
if (m.g.over) {
reset();
}
else {
autoMove();
}
},
};
function reset() {
m.g = newGame();
t = 0;
reply = -1;
hold = 0;
onReset();
}
function autoMove() {
if (pick) {
const p = pick();
if (p >= 0) {
m.move(p);
}
return;
}
const e = emptyCells(m.g.b);
if (m.g.turn === 1 && (e.length > 6 || rng() < 0.3)) {
m.move(e[Math.floor(rng() * e.length)]);
return;
}
m.move(bestCell(m.g.b, m.g.turn, depth));
}
return m;
}
const SYM_COUNT = 8;
const FOLD_MS = 800;
const MERGE_ALPHA = 0.5;
const SPREAD_R = 104;
const SPREAD_MS = 450;
const HOLD_P = 300;
const SETTLE_MS = 260;
const BIG_SIZE = 120;
const GHOST_SIZE = 66;
const TXT_DISTINCT = '異なる形';
// 회전 4 × 반사 2 = 8 가지 マス 옮기기 — SYM[k][i] 는 i 번 칸이 k 번 변환에서 가는 マス
function buildSym() {
const out = [];
for (let f = 0; f < 2; f += 1) {
for (let r = 0; r < 4; r += 1) {
const perm = [];
for (let i = 0; i < 9; i += 1) {
let row = Math.floor(i / 3);
let col = i % 3;
for (let q = 0; q < r; q += 1) {
const nr = col;
col = 2 - row;
row = nr;
}
if (f === 1) {
col = 2 - col;
}
perm.push(row * 3 + col);
}
out.push(perm);
}
}
return out;
}
const SYM = buildSym();
function applySym(b, perm) {
const out = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (let i = 0; i < 9; i += 1) {
out[perm[i]] = b[i];
}
return out;
}
const make = function (inp, reduce) {
const rng = lcg(71);
let variants = [];
let canon = 0;
let distinct = 1;
let phase = 0;
let pt = 0;
let hov = -1;
function analyze() {
variants = [];
const keys = [];
for (let k = 0; k < SYM_COUNT; k += 1) {
variants.push(applySym(m.g.b, SYM[k]));
keys.push(variants[k].join(''));
}
canon = 0;
for (let k = 1; k < SYM_COUNT; k += 1) {
if (keys[k] < keys[canon]) {
canon = k;
}
}
distinct = keys.filter(function (v, i) { return keys.indexOf(v) === i; }).length;
}
const m = makeMatch(inp, reduce, 1000000, 1200, 71, MATCH_DEPTH, function () {
analyze();
phase = 0;
pt = 0;
}, function () {
analyze();
phase = 0;
pt = 0;
}, null);
// 첫 판은 세 수를 둔 국면으로 시작
function warm() {
const e0 = emptyCells(m.g.b);
m.move(e0[Math.floor(rng() * e0.length)]);
m.move(bestCell(m.g.b, 2, MATCH_DEPTH));
const e1 = emptyCells(m.g.b);
m.move(e1[Math.floor(rng() * e1.length)]);
analyze();
}
warm();
function ringPos(k, s) {
const a = (k / SYM_COUNT) * PI2 - Math.PI / 2 + Math.PI / SYM_COUNT;
return [W / 2 + Math.cos(a) * SPREAD_R * s, H / 2 + Math.sin(a) * SPREAD_R * s];
}
function drawBoard(ctx, b, cx, cy, size, alpha, outline) {
const x0 = cx - size / 2;
const y0 = cy - size / 2;
const cs = size / 3;
ctx.globalAlpha = alpha;
ctx.fillStyle = rgba(TILE, 1);
rrect(ctx, x0, y0, size, size, size * 0.1);
ctx.fill();
if (outline) {
ctx.strokeStyle = rgba(MINT, 1);
ctx.lineWidth = 2;
rrect(ctx, x0, y0, size, size, size * 0.1);
ctx.stroke();
}
ctx.strokeStyle = rgba(CREAM, 0.22);
ctx.lineWidth = 1;
ctx.beginPath();
for (let q = 1; q < 3; q += 1) {
ctx.moveTo(x0 + q * cs, y0 + size * 0.08);
ctx.lineTo(x0 + q * cs, y0 + size * 0.92);
ctx.moveTo(x0 + size * 0.08, y0 + q * cs);
ctx.lineTo(x0 + size * 0.92, y0 + q * cs);
}
ctx.stroke();
for (let i = 0; i < 9; i += 1) {
if (b[i] === 0) {
continue;
}
drawMark(ctx, b[i], x0 + (i % 3) * cs + cs / 2, y0 + Math.floor(i / 3) * cs + cs / 2, cs * 0.27, 1, Math.max(size * 0.034, 2), rgba(b[i] === 1 ? SKY : PINK, 1));
}
ctx.globalAlpha = 1;
}
function phaseLen() { return phase === 0 ? HOLD_P : (phase === 1 ? SPREAD_MS : (phase === 2 ? FOLD_MS : SETTLE_MS)); }
return {
step() {
m.step();
if (m.g.over) {
return;
}
if (inp.idle <= IDLE_TICKS && phase === 0) {
pt = 0;
return;
}
pt += FIXED_DT;
if (pt >= phaseLen()) {
pt = 0;
phase += 1;
if (phase > 3) {
phase = 0;
if (inp.idle > IDLE_TICKS) {
m.auto();
}
}
}
},
draw(ctx) {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
const real = m.g.b;
const ph = reduce ? 0 : phase;
const q = clamp(pt / phaseLen(), 0, 1);
if (ph === 0 || reduce) {
drawBoard(ctx, real, W / 2, H / 2, BIG_SIZE + 16, 1, false);
if (reduce) {
for (let k = 0; k < SYM_COUNT; k += 1) {
const p = ringPos(k, 1);
drawBoard(ctx, variants[k], p[0], p[1], GHOST_SIZE, 0.85, k === canon);
}
}
return;
}
const spread = ph === 1 ? smooth(q) : 1;
const fold = ph === 2 ? smooth(q) : (ph === 3 ? 1 : 0);
// 중앙 판: 접히는 동안 실제 국면에서 표준 모양으로 서서히 바뀐다
drawBoard(ctx, real, W / 2, H / 2, BIG_SIZE + 16, 1 - fold, false);
if (fold > 0) {
drawBoard(ctx, variants[canon], W / 2, H / 2, BIG_SIZE + 16, fold, false);
}
for (let k = 0; k < SYM_COUNT; k += 1) {
const p0 = ringPos(k, spread);
const p1 = ringPos(k, 1);
const x = lerp(p0[0], W / 2, fold);
const y = lerp(p0[1], H / 2, fold);
const size = lerp(GHOST_SIZE * (0.6 + 0.4 * spread), BIG_SIZE + 16, fold);
const fade = ph === 3 ? Math.max(0, 1 - q * 4) : 1;
if (fade <= 0) {
continue;
}
const a = lerp(0.95, MERGE_ALPHA, fold) * fade;
drawBoard(ctx, variants[k], x, y, size, a * (1 - fold * 0.55), k === canon && fold < 0.7);
void p1;
}
},
label() { return gameLabel(m.g) + (phase >= 1 || reduce ? ' · ' + TXT_DISTINCT + ' ' + distinct + '/' + SYM_COUNT : ''); },
hit(x, y) { return gridHit(x, y, W / 2 - (BIG_SIZE + 16) / 2, H / 2 - (BIG_SIZE + 16) / 2, (BIG_SIZE + 16) / 3); },
hover(i) { hov = i; void hov; },
tap(i) {
// 접히는 동안 가운데 판은 표준 모양으로 바뀌므로, 그동안 누른 칸은 실제 국면의 칸과 어긋난다 — 받지 않는다
if (!reduce && phase >= 2) {
return;
}
m.tap(i);
},
auto() { m.auto(); },
rect(i) { return gridRect(i, W / 2 - (BIG_SIZE + 16) / 2, H / 2 - (BIG_SIZE + 16) / 2, (BIG_SIZE + 16) / 3); },
n() { return 3; },
};
};
return mount(root, make);
})();// TttSymmetryFoldCanonical - vanilla 판(07-ttt-symmetry-fold-canonical) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./TttSymmetryFoldCanonical.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function TttSymmetryFoldCanonical() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
const TXT_CELL = '칸';
type RGB = [number, number, number];
interface Input { x: number; y: number; cur: number; key: boolean; idle: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
hit(x: number, y: number): number;
hover(i: number): void;
tap(i: number): void;
auto(): void;
rect(i: number): [number, number, number, number];
n(): number;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 팔레트 — 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG: RGB = [29, 26, 44];
const TILE: RGB = [40, 36, 62];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
const CREAM: RGB = [255, 244, 224];
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 smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 두 개로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash2(x: number, y: number, seed: number): number {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 격자점 해시 네 개를 부드럽게 섞는다
function noise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = hash2(xi, yi, seed);
const b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed);
const d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 말 하나를 (cx, cy) 에 그린다. p(0~1) 만큼 대시 오프셋으로 획을 긋는다 — who 1 은 X(두 획), 2 는 O(원)
function drawMark(ctx: CanvasRenderingContext2D, who: number, cx: number, cy: number, r: number, p: number, w: number, col: string): void {
ctx.save();
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.strokeStyle = col;
if (who === 1) {
const d = r * 2 * Math.SQRT2;
for (let k = 0; k < 2; k += 1) {
const pk = clamp(p * 2 - k, 0, 1);
if (pk <= 0.001) { continue; }
ctx.setLineDash([d, d]);
ctx.lineDashOffset = d * (1 - pk);
ctx.beginPath();
if (k === 0) { ctx.moveTo(cx - r, cy - r); ctx.lineTo(cx + r, cy + r); }
else { ctx.moveTo(cx + r, cy - r); ctx.lineTo(cx - r, cy + r); }
ctx.stroke();
}
} else {
const L = PI2 * r;
if (p > 0.001) {
ctx.setLineDash([L, L]);
ctx.lineDashOffset = L * (1 - clamp(p, 0, 1));
ctx.beginPath();
ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + PI2);
ctx.stroke();
}
}
ctx.restore();
}
function mount(root: HTMLElement, make: Maker): () => void {
const canvas = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const btnEl = root.querySelector('[data-role="btn"]') as HTMLButtonElement | null;
if (!canvas || !statusEl || !btnEl) { return function (): void { return; }; }
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
const ctx: CanvasRenderingContext2D = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: -1, y: -1, cur: -1, key: false, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let focused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw(ctx);
if (inp.key && focused && inp.cur >= 0) {
const r = scene.rect(inp.cur);
ctx.save();
ctx.lineWidth = 2.5;
ctx.strokeStyle = 'rgba(255,255,255,0.92)';
rrect(ctx, r[0] + 2, r[1] + 2, r[2] - 4, r[3] - 4, 8);
ctx.stroke();
ctx.restore();
}
let t = scene.label();
if (inp.key && inp.cur >= 0) {
const n = scene.n();
t += ' · ' + TXT_CELL + ' ' + (Math.floor(inp.cur / n) + 1) + '-' + ((inp.cur % n) + 1);
}
if (t !== lastLabel) {
lastLabel = t;
statusEl!.textContent = t;
}
}
function localPoint(e: PointerEvent): void {
const r = canvas!.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
inp.key = false;
}
function tick(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
scene.auto();
scene.auto();
scene.auto();
show();
}
listen(btnEl, 'click', function (): void {
if (reduce) { scene.auto(); show(); return; }
paused = !paused;
if (paused) { root.classList.remove('is-demo'); } else { root.classList.add('is-demo'); }
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'focus', function (): void { focused = true; });
listen(canvas, 'blur', function (): void { focused = false; if (reduce) { show(); } });
listen(canvas, 'pointermove', function (e: Event): void {
localPoint(e as PointerEvent);
scene.hover(scene.hit(inp.x, inp.y));
if (reduce) { show(); }
});
listen(canvas, 'pointerleave', function (): void { scene.hover(-1); if (reduce) { show(); } });
listen(canvas, 'pointerdown', function (e: Event): void {
localPoint(e as PointerEvent);
const i = scene.hit(inp.x, inp.y);
if (i >= 0) { scene.tap(i); }
if (reduce) { show(); }
});
listen(canvas, 'keydown', function (e: Event): void {
const ev = e as KeyboardEvent;
const k = ev.key;
const n = scene.n();
const mid = Math.floor((n * n) / 2);
if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowUp' || k === 'ArrowDown') {
ev.preventDefault();
if (inp.cur < 0) { inp.cur = mid; }
else {
let r = Math.floor(inp.cur / n);
let c = inp.cur % n;
if (k === 'ArrowLeft') { c = Math.max(0, c - 1); }
if (k === 'ArrowRight') { c = Math.min(n - 1, c + 1); }
if (k === 'ArrowUp') { r = Math.max(0, r - 1); }
if (k === 'ArrowDown') { r = Math.min(n - 1, r + 1); }
inp.cur = r * n + c;
}
inp.key = true;
inp.idle = 0;
scene.hover(inp.cur);
if (reduce) { show(); }
return;
}
if (k === 'Enter' || k === ' ') {
ev.preventDefault();
if (inp.cur < 0) { inp.cur = mid; }
inp.key = true;
inp.idle = 0;
scene.tap(inp.cur);
if (reduce) { show(); }
}
});
if (!reduce) { rafId = requestAnimationFrame(tick); }
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
};
}
const LINES: number[][] = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]];
const SCORE_WIN = 10;
const TXT_X_TURN = 'X 차례';
const TXT_O_TURN = 'O 차례';
const TXT_X_WIN = 'X 승';
const TXT_O_WIN = 'O 승';
const TXT_DRAW = '무승부';
interface Game { b: number[]; turn: number; over: boolean; line: number; }
interface Counter { n: number; x: number; o: number; }
function newGame(): Game { return { b: [0, 0, 0, 0, 0, 0, 0, 0, 0], turn: 1, over: false, line: -1 }; }
// 완성된 줄의 번호(LINES 안의 위치). 없으면 -1
function lineOf(b: number[]): number {
for (let k = 0; k < LINES.length; k += 1) {
const l = LINES[k];
if (b[l[0]] !== 0 && b[l[0]] === b[l[1]] && b[l[1]] === b[l[2]]) { return k; }
}
return -1;
}
function emptyCells(b: number[]): number[] {
const out: number[] = [];
for (let i = 0; i < 9; i += 1) { if (b[i] === 0) { out.push(i); } }
return out;
}
function place(g: Game, i: number): boolean {
if (g.over || g.b[i] !== 0) { return false; }
g.b[i] = g.turn;
g.line = lineOf(g.b);
if (g.line >= 0 || emptyCells(g.b).length === 0) { g.over = true; } else { g.turn = 3 - g.turn; }
return true;
}
function gameLabel(g: Game): string {
if (g.over) { return g.line >= 0 ? (g.b[LINES[g.line][0]] === 1 ? TXT_X_WIN : TXT_O_WIN) : TXT_DRAW; }
return g.turn === 1 ? TXT_X_TURN : TXT_O_TURN;
}
// 전수 탐색 — X 시점 점수. depth 가 0 이 되면 더 보지 않고 0 으로 친다. cnt.n 은 방문한 국면 수
function solve(b: number[], turn: number, ply: number, depth: number, cnt: Counter): number {
cnt.n += 1;
const w = lineOf(b);
if (w >= 0) {
const s = SCORE_WIN - ply;
if (b[LINES[w][0]] === 1) { cnt.x += 1; return s; }
cnt.o += 1;
return -s;
}
if (depth <= 0) { return 0; }
let best = turn === 1 ? -1000 : 1000;
let any = false;
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) { continue; }
b[i] = turn;
const v = solve(b, 3 - turn, ply + 1, depth - 1, cnt);
b[i] = 0;
any = true;
if (turn === 1 ? v > best : v < best) { best = v; }
}
return any ? best : 0;
}
// 둘 곳마다 점수. 이미 찬 칸은 NaN. 둔 사람(turn) 시점이 아니라 X 시점 값이다
function cellScores(b: number[], turn: number, depth: number, cnt: Counter, per?: Counter[]): number[] {
const out: number[] = [];
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) { out.push(NaN); if (per) { per.push({ n: 0, x: 0, o: 0 }); } continue; }
b[i] = turn;
const c: Counter = { n: 0, x: 0, o: 0 };
out.push(solve(b, 3 - turn, 1, depth - 1, c));
b[i] = 0;
cnt.n += c.n; cnt.x += c.x; cnt.o += c.o;
if (per) { per.push(c); }
}
return out;
}
function bestCell(b: number[], turn: number, depth: number): number {
const cnt: Counter = { n: 0, x: 0, o: 0 };
const sc = cellScores(b, turn, depth, cnt);
let best = -1;
for (let i = 0; i < 9; i += 1) {
if (isNaN(sc[i])) { continue; }
if (best < 0 || (turn === 1 ? sc[i] > sc[best] : sc[i] < sc[best])) { best = i; }
}
return best;
}
function gridRect(i: number, ox: number, oy: number, cs: number): [number, number, number, number] {
return [ox + (i % 3) * cs, oy + Math.floor(i / 3) * cs, cs, cs];
}
function gridHit(x: number, y: number, ox: number, oy: number, cs: number): number {
const c = Math.floor((x - ox) / cs);
const r = Math.floor((y - oy) / cs);
return c >= 0 && c < 3 && r >= 0 && r < 3 ? r * 3 + c : -1;
}
interface Match {
g: Game;
since: number;
move(i: number): void;
tap(i: number): void;
step(): void;
auto(): void;
}
const MATCH_DEPTH = 9;
const REPLY_MS = 420;
// 한 판 진행기 — 사람은 X, 컴퓨터는 O. 사람이 가만히 있으면(idle) 둘 다 컴퓨터가 두어 루프를 만든다.
// pick 이 있으면 자동 대국의 수를 그 함수가 정한다. 판이 끝나면 holdMs 뒤에 새 판
function makeMatch(inp: Input, reduce: boolean, turnMs: number, holdMs: number, seed: number, depth: number, onPlace: (i: number, who: number) => void, onReset: () => void, pick: (() => number) | null): Match {
const rng = lcg(seed);
let t = 0;
let reply = -1;
let hold = 0;
const m: Match = {
g: newGame(),
since: 0,
move(i: number): void {
const who = m.g.turn;
if (place(m.g, i)) { m.since = 0; onPlace(i, who); }
},
tap(i: number): void {
if (m.g.over) { reset(); return; }
if (m.g.turn !== 1 || m.g.b[i] !== 0) { return; }
m.move(i);
if (!m.g.over) {
if (reduce) { m.move(bestCell(m.g.b, 2, depth)); } else { reply = REPLY_MS; }
}
},
step(): void {
m.since += FIXED_DT;
if (m.g.over) {
hold += FIXED_DT;
if (hold >= holdMs) { reset(); }
return;
}
if (reply >= 0) {
reply -= FIXED_DT;
if (reply < 0) { m.move(bestCell(m.g.b, 2, depth)); }
return;
}
if (inp.idle > IDLE_TICKS) {
t += FIXED_DT;
if (t >= turnMs) { t = 0; autoMove(); }
}
},
auto(): void {
if (m.g.over) { reset(); } else { autoMove(); }
},
};
function reset(): void {
m.g = newGame();
t = 0;
reply = -1;
hold = 0;
onReset();
}
function autoMove(): void {
if (pick) { const p = pick(); if (p >= 0) { m.move(p); } return; }
const e = emptyCells(m.g.b);
if (m.g.turn === 1 && (e.length > 6 || rng() < 0.3)) { m.move(e[Math.floor(rng() * e.length)]); return; }
m.move(bestCell(m.g.b, m.g.turn, depth));
}
return m;
}
const SYM_COUNT = 8;
const FOLD_MS = 800;
const MERGE_ALPHA = 0.5;
const SPREAD_R = 104;
const SPREAD_MS = 450;
const HOLD_P = 300;
const SETTLE_MS = 260;
const BIG_SIZE = 120;
const GHOST_SIZE = 66;
const TXT_DISTINCT = '서로 다른 모양';
// 회전 4 × 반사 2 = 8 가지 칸 옮기기 — SYM[k][i] 는 i 번 칸이 k 번 변환에서 가는 칸
function buildSym(): number[][] {
const out: number[][] = [];
for (let f = 0; f < 2; f += 1) {
for (let r = 0; r < 4; r += 1) {
const perm: number[] = [];
for (let i = 0; i < 9; i += 1) {
let row = Math.floor(i / 3);
let col = i % 3;
for (let q = 0; q < r; q += 1) { const nr = col; col = 2 - row; row = nr; }
if (f === 1) { col = 2 - col; }
perm.push(row * 3 + col);
}
out.push(perm);
}
}
return out;
}
const SYM = buildSym();
function applySym(b: number[], perm: number[]): number[] {
const out: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (let i = 0; i < 9; i += 1) { out[perm[i]] = b[i]; }
return out;
}
const make: Maker = function (inp: Input, reduce: boolean): Scene {
const rng = lcg(71);
let variants: number[][] = [];
let canon = 0;
let distinct = 1;
let phase = 0;
let pt = 0;
let hov = -1;
function analyze(): void {
variants = [];
const keys: string[] = [];
for (let k = 0; k < SYM_COUNT; k += 1) { variants.push(applySym(m.g.b, SYM[k])); keys.push(variants[k].join('')); }
canon = 0;
for (let k = 1; k < SYM_COUNT; k += 1) { if (keys[k] < keys[canon]) { canon = k; } }
distinct = keys.filter(function (v: string, i: number): boolean { return keys.indexOf(v) === i; }).length;
}
const m: Match = makeMatch(inp, reduce, 1000000, 1200, 71, MATCH_DEPTH, function (): void {
analyze();
phase = 0;
pt = 0;
}, function (): void {
analyze();
phase = 0;
pt = 0;
}, null);
// 첫 판은 세 수를 둔 국면으로 시작
function warm(): void {
const e0 = emptyCells(m.g.b);
m.move(e0[Math.floor(rng() * e0.length)]);
m.move(bestCell(m.g.b, 2, MATCH_DEPTH));
const e1 = emptyCells(m.g.b);
m.move(e1[Math.floor(rng() * e1.length)]);
analyze();
}
warm();
function ringPos(k: number, s: number): [number, number] {
const a = (k / SYM_COUNT) * PI2 - Math.PI / 2 + Math.PI / SYM_COUNT;
return [W / 2 + Math.cos(a) * SPREAD_R * s, H / 2 + Math.sin(a) * SPREAD_R * s];
}
function drawBoard(ctx: CanvasRenderingContext2D, b: number[], cx: number, cy: number, size: number, alpha: number, outline: boolean): void {
const x0 = cx - size / 2;
const y0 = cy - size / 2;
const cs = size / 3;
ctx.globalAlpha = alpha;
ctx.fillStyle = rgba(TILE, 1);
rrect(ctx, x0, y0, size, size, size * 0.1);
ctx.fill();
if (outline) {
ctx.strokeStyle = rgba(MINT, 1);
ctx.lineWidth = 2;
rrect(ctx, x0, y0, size, size, size * 0.1);
ctx.stroke();
}
ctx.strokeStyle = rgba(CREAM, 0.22);
ctx.lineWidth = 1;
ctx.beginPath();
for (let q = 1; q < 3; q += 1) {
ctx.moveTo(x0 + q * cs, y0 + size * 0.08); ctx.lineTo(x0 + q * cs, y0 + size * 0.92);
ctx.moveTo(x0 + size * 0.08, y0 + q * cs); ctx.lineTo(x0 + size * 0.92, y0 + q * cs);
}
ctx.stroke();
for (let i = 0; i < 9; i += 1) {
if (b[i] === 0) { continue; }
drawMark(ctx, b[i], x0 + (i % 3) * cs + cs / 2, y0 + Math.floor(i / 3) * cs + cs / 2, cs * 0.27, 1, Math.max(size * 0.034, 2), rgba(b[i] === 1 ? SKY : PINK, 1));
}
ctx.globalAlpha = 1;
}
function phaseLen(): number { return phase === 0 ? HOLD_P : (phase === 1 ? SPREAD_MS : (phase === 2 ? FOLD_MS : SETTLE_MS)); }
return {
step(): void {
m.step();
if (m.g.over) { return; }
if (inp.idle <= IDLE_TICKS && phase === 0) { pt = 0; return; }
pt += FIXED_DT;
if (pt >= phaseLen()) {
pt = 0;
phase += 1;
if (phase > 3) {
phase = 0;
if (inp.idle > IDLE_TICKS) { m.auto(); }
}
}
},
draw(ctx: CanvasRenderingContext2D): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
const real = m.g.b;
const ph = reduce ? 0 : phase;
const q = clamp(pt / phaseLen(), 0, 1);
if (ph === 0 || reduce) {
drawBoard(ctx, real, W / 2, H / 2, BIG_SIZE + 16, 1, false);
if (reduce) {
for (let k = 0; k < SYM_COUNT; k += 1) {
const p = ringPos(k, 1);
drawBoard(ctx, variants[k], p[0], p[1], GHOST_SIZE, 0.85, k === canon);
}
}
return;
}
const spread = ph === 1 ? smooth(q) : 1;
const fold = ph === 2 ? smooth(q) : (ph === 3 ? 1 : 0);
// 중앙 판: 접히는 동안 실제 국면에서 표준 모양으로 서서히 바뀐다
drawBoard(ctx, real, W / 2, H / 2, BIG_SIZE + 16, 1 - fold, false);
if (fold > 0) { drawBoard(ctx, variants[canon], W / 2, H / 2, BIG_SIZE + 16, fold, false); }
for (let k = 0; k < SYM_COUNT; k += 1) {
const p0 = ringPos(k, spread);
const p1 = ringPos(k, 1);
const x = lerp(p0[0], W / 2, fold);
const y = lerp(p0[1], H / 2, fold);
const size = lerp(GHOST_SIZE * (0.6 + 0.4 * spread), BIG_SIZE + 16, fold);
const fade = ph === 3 ? Math.max(0, 1 - q * 4) : 1;
if (fade <= 0) { continue; }
const a = lerp(0.95, MERGE_ALPHA, fold) * fade;
drawBoard(ctx, variants[k], x, y, size, a * (1 - fold * 0.55), k === canon && fold < 0.7);
void p1;
}
},
label(): string { return gameLabel(m.g) + (phase >= 1 || reduce ? ' · ' + TXT_DISTINCT + ' ' + distinct + '/' + SYM_COUNT : ''); },
hit(x: number, y: number): number { return gridHit(x, y, W / 2 - (BIG_SIZE + 16) / 2, H / 2 - (BIG_SIZE + 16) / 2, (BIG_SIZE + 16) / 3); },
hover(i: number): void { hov = i; void hov; },
tap(i: number): void {
// 접히는 동안 가운데 판은 표준 모양으로 바뀌므로, 그동안 누른 칸은 실제 국면의 칸과 어긋난다 — 받지 않는다
if (!reduce && phase >= 2) { return; }
m.tap(i);
},
auto(): void { m.auto(); },
rect(i: number): [number, number, number, number] { return gridRect(i, W / 2 - (BIG_SIZE + 16) / 2, H / 2 - (BIG_SIZE + 16) / 2, (BIG_SIZE + 16) / 3); },
n(): number { return 3; },
};
};
return mount(root, make);
}, []);
return (
<div className={cx("tt") + " is-demo"} id="tt" ref={rootRef}>
<canvas className={cx("tt__canvas")} data-role="canvas" width="480" height="300" tabIndex={0} aria-label="돌리고 뒤집어도 같은 국면을 한 판으로 접어 겹치는 대칭 접기 보기"></canvas>
<p className={cx("tt__status")} data-role="status" aria-live="polite">X 차례</p>
<button className={cx("tt__btn")} data-role="btn" type="button">멈춤</button>
</div>
);
}07 · 回しても裏返しても同じ局面を一つの盤に折り重ねる対称の折りたたみ表示 — 計測 累積面積 29.139% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function buildSym() {
const out = [];
for (let f = 0; f < 2; f += 1) {
for (let r = 0; r < 4; r += 1) {
const perm = [];
for (let i = 0; i < 9; i += 1) {
let row = Math.floor(i / 3);
let col = i % 3;
for (let q = 0; q < r; q += 1) {
const nr = col;
col = 2 - row;
row = nr;
}
if (f === 1) {
col = 2 - col;
}
perm.push(row * 3 + col);
}
out.push(perm);
}
}
return out;
}
08現在の局面から分かれる手を枝に広げ、その先の終局の数で枝の太さが変わる木の表示
根は現在の局面を示す54pxの小さな盤で、空きマスごとに半径70pxの円の上へ26pxの子の盤が55msずつずれて広がり、160ms遅れて孫の点が出てきます。枝の太さは1に、BRANCH_W の5と終局の数の対数比を掛けた値を足したもので、最後まで打った点数が正なら空色、負ならピンク、0ならクリーム色です。この木は累積面積18.6%、平均強度75.0、動いたフレーム16/23と測れました。

<main class="stage">
<div class="tt is-demo" id="tt">
<canvas class="tt__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="現在の局面から分かれる手を枝に広げ、その先の終局の数で枝の太さが変わる木の表示"></canvas>
<p class="tt__status" data-role="status" aria-live="polite">Xの番</p>
<button class="tt__btn" data-role="btn" type="button">一時停止</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.tt {
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;
}
.tt__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.tt__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.tt__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.tt__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.tt__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.tt__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.tt__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.tt { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tt {
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;
}
.tt__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.tt__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.tt__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.tt__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.tt__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.tt__btn {
font-size: 10px;
padding: 4px 8px;
}
.tt__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.tt {
cursor: default;
}
}(function () {
const root = document.getElementById("tt");
if (!root) return;
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const IDLE_TICKS = 150;
const BTN_PAUSE = '一時停止';
const BTN_PLAY = '再生';
const BTN_NEXT = '次の場面';
const TXT_CELL = 'マス';
// 파스텔 팔레트 — 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG = [29, 26, 44];
const TILE = [40, 36, 62];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
const CREAM = [255, 244, 224];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 두 개로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash2(x, y, seed) {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 격자점 해시 네 개를 부드럽게 섞는다
function noise2(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = hash2(xi, yi, seed);
const b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed);
const d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
function rrect(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 말 하나를 (cx, cy) 에 그린다. p(0~1) 만큼 대시 오프셋으로 획을 긋는다 — who 1 은 X(두 획), 2 는 O(원)
function drawMark(ctx, who, cx, cy, r, p, w, col) {
ctx.save();
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.strokeStyle = col;
if (who === 1) {
const d = r * 2 * Math.SQRT2;
for (let k = 0; k < 2; k += 1) {
const pk = clamp(p * 2 - k, 0, 1);
if (pk <= 0.001) {
continue;
}
ctx.setLineDash([d, d]);
ctx.lineDashOffset = d * (1 - pk);
ctx.beginPath();
if (k === 0) {
ctx.moveTo(cx - r, cy - r);
ctx.lineTo(cx + r, cy + r);
}
else {
ctx.moveTo(cx + r, cy - r);
ctx.lineTo(cx - r, cy + r);
}
ctx.stroke();
}
}
else {
const L = PI2 * r;
if (p > 0.001) {
ctx.setLineDash([L, L]);
ctx.lineDashOffset = L * (1 - clamp(p, 0, 1));
ctx.beginPath();
ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + PI2);
ctx.stroke();
}
}
ctx.restore();
}
function mount(root, make) {
const canvas = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const btnEl = root.querySelector('[data-role="btn"]');
if (!canvas || !statusEl || !btnEl) {
return function () { return; };
}
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: -1, y: -1, cur: -1, key: false, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let focused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw(ctx);
if (inp.key && focused && inp.cur >= 0) {
const r = scene.rect(inp.cur);
ctx.save();
ctx.lineWidth = 2.5;
ctx.strokeStyle = 'rgba(255,255,255,0.92)';
rrect(ctx, r[0] + 2, r[1] + 2, r[2] - 4, r[3] - 4, 8);
ctx.stroke();
ctx.restore();
}
let t = scene.label();
if (inp.key && inp.cur >= 0) {
const n = scene.n();
t += ' · ' + TXT_CELL + ' ' + (Math.floor(inp.cur / n) + 1) + '-' + ((inp.cur % n) + 1);
}
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function localPoint(e) {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
inp.key = false;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
scene.auto();
scene.auto();
scene.auto();
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
scene.auto();
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'focus', function () { focused = true; });
listen(canvas, 'blur', function () { focused = false; if (reduce) {
show();
} });
listen(canvas, 'pointermove', function (e) {
localPoint(e);
scene.hover(scene.hit(inp.x, inp.y));
if (reduce) {
show();
}
});
listen(canvas, 'pointerleave', function () { scene.hover(-1); if (reduce) {
show();
} });
listen(canvas, 'pointerdown', function (e) {
localPoint(e);
const i = scene.hit(inp.x, inp.y);
if (i >= 0) {
scene.tap(i);
}
if (reduce) {
show();
}
});
listen(canvas, 'keydown', function (e) {
const ev = e;
const k = ev.key;
const n = scene.n();
const mid = Math.floor((n * n) / 2);
if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowUp' || k === 'ArrowDown') {
ev.preventDefault();
if (inp.cur < 0) {
inp.cur = mid;
}
else {
let r = Math.floor(inp.cur / n);
let c = inp.cur % n;
if (k === 'ArrowLeft') {
c = Math.max(0, c - 1);
}
if (k === 'ArrowRight') {
c = Math.min(n - 1, c + 1);
}
if (k === 'ArrowUp') {
r = Math.max(0, r - 1);
}
if (k === 'ArrowDown') {
r = Math.min(n - 1, r + 1);
}
inp.cur = r * n + c;
}
inp.key = true;
inp.idle = 0;
scene.hover(inp.cur);
if (reduce) {
show();
}
return;
}
if (k === 'Enter' || k === ' ') {
ev.preventDefault();
if (inp.cur < 0) {
inp.cur = mid;
}
inp.key = true;
inp.idle = 0;
scene.tap(inp.cur);
if (reduce) {
show();
}
}
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const LINES = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]];
const SCORE_WIN = 10;
const TXT_X_TURN = 'Xの番';
const TXT_O_TURN = 'Oの番';
const TXT_X_WIN = 'Xの勝ち';
const TXT_O_WIN = 'Oの勝ち';
const TXT_DRAW = '引き分け';
function newGame() { return { b: [0, 0, 0, 0, 0, 0, 0, 0, 0], turn: 1, over: false, line: -1 }; }
// 완성된 줄의 번호(LINES 안의 위치). 없으면 -1
function lineOf(b) {
for (let k = 0; k < LINES.length; k += 1) {
const l = LINES[k];
if (b[l[0]] !== 0 && b[l[0]] === b[l[1]] && b[l[1]] === b[l[2]]) {
return k;
}
}
return -1;
}
function emptyCells(b) {
const out = [];
for (let i = 0; i < 9; i += 1) {
if (b[i] === 0) {
out.push(i);
}
}
return out;
}
function place(g, i) {
if (g.over || g.b[i] !== 0) {
return false;
}
g.b[i] = g.turn;
g.line = lineOf(g.b);
if (g.line >= 0 || emptyCells(g.b).length === 0) {
g.over = true;
}
else {
g.turn = 3 - g.turn;
}
return true;
}
function gameLabel(g) {
if (g.over) {
return g.line >= 0 ? (g.b[LINES[g.line][0]] === 1 ? TXT_X_WIN : TXT_O_WIN) : TXT_DRAW;
}
return g.turn === 1 ? TXT_X_TURN : TXT_O_TURN;
}
// 전수 탐색 — X 시점 점수. depth 가 0 이 되면 더 보지 않고 0 으로 친다. cnt.n 은 방문한 국면 수
function solve(b, turn, ply, depth, cnt) {
cnt.n += 1;
const w = lineOf(b);
if (w >= 0) {
const s = SCORE_WIN - ply;
if (b[LINES[w][0]] === 1) {
cnt.x += 1;
return s;
}
cnt.o += 1;
return -s;
}
if (depth <= 0) {
return 0;
}
let best = turn === 1 ? -1000 : 1000;
let any = false;
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) {
continue;
}
b[i] = turn;
const v = solve(b, 3 - turn, ply + 1, depth - 1, cnt);
b[i] = 0;
any = true;
if (turn === 1 ? v > best : v < best) {
best = v;
}
}
return any ? best : 0;
}
// 둘 곳마다 점수. 이미 찬 칸은 NaN. 둔 사람(turn) 시점이 아니라 X 시점 값이다
function cellScores(b, turn, depth, cnt, per) {
const out = [];
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) {
out.push(NaN);
if (per) {
per.push({ n: 0, x: 0, o: 0 });
}
continue;
}
b[i] = turn;
const c = { n: 0, x: 0, o: 0 };
out.push(solve(b, 3 - turn, 1, depth - 1, c));
b[i] = 0;
cnt.n += c.n;
cnt.x += c.x;
cnt.o += c.o;
if (per) {
per.push(c);
}
}
return out;
}
function bestCell(b, turn, depth) {
const cnt = { n: 0, x: 0, o: 0 };
const sc = cellScores(b, turn, depth, cnt);
let best = -1;
for (let i = 0; i < 9; i += 1) {
if (isNaN(sc[i])) {
continue;
}
if (best < 0 || (turn === 1 ? sc[i] > sc[best] : sc[i] < sc[best])) {
best = i;
}
}
return best;
}
function gridRect(i, ox, oy, cs) {
return [ox + (i % 3) * cs, oy + Math.floor(i / 3) * cs, cs, cs];
}
function gridHit(x, y, ox, oy, cs) {
const c = Math.floor((x - ox) / cs);
const r = Math.floor((y - oy) / cs);
return c >= 0 && c < 3 && r >= 0 && r < 3 ? r * 3 + c : -1;
}
const MATCH_DEPTH = 9;
const REPLY_MS = 420;
// 한 판 진행기 — 사람은 X, 컴퓨터는 O. 사람이 가만히 있으면(idle) 둘 다 컴퓨터가 두어 루프를 만든다.
// pick 이 있으면 자동 대국의 수를 그 함수가 정한다. 판이 끝나면 holdMs 뒤에 새 판
function makeMatch(inp, reduce, turnMs, holdMs, seed, depth, onPlace, onReset, pick) {
const rng = lcg(seed);
let t = 0;
let reply = -1;
let hold = 0;
const m = {
g: newGame(),
since: 0,
move(i) {
const who = m.g.turn;
if (place(m.g, i)) {
m.since = 0;
onPlace(i, who);
}
},
tap(i) {
if (m.g.over) {
reset();
return;
}
if (m.g.turn !== 1 || m.g.b[i] !== 0) {
return;
}
m.move(i);
if (!m.g.over) {
if (reduce) {
m.move(bestCell(m.g.b, 2, depth));
}
else {
reply = REPLY_MS;
}
}
},
step() {
m.since += FIXED_DT;
if (m.g.over) {
hold += FIXED_DT;
if (hold >= holdMs) {
reset();
}
return;
}
if (reply >= 0) {
reply -= FIXED_DT;
if (reply < 0) {
m.move(bestCell(m.g.b, 2, depth));
}
return;
}
if (inp.idle > IDLE_TICKS) {
t += FIXED_DT;
if (t >= turnMs) {
t = 0;
autoMove();
}
}
},
auto() {
if (m.g.over) {
reset();
}
else {
autoMove();
}
},
};
function reset() {
m.g = newGame();
t = 0;
reply = -1;
hold = 0;
onReset();
}
function autoMove() {
if (pick) {
const p = pick();
if (p >= 0) {
m.move(p);
}
return;
}
const e = emptyCells(m.g.b);
if (m.g.turn === 1 && (e.length > 6 || rng() < 0.3)) {
m.move(e[Math.floor(rng() * e.length)]);
return;
}
m.move(bestCell(m.g.b, m.g.turn, depth));
}
return m;
}
const RING_R = 70;
const NODE_R = 3.2;
const BRANCH_W = 5;
const EXPAND_MS = 520;
const TURN_MS = 1700;
const HOLD_MS = 1500;
const OUTER_R = 120;
const ROOT_SIZE = 54;
const KID_SIZE = 26;
// 이 국면에서 이어질 수 있는 끝판의 수
function countLeaves(b, turn) {
if (lineOf(b) >= 0) {
return 1;
}
let sum = 0;
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) {
continue;
}
b[i] = turn;
sum += countLeaves(b, 3 - turn);
b[i] = 0;
}
return sum === 0 ? 1 : sum;
}
function makeNode(b, cell, turn, deep) {
const nb = b.slice();
nb[cell] = turn;
const cnt = { n: 0, x: 0, o: 0 };
const node = { cell: cell, leaves: countLeaves(nb.slice(), 3 - turn), val: solve(nb.slice(), 3 - turn, 1, 9, cnt), b: nb, kids: [] };
if (deep && lineOf(nb) < 0) {
for (let j = 0; j < 9; j += 1) {
if (nb[j] === 0) {
node.kids.push(makeNode(nb, j, 3 - turn, false));
}
}
}
return node;
}
const make = function (inp, reduce) {
let kids = [];
let maxK = 1;
let maxG = 1;
let expandT = 0;
let hov = -1;
let angles = [];
const m = makeMatch(inp, reduce, TURN_MS, HOLD_MS, 81, MATCH_DEPTH, function () { rebuild(); }, function () { rebuild(); }, null);
function rebuild() {
kids = [];
maxK = 1;
maxG = 1;
if (!m.g.over) {
for (let i = 0; i < 9; i += 1) {
if (m.g.b[i] === 0) {
kids.push(makeNode(m.g.b, i, m.g.turn, true));
}
}
}
for (let k = 0; k < kids.length; k += 1) {
maxK = Math.max(maxK, kids[k].leaves);
for (let g = 0; g < kids[k].kids.length; g += 1) {
maxG = Math.max(maxG, kids[k].kids[g].leaves);
}
}
angles = kids.map(function (_n, k) { return -Math.PI / 2 + (k / kids.length) * PI2; });
expandT = reduce ? 99999 : 0;
}
function kidPos(k) { return [W / 2 + Math.cos(angles[k]) * RING_R, H / 2 + Math.sin(angles[k]) * RING_R]; }
function valCol(v) { return v > 0 ? SKY : (v < 0 ? PINK : CREAM); }
function drawMini(ctx, b, cx, cy, size, newCell, col, alpha) {
const x0 = cx - size / 2;
const y0 = cy - size / 2;
const cs = size / 3;
ctx.globalAlpha = alpha;
ctx.fillStyle = rgba(TILE, 1);
rrect(ctx, x0, y0, size, size, 5);
ctx.fill();
ctx.strokeStyle = rgba(col, 0.95);
ctx.lineWidth = 1.6;
rrect(ctx, x0, y0, size, size, 5);
ctx.stroke();
for (let i = 0; i < 9; i += 1) {
if (b[i] === 0) {
continue;
}
const mc = rgba(b[i] === 1 ? SKY : PINK, i === newCell ? 1 : 0.7);
drawMark(ctx, b[i], x0 + (i % 3) * cs + cs / 2, y0 + Math.floor(i / 3) * cs + cs / 2, cs * 0.26, 1, Math.max(size * 0.045, 1.4), mc);
}
ctx.globalAlpha = 1;
}
function branch(ctx, x0, y0, x1, y1, leaves, mx, prog, col, hot) {
const w = 1 + BRANCH_W * (Math.log(1 + leaves) / Math.log(1 + mx));
ctx.strokeStyle = rgba(col, hot ? 0.95 : 0.55);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(lerp(x0, x1, prog), lerp(y0, y1, prog));
ctx.stroke();
}
rebuild();
m.move(emptyCells(m.g.b)[4]);
m.move(bestCell(m.g.b, 2, MATCH_DEPTH));
return {
step() {
expandT += FIXED_DT;
m.step();
},
draw(ctx) {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
const n = kids.length;
const cxr = W / 2;
const cyr = H / 2;
for (let k = 0; k < n; k += 1) {
const kp = kidPos(k);
const pk = smooth((expandT - k * 55) / EXPAND_MS);
const hot = hov === kids[k].cell;
branch(ctx, cxr, cyr, kp[0], kp[1], kids[k].leaves, maxK, pk, valCol(kids[k].val), hot);
const nk = kids[k].kids.length;
const spread = Math.min((PI2 / n) * 0.86, 1.05);
for (let g = 0; g < nk; g += 1) {
const pg = smooth((expandT - 160 - k * 55 - g * 18) / EXPAND_MS);
if (pg <= 0) {
continue;
}
const ga = angles[k] + (nk > 1 ? (g / (nk - 1) - 0.5) * spread : 0);
const rr = OUTER_R + (g % 2) * 7;
const gx = cxr + Math.cos(ga) * rr;
const gy = cyr + Math.sin(ga) * rr;
const gc = valCol(kids[k].kids[g].val);
branch(ctx, kp[0], kp[1], gx, gy, kids[k].kids[g].leaves, maxG, pg, gc, hot);
ctx.fillStyle = rgba(gc, 0.9 * pg);
ctx.beginPath();
ctx.arc(lerp(kp[0], gx, pg), lerp(kp[1], gy, pg), NODE_R * pg, 0, PI2);
ctx.fill();
}
}
drawMini(ctx, m.g.b, cxr, cyr, ROOT_SIZE, -1, mix(LILAC, CREAM, 0.3), 1);
for (let k = 0; k < n; k += 1) {
const kp = kidPos(k);
const pk = smooth((expandT - k * 55) / EXPAND_MS);
if (pk <= 0) {
continue;
}
const x = lerp(cxr, kp[0], pk);
const y = lerp(cyr, kp[1], pk);
const hot = hov === kids[k].cell;
drawMini(ctx, kids[k].b, x, y, hot ? KID_SIZE + 6 : KID_SIZE, kids[k].cell, valCol(kids[k].val), pk);
}
},
label() { return gameLabel(m.g); },
hit(x, y) {
for (let k = 0; k < kids.length; k += 1) {
const kp = kidPos(k);
if (Math.abs(x - kp[0]) <= KID_SIZE / 2 + 3 && Math.abs(y - kp[1]) <= KID_SIZE / 2 + 3) {
return kids[k].cell;
}
}
return -1;
},
hover(i) { hov = i; },
tap(i) { m.tap(i); },
auto() { m.auto(); },
rect(i) {
for (let k = 0; k < kids.length; k += 1) {
if (kids[k].cell === i) {
const kp = kidPos(k);
return [kp[0] - KID_SIZE / 2, kp[1] - KID_SIZE / 2, KID_SIZE, KID_SIZE];
}
}
return gridRect(i, W / 2 - ROOT_SIZE / 2, H / 2 - ROOT_SIZE / 2, ROOT_SIZE / 3);
},
n() { return 3; },
};
};
return mount(root, make);
})();// TttGameTreeBranchExpand - vanilla 판(08-ttt-game-tree-branch-expand) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./TttGameTreeBranchExpand.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function TttGameTreeBranchExpand() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
const TXT_CELL = '칸';
type RGB = [number, number, number];
interface Input { x: number; y: number; cur: number; key: boolean; idle: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
hit(x: number, y: number): number;
hover(i: number): void;
tap(i: number): void;
auto(): void;
rect(i: number): [number, number, number, number];
n(): number;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 팔레트 — 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG: RGB = [29, 26, 44];
const TILE: RGB = [40, 36, 62];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
const CREAM: RGB = [255, 244, 224];
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 smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 두 개로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash2(x: number, y: number, seed: number): number {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 격자점 해시 네 개를 부드럽게 섞는다
function noise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = hash2(xi, yi, seed);
const b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed);
const d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 말 하나를 (cx, cy) 에 그린다. p(0~1) 만큼 대시 오프셋으로 획을 긋는다 — who 1 은 X(두 획), 2 는 O(원)
function drawMark(ctx: CanvasRenderingContext2D, who: number, cx: number, cy: number, r: number, p: number, w: number, col: string): void {
ctx.save();
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.strokeStyle = col;
if (who === 1) {
const d = r * 2 * Math.SQRT2;
for (let k = 0; k < 2; k += 1) {
const pk = clamp(p * 2 - k, 0, 1);
if (pk <= 0.001) { continue; }
ctx.setLineDash([d, d]);
ctx.lineDashOffset = d * (1 - pk);
ctx.beginPath();
if (k === 0) { ctx.moveTo(cx - r, cy - r); ctx.lineTo(cx + r, cy + r); }
else { ctx.moveTo(cx + r, cy - r); ctx.lineTo(cx - r, cy + r); }
ctx.stroke();
}
} else {
const L = PI2 * r;
if (p > 0.001) {
ctx.setLineDash([L, L]);
ctx.lineDashOffset = L * (1 - clamp(p, 0, 1));
ctx.beginPath();
ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + PI2);
ctx.stroke();
}
}
ctx.restore();
}
function mount(root: HTMLElement, make: Maker): () => void {
const canvas = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const btnEl = root.querySelector('[data-role="btn"]') as HTMLButtonElement | null;
if (!canvas || !statusEl || !btnEl) { return function (): void { return; }; }
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
const ctx: CanvasRenderingContext2D = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: -1, y: -1, cur: -1, key: false, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let focused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw(ctx);
if (inp.key && focused && inp.cur >= 0) {
const r = scene.rect(inp.cur);
ctx.save();
ctx.lineWidth = 2.5;
ctx.strokeStyle = 'rgba(255,255,255,0.92)';
rrect(ctx, r[0] + 2, r[1] + 2, r[2] - 4, r[3] - 4, 8);
ctx.stroke();
ctx.restore();
}
let t = scene.label();
if (inp.key && inp.cur >= 0) {
const n = scene.n();
t += ' · ' + TXT_CELL + ' ' + (Math.floor(inp.cur / n) + 1) + '-' + ((inp.cur % n) + 1);
}
if (t !== lastLabel) {
lastLabel = t;
statusEl!.textContent = t;
}
}
function localPoint(e: PointerEvent): void {
const r = canvas!.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
inp.key = false;
}
function tick(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
scene.auto();
scene.auto();
scene.auto();
show();
}
listen(btnEl, 'click', function (): void {
if (reduce) { scene.auto(); show(); return; }
paused = !paused;
if (paused) { root.classList.remove('is-demo'); } else { root.classList.add('is-demo'); }
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'focus', function (): void { focused = true; });
listen(canvas, 'blur', function (): void { focused = false; if (reduce) { show(); } });
listen(canvas, 'pointermove', function (e: Event): void {
localPoint(e as PointerEvent);
scene.hover(scene.hit(inp.x, inp.y));
if (reduce) { show(); }
});
listen(canvas, 'pointerleave', function (): void { scene.hover(-1); if (reduce) { show(); } });
listen(canvas, 'pointerdown', function (e: Event): void {
localPoint(e as PointerEvent);
const i = scene.hit(inp.x, inp.y);
if (i >= 0) { scene.tap(i); }
if (reduce) { show(); }
});
listen(canvas, 'keydown', function (e: Event): void {
const ev = e as KeyboardEvent;
const k = ev.key;
const n = scene.n();
const mid = Math.floor((n * n) / 2);
if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowUp' || k === 'ArrowDown') {
ev.preventDefault();
if (inp.cur < 0) { inp.cur = mid; }
else {
let r = Math.floor(inp.cur / n);
let c = inp.cur % n;
if (k === 'ArrowLeft') { c = Math.max(0, c - 1); }
if (k === 'ArrowRight') { c = Math.min(n - 1, c + 1); }
if (k === 'ArrowUp') { r = Math.max(0, r - 1); }
if (k === 'ArrowDown') { r = Math.min(n - 1, r + 1); }
inp.cur = r * n + c;
}
inp.key = true;
inp.idle = 0;
scene.hover(inp.cur);
if (reduce) { show(); }
return;
}
if (k === 'Enter' || k === ' ') {
ev.preventDefault();
if (inp.cur < 0) { inp.cur = mid; }
inp.key = true;
inp.idle = 0;
scene.tap(inp.cur);
if (reduce) { show(); }
}
});
if (!reduce) { rafId = requestAnimationFrame(tick); }
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
};
}
const LINES: number[][] = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]];
const SCORE_WIN = 10;
const TXT_X_TURN = 'X 차례';
const TXT_O_TURN = 'O 차례';
const TXT_X_WIN = 'X 승';
const TXT_O_WIN = 'O 승';
const TXT_DRAW = '무승부';
interface Game { b: number[]; turn: number; over: boolean; line: number; }
interface Counter { n: number; x: number; o: number; }
function newGame(): Game { return { b: [0, 0, 0, 0, 0, 0, 0, 0, 0], turn: 1, over: false, line: -1 }; }
// 완성된 줄의 번호(LINES 안의 위치). 없으면 -1
function lineOf(b: number[]): number {
for (let k = 0; k < LINES.length; k += 1) {
const l = LINES[k];
if (b[l[0]] !== 0 && b[l[0]] === b[l[1]] && b[l[1]] === b[l[2]]) { return k; }
}
return -1;
}
function emptyCells(b: number[]): number[] {
const out: number[] = [];
for (let i = 0; i < 9; i += 1) { if (b[i] === 0) { out.push(i); } }
return out;
}
function place(g: Game, i: number): boolean {
if (g.over || g.b[i] !== 0) { return false; }
g.b[i] = g.turn;
g.line = lineOf(g.b);
if (g.line >= 0 || emptyCells(g.b).length === 0) { g.over = true; } else { g.turn = 3 - g.turn; }
return true;
}
function gameLabel(g: Game): string {
if (g.over) { return g.line >= 0 ? (g.b[LINES[g.line][0]] === 1 ? TXT_X_WIN : TXT_O_WIN) : TXT_DRAW; }
return g.turn === 1 ? TXT_X_TURN : TXT_O_TURN;
}
// 전수 탐색 — X 시점 점수. depth 가 0 이 되면 더 보지 않고 0 으로 친다. cnt.n 은 방문한 국면 수
function solve(b: number[], turn: number, ply: number, depth: number, cnt: Counter): number {
cnt.n += 1;
const w = lineOf(b);
if (w >= 0) {
const s = SCORE_WIN - ply;
if (b[LINES[w][0]] === 1) { cnt.x += 1; return s; }
cnt.o += 1;
return -s;
}
if (depth <= 0) { return 0; }
let best = turn === 1 ? -1000 : 1000;
let any = false;
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) { continue; }
b[i] = turn;
const v = solve(b, 3 - turn, ply + 1, depth - 1, cnt);
b[i] = 0;
any = true;
if (turn === 1 ? v > best : v < best) { best = v; }
}
return any ? best : 0;
}
// 둘 곳마다 점수. 이미 찬 칸은 NaN. 둔 사람(turn) 시점이 아니라 X 시점 값이다
function cellScores(b: number[], turn: number, depth: number, cnt: Counter, per?: Counter[]): number[] {
const out: number[] = [];
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) { out.push(NaN); if (per) { per.push({ n: 0, x: 0, o: 0 }); } continue; }
b[i] = turn;
const c: Counter = { n: 0, x: 0, o: 0 };
out.push(solve(b, 3 - turn, 1, depth - 1, c));
b[i] = 0;
cnt.n += c.n; cnt.x += c.x; cnt.o += c.o;
if (per) { per.push(c); }
}
return out;
}
function bestCell(b: number[], turn: number, depth: number): number {
const cnt: Counter = { n: 0, x: 0, o: 0 };
const sc = cellScores(b, turn, depth, cnt);
let best = -1;
for (let i = 0; i < 9; i += 1) {
if (isNaN(sc[i])) { continue; }
if (best < 0 || (turn === 1 ? sc[i] > sc[best] : sc[i] < sc[best])) { best = i; }
}
return best;
}
function gridRect(i: number, ox: number, oy: number, cs: number): [number, number, number, number] {
return [ox + (i % 3) * cs, oy + Math.floor(i / 3) * cs, cs, cs];
}
function gridHit(x: number, y: number, ox: number, oy: number, cs: number): number {
const c = Math.floor((x - ox) / cs);
const r = Math.floor((y - oy) / cs);
return c >= 0 && c < 3 && r >= 0 && r < 3 ? r * 3 + c : -1;
}
interface Match {
g: Game;
since: number;
move(i: number): void;
tap(i: number): void;
step(): void;
auto(): void;
}
const MATCH_DEPTH = 9;
const REPLY_MS = 420;
// 한 판 진행기 — 사람은 X, 컴퓨터는 O. 사람이 가만히 있으면(idle) 둘 다 컴퓨터가 두어 루프를 만든다.
// pick 이 있으면 자동 대국의 수를 그 함수가 정한다. 판이 끝나면 holdMs 뒤에 새 판
function makeMatch(inp: Input, reduce: boolean, turnMs: number, holdMs: number, seed: number, depth: number, onPlace: (i: number, who: number) => void, onReset: () => void, pick: (() => number) | null): Match {
const rng = lcg(seed);
let t = 0;
let reply = -1;
let hold = 0;
const m: Match = {
g: newGame(),
since: 0,
move(i: number): void {
const who = m.g.turn;
if (place(m.g, i)) { m.since = 0; onPlace(i, who); }
},
tap(i: number): void {
if (m.g.over) { reset(); return; }
if (m.g.turn !== 1 || m.g.b[i] !== 0) { return; }
m.move(i);
if (!m.g.over) {
if (reduce) { m.move(bestCell(m.g.b, 2, depth)); } else { reply = REPLY_MS; }
}
},
step(): void {
m.since += FIXED_DT;
if (m.g.over) {
hold += FIXED_DT;
if (hold >= holdMs) { reset(); }
return;
}
if (reply >= 0) {
reply -= FIXED_DT;
if (reply < 0) { m.move(bestCell(m.g.b, 2, depth)); }
return;
}
if (inp.idle > IDLE_TICKS) {
t += FIXED_DT;
if (t >= turnMs) { t = 0; autoMove(); }
}
},
auto(): void {
if (m.g.over) { reset(); } else { autoMove(); }
},
};
function reset(): void {
m.g = newGame();
t = 0;
reply = -1;
hold = 0;
onReset();
}
function autoMove(): void {
if (pick) { const p = pick(); if (p >= 0) { m.move(p); } return; }
const e = emptyCells(m.g.b);
if (m.g.turn === 1 && (e.length > 6 || rng() < 0.3)) { m.move(e[Math.floor(rng() * e.length)]); return; }
m.move(bestCell(m.g.b, m.g.turn, depth));
}
return m;
}
const RING_R = 70;
const NODE_R = 3.2;
const BRANCH_W = 5;
const EXPAND_MS = 520;
const TURN_MS = 1700;
const HOLD_MS = 1500;
const OUTER_R = 120;
const ROOT_SIZE = 54;
const KID_SIZE = 26;
interface TreeNode { cell: number; leaves: number; val: number; b: number[]; kids: TreeNode[]; }
// 이 국면에서 이어질 수 있는 끝판의 수
function countLeaves(b: number[], turn: number): number {
if (lineOf(b) >= 0) { return 1; }
let sum = 0;
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) { continue; }
b[i] = turn;
sum += countLeaves(b, 3 - turn);
b[i] = 0;
}
return sum === 0 ? 1 : sum;
}
function makeNode(b: number[], cell: number, turn: number, deep: boolean): TreeNode {
const nb = b.slice();
nb[cell] = turn;
const cnt: Counter = { n: 0, x: 0, o: 0 };
const node: TreeNode = { cell: cell, leaves: countLeaves(nb.slice(), 3 - turn), val: solve(nb.slice(), 3 - turn, 1, 9, cnt), b: nb, kids: [] };
if (deep && lineOf(nb) < 0) {
for (let j = 0; j < 9; j += 1) { if (nb[j] === 0) { node.kids.push(makeNode(nb, j, 3 - turn, false)); } }
}
return node;
}
const make: Maker = function (inp: Input, reduce: boolean): Scene {
let kids: TreeNode[] = [];
let maxK = 1;
let maxG = 1;
let expandT = 0;
let hov = -1;
let angles: number[] = [];
const m: Match = makeMatch(inp, reduce, TURN_MS, HOLD_MS, 81, MATCH_DEPTH, function (): void { rebuild(); }, function (): void { rebuild(); }, null);
function rebuild(): void {
kids = [];
maxK = 1;
maxG = 1;
if (!m.g.over) {
for (let i = 0; i < 9; i += 1) { if (m.g.b[i] === 0) { kids.push(makeNode(m.g.b, i, m.g.turn, true)); } }
}
for (let k = 0; k < kids.length; k += 1) {
maxK = Math.max(maxK, kids[k].leaves);
for (let g = 0; g < kids[k].kids.length; g += 1) { maxG = Math.max(maxG, kids[k].kids[g].leaves); }
}
angles = kids.map(function (_n: TreeNode, k: number): number { return -Math.PI / 2 + (k / kids.length) * PI2; });
expandT = reduce ? 99999 : 0;
}
function kidPos(k: number): [number, number] { return [W / 2 + Math.cos(angles[k]) * RING_R, H / 2 + Math.sin(angles[k]) * RING_R]; }
function valCol(v: number): RGB { return v > 0 ? SKY : (v < 0 ? PINK : CREAM); }
function drawMini(ctx: CanvasRenderingContext2D, b: number[], cx: number, cy: number, size: number, newCell: number, col: RGB, alpha: number): void {
const x0 = cx - size / 2;
const y0 = cy - size / 2;
const cs = size / 3;
ctx.globalAlpha = alpha;
ctx.fillStyle = rgba(TILE, 1);
rrect(ctx, x0, y0, size, size, 5);
ctx.fill();
ctx.strokeStyle = rgba(col, 0.95);
ctx.lineWidth = 1.6;
rrect(ctx, x0, y0, size, size, 5);
ctx.stroke();
for (let i = 0; i < 9; i += 1) {
if (b[i] === 0) { continue; }
const mc = rgba(b[i] === 1 ? SKY : PINK, i === newCell ? 1 : 0.7);
drawMark(ctx, b[i], x0 + (i % 3) * cs + cs / 2, y0 + Math.floor(i / 3) * cs + cs / 2, cs * 0.26, 1, Math.max(size * 0.045, 1.4), mc);
}
ctx.globalAlpha = 1;
}
function branch(ctx: CanvasRenderingContext2D, x0: number, y0: number, x1: number, y1: number, leaves: number, mx: number, prog: number, col: RGB, hot: boolean): void {
const w = 1 + BRANCH_W * (Math.log(1 + leaves) / Math.log(1 + mx));
ctx.strokeStyle = rgba(col, hot ? 0.95 : 0.55);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(lerp(x0, x1, prog), lerp(y0, y1, prog));
ctx.stroke();
}
rebuild();
m.move(emptyCells(m.g.b)[4]);
m.move(bestCell(m.g.b, 2, MATCH_DEPTH));
return {
step(): void {
expandT += FIXED_DT;
m.step();
},
draw(ctx: CanvasRenderingContext2D): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
const n = kids.length;
const cxr = W / 2;
const cyr = H / 2;
for (let k = 0; k < n; k += 1) {
const kp = kidPos(k);
const pk = smooth((expandT - k * 55) / EXPAND_MS);
const hot = hov === kids[k].cell;
branch(ctx, cxr, cyr, kp[0], kp[1], kids[k].leaves, maxK, pk, valCol(kids[k].val), hot);
const nk = kids[k].kids.length;
const spread = Math.min((PI2 / n) * 0.86, 1.05);
for (let g = 0; g < nk; g += 1) {
const pg = smooth((expandT - 160 - k * 55 - g * 18) / EXPAND_MS);
if (pg <= 0) { continue; }
const ga = angles[k] + (nk > 1 ? (g / (nk - 1) - 0.5) * spread : 0);
const rr = OUTER_R + (g % 2) * 7;
const gx = cxr + Math.cos(ga) * rr;
const gy = cyr + Math.sin(ga) * rr;
const gc = valCol(kids[k].kids[g].val);
branch(ctx, kp[0], kp[1], gx, gy, kids[k].kids[g].leaves, maxG, pg, gc, hot);
ctx.fillStyle = rgba(gc, 0.9 * pg);
ctx.beginPath();
ctx.arc(lerp(kp[0], gx, pg), lerp(kp[1], gy, pg), NODE_R * pg, 0, PI2);
ctx.fill();
}
}
drawMini(ctx, m.g.b, cxr, cyr, ROOT_SIZE, -1, mix(LILAC, CREAM, 0.3), 1);
for (let k = 0; k < n; k += 1) {
const kp = kidPos(k);
const pk = smooth((expandT - k * 55) / EXPAND_MS);
if (pk <= 0) { continue; }
const x = lerp(cxr, kp[0], pk);
const y = lerp(cyr, kp[1], pk);
const hot = hov === kids[k].cell;
drawMini(ctx, kids[k].b, x, y, hot ? KID_SIZE + 6 : KID_SIZE, kids[k].cell, valCol(kids[k].val), pk);
}
},
label(): string { return gameLabel(m.g); },
hit(x: number, y: number): number {
for (let k = 0; k < kids.length; k += 1) {
const kp = kidPos(k);
if (Math.abs(x - kp[0]) <= KID_SIZE / 2 + 3 && Math.abs(y - kp[1]) <= KID_SIZE / 2 + 3) { return kids[k].cell; }
}
return -1;
},
hover(i: number): void { hov = i; },
tap(i: number): void { m.tap(i); },
auto(): void { m.auto(); },
rect(i: number): [number, number, number, number] {
for (let k = 0; k < kids.length; k += 1) {
if (kids[k].cell === i) { const kp = kidPos(k); return [kp[0] - KID_SIZE / 2, kp[1] - KID_SIZE / 2, KID_SIZE, KID_SIZE]; }
}
return gridRect(i, W / 2 - ROOT_SIZE / 2, H / 2 - ROOT_SIZE / 2, ROOT_SIZE / 3);
},
n(): number { return 3; },
};
};
return mount(root, make);
}, []);
return (
<div className={cx("tt") + " is-demo"} id="tt" ref={rootRef}>
<canvas className={cx("tt__canvas")} data-role="canvas" width="480" height="300" tabIndex={0} aria-label="현재 국면에서 갈라지는 수를 가지로 펼치고 그 아래 끝판 수에 따라 가지 굵기가 달라지는 나무 보기"></canvas>
<p className={cx("tt__status")} data-role="status" aria-live="polite">X 차례</p>
<button className={cx("tt__btn")} data-role="btn" type="button">멈춤</button>
</div>
);
}08 · 現在の局面から分かれる手を枝に広げ、その先の終局の数で枝の太さが変わる木の表示 — 計測 累積面積 18.648% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function branch(ctx, x0, y0, x1, y1, leaves, mx, prog, col, hot) {
const w = 1 + BRANCH_W * (Math.log(1 + leaves) / Math.log(1 + mx));
ctx.strokeStyle = rgba(col, hot ? 0.95 : 0.55);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(lerp(x0, x1, prog), lerp(y0, y1, prog));
ctx.stroke();
}
09チョーク質感の黒板で二つのAIが自ら打ち、粉が舞い、黒板消しで消されていくヒーロー
チョークの線は直線を5pxごとに区切り、垂直方向へ値ノイズ × 1.8pxを足して揺らし、粉は90個のプールから、駒を引くティックごとに2個ずつ生まれて重力と風を受けます。最後の一手の400ms後に黒板消しが800msかけて x 74 から406まで掃き、盤を空にして、このとき帯の右側だけ駒を描くので、通った跡が消えて見えます。この黒板は累積面積45.6%、平均強度85.6、動いたフレーム22/23でした。

<main class="stage">
<div class="tt is-demo" id="tt">
<canvas class="tt__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="チョーク質感の黒板で二つのAIが自ら打ち、粉が舞い、黒板消しで消されていくヒーロー"></canvas>
<p class="tt__status" data-role="status" aria-live="polite">Xの番</p>
<button class="tt__btn" data-role="btn" type="button">一時停止</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.tt {
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;
}
.tt__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.tt__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.tt__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.tt__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.tt__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.tt__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.tt__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.tt { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tt {
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;
}
.tt__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.tt__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.tt__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.tt__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.tt__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.tt__btn {
font-size: 10px;
padding: 4px 8px;
}
.tt__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.tt {
cursor: default;
}
}(function () {
const root = document.getElementById("tt");
if (!root) return;
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const IDLE_TICKS = 150;
const BTN_PAUSE = '一時停止';
const BTN_PLAY = '再生';
const BTN_NEXT = '次の場面';
const TXT_CELL = 'マス';
// 파스텔 팔레트 — 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG = [29, 26, 44];
const TILE = [40, 36, 62];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
const CREAM = [255, 244, 224];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 두 개로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash2(x, y, seed) {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 격자점 해시 네 개를 부드럽게 섞는다
function noise2(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = hash2(xi, yi, seed);
const b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed);
const d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
function rrect(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 말 하나를 (cx, cy) 에 그린다. p(0~1) 만큼 대시 오프셋으로 획을 긋는다 — who 1 은 X(두 획), 2 는 O(원)
function drawMark(ctx, who, cx, cy, r, p, w, col) {
ctx.save();
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.strokeStyle = col;
if (who === 1) {
const d = r * 2 * Math.SQRT2;
for (let k = 0; k < 2; k += 1) {
const pk = clamp(p * 2 - k, 0, 1);
if (pk <= 0.001) {
continue;
}
ctx.setLineDash([d, d]);
ctx.lineDashOffset = d * (1 - pk);
ctx.beginPath();
if (k === 0) {
ctx.moveTo(cx - r, cy - r);
ctx.lineTo(cx + r, cy + r);
}
else {
ctx.moveTo(cx + r, cy - r);
ctx.lineTo(cx - r, cy + r);
}
ctx.stroke();
}
}
else {
const L = PI2 * r;
if (p > 0.001) {
ctx.setLineDash([L, L]);
ctx.lineDashOffset = L * (1 - clamp(p, 0, 1));
ctx.beginPath();
ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + PI2);
ctx.stroke();
}
}
ctx.restore();
}
function mount(root, make) {
const canvas = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const btnEl = root.querySelector('[data-role="btn"]');
if (!canvas || !statusEl || !btnEl) {
return function () { return; };
}
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: -1, y: -1, cur: -1, key: false, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let focused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw(ctx);
if (inp.key && focused && inp.cur >= 0) {
const r = scene.rect(inp.cur);
ctx.save();
ctx.lineWidth = 2.5;
ctx.strokeStyle = 'rgba(255,255,255,0.92)';
rrect(ctx, r[0] + 2, r[1] + 2, r[2] - 4, r[3] - 4, 8);
ctx.stroke();
ctx.restore();
}
let t = scene.label();
if (inp.key && inp.cur >= 0) {
const n = scene.n();
t += ' · ' + TXT_CELL + ' ' + (Math.floor(inp.cur / n) + 1) + '-' + ((inp.cur % n) + 1);
}
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function localPoint(e) {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
inp.key = false;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
scene.auto();
scene.auto();
scene.auto();
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
scene.auto();
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'focus', function () { focused = true; });
listen(canvas, 'blur', function () { focused = false; if (reduce) {
show();
} });
listen(canvas, 'pointermove', function (e) {
localPoint(e);
scene.hover(scene.hit(inp.x, inp.y));
if (reduce) {
show();
}
});
listen(canvas, 'pointerleave', function () { scene.hover(-1); if (reduce) {
show();
} });
listen(canvas, 'pointerdown', function (e) {
localPoint(e);
const i = scene.hit(inp.x, inp.y);
if (i >= 0) {
scene.tap(i);
}
if (reduce) {
show();
}
});
listen(canvas, 'keydown', function (e) {
const ev = e;
const k = ev.key;
const n = scene.n();
const mid = Math.floor((n * n) / 2);
if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowUp' || k === 'ArrowDown') {
ev.preventDefault();
if (inp.cur < 0) {
inp.cur = mid;
}
else {
let r = Math.floor(inp.cur / n);
let c = inp.cur % n;
if (k === 'ArrowLeft') {
c = Math.max(0, c - 1);
}
if (k === 'ArrowRight') {
c = Math.min(n - 1, c + 1);
}
if (k === 'ArrowUp') {
r = Math.max(0, r - 1);
}
if (k === 'ArrowDown') {
r = Math.min(n - 1, r + 1);
}
inp.cur = r * n + c;
}
inp.key = true;
inp.idle = 0;
scene.hover(inp.cur);
if (reduce) {
show();
}
return;
}
if (k === 'Enter' || k === ' ') {
ev.preventDefault();
if (inp.cur < 0) {
inp.cur = mid;
}
inp.key = true;
inp.idle = 0;
scene.tap(inp.cur);
if (reduce) {
show();
}
}
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const LINES = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]];
const SCORE_WIN = 10;
const TXT_X_TURN = 'Xの番';
const TXT_O_TURN = 'Oの番';
const TXT_X_WIN = 'Xの勝ち';
const TXT_O_WIN = 'Oの勝ち';
const TXT_DRAW = '引き分け';
function newGame() { return { b: [0, 0, 0, 0, 0, 0, 0, 0, 0], turn: 1, over: false, line: -1 }; }
// 완성된 줄의 번호(LINES 안의 위치). 없으면 -1
function lineOf(b) {
for (let k = 0; k < LINES.length; k += 1) {
const l = LINES[k];
if (b[l[0]] !== 0 && b[l[0]] === b[l[1]] && b[l[1]] === b[l[2]]) {
return k;
}
}
return -1;
}
function emptyCells(b) {
const out = [];
for (let i = 0; i < 9; i += 1) {
if (b[i] === 0) {
out.push(i);
}
}
return out;
}
function place(g, i) {
if (g.over || g.b[i] !== 0) {
return false;
}
g.b[i] = g.turn;
g.line = lineOf(g.b);
if (g.line >= 0 || emptyCells(g.b).length === 0) {
g.over = true;
}
else {
g.turn = 3 - g.turn;
}
return true;
}
function gameLabel(g) {
if (g.over) {
return g.line >= 0 ? (g.b[LINES[g.line][0]] === 1 ? TXT_X_WIN : TXT_O_WIN) : TXT_DRAW;
}
return g.turn === 1 ? TXT_X_TURN : TXT_O_TURN;
}
// 전수 탐색 — X 시점 점수. depth 가 0 이 되면 더 보지 않고 0 으로 친다. cnt.n 은 방문한 국면 수
function solve(b, turn, ply, depth, cnt) {
cnt.n += 1;
const w = lineOf(b);
if (w >= 0) {
const s = SCORE_WIN - ply;
if (b[LINES[w][0]] === 1) {
cnt.x += 1;
return s;
}
cnt.o += 1;
return -s;
}
if (depth <= 0) {
return 0;
}
let best = turn === 1 ? -1000 : 1000;
let any = false;
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) {
continue;
}
b[i] = turn;
const v = solve(b, 3 - turn, ply + 1, depth - 1, cnt);
b[i] = 0;
any = true;
if (turn === 1 ? v > best : v < best) {
best = v;
}
}
return any ? best : 0;
}
// 둘 곳마다 점수. 이미 찬 칸은 NaN. 둔 사람(turn) 시점이 아니라 X 시점 값이다
function cellScores(b, turn, depth, cnt, per) {
const out = [];
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) {
out.push(NaN);
if (per) {
per.push({ n: 0, x: 0, o: 0 });
}
continue;
}
b[i] = turn;
const c = { n: 0, x: 0, o: 0 };
out.push(solve(b, 3 - turn, 1, depth - 1, c));
b[i] = 0;
cnt.n += c.n;
cnt.x += c.x;
cnt.o += c.o;
if (per) {
per.push(c);
}
}
return out;
}
function bestCell(b, turn, depth) {
const cnt = { n: 0, x: 0, o: 0 };
const sc = cellScores(b, turn, depth, cnt);
let best = -1;
for (let i = 0; i < 9; i += 1) {
if (isNaN(sc[i])) {
continue;
}
if (best < 0 || (turn === 1 ? sc[i] > sc[best] : sc[i] < sc[best])) {
best = i;
}
}
return best;
}
function gridRect(i, ox, oy, cs) {
return [ox + (i % 3) * cs, oy + Math.floor(i / 3) * cs, cs, cs];
}
function gridHit(x, y, ox, oy, cs) {
const c = Math.floor((x - ox) / cs);
const r = Math.floor((y - oy) / cs);
return c >= 0 && c < 3 && r >= 0 && r < 3 ? r * 3 + c : -1;
}
const MATCH_DEPTH = 9;
const REPLY_MS = 420;
// 한 판 진행기 — 사람은 X, 컴퓨터는 O. 사람이 가만히 있으면(idle) 둘 다 컴퓨터가 두어 루프를 만든다.
// pick 이 있으면 자동 대국의 수를 그 함수가 정한다. 판이 끝나면 holdMs 뒤에 새 판
function makeMatch(inp, reduce, turnMs, holdMs, seed, depth, onPlace, onReset, pick) {
const rng = lcg(seed);
let t = 0;
let reply = -1;
let hold = 0;
const m = {
g: newGame(),
since: 0,
move(i) {
const who = m.g.turn;
if (place(m.g, i)) {
m.since = 0;
onPlace(i, who);
}
},
tap(i) {
if (m.g.over) {
reset();
return;
}
if (m.g.turn !== 1 || m.g.b[i] !== 0) {
return;
}
m.move(i);
if (!m.g.over) {
if (reduce) {
m.move(bestCell(m.g.b, 2, depth));
}
else {
reply = REPLY_MS;
}
}
},
step() {
m.since += FIXED_DT;
if (m.g.over) {
hold += FIXED_DT;
if (hold >= holdMs) {
reset();
}
return;
}
if (reply >= 0) {
reply -= FIXED_DT;
if (reply < 0) {
m.move(bestCell(m.g.b, 2, depth));
}
return;
}
if (inp.idle > IDLE_TICKS) {
t += FIXED_DT;
if (t >= turnMs) {
t = 0;
autoMove();
}
}
},
auto() {
if (m.g.over) {
reset();
}
else {
autoMove();
}
},
};
function reset() {
m.g = newGame();
t = 0;
reply = -1;
hold = 0;
onReset();
}
function autoMove() {
if (pick) {
const p = pick();
if (p >= 0) {
m.move(p);
}
return;
}
const e = emptyCells(m.g.b);
if (m.g.turn === 1 && (e.length > 6 || rng() < 0.3)) {
m.move(e[Math.floor(rng() * e.length)]);
return;
}
m.move(bestCell(m.g.b, m.g.turn, depth));
}
return m;
}
const CHALK_NOISE = 1.8;
const DUST_N = 90;
const TURN_MS = 450;
const ERASE_MS = 800;
const DRAW_MS = 320;
const ERASE_WAIT = 400;
const CS = 84;
const OX = 114;
const OY = 24;
const SLATE = [46, 66, 68];
const WOOD = [214, 178, 148];
const FELT = [236, 226, 206];
const make = function (inp, reduce) {
const rng = lcg(91);
const dust = [];
const strokes = [];
const age = [-1, -1, -1, -1, -1, -1, -1, -1, -1];
const poly = [[], [], [], [], [], [], [], [], []];
let winPts = [];
let clock = 0;
let hov = -1;
let bake = null;
// 분필 선 — 직선을 5px 마다 끊고 수직 방향으로 값 노이즈만큼 흔든다
function chalkLine(x0, y0, x1, y1, seed) {
const len = Math.hypot(x1 - x0, y1 - y0);
const n = Math.max(2, Math.ceil(len / 5));
const nx = -(y1 - y0) / len;
const ny = (x1 - x0) / len;
const out = [];
for (let k = 0; k <= n; k += 1) {
const t = k / n;
const off = (noise2(t * len * 0.045, seed * 7.31, 3) - 0.5) * 2 * CHALK_NOISE;
out.push([lerp(x0, x1, t) + nx * off, lerp(y0, y1, t) + ny * off]);
}
return out;
}
function chalkCircle(cx, cy, r, seed) {
const out = [];
const n = 44;
const a0 = -Math.PI / 2 + (noise2(seed, 1, 5) - 0.5) * 0.6;
for (let k = 0; k <= n; k += 1) {
const a = a0 + (k / n) * PI2 * 1.04;
const rr = r + (noise2(Math.cos(a) * 2 + seed, Math.sin(a) * 2, 9) - 0.5) * 2 * CHALK_NOISE;
out.push([cx + Math.cos(a) * rr, cy + Math.sin(a) * rr]);
}
return out;
}
const boardLines = [
chalkLine(OX + CS, OY + 6, OX + CS, OY + CS * 3 - 6, 1),
chalkLine(OX + CS * 2, OY + 6, OX + CS * 2, OY + CS * 3 - 6, 2),
chalkLine(OX + 6, OY + CS, OX + CS * 3 - 6, OY + CS, 3),
chalkLine(OX + 6, OY + CS * 2, OX + CS * 3 - 6, OY + CS * 2, 4),
];
// 분필 한 줄을 세 겹으로 — 굵은 바탕, 끊기는 알갱이, 가는 심
function chalk(ctx, pts, p, col, w, alpha) {
const cut = clamp(p, 0, 1) * (pts.length - 1);
const full = Math.floor(cut);
const part = cut - full;
const draw = pts.slice(0, full + 1);
if (full < pts.length - 1 && part > 0) {
draw.push([lerp(pts[full][0], pts[full + 1][0], part), lerp(pts[full][1], pts[full + 1][1], part)]);
}
if (draw.length < 2) {
return [pts[0][0], pts[0][1]];
}
const passes = [[w, 0.5, [], 0], [w * 0.55, 0.65, [7, 2], 0.7], [w * 0.28, 0.85, [3, 4], -0.6]];
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
for (let k = 0; k < passes.length; k += 1) {
ctx.setLineDash(passes[k][2]);
ctx.lineWidth = passes[k][0];
ctx.strokeStyle = rgba(col, passes[k][1] * alpha);
ctx.beginPath();
for (let q = 0; q < draw.length; q += 1) {
const x = draw[q][0] + passes[k][3];
const y = draw[q][1] + passes[k][3] * 0.6;
if (q === 0) {
ctx.moveTo(x, y);
}
else {
ctx.lineTo(x, y);
}
}
ctx.stroke();
}
ctx.setLineDash([]);
const tip = draw[draw.length - 1];
return [tip[0], tip[1]];
}
function spawn(x, y, n) {
for (let k = 0; k < n; k += 1) {
if (dust.length >= DUST_N) {
dust.shift();
}
dust.push({ x: x + (rng() - 0.5) * 6, y: y + (rng() - 0.5) * 4, vx: (rng() - 0.5) * 30, vy: -rng() * 14, life: 1, s: 0.8 + rng() * 1.6 });
}
}
const m = makeMatch(inp, reduce, TURN_MS, ERASE_WAIT + ERASE_MS + 150, 91, MATCH_DEPTH, function (i, w) {
const cx = OX + (i % 3) * CS + CS / 2;
const cy = OY + Math.floor(i / 3) * CS + CS / 2;
const r = CS * 0.28;
if (w === 1) {
poly[i] = [chalkLine(cx - r, cy - r, cx + r, cy + r, i + 11), chalkLine(cx + r, cy - r, cx - r, cy + r, i + 29)];
}
else {
poly[i] = [chalkCircle(cx, cy, r, i + 7)];
}
strokes.push({ pts: [], who: w });
age[i] = reduce ? 99999 : 0;
if (m.g.line >= 0) {
const l = LINES[m.g.line];
const a = gridRect(l[0], OX, OY, CS);
const b = gridRect(l[2], OX, OY, CS);
winPts = chalkLine(a[0] + CS / 2 - 14, a[1] + CS / 2 - 8, b[0] + CS / 2 + 14, b[1] + CS / 2 + 8, 77);
if (l[0] % 3 === l[2] % 3 && l[0] !== l[2]) {
winPts = chalkLine(a[0] + CS / 2, a[1] + 8, b[0] + CS / 2, b[1] + CS - 8, 77);
}
if (Math.floor(l[0] / 3) === Math.floor(l[2] / 3)) {
winPts = chalkLine(a[0] + 8, a[1] + CS / 2, b[0] + CS - 8, b[1] + CS / 2, 77);
}
}
}, function () {
for (let i = 0; i < 9; i += 1) {
age[i] = -1;
poly[i] = [];
}
winPts = [];
strokes.length = 0;
}, null);
function eraserX() {
if (!m.g.over || reduce) {
return -999;
}
const e = m.since - ERASE_WAIT;
return e <= 0 ? -999 : lerp(OX - 40, OX + CS * 3 + 40, clamp(e / ERASE_MS, 0, 1));
}
function bakeBackground() {
if (bake || typeof document === 'undefined') {
return;
}
const c = document.createElement('canvas');
c.width = W;
c.height = H;
const g = c.getContext('2d');
if (!g) {
return;
}
g.fillStyle = rgba(SLATE, 1);
g.fillRect(0, 0, W, H);
const r2 = lcg(5);
for (let k = 0; k < 70; k += 1) {
const x = r2() * W;
const y = r2() * H;
const rad = 20 + r2() * 60;
const gr = g.createRadialGradient(x, y, 0, x, y, rad);
gr.addColorStop(0, 'rgba(255,248,230,' + (0.015 + r2() * 0.03).toFixed(3) + ')');
gr.addColorStop(1, 'rgba(255,248,230,0)');
g.fillStyle = gr;
g.fillRect(x - rad, y - rad, rad * 2, rad * 2);
}
bake = c;
}
// 시작할 때 이미 일곱 수가 놓인 판 — 곧 끝나서 지우개가 지나가는 장면까지 한 번에 보인다
if (!reduce) {
for (let k = 0; k < 7 && !m.g.over; k += 1) {
m.auto();
}
for (let i = 0; i < 9; i += 1) {
if (age[i] >= 0) {
age[i] = 99999;
}
}
}
return {
step() {
clock += FIXED_DT;
m.step();
const dt = FIXED_DT / 1000;
for (let i = 0; i < 9; i += 1) {
if (age[i] < 0) {
continue;
}
age[i] += FIXED_DT;
const p = clamp(age[i] / DRAW_MS, 0, 1);
if (p < 1 && poly[i].length > 0) {
const pts = poly[i][m.g.b[i] === 1 ? (p < 0.5 ? 0 : 1) : 0];
const pp = m.g.b[i] === 1 ? (p < 0.5 ? p * 2 : (p - 0.5) * 2) : p;
const idx = Math.min(pts.length - 1, Math.floor(pp * (pts.length - 1)));
spawn(pts[idx][0], pts[idx][1] + 2, 2);
}
}
const ex = eraserX();
if (ex > -900 && ex < OX + CS * 3 + 30) {
spawn(ex + 6, OY + 20 + rng() * (CS * 3 - 40), 3);
}
for (let k = dust.length - 1; k >= 0; k -= 1) {
const d = dust[k];
d.vy += 150 * dt;
d.vx += Math.sin(clock * 0.0022 + d.y * 0.03) * 40 * dt;
d.x += d.vx * dt;
d.y += d.vy * dt;
d.life -= dt * 0.55;
if (d.life <= 0 || d.y > H - 8) {
dust.splice(k, 1);
}
}
},
draw(ctx) {
bakeBackground();
if (bake) {
ctx.drawImage(bake, 0, 0, W, H);
}
else {
ctx.fillStyle = rgba(SLATE, 1);
ctx.fillRect(0, 0, W, H);
}
ctx.strokeStyle = rgba(WOOD, 1);
ctx.lineWidth = 8;
ctx.strokeRect(4, 4, W - 8, H - 8);
const ex = eraserX();
for (let k = 0; k < boardLines.length; k += 1) {
chalk(ctx, boardLines[k], 1, CREAM, 3.4, 0.9);
}
if (hov >= 0 && m.g.b[hov] === 0 && !m.g.over) {
const r = gridRect(hov, OX, OY, CS);
ctx.fillStyle = rgba(CREAM, 0.08);
rrect(ctx, r[0] + 6, r[1] + 6, CS - 12, CS - 12, 10);
ctx.fill();
}
ctx.save();
if (ex > -900) {
ctx.beginPath();
ctx.rect(ex + 28, 0, W, H);
ctx.clip();
}
for (let i = 0; i < 9; i += 1) {
if (age[i] < 0 || poly[i].length === 0) {
continue;
}
const p = clamp(age[i] / DRAW_MS, 0, 1);
const col = m.g.b[i] === 1 ? PEACH : SKY;
if (m.g.b[i] === 1) {
chalk(ctx, poly[i][0], clamp(p * 2, 0, 1), col, 5.2, 1);
chalk(ctx, poly[i][1], clamp(p * 2 - 1, 0, 1), col, 5.2, 1);
}
else {
chalk(ctx, poly[i][0], p, col, 5.2, 1);
}
}
if (winPts.length > 1) {
chalk(ctx, winPts, clamp((m.since - 120) / 300, 0, 1), MINT, 6, 1);
}
ctx.restore();
// 지우개 띠 — 지나간 자리에는 옅은 가루 얼룩이 남는다
if (ex > -900) {
const sm = ctx.createLinearGradient(OX - 40, 0, ex, 0);
sm.addColorStop(0, 'rgba(255,248,230,0)');
sm.addColorStop(1, 'rgba(255,248,230,0.1)');
ctx.fillStyle = sm;
ctx.fillRect(OX - 40, OY, Math.max(0, ex - (OX - 40)), CS * 3);
ctx.fillStyle = rgba(FELT, 1);
rrect(ctx, ex - 4, OY - 6, 36, CS * 3 + 12, 10);
ctx.fill();
ctx.fillStyle = rgba(mix(FELT, WOOD, 0.5), 1);
rrect(ctx, ex + 20, OY - 6, 12, CS * 3 + 12, 6);
ctx.fill();
}
for (let k = 0; k < dust.length; k += 1) {
const d = dust[k];
ctx.fillStyle = rgba(CREAM, clamp(d.life, 0, 1) * 0.8);
ctx.beginPath();
ctx.arc(d.x, d.y, d.s, 0, PI2);
ctx.fill();
}
},
label() { return gameLabel(m.g); },
hit(x, y) { return gridHit(x, y, OX, OY, CS); },
hover(i) { hov = i; },
tap(i) { m.tap(i); },
auto() { m.auto(); },
rect(i) { return gridRect(i, OX, OY, CS); },
n() { return 3; },
};
};
return mount(root, make);
})();// TttChalkboardSelfplayDustHero - vanilla 판(09-ttt-chalkboard-selfplay-dust-hero) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./TttChalkboardSelfplayDustHero.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function TttChalkboardSelfplayDustHero() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
const TXT_CELL = '칸';
type RGB = [number, number, number];
interface Input { x: number; y: number; cur: number; key: boolean; idle: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
hit(x: number, y: number): number;
hover(i: number): void;
tap(i: number): void;
auto(): void;
rect(i: number): [number, number, number, number];
n(): number;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 팔레트 — 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG: RGB = [29, 26, 44];
const TILE: RGB = [40, 36, 62];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
const CREAM: RGB = [255, 244, 224];
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 smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 두 개로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash2(x: number, y: number, seed: number): number {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 격자점 해시 네 개를 부드럽게 섞는다
function noise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = hash2(xi, yi, seed);
const b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed);
const d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
// 말 하나를 (cx, cy) 에 그린다. p(0~1) 만큼 대시 오프셋으로 획을 긋는다 — who 1 은 X(두 획), 2 는 O(원)
function drawMark(ctx: CanvasRenderingContext2D, who: number, cx: number, cy: number, r: number, p: number, w: number, col: string): void {
ctx.save();
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.strokeStyle = col;
if (who === 1) {
const d = r * 2 * Math.SQRT2;
for (let k = 0; k < 2; k += 1) {
const pk = clamp(p * 2 - k, 0, 1);
if (pk <= 0.001) { continue; }
ctx.setLineDash([d, d]);
ctx.lineDashOffset = d * (1 - pk);
ctx.beginPath();
if (k === 0) { ctx.moveTo(cx - r, cy - r); ctx.lineTo(cx + r, cy + r); }
else { ctx.moveTo(cx + r, cy - r); ctx.lineTo(cx - r, cy + r); }
ctx.stroke();
}
} else {
const L = PI2 * r;
if (p > 0.001) {
ctx.setLineDash([L, L]);
ctx.lineDashOffset = L * (1 - clamp(p, 0, 1));
ctx.beginPath();
ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + PI2);
ctx.stroke();
}
}
ctx.restore();
}
function mount(root: HTMLElement, make: Maker): () => void {
const canvas = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const btnEl = root.querySelector('[data-role="btn"]') as HTMLButtonElement | null;
if (!canvas || !statusEl || !btnEl) { return function (): void { return; }; }
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
const ctx: CanvasRenderingContext2D = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: -1, y: -1, cur: -1, key: false, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let focused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw(ctx);
if (inp.key && focused && inp.cur >= 0) {
const r = scene.rect(inp.cur);
ctx.save();
ctx.lineWidth = 2.5;
ctx.strokeStyle = 'rgba(255,255,255,0.92)';
rrect(ctx, r[0] + 2, r[1] + 2, r[2] - 4, r[3] - 4, 8);
ctx.stroke();
ctx.restore();
}
let t = scene.label();
if (inp.key && inp.cur >= 0) {
const n = scene.n();
t += ' · ' + TXT_CELL + ' ' + (Math.floor(inp.cur / n) + 1) + '-' + ((inp.cur % n) + 1);
}
if (t !== lastLabel) {
lastLabel = t;
statusEl!.textContent = t;
}
}
function localPoint(e: PointerEvent): void {
const r = canvas!.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
inp.key = false;
}
function tick(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
scene.auto();
scene.auto();
scene.auto();
show();
}
listen(btnEl, 'click', function (): void {
if (reduce) { scene.auto(); show(); return; }
paused = !paused;
if (paused) { root.classList.remove('is-demo'); } else { root.classList.add('is-demo'); }
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'focus', function (): void { focused = true; });
listen(canvas, 'blur', function (): void { focused = false; if (reduce) { show(); } });
listen(canvas, 'pointermove', function (e: Event): void {
localPoint(e as PointerEvent);
scene.hover(scene.hit(inp.x, inp.y));
if (reduce) { show(); }
});
listen(canvas, 'pointerleave', function (): void { scene.hover(-1); if (reduce) { show(); } });
listen(canvas, 'pointerdown', function (e: Event): void {
localPoint(e as PointerEvent);
const i = scene.hit(inp.x, inp.y);
if (i >= 0) { scene.tap(i); }
if (reduce) { show(); }
});
listen(canvas, 'keydown', function (e: Event): void {
const ev = e as KeyboardEvent;
const k = ev.key;
const n = scene.n();
const mid = Math.floor((n * n) / 2);
if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowUp' || k === 'ArrowDown') {
ev.preventDefault();
if (inp.cur < 0) { inp.cur = mid; }
else {
let r = Math.floor(inp.cur / n);
let c = inp.cur % n;
if (k === 'ArrowLeft') { c = Math.max(0, c - 1); }
if (k === 'ArrowRight') { c = Math.min(n - 1, c + 1); }
if (k === 'ArrowUp') { r = Math.max(0, r - 1); }
if (k === 'ArrowDown') { r = Math.min(n - 1, r + 1); }
inp.cur = r * n + c;
}
inp.key = true;
inp.idle = 0;
scene.hover(inp.cur);
if (reduce) { show(); }
return;
}
if (k === 'Enter' || k === ' ') {
ev.preventDefault();
if (inp.cur < 0) { inp.cur = mid; }
inp.key = true;
inp.idle = 0;
scene.tap(inp.cur);
if (reduce) { show(); }
}
});
if (!reduce) { rafId = requestAnimationFrame(tick); }
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
};
}
const LINES: number[][] = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]];
const SCORE_WIN = 10;
const TXT_X_TURN = 'X 차례';
const TXT_O_TURN = 'O 차례';
const TXT_X_WIN = 'X 승';
const TXT_O_WIN = 'O 승';
const TXT_DRAW = '무승부';
interface Game { b: number[]; turn: number; over: boolean; line: number; }
interface Counter { n: number; x: number; o: number; }
function newGame(): Game { return { b: [0, 0, 0, 0, 0, 0, 0, 0, 0], turn: 1, over: false, line: -1 }; }
// 완성된 줄의 번호(LINES 안의 위치). 없으면 -1
function lineOf(b: number[]): number {
for (let k = 0; k < LINES.length; k += 1) {
const l = LINES[k];
if (b[l[0]] !== 0 && b[l[0]] === b[l[1]] && b[l[1]] === b[l[2]]) { return k; }
}
return -1;
}
function emptyCells(b: number[]): number[] {
const out: number[] = [];
for (let i = 0; i < 9; i += 1) { if (b[i] === 0) { out.push(i); } }
return out;
}
function place(g: Game, i: number): boolean {
if (g.over || g.b[i] !== 0) { return false; }
g.b[i] = g.turn;
g.line = lineOf(g.b);
if (g.line >= 0 || emptyCells(g.b).length === 0) { g.over = true; } else { g.turn = 3 - g.turn; }
return true;
}
function gameLabel(g: Game): string {
if (g.over) { return g.line >= 0 ? (g.b[LINES[g.line][0]] === 1 ? TXT_X_WIN : TXT_O_WIN) : TXT_DRAW; }
return g.turn === 1 ? TXT_X_TURN : TXT_O_TURN;
}
// 전수 탐색 — X 시점 점수. depth 가 0 이 되면 더 보지 않고 0 으로 친다. cnt.n 은 방문한 국면 수
function solve(b: number[], turn: number, ply: number, depth: number, cnt: Counter): number {
cnt.n += 1;
const w = lineOf(b);
if (w >= 0) {
const s = SCORE_WIN - ply;
if (b[LINES[w][0]] === 1) { cnt.x += 1; return s; }
cnt.o += 1;
return -s;
}
if (depth <= 0) { return 0; }
let best = turn === 1 ? -1000 : 1000;
let any = false;
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) { continue; }
b[i] = turn;
const v = solve(b, 3 - turn, ply + 1, depth - 1, cnt);
b[i] = 0;
any = true;
if (turn === 1 ? v > best : v < best) { best = v; }
}
return any ? best : 0;
}
// 둘 곳마다 점수. 이미 찬 칸은 NaN. 둔 사람(turn) 시점이 아니라 X 시점 값이다
function cellScores(b: number[], turn: number, depth: number, cnt: Counter, per?: Counter[]): number[] {
const out: number[] = [];
for (let i = 0; i < 9; i += 1) {
if (b[i] !== 0) { out.push(NaN); if (per) { per.push({ n: 0, x: 0, o: 0 }); } continue; }
b[i] = turn;
const c: Counter = { n: 0, x: 0, o: 0 };
out.push(solve(b, 3 - turn, 1, depth - 1, c));
b[i] = 0;
cnt.n += c.n; cnt.x += c.x; cnt.o += c.o;
if (per) { per.push(c); }
}
return out;
}
function bestCell(b: number[], turn: number, depth: number): number {
const cnt: Counter = { n: 0, x: 0, o: 0 };
const sc = cellScores(b, turn, depth, cnt);
let best = -1;
for (let i = 0; i < 9; i += 1) {
if (isNaN(sc[i])) { continue; }
if (best < 0 || (turn === 1 ? sc[i] > sc[best] : sc[i] < sc[best])) { best = i; }
}
return best;
}
function gridRect(i: number, ox: number, oy: number, cs: number): [number, number, number, number] {
return [ox + (i % 3) * cs, oy + Math.floor(i / 3) * cs, cs, cs];
}
function gridHit(x: number, y: number, ox: number, oy: number, cs: number): number {
const c = Math.floor((x - ox) / cs);
const r = Math.floor((y - oy) / cs);
return c >= 0 && c < 3 && r >= 0 && r < 3 ? r * 3 + c : -1;
}
interface Match {
g: Game;
since: number;
move(i: number): void;
tap(i: number): void;
step(): void;
auto(): void;
}
const MATCH_DEPTH = 9;
const REPLY_MS = 420;
// 한 판 진행기 — 사람은 X, 컴퓨터는 O. 사람이 가만히 있으면(idle) 둘 다 컴퓨터가 두어 루프를 만든다.
// pick 이 있으면 자동 대국의 수를 그 함수가 정한다. 판이 끝나면 holdMs 뒤에 새 판
function makeMatch(inp: Input, reduce: boolean, turnMs: number, holdMs: number, seed: number, depth: number, onPlace: (i: number, who: number) => void, onReset: () => void, pick: (() => number) | null): Match {
const rng = lcg(seed);
let t = 0;
let reply = -1;
let hold = 0;
const m: Match = {
g: newGame(),
since: 0,
move(i: number): void {
const who = m.g.turn;
if (place(m.g, i)) { m.since = 0; onPlace(i, who); }
},
tap(i: number): void {
if (m.g.over) { reset(); return; }
if (m.g.turn !== 1 || m.g.b[i] !== 0) { return; }
m.move(i);
if (!m.g.over) {
if (reduce) { m.move(bestCell(m.g.b, 2, depth)); } else { reply = REPLY_MS; }
}
},
step(): void {
m.since += FIXED_DT;
if (m.g.over) {
hold += FIXED_DT;
if (hold >= holdMs) { reset(); }
return;
}
if (reply >= 0) {
reply -= FIXED_DT;
if (reply < 0) { m.move(bestCell(m.g.b, 2, depth)); }
return;
}
if (inp.idle > IDLE_TICKS) {
t += FIXED_DT;
if (t >= turnMs) { t = 0; autoMove(); }
}
},
auto(): void {
if (m.g.over) { reset(); } else { autoMove(); }
},
};
function reset(): void {
m.g = newGame();
t = 0;
reply = -1;
hold = 0;
onReset();
}
function autoMove(): void {
if (pick) { const p = pick(); if (p >= 0) { m.move(p); } return; }
const e = emptyCells(m.g.b);
if (m.g.turn === 1 && (e.length > 6 || rng() < 0.3)) { m.move(e[Math.floor(rng() * e.length)]); return; }
m.move(bestCell(m.g.b, m.g.turn, depth));
}
return m;
}
const CHALK_NOISE = 1.8;
const DUST_N = 90;
const TURN_MS = 450;
const ERASE_MS = 800;
const DRAW_MS = 320;
const ERASE_WAIT = 400;
const CS = 84;
const OX = 114;
const OY = 24;
const SLATE: RGB = [46, 66, 68];
const WOOD: RGB = [214, 178, 148];
const FELT: RGB = [236, 226, 206];
interface Dust { x: number; y: number; vx: number; vy: number; life: number; s: number; }
interface Stroke { pts: number[][]; who: number; }
const make: Maker = function (inp: Input, reduce: boolean): Scene {
const rng = lcg(91);
const dust: Dust[] = [];
const strokes: Stroke[] = [];
const age: number[] = [-1, -1, -1, -1, -1, -1, -1, -1, -1];
const poly: number[][][][] = [[], [], [], [], [], [], [], [], []];
let winPts: number[][] = [];
let clock = 0;
let hov = -1;
let bake: HTMLCanvasElement | null = null;
// 분필 선 — 직선을 5px 마다 끊고 수직 방향으로 값 노이즈만큼 흔든다
function chalkLine(x0: number, y0: number, x1: number, y1: number, seed: number): number[][] {
const len = Math.hypot(x1 - x0, y1 - y0);
const n = Math.max(2, Math.ceil(len / 5));
const nx = -(y1 - y0) / len;
const ny = (x1 - x0) / len;
const out: number[][] = [];
for (let k = 0; k <= n; k += 1) {
const t = k / n;
const off = (noise2(t * len * 0.045, seed * 7.31, 3) - 0.5) * 2 * CHALK_NOISE;
out.push([lerp(x0, x1, t) + nx * off, lerp(y0, y1, t) + ny * off]);
}
return out;
}
function chalkCircle(cx: number, cy: number, r: number, seed: number): number[][] {
const out: number[][] = [];
const n = 44;
const a0 = -Math.PI / 2 + (noise2(seed, 1, 5) - 0.5) * 0.6;
for (let k = 0; k <= n; k += 1) {
const a = a0 + (k / n) * PI2 * 1.04;
const rr = r + (noise2(Math.cos(a) * 2 + seed, Math.sin(a) * 2, 9) - 0.5) * 2 * CHALK_NOISE;
out.push([cx + Math.cos(a) * rr, cy + Math.sin(a) * rr]);
}
return out;
}
const boardLines: number[][][] = [
chalkLine(OX + CS, OY + 6, OX + CS, OY + CS * 3 - 6, 1),
chalkLine(OX + CS * 2, OY + 6, OX + CS * 2, OY + CS * 3 - 6, 2),
chalkLine(OX + 6, OY + CS, OX + CS * 3 - 6, OY + CS, 3),
chalkLine(OX + 6, OY + CS * 2, OX + CS * 3 - 6, OY + CS * 2, 4),
];
// 분필 한 줄을 세 겹으로 — 굵은 바탕, 끊기는 알갱이, 가는 심
function chalk(ctx: CanvasRenderingContext2D, pts: number[][], p: number, col: RGB, w: number, alpha: number): [number, number] {
const cut = clamp(p, 0, 1) * (pts.length - 1);
const full = Math.floor(cut);
const part = cut - full;
const draw = pts.slice(0, full + 1);
if (full < pts.length - 1 && part > 0) { draw.push([lerp(pts[full][0], pts[full + 1][0], part), lerp(pts[full][1], pts[full + 1][1], part)]); }
if (draw.length < 2) { return [pts[0][0], pts[0][1]]; }
const passes: Array<[number, number, number[], number]> = [[w, 0.5, [], 0], [w * 0.55, 0.65, [7, 2], 0.7], [w * 0.28, 0.85, [3, 4], -0.6]];
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
for (let k = 0; k < passes.length; k += 1) {
ctx.setLineDash(passes[k][2]);
ctx.lineWidth = passes[k][0];
ctx.strokeStyle = rgba(col, passes[k][1] * alpha);
ctx.beginPath();
for (let q = 0; q < draw.length; q += 1) {
const x = draw[q][0] + passes[k][3];
const y = draw[q][1] + passes[k][3] * 0.6;
if (q === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); }
}
ctx.stroke();
}
ctx.setLineDash([]);
const tip = draw[draw.length - 1];
return [tip[0], tip[1]];
}
function spawn(x: number, y: number, n: number): void {
for (let k = 0; k < n; k += 1) {
if (dust.length >= DUST_N) { dust.shift(); }
dust.push({ x: x + (rng() - 0.5) * 6, y: y + (rng() - 0.5) * 4, vx: (rng() - 0.5) * 30, vy: -rng() * 14, life: 1, s: 0.8 + rng() * 1.6 });
}
}
const m: Match = makeMatch(inp, reduce, TURN_MS, ERASE_WAIT + ERASE_MS + 150, 91, MATCH_DEPTH, function (i: number, w: number): void {
const cx = OX + (i % 3) * CS + CS / 2;
const cy = OY + Math.floor(i / 3) * CS + CS / 2;
const r = CS * 0.28;
if (w === 1) { poly[i] = [chalkLine(cx - r, cy - r, cx + r, cy + r, i + 11), chalkLine(cx + r, cy - r, cx - r, cy + r, i + 29)]; }
else { poly[i] = [chalkCircle(cx, cy, r, i + 7)]; }
strokes.push({ pts: [], who: w });
age[i] = reduce ? 99999 : 0;
if (m.g.line >= 0) {
const l = LINES[m.g.line];
const a = gridRect(l[0], OX, OY, CS);
const b = gridRect(l[2], OX, OY, CS);
winPts = chalkLine(a[0] + CS / 2 - 14, a[1] + CS / 2 - 8, b[0] + CS / 2 + 14, b[1] + CS / 2 + 8, 77);
if (l[0] % 3 === l[2] % 3 && l[0] !== l[2]) { winPts = chalkLine(a[0] + CS / 2, a[1] + 8, b[0] + CS / 2, b[1] + CS - 8, 77); }
if (Math.floor(l[0] / 3) === Math.floor(l[2] / 3)) { winPts = chalkLine(a[0] + 8, a[1] + CS / 2, b[0] + CS - 8, b[1] + CS / 2, 77); }
}
}, function (): void {
for (let i = 0; i < 9; i += 1) { age[i] = -1; poly[i] = []; }
winPts = [];
strokes.length = 0;
}, null);
function eraserX(): number {
if (!m.g.over || reduce) { return -999; }
const e = m.since - ERASE_WAIT;
return e <= 0 ? -999 : lerp(OX - 40, OX + CS * 3 + 40, clamp(e / ERASE_MS, 0, 1));
}
function bakeBackground(): void {
if (bake || typeof document === 'undefined') { return; }
const c = document.createElement('canvas');
c.width = W;
c.height = H;
const g = c.getContext('2d');
if (!g) { return; }
g.fillStyle = rgba(SLATE, 1);
g.fillRect(0, 0, W, H);
const r2 = lcg(5);
for (let k = 0; k < 70; k += 1) {
const x = r2() * W;
const y = r2() * H;
const rad = 20 + r2() * 60;
const gr = g.createRadialGradient(x, y, 0, x, y, rad);
gr.addColorStop(0, 'rgba(255,248,230,' + (0.015 + r2() * 0.03).toFixed(3) + ')');
gr.addColorStop(1, 'rgba(255,248,230,0)');
g.fillStyle = gr;
g.fillRect(x - rad, y - rad, rad * 2, rad * 2);
}
bake = c;
}
// 시작할 때 이미 일곱 수가 놓인 판 — 곧 끝나서 지우개가 지나가는 장면까지 한 번에 보인다
if (!reduce) {
for (let k = 0; k < 7 && !m.g.over; k += 1) { m.auto(); }
for (let i = 0; i < 9; i += 1) { if (age[i] >= 0) { age[i] = 99999; } }
}
return {
step(): void {
clock += FIXED_DT;
m.step();
const dt = FIXED_DT / 1000;
for (let i = 0; i < 9; i += 1) {
if (age[i] < 0) { continue; }
age[i] += FIXED_DT;
const p = clamp(age[i] / DRAW_MS, 0, 1);
if (p < 1 && poly[i].length > 0) {
const pts = poly[i][m.g.b[i] === 1 ? (p < 0.5 ? 0 : 1) : 0];
const pp = m.g.b[i] === 1 ? (p < 0.5 ? p * 2 : (p - 0.5) * 2) : p;
const idx = Math.min(pts.length - 1, Math.floor(pp * (pts.length - 1)));
spawn(pts[idx][0], pts[idx][1] + 2, 2);
}
}
const ex = eraserX();
if (ex > -900 && ex < OX + CS * 3 + 30) { spawn(ex + 6, OY + 20 + rng() * (CS * 3 - 40), 3); }
for (let k = dust.length - 1; k >= 0; k -= 1) {
const d = dust[k];
d.vy += 150 * dt;
d.vx += Math.sin(clock * 0.0022 + d.y * 0.03) * 40 * dt;
d.x += d.vx * dt;
d.y += d.vy * dt;
d.life -= dt * 0.55;
if (d.life <= 0 || d.y > H - 8) { dust.splice(k, 1); }
}
},
draw(ctx: CanvasRenderingContext2D): void {
bakeBackground();
if (bake) { ctx.drawImage(bake, 0, 0, W, H); } else { ctx.fillStyle = rgba(SLATE, 1); ctx.fillRect(0, 0, W, H); }
ctx.strokeStyle = rgba(WOOD, 1);
ctx.lineWidth = 8;
ctx.strokeRect(4, 4, W - 8, H - 8);
const ex = eraserX();
for (let k = 0; k < boardLines.length; k += 1) { chalk(ctx, boardLines[k], 1, CREAM, 3.4, 0.9); }
if (hov >= 0 && m.g.b[hov] === 0 && !m.g.over) {
const r = gridRect(hov, OX, OY, CS);
ctx.fillStyle = rgba(CREAM, 0.08);
rrect(ctx, r[0] + 6, r[1] + 6, CS - 12, CS - 12, 10);
ctx.fill();
}
ctx.save();
if (ex > -900) {
ctx.beginPath();
ctx.rect(ex + 28, 0, W, H);
ctx.clip();
}
for (let i = 0; i < 9; i += 1) {
if (age[i] < 0 || poly[i].length === 0) { continue; }
const p = clamp(age[i] / DRAW_MS, 0, 1);
const col = m.g.b[i] === 1 ? PEACH : SKY;
if (m.g.b[i] === 1) {
chalk(ctx, poly[i][0], clamp(p * 2, 0, 1), col, 5.2, 1);
chalk(ctx, poly[i][1], clamp(p * 2 - 1, 0, 1), col, 5.2, 1);
} else {
chalk(ctx, poly[i][0], p, col, 5.2, 1);
}
}
if (winPts.length > 1) { chalk(ctx, winPts, clamp((m.since - 120) / 300, 0, 1), MINT, 6, 1); }
ctx.restore();
// 지우개 띠 — 지나간 자리에는 옅은 가루 얼룩이 남는다
if (ex > -900) {
const sm = ctx.createLinearGradient(OX - 40, 0, ex, 0);
sm.addColorStop(0, 'rgba(255,248,230,0)');
sm.addColorStop(1, 'rgba(255,248,230,0.1)');
ctx.fillStyle = sm;
ctx.fillRect(OX - 40, OY, Math.max(0, ex - (OX - 40)), CS * 3);
ctx.fillStyle = rgba(FELT, 1);
rrect(ctx, ex - 4, OY - 6, 36, CS * 3 + 12, 10);
ctx.fill();
ctx.fillStyle = rgba(mix(FELT, WOOD, 0.5), 1);
rrect(ctx, ex + 20, OY - 6, 12, CS * 3 + 12, 6);
ctx.fill();
}
for (let k = 0; k < dust.length; k += 1) {
const d = dust[k];
ctx.fillStyle = rgba(CREAM, clamp(d.life, 0, 1) * 0.8);
ctx.beginPath();
ctx.arc(d.x, d.y, d.s, 0, PI2);
ctx.fill();
}
},
label(): string { return gameLabel(m.g); },
hit(x: number, y: number): number { return gridHit(x, y, OX, OY, CS); },
hover(i: number): void { hov = i; },
tap(i: number): void { m.tap(i); },
auto(): void { m.auto(); },
rect(i: number): [number, number, number, number] { return gridRect(i, OX, OY, CS); },
n(): number { return 3; },
};
};
return mount(root, make);
}, []);
return (
<div className={cx("tt") + " is-demo"} id="tt" ref={rootRef}>
<canvas className={cx("tt__canvas")} data-role="canvas" width="480" height="300" tabIndex={0} aria-label="분필 질감의 칠판 위에서 두 AI 가 스스로 두고 가루가 흩날리다 지우개로 지워지는 히어로"></canvas>
<p className={cx("tt__status")} data-role="status" aria-live="polite">X 차례</p>
<button className={cx("tt__btn")} data-role="btn" type="button">멈춤</button>
</div>
);
}09 · チョーク質感の黒板で二つのAIが自ら打ち、粉が舞い、黒板消しで消されていくヒーロー — 計測 累積面積 45.573% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function chalkLine(x0, y0, x1, y1, seed) {
const len = Math.hypot(x1 - x0, y1 - y0);
const n = Math.max(2, Math.ceil(len / 5));
const nx = -(y1 - y0) / len;
const ny = (x1 - x0) / len;
const out = [];
for (let k = 0; k <= n; k += 1) {
const t = k / n;
const off = (noise2(t * len * 0.045, seed * 7.31, 3) - 0.5) * 2 * CHALK_NOISE;
out.push([lerp(x0, x1, t) + nx * off, lerp(y0, y1, t) + ny * off]);
}
return out;
}
値が盤の印象を変えるところ — 弾みの強さ・探索の深さ・にじみの半径
三つの値は画面を撮って測ったのではなく、ソースにある関数をノードでそのまま呼んで数えたものです。レンダーで測った数値は、後ろの表にまとめました。
弾みの強さを1.1から1.6に上げると、落ち着くまでが368msから1,424msに延びる
01の OVERSHOOT が1.1のとき、減衰比は0.591で、マスは240msで頂点の1.093倍に達し、368ms以内に2%以内へ収まります。1.6に上げると減衰比は0.160に下がり、頂点は192msで1.598倍になって、落ち着くまで1,424msかかります。Oの応答は420ms後に来るので、その間に次の駒が置かれても、前の駒はまだ長く揺れていることになります。
探索の深さを9から3に下げると、ヒートが平らになる
03の SEARCH_DEPTH が9なら空の盤で549,945個の局面を調べます(ノードで cellScores を一回呼ぶのに約13ms)が、3なら585個しか見ません。駒が1〜2個置かれた全局面は81個で、その候補は576マスあり、符号つきの点数が出るマスは深さ9で276マス、深さ3では0マスです。
にじみの半径を6から20に上げると、光の届く面積が約2.3倍になる
05の GLOW_BLUR が6のとき、280pxの線一本(太さ9px)を480×300のキャンバスに描くと、中心線から光が届く距離は10pxで、届くピクセルは5,900個です。20に上げると22pxと13,348個になり、面積は約2.3倍なので、マスの間のすき間まで光がにじみます。
レンダーした九つの場面の面積・強度・フレームは、次の表に集めました。動いたフレームは項目ごとに14〜23と幅があり、05が14/23で最も低い理由は、この測定だけでは切り分けられませんでした。
| 項目 | 累積面積 | 平均強度 | 動いたフレーム |
|---|---|---|---|
| 01 バネの駒置き | 19.3% | 45.1 | 21/23 |
| 02 立体の盤 | 26.7% | 45.6 | 23/23 |
| 03 点数ヒート | 37.2% | 30.2 | 21/23 |
| 04 勝敗リング | 22.5% | 115.8 | 21/23 |
| 05 勝利の線 | 11.4% | 97.6 | 14/23 |
| 06 入れ子の盤 | 59.0% | 32.3 | 19/23 |
| 07 対称の折りたたみ | 29.1% | 70.0 | 21/23 |
| 08 ゲームの木 | 18.6% | 75.0 | 16/23 |
| 09 黒板の自動対局 | 45.6% | 85.6 | 22/23 |
どこで壊れるか — 速く連打するとき・終局のあと・動きを減らす設定
連打すると、Oの応答の420msの間は入力が消える
九つの盤とも、人が打ったあとの420msは turn !== 1 なので新しい入力を捨てます。連打が効かないように見える区間で、すでに駒のあるマスを押した入力も同じ理由で無視されます。
02は落ちてくる駒と押すマスがずれて見えることがある
クリック判定の hit は駒の高さではなく、台座の面(z = 0)の四角形で計算します。最大の傾き0.42radのとき、高さ90pxの駒は遠近を無視すると画面上で約37px上に浮いて見え、proj() で遠近を入れると真ん中の行で約43px(行によって31〜52px)になるので、落ちている間は絵の駒と押すべきマスがずれます。
04の空の盤の割合は、前のマスの側へ偏る
上限20,000で止まる集計は深さ優先なので、番号の小さいマスの側の枝から先に数えます。中央にXが一つあり、最初の空きマスを指した場合は、訪れる局面が6,812個で上限の下に収まり、終局を最後まで数えます。
05は終局のあとに押すと、演出が途中で切れる
勝利の線を引いている最中でも、にじみが静まっている最中でも、人が押せば新しい対局になるので、効果はその場で消えます。引き分けのときは線もにじみもありません。
06は全体表示でマスが小さい
マスはキャンバス座標で、全体表示では30px、ズームでは78pxです。320×200のビューポートでは .tt の幅が約279pxに縮み、それぞれ約17pxと約45pxになるので、全体表示のタッチ対象としては小さすぎます。小さい盤が決まっていない自由選択の瞬間も、カメラは全体へズームアウトしますが、クリックはマスの座標で計算されます。
07は折りたたまれている間、タップを受け付けない
折りたたみと落ち着きの段階の1,060msは入力を無視します。中央の盤の形が標準形に変わり、実際のマスとずれるからです。到達できる局面5,478個は標準形で765個に折りたたまれ、角のXと辺のOの局面は変形八つがすべて異なるので、異なる形は8/8まで行きます。
08は空の盤で太さの差がほとんどない
空の盤の下の終局は255,168個で、子ごとには25,872〜29,592個なので、太さは5.9〜6.0pxの範囲に集まります。駒が2個の盤の子7個も終局が441〜576個で太さの差は0.2px程度、差が大きく出るのは孫の層です。
09は背景が高解像度でぼやける
背景のにじみを一度焼き付けるオフスクリーンキャンバス(480×300)には devicePixelRatio を掛けていないので、dpr 2の画面では二倍に引き伸ばされてぼやけます。粉のプールが90個で埋まると、いちばん古いものから消えます。
上の三つの値を自分で書き換えて試したい場合は、下のカードから圧縮ファイルを受け取ってください。展開するときに入力するパスワードは gvrueby4 で、盤ごとにHTMLとSCSSの一組とReact版が入っています。
変形三つとアクセシビリティ
| 名前 | 値 | 印象 |
|---|---|---|
| 弾みの強さを変える | 01 OVERSHOOT 1.1 → 1.6 | 行き過ぎが大きくなる。減衰比が0.59から0.16に下がり、ピークは1.09倍から1.60倍へ上がり、2%以内に収まる時間は約370msから約1,420msに延びて、駒がしばらく揺れ続ける。 |
| 探索の深さを変える | 03 SEARCH_DEPTH 9 → 3 | ヒートが平らになる。駒が1〜2個置かれた全81局面の候補576マスのうち、符号つきの点数は深さ9で276マス、深さ3で0マスになり、空の盤の探索局面は549,945から585に減って、考える時間がほぼ消える。 |
| にじみの半径を変える | 05 GLOW_BLUR 6 → 20 | 光が広くにじむ。280pxの線一本を480×300に描いたとき、中心線から光が届く距離が10pxから22px、光が届く面積が5,900pxから13,348pxへ約2.3倍になり、マスの間までにじむ。 |
三つの変形の数字も、上の値の節と同じやり方でソースの関数をノードから直接呼んで数えたものです。
入力の処理は、九つのファイルが同じ骨組みを使っています。どのファイルも <button> は一つだけで、キャンバスは tabindex="0" と aria-label を、状態行は aria-live をそれぞれ一度ずつ持ちます。preventDefault は矢印キーの分岐とEnter・Spaceの分岐の二か所でだけ呼ぶのでTabは塞がれず、キャンバスからボタンへフォーカスが移れます。setPointerCapture は九つのファイルのどれも使いません。
prefers-reduced-motion は、JavaScriptの matchMedia 一か所とCSSの一か所で読み取ります。オンのときは requestAnimationFrame のループを始めず、三手を置いた場面を一枚だけ描き、入力が来たら show() で補間なしにすぐ描き直して、Oの応答も待ちません。ボタンは「次の場面」に変わります。01はバネなしで最終の大きさと描き終えた筆跡を、02は傾き0と落下の高さ0を、04はリングが目標の割合にすぐ届いた姿を、06はカメラが目標へ即座に合った姿を見せます。07は折りたたみなしで八つの変形を輪に固定し、08は枝を即座に広げ、09は粉と黒板消しがありません。
tic tac toe html css javascript だけで足りるか?
九つのファイルにライブラリ・CDN・WebGLはなく、<script src も http も0個なので、ファイル一つあれば動きます。点数ヒートの探索、勝敗リングの集計、対称の折りたたみ、ゲームの木が、すべてこの一つのファイルの中のJavaScriptで計算されます。
足りないのは、サービスとして作るときの部分です。勝敗の記録を保存する localStorage も、効果音を鳴らす AudioContext もなく、二人が向かい合って打つモードもありません。ボールを弾いてブロックを崩す盤の反射角はjavascript ブロック崩し 反射角で変わる9パターンで、経路を探すヘビはcss snake game 9パターン — キー操作なしで経路を探して餌を食べるヘビで続きを読めます。ジャンルをもっと広く見たいときは、CSS ゲーム 作成 — CSSだけで動くミニゲーム9選とHTML CSS JS ゲーム 9種 — 電卓・タイマー・パズルのコピペをあわせて開いてください。
tic tac toe game html css js のコードを使い回す前によくある質問
html css tic tac toe のソースをそのまま自分のサイトに貼ってもいいですか?
外部のアドレスを呼ばないので、貼るときについて回る依存の一覧はありません。ファイルごとに index.html 一つにスタイルとスクリプトが入っていて、乱数は種で固定されているため同じ画面が再現されます。ただし画面の文言は、受け取る圧縮ファイルの言語に合わせて入っています(日本語版の圧縮ファイルなら日本語です)が、文字列として直接書かれているので、サイトの言語が違うときは使う場所の言葉に書き換えてください。
コンピューターの手はどこで決めていますか?
人が打ったあとのOの応答は、01〜05と07〜09では bestCell が全探索の点数がいちばん高いマスを選び、同点なら番号の小さいマスです。06だけは小さい盤があるので、chooseMove が線の完成 +100・相手の線を止める +40・中央のマス +6 といった加点から、相手がすぐ勝てる盤を渡す手の −30 を引き、lcg のノイズを足して決めます。03の点数は全探索の値で、04の勝敗の割合は上限つきの終局の集計ですから、どちらも盤の読み方を見せるだけで、勝ちを約束する値ではありません。
毎回違う対局にするには、どこを変えますか?
自動対局でのXの初手は lcg の種で決まるので、種の数字を変えれば別の対局になります(05は台本の対局なので、種を変えても変わりません)。毎回ランダムにしたいなら、その場所で使う値を自分で作って入れる必要があり、この記事のファイルはそうした値を作りません。









