09A Hero Where Two AIs Play Each Other on a Chalk-Textured Board, Dust Drifts, and an Eraser Wipes It Clean
Noise displaces the line edges to make chalk marks, the dust particles drift on gravity and wind, and the board is wiped by an eraser band that sweeps across after the game ends.
Best forEducation service landings and developer event posters
CHALK_NOISEDUST_NTURN_MSERASE_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="A Hero Where Two AIs Play Each Other on a Chalk-Textured Board, Dust Drifts, and an Eraser Wipes It Clean"></canvas>
<p class="tt__status" data-role="status" aria-live="polite">X to move</p>
<button class="tt__btn" data-role="btn" type="button">Pause</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 = 'Pause';
const BTN_PLAY = 'Play';
const BTN_NEXT = 'Next scene';
const TXT_CELL = '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 to move';
const TXT_O_TURN = 'O to move';
const TXT_X_WIN = 'X wins';
const TXT_O_WIN = 'O wins';
const TXT_DRAW = '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 A Hero Where Two AIs Play Each Other on a Chalk-Textured Board, Dust Drifts, and an Eraser Wipes It Clean — Measured area 45.573% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · CSS Tic Tac Toe: 9 Boards That Count Ahead
The post this demo lives in
CSS Tic Tac Toe: 9 Boards That Count AheadBefore you place a mark on a css tic tac toe board, the computer has counted the endings left — the score heat in scene See all demos in this post
If you like this effect - similar demos
game08 A Board Where Distance Becomes Heat Color and the Answer Appears as a ValleyMaze Generator: 9 Carve-and-Solve Scenes
game09 A Hero Scene Where the 2048 Board Plays Itself2048 Game JavaScript: 9 Boards from Merges to AI
game09 The hero scene — a finished wheel with a confetti burst and a saveable resultSpin Wheel: 9 Canvas Roulettes, Flick to Stop