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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 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 의 자바스크립트가 하고, 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 은 11, 03 은 lcg(31), 09 는 lcg(91) 이며 09 의 배경 번짐만 lcg(5) 를 따로 쓴다), Math.random·new Date·Date.now·fetch·localStorage 는 아홉 파일 모두 0번 나온다.
이 판들을 붙일 만한 화면
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 카메라 목표 — 정해진 판이 있으면 그 판으로 줌, 없으면 전체
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 늦게 손자 점이 따라 나온다. 가지 굵기는 BRANCH_W 5 에 끝판 수의 로그 비율을 곱해 1 에 더한 값이고, 끝까지 둔 점수가 양수면 하늘색·음수면 분홍·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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 높이의 말은 원근을 빼고 어림하면 90 × sin 0.42 로 약 37px 위에 보이고, 원근까지 계산하면 가운데 줄에서 약 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 한 쌍과 리액트 판이 함께 들어 있다.
변형 세 가지와 접근성
| 이름 | 값 | 느낌 |
|---|---|---|
| 튕김 세기 바꾸기 | 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 은 자바스크립트의 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번이라 파일 하나만 있으면 돈다. 점수 열지도의 탐색, 승패 링의 집계, 대칭 접기, 게임 나무가 모두 이 한 파일 안의 자바스크립트로 계산된다.
부족한 것은 서비스로 만들 때의 부분이다. 승패 기록을 저장하는 localStorage 도 효과음을 내는 AudioContext 도 없고, 두 사람이 마주 앉아 두는 모드도 없다. 공을 튕겨 벽돌을 깨는 판의 반사각은 벽돌깨기 게임 만들기 9가지 — 반사각은 맞은 위치가 정한다 에서, 길을 찾는 뱀은 css snake game 9가지 — 키보드 없이 길을 찾아 먹이를 먹는 뱀 게임 에서 이어서 볼 수 있다. 장르를 더 넓게 훑고 싶다면 CSS 게임 만들기 — 미니게임 9종 복붙 코드와 웹 미니게임 만들기 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 는 줄 순서가 정해진 대본이라 시드로 달라지지 않는다. 매번 무작위로 하고 싶으면 그 자리에 쓸 값을 직접 만들어 넣어야 하고, 이 편의 파일은 그런 값을 만들지 않는다.









