08현재 국면에서 갈라지는 수를 가지로 펼치고 그 아래 끝판 수에 따라 가지 굵기가 달라지는 나무 보기
현재 국면을 뿌리로 두고 남은 수를 깊이별 원 배치로 펼치며, 가지 굵기는 그 아래 끝판 수의 로그에 비례하게 한다.
쓰임알고리즘 강의 자료·개발 블로그 도해
RING_RNODE_RBRANCH_WEXPAND_MS

<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 원작 · zip 에 SCSS·React 포함 · css tic tac toe 9가지 — 판이 스스로 셈하고 줄을 긋는 삼목 게임
이 데모가 속한 글
css tic tac toe 9가지 — 판이 스스로 셈하고 줄을 긋는 삼목 게임css tic tac toe 판에 말을 놓기 전에 컴퓨터는 남은 끝판을 세어 본다 — 03번 점수 열지도와 04번 승패 링이 그 셈을 칸 위에 그린다. 이 글의 데모 전부 보기


