css snake game 9가지 — 키보드 없이 길을 찾아 먹이를 먹는 뱀 게임
css snake game 을 열면 키보드를 누르기도 전에 뱀이 먹이 쪽으로 돌아 나간다 — 01번 최단 경로와 02번 해밀턴 순환이 그 한 수를 계산으로 정한다.
아홉 가지 뱀 게임을 화면에서 바로 돌려 본다
01 sk-bfs-safe-autopilot
02 sk-hamilton-cycle-fill
03 sk-smooth-lerp-body
04 sk-chain-follow-rope
05 sk-heat-trail-decay
06 sk-wrap-torus-ghost
07 sk-scale-skin-pattern
08 sk-eat-burst-score-roll
09 sk-bfs-distance-wave
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 너비 우선 탐색으로 먹이까지 최단 경로를 잡고 막다른 길이면 꼬리를 쫓는 뱀
- 02 판 전체를 한 바퀴 도는 해밀턴 순환으로 판을 끝까지 채우는 뱀
- 03 틱 사이를 보간해 칸이 아니라 픽셀 단위로 미끄러지는 뱀 몸통
- 04 칸 없이 머리를 따라 거리 제약으로 끌려오는 사슬 몸통의 뱀
- 05 지나간 칸에 열이 남았다가 식으며 이동 경로를 그리는 뱀
- 06 벽을 지나면 반대편에서 나오고 경계에서 몸이 두 조각으로 그려지는 뱀
- 07 몸통을 따라 비늘 무늬와 밝기 물결이 흐르는 뱀 가죽
- 08 먹이를 먹는 순간 중력을 받는 입자가 터지고 점수 숫자가 튀어 오르는 뱀
- 09 먹이에서 퍼지는 거리장 물결과 그 비탈을 타고 내려가는 뱀
css snake game 9가지 — 길찾기·순환·보간으로 뱀이 스스로 움직인다
판은 캔버스에 그리고 화면 틀은 CSS 가 잡는다. 칸과 뱀과 먹이를 칠하고 다음 한 수를 정하는 일은 index.html 의 자바스크립트가 하고, CSS 는 무대 크기와 그 위에 얹힌 상태 알약·캡션·버튼, 키보드 포커스 외곽선을 맡는다. 무대 폭은 min(92vw, calc(160vh - 12.8vw)), 비율은 480:300, 모서리는 16px 이다.
캔버스 안쪽 해상도는 480×300 에 devicePixelRatio(최대 2)를 곱한 값이라 창 크기가 바뀌어도 판의 칸 수는 그대로다. 칸은 makeBoard(GRID_N) 이 정한다. 줄 수가 16 이면 칸이 18.75px, 가로 25칸 × 세로 16칸으로 400칸이 되고, 12 이면 25px 칸 228칸, 8 이면 37.5px 칸 96칸이다.
시간은 16ms 고정 스텝으로 흐른다. 장면마다 스텝을 모았다가 TICK_MS 가 찰 때 논리 틱을 한 번 돌리고 그림은 프레임당 한 번만 그린다. 난수는 시드를 고정한 lcg 뿐이라 먹이 자리가 매번 같고, 01·02·03·05·06·07·08 일곱 장면은 숨은 워밍 틱을 먼저 돌려 중반 상태에서 열린다. 01 은 길이 54, 02 는 91/96칸, 08 은 점수 0070·길이 10, 07 은 길이 11 이고 04 와 09 는 처음부터 보인다.
아홉 장면은 뱀이 무엇을 계산하느냐로 나눠 놓았다. 01·02 는 다음 한 칸을 고르는 판단이고, 03·04 는 같은 이동을 몸통으로 그리는 두 방식이다. 05·07·09 는 칸이나 몸에 값을 칠하는 장면이고, 06 은 판의 모양을 바꾸고 08 은 먹는 순간의 반응을 붙인다.
어디에 붙일지는 쓰임으로 고르면 빠르다. 자동 플레이 데모나 AI 대전 미리보기라면 01, 엔딩 없는 대기 화면이라면 02, 부드러운 모션이 중요한 모바일 화면이라면 03, 자유 이동 벌레 캐릭터라면 04 에서 시작한다. 플레이 기록 시각화는 05, 벽 없는 맵은 06, 스킨 상점 미리보기는 07, 점수 보상 HUD 는 08, 길찾기 설명 화면은 09 가 맞다.
사람이 방향키로 조작하는 쪽이 먼저 필요하다면 웹 미니게임 만들기 9종 — 계산기·타이머·퍼즐 복붙 코드나 CSS 게임 만들기 — 미니게임 9종 복붙 코드가 맞다. 이 편은 사람이 손대지 않아도 뱀이 스스로 길을 찾는 쪽이다.
snake game using html css and javascript 를 기법별 코드로 본다
각 항목의 코드는 index.html 의 <script> 에서 연속된 줄을 그대로 잘라 붙였고, 한 조각은 25줄을 넘지 않는다. snake game using html css and javascript 는 방향키 입력과 충돌 검사부터 찾게 되는 주제인데, 이 아홉 장면은 입력 대신 판단과 그림 쪽 계산을 코드로 보여 준다.
01너비 우선 탐색으로 먹이까지 최단 경로를 잡고 막다른 길이면 꼬리를 쫓는 뱀
매 틱 먹이에서 너비 우선으로 거리를 채워 머리가 갈 최단 길을 뽑고, simulate 가 그 길을 끝까지 걸은 가상의 몸을 만든다. tailReachable 이 가상 머리에서 가상 꼬리까지 닿는지 보고, 막히면 그 길을 버려(분홍 선) 꼬리를 쫓는다. 캡처 24장에서 잰 값은 누적면적 49.8%, 평균 강도 114.0, 움직인 프레임 23/23 이다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="너비 우선 탐색으로 먹이까지 최단 경로를 잡고 막다른 길이면 꼬리를 쫓는 뱀"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">검사 켜짐</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">너비 우선 탐색으로 먹이까지 최단 경로를 잡고 막다른 길이면 꼬리를 쫓는 뱀</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
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;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__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;
}
.sc__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;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
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: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { 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);
}
.sc {
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;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__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;
}
.sc__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;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
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 BG = [29, 26, 44];
const TILE_A = [37, 33, 58];
const TILE_B = [42, 38, 66];
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 glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
// 둥근 사각형 경로 (roundRect 가 없는 브라우저에서도 같은 모양)
function rrPath(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
function makeBoard(rows) {
const cell = H / rows;
const cols = Math.floor(W / cell + 1e-6);
return { cols: cols, rows: rows, cell: cell, ox: (W - cols * cell) / 2, oy: 0, n: cols * rows };
}
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
// 칸 번호 y*cols+x. 판 밖이면 -1 (wrap 이면 반대편으로 이어진다)
function cellAt(b, x, y, wrap) {
let xx = x;
let yy = y;
if (wrap) {
xx = (x % b.cols + b.cols) % b.cols;
yy = (y % b.rows + b.rows) % b.rows;
}
else if (x < 0 || y < 0 || x >= b.cols || y >= b.rows) {
return -1;
}
return yy * b.cols + xx;
}
function neighborCell(b, i, d, wrap) {
return cellAt(b, (i % b.cols) + DX[d], Math.floor(i / b.cols) + DY[d], wrap);
}
// 목표 칸에서 너비 우선으로 퍼지는 거리장. blocked 칸과 닿지 못하는 칸은 -1
function distField(b, target, blocked, wrap) {
const dist = new Int16Array(b.n).fill(-1);
const queue = new Int16Array(b.n);
let qh = 0;
let qt = 0;
dist[target] = 0;
queue[qt] = target;
qt += 1;
while (qh < qt) {
const c = queue[qh];
qh += 1;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb >= 0 && dist[nb] < 0 && blocked[nb] === 0) {
dist[nb] = dist[c] + 1;
queue[qt] = nb;
qt += 1;
}
}
}
return dist;
}
// 거리장에서 값이 가장 낮은 이웃 (없으면 -1)
function lowestNeighbor(b, dist, from, wrap) {
let best = -1;
let bv = 32000;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, from, d, wrap);
if (nb >= 0 && dist[nb] >= 0 && dist[nb] < bv) {
bv = dist[nb];
best = nb;
}
}
return best;
}
function pathFrom(b, dist, head, wrap) {
const path = [];
let cur = head;
for (let k = 0; k < b.n; k += 1) {
const nx = lowestNeighbor(b, dist, cur, wrap);
if (nx < 0 || (dist[nx] >= dist[cur] && cur !== head)) {
return [];
}
path.push(nx);
if (dist[nx] === 0) {
return path;
}
cur = nx;
}
return [];
}
function placeFood(g) {
const used = new Uint8Array(g.b.n);
for (let i = 0; i < g.body.length; i += 1) {
used[g.body[i]] = 1;
}
const free = [];
for (let i = 0; i < g.b.n; i += 1) {
if (used[i] === 0) {
free.push(i);
}
}
g.food = free.length > 0 ? free[Math.floor(g.rnd() * free.length)] : -1;
}
function resetGame(g) {
const b = g.b;
const hx = Math.floor(b.cols / 2);
const hy = Math.floor(b.rows / 2);
g.body = [hy * b.cols + hx, hy * b.cols + hx - 1, hy * b.cols + hx - 2];
g.prev = g.body.slice();
g.dead = false;
g.resets += 1;
placeFood(g);
}
function newGame(b, wrap, seed) {
const g = { b: b, wrap: wrap, body: [], prev: [], food: -1, rnd: lcg(seed), eaten: 0, dead: false, resets: -1 };
resetGame(g);
return g;
}
// 머리를 next 칸으로 옮긴다. 먹으면 true, 몸에 부딪히면 dead
function moveSnake(g, next) {
g.prev = g.body.slice();
const ate = next === g.food;
const tail = g.body[g.body.length - 1];
for (let i = 0; i < g.body.length; i += 1) {
if (g.body[i] === next && !(i === g.body.length - 1 && !ate && next === tail)) {
g.dead = true;
return false;
}
}
g.body.unshift(next);
if (ate) {
g.eaten += 1;
placeFood(g);
}
else {
g.body.pop();
}
return ate;
}
// 경로를 끝까지 따라가 먹은 뒤의 가상 몸
function simulate(body, path) {
const v = body.slice();
for (let k = 0; k < path.length; k += 1) {
v.unshift(path[k]);
if (k < path.length - 1) {
v.pop();
}
}
return v;
}
// 가상 몸의 머리에서 번져 나가 꼬리에 닿는가. mark 를 주면 닿은 칸을 표시한다
function tailReachable(b, v, wrap, mark) {
const blocked = new Uint8Array(b.n);
for (let i = 1; i < v.length - 1; i += 1) {
blocked[v[i]] = 1;
}
const tail = v[v.length - 1];
const head = v[0];
const seen = new Uint8Array(b.n);
const stack = [head];
seen[head] = 1;
let hit = false;
while (stack.length > 0) {
const c = stack.pop();
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb < 0 || seen[nb] === 1) {
continue;
}
if (nb === tail) {
hit = true;
continue;
}
if (blocked[nb] === 1 || nb === head) {
continue;
}
seen[nb] = 1;
stack.push(nb);
}
}
if (mark) {
mark.set(seen);
}
return hit;
}
// 한 수를 정한다: 먹이까지 최단 경로 → (안전 검사) → 막히면 꼬리를 쫓는다
function planMove(g, safeOn) {
const b = g.b;
const head = g.body[0];
const tail = g.body[g.body.length - 1];
const plan = { next: -1, path: [], rejected: [], trap: new Uint8Array(b.n), chase: false };
const blocked = new Uint8Array(b.n);
for (let i = 0; i < g.body.length - 1; i += 1) {
blocked[g.body[i]] = 1;
}
if (g.food >= 0) {
const dist = distField(b, g.food, blocked, g.wrap);
const path = pathFrom(b, dist, head, g.wrap);
plan.path = path;
if (path.length > 0) {
if (!safeOn) {
plan.next = path[0];
return plan;
}
const v = simulate(g.body, path);
if (tailReachable(b, v, g.wrap, plan.trap)) {
plan.next = path[0];
return plan;
}
plan.rejected = path;
}
}
plan.chase = true;
const dist2 = distField(b, tail, blocked, g.wrap);
const nx = lowestNeighbor(b, dist2, head, g.wrap);
if (nx >= 0) {
plan.next = nx;
return plan;
}
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, head, d, g.wrap);
if (nb >= 0 && blocked[nb] === 0 && nb !== head) {
plan.next = nb;
return plan;
}
}
return plan;
}
// 칸 중심의 화면 좌표
function cellX(b, i) { return b.ox + ((i % b.cols) + 0.5) * b.cell; }
function cellY(b, i) { return b.oy + (Math.floor(i / b.cols) + 0.5) * b.cell; }
function wrapDelta(a, c, n, wrap) {
let d = c - a;
if (wrap) {
if (d > n / 2) {
d -= n;
}
if (d < -n / 2) {
d += n;
}
}
return d;
}
// 마디 i 의 보간 위치 (칸 좌표). 지난 틱의 자리에서 이번 틱의 자리로 p(0~1) 만큼
function segPos(g, i, p) {
const b = g.b;
const cur = g.body[i];
const pv = i < g.prev.length ? g.prev[i] : cur;
const cx = cur % b.cols;
const cy = Math.floor(cur / b.cols);
const px = pv % b.cols;
const py = Math.floor(pv / b.cols);
return [px + wrapDelta(px, cx, b.cols, g.wrap) * p, py + wrapDelta(py, cy, b.rows, g.wrap) * p];
}
function drawTiles(ctx, b) {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
for (let y = 0; y < b.rows; y += 1) {
for (let x = 0; x < b.cols; x += 1) {
ctx.fillStyle = rgba((x + y) % 2 === 0 ? TILE_A : TILE_B, 1);
ctx.fillRect(b.ox + x * b.cell, b.oy + y * b.cell, b.cell + 0.5, b.cell + 0.5);
}
}
}
function drawFood(ctx, b, food, t) {
if (food < 0) {
return;
}
const x = cellX(b, food);
const y = cellY(b, food);
const pulse = 0.5 + 0.5 * Math.sin(t * 0.12);
glow(ctx, x, y, b.cell * (1.3 + 0.35 * pulse), PINK, 0.55);
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.arc(x, y, b.cell * 0.3, 0, PI2);
ctx.fill();
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.arc(x - b.cell * 0.08, y - b.cell * 0.09, b.cell * 0.09, 0, PI2);
ctx.fill();
}
function bodyColor(i, len) {
return mix(MINT, SKY, len <= 1 ? 0 : i / (len - 1));
}
// 칸 중심 (x,y 칸 좌표, 소수 허용)에 마디 하나
function drawSeg(ctx, b, gx, gy, c, shrink) {
const s = b.cell * (1 - shrink);
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
ctx.fillStyle = rgba(c, 1);
rrPath(ctx, cx - s / 2, cy - s / 2, s, s, s * 0.32);
ctx.fill();
}
function drawEyes(ctx, b, gx, gy, dirx, diry) {
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
const px = -diry;
const py = dirx;
const r = b.cell * 0.09;
ctx.fillStyle = rgba(BG, 1);
for (let s = -1; s <= 1; s += 2) {
ctx.beginPath();
ctx.arc(cx + dirx * b.cell * 0.16 + px * s * b.cell * 0.18, cy + diry * b.cell * 0.16 + py * s * b.cell * 0.18, r, 0, PI2);
ctx.fill();
}
}
function headDir(g) {
if (g.body.length < 2) {
return [1, 0];
}
const b = g.b;
const dx = wrapDelta(g.body[1] % b.cols, g.body[0] % b.cols, b.cols, g.wrap);
const dy = wrapDelta(Math.floor(g.body[1] / b.cols), Math.floor(g.body[0] / b.cols), b.rows, g.wrap);
return [clamp(dx, -1, 1), clamp(dy, -1, 1)];
}
// 보간 없이 칸 그대로 그리는 기본 뱀
function drawSnakeCells(ctx, g, dim) {
const len = g.body.length;
for (let i = len - 1; i >= 0; i -= 1) {
const c = g.body[i];
const col = mix(bodyColor(i, len), BG, dim);
drawSeg(ctx, g.b, c % g.b.cols, Math.floor(c / g.b.cols), col, 0.08);
}
const hd = headDir(g);
drawEyes(ctx, g.b, g.body[0] % g.b.cols, Math.floor(g.body[0] / g.b.cols), hd[0], hd[1]);
}
function start(root, canvas, statusEl, btnEl, make) {
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: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = 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);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
// 움직임 줄이기에서는 루프가 없으니 입력 직후 한 틱쯤 앞으로 보내 바로 그린다
function refresh() {
if (reduce) {
advance(FIXED_DT * 8);
show();
}
}
function toLocal(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;
}
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');
advance(1400);
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
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, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
// 이 장면이 쓰는 키만 막는다. 쓰지 않는 키(Tab 등)는 그대로 둔다
if (scene.key(k)) {
e.preventDefault();
refresh();
}
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const TICK_MS = 90;
const GRID_N = 16;
const SAFE_CHECK = true;
const REJECT_LIFE = 8;
const WARM_TICKS = 826;
const TXT_SAFE_ON = '검사 켜짐';
const TXT_SAFE_OFF = '검사 꺼짐';
const TXT_LEN = '길이';
const TXT_DROP = '버린 길';
const TXT_DEAD = '갇혀서 끝 — 다시 시작';
function makeScene(inp, reduce) {
const b = makeBoard(GRID_N);
const g = newGame(b, false, 455101);
let safeOn = SAFE_CHECK;
let acc = 0;
let t = 0;
let rejected = [];
let rejLife = 0;
let trap = new Uint8Array(b.n);
let dropped = 0;
let route = [];
let hold = 0;
function logic() {
if (hold > 0) {
hold -= 1;
if (hold === 0) {
resetGame(g);
rejected = [];
rejLife = 0;
route = [];
}
return;
}
const p = planMove(g, safeOn);
route = p.path;
if (p.rejected.length > 0) {
if (rejLife === 0) {
dropped += 1;
}
rejected = p.rejected;
rejLife = REJECT_LIFE;
trap = p.trap;
}
if (rejLife > 0 && p.rejected.length === 0) {
rejLife -= 1;
}
if (p.next < 0) {
g.dead = true;
}
else {
moveSnake(g, p.next);
}
if (g.dead) {
hold = 8;
}
}
const scene = {
step() {
t += 1;
acc += FIXED_DT;
while (acc >= TICK_MS) {
acc -= TICK_MS;
logic();
}
},
draw(ctx) {
drawTiles(ctx, b);
if (rejLife > 0) {
const a = 0.1 + 0.24 * (rejLife / REJECT_LIFE);
ctx.fillStyle = rgba(PINK, a);
for (let i = 0; i < b.n; i += 1) {
if (trap[i] === 1) {
ctx.fillRect(b.ox + (i % b.cols) * b.cell, b.oy + Math.floor(i / b.cols) * b.cell, b.cell + 0.5, b.cell + 0.5);
}
}
}
for (let k = 0; k < route.length; k += 1) {
ctx.fillStyle = rgba(SKY, 0.5);
ctx.beginPath();
ctx.arc(cellX(b, route[k]), cellY(b, route[k]), b.cell * 0.11, 0, PI2);
ctx.fill();
}
if (rejLife > 0) {
ctx.strokeStyle = rgba(PINK, 0.35 + 0.65 * (rejLife / REJECT_LIFE));
ctx.lineWidth = b.cell * 0.2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(cellX(b, g.body[0]), cellY(b, g.body[0]));
for (let k = 0; k < rejected.length; k += 1) {
ctx.lineTo(cellX(b, rejected[k]), cellY(b, rejected[k]));
}
ctx.stroke();
}
drawFood(ctx, b, g.food, t);
drawSnakeCells(ctx, g, g.dead ? 0.55 : 0);
},
label() {
if (g.dead) {
return TXT_DEAD;
}
return (safeOn ? TXT_SAFE_ON : TXT_SAFE_OFF) + ' · ' + TXT_LEN + ' ' + g.body.length + ' · ' + TXT_DROP + ' ' + dropped;
},
tap() {
safeOn = !safeOn;
},
key(k) {
return false;
}
};
for (let i = 0; i < WARM_TICKS; i += 1) {
logic();
}
dropped = 0;
rejLife = 0;
if (reduce) {
for (let i = 0; i < 4; i += 1) {
scene.step();
}
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./SkBfsSafeAutopilot.module.scss";
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 = '다음 장면';
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
key(k: string): boolean;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 팔레트 — 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG: RGB = [29, 26, 44];
const TILE_A: RGB = [37, 33, 58];
const TILE_B: RGB = [42, 38, 66];
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 glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: RGB, a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
// 둥근 사각형 경로 (roundRect 가 없는 브라우저에서도 같은 모양)
function rrPath(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.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// ---- 판과 뱀 ----
interface Board { cols: number; rows: number; cell: number; ox: number; oy: number; n: number; }
function makeBoard(rows: number): Board {
const cell = H / rows;
const cols = Math.floor(W / cell + 1e-6);
return { cols: cols, rows: rows, cell: cell, ox: (W - cols * cell) / 2, oy: 0, n: cols * rows };
}
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
// 칸 번호 y*cols+x. 판 밖이면 -1 (wrap 이면 반대편으로 이어진다)
function cellAt(b: Board, x: number, y: number, wrap: boolean): number {
let xx = x;
let yy = y;
if (wrap) {
xx = (x % b.cols + b.cols) % b.cols;
yy = (y % b.rows + b.rows) % b.rows;
} else if (x < 0 || y < 0 || x >= b.cols || y >= b.rows) {
return -1;
}
return yy * b.cols + xx;
}
function neighborCell(b: Board, i: number, d: number, wrap: boolean): number {
return cellAt(b, (i % b.cols) + DX[d], Math.floor(i / b.cols) + DY[d], wrap);
}
// 목표 칸에서 너비 우선으로 퍼지는 거리장. blocked 칸과 닿지 못하는 칸은 -1
function distField(b: Board, target: number, blocked: Uint8Array, wrap: boolean): Int16Array {
const dist = new Int16Array(b.n).fill(-1);
const queue = new Int16Array(b.n);
let qh = 0;
let qt = 0;
dist[target] = 0;
queue[qt] = target;
qt += 1;
while (qh < qt) {
const c = queue[qh];
qh += 1;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb >= 0 && dist[nb] < 0 && blocked[nb] === 0) {
dist[nb] = dist[c] + 1;
queue[qt] = nb;
qt += 1;
}
}
}
return dist;
}
// 거리장에서 값이 가장 낮은 이웃 (없으면 -1)
function lowestNeighbor(b: Board, dist: Int16Array, from: number, wrap: boolean): number {
let best = -1;
let bv = 32000;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, from, d, wrap);
if (nb >= 0 && dist[nb] >= 0 && dist[nb] < bv) {
bv = dist[nb];
best = nb;
}
}
return best;
}
function pathFrom(b: Board, dist: Int16Array, head: number, wrap: boolean): number[] {
const path: number[] = [];
let cur = head;
for (let k = 0; k < b.n; k += 1) {
const nx = lowestNeighbor(b, dist, cur, wrap);
if (nx < 0 || (dist[nx] >= dist[cur] && cur !== head)) {
return [];
}
path.push(nx);
if (dist[nx] === 0) {
return path;
}
cur = nx;
}
return [];
}
interface Game {
b: Board;
wrap: boolean;
body: number[];
prev: number[];
food: number;
rnd: () => number;
eaten: number;
dead: boolean;
resets: number;
}
function placeFood(g: Game): void {
const used = new Uint8Array(g.b.n);
for (let i = 0; i < g.body.length; i += 1) {
used[g.body[i]] = 1;
}
const free: number[] = [];
for (let i = 0; i < g.b.n; i += 1) {
if (used[i] === 0) {
free.push(i);
}
}
g.food = free.length > 0 ? free[Math.floor(g.rnd() * free.length)] : -1;
}
function resetGame(g: Game): void {
const b = g.b;
const hx = Math.floor(b.cols / 2);
const hy = Math.floor(b.rows / 2);
g.body = [hy * b.cols + hx, hy * b.cols + hx - 1, hy * b.cols + hx - 2];
g.prev = g.body.slice();
g.dead = false;
g.resets += 1;
placeFood(g);
}
function newGame(b: Board, wrap: boolean, seed: number): Game {
const g: Game = { b: b, wrap: wrap, body: [], prev: [], food: -1, rnd: lcg(seed), eaten: 0, dead: false, resets: -1 };
resetGame(g);
return g;
}
// 머리를 next 칸으로 옮긴다. 먹으면 true, 몸에 부딪히면 dead
function moveSnake(g: Game, next: number): boolean {
g.prev = g.body.slice();
const ate = next === g.food;
const tail = g.body[g.body.length - 1];
for (let i = 0; i < g.body.length; i += 1) {
if (g.body[i] === next && !(i === g.body.length - 1 && !ate && next === tail)) {
g.dead = true;
return false;
}
}
g.body.unshift(next);
if (ate) {
g.eaten += 1;
placeFood(g);
} else {
g.body.pop();
}
return ate;
}
// 경로를 끝까지 따라가 먹은 뒤의 가상 몸
function simulate(body: number[], path: number[]): number[] {
const v = body.slice();
for (let k = 0; k < path.length; k += 1) {
v.unshift(path[k]);
if (k < path.length - 1) {
v.pop();
}
}
return v;
}
// 가상 몸의 머리에서 번져 나가 꼬리에 닿는가. mark 를 주면 닿은 칸을 표시한다
function tailReachable(b: Board, v: number[], wrap: boolean, mark: Uint8Array | null): boolean {
const blocked = new Uint8Array(b.n);
for (let i = 1; i < v.length - 1; i += 1) {
blocked[v[i]] = 1;
}
const tail = v[v.length - 1];
const head = v[0];
const seen = new Uint8Array(b.n);
const stack: number[] = [head];
seen[head] = 1;
let hit = false;
while (stack.length > 0) {
const c = stack.pop() as number;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb < 0 || seen[nb] === 1) {
continue;
}
if (nb === tail) {
hit = true;
continue;
}
if (blocked[nb] === 1 || nb === head) {
continue;
}
seen[nb] = 1;
stack.push(nb);
}
}
if (mark) {
mark.set(seen);
}
return hit;
}
interface Plan { next: number; path: number[]; rejected: number[]; trap: Uint8Array; chase: boolean; }
// 한 수를 정한다: 먹이까지 최단 경로 → (안전 검사) → 막히면 꼬리를 쫓는다
function planMove(g: Game, safeOn: boolean): Plan {
const b = g.b;
const head = g.body[0];
const tail = g.body[g.body.length - 1];
const plan: Plan = { next: -1, path: [], rejected: [], trap: new Uint8Array(b.n), chase: false };
const blocked = new Uint8Array(b.n);
for (let i = 0; i < g.body.length - 1; i += 1) {
blocked[g.body[i]] = 1;
}
if (g.food >= 0) {
const dist = distField(b, g.food, blocked, g.wrap);
const path = pathFrom(b, dist, head, g.wrap);
plan.path = path;
if (path.length > 0) {
if (!safeOn) {
plan.next = path[0];
return plan;
}
const v = simulate(g.body, path);
if (tailReachable(b, v, g.wrap, plan.trap)) {
plan.next = path[0];
return plan;
}
plan.rejected = path;
}
}
plan.chase = true;
const dist2 = distField(b, tail, blocked, g.wrap);
const nx = lowestNeighbor(b, dist2, head, g.wrap);
if (nx >= 0) {
plan.next = nx;
return plan;
}
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, head, d, g.wrap);
if (nb >= 0 && blocked[nb] === 0 && nb !== head) {
plan.next = nb;
return plan;
}
}
return plan;
}
// 칸 중심의 화면 좌표
function cellX(b: Board, i: number): number { return b.ox + ((i % b.cols) + 0.5) * b.cell; }
function cellY(b: Board, i: number): number { return b.oy + (Math.floor(i / b.cols) + 0.5) * b.cell; }
function wrapDelta(a: number, c: number, n: number, wrap: boolean): number {
let d = c - a;
if (wrap) {
if (d > n / 2) { d -= n; }
if (d < -n / 2) { d += n; }
}
return d;
}
// 마디 i 의 보간 위치 (칸 좌표). 지난 틱의 자리에서 이번 틱의 자리로 p(0~1) 만큼
function segPos(g: Game, i: number, p: number): [number, number] {
const b = g.b;
const cur = g.body[i];
const pv = i < g.prev.length ? g.prev[i] : cur;
const cx = cur % b.cols;
const cy = Math.floor(cur / b.cols);
const px = pv % b.cols;
const py = Math.floor(pv / b.cols);
return [px + wrapDelta(px, cx, b.cols, g.wrap) * p, py + wrapDelta(py, cy, b.rows, g.wrap) * p];
}
function drawTiles(ctx: CanvasRenderingContext2D, b: Board): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
for (let y = 0; y < b.rows; y += 1) {
for (let x = 0; x < b.cols; x += 1) {
ctx.fillStyle = rgba((x + y) % 2 === 0 ? TILE_A : TILE_B, 1);
ctx.fillRect(b.ox + x * b.cell, b.oy + y * b.cell, b.cell + 0.5, b.cell + 0.5);
}
}
}
function drawFood(ctx: CanvasRenderingContext2D, b: Board, food: number, t: number): void {
if (food < 0) {
return;
}
const x = cellX(b, food);
const y = cellY(b, food);
const pulse = 0.5 + 0.5 * Math.sin(t * 0.12);
glow(ctx, x, y, b.cell * (1.3 + 0.35 * pulse), PINK, 0.55);
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.arc(x, y, b.cell * 0.3, 0, PI2);
ctx.fill();
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.arc(x - b.cell * 0.08, y - b.cell * 0.09, b.cell * 0.09, 0, PI2);
ctx.fill();
}
function bodyColor(i: number, len: number): RGB {
return mix(MINT, SKY, len <= 1 ? 0 : i / (len - 1));
}
// 칸 중심 (x,y 칸 좌표, 소수 허용)에 마디 하나
function drawSeg(ctx: CanvasRenderingContext2D, b: Board, gx: number, gy: number, c: RGB, shrink: number): void {
const s = b.cell * (1 - shrink);
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
ctx.fillStyle = rgba(c, 1);
rrPath(ctx, cx - s / 2, cy - s / 2, s, s, s * 0.32);
ctx.fill();
}
function drawEyes(ctx: CanvasRenderingContext2D, b: Board, gx: number, gy: number, dirx: number, diry: number): void {
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
const px = -diry;
const py = dirx;
const r = b.cell * 0.09;
ctx.fillStyle = rgba(BG, 1);
for (let s = -1; s <= 1; s += 2) {
ctx.beginPath();
ctx.arc(cx + dirx * b.cell * 0.16 + px * s * b.cell * 0.18, cy + diry * b.cell * 0.16 + py * s * b.cell * 0.18, r, 0, PI2);
ctx.fill();
}
}
function headDir(g: Game): [number, number] {
if (g.body.length < 2) {
return [1, 0];
}
const b = g.b;
const dx = wrapDelta(g.body[1] % b.cols, g.body[0] % b.cols, b.cols, g.wrap);
const dy = wrapDelta(Math.floor(g.body[1] / b.cols), Math.floor(g.body[0] / b.cols), b.rows, g.wrap);
return [clamp(dx, -1, 1), clamp(dy, -1, 1)];
}
// 보간 없이 칸 그대로 그리는 기본 뱀
function drawSnakeCells(ctx: CanvasRenderingContext2D, g: Game, dim: number): void {
const len = g.body.length;
for (let i = len - 1; i >= 0; i -= 1) {
const c = g.body[i];
const col = mix(bodyColor(i, len), BG, dim);
drawSeg(ctx, g.b, c % g.b.cols, Math.floor(c / g.b.cols), col, 0.08);
}
const hd = headDir(g);
drawEyes(ctx, g.b, g.body[0] % g.b.cols, Math.floor(g.body[0] / g.b.cols), hd[0], hd[1]);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement, make: Maker): () => void {
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: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = 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);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
// 움직임 줄이기에서는 루프가 없으니 입력 직후 한 틱쯤 앞으로 보내 바로 그린다
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 8);
show();
}
}
function toLocal(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;
}
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');
advance(1400);
show();
}
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
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, 'pointermove', function (e: Event): void {
toLocal(e as PointerEvent);
refresh();
});
listen(canvas, 'pointerdown', function (e: Event): void {
toLocal(e as PointerEvent);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
// 이 장면이 쓰는 키만 막는다. 쓰지 않는 키(Tab 등)는 그대로 둔다
if (scene.key(k)) {
e.preventDefault();
refresh();
}
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const TICK_MS = 90;
const GRID_N = 16;
const SAFE_CHECK = true;
const REJECT_LIFE = 8;
const WARM_TICKS = 826;
const TXT_SAFE_ON = '검사 켜짐';
const TXT_SAFE_OFF = '검사 꺼짐';
const TXT_LEN = '길이';
const TXT_DROP = '버린 길';
const TXT_DEAD = '갇혀서 끝 — 다시 시작';
function makeScene(inp: Input, reduce: boolean): Scene {
const b = makeBoard(GRID_N);
const g = newGame(b, false, 455101);
let safeOn = SAFE_CHECK;
let acc = 0;
let t = 0;
let rejected: number[] = [];
let rejLife = 0;
let trap: Uint8Array = new Uint8Array(b.n);
let dropped = 0;
let route: number[] = [];
let hold = 0;
function logic(): void {
if (hold > 0) {
hold -= 1;
if (hold === 0) {
resetGame(g);
rejected = [];
rejLife = 0;
route = [];
}
return;
}
const p = planMove(g, safeOn);
route = p.path;
if (p.rejected.length > 0) {
if (rejLife === 0) {
dropped += 1;
}
rejected = p.rejected;
rejLife = REJECT_LIFE;
trap = p.trap;
}
if (rejLife > 0 && p.rejected.length === 0) {
rejLife -= 1;
}
if (p.next < 0) {
g.dead = true;
} else {
moveSnake(g, p.next);
}
if (g.dead) {
hold = 8;
}
}
const scene: Scene = {
step(): void {
t += 1;
acc += FIXED_DT;
while (acc >= TICK_MS) {
acc -= TICK_MS;
logic();
}
},
draw(ctx: CanvasRenderingContext2D): void {
drawTiles(ctx, b);
if (rejLife > 0) {
const a = 0.1 + 0.24 * (rejLife / REJECT_LIFE);
ctx.fillStyle = rgba(PINK, a);
for (let i = 0; i < b.n; i += 1) {
if (trap[i] === 1) {
ctx.fillRect(b.ox + (i % b.cols) * b.cell, b.oy + Math.floor(i / b.cols) * b.cell, b.cell + 0.5, b.cell + 0.5);
}
}
}
for (let k = 0; k < route.length; k += 1) {
ctx.fillStyle = rgba(SKY, 0.5);
ctx.beginPath();
ctx.arc(cellX(b, route[k]), cellY(b, route[k]), b.cell * 0.11, 0, PI2);
ctx.fill();
}
if (rejLife > 0) {
ctx.strokeStyle = rgba(PINK, 0.35 + 0.65 * (rejLife / REJECT_LIFE));
ctx.lineWidth = b.cell * 0.2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(cellX(b, g.body[0]), cellY(b, g.body[0]));
for (let k = 0; k < rejected.length; k += 1) {
ctx.lineTo(cellX(b, rejected[k]), cellY(b, rejected[k]));
}
ctx.stroke();
}
drawFood(ctx, b, g.food, t);
drawSnakeCells(ctx, g, g.dead ? 0.55 : 0);
},
label(): string {
if (g.dead) {
return TXT_DEAD;
}
return (safeOn ? TXT_SAFE_ON : TXT_SAFE_OFF) + ' · ' + TXT_LEN + ' ' + g.body.length + ' · ' + TXT_DROP + ' ' + dropped;
},
tap(): void {
safeOn = !safeOn;
},
key(k: string): boolean {
return false;
}
};
for (let i = 0; i < WARM_TICKS; i += 1) {
logic();
}
dropped = 0;
rejLife = 0;
if (reduce) {
for (let i = 0; i < 4; i += 1) {
scene.step();
}
}
return scene;
}
export default function SkBfsSafeAutopilot() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="너비 우선 탐색으로 먹이까지 최단 경로를 잡고 막다른 길이면 꼬리를 쫓는 뱀" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">검사 켜짐</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>너비 우선 탐색으로 먹이까지 최단 경로를 잡고 막다른 길이면 꼬리를 쫓는 뱀</p>
</div>
</main>
);
}01 · 너비 우선 탐색으로 먹이까지 최단 경로를 잡고 막다른 길이면 꼬리를 쫓는 뱀 — 측정 누적면적 49.751% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
if (g.food >= 0) {
const dist = distField(b, g.food, blocked, g.wrap);
const path = pathFrom(b, dist, head, g.wrap);
plan.path = path;
if (path.length > 0) {
if (!safeOn) {
plan.next = path[0];
return plan;
}
const v = simulate(g.body, path);
if (tailReachable(b, v, g.wrap, plan.trap)) {
plan.next = path[0];
return plan;
}
plan.rejected = path;
}
}
02판 전체를 한 바퀴 도는 해밀턴 순환으로 판을 끝까지 채우는 뱀
판의 모든 칸을 한 번씩 지나는 고정 순환에 ord 로 번호를 매기고, 이웃 중 먹이에 가까우면서 꼬리를 앞지르지 않는 칸이 있으면 순환 순서를 건너뛴다. 시작은 숨은 틱으로 91/96칸을 채운 상태라 약 10틱 뒤 판이 가득 차고, 14틱 멈춘 다음 새 판이 열린다. 누적면적 61.4%, 평균 강도 90.0 이고 움직인 프레임이 17/23 인 것은 가득 찬 판이 멈춰 있는 동안 화면이 같기 때문이다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="판 전체를 한 바퀴 도는 해밀턴 순환으로 판을 끝까지 채우는 뱀"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">길이</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">판 전체를 한 바퀴 도는 해밀턴 순환으로 판을 끝까지 채우는 뱀</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
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;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__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;
}
.sc__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;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
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: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { 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);
}
.sc {
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;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__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;
}
.sc__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;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
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 BG = [29, 26, 44];
const TILE_A = [37, 33, 58];
const TILE_B = [42, 38, 66];
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 glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
// 둥근 사각형 경로 (roundRect 가 없는 브라우저에서도 같은 모양)
function rrPath(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
function makeBoard(rows) {
const cell = H / rows;
const cols = Math.floor(W / cell + 1e-6);
return { cols: cols, rows: rows, cell: cell, ox: (W - cols * cell) / 2, oy: 0, n: cols * rows };
}
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
// 칸 번호 y*cols+x. 판 밖이면 -1 (wrap 이면 반대편으로 이어진다)
function cellAt(b, x, y, wrap) {
let xx = x;
let yy = y;
if (wrap) {
xx = (x % b.cols + b.cols) % b.cols;
yy = (y % b.rows + b.rows) % b.rows;
}
else if (x < 0 || y < 0 || x >= b.cols || y >= b.rows) {
return -1;
}
return yy * b.cols + xx;
}
function neighborCell(b, i, d, wrap) {
return cellAt(b, (i % b.cols) + DX[d], Math.floor(i / b.cols) + DY[d], wrap);
}
// 목표 칸에서 너비 우선으로 퍼지는 거리장. blocked 칸과 닿지 못하는 칸은 -1
function distField(b, target, blocked, wrap) {
const dist = new Int16Array(b.n).fill(-1);
const queue = new Int16Array(b.n);
let qh = 0;
let qt = 0;
dist[target] = 0;
queue[qt] = target;
qt += 1;
while (qh < qt) {
const c = queue[qh];
qh += 1;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb >= 0 && dist[nb] < 0 && blocked[nb] === 0) {
dist[nb] = dist[c] + 1;
queue[qt] = nb;
qt += 1;
}
}
}
return dist;
}
// 거리장에서 값이 가장 낮은 이웃 (없으면 -1)
function lowestNeighbor(b, dist, from, wrap) {
let best = -1;
let bv = 32000;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, from, d, wrap);
if (nb >= 0 && dist[nb] >= 0 && dist[nb] < bv) {
bv = dist[nb];
best = nb;
}
}
return best;
}
function pathFrom(b, dist, head, wrap) {
const path = [];
let cur = head;
for (let k = 0; k < b.n; k += 1) {
const nx = lowestNeighbor(b, dist, cur, wrap);
if (nx < 0 || (dist[nx] >= dist[cur] && cur !== head)) {
return [];
}
path.push(nx);
if (dist[nx] === 0) {
return path;
}
cur = nx;
}
return [];
}
function placeFood(g) {
const used = new Uint8Array(g.b.n);
for (let i = 0; i < g.body.length; i += 1) {
used[g.body[i]] = 1;
}
const free = [];
for (let i = 0; i < g.b.n; i += 1) {
if (used[i] === 0) {
free.push(i);
}
}
g.food = free.length > 0 ? free[Math.floor(g.rnd() * free.length)] : -1;
}
function resetGame(g) {
const b = g.b;
const hx = Math.floor(b.cols / 2);
const hy = Math.floor(b.rows / 2);
g.body = [hy * b.cols + hx, hy * b.cols + hx - 1, hy * b.cols + hx - 2];
g.prev = g.body.slice();
g.dead = false;
g.resets += 1;
placeFood(g);
}
function newGame(b, wrap, seed) {
const g = { b: b, wrap: wrap, body: [], prev: [], food: -1, rnd: lcg(seed), eaten: 0, dead: false, resets: -1 };
resetGame(g);
return g;
}
// 머리를 next 칸으로 옮긴다. 먹으면 true, 몸에 부딪히면 dead
function moveSnake(g, next) {
g.prev = g.body.slice();
const ate = next === g.food;
const tail = g.body[g.body.length - 1];
for (let i = 0; i < g.body.length; i += 1) {
if (g.body[i] === next && !(i === g.body.length - 1 && !ate && next === tail)) {
g.dead = true;
return false;
}
}
g.body.unshift(next);
if (ate) {
g.eaten += 1;
placeFood(g);
}
else {
g.body.pop();
}
return ate;
}
// 경로를 끝까지 따라가 먹은 뒤의 가상 몸
function simulate(body, path) {
const v = body.slice();
for (let k = 0; k < path.length; k += 1) {
v.unshift(path[k]);
if (k < path.length - 1) {
v.pop();
}
}
return v;
}
// 가상 몸의 머리에서 번져 나가 꼬리에 닿는가. mark 를 주면 닿은 칸을 표시한다
function tailReachable(b, v, wrap, mark) {
const blocked = new Uint8Array(b.n);
for (let i = 1; i < v.length - 1; i += 1) {
blocked[v[i]] = 1;
}
const tail = v[v.length - 1];
const head = v[0];
const seen = new Uint8Array(b.n);
const stack = [head];
seen[head] = 1;
let hit = false;
while (stack.length > 0) {
const c = stack.pop();
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb < 0 || seen[nb] === 1) {
continue;
}
if (nb === tail) {
hit = true;
continue;
}
if (blocked[nb] === 1 || nb === head) {
continue;
}
seen[nb] = 1;
stack.push(nb);
}
}
if (mark) {
mark.set(seen);
}
return hit;
}
// 한 수를 정한다: 먹이까지 최단 경로 → (안전 검사) → 막히면 꼬리를 쫓는다
function planMove(g, safeOn) {
const b = g.b;
const head = g.body[0];
const tail = g.body[g.body.length - 1];
const plan = { next: -1, path: [], rejected: [], trap: new Uint8Array(b.n), chase: false };
const blocked = new Uint8Array(b.n);
for (let i = 0; i < g.body.length - 1; i += 1) {
blocked[g.body[i]] = 1;
}
if (g.food >= 0) {
const dist = distField(b, g.food, blocked, g.wrap);
const path = pathFrom(b, dist, head, g.wrap);
plan.path = path;
if (path.length > 0) {
if (!safeOn) {
plan.next = path[0];
return plan;
}
const v = simulate(g.body, path);
if (tailReachable(b, v, g.wrap, plan.trap)) {
plan.next = path[0];
return plan;
}
plan.rejected = path;
}
}
plan.chase = true;
const dist2 = distField(b, tail, blocked, g.wrap);
const nx = lowestNeighbor(b, dist2, head, g.wrap);
if (nx >= 0) {
plan.next = nx;
return plan;
}
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, head, d, g.wrap);
if (nb >= 0 && blocked[nb] === 0 && nb !== head) {
plan.next = nb;
return plan;
}
}
return plan;
}
// 칸 중심의 화면 좌표
function cellX(b, i) { return b.ox + ((i % b.cols) + 0.5) * b.cell; }
function cellY(b, i) { return b.oy + (Math.floor(i / b.cols) + 0.5) * b.cell; }
function wrapDelta(a, c, n, wrap) {
let d = c - a;
if (wrap) {
if (d > n / 2) {
d -= n;
}
if (d < -n / 2) {
d += n;
}
}
return d;
}
// 마디 i 의 보간 위치 (칸 좌표). 지난 틱의 자리에서 이번 틱의 자리로 p(0~1) 만큼
function segPos(g, i, p) {
const b = g.b;
const cur = g.body[i];
const pv = i < g.prev.length ? g.prev[i] : cur;
const cx = cur % b.cols;
const cy = Math.floor(cur / b.cols);
const px = pv % b.cols;
const py = Math.floor(pv / b.cols);
return [px + wrapDelta(px, cx, b.cols, g.wrap) * p, py + wrapDelta(py, cy, b.rows, g.wrap) * p];
}
function drawTiles(ctx, b) {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
for (let y = 0; y < b.rows; y += 1) {
for (let x = 0; x < b.cols; x += 1) {
ctx.fillStyle = rgba((x + y) % 2 === 0 ? TILE_A : TILE_B, 1);
ctx.fillRect(b.ox + x * b.cell, b.oy + y * b.cell, b.cell + 0.5, b.cell + 0.5);
}
}
}
function drawFood(ctx, b, food, t) {
if (food < 0) {
return;
}
const x = cellX(b, food);
const y = cellY(b, food);
const pulse = 0.5 + 0.5 * Math.sin(t * 0.12);
glow(ctx, x, y, b.cell * (1.3 + 0.35 * pulse), PINK, 0.55);
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.arc(x, y, b.cell * 0.3, 0, PI2);
ctx.fill();
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.arc(x - b.cell * 0.08, y - b.cell * 0.09, b.cell * 0.09, 0, PI2);
ctx.fill();
}
function bodyColor(i, len) {
return mix(MINT, SKY, len <= 1 ? 0 : i / (len - 1));
}
// 칸 중심 (x,y 칸 좌표, 소수 허용)에 마디 하나
function drawSeg(ctx, b, gx, gy, c, shrink) {
const s = b.cell * (1 - shrink);
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
ctx.fillStyle = rgba(c, 1);
rrPath(ctx, cx - s / 2, cy - s / 2, s, s, s * 0.32);
ctx.fill();
}
function drawEyes(ctx, b, gx, gy, dirx, diry) {
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
const px = -diry;
const py = dirx;
const r = b.cell * 0.09;
ctx.fillStyle = rgba(BG, 1);
for (let s = -1; s <= 1; s += 2) {
ctx.beginPath();
ctx.arc(cx + dirx * b.cell * 0.16 + px * s * b.cell * 0.18, cy + diry * b.cell * 0.16 + py * s * b.cell * 0.18, r, 0, PI2);
ctx.fill();
}
}
function headDir(g) {
if (g.body.length < 2) {
return [1, 0];
}
const b = g.b;
const dx = wrapDelta(g.body[1] % b.cols, g.body[0] % b.cols, b.cols, g.wrap);
const dy = wrapDelta(Math.floor(g.body[1] / b.cols), Math.floor(g.body[0] / b.cols), b.rows, g.wrap);
return [clamp(dx, -1, 1), clamp(dy, -1, 1)];
}
// 보간 없이 칸 그대로 그리는 기본 뱀
function drawSnakeCells(ctx, g, dim) {
const len = g.body.length;
for (let i = len - 1; i >= 0; i -= 1) {
const c = g.body[i];
const col = mix(bodyColor(i, len), BG, dim);
drawSeg(ctx, g.b, c % g.b.cols, Math.floor(c / g.b.cols), col, 0.08);
}
const hd = headDir(g);
drawEyes(ctx, g.b, g.body[0] % g.b.cols, Math.floor(g.body[0] / g.b.cols), hd[0], hd[1]);
}
function start(root, canvas, statusEl, btnEl, make) {
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: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = 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);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
// 움직임 줄이기에서는 루프가 없으니 입력 직후 한 틱쯤 앞으로 보내 바로 그린다
function refresh() {
if (reduce) {
advance(FIXED_DT * 8);
show();
}
}
function toLocal(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;
}
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');
advance(1400);
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
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, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
// 이 장면이 쓰는 키만 막는다. 쓰지 않는 키(Tab 등)는 그대로 둔다
if (scene.key(k)) {
e.preventDefault();
refresh();
}
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const TICK_MS = 60;
const GRID_N = 8;
const SHORTCUT_MARGIN = 2;
const WARM_FILL = 0.95;
const FULL_HOLD = 14;
const TXT_LEN = '길이';
const TXT_CUT = '지름길';
const TXT_FULL = '판이 가득 참 — 새 판';
function makeScene(inp, reduce) {
const b = makeBoard(GRID_N);
const g = newGame(b, false, 455202);
const N = b.n;
// 해밀턴 순환: 맨 윗줄을 가로지른 뒤 나머지 줄을 지그재그로 훑고 왼쪽 열로 올라온다
const cyc = [];
for (let x = 0; x < b.cols; x += 1) {
cyc.push(x);
}
for (let y = 1; y < b.rows; y += 1) {
for (let k = 1; k < b.cols; k += 1) {
const x = y % 2 === 1 ? b.cols - k : k;
cyc.push(y * b.cols + x);
}
}
for (let y = b.rows - 1; y >= 1; y -= 1) {
cyc.push(y * b.cols);
}
const ord = new Int16Array(N);
for (let i = 0; i < N; i += 1) {
ord[cyc[i]] = i;
}
let acc = 0;
let t = 0;
let cuts = 0;
let hold = 0;
let jump = 0;
function cd(from, to) {
return (ord[to] - ord[from] + N) % N;
}
function fresh() {
g.body = [cyc[2], cyc[1], cyc[0]];
g.prev = g.body.slice();
g.dead = false;
placeFood(g);
cuts = 0;
}
function logic() {
if (hold > 0) {
hold -= 1;
if (hold === 0) {
fresh();
}
return;
}
const head = g.body[0];
const tail = g.body[g.body.length - 1];
const foodD = cd(head, g.food);
let tailD = cd(head, tail);
if (tailD === 0) {
tailD = N;
}
const occupied = new Uint8Array(N);
for (let i = 0; i < g.body.length - 1; i += 1) {
occupied[g.body[i]] = 1;
}
let best = cyc[(ord[head] + 1) % N];
let bestD = 1;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, head, d, false);
if (nb < 0 || occupied[nb] === 1 || nb === tail) {
continue;
}
const dc = cd(head, nb);
if (dc > bestD && dc <= foodD && dc < tailD - SHORTCUT_MARGIN) {
best = nb;
bestD = dc;
}
}
if (bestD > 1) {
cuts += 1;
jump = 6;
}
moveSnake(g, best);
if (g.body.length >= N) {
// 움직임 줄이기에서는 가득 찬 판을 그대로 두고 입력이 있을 때만 새 판을 연다
hold = reduce ? 100000 : FULL_HOLD;
}
}
const scene = {
step() {
t += 1;
acc += FIXED_DT;
while (acc >= TICK_MS) {
acc -= TICK_MS;
logic();
}
if (jump > 0) {
jump -= 1;
}
},
draw(ctx) {
drawTiles(ctx, b);
// 순환 선 — 뱀이 지나온 구간은 밝아진다
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.lineWidth = Math.max(2, b.cell * 0.1);
const inBody = new Uint8Array(N);
for (let i = 0; i < g.body.length; i += 1) {
inBody[g.body[i]] = 1;
}
for (let i = 0; i < N; i += 1) {
const a = cyc[i];
const c = cyc[(i + 1) % N];
ctx.strokeStyle = rgba(LILAC, inBody[a] === 1 ? 0.55 : 0.22);
ctx.beginPath();
ctx.moveTo(cellX(b, a), cellY(b, a));
ctx.lineTo(cellX(b, c), cellY(b, c));
ctx.stroke();
}
drawFood(ctx, b, g.food, t);
drawSnakeCells(ctx, g, 0);
if (jump > 0) {
glow(ctx, cellX(b, g.body[0]), cellY(b, g.body[0]), b.cell * 1.3, CREAM, 0.12 + 0.05 * jump);
}
},
label() {
if (hold > 0) {
return TXT_FULL;
}
return TXT_LEN + ' ' + g.body.length + '/' + N + ' · ' + TXT_CUT + ' ' + cuts;
},
tap() {
hold = 0;
fresh();
},
key(k) {
return false;
}
};
// 판이 어느 정도 찬 상태에서 시작해 마지막 채움이 짧은 시간에 보이게 한다
fresh();
for (let i = 0; i < 20000 && g.body.length < Math.floor(N * WARM_FILL); i += 1) {
logic();
}
cuts = 0;
jump = 0;
if (reduce) {
for (let i = 0; i < 4; i += 1) {
scene.step();
}
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./SkHamiltonCycleFill.module.scss";
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 = '다음 장면';
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
key(k: string): boolean;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 팔레트 — 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG: RGB = [29, 26, 44];
const TILE_A: RGB = [37, 33, 58];
const TILE_B: RGB = [42, 38, 66];
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 glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: RGB, a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
// 둥근 사각형 경로 (roundRect 가 없는 브라우저에서도 같은 모양)
function rrPath(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.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// ---- 판과 뱀 ----
interface Board { cols: number; rows: number; cell: number; ox: number; oy: number; n: number; }
function makeBoard(rows: number): Board {
const cell = H / rows;
const cols = Math.floor(W / cell + 1e-6);
return { cols: cols, rows: rows, cell: cell, ox: (W - cols * cell) / 2, oy: 0, n: cols * rows };
}
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
// 칸 번호 y*cols+x. 판 밖이면 -1 (wrap 이면 반대편으로 이어진다)
function cellAt(b: Board, x: number, y: number, wrap: boolean): number {
let xx = x;
let yy = y;
if (wrap) {
xx = (x % b.cols + b.cols) % b.cols;
yy = (y % b.rows + b.rows) % b.rows;
} else if (x < 0 || y < 0 || x >= b.cols || y >= b.rows) {
return -1;
}
return yy * b.cols + xx;
}
function neighborCell(b: Board, i: number, d: number, wrap: boolean): number {
return cellAt(b, (i % b.cols) + DX[d], Math.floor(i / b.cols) + DY[d], wrap);
}
// 목표 칸에서 너비 우선으로 퍼지는 거리장. blocked 칸과 닿지 못하는 칸은 -1
function distField(b: Board, target: number, blocked: Uint8Array, wrap: boolean): Int16Array {
const dist = new Int16Array(b.n).fill(-1);
const queue = new Int16Array(b.n);
let qh = 0;
let qt = 0;
dist[target] = 0;
queue[qt] = target;
qt += 1;
while (qh < qt) {
const c = queue[qh];
qh += 1;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb >= 0 && dist[nb] < 0 && blocked[nb] === 0) {
dist[nb] = dist[c] + 1;
queue[qt] = nb;
qt += 1;
}
}
}
return dist;
}
// 거리장에서 값이 가장 낮은 이웃 (없으면 -1)
function lowestNeighbor(b: Board, dist: Int16Array, from: number, wrap: boolean): number {
let best = -1;
let bv = 32000;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, from, d, wrap);
if (nb >= 0 && dist[nb] >= 0 && dist[nb] < bv) {
bv = dist[nb];
best = nb;
}
}
return best;
}
function pathFrom(b: Board, dist: Int16Array, head: number, wrap: boolean): number[] {
const path: number[] = [];
let cur = head;
for (let k = 0; k < b.n; k += 1) {
const nx = lowestNeighbor(b, dist, cur, wrap);
if (nx < 0 || (dist[nx] >= dist[cur] && cur !== head)) {
return [];
}
path.push(nx);
if (dist[nx] === 0) {
return path;
}
cur = nx;
}
return [];
}
interface Game {
b: Board;
wrap: boolean;
body: number[];
prev: number[];
food: number;
rnd: () => number;
eaten: number;
dead: boolean;
resets: number;
}
function placeFood(g: Game): void {
const used = new Uint8Array(g.b.n);
for (let i = 0; i < g.body.length; i += 1) {
used[g.body[i]] = 1;
}
const free: number[] = [];
for (let i = 0; i < g.b.n; i += 1) {
if (used[i] === 0) {
free.push(i);
}
}
g.food = free.length > 0 ? free[Math.floor(g.rnd() * free.length)] : -1;
}
function resetGame(g: Game): void {
const b = g.b;
const hx = Math.floor(b.cols / 2);
const hy = Math.floor(b.rows / 2);
g.body = [hy * b.cols + hx, hy * b.cols + hx - 1, hy * b.cols + hx - 2];
g.prev = g.body.slice();
g.dead = false;
g.resets += 1;
placeFood(g);
}
function newGame(b: Board, wrap: boolean, seed: number): Game {
const g: Game = { b: b, wrap: wrap, body: [], prev: [], food: -1, rnd: lcg(seed), eaten: 0, dead: false, resets: -1 };
resetGame(g);
return g;
}
// 머리를 next 칸으로 옮긴다. 먹으면 true, 몸에 부딪히면 dead
function moveSnake(g: Game, next: number): boolean {
g.prev = g.body.slice();
const ate = next === g.food;
const tail = g.body[g.body.length - 1];
for (let i = 0; i < g.body.length; i += 1) {
if (g.body[i] === next && !(i === g.body.length - 1 && !ate && next === tail)) {
g.dead = true;
return false;
}
}
g.body.unshift(next);
if (ate) {
g.eaten += 1;
placeFood(g);
} else {
g.body.pop();
}
return ate;
}
// 경로를 끝까지 따라가 먹은 뒤의 가상 몸
function simulate(body: number[], path: number[]): number[] {
const v = body.slice();
for (let k = 0; k < path.length; k += 1) {
v.unshift(path[k]);
if (k < path.length - 1) {
v.pop();
}
}
return v;
}
// 가상 몸의 머리에서 번져 나가 꼬리에 닿는가. mark 를 주면 닿은 칸을 표시한다
function tailReachable(b: Board, v: number[], wrap: boolean, mark: Uint8Array | null): boolean {
const blocked = new Uint8Array(b.n);
for (let i = 1; i < v.length - 1; i += 1) {
blocked[v[i]] = 1;
}
const tail = v[v.length - 1];
const head = v[0];
const seen = new Uint8Array(b.n);
const stack: number[] = [head];
seen[head] = 1;
let hit = false;
while (stack.length > 0) {
const c = stack.pop() as number;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb < 0 || seen[nb] === 1) {
continue;
}
if (nb === tail) {
hit = true;
continue;
}
if (blocked[nb] === 1 || nb === head) {
continue;
}
seen[nb] = 1;
stack.push(nb);
}
}
if (mark) {
mark.set(seen);
}
return hit;
}
interface Plan { next: number; path: number[]; rejected: number[]; trap: Uint8Array; chase: boolean; }
// 한 수를 정한다: 먹이까지 최단 경로 → (안전 검사) → 막히면 꼬리를 쫓는다
function planMove(g: Game, safeOn: boolean): Plan {
const b = g.b;
const head = g.body[0];
const tail = g.body[g.body.length - 1];
const plan: Plan = { next: -1, path: [], rejected: [], trap: new Uint8Array(b.n), chase: false };
const blocked = new Uint8Array(b.n);
for (let i = 0; i < g.body.length - 1; i += 1) {
blocked[g.body[i]] = 1;
}
if (g.food >= 0) {
const dist = distField(b, g.food, blocked, g.wrap);
const path = pathFrom(b, dist, head, g.wrap);
plan.path = path;
if (path.length > 0) {
if (!safeOn) {
plan.next = path[0];
return plan;
}
const v = simulate(g.body, path);
if (tailReachable(b, v, g.wrap, plan.trap)) {
plan.next = path[0];
return plan;
}
plan.rejected = path;
}
}
plan.chase = true;
const dist2 = distField(b, tail, blocked, g.wrap);
const nx = lowestNeighbor(b, dist2, head, g.wrap);
if (nx >= 0) {
plan.next = nx;
return plan;
}
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, head, d, g.wrap);
if (nb >= 0 && blocked[nb] === 0 && nb !== head) {
plan.next = nb;
return plan;
}
}
return plan;
}
// 칸 중심의 화면 좌표
function cellX(b: Board, i: number): number { return b.ox + ((i % b.cols) + 0.5) * b.cell; }
function cellY(b: Board, i: number): number { return b.oy + (Math.floor(i / b.cols) + 0.5) * b.cell; }
function wrapDelta(a: number, c: number, n: number, wrap: boolean): number {
let d = c - a;
if (wrap) {
if (d > n / 2) { d -= n; }
if (d < -n / 2) { d += n; }
}
return d;
}
// 마디 i 의 보간 위치 (칸 좌표). 지난 틱의 자리에서 이번 틱의 자리로 p(0~1) 만큼
function segPos(g: Game, i: number, p: number): [number, number] {
const b = g.b;
const cur = g.body[i];
const pv = i < g.prev.length ? g.prev[i] : cur;
const cx = cur % b.cols;
const cy = Math.floor(cur / b.cols);
const px = pv % b.cols;
const py = Math.floor(pv / b.cols);
return [px + wrapDelta(px, cx, b.cols, g.wrap) * p, py + wrapDelta(py, cy, b.rows, g.wrap) * p];
}
function drawTiles(ctx: CanvasRenderingContext2D, b: Board): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
for (let y = 0; y < b.rows; y += 1) {
for (let x = 0; x < b.cols; x += 1) {
ctx.fillStyle = rgba((x + y) % 2 === 0 ? TILE_A : TILE_B, 1);
ctx.fillRect(b.ox + x * b.cell, b.oy + y * b.cell, b.cell + 0.5, b.cell + 0.5);
}
}
}
function drawFood(ctx: CanvasRenderingContext2D, b: Board, food: number, t: number): void {
if (food < 0) {
return;
}
const x = cellX(b, food);
const y = cellY(b, food);
const pulse = 0.5 + 0.5 * Math.sin(t * 0.12);
glow(ctx, x, y, b.cell * (1.3 + 0.35 * pulse), PINK, 0.55);
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.arc(x, y, b.cell * 0.3, 0, PI2);
ctx.fill();
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.arc(x - b.cell * 0.08, y - b.cell * 0.09, b.cell * 0.09, 0, PI2);
ctx.fill();
}
function bodyColor(i: number, len: number): RGB {
return mix(MINT, SKY, len <= 1 ? 0 : i / (len - 1));
}
// 칸 중심 (x,y 칸 좌표, 소수 허용)에 마디 하나
function drawSeg(ctx: CanvasRenderingContext2D, b: Board, gx: number, gy: number, c: RGB, shrink: number): void {
const s = b.cell * (1 - shrink);
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
ctx.fillStyle = rgba(c, 1);
rrPath(ctx, cx - s / 2, cy - s / 2, s, s, s * 0.32);
ctx.fill();
}
function drawEyes(ctx: CanvasRenderingContext2D, b: Board, gx: number, gy: number, dirx: number, diry: number): void {
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
const px = -diry;
const py = dirx;
const r = b.cell * 0.09;
ctx.fillStyle = rgba(BG, 1);
for (let s = -1; s <= 1; s += 2) {
ctx.beginPath();
ctx.arc(cx + dirx * b.cell * 0.16 + px * s * b.cell * 0.18, cy + diry * b.cell * 0.16 + py * s * b.cell * 0.18, r, 0, PI2);
ctx.fill();
}
}
function headDir(g: Game): [number, number] {
if (g.body.length < 2) {
return [1, 0];
}
const b = g.b;
const dx = wrapDelta(g.body[1] % b.cols, g.body[0] % b.cols, b.cols, g.wrap);
const dy = wrapDelta(Math.floor(g.body[1] / b.cols), Math.floor(g.body[0] / b.cols), b.rows, g.wrap);
return [clamp(dx, -1, 1), clamp(dy, -1, 1)];
}
// 보간 없이 칸 그대로 그리는 기본 뱀
function drawSnakeCells(ctx: CanvasRenderingContext2D, g: Game, dim: number): void {
const len = g.body.length;
for (let i = len - 1; i >= 0; i -= 1) {
const c = g.body[i];
const col = mix(bodyColor(i, len), BG, dim);
drawSeg(ctx, g.b, c % g.b.cols, Math.floor(c / g.b.cols), col, 0.08);
}
const hd = headDir(g);
drawEyes(ctx, g.b, g.body[0] % g.b.cols, Math.floor(g.body[0] / g.b.cols), hd[0], hd[1]);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement, make: Maker): () => void {
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: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = 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);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
// 움직임 줄이기에서는 루프가 없으니 입력 직후 한 틱쯤 앞으로 보내 바로 그린다
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 8);
show();
}
}
function toLocal(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;
}
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');
advance(1400);
show();
}
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
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, 'pointermove', function (e: Event): void {
toLocal(e as PointerEvent);
refresh();
});
listen(canvas, 'pointerdown', function (e: Event): void {
toLocal(e as PointerEvent);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
// 이 장면이 쓰는 키만 막는다. 쓰지 않는 키(Tab 등)는 그대로 둔다
if (scene.key(k)) {
e.preventDefault();
refresh();
}
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const TICK_MS = 60;
const GRID_N = 8;
const SHORTCUT_MARGIN = 2;
const WARM_FILL = 0.95;
const FULL_HOLD = 14;
const TXT_LEN = '길이';
const TXT_CUT = '지름길';
const TXT_FULL = '판이 가득 참 — 새 판';
function makeScene(inp: Input, reduce: boolean): Scene {
const b = makeBoard(GRID_N);
const g = newGame(b, false, 455202);
const N = b.n;
// 해밀턴 순환: 맨 윗줄을 가로지른 뒤 나머지 줄을 지그재그로 훑고 왼쪽 열로 올라온다
const cyc: number[] = [];
for (let x = 0; x < b.cols; x += 1) {
cyc.push(x);
}
for (let y = 1; y < b.rows; y += 1) {
for (let k = 1; k < b.cols; k += 1) {
const x = y % 2 === 1 ? b.cols - k : k;
cyc.push(y * b.cols + x);
}
}
for (let y = b.rows - 1; y >= 1; y -= 1) {
cyc.push(y * b.cols);
}
const ord = new Int16Array(N);
for (let i = 0; i < N; i += 1) {
ord[cyc[i]] = i;
}
let acc = 0;
let t = 0;
let cuts = 0;
let hold = 0;
let jump = 0;
function cd(from: number, to: number): number {
return (ord[to] - ord[from] + N) % N;
}
function fresh(): void {
g.body = [cyc[2], cyc[1], cyc[0]];
g.prev = g.body.slice();
g.dead = false;
placeFood(g);
cuts = 0;
}
function logic(): void {
if (hold > 0) {
hold -= 1;
if (hold === 0) {
fresh();
}
return;
}
const head = g.body[0];
const tail = g.body[g.body.length - 1];
const foodD = cd(head, g.food);
let tailD = cd(head, tail);
if (tailD === 0) {
tailD = N;
}
const occupied = new Uint8Array(N);
for (let i = 0; i < g.body.length - 1; i += 1) {
occupied[g.body[i]] = 1;
}
let best = cyc[(ord[head] + 1) % N];
let bestD = 1;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, head, d, false);
if (nb < 0 || occupied[nb] === 1 || nb === tail) {
continue;
}
const dc = cd(head, nb);
if (dc > bestD && dc <= foodD && dc < tailD - SHORTCUT_MARGIN) {
best = nb;
bestD = dc;
}
}
if (bestD > 1) {
cuts += 1;
jump = 6;
}
moveSnake(g, best);
if (g.body.length >= N) {
// 움직임 줄이기에서는 가득 찬 판을 그대로 두고 입력이 있을 때만 새 판을 연다
hold = reduce ? 100000 : FULL_HOLD;
}
}
const scene: Scene = {
step(): void {
t += 1;
acc += FIXED_DT;
while (acc >= TICK_MS) {
acc -= TICK_MS;
logic();
}
if (jump > 0) {
jump -= 1;
}
},
draw(ctx: CanvasRenderingContext2D): void {
drawTiles(ctx, b);
// 순환 선 — 뱀이 지나온 구간은 밝아진다
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.lineWidth = Math.max(2, b.cell * 0.1);
const inBody = new Uint8Array(N);
for (let i = 0; i < g.body.length; i += 1) {
inBody[g.body[i]] = 1;
}
for (let i = 0; i < N; i += 1) {
const a = cyc[i];
const c = cyc[(i + 1) % N];
ctx.strokeStyle = rgba(LILAC, inBody[a] === 1 ? 0.55 : 0.22);
ctx.beginPath();
ctx.moveTo(cellX(b, a), cellY(b, a));
ctx.lineTo(cellX(b, c), cellY(b, c));
ctx.stroke();
}
drawFood(ctx, b, g.food, t);
drawSnakeCells(ctx, g, 0);
if (jump > 0) {
glow(ctx, cellX(b, g.body[0]), cellY(b, g.body[0]), b.cell * 1.3, CREAM, 0.12 + 0.05 * jump);
}
},
label(): string {
if (hold > 0) {
return TXT_FULL;
}
return TXT_LEN + ' ' + g.body.length + '/' + N + ' · ' + TXT_CUT + ' ' + cuts;
},
tap(): void {
hold = 0;
fresh();
},
key(k: string): boolean {
return false;
}
};
// 판이 어느 정도 찬 상태에서 시작해 마지막 채움이 짧은 시간에 보이게 한다
fresh();
for (let i = 0; i < 20000 && g.body.length < Math.floor(N * WARM_FILL); i += 1) {
logic();
}
cuts = 0;
jump = 0;
if (reduce) {
for (let i = 0; i < 4; i += 1) {
scene.step();
}
}
return scene;
}
export default function SkHamiltonCycleFill() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="판 전체를 한 바퀴 도는 해밀턴 순환으로 판을 끝까지 채우는 뱀" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">길이</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>판 전체를 한 바퀴 도는 해밀턴 순환으로 판을 끝까지 채우는 뱀</p>
</div>
</main>
);
}02 · 판 전체를 한 바퀴 도는 해밀턴 순환으로 판을 끝까지 채우는 뱀 — 측정 누적면적 61.428% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
let best = cyc[(ord[head] + 1) % N];
let bestD = 1;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, head, d, false);
if (nb < 0 || occupied[nb] === 1 || nb === tail) {
continue;
}
const dc = cd(head, nb);
if (dc > bestD && dc <= foodD && dc < tailD - SHORTCUT_MARGIN) {
best = nb;
bestD = dc;
}
}
if (bestD > 1) {
cuts += 1;
jump = 6;
}
moveSnake(g, best);
03틱 사이를 보간해 칸이 아니라 픽셀 단위로 미끄러지는 뱀 몸통
논리는 100ms 틱으로 칸을 한 칸씩 옮기고, 그리는 자리 px·py 는 16ms 스텝마다 논리 칸 중심으로 BODY_LERP 0.35 만큼 다가간다. 탭을 누르면 보간이 꺼져 몸이 칸 중심에 바로 붙으므로 크림색 윤곽 사각형(논리 칸)과 굵은 몸통을 번갈아 비교할 수 있다. 이 장면의 값은 누적면적 18.5%, 평균 강도 78.6, 움직인 프레임 23/23 이다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="틱 사이를 보간해 칸이 아니라 픽셀 단위로 미끄러지는 뱀 몸통"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">보간 켜짐</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">틱 사이를 보간해 칸이 아니라 픽셀 단위로 미끄러지는 뱀 몸통</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
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;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__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;
}
.sc__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;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
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: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { 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);
}
.sc {
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;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__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;
}
.sc__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;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
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 BG = [29, 26, 44];
const TILE_A = [37, 33, 58];
const TILE_B = [42, 38, 66];
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 glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
// 둥근 사각형 경로 (roundRect 가 없는 브라우저에서도 같은 모양)
function rrPath(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
function makeBoard(rows) {
const cell = H / rows;
const cols = Math.floor(W / cell + 1e-6);
return { cols: cols, rows: rows, cell: cell, ox: (W - cols * cell) / 2, oy: 0, n: cols * rows };
}
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
// 칸 번호 y*cols+x. 판 밖이면 -1 (wrap 이면 반대편으로 이어진다)
function cellAt(b, x, y, wrap) {
let xx = x;
let yy = y;
if (wrap) {
xx = (x % b.cols + b.cols) % b.cols;
yy = (y % b.rows + b.rows) % b.rows;
}
else if (x < 0 || y < 0 || x >= b.cols || y >= b.rows) {
return -1;
}
return yy * b.cols + xx;
}
function neighborCell(b, i, d, wrap) {
return cellAt(b, (i % b.cols) + DX[d], Math.floor(i / b.cols) + DY[d], wrap);
}
// 목표 칸에서 너비 우선으로 퍼지는 거리장. blocked 칸과 닿지 못하는 칸은 -1
function distField(b, target, blocked, wrap) {
const dist = new Int16Array(b.n).fill(-1);
const queue = new Int16Array(b.n);
let qh = 0;
let qt = 0;
dist[target] = 0;
queue[qt] = target;
qt += 1;
while (qh < qt) {
const c = queue[qh];
qh += 1;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb >= 0 && dist[nb] < 0 && blocked[nb] === 0) {
dist[nb] = dist[c] + 1;
queue[qt] = nb;
qt += 1;
}
}
}
return dist;
}
// 거리장에서 값이 가장 낮은 이웃 (없으면 -1)
function lowestNeighbor(b, dist, from, wrap) {
let best = -1;
let bv = 32000;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, from, d, wrap);
if (nb >= 0 && dist[nb] >= 0 && dist[nb] < bv) {
bv = dist[nb];
best = nb;
}
}
return best;
}
function pathFrom(b, dist, head, wrap) {
const path = [];
let cur = head;
for (let k = 0; k < b.n; k += 1) {
const nx = lowestNeighbor(b, dist, cur, wrap);
if (nx < 0 || (dist[nx] >= dist[cur] && cur !== head)) {
return [];
}
path.push(nx);
if (dist[nx] === 0) {
return path;
}
cur = nx;
}
return [];
}
function placeFood(g) {
const used = new Uint8Array(g.b.n);
for (let i = 0; i < g.body.length; i += 1) {
used[g.body[i]] = 1;
}
const free = [];
for (let i = 0; i < g.b.n; i += 1) {
if (used[i] === 0) {
free.push(i);
}
}
g.food = free.length > 0 ? free[Math.floor(g.rnd() * free.length)] : -1;
}
function resetGame(g) {
const b = g.b;
const hx = Math.floor(b.cols / 2);
const hy = Math.floor(b.rows / 2);
g.body = [hy * b.cols + hx, hy * b.cols + hx - 1, hy * b.cols + hx - 2];
g.prev = g.body.slice();
g.dead = false;
g.resets += 1;
placeFood(g);
}
function newGame(b, wrap, seed) {
const g = { b: b, wrap: wrap, body: [], prev: [], food: -1, rnd: lcg(seed), eaten: 0, dead: false, resets: -1 };
resetGame(g);
return g;
}
// 머리를 next 칸으로 옮긴다. 먹으면 true, 몸에 부딪히면 dead
function moveSnake(g, next) {
g.prev = g.body.slice();
const ate = next === g.food;
const tail = g.body[g.body.length - 1];
for (let i = 0; i < g.body.length; i += 1) {
if (g.body[i] === next && !(i === g.body.length - 1 && !ate && next === tail)) {
g.dead = true;
return false;
}
}
g.body.unshift(next);
if (ate) {
g.eaten += 1;
placeFood(g);
}
else {
g.body.pop();
}
return ate;
}
// 경로를 끝까지 따라가 먹은 뒤의 가상 몸
function simulate(body, path) {
const v = body.slice();
for (let k = 0; k < path.length; k += 1) {
v.unshift(path[k]);
if (k < path.length - 1) {
v.pop();
}
}
return v;
}
// 가상 몸의 머리에서 번져 나가 꼬리에 닿는가. mark 를 주면 닿은 칸을 표시한다
function tailReachable(b, v, wrap, mark) {
const blocked = new Uint8Array(b.n);
for (let i = 1; i < v.length - 1; i += 1) {
blocked[v[i]] = 1;
}
const tail = v[v.length - 1];
const head = v[0];
const seen = new Uint8Array(b.n);
const stack = [head];
seen[head] = 1;
let hit = false;
while (stack.length > 0) {
const c = stack.pop();
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb < 0 || seen[nb] === 1) {
continue;
}
if (nb === tail) {
hit = true;
continue;
}
if (blocked[nb] === 1 || nb === head) {
continue;
}
seen[nb] = 1;
stack.push(nb);
}
}
if (mark) {
mark.set(seen);
}
return hit;
}
// 한 수를 정한다: 먹이까지 최단 경로 → (안전 검사) → 막히면 꼬리를 쫓는다
function planMove(g, safeOn) {
const b = g.b;
const head = g.body[0];
const tail = g.body[g.body.length - 1];
const plan = { next: -1, path: [], rejected: [], trap: new Uint8Array(b.n), chase: false };
const blocked = new Uint8Array(b.n);
for (let i = 0; i < g.body.length - 1; i += 1) {
blocked[g.body[i]] = 1;
}
if (g.food >= 0) {
const dist = distField(b, g.food, blocked, g.wrap);
const path = pathFrom(b, dist, head, g.wrap);
plan.path = path;
if (path.length > 0) {
if (!safeOn) {
plan.next = path[0];
return plan;
}
const v = simulate(g.body, path);
if (tailReachable(b, v, g.wrap, plan.trap)) {
plan.next = path[0];
return plan;
}
plan.rejected = path;
}
}
plan.chase = true;
const dist2 = distField(b, tail, blocked, g.wrap);
const nx = lowestNeighbor(b, dist2, head, g.wrap);
if (nx >= 0) {
plan.next = nx;
return plan;
}
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, head, d, g.wrap);
if (nb >= 0 && blocked[nb] === 0 && nb !== head) {
plan.next = nb;
return plan;
}
}
return plan;
}
// 칸 중심의 화면 좌표
function cellX(b, i) { return b.ox + ((i % b.cols) + 0.5) * b.cell; }
function cellY(b, i) { return b.oy + (Math.floor(i / b.cols) + 0.5) * b.cell; }
function wrapDelta(a, c, n, wrap) {
let d = c - a;
if (wrap) {
if (d > n / 2) {
d -= n;
}
if (d < -n / 2) {
d += n;
}
}
return d;
}
// 마디 i 의 보간 위치 (칸 좌표). 지난 틱의 자리에서 이번 틱의 자리로 p(0~1) 만큼
function segPos(g, i, p) {
const b = g.b;
const cur = g.body[i];
const pv = i < g.prev.length ? g.prev[i] : cur;
const cx = cur % b.cols;
const cy = Math.floor(cur / b.cols);
const px = pv % b.cols;
const py = Math.floor(pv / b.cols);
return [px + wrapDelta(px, cx, b.cols, g.wrap) * p, py + wrapDelta(py, cy, b.rows, g.wrap) * p];
}
function drawTiles(ctx, b) {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
for (let y = 0; y < b.rows; y += 1) {
for (let x = 0; x < b.cols; x += 1) {
ctx.fillStyle = rgba((x + y) % 2 === 0 ? TILE_A : TILE_B, 1);
ctx.fillRect(b.ox + x * b.cell, b.oy + y * b.cell, b.cell + 0.5, b.cell + 0.5);
}
}
}
function drawFood(ctx, b, food, t) {
if (food < 0) {
return;
}
const x = cellX(b, food);
const y = cellY(b, food);
const pulse = 0.5 + 0.5 * Math.sin(t * 0.12);
glow(ctx, x, y, b.cell * (1.3 + 0.35 * pulse), PINK, 0.55);
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.arc(x, y, b.cell * 0.3, 0, PI2);
ctx.fill();
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.arc(x - b.cell * 0.08, y - b.cell * 0.09, b.cell * 0.09, 0, PI2);
ctx.fill();
}
function bodyColor(i, len) {
return mix(MINT, SKY, len <= 1 ? 0 : i / (len - 1));
}
// 칸 중심 (x,y 칸 좌표, 소수 허용)에 마디 하나
function drawSeg(ctx, b, gx, gy, c, shrink) {
const s = b.cell * (1 - shrink);
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
ctx.fillStyle = rgba(c, 1);
rrPath(ctx, cx - s / 2, cy - s / 2, s, s, s * 0.32);
ctx.fill();
}
function drawEyes(ctx, b, gx, gy, dirx, diry) {
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
const px = -diry;
const py = dirx;
const r = b.cell * 0.09;
ctx.fillStyle = rgba(BG, 1);
for (let s = -1; s <= 1; s += 2) {
ctx.beginPath();
ctx.arc(cx + dirx * b.cell * 0.16 + px * s * b.cell * 0.18, cy + diry * b.cell * 0.16 + py * s * b.cell * 0.18, r, 0, PI2);
ctx.fill();
}
}
function headDir(g) {
if (g.body.length < 2) {
return [1, 0];
}
const b = g.b;
const dx = wrapDelta(g.body[1] % b.cols, g.body[0] % b.cols, b.cols, g.wrap);
const dy = wrapDelta(Math.floor(g.body[1] / b.cols), Math.floor(g.body[0] / b.cols), b.rows, g.wrap);
return [clamp(dx, -1, 1), clamp(dy, -1, 1)];
}
// 보간 없이 칸 그대로 그리는 기본 뱀
function drawSnakeCells(ctx, g, dim) {
const len = g.body.length;
for (let i = len - 1; i >= 0; i -= 1) {
const c = g.body[i];
const col = mix(bodyColor(i, len), BG, dim);
drawSeg(ctx, g.b, c % g.b.cols, Math.floor(c / g.b.cols), col, 0.08);
}
const hd = headDir(g);
drawEyes(ctx, g.b, g.body[0] % g.b.cols, Math.floor(g.body[0] / g.b.cols), hd[0], hd[1]);
}
function start(root, canvas, statusEl, btnEl, make) {
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: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = 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);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
// 움직임 줄이기에서는 루프가 없으니 입력 직후 한 틱쯤 앞으로 보내 바로 그린다
function refresh() {
if (reduce) {
advance(FIXED_DT * 8);
show();
}
}
function toLocal(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;
}
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');
advance(1400);
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
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, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
// 이 장면이 쓰는 키만 막는다. 쓰지 않는 키(Tab 등)는 그대로 둔다
if (scene.key(k)) {
e.preventDefault();
refresh();
}
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const TICK_MS = 100;
const GRID_N = 12;
const BODY_LERP = 0.35;
const TUBE_FRAC = 0.62;
const TRAIL_LEN = 16;
const WARM_TICKS = 70;
const TXT_SMOOTH = '보간 켜짐';
const TXT_SNAP = '보간 꺼짐';
const TXT_LEN = '길이';
const TXT_DEAD = '갇혀서 끝 — 다시 시작';
function makeScene(inp, reduce) {
const b = makeBoard(GRID_N);
const g = newGame(b, false, 455303);
let acc = 0;
let t = 0;
let smoothOn = true;
let hold = 0;
const trail = [];
let px = [];
let py = [];
function snapAll() {
px = [];
py = [];
for (let i = 0; i < g.body.length; i += 1) {
px.push(cellX(b, g.body[i]));
py.push(cellY(b, g.body[i]));
}
}
function logic() {
if (hold > 0) {
hold -= 1;
if (hold === 0) {
resetGame(g);
snapAll();
}
return;
}
const p = planMove(g, true);
if (p.next < 0) {
g.dead = true;
hold = 8;
return;
}
const ate = moveSnake(g, p.next);
if (ate) {
px.push(px[px.length - 1]);
py.push(py[py.length - 1]);
}
}
snapAll();
const scene = {
step() {
t += 1;
acc += FIXED_DT;
while (acc >= TICK_MS) {
acc -= TICK_MS;
logic();
}
trail.push(px[0], py[0]);
while (trail.length > TRAIL_LEN * 2) {
trail.shift();
trail.shift();
}
// 그리는 자리가 논리 칸 자리를 BODY_LERP 만큼씩 따라간다
for (let i = 0; i < g.body.length; i += 1) {
const tx = cellX(b, g.body[i]);
const ty = cellY(b, g.body[i]);
if (smoothOn) {
px[i] = lerp(px[i], tx, BODY_LERP);
py[i] = lerp(py[i], ty, BODY_LERP);
}
else {
px[i] = tx;
py[i] = ty;
}
}
},
draw(ctx) {
drawTiles(ctx, b);
// 논리 칸 — 틱마다 한 칸씩 뛰는 자리
for (let i = 0; i < g.body.length; i += 1) {
const c = g.body[i];
ctx.strokeStyle = rgba(CREAM, 0.34);
ctx.lineWidth = 1.5;
ctx.strokeRect(b.ox + (c % b.cols) * b.cell + 3, b.oy + Math.floor(c / b.cols) * b.cell + 3, b.cell - 6, b.cell - 6);
}
drawFood(ctx, b, g.food, t);
for (let k = 0; k < trail.length / 2; k += 1) {
const a = (k + 1) / (trail.length / 2);
glow(ctx, trail[k * 2], trail[k * 2 + 1], b.cell * (0.5 + 0.5 * a), MINT, 0.22 * a);
}
// 그리는 몸 — 마디마다 색을 달리 한 굵은 선
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.lineWidth = b.cell * TUBE_FRAC;
const len = g.body.length;
for (let i = len - 1; i >= 1; i -= 1) {
ctx.strokeStyle = rgba(mix(bodyColor(i, len), BG, g.dead ? 0.55 : 0), 1);
ctx.beginPath();
ctx.moveTo(px[i], py[i]);
ctx.lineTo(px[i - 1], py[i - 1]);
ctx.stroke();
}
ctx.fillStyle = rgba(mix(MINT, BG, g.dead ? 0.55 : 0), 1);
ctx.beginPath();
ctx.arc(px[0], py[0], b.cell * (TUBE_FRAC * 0.5 + 0.06), 0, PI2);
ctx.fill();
const hd = headDir(g);
const ex = -hd[1];
const ey = hd[0];
ctx.fillStyle = rgba(BG, 1);
for (let s = -1; s <= 1; s += 2) {
ctx.beginPath();
ctx.arc(px[0] + hd[0] * b.cell * 0.14 + ex * s * b.cell * 0.15, py[0] + hd[1] * b.cell * 0.14 + ey * s * b.cell * 0.15, b.cell * 0.07, 0, PI2);
ctx.fill();
}
},
label() {
if (g.dead) {
return TXT_DEAD;
}
return (smoothOn ? TXT_SMOOTH : TXT_SNAP) + ' · ' + TXT_LEN + ' ' + g.body.length;
},
tap() {
smoothOn = !smoothOn;
},
key(k) {
return false;
}
};
for (let i = 0; i < WARM_TICKS; i += 1) {
logic();
}
snapAll();
if (reduce) {
for (let i = 0; i < 4; i += 1) {
scene.step();
}
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./SkSmoothLerpBody.module.scss";
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 = '다음 장면';
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
key(k: string): boolean;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 팔레트 — 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG: RGB = [29, 26, 44];
const TILE_A: RGB = [37, 33, 58];
const TILE_B: RGB = [42, 38, 66];
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 glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: RGB, a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
// 둥근 사각형 경로 (roundRect 가 없는 브라우저에서도 같은 모양)
function rrPath(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.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// ---- 판과 뱀 ----
interface Board { cols: number; rows: number; cell: number; ox: number; oy: number; n: number; }
function makeBoard(rows: number): Board {
const cell = H / rows;
const cols = Math.floor(W / cell + 1e-6);
return { cols: cols, rows: rows, cell: cell, ox: (W - cols * cell) / 2, oy: 0, n: cols * rows };
}
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
// 칸 번호 y*cols+x. 판 밖이면 -1 (wrap 이면 반대편으로 이어진다)
function cellAt(b: Board, x: number, y: number, wrap: boolean): number {
let xx = x;
let yy = y;
if (wrap) {
xx = (x % b.cols + b.cols) % b.cols;
yy = (y % b.rows + b.rows) % b.rows;
} else if (x < 0 || y < 0 || x >= b.cols || y >= b.rows) {
return -1;
}
return yy * b.cols + xx;
}
function neighborCell(b: Board, i: number, d: number, wrap: boolean): number {
return cellAt(b, (i % b.cols) + DX[d], Math.floor(i / b.cols) + DY[d], wrap);
}
// 목표 칸에서 너비 우선으로 퍼지는 거리장. blocked 칸과 닿지 못하는 칸은 -1
function distField(b: Board, target: number, blocked: Uint8Array, wrap: boolean): Int16Array {
const dist = new Int16Array(b.n).fill(-1);
const queue = new Int16Array(b.n);
let qh = 0;
let qt = 0;
dist[target] = 0;
queue[qt] = target;
qt += 1;
while (qh < qt) {
const c = queue[qh];
qh += 1;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb >= 0 && dist[nb] < 0 && blocked[nb] === 0) {
dist[nb] = dist[c] + 1;
queue[qt] = nb;
qt += 1;
}
}
}
return dist;
}
// 거리장에서 값이 가장 낮은 이웃 (없으면 -1)
function lowestNeighbor(b: Board, dist: Int16Array, from: number, wrap: boolean): number {
let best = -1;
let bv = 32000;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, from, d, wrap);
if (nb >= 0 && dist[nb] >= 0 && dist[nb] < bv) {
bv = dist[nb];
best = nb;
}
}
return best;
}
function pathFrom(b: Board, dist: Int16Array, head: number, wrap: boolean): number[] {
const path: number[] = [];
let cur = head;
for (let k = 0; k < b.n; k += 1) {
const nx = lowestNeighbor(b, dist, cur, wrap);
if (nx < 0 || (dist[nx] >= dist[cur] && cur !== head)) {
return [];
}
path.push(nx);
if (dist[nx] === 0) {
return path;
}
cur = nx;
}
return [];
}
interface Game {
b: Board;
wrap: boolean;
body: number[];
prev: number[];
food: number;
rnd: () => number;
eaten: number;
dead: boolean;
resets: number;
}
function placeFood(g: Game): void {
const used = new Uint8Array(g.b.n);
for (let i = 0; i < g.body.length; i += 1) {
used[g.body[i]] = 1;
}
const free: number[] = [];
for (let i = 0; i < g.b.n; i += 1) {
if (used[i] === 0) {
free.push(i);
}
}
g.food = free.length > 0 ? free[Math.floor(g.rnd() * free.length)] : -1;
}
function resetGame(g: Game): void {
const b = g.b;
const hx = Math.floor(b.cols / 2);
const hy = Math.floor(b.rows / 2);
g.body = [hy * b.cols + hx, hy * b.cols + hx - 1, hy * b.cols + hx - 2];
g.prev = g.body.slice();
g.dead = false;
g.resets += 1;
placeFood(g);
}
function newGame(b: Board, wrap: boolean, seed: number): Game {
const g: Game = { b: b, wrap: wrap, body: [], prev: [], food: -1, rnd: lcg(seed), eaten: 0, dead: false, resets: -1 };
resetGame(g);
return g;
}
// 머리를 next 칸으로 옮긴다. 먹으면 true, 몸에 부딪히면 dead
function moveSnake(g: Game, next: number): boolean {
g.prev = g.body.slice();
const ate = next === g.food;
const tail = g.body[g.body.length - 1];
for (let i = 0; i < g.body.length; i += 1) {
if (g.body[i] === next && !(i === g.body.length - 1 && !ate && next === tail)) {
g.dead = true;
return false;
}
}
g.body.unshift(next);
if (ate) {
g.eaten += 1;
placeFood(g);
} else {
g.body.pop();
}
return ate;
}
// 경로를 끝까지 따라가 먹은 뒤의 가상 몸
function simulate(body: number[], path: number[]): number[] {
const v = body.slice();
for (let k = 0; k < path.length; k += 1) {
v.unshift(path[k]);
if (k < path.length - 1) {
v.pop();
}
}
return v;
}
// 가상 몸의 머리에서 번져 나가 꼬리에 닿는가. mark 를 주면 닿은 칸을 표시한다
function tailReachable(b: Board, v: number[], wrap: boolean, mark: Uint8Array | null): boolean {
const blocked = new Uint8Array(b.n);
for (let i = 1; i < v.length - 1; i += 1) {
blocked[v[i]] = 1;
}
const tail = v[v.length - 1];
const head = v[0];
const seen = new Uint8Array(b.n);
const stack: number[] = [head];
seen[head] = 1;
let hit = false;
while (stack.length > 0) {
const c = stack.pop() as number;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb < 0 || seen[nb] === 1) {
continue;
}
if (nb === tail) {
hit = true;
continue;
}
if (blocked[nb] === 1 || nb === head) {
continue;
}
seen[nb] = 1;
stack.push(nb);
}
}
if (mark) {
mark.set(seen);
}
return hit;
}
interface Plan { next: number; path: number[]; rejected: number[]; trap: Uint8Array; chase: boolean; }
// 한 수를 정한다: 먹이까지 최단 경로 → (안전 검사) → 막히면 꼬리를 쫓는다
function planMove(g: Game, safeOn: boolean): Plan {
const b = g.b;
const head = g.body[0];
const tail = g.body[g.body.length - 1];
const plan: Plan = { next: -1, path: [], rejected: [], trap: new Uint8Array(b.n), chase: false };
const blocked = new Uint8Array(b.n);
for (let i = 0; i < g.body.length - 1; i += 1) {
blocked[g.body[i]] = 1;
}
if (g.food >= 0) {
const dist = distField(b, g.food, blocked, g.wrap);
const path = pathFrom(b, dist, head, g.wrap);
plan.path = path;
if (path.length > 0) {
if (!safeOn) {
plan.next = path[0];
return plan;
}
const v = simulate(g.body, path);
if (tailReachable(b, v, g.wrap, plan.trap)) {
plan.next = path[0];
return plan;
}
plan.rejected = path;
}
}
plan.chase = true;
const dist2 = distField(b, tail, blocked, g.wrap);
const nx = lowestNeighbor(b, dist2, head, g.wrap);
if (nx >= 0) {
plan.next = nx;
return plan;
}
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, head, d, g.wrap);
if (nb >= 0 && blocked[nb] === 0 && nb !== head) {
plan.next = nb;
return plan;
}
}
return plan;
}
// 칸 중심의 화면 좌표
function cellX(b: Board, i: number): number { return b.ox + ((i % b.cols) + 0.5) * b.cell; }
function cellY(b: Board, i: number): number { return b.oy + (Math.floor(i / b.cols) + 0.5) * b.cell; }
function wrapDelta(a: number, c: number, n: number, wrap: boolean): number {
let d = c - a;
if (wrap) {
if (d > n / 2) { d -= n; }
if (d < -n / 2) { d += n; }
}
return d;
}
// 마디 i 의 보간 위치 (칸 좌표). 지난 틱의 자리에서 이번 틱의 자리로 p(0~1) 만큼
function segPos(g: Game, i: number, p: number): [number, number] {
const b = g.b;
const cur = g.body[i];
const pv = i < g.prev.length ? g.prev[i] : cur;
const cx = cur % b.cols;
const cy = Math.floor(cur / b.cols);
const px = pv % b.cols;
const py = Math.floor(pv / b.cols);
return [px + wrapDelta(px, cx, b.cols, g.wrap) * p, py + wrapDelta(py, cy, b.rows, g.wrap) * p];
}
function drawTiles(ctx: CanvasRenderingContext2D, b: Board): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
for (let y = 0; y < b.rows; y += 1) {
for (let x = 0; x < b.cols; x += 1) {
ctx.fillStyle = rgba((x + y) % 2 === 0 ? TILE_A : TILE_B, 1);
ctx.fillRect(b.ox + x * b.cell, b.oy + y * b.cell, b.cell + 0.5, b.cell + 0.5);
}
}
}
function drawFood(ctx: CanvasRenderingContext2D, b: Board, food: number, t: number): void {
if (food < 0) {
return;
}
const x = cellX(b, food);
const y = cellY(b, food);
const pulse = 0.5 + 0.5 * Math.sin(t * 0.12);
glow(ctx, x, y, b.cell * (1.3 + 0.35 * pulse), PINK, 0.55);
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.arc(x, y, b.cell * 0.3, 0, PI2);
ctx.fill();
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.arc(x - b.cell * 0.08, y - b.cell * 0.09, b.cell * 0.09, 0, PI2);
ctx.fill();
}
function bodyColor(i: number, len: number): RGB {
return mix(MINT, SKY, len <= 1 ? 0 : i / (len - 1));
}
// 칸 중심 (x,y 칸 좌표, 소수 허용)에 마디 하나
function drawSeg(ctx: CanvasRenderingContext2D, b: Board, gx: number, gy: number, c: RGB, shrink: number): void {
const s = b.cell * (1 - shrink);
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
ctx.fillStyle = rgba(c, 1);
rrPath(ctx, cx - s / 2, cy - s / 2, s, s, s * 0.32);
ctx.fill();
}
function drawEyes(ctx: CanvasRenderingContext2D, b: Board, gx: number, gy: number, dirx: number, diry: number): void {
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
const px = -diry;
const py = dirx;
const r = b.cell * 0.09;
ctx.fillStyle = rgba(BG, 1);
for (let s = -1; s <= 1; s += 2) {
ctx.beginPath();
ctx.arc(cx + dirx * b.cell * 0.16 + px * s * b.cell * 0.18, cy + diry * b.cell * 0.16 + py * s * b.cell * 0.18, r, 0, PI2);
ctx.fill();
}
}
function headDir(g: Game): [number, number] {
if (g.body.length < 2) {
return [1, 0];
}
const b = g.b;
const dx = wrapDelta(g.body[1] % b.cols, g.body[0] % b.cols, b.cols, g.wrap);
const dy = wrapDelta(Math.floor(g.body[1] / b.cols), Math.floor(g.body[0] / b.cols), b.rows, g.wrap);
return [clamp(dx, -1, 1), clamp(dy, -1, 1)];
}
// 보간 없이 칸 그대로 그리는 기본 뱀
function drawSnakeCells(ctx: CanvasRenderingContext2D, g: Game, dim: number): void {
const len = g.body.length;
for (let i = len - 1; i >= 0; i -= 1) {
const c = g.body[i];
const col = mix(bodyColor(i, len), BG, dim);
drawSeg(ctx, g.b, c % g.b.cols, Math.floor(c / g.b.cols), col, 0.08);
}
const hd = headDir(g);
drawEyes(ctx, g.b, g.body[0] % g.b.cols, Math.floor(g.body[0] / g.b.cols), hd[0], hd[1]);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement, make: Maker): () => void {
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: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = 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);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
// 움직임 줄이기에서는 루프가 없으니 입력 직후 한 틱쯤 앞으로 보내 바로 그린다
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 8);
show();
}
}
function toLocal(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;
}
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');
advance(1400);
show();
}
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
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, 'pointermove', function (e: Event): void {
toLocal(e as PointerEvent);
refresh();
});
listen(canvas, 'pointerdown', function (e: Event): void {
toLocal(e as PointerEvent);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
// 이 장면이 쓰는 키만 막는다. 쓰지 않는 키(Tab 등)는 그대로 둔다
if (scene.key(k)) {
e.preventDefault();
refresh();
}
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const TICK_MS = 100;
const GRID_N = 12;
const BODY_LERP = 0.35;
const TUBE_FRAC = 0.62;
const TRAIL_LEN = 16;
const WARM_TICKS = 70;
const TXT_SMOOTH = '보간 켜짐';
const TXT_SNAP = '보간 꺼짐';
const TXT_LEN = '길이';
const TXT_DEAD = '갇혀서 끝 — 다시 시작';
function makeScene(inp: Input, reduce: boolean): Scene {
const b = makeBoard(GRID_N);
const g = newGame(b, false, 455303);
let acc = 0;
let t = 0;
let smoothOn = true;
let hold = 0;
const trail: number[] = [];
let px: number[] = [];
let py: number[] = [];
function snapAll(): void {
px = [];
py = [];
for (let i = 0; i < g.body.length; i += 1) {
px.push(cellX(b, g.body[i]));
py.push(cellY(b, g.body[i]));
}
}
function logic(): void {
if (hold > 0) {
hold -= 1;
if (hold === 0) {
resetGame(g);
snapAll();
}
return;
}
const p = planMove(g, true);
if (p.next < 0) {
g.dead = true;
hold = 8;
return;
}
const ate = moveSnake(g, p.next);
if (ate) {
px.push(px[px.length - 1]);
py.push(py[py.length - 1]);
}
}
snapAll();
const scene: Scene = {
step(): void {
t += 1;
acc += FIXED_DT;
while (acc >= TICK_MS) {
acc -= TICK_MS;
logic();
}
trail.push(px[0], py[0]);
while (trail.length > TRAIL_LEN * 2) {
trail.shift();
trail.shift();
}
// 그리는 자리가 논리 칸 자리를 BODY_LERP 만큼씩 따라간다
for (let i = 0; i < g.body.length; i += 1) {
const tx = cellX(b, g.body[i]);
const ty = cellY(b, g.body[i]);
if (smoothOn) {
px[i] = lerp(px[i], tx, BODY_LERP);
py[i] = lerp(py[i], ty, BODY_LERP);
} else {
px[i] = tx;
py[i] = ty;
}
}
},
draw(ctx: CanvasRenderingContext2D): void {
drawTiles(ctx, b);
// 논리 칸 — 틱마다 한 칸씩 뛰는 자리
for (let i = 0; i < g.body.length; i += 1) {
const c = g.body[i];
ctx.strokeStyle = rgba(CREAM, 0.34);
ctx.lineWidth = 1.5;
ctx.strokeRect(b.ox + (c % b.cols) * b.cell + 3, b.oy + Math.floor(c / b.cols) * b.cell + 3, b.cell - 6, b.cell - 6);
}
drawFood(ctx, b, g.food, t);
for (let k = 0; k < trail.length / 2; k += 1) {
const a = (k + 1) / (trail.length / 2);
glow(ctx, trail[k * 2], trail[k * 2 + 1], b.cell * (0.5 + 0.5 * a), MINT, 0.22 * a);
}
// 그리는 몸 — 마디마다 색을 달리 한 굵은 선
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.lineWidth = b.cell * TUBE_FRAC;
const len = g.body.length;
for (let i = len - 1; i >= 1; i -= 1) {
ctx.strokeStyle = rgba(mix(bodyColor(i, len), BG, g.dead ? 0.55 : 0), 1);
ctx.beginPath();
ctx.moveTo(px[i], py[i]);
ctx.lineTo(px[i - 1], py[i - 1]);
ctx.stroke();
}
ctx.fillStyle = rgba(mix(MINT, BG, g.dead ? 0.55 : 0), 1);
ctx.beginPath();
ctx.arc(px[0], py[0], b.cell * (TUBE_FRAC * 0.5 + 0.06), 0, PI2);
ctx.fill();
const hd = headDir(g);
const ex = -hd[1];
const ey = hd[0];
ctx.fillStyle = rgba(BG, 1);
for (let s = -1; s <= 1; s += 2) {
ctx.beginPath();
ctx.arc(px[0] + hd[0] * b.cell * 0.14 + ex * s * b.cell * 0.15, py[0] + hd[1] * b.cell * 0.14 + ey * s * b.cell * 0.15, b.cell * 0.07, 0, PI2);
ctx.fill();
}
},
label(): string {
if (g.dead) {
return TXT_DEAD;
}
return (smoothOn ? TXT_SMOOTH : TXT_SNAP) + ' · ' + TXT_LEN + ' ' + g.body.length;
},
tap(): void {
smoothOn = !smoothOn;
},
key(k: string): boolean {
return false;
}
};
for (let i = 0; i < WARM_TICKS; i += 1) {
logic();
}
snapAll();
if (reduce) {
for (let i = 0; i < 4; i += 1) {
scene.step();
}
}
return scene;
}
export default function SkSmoothLerpBody() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="틱 사이를 보간해 칸이 아니라 픽셀 단위로 미끄러지는 뱀 몸통" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">보간 켜짐</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>틱 사이를 보간해 칸이 아니라 픽셀 단위로 미끄러지는 뱀 몸통</p>
</div>
</main>
);
}03 · 틱 사이를 보간해 칸이 아니라 픽셀 단위로 미끄러지는 뱀 몸통 — 측정 누적면적 18.462% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 그리는 자리가 논리 칸 자리를 BODY_LERP 만큼씩 따라간다
for (let i = 0; i < g.body.length; i += 1) {
const tx = cellX(b, g.body[i]);
const ty = cellY(b, g.body[i]);
if (smoothOn) {
px[i] = lerp(px[i], tx, BODY_LERP);
py[i] = lerp(py[i], ty, BODY_LERP);
}
else {
px[i] = tx;
py[i] = ty;
}
}
04칸 없이 머리를 따라 거리 제약으로 끌려오는 사슬 몸통의 뱀
칸이 없고 머리만 ang 방향으로 직접 움직인다. 머리 뒤로 이어지는 마디(처음 26마디 중 머리를 뺀 25개, 먹이를 먹으면 늘어난다)는 이전 위치와의 차이(관성)에 DAMP 0.97 을 곱해 나아간 뒤 이웃 마디 간격을 9px 로 되돌리는 제약을 4번 푼다. 누적면적은 7.7% 로 아홉 중 가장 작고 평균 강도 50.4, 움직인 프레임 23/23 이다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="칸 없이 머리를 따라 거리 제약으로 끌려오는 사슬 몸통의 뱀"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">자동 조향</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">칸 없이 머리를 따라 거리 제약으로 끌려오는 사슬 몸통의 뱀</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
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;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__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;
}
.sc__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;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
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: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { 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);
}
.sc {
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;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__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;
}
.sc__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;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
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 BG = [29, 26, 44];
const TILE_A = [37, 33, 58];
const TILE_B = [42, 38, 66];
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 glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
// 둥근 사각형 경로 (roundRect 가 없는 브라우저에서도 같은 모양)
function rrPath(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
function makeBoard(rows) {
const cell = H / rows;
const cols = Math.floor(W / cell + 1e-6);
return { cols: cols, rows: rows, cell: cell, ox: (W - cols * cell) / 2, oy: 0, n: cols * rows };
}
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
// 칸 번호 y*cols+x. 판 밖이면 -1 (wrap 이면 반대편으로 이어진다)
function cellAt(b, x, y, wrap) {
let xx = x;
let yy = y;
if (wrap) {
xx = (x % b.cols + b.cols) % b.cols;
yy = (y % b.rows + b.rows) % b.rows;
}
else if (x < 0 || y < 0 || x >= b.cols || y >= b.rows) {
return -1;
}
return yy * b.cols + xx;
}
function neighborCell(b, i, d, wrap) {
return cellAt(b, (i % b.cols) + DX[d], Math.floor(i / b.cols) + DY[d], wrap);
}
// 목표 칸에서 너비 우선으로 퍼지는 거리장. blocked 칸과 닿지 못하는 칸은 -1
function distField(b, target, blocked, wrap) {
const dist = new Int16Array(b.n).fill(-1);
const queue = new Int16Array(b.n);
let qh = 0;
let qt = 0;
dist[target] = 0;
queue[qt] = target;
qt += 1;
while (qh < qt) {
const c = queue[qh];
qh += 1;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb >= 0 && dist[nb] < 0 && blocked[nb] === 0) {
dist[nb] = dist[c] + 1;
queue[qt] = nb;
qt += 1;
}
}
}
return dist;
}
// 거리장에서 값이 가장 낮은 이웃 (없으면 -1)
function lowestNeighbor(b, dist, from, wrap) {
let best = -1;
let bv = 32000;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, from, d, wrap);
if (nb >= 0 && dist[nb] >= 0 && dist[nb] < bv) {
bv = dist[nb];
best = nb;
}
}
return best;
}
function pathFrom(b, dist, head, wrap) {
const path = [];
let cur = head;
for (let k = 0; k < b.n; k += 1) {
const nx = lowestNeighbor(b, dist, cur, wrap);
if (nx < 0 || (dist[nx] >= dist[cur] && cur !== head)) {
return [];
}
path.push(nx);
if (dist[nx] === 0) {
return path;
}
cur = nx;
}
return [];
}
function placeFood(g) {
const used = new Uint8Array(g.b.n);
for (let i = 0; i < g.body.length; i += 1) {
used[g.body[i]] = 1;
}
const free = [];
for (let i = 0; i < g.b.n; i += 1) {
if (used[i] === 0) {
free.push(i);
}
}
g.food = free.length > 0 ? free[Math.floor(g.rnd() * free.length)] : -1;
}
function resetGame(g) {
const b = g.b;
const hx = Math.floor(b.cols / 2);
const hy = Math.floor(b.rows / 2);
g.body = [hy * b.cols + hx, hy * b.cols + hx - 1, hy * b.cols + hx - 2];
g.prev = g.body.slice();
g.dead = false;
g.resets += 1;
placeFood(g);
}
function newGame(b, wrap, seed) {
const g = { b: b, wrap: wrap, body: [], prev: [], food: -1, rnd: lcg(seed), eaten: 0, dead: false, resets: -1 };
resetGame(g);
return g;
}
// 머리를 next 칸으로 옮긴다. 먹으면 true, 몸에 부딪히면 dead
function moveSnake(g, next) {
g.prev = g.body.slice();
const ate = next === g.food;
const tail = g.body[g.body.length - 1];
for (let i = 0; i < g.body.length; i += 1) {
if (g.body[i] === next && !(i === g.body.length - 1 && !ate && next === tail)) {
g.dead = true;
return false;
}
}
g.body.unshift(next);
if (ate) {
g.eaten += 1;
placeFood(g);
}
else {
g.body.pop();
}
return ate;
}
// 경로를 끝까지 따라가 먹은 뒤의 가상 몸
function simulate(body, path) {
const v = body.slice();
for (let k = 0; k < path.length; k += 1) {
v.unshift(path[k]);
if (k < path.length - 1) {
v.pop();
}
}
return v;
}
// 가상 몸의 머리에서 번져 나가 꼬리에 닿는가. mark 를 주면 닿은 칸을 표시한다
function tailReachable(b, v, wrap, mark) {
const blocked = new Uint8Array(b.n);
for (let i = 1; i < v.length - 1; i += 1) {
blocked[v[i]] = 1;
}
const tail = v[v.length - 1];
const head = v[0];
const seen = new Uint8Array(b.n);
const stack = [head];
seen[head] = 1;
let hit = false;
while (stack.length > 0) {
const c = stack.pop();
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb < 0 || seen[nb] === 1) {
continue;
}
if (nb === tail) {
hit = true;
continue;
}
if (blocked[nb] === 1 || nb === head) {
continue;
}
seen[nb] = 1;
stack.push(nb);
}
}
if (mark) {
mark.set(seen);
}
return hit;
}
// 한 수를 정한다: 먹이까지 최단 경로 → (안전 검사) → 막히면 꼬리를 쫓는다
function planMove(g, safeOn) {
const b = g.b;
const head = g.body[0];
const tail = g.body[g.body.length - 1];
const plan = { next: -1, path: [], rejected: [], trap: new Uint8Array(b.n), chase: false };
const blocked = new Uint8Array(b.n);
for (let i = 0; i < g.body.length - 1; i += 1) {
blocked[g.body[i]] = 1;
}
if (g.food >= 0) {
const dist = distField(b, g.food, blocked, g.wrap);
const path = pathFrom(b, dist, head, g.wrap);
plan.path = path;
if (path.length > 0) {
if (!safeOn) {
plan.next = path[0];
return plan;
}
const v = simulate(g.body, path);
if (tailReachable(b, v, g.wrap, plan.trap)) {
plan.next = path[0];
return plan;
}
plan.rejected = path;
}
}
plan.chase = true;
const dist2 = distField(b, tail, blocked, g.wrap);
const nx = lowestNeighbor(b, dist2, head, g.wrap);
if (nx >= 0) {
plan.next = nx;
return plan;
}
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, head, d, g.wrap);
if (nb >= 0 && blocked[nb] === 0 && nb !== head) {
plan.next = nb;
return plan;
}
}
return plan;
}
// 칸 중심의 화면 좌표
function cellX(b, i) { return b.ox + ((i % b.cols) + 0.5) * b.cell; }
function cellY(b, i) { return b.oy + (Math.floor(i / b.cols) + 0.5) * b.cell; }
function wrapDelta(a, c, n, wrap) {
let d = c - a;
if (wrap) {
if (d > n / 2) {
d -= n;
}
if (d < -n / 2) {
d += n;
}
}
return d;
}
// 마디 i 의 보간 위치 (칸 좌표). 지난 틱의 자리에서 이번 틱의 자리로 p(0~1) 만큼
function segPos(g, i, p) {
const b = g.b;
const cur = g.body[i];
const pv = i < g.prev.length ? g.prev[i] : cur;
const cx = cur % b.cols;
const cy = Math.floor(cur / b.cols);
const px = pv % b.cols;
const py = Math.floor(pv / b.cols);
return [px + wrapDelta(px, cx, b.cols, g.wrap) * p, py + wrapDelta(py, cy, b.rows, g.wrap) * p];
}
function drawTiles(ctx, b) {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
for (let y = 0; y < b.rows; y += 1) {
for (let x = 0; x < b.cols; x += 1) {
ctx.fillStyle = rgba((x + y) % 2 === 0 ? TILE_A : TILE_B, 1);
ctx.fillRect(b.ox + x * b.cell, b.oy + y * b.cell, b.cell + 0.5, b.cell + 0.5);
}
}
}
function drawFood(ctx, b, food, t) {
if (food < 0) {
return;
}
const x = cellX(b, food);
const y = cellY(b, food);
const pulse = 0.5 + 0.5 * Math.sin(t * 0.12);
glow(ctx, x, y, b.cell * (1.3 + 0.35 * pulse), PINK, 0.55);
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.arc(x, y, b.cell * 0.3, 0, PI2);
ctx.fill();
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.arc(x - b.cell * 0.08, y - b.cell * 0.09, b.cell * 0.09, 0, PI2);
ctx.fill();
}
function bodyColor(i, len) {
return mix(MINT, SKY, len <= 1 ? 0 : i / (len - 1));
}
// 칸 중심 (x,y 칸 좌표, 소수 허용)에 마디 하나
function drawSeg(ctx, b, gx, gy, c, shrink) {
const s = b.cell * (1 - shrink);
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
ctx.fillStyle = rgba(c, 1);
rrPath(ctx, cx - s / 2, cy - s / 2, s, s, s * 0.32);
ctx.fill();
}
function drawEyes(ctx, b, gx, gy, dirx, diry) {
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
const px = -diry;
const py = dirx;
const r = b.cell * 0.09;
ctx.fillStyle = rgba(BG, 1);
for (let s = -1; s <= 1; s += 2) {
ctx.beginPath();
ctx.arc(cx + dirx * b.cell * 0.16 + px * s * b.cell * 0.18, cy + diry * b.cell * 0.16 + py * s * b.cell * 0.18, r, 0, PI2);
ctx.fill();
}
}
function headDir(g) {
if (g.body.length < 2) {
return [1, 0];
}
const b = g.b;
const dx = wrapDelta(g.body[1] % b.cols, g.body[0] % b.cols, b.cols, g.wrap);
const dy = wrapDelta(Math.floor(g.body[1] / b.cols), Math.floor(g.body[0] / b.cols), b.rows, g.wrap);
return [clamp(dx, -1, 1), clamp(dy, -1, 1)];
}
// 보간 없이 칸 그대로 그리는 기본 뱀
function drawSnakeCells(ctx, g, dim) {
const len = g.body.length;
for (let i = len - 1; i >= 0; i -= 1) {
const c = g.body[i];
const col = mix(bodyColor(i, len), BG, dim);
drawSeg(ctx, g.b, c % g.b.cols, Math.floor(c / g.b.cols), col, 0.08);
}
const hd = headDir(g);
drawEyes(ctx, g.b, g.body[0] % g.b.cols, Math.floor(g.body[0] / g.b.cols), hd[0], hd[1]);
}
function start(root, canvas, statusEl, btnEl, make) {
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: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = 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);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
// 움직임 줄이기에서는 루프가 없으니 입력 직후 한 틱쯤 앞으로 보내 바로 그린다
function refresh() {
if (reduce) {
advance(FIXED_DT * 8);
show();
}
}
function toLocal(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;
}
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');
advance(1400);
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
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, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
// 이 장면이 쓰는 키만 막는다. 쓰지 않는 키(Tab 등)는 그대로 둔다
if (scene.key(k)) {
e.preventDefault();
refresh();
}
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const SEG_GAP = 9;
const SEG_COUNT = 26;
const SEG_MAX = 40;
const STEER_RATE = 0.075;
const FOLLOW_ITER = 4;
const FOLLOW_STIFF = 0.85;
const SPEED = 2.3;
const DAMP = 0.97;
const EAT_R = 11;
const WALL_PAD = 34;
const KEY_HOLD = 36;
const TRAIL_KEEP = 0.34;
const TXT_AUTO = '자동 조향';
const TXT_POINTER = '포인터를 쫓는 중';
const TXT_KEYS = '방향키 조종';
const TXT_LEN = '마디';
const TXT_SCORE = '먹이';
function makeScene(inp, reduce) {
const rnd = lcg(455404);
const px = [];
const py = [];
const qx = [];
const qy = [];
let ang = 0.3;
let fx = 0;
let fy = 0;
let t = 0;
let eaten = 0;
let keyAng = 0;
let keyLeft = 0;
let count = SEG_COUNT;
let painted = false;
function placeFoodAt() {
fx = 40 + rnd() * (W - 80);
fy = 36 + rnd() * (H - 72);
}
placeFoodAt();
for (let i = 0; i < SEG_MAX; i += 1) {
px.push(W * 0.3 - i * SEG_GAP);
py.push(H * 0.55);
qx.push(px[i]);
qy.push(py[i]);
}
function angDiff(a, c) {
let d = c - a;
while (d > Math.PI) {
d -= PI2;
}
while (d < -Math.PI) {
d += PI2;
}
return d;
}
const scene = {
step() {
t += 1;
let goalX = fx;
let goalY = fy;
if (inp.idle < IDLE_TICKS) {
goalX = inp.x;
goalY = inp.y;
}
let want = Math.atan2(goalY - py[0], goalX - px[0]);
if (keyLeft > 0) {
keyLeft -= 1;
want = keyAng;
}
// 벽 가까이에서는 판 가운데 쪽으로 틀어 준다
if (px[0] < WALL_PAD || px[0] > W - WALL_PAD || py[0] < WALL_PAD || py[0] > H - WALL_PAD) {
want = Math.atan2(H / 2 - py[0], W / 2 - px[0]);
}
ang += clamp(angDiff(ang, want), -STEER_RATE, STEER_RATE) + Math.sin(t * 0.11) * 0.018;
// 머리만 직접 움직이고 나머지 마디는 이전 위치와의 차이(관성)로 나아간다
qx[0] = px[0];
qy[0] = py[0];
px[0] += Math.cos(ang) * SPEED;
py[0] += Math.sin(ang) * SPEED;
for (let i = 1; i < count; i += 1) {
const vx = (px[i] - qx[i]) * DAMP;
const vy = (py[i] - qy[i]) * DAMP;
qx[i] = px[i];
qy[i] = py[i];
px[i] += vx;
py[i] += vy;
}
// 거리 제약: 이웃 마디와의 간격을 SEG_GAP 으로 되돌린다
for (let it = 0; it < FOLLOW_ITER; it += 1) {
for (let i = 1; i < count; i += 1) {
const dx = px[i] - px[i - 1];
const dy = py[i] - py[i - 1];
const d = Math.sqrt(dx * dx + dy * dy) || 0.0001;
const k = ((d - SEG_GAP) / d) * FOLLOW_STIFF;
if (i === 1) {
px[i] -= dx * k;
py[i] -= dy * k;
}
else {
px[i] -= dx * k * 0.5;
py[i] -= dy * k * 0.5;
px[i - 1] += dx * k * 0.5;
py[i - 1] += dy * k * 0.5;
}
}
}
const ex = px[0] - fx;
const ey = py[0] - fy;
if (ex * ex + ey * ey < EAT_R * EAT_R) {
eaten += 1;
if (count < SEG_MAX) {
count += 1;
px[count - 1] = px[count - 2];
py[count - 1] = py[count - 2];
qx[count - 1] = px[count - 1];
qy[count - 1] = py[count - 1];
}
placeFoodAt();
}
},
draw(ctx) {
// 배경을 반투명으로 덮어 지난 자리가 몇 프레임 남게 한다 (첫 프레임만 불투명)
ctx.fillStyle = rgba(BG, painted ? TRAIL_KEEP : 1);
ctx.fillRect(0, 0, W, H);
painted = true;
// 바닥 격자 — 칸 없는 판이라는 걸 보여 주려고 아주 옅게
ctx.strokeStyle = rgba(LILAC, 0.1);
ctx.lineWidth = 1;
for (let x = 0; x <= W; x += 30) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, H);
ctx.stroke();
}
for (let y = 0; y <= H; y += 30) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(W, y);
ctx.stroke();
}
const pulse = 0.5 + 0.5 * Math.sin(t * 0.12);
glow(ctx, fx, fy, 22 + 6 * pulse, PINK, 0.6);
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.arc(fx, fy, 6.5, 0, PI2);
ctx.fill();
// 지난 자리 잔상 — 이전 위치(qx,qy)에서 지금 위치로 끄는 꼬리빛
for (let i = count - 1; i >= 0; i -= 1) {
const f = i / count;
const c = mix(MINT, SKY, f);
glow(ctx, qx[i], qy[i], 13 - 5 * f, c, 0.22);
}
for (let i = count - 1; i >= 0; i -= 1) {
const f = i / count;
const r = 6.4 - 3.4 * f;
ctx.fillStyle = rgba(mix(MINT, SKY, f), 1);
ctx.beginPath();
ctx.arc(px[i], py[i], r, 0, PI2);
ctx.fill();
}
ctx.fillStyle = rgba(BG, 1);
for (let s = -1; s <= 1; s += 2) {
ctx.beginPath();
ctx.arc(px[0] + Math.cos(ang) * 2.4 - Math.sin(ang) * s * 2.8, py[0] + Math.sin(ang) * 2.4 + Math.cos(ang) * s * 2.8, 1.3, 0, PI2);
ctx.fill();
}
},
label() {
const mode = keyLeft > 0 ? TXT_KEYS : (inp.idle < IDLE_TICKS ? TXT_POINTER : TXT_AUTO);
return mode + ' · ' + TXT_LEN + ' ' + count + ' · ' + TXT_SCORE + ' ' + eaten;
},
tap() {
placeFoodAt();
},
key(k) {
if (k === 'ArrowLeft') {
keyAng = Math.PI;
}
else if (k === 'ArrowRight') {
keyAng = 0;
}
else if (k === 'ArrowUp') {
keyAng = -Math.PI / 2;
}
else if (k === 'ArrowDown') {
keyAng = Math.PI / 2;
}
else {
return false;
}
keyLeft = KEY_HOLD;
return true;
}
};
if (reduce) {
for (let i = 0; i < 60; i += 1) {
scene.step();
}
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./SkChainFollowRope.module.scss";
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 = '다음 장면';
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
key(k: string): boolean;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 팔레트 — 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG: RGB = [29, 26, 44];
const TILE_A: RGB = [37, 33, 58];
const TILE_B: RGB = [42, 38, 66];
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 glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: RGB, a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
// 둥근 사각형 경로 (roundRect 가 없는 브라우저에서도 같은 모양)
function rrPath(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.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// ---- 판과 뱀 ----
interface Board { cols: number; rows: number; cell: number; ox: number; oy: number; n: number; }
function makeBoard(rows: number): Board {
const cell = H / rows;
const cols = Math.floor(W / cell + 1e-6);
return { cols: cols, rows: rows, cell: cell, ox: (W - cols * cell) / 2, oy: 0, n: cols * rows };
}
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
// 칸 번호 y*cols+x. 판 밖이면 -1 (wrap 이면 반대편으로 이어진다)
function cellAt(b: Board, x: number, y: number, wrap: boolean): number {
let xx = x;
let yy = y;
if (wrap) {
xx = (x % b.cols + b.cols) % b.cols;
yy = (y % b.rows + b.rows) % b.rows;
} else if (x < 0 || y < 0 || x >= b.cols || y >= b.rows) {
return -1;
}
return yy * b.cols + xx;
}
function neighborCell(b: Board, i: number, d: number, wrap: boolean): number {
return cellAt(b, (i % b.cols) + DX[d], Math.floor(i / b.cols) + DY[d], wrap);
}
// 목표 칸에서 너비 우선으로 퍼지는 거리장. blocked 칸과 닿지 못하는 칸은 -1
function distField(b: Board, target: number, blocked: Uint8Array, wrap: boolean): Int16Array {
const dist = new Int16Array(b.n).fill(-1);
const queue = new Int16Array(b.n);
let qh = 0;
let qt = 0;
dist[target] = 0;
queue[qt] = target;
qt += 1;
while (qh < qt) {
const c = queue[qh];
qh += 1;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb >= 0 && dist[nb] < 0 && blocked[nb] === 0) {
dist[nb] = dist[c] + 1;
queue[qt] = nb;
qt += 1;
}
}
}
return dist;
}
// 거리장에서 값이 가장 낮은 이웃 (없으면 -1)
function lowestNeighbor(b: Board, dist: Int16Array, from: number, wrap: boolean): number {
let best = -1;
let bv = 32000;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, from, d, wrap);
if (nb >= 0 && dist[nb] >= 0 && dist[nb] < bv) {
bv = dist[nb];
best = nb;
}
}
return best;
}
function pathFrom(b: Board, dist: Int16Array, head: number, wrap: boolean): number[] {
const path: number[] = [];
let cur = head;
for (let k = 0; k < b.n; k += 1) {
const nx = lowestNeighbor(b, dist, cur, wrap);
if (nx < 0 || (dist[nx] >= dist[cur] && cur !== head)) {
return [];
}
path.push(nx);
if (dist[nx] === 0) {
return path;
}
cur = nx;
}
return [];
}
interface Game {
b: Board;
wrap: boolean;
body: number[];
prev: number[];
food: number;
rnd: () => number;
eaten: number;
dead: boolean;
resets: number;
}
function placeFood(g: Game): void {
const used = new Uint8Array(g.b.n);
for (let i = 0; i < g.body.length; i += 1) {
used[g.body[i]] = 1;
}
const free: number[] = [];
for (let i = 0; i < g.b.n; i += 1) {
if (used[i] === 0) {
free.push(i);
}
}
g.food = free.length > 0 ? free[Math.floor(g.rnd() * free.length)] : -1;
}
function resetGame(g: Game): void {
const b = g.b;
const hx = Math.floor(b.cols / 2);
const hy = Math.floor(b.rows / 2);
g.body = [hy * b.cols + hx, hy * b.cols + hx - 1, hy * b.cols + hx - 2];
g.prev = g.body.slice();
g.dead = false;
g.resets += 1;
placeFood(g);
}
function newGame(b: Board, wrap: boolean, seed: number): Game {
const g: Game = { b: b, wrap: wrap, body: [], prev: [], food: -1, rnd: lcg(seed), eaten: 0, dead: false, resets: -1 };
resetGame(g);
return g;
}
// 머리를 next 칸으로 옮긴다. 먹으면 true, 몸에 부딪히면 dead
function moveSnake(g: Game, next: number): boolean {
g.prev = g.body.slice();
const ate = next === g.food;
const tail = g.body[g.body.length - 1];
for (let i = 0; i < g.body.length; i += 1) {
if (g.body[i] === next && !(i === g.body.length - 1 && !ate && next === tail)) {
g.dead = true;
return false;
}
}
g.body.unshift(next);
if (ate) {
g.eaten += 1;
placeFood(g);
} else {
g.body.pop();
}
return ate;
}
// 경로를 끝까지 따라가 먹은 뒤의 가상 몸
function simulate(body: number[], path: number[]): number[] {
const v = body.slice();
for (let k = 0; k < path.length; k += 1) {
v.unshift(path[k]);
if (k < path.length - 1) {
v.pop();
}
}
return v;
}
// 가상 몸의 머리에서 번져 나가 꼬리에 닿는가. mark 를 주면 닿은 칸을 표시한다
function tailReachable(b: Board, v: number[], wrap: boolean, mark: Uint8Array | null): boolean {
const blocked = new Uint8Array(b.n);
for (let i = 1; i < v.length - 1; i += 1) {
blocked[v[i]] = 1;
}
const tail = v[v.length - 1];
const head = v[0];
const seen = new Uint8Array(b.n);
const stack: number[] = [head];
seen[head] = 1;
let hit = false;
while (stack.length > 0) {
const c = stack.pop() as number;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb < 0 || seen[nb] === 1) {
continue;
}
if (nb === tail) {
hit = true;
continue;
}
if (blocked[nb] === 1 || nb === head) {
continue;
}
seen[nb] = 1;
stack.push(nb);
}
}
if (mark) {
mark.set(seen);
}
return hit;
}
interface Plan { next: number; path: number[]; rejected: number[]; trap: Uint8Array; chase: boolean; }
// 한 수를 정한다: 먹이까지 최단 경로 → (안전 검사) → 막히면 꼬리를 쫓는다
function planMove(g: Game, safeOn: boolean): Plan {
const b = g.b;
const head = g.body[0];
const tail = g.body[g.body.length - 1];
const plan: Plan = { next: -1, path: [], rejected: [], trap: new Uint8Array(b.n), chase: false };
const blocked = new Uint8Array(b.n);
for (let i = 0; i < g.body.length - 1; i += 1) {
blocked[g.body[i]] = 1;
}
if (g.food >= 0) {
const dist = distField(b, g.food, blocked, g.wrap);
const path = pathFrom(b, dist, head, g.wrap);
plan.path = path;
if (path.length > 0) {
if (!safeOn) {
plan.next = path[0];
return plan;
}
const v = simulate(g.body, path);
if (tailReachable(b, v, g.wrap, plan.trap)) {
plan.next = path[0];
return plan;
}
plan.rejected = path;
}
}
plan.chase = true;
const dist2 = distField(b, tail, blocked, g.wrap);
const nx = lowestNeighbor(b, dist2, head, g.wrap);
if (nx >= 0) {
plan.next = nx;
return plan;
}
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, head, d, g.wrap);
if (nb >= 0 && blocked[nb] === 0 && nb !== head) {
plan.next = nb;
return plan;
}
}
return plan;
}
// 칸 중심의 화면 좌표
function cellX(b: Board, i: number): number { return b.ox + ((i % b.cols) + 0.5) * b.cell; }
function cellY(b: Board, i: number): number { return b.oy + (Math.floor(i / b.cols) + 0.5) * b.cell; }
function wrapDelta(a: number, c: number, n: number, wrap: boolean): number {
let d = c - a;
if (wrap) {
if (d > n / 2) { d -= n; }
if (d < -n / 2) { d += n; }
}
return d;
}
// 마디 i 의 보간 위치 (칸 좌표). 지난 틱의 자리에서 이번 틱의 자리로 p(0~1) 만큼
function segPos(g: Game, i: number, p: number): [number, number] {
const b = g.b;
const cur = g.body[i];
const pv = i < g.prev.length ? g.prev[i] : cur;
const cx = cur % b.cols;
const cy = Math.floor(cur / b.cols);
const px = pv % b.cols;
const py = Math.floor(pv / b.cols);
return [px + wrapDelta(px, cx, b.cols, g.wrap) * p, py + wrapDelta(py, cy, b.rows, g.wrap) * p];
}
function drawTiles(ctx: CanvasRenderingContext2D, b: Board): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
for (let y = 0; y < b.rows; y += 1) {
for (let x = 0; x < b.cols; x += 1) {
ctx.fillStyle = rgba((x + y) % 2 === 0 ? TILE_A : TILE_B, 1);
ctx.fillRect(b.ox + x * b.cell, b.oy + y * b.cell, b.cell + 0.5, b.cell + 0.5);
}
}
}
function drawFood(ctx: CanvasRenderingContext2D, b: Board, food: number, t: number): void {
if (food < 0) {
return;
}
const x = cellX(b, food);
const y = cellY(b, food);
const pulse = 0.5 + 0.5 * Math.sin(t * 0.12);
glow(ctx, x, y, b.cell * (1.3 + 0.35 * pulse), PINK, 0.55);
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.arc(x, y, b.cell * 0.3, 0, PI2);
ctx.fill();
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.arc(x - b.cell * 0.08, y - b.cell * 0.09, b.cell * 0.09, 0, PI2);
ctx.fill();
}
function bodyColor(i: number, len: number): RGB {
return mix(MINT, SKY, len <= 1 ? 0 : i / (len - 1));
}
// 칸 중심 (x,y 칸 좌표, 소수 허용)에 마디 하나
function drawSeg(ctx: CanvasRenderingContext2D, b: Board, gx: number, gy: number, c: RGB, shrink: number): void {
const s = b.cell * (1 - shrink);
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
ctx.fillStyle = rgba(c, 1);
rrPath(ctx, cx - s / 2, cy - s / 2, s, s, s * 0.32);
ctx.fill();
}
function drawEyes(ctx: CanvasRenderingContext2D, b: Board, gx: number, gy: number, dirx: number, diry: number): void {
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
const px = -diry;
const py = dirx;
const r = b.cell * 0.09;
ctx.fillStyle = rgba(BG, 1);
for (let s = -1; s <= 1; s += 2) {
ctx.beginPath();
ctx.arc(cx + dirx * b.cell * 0.16 + px * s * b.cell * 0.18, cy + diry * b.cell * 0.16 + py * s * b.cell * 0.18, r, 0, PI2);
ctx.fill();
}
}
function headDir(g: Game): [number, number] {
if (g.body.length < 2) {
return [1, 0];
}
const b = g.b;
const dx = wrapDelta(g.body[1] % b.cols, g.body[0] % b.cols, b.cols, g.wrap);
const dy = wrapDelta(Math.floor(g.body[1] / b.cols), Math.floor(g.body[0] / b.cols), b.rows, g.wrap);
return [clamp(dx, -1, 1), clamp(dy, -1, 1)];
}
// 보간 없이 칸 그대로 그리는 기본 뱀
function drawSnakeCells(ctx: CanvasRenderingContext2D, g: Game, dim: number): void {
const len = g.body.length;
for (let i = len - 1; i >= 0; i -= 1) {
const c = g.body[i];
const col = mix(bodyColor(i, len), BG, dim);
drawSeg(ctx, g.b, c % g.b.cols, Math.floor(c / g.b.cols), col, 0.08);
}
const hd = headDir(g);
drawEyes(ctx, g.b, g.body[0] % g.b.cols, Math.floor(g.body[0] / g.b.cols), hd[0], hd[1]);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement, make: Maker): () => void {
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: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = 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);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
// 움직임 줄이기에서는 루프가 없으니 입력 직후 한 틱쯤 앞으로 보내 바로 그린다
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 8);
show();
}
}
function toLocal(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;
}
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');
advance(1400);
show();
}
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
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, 'pointermove', function (e: Event): void {
toLocal(e as PointerEvent);
refresh();
});
listen(canvas, 'pointerdown', function (e: Event): void {
toLocal(e as PointerEvent);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
// 이 장면이 쓰는 키만 막는다. 쓰지 않는 키(Tab 등)는 그대로 둔다
if (scene.key(k)) {
e.preventDefault();
refresh();
}
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const SEG_GAP = 9;
const SEG_COUNT = 26;
const SEG_MAX = 40;
const STEER_RATE = 0.075;
const FOLLOW_ITER = 4;
const FOLLOW_STIFF = 0.85;
const SPEED = 2.3;
const DAMP = 0.97;
const EAT_R = 11;
const WALL_PAD = 34;
const KEY_HOLD = 36;
const TRAIL_KEEP = 0.34;
const TXT_AUTO = '자동 조향';
const TXT_POINTER = '포인터를 쫓는 중';
const TXT_KEYS = '방향키 조종';
const TXT_LEN = '마디';
const TXT_SCORE = '먹이';
function makeScene(inp: Input, reduce: boolean): Scene {
const rnd = lcg(455404);
const px: number[] = [];
const py: number[] = [];
const qx: number[] = [];
const qy: number[] = [];
let ang = 0.3;
let fx = 0;
let fy = 0;
let t = 0;
let eaten = 0;
let keyAng = 0;
let keyLeft = 0;
let count = SEG_COUNT;
let painted = false;
function placeFoodAt(): void {
fx = 40 + rnd() * (W - 80);
fy = 36 + rnd() * (H - 72);
}
placeFoodAt();
for (let i = 0; i < SEG_MAX; i += 1) {
px.push(W * 0.3 - i * SEG_GAP);
py.push(H * 0.55);
qx.push(px[i]);
qy.push(py[i]);
}
function angDiff(a: number, c: number): number {
let d = c - a;
while (d > Math.PI) { d -= PI2; }
while (d < -Math.PI) { d += PI2; }
return d;
}
const scene: Scene = {
step(): void {
t += 1;
let goalX = fx;
let goalY = fy;
if (inp.idle < IDLE_TICKS) {
goalX = inp.x;
goalY = inp.y;
}
let want = Math.atan2(goalY - py[0], goalX - px[0]);
if (keyLeft > 0) {
keyLeft -= 1;
want = keyAng;
}
// 벽 가까이에서는 판 가운데 쪽으로 틀어 준다
if (px[0] < WALL_PAD || px[0] > W - WALL_PAD || py[0] < WALL_PAD || py[0] > H - WALL_PAD) {
want = Math.atan2(H / 2 - py[0], W / 2 - px[0]);
}
ang += clamp(angDiff(ang, want), -STEER_RATE, STEER_RATE) + Math.sin(t * 0.11) * 0.018;
// 머리만 직접 움직이고 나머지 마디는 이전 위치와의 차이(관성)로 나아간다
qx[0] = px[0];
qy[0] = py[0];
px[0] += Math.cos(ang) * SPEED;
py[0] += Math.sin(ang) * SPEED;
for (let i = 1; i < count; i += 1) {
const vx = (px[i] - qx[i]) * DAMP;
const vy = (py[i] - qy[i]) * DAMP;
qx[i] = px[i];
qy[i] = py[i];
px[i] += vx;
py[i] += vy;
}
// 거리 제약: 이웃 마디와의 간격을 SEG_GAP 으로 되돌린다
for (let it = 0; it < FOLLOW_ITER; it += 1) {
for (let i = 1; i < count; i += 1) {
const dx = px[i] - px[i - 1];
const dy = py[i] - py[i - 1];
const d = Math.sqrt(dx * dx + dy * dy) || 0.0001;
const k = ((d - SEG_GAP) / d) * FOLLOW_STIFF;
if (i === 1) {
px[i] -= dx * k;
py[i] -= dy * k;
} else {
px[i] -= dx * k * 0.5;
py[i] -= dy * k * 0.5;
px[i - 1] += dx * k * 0.5;
py[i - 1] += dy * k * 0.5;
}
}
}
const ex = px[0] - fx;
const ey = py[0] - fy;
if (ex * ex + ey * ey < EAT_R * EAT_R) {
eaten += 1;
if (count < SEG_MAX) {
count += 1;
px[count - 1] = px[count - 2];
py[count - 1] = py[count - 2];
qx[count - 1] = px[count - 1];
qy[count - 1] = py[count - 1];
}
placeFoodAt();
}
},
draw(ctx: CanvasRenderingContext2D): void {
// 배경을 반투명으로 덮어 지난 자리가 몇 프레임 남게 한다 (첫 프레임만 불투명)
ctx.fillStyle = rgba(BG, painted ? TRAIL_KEEP : 1);
ctx.fillRect(0, 0, W, H);
painted = true;
// 바닥 격자 — 칸 없는 판이라는 걸 보여 주려고 아주 옅게
ctx.strokeStyle = rgba(LILAC, 0.1);
ctx.lineWidth = 1;
for (let x = 0; x <= W; x += 30) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, H);
ctx.stroke();
}
for (let y = 0; y <= H; y += 30) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(W, y);
ctx.stroke();
}
const pulse = 0.5 + 0.5 * Math.sin(t * 0.12);
glow(ctx, fx, fy, 22 + 6 * pulse, PINK, 0.6);
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.arc(fx, fy, 6.5, 0, PI2);
ctx.fill();
// 지난 자리 잔상 — 이전 위치(qx,qy)에서 지금 위치로 끄는 꼬리빛
for (let i = count - 1; i >= 0; i -= 1) {
const f = i / count;
const c = mix(MINT, SKY, f);
glow(ctx, qx[i], qy[i], 13 - 5 * f, c, 0.22);
}
for (let i = count - 1; i >= 0; i -= 1) {
const f = i / count;
const r = 6.4 - 3.4 * f;
ctx.fillStyle = rgba(mix(MINT, SKY, f), 1);
ctx.beginPath();
ctx.arc(px[i], py[i], r, 0, PI2);
ctx.fill();
}
ctx.fillStyle = rgba(BG, 1);
for (let s = -1; s <= 1; s += 2) {
ctx.beginPath();
ctx.arc(px[0] + Math.cos(ang) * 2.4 - Math.sin(ang) * s * 2.8, py[0] + Math.sin(ang) * 2.4 + Math.cos(ang) * s * 2.8, 1.3, 0, PI2);
ctx.fill();
}
},
label(): string {
const mode = keyLeft > 0 ? TXT_KEYS : (inp.idle < IDLE_TICKS ? TXT_POINTER : TXT_AUTO);
return mode + ' · ' + TXT_LEN + ' ' + count + ' · ' + TXT_SCORE + ' ' + eaten;
},
tap(): void {
placeFoodAt();
},
key(k: string): boolean {
if (k === 'ArrowLeft') { keyAng = Math.PI; }
else if (k === 'ArrowRight') { keyAng = 0; }
else if (k === 'ArrowUp') { keyAng = -Math.PI / 2; }
else if (k === 'ArrowDown') { keyAng = Math.PI / 2; }
else { return false; }
keyLeft = KEY_HOLD;
return true;
}
};
if (reduce) {
for (let i = 0; i < 60; i += 1) {
scene.step();
}
}
return scene;
}
export default function SkChainFollowRope() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="칸 없이 머리를 따라 거리 제약으로 끌려오는 사슬 몸통의 뱀" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">자동 조향</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>칸 없이 머리를 따라 거리 제약으로 끌려오는 사슬 몸통의 뱀</p>
</div>
</main>
);
}04 · 칸 없이 머리를 따라 거리 제약으로 끌려오는 사슬 몸통의 뱀 — 측정 누적면적 7.728% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 거리 제약: 이웃 마디와의 간격을 SEG_GAP 으로 되돌린다
for (let it = 0; it < FOLLOW_ITER; it += 1) {
for (let i = 1; i < count; i += 1) {
const dx = px[i] - px[i - 1];
const dy = py[i] - py[i - 1];
const d = Math.sqrt(dx * dx + dy * dy) || 0.0001;
const k = ((d - SEG_GAP) / d) * FOLLOW_STIFF;
if (i === 1) {
px[i] -= dx * k;
py[i] -= dy * k;
}
else {
px[i] -= dx * k * 0.5;
py[i] -= dy * k * 0.5;
px[i - 1] += dx * k * 0.5;
py[i - 1] += dy * k * 0.5;
}
}
}
05지나간 칸에 열이 남았다가 식으며 이동 경로를 그리는 뱀
머리가 새 칸에 닿을 때 그 칸의 heat 에 1 을 더하고(최대 1.4), 매 16ms 스텝 모든 칸에 0.988 을 곱해 식힌다. 곱셈 하나라 반감기가 약 57스텝(0.92초)이고, heatColor 가 값을 7단계로 끊어 보라에서 크림까지 칠한다. 누적면적 22.8%, 평균 강도 62.6, 움직인 프레임 23/23 으로 잡혔다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="지나간 칸에 열이 남았다가 식으며 이동 경로를 그리는 뱀"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">계단 팔레트</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">지나간 칸에 열이 남았다가 식으며 이동 경로를 그리는 뱀</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
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;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__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;
}
.sc__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;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
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: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { 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);
}
.sc {
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;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__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;
}
.sc__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;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
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 BG = [29, 26, 44];
const TILE_A = [37, 33, 58];
const TILE_B = [42, 38, 66];
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 glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
// 둥근 사각형 경로 (roundRect 가 없는 브라우저에서도 같은 모양)
function rrPath(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
function makeBoard(rows) {
const cell = H / rows;
const cols = Math.floor(W / cell + 1e-6);
return { cols: cols, rows: rows, cell: cell, ox: (W - cols * cell) / 2, oy: 0, n: cols * rows };
}
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
// 칸 번호 y*cols+x. 판 밖이면 -1 (wrap 이면 반대편으로 이어진다)
function cellAt(b, x, y, wrap) {
let xx = x;
let yy = y;
if (wrap) {
xx = (x % b.cols + b.cols) % b.cols;
yy = (y % b.rows + b.rows) % b.rows;
}
else if (x < 0 || y < 0 || x >= b.cols || y >= b.rows) {
return -1;
}
return yy * b.cols + xx;
}
function neighborCell(b, i, d, wrap) {
return cellAt(b, (i % b.cols) + DX[d], Math.floor(i / b.cols) + DY[d], wrap);
}
// 목표 칸에서 너비 우선으로 퍼지는 거리장. blocked 칸과 닿지 못하는 칸은 -1
function distField(b, target, blocked, wrap) {
const dist = new Int16Array(b.n).fill(-1);
const queue = new Int16Array(b.n);
let qh = 0;
let qt = 0;
dist[target] = 0;
queue[qt] = target;
qt += 1;
while (qh < qt) {
const c = queue[qh];
qh += 1;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb >= 0 && dist[nb] < 0 && blocked[nb] === 0) {
dist[nb] = dist[c] + 1;
queue[qt] = nb;
qt += 1;
}
}
}
return dist;
}
// 거리장에서 값이 가장 낮은 이웃 (없으면 -1)
function lowestNeighbor(b, dist, from, wrap) {
let best = -1;
let bv = 32000;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, from, d, wrap);
if (nb >= 0 && dist[nb] >= 0 && dist[nb] < bv) {
bv = dist[nb];
best = nb;
}
}
return best;
}
function pathFrom(b, dist, head, wrap) {
const path = [];
let cur = head;
for (let k = 0; k < b.n; k += 1) {
const nx = lowestNeighbor(b, dist, cur, wrap);
if (nx < 0 || (dist[nx] >= dist[cur] && cur !== head)) {
return [];
}
path.push(nx);
if (dist[nx] === 0) {
return path;
}
cur = nx;
}
return [];
}
function placeFood(g) {
const used = new Uint8Array(g.b.n);
for (let i = 0; i < g.body.length; i += 1) {
used[g.body[i]] = 1;
}
const free = [];
for (let i = 0; i < g.b.n; i += 1) {
if (used[i] === 0) {
free.push(i);
}
}
g.food = free.length > 0 ? free[Math.floor(g.rnd() * free.length)] : -1;
}
function resetGame(g) {
const b = g.b;
const hx = Math.floor(b.cols / 2);
const hy = Math.floor(b.rows / 2);
g.body = [hy * b.cols + hx, hy * b.cols + hx - 1, hy * b.cols + hx - 2];
g.prev = g.body.slice();
g.dead = false;
g.resets += 1;
placeFood(g);
}
function newGame(b, wrap, seed) {
const g = { b: b, wrap: wrap, body: [], prev: [], food: -1, rnd: lcg(seed), eaten: 0, dead: false, resets: -1 };
resetGame(g);
return g;
}
// 머리를 next 칸으로 옮긴다. 먹으면 true, 몸에 부딪히면 dead
function moveSnake(g, next) {
g.prev = g.body.slice();
const ate = next === g.food;
const tail = g.body[g.body.length - 1];
for (let i = 0; i < g.body.length; i += 1) {
if (g.body[i] === next && !(i === g.body.length - 1 && !ate && next === tail)) {
g.dead = true;
return false;
}
}
g.body.unshift(next);
if (ate) {
g.eaten += 1;
placeFood(g);
}
else {
g.body.pop();
}
return ate;
}
// 경로를 끝까지 따라가 먹은 뒤의 가상 몸
function simulate(body, path) {
const v = body.slice();
for (let k = 0; k < path.length; k += 1) {
v.unshift(path[k]);
if (k < path.length - 1) {
v.pop();
}
}
return v;
}
// 가상 몸의 머리에서 번져 나가 꼬리에 닿는가. mark 를 주면 닿은 칸을 표시한다
function tailReachable(b, v, wrap, mark) {
const blocked = new Uint8Array(b.n);
for (let i = 1; i < v.length - 1; i += 1) {
blocked[v[i]] = 1;
}
const tail = v[v.length - 1];
const head = v[0];
const seen = new Uint8Array(b.n);
const stack = [head];
seen[head] = 1;
let hit = false;
while (stack.length > 0) {
const c = stack.pop();
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb < 0 || seen[nb] === 1) {
continue;
}
if (nb === tail) {
hit = true;
continue;
}
if (blocked[nb] === 1 || nb === head) {
continue;
}
seen[nb] = 1;
stack.push(nb);
}
}
if (mark) {
mark.set(seen);
}
return hit;
}
// 한 수를 정한다: 먹이까지 최단 경로 → (안전 검사) → 막히면 꼬리를 쫓는다
function planMove(g, safeOn) {
const b = g.b;
const head = g.body[0];
const tail = g.body[g.body.length - 1];
const plan = { next: -1, path: [], rejected: [], trap: new Uint8Array(b.n), chase: false };
const blocked = new Uint8Array(b.n);
for (let i = 0; i < g.body.length - 1; i += 1) {
blocked[g.body[i]] = 1;
}
if (g.food >= 0) {
const dist = distField(b, g.food, blocked, g.wrap);
const path = pathFrom(b, dist, head, g.wrap);
plan.path = path;
if (path.length > 0) {
if (!safeOn) {
plan.next = path[0];
return plan;
}
const v = simulate(g.body, path);
if (tailReachable(b, v, g.wrap, plan.trap)) {
plan.next = path[0];
return plan;
}
plan.rejected = path;
}
}
plan.chase = true;
const dist2 = distField(b, tail, blocked, g.wrap);
const nx = lowestNeighbor(b, dist2, head, g.wrap);
if (nx >= 0) {
plan.next = nx;
return plan;
}
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, head, d, g.wrap);
if (nb >= 0 && blocked[nb] === 0 && nb !== head) {
plan.next = nb;
return plan;
}
}
return plan;
}
// 칸 중심의 화면 좌표
function cellX(b, i) { return b.ox + ((i % b.cols) + 0.5) * b.cell; }
function cellY(b, i) { return b.oy + (Math.floor(i / b.cols) + 0.5) * b.cell; }
function wrapDelta(a, c, n, wrap) {
let d = c - a;
if (wrap) {
if (d > n / 2) {
d -= n;
}
if (d < -n / 2) {
d += n;
}
}
return d;
}
// 마디 i 의 보간 위치 (칸 좌표). 지난 틱의 자리에서 이번 틱의 자리로 p(0~1) 만큼
function segPos(g, i, p) {
const b = g.b;
const cur = g.body[i];
const pv = i < g.prev.length ? g.prev[i] : cur;
const cx = cur % b.cols;
const cy = Math.floor(cur / b.cols);
const px = pv % b.cols;
const py = Math.floor(pv / b.cols);
return [px + wrapDelta(px, cx, b.cols, g.wrap) * p, py + wrapDelta(py, cy, b.rows, g.wrap) * p];
}
function drawTiles(ctx, b) {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
for (let y = 0; y < b.rows; y += 1) {
for (let x = 0; x < b.cols; x += 1) {
ctx.fillStyle = rgba((x + y) % 2 === 0 ? TILE_A : TILE_B, 1);
ctx.fillRect(b.ox + x * b.cell, b.oy + y * b.cell, b.cell + 0.5, b.cell + 0.5);
}
}
}
function drawFood(ctx, b, food, t) {
if (food < 0) {
return;
}
const x = cellX(b, food);
const y = cellY(b, food);
const pulse = 0.5 + 0.5 * Math.sin(t * 0.12);
glow(ctx, x, y, b.cell * (1.3 + 0.35 * pulse), PINK, 0.55);
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.arc(x, y, b.cell * 0.3, 0, PI2);
ctx.fill();
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.arc(x - b.cell * 0.08, y - b.cell * 0.09, b.cell * 0.09, 0, PI2);
ctx.fill();
}
function bodyColor(i, len) {
return mix(MINT, SKY, len <= 1 ? 0 : i / (len - 1));
}
// 칸 중심 (x,y 칸 좌표, 소수 허용)에 마디 하나
function drawSeg(ctx, b, gx, gy, c, shrink) {
const s = b.cell * (1 - shrink);
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
ctx.fillStyle = rgba(c, 1);
rrPath(ctx, cx - s / 2, cy - s / 2, s, s, s * 0.32);
ctx.fill();
}
function drawEyes(ctx, b, gx, gy, dirx, diry) {
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
const px = -diry;
const py = dirx;
const r = b.cell * 0.09;
ctx.fillStyle = rgba(BG, 1);
for (let s = -1; s <= 1; s += 2) {
ctx.beginPath();
ctx.arc(cx + dirx * b.cell * 0.16 + px * s * b.cell * 0.18, cy + diry * b.cell * 0.16 + py * s * b.cell * 0.18, r, 0, PI2);
ctx.fill();
}
}
function headDir(g) {
if (g.body.length < 2) {
return [1, 0];
}
const b = g.b;
const dx = wrapDelta(g.body[1] % b.cols, g.body[0] % b.cols, b.cols, g.wrap);
const dy = wrapDelta(Math.floor(g.body[1] / b.cols), Math.floor(g.body[0] / b.cols), b.rows, g.wrap);
return [clamp(dx, -1, 1), clamp(dy, -1, 1)];
}
// 보간 없이 칸 그대로 그리는 기본 뱀
function drawSnakeCells(ctx, g, dim) {
const len = g.body.length;
for (let i = len - 1; i >= 0; i -= 1) {
const c = g.body[i];
const col = mix(bodyColor(i, len), BG, dim);
drawSeg(ctx, g.b, c % g.b.cols, Math.floor(c / g.b.cols), col, 0.08);
}
const hd = headDir(g);
drawEyes(ctx, g.b, g.body[0] % g.b.cols, Math.floor(g.body[0] / g.b.cols), hd[0], hd[1]);
}
function start(root, canvas, statusEl, btnEl, make) {
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: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = 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);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
// 움직임 줄이기에서는 루프가 없으니 입력 직후 한 틱쯤 앞으로 보내 바로 그린다
function refresh() {
if (reduce) {
advance(FIXED_DT * 8);
show();
}
}
function toLocal(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;
}
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');
advance(1400);
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
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, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
// 이 장면이 쓰는 키만 막는다. 쓰지 않는 키(Tab 등)는 그대로 둔다
if (scene.key(k)) {
e.preventDefault();
refresh();
}
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const TICK_MS = 70;
const GRID_N = 16;
const HEAT_GAIN = 1;
const HEAT_DECAY = 0.988;
const HEAT_CAP = 1.4;
const PALETTE_STEPS = 7;
const WARM_TICKS = 90;
const TXT_STEPPED = '계단 팔레트';
const TXT_SMOOTHPAL = '연속 팔레트';
const TXT_LEN = '길이';
const TXT_DEAD = '갇혀서 끝 — 다시 시작';
const RAMP = [BG, [110, 88, 190], LILAC, PINK, PEACH, CREAM];
// 열 값 0~1 을 색으로. steps 가 0 이면 연속, 아니면 그 단계 수로 끊는다
function heatColor(h, steps) {
let v = clamp(h, 0, 1);
if (steps > 0) {
v = Math.floor(v * steps) / steps;
}
const f = v * (RAMP.length - 1);
const i = Math.min(RAMP.length - 2, Math.floor(f));
return mix(RAMP[i], RAMP[i + 1], f - i);
}
function makeScene(inp, reduce) {
const b = makeBoard(GRID_N);
const g = newGame(b, false, 455505);
const heat = new Float32Array(b.n);
let acc = 0;
let t = 0;
let stepped = true;
let hold = 0;
function logic() {
if (hold > 0) {
hold -= 1;
if (hold === 0) {
resetGame(g);
}
return;
}
const p = planMove(g, true);
if (p.next < 0) {
g.dead = true;
hold = 8;
return;
}
moveSnake(g, p.next);
heat[g.body[0]] = Math.min(HEAT_CAP, heat[g.body[0]] + HEAT_GAIN);
}
function cool() {
for (let i = 0; i < b.n; i += 1) {
heat[i] *= HEAT_DECAY;
}
}
const scene = {
step() {
t += 1;
acc += FIXED_DT;
while (acc >= TICK_MS) {
acc -= TICK_MS;
logic();
}
cool();
},
draw(ctx) {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
const steps = stepped ? PALETTE_STEPS : 0;
for (let i = 0; i < b.n; i += 1) {
const c = heatColor(heat[i], steps);
ctx.fillStyle = rgba(heat[i] < 0.02 ? ((i % b.cols + Math.floor(i / b.cols)) % 2 === 0 ? TILE_A : TILE_B) : c, 1);
ctx.fillRect(b.ox + (i % b.cols) * b.cell, b.oy + Math.floor(i / b.cols) * b.cell, b.cell + 0.5, b.cell + 0.5);
}
drawFood(ctx, b, g.food, t);
// 몸통은 윤곽만 그려 열지도를 가리지 않는다
const len = g.body.length;
ctx.lineWidth = 2;
for (let i = len - 1; i >= 0; i -= 1) {
const c = g.body[i];
const s = b.cell - 5;
ctx.strokeStyle = rgba(i === 0 ? CREAM : MINT, g.dead ? 0.4 : 0.95);
rrPath(ctx, b.ox + (c % b.cols) * b.cell + 2.5, b.oy + Math.floor(c / b.cols) * b.cell + 2.5, s, s, 4);
ctx.stroke();
}
// 범례 — 팔레트 단계
const n = stepped ? PALETTE_STEPS : 24;
for (let k = 0; k < n; k += 1) {
ctx.fillStyle = rgba(heatColor((k + 0.5) / n, stepped ? PALETTE_STEPS : 0), 1);
ctx.fillRect(W - 92 + k * (80 / n), 10, 80 / n + 0.5, 8);
}
},
label() {
if (g.dead) {
return TXT_DEAD;
}
return (stepped ? TXT_STEPPED : TXT_SMOOTHPAL) + ' · ' + TXT_LEN + ' ' + g.body.length;
},
tap() {
stepped = !stepped;
},
key(k) {
return false;
}
};
for (let i = 0; i < WARM_TICKS; i += 1) {
logic();
for (let j = 0; j < Math.round(TICK_MS / FIXED_DT); j += 1) {
cool();
}
}
if (reduce) {
for (let i = 0; i < 4; i += 1) {
scene.step();
}
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./SkHeatTrailDecay.module.scss";
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 = '다음 장면';
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
key(k: string): boolean;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 팔레트 — 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG: RGB = [29, 26, 44];
const TILE_A: RGB = [37, 33, 58];
const TILE_B: RGB = [42, 38, 66];
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 glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: RGB, a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
// 둥근 사각형 경로 (roundRect 가 없는 브라우저에서도 같은 모양)
function rrPath(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.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// ---- 판과 뱀 ----
interface Board { cols: number; rows: number; cell: number; ox: number; oy: number; n: number; }
function makeBoard(rows: number): Board {
const cell = H / rows;
const cols = Math.floor(W / cell + 1e-6);
return { cols: cols, rows: rows, cell: cell, ox: (W - cols * cell) / 2, oy: 0, n: cols * rows };
}
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
// 칸 번호 y*cols+x. 판 밖이면 -1 (wrap 이면 반대편으로 이어진다)
function cellAt(b: Board, x: number, y: number, wrap: boolean): number {
let xx = x;
let yy = y;
if (wrap) {
xx = (x % b.cols + b.cols) % b.cols;
yy = (y % b.rows + b.rows) % b.rows;
} else if (x < 0 || y < 0 || x >= b.cols || y >= b.rows) {
return -1;
}
return yy * b.cols + xx;
}
function neighborCell(b: Board, i: number, d: number, wrap: boolean): number {
return cellAt(b, (i % b.cols) + DX[d], Math.floor(i / b.cols) + DY[d], wrap);
}
// 목표 칸에서 너비 우선으로 퍼지는 거리장. blocked 칸과 닿지 못하는 칸은 -1
function distField(b: Board, target: number, blocked: Uint8Array, wrap: boolean): Int16Array {
const dist = new Int16Array(b.n).fill(-1);
const queue = new Int16Array(b.n);
let qh = 0;
let qt = 0;
dist[target] = 0;
queue[qt] = target;
qt += 1;
while (qh < qt) {
const c = queue[qh];
qh += 1;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb >= 0 && dist[nb] < 0 && blocked[nb] === 0) {
dist[nb] = dist[c] + 1;
queue[qt] = nb;
qt += 1;
}
}
}
return dist;
}
// 거리장에서 값이 가장 낮은 이웃 (없으면 -1)
function lowestNeighbor(b: Board, dist: Int16Array, from: number, wrap: boolean): number {
let best = -1;
let bv = 32000;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, from, d, wrap);
if (nb >= 0 && dist[nb] >= 0 && dist[nb] < bv) {
bv = dist[nb];
best = nb;
}
}
return best;
}
function pathFrom(b: Board, dist: Int16Array, head: number, wrap: boolean): number[] {
const path: number[] = [];
let cur = head;
for (let k = 0; k < b.n; k += 1) {
const nx = lowestNeighbor(b, dist, cur, wrap);
if (nx < 0 || (dist[nx] >= dist[cur] && cur !== head)) {
return [];
}
path.push(nx);
if (dist[nx] === 0) {
return path;
}
cur = nx;
}
return [];
}
interface Game {
b: Board;
wrap: boolean;
body: number[];
prev: number[];
food: number;
rnd: () => number;
eaten: number;
dead: boolean;
resets: number;
}
function placeFood(g: Game): void {
const used = new Uint8Array(g.b.n);
for (let i = 0; i < g.body.length; i += 1) {
used[g.body[i]] = 1;
}
const free: number[] = [];
for (let i = 0; i < g.b.n; i += 1) {
if (used[i] === 0) {
free.push(i);
}
}
g.food = free.length > 0 ? free[Math.floor(g.rnd() * free.length)] : -1;
}
function resetGame(g: Game): void {
const b = g.b;
const hx = Math.floor(b.cols / 2);
const hy = Math.floor(b.rows / 2);
g.body = [hy * b.cols + hx, hy * b.cols + hx - 1, hy * b.cols + hx - 2];
g.prev = g.body.slice();
g.dead = false;
g.resets += 1;
placeFood(g);
}
function newGame(b: Board, wrap: boolean, seed: number): Game {
const g: Game = { b: b, wrap: wrap, body: [], prev: [], food: -1, rnd: lcg(seed), eaten: 0, dead: false, resets: -1 };
resetGame(g);
return g;
}
// 머리를 next 칸으로 옮긴다. 먹으면 true, 몸에 부딪히면 dead
function moveSnake(g: Game, next: number): boolean {
g.prev = g.body.slice();
const ate = next === g.food;
const tail = g.body[g.body.length - 1];
for (let i = 0; i < g.body.length; i += 1) {
if (g.body[i] === next && !(i === g.body.length - 1 && !ate && next === tail)) {
g.dead = true;
return false;
}
}
g.body.unshift(next);
if (ate) {
g.eaten += 1;
placeFood(g);
} else {
g.body.pop();
}
return ate;
}
// 경로를 끝까지 따라가 먹은 뒤의 가상 몸
function simulate(body: number[], path: number[]): number[] {
const v = body.slice();
for (let k = 0; k < path.length; k += 1) {
v.unshift(path[k]);
if (k < path.length - 1) {
v.pop();
}
}
return v;
}
// 가상 몸의 머리에서 번져 나가 꼬리에 닿는가. mark 를 주면 닿은 칸을 표시한다
function tailReachable(b: Board, v: number[], wrap: boolean, mark: Uint8Array | null): boolean {
const blocked = new Uint8Array(b.n);
for (let i = 1; i < v.length - 1; i += 1) {
blocked[v[i]] = 1;
}
const tail = v[v.length - 1];
const head = v[0];
const seen = new Uint8Array(b.n);
const stack: number[] = [head];
seen[head] = 1;
let hit = false;
while (stack.length > 0) {
const c = stack.pop() as number;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb < 0 || seen[nb] === 1) {
continue;
}
if (nb === tail) {
hit = true;
continue;
}
if (blocked[nb] === 1 || nb === head) {
continue;
}
seen[nb] = 1;
stack.push(nb);
}
}
if (mark) {
mark.set(seen);
}
return hit;
}
interface Plan { next: number; path: number[]; rejected: number[]; trap: Uint8Array; chase: boolean; }
// 한 수를 정한다: 먹이까지 최단 경로 → (안전 검사) → 막히면 꼬리를 쫓는다
function planMove(g: Game, safeOn: boolean): Plan {
const b = g.b;
const head = g.body[0];
const tail = g.body[g.body.length - 1];
const plan: Plan = { next: -1, path: [], rejected: [], trap: new Uint8Array(b.n), chase: false };
const blocked = new Uint8Array(b.n);
for (let i = 0; i < g.body.length - 1; i += 1) {
blocked[g.body[i]] = 1;
}
if (g.food >= 0) {
const dist = distField(b, g.food, blocked, g.wrap);
const path = pathFrom(b, dist, head, g.wrap);
plan.path = path;
if (path.length > 0) {
if (!safeOn) {
plan.next = path[0];
return plan;
}
const v = simulate(g.body, path);
if (tailReachable(b, v, g.wrap, plan.trap)) {
plan.next = path[0];
return plan;
}
plan.rejected = path;
}
}
plan.chase = true;
const dist2 = distField(b, tail, blocked, g.wrap);
const nx = lowestNeighbor(b, dist2, head, g.wrap);
if (nx >= 0) {
plan.next = nx;
return plan;
}
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, head, d, g.wrap);
if (nb >= 0 && blocked[nb] === 0 && nb !== head) {
plan.next = nb;
return plan;
}
}
return plan;
}
// 칸 중심의 화면 좌표
function cellX(b: Board, i: number): number { return b.ox + ((i % b.cols) + 0.5) * b.cell; }
function cellY(b: Board, i: number): number { return b.oy + (Math.floor(i / b.cols) + 0.5) * b.cell; }
function wrapDelta(a: number, c: number, n: number, wrap: boolean): number {
let d = c - a;
if (wrap) {
if (d > n / 2) { d -= n; }
if (d < -n / 2) { d += n; }
}
return d;
}
// 마디 i 의 보간 위치 (칸 좌표). 지난 틱의 자리에서 이번 틱의 자리로 p(0~1) 만큼
function segPos(g: Game, i: number, p: number): [number, number] {
const b = g.b;
const cur = g.body[i];
const pv = i < g.prev.length ? g.prev[i] : cur;
const cx = cur % b.cols;
const cy = Math.floor(cur / b.cols);
const px = pv % b.cols;
const py = Math.floor(pv / b.cols);
return [px + wrapDelta(px, cx, b.cols, g.wrap) * p, py + wrapDelta(py, cy, b.rows, g.wrap) * p];
}
function drawTiles(ctx: CanvasRenderingContext2D, b: Board): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
for (let y = 0; y < b.rows; y += 1) {
for (let x = 0; x < b.cols; x += 1) {
ctx.fillStyle = rgba((x + y) % 2 === 0 ? TILE_A : TILE_B, 1);
ctx.fillRect(b.ox + x * b.cell, b.oy + y * b.cell, b.cell + 0.5, b.cell + 0.5);
}
}
}
function drawFood(ctx: CanvasRenderingContext2D, b: Board, food: number, t: number): void {
if (food < 0) {
return;
}
const x = cellX(b, food);
const y = cellY(b, food);
const pulse = 0.5 + 0.5 * Math.sin(t * 0.12);
glow(ctx, x, y, b.cell * (1.3 + 0.35 * pulse), PINK, 0.55);
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.arc(x, y, b.cell * 0.3, 0, PI2);
ctx.fill();
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.arc(x - b.cell * 0.08, y - b.cell * 0.09, b.cell * 0.09, 0, PI2);
ctx.fill();
}
function bodyColor(i: number, len: number): RGB {
return mix(MINT, SKY, len <= 1 ? 0 : i / (len - 1));
}
// 칸 중심 (x,y 칸 좌표, 소수 허용)에 마디 하나
function drawSeg(ctx: CanvasRenderingContext2D, b: Board, gx: number, gy: number, c: RGB, shrink: number): void {
const s = b.cell * (1 - shrink);
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
ctx.fillStyle = rgba(c, 1);
rrPath(ctx, cx - s / 2, cy - s / 2, s, s, s * 0.32);
ctx.fill();
}
function drawEyes(ctx: CanvasRenderingContext2D, b: Board, gx: number, gy: number, dirx: number, diry: number): void {
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
const px = -diry;
const py = dirx;
const r = b.cell * 0.09;
ctx.fillStyle = rgba(BG, 1);
for (let s = -1; s <= 1; s += 2) {
ctx.beginPath();
ctx.arc(cx + dirx * b.cell * 0.16 + px * s * b.cell * 0.18, cy + diry * b.cell * 0.16 + py * s * b.cell * 0.18, r, 0, PI2);
ctx.fill();
}
}
function headDir(g: Game): [number, number] {
if (g.body.length < 2) {
return [1, 0];
}
const b = g.b;
const dx = wrapDelta(g.body[1] % b.cols, g.body[0] % b.cols, b.cols, g.wrap);
const dy = wrapDelta(Math.floor(g.body[1] / b.cols), Math.floor(g.body[0] / b.cols), b.rows, g.wrap);
return [clamp(dx, -1, 1), clamp(dy, -1, 1)];
}
// 보간 없이 칸 그대로 그리는 기본 뱀
function drawSnakeCells(ctx: CanvasRenderingContext2D, g: Game, dim: number): void {
const len = g.body.length;
for (let i = len - 1; i >= 0; i -= 1) {
const c = g.body[i];
const col = mix(bodyColor(i, len), BG, dim);
drawSeg(ctx, g.b, c % g.b.cols, Math.floor(c / g.b.cols), col, 0.08);
}
const hd = headDir(g);
drawEyes(ctx, g.b, g.body[0] % g.b.cols, Math.floor(g.body[0] / g.b.cols), hd[0], hd[1]);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement, make: Maker): () => void {
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: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = 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);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
// 움직임 줄이기에서는 루프가 없으니 입력 직후 한 틱쯤 앞으로 보내 바로 그린다
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 8);
show();
}
}
function toLocal(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;
}
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');
advance(1400);
show();
}
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
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, 'pointermove', function (e: Event): void {
toLocal(e as PointerEvent);
refresh();
});
listen(canvas, 'pointerdown', function (e: Event): void {
toLocal(e as PointerEvent);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
// 이 장면이 쓰는 키만 막는다. 쓰지 않는 키(Tab 등)는 그대로 둔다
if (scene.key(k)) {
e.preventDefault();
refresh();
}
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const TICK_MS = 70;
const GRID_N = 16;
const HEAT_GAIN = 1;
const HEAT_DECAY = 0.988;
const HEAT_CAP = 1.4;
const PALETTE_STEPS = 7;
const WARM_TICKS = 90;
const TXT_STEPPED = '계단 팔레트';
const TXT_SMOOTHPAL = '연속 팔레트';
const TXT_LEN = '길이';
const TXT_DEAD = '갇혀서 끝 — 다시 시작';
const RAMP: RGB[] = [BG, [110, 88, 190], LILAC, PINK, PEACH, CREAM];
// 열 값 0~1 을 색으로. steps 가 0 이면 연속, 아니면 그 단계 수로 끊는다
function heatColor(h: number, steps: number): RGB {
let v = clamp(h, 0, 1);
if (steps > 0) {
v = Math.floor(v * steps) / steps;
}
const f = v * (RAMP.length - 1);
const i = Math.min(RAMP.length - 2, Math.floor(f));
return mix(RAMP[i], RAMP[i + 1], f - i);
}
function makeScene(inp: Input, reduce: boolean): Scene {
const b = makeBoard(GRID_N);
const g = newGame(b, false, 455505);
const heat = new Float32Array(b.n);
let acc = 0;
let t = 0;
let stepped = true;
let hold = 0;
function logic(): void {
if (hold > 0) {
hold -= 1;
if (hold === 0) {
resetGame(g);
}
return;
}
const p = planMove(g, true);
if (p.next < 0) {
g.dead = true;
hold = 8;
return;
}
moveSnake(g, p.next);
heat[g.body[0]] = Math.min(HEAT_CAP, heat[g.body[0]] + HEAT_GAIN);
}
function cool(): void {
for (let i = 0; i < b.n; i += 1) {
heat[i] *= HEAT_DECAY;
}
}
const scene: Scene = {
step(): void {
t += 1;
acc += FIXED_DT;
while (acc >= TICK_MS) {
acc -= TICK_MS;
logic();
}
cool();
},
draw(ctx: CanvasRenderingContext2D): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
const steps = stepped ? PALETTE_STEPS : 0;
for (let i = 0; i < b.n; i += 1) {
const c = heatColor(heat[i], steps);
ctx.fillStyle = rgba(heat[i] < 0.02 ? ((i % b.cols + Math.floor(i / b.cols)) % 2 === 0 ? TILE_A : TILE_B) : c, 1);
ctx.fillRect(b.ox + (i % b.cols) * b.cell, b.oy + Math.floor(i / b.cols) * b.cell, b.cell + 0.5, b.cell + 0.5);
}
drawFood(ctx, b, g.food, t);
// 몸통은 윤곽만 그려 열지도를 가리지 않는다
const len = g.body.length;
ctx.lineWidth = 2;
for (let i = len - 1; i >= 0; i -= 1) {
const c = g.body[i];
const s = b.cell - 5;
ctx.strokeStyle = rgba(i === 0 ? CREAM : MINT, g.dead ? 0.4 : 0.95);
rrPath(ctx, b.ox + (c % b.cols) * b.cell + 2.5, b.oy + Math.floor(c / b.cols) * b.cell + 2.5, s, s, 4);
ctx.stroke();
}
// 범례 — 팔레트 단계
const n = stepped ? PALETTE_STEPS : 24;
for (let k = 0; k < n; k += 1) {
ctx.fillStyle = rgba(heatColor((k + 0.5) / n, stepped ? PALETTE_STEPS : 0), 1);
ctx.fillRect(W - 92 + k * (80 / n), 10, 80 / n + 0.5, 8);
}
},
label(): string {
if (g.dead) {
return TXT_DEAD;
}
return (stepped ? TXT_STEPPED : TXT_SMOOTHPAL) + ' · ' + TXT_LEN + ' ' + g.body.length;
},
tap(): void {
stepped = !stepped;
},
key(k: string): boolean {
return false;
}
};
for (let i = 0; i < WARM_TICKS; i += 1) {
logic();
for (let j = 0; j < Math.round(TICK_MS / FIXED_DT); j += 1) {
cool();
}
}
if (reduce) {
for (let i = 0; i < 4; i += 1) {
scene.step();
}
}
return scene;
}
export default function SkHeatTrailDecay() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="지나간 칸에 열이 남았다가 식으며 이동 경로를 그리는 뱀" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">계단 팔레트</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>지나간 칸에 열이 남았다가 식으며 이동 경로를 그리는 뱀</p>
</div>
</main>
);
}05 · 지나간 칸에 열이 남았다가 식으며 이동 경로를 그리는 뱀 — 측정 누적면적 22.775% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function heatColor(h, steps) {
let v = clamp(h, 0, 1);
if (steps > 0) {
v = Math.floor(v * steps) / steps;
}
const f = v * (RAMP.length - 1);
const i = Math.min(RAMP.length - 2, Math.floor(f));
return mix(RAMP[i], RAMP[i + 1], f - i);
}
06벽을 지나면 반대편에서 나오고 경계에서 몸이 두 조각으로 그려지는 뱀
이웃 칸과 거리는 나머지 연산으로 계산해 판에 벽이 없어지고, 마디 사이 이동은 wrapDelta 가 짧은 쪽을 골라 판 끝에서 반대편으로 튀지 않는다. 그림은 마디를 3×3 곳에 복제해 판 사각형으로 잘라 이음매를 잇고, 탭을 누르면 복제가 꺼져 몸이 반쪽만 보인다. 렌더 값은 누적면적 20.9%, 평균 강도 95.3, 움직인 프레임 23/23 이다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="벽을 지나면 반대편에서 나오고 경계에서 몸이 두 조각으로 그려지는 뱀"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">경계 복제 켜짐</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">벽을 지나면 반대편에서 나오고 경계에서 몸이 두 조각으로 그려지는 뱀</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
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;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__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;
}
.sc__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;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
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: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { 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);
}
.sc {
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;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__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;
}
.sc__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;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
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 BG = [29, 26, 44];
const TILE_A = [37, 33, 58];
const TILE_B = [42, 38, 66];
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 glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
// 둥근 사각형 경로 (roundRect 가 없는 브라우저에서도 같은 모양)
function rrPath(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
function makeBoard(rows) {
const cell = H / rows;
const cols = Math.floor(W / cell + 1e-6);
return { cols: cols, rows: rows, cell: cell, ox: (W - cols * cell) / 2, oy: 0, n: cols * rows };
}
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
// 칸 번호 y*cols+x. 판 밖이면 -1 (wrap 이면 반대편으로 이어진다)
function cellAt(b, x, y, wrap) {
let xx = x;
let yy = y;
if (wrap) {
xx = (x % b.cols + b.cols) % b.cols;
yy = (y % b.rows + b.rows) % b.rows;
}
else if (x < 0 || y < 0 || x >= b.cols || y >= b.rows) {
return -1;
}
return yy * b.cols + xx;
}
function neighborCell(b, i, d, wrap) {
return cellAt(b, (i % b.cols) + DX[d], Math.floor(i / b.cols) + DY[d], wrap);
}
// 목표 칸에서 너비 우선으로 퍼지는 거리장. blocked 칸과 닿지 못하는 칸은 -1
function distField(b, target, blocked, wrap) {
const dist = new Int16Array(b.n).fill(-1);
const queue = new Int16Array(b.n);
let qh = 0;
let qt = 0;
dist[target] = 0;
queue[qt] = target;
qt += 1;
while (qh < qt) {
const c = queue[qh];
qh += 1;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb >= 0 && dist[nb] < 0 && blocked[nb] === 0) {
dist[nb] = dist[c] + 1;
queue[qt] = nb;
qt += 1;
}
}
}
return dist;
}
// 거리장에서 값이 가장 낮은 이웃 (없으면 -1)
function lowestNeighbor(b, dist, from, wrap) {
let best = -1;
let bv = 32000;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, from, d, wrap);
if (nb >= 0 && dist[nb] >= 0 && dist[nb] < bv) {
bv = dist[nb];
best = nb;
}
}
return best;
}
function pathFrom(b, dist, head, wrap) {
const path = [];
let cur = head;
for (let k = 0; k < b.n; k += 1) {
const nx = lowestNeighbor(b, dist, cur, wrap);
if (nx < 0 || (dist[nx] >= dist[cur] && cur !== head)) {
return [];
}
path.push(nx);
if (dist[nx] === 0) {
return path;
}
cur = nx;
}
return [];
}
function placeFood(g) {
const used = new Uint8Array(g.b.n);
for (let i = 0; i < g.body.length; i += 1) {
used[g.body[i]] = 1;
}
const free = [];
for (let i = 0; i < g.b.n; i += 1) {
if (used[i] === 0) {
free.push(i);
}
}
g.food = free.length > 0 ? free[Math.floor(g.rnd() * free.length)] : -1;
}
function resetGame(g) {
const b = g.b;
const hx = Math.floor(b.cols / 2);
const hy = Math.floor(b.rows / 2);
g.body = [hy * b.cols + hx, hy * b.cols + hx - 1, hy * b.cols + hx - 2];
g.prev = g.body.slice();
g.dead = false;
g.resets += 1;
placeFood(g);
}
function newGame(b, wrap, seed) {
const g = { b: b, wrap: wrap, body: [], prev: [], food: -1, rnd: lcg(seed), eaten: 0, dead: false, resets: -1 };
resetGame(g);
return g;
}
// 머리를 next 칸으로 옮긴다. 먹으면 true, 몸에 부딪히면 dead
function moveSnake(g, next) {
g.prev = g.body.slice();
const ate = next === g.food;
const tail = g.body[g.body.length - 1];
for (let i = 0; i < g.body.length; i += 1) {
if (g.body[i] === next && !(i === g.body.length - 1 && !ate && next === tail)) {
g.dead = true;
return false;
}
}
g.body.unshift(next);
if (ate) {
g.eaten += 1;
placeFood(g);
}
else {
g.body.pop();
}
return ate;
}
// 경로를 끝까지 따라가 먹은 뒤의 가상 몸
function simulate(body, path) {
const v = body.slice();
for (let k = 0; k < path.length; k += 1) {
v.unshift(path[k]);
if (k < path.length - 1) {
v.pop();
}
}
return v;
}
// 가상 몸의 머리에서 번져 나가 꼬리에 닿는가. mark 를 주면 닿은 칸을 표시한다
function tailReachable(b, v, wrap, mark) {
const blocked = new Uint8Array(b.n);
for (let i = 1; i < v.length - 1; i += 1) {
blocked[v[i]] = 1;
}
const tail = v[v.length - 1];
const head = v[0];
const seen = new Uint8Array(b.n);
const stack = [head];
seen[head] = 1;
let hit = false;
while (stack.length > 0) {
const c = stack.pop();
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb < 0 || seen[nb] === 1) {
continue;
}
if (nb === tail) {
hit = true;
continue;
}
if (blocked[nb] === 1 || nb === head) {
continue;
}
seen[nb] = 1;
stack.push(nb);
}
}
if (mark) {
mark.set(seen);
}
return hit;
}
// 한 수를 정한다: 먹이까지 최단 경로 → (안전 검사) → 막히면 꼬리를 쫓는다
function planMove(g, safeOn) {
const b = g.b;
const head = g.body[0];
const tail = g.body[g.body.length - 1];
const plan = { next: -1, path: [], rejected: [], trap: new Uint8Array(b.n), chase: false };
const blocked = new Uint8Array(b.n);
for (let i = 0; i < g.body.length - 1; i += 1) {
blocked[g.body[i]] = 1;
}
if (g.food >= 0) {
const dist = distField(b, g.food, blocked, g.wrap);
const path = pathFrom(b, dist, head, g.wrap);
plan.path = path;
if (path.length > 0) {
if (!safeOn) {
plan.next = path[0];
return plan;
}
const v = simulate(g.body, path);
if (tailReachable(b, v, g.wrap, plan.trap)) {
plan.next = path[0];
return plan;
}
plan.rejected = path;
}
}
plan.chase = true;
const dist2 = distField(b, tail, blocked, g.wrap);
const nx = lowestNeighbor(b, dist2, head, g.wrap);
if (nx >= 0) {
plan.next = nx;
return plan;
}
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, head, d, g.wrap);
if (nb >= 0 && blocked[nb] === 0 && nb !== head) {
plan.next = nb;
return plan;
}
}
return plan;
}
// 칸 중심의 화면 좌표
function cellX(b, i) { return b.ox + ((i % b.cols) + 0.5) * b.cell; }
function cellY(b, i) { return b.oy + (Math.floor(i / b.cols) + 0.5) * b.cell; }
function wrapDelta(a, c, n, wrap) {
let d = c - a;
if (wrap) {
if (d > n / 2) {
d -= n;
}
if (d < -n / 2) {
d += n;
}
}
return d;
}
// 마디 i 의 보간 위치 (칸 좌표). 지난 틱의 자리에서 이번 틱의 자리로 p(0~1) 만큼
function segPos(g, i, p) {
const b = g.b;
const cur = g.body[i];
const pv = i < g.prev.length ? g.prev[i] : cur;
const cx = cur % b.cols;
const cy = Math.floor(cur / b.cols);
const px = pv % b.cols;
const py = Math.floor(pv / b.cols);
return [px + wrapDelta(px, cx, b.cols, g.wrap) * p, py + wrapDelta(py, cy, b.rows, g.wrap) * p];
}
function drawTiles(ctx, b) {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
for (let y = 0; y < b.rows; y += 1) {
for (let x = 0; x < b.cols; x += 1) {
ctx.fillStyle = rgba((x + y) % 2 === 0 ? TILE_A : TILE_B, 1);
ctx.fillRect(b.ox + x * b.cell, b.oy + y * b.cell, b.cell + 0.5, b.cell + 0.5);
}
}
}
function drawFood(ctx, b, food, t) {
if (food < 0) {
return;
}
const x = cellX(b, food);
const y = cellY(b, food);
const pulse = 0.5 + 0.5 * Math.sin(t * 0.12);
glow(ctx, x, y, b.cell * (1.3 + 0.35 * pulse), PINK, 0.55);
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.arc(x, y, b.cell * 0.3, 0, PI2);
ctx.fill();
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.arc(x - b.cell * 0.08, y - b.cell * 0.09, b.cell * 0.09, 0, PI2);
ctx.fill();
}
function bodyColor(i, len) {
return mix(MINT, SKY, len <= 1 ? 0 : i / (len - 1));
}
// 칸 중심 (x,y 칸 좌표, 소수 허용)에 마디 하나
function drawSeg(ctx, b, gx, gy, c, shrink) {
const s = b.cell * (1 - shrink);
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
ctx.fillStyle = rgba(c, 1);
rrPath(ctx, cx - s / 2, cy - s / 2, s, s, s * 0.32);
ctx.fill();
}
function drawEyes(ctx, b, gx, gy, dirx, diry) {
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
const px = -diry;
const py = dirx;
const r = b.cell * 0.09;
ctx.fillStyle = rgba(BG, 1);
for (let s = -1; s <= 1; s += 2) {
ctx.beginPath();
ctx.arc(cx + dirx * b.cell * 0.16 + px * s * b.cell * 0.18, cy + diry * b.cell * 0.16 + py * s * b.cell * 0.18, r, 0, PI2);
ctx.fill();
}
}
function headDir(g) {
if (g.body.length < 2) {
return [1, 0];
}
const b = g.b;
const dx = wrapDelta(g.body[1] % b.cols, g.body[0] % b.cols, b.cols, g.wrap);
const dy = wrapDelta(Math.floor(g.body[1] / b.cols), Math.floor(g.body[0] / b.cols), b.rows, g.wrap);
return [clamp(dx, -1, 1), clamp(dy, -1, 1)];
}
// 보간 없이 칸 그대로 그리는 기본 뱀
function drawSnakeCells(ctx, g, dim) {
const len = g.body.length;
for (let i = len - 1; i >= 0; i -= 1) {
const c = g.body[i];
const col = mix(bodyColor(i, len), BG, dim);
drawSeg(ctx, g.b, c % g.b.cols, Math.floor(c / g.b.cols), col, 0.08);
}
const hd = headDir(g);
drawEyes(ctx, g.b, g.body[0] % g.b.cols, Math.floor(g.body[0] / g.b.cols), hd[0], hd[1]);
}
function start(root, canvas, statusEl, btnEl, make) {
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: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = 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);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
// 움직임 줄이기에서는 루프가 없으니 입력 직후 한 틱쯤 앞으로 보내 바로 그린다
function refresh() {
if (reduce) {
advance(FIXED_DT * 8);
show();
}
}
function toLocal(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;
}
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');
advance(1400);
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
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, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
// 이 장면이 쓰는 키만 막는다. 쓰지 않는 키(Tab 등)는 그대로 둔다
if (scene.key(k)) {
e.preventDefault();
refresh();
}
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const TICK_MS = 110;
const GRID_N = 12;
const WRAP_MODE = true;
const SEAM_GHOSTS = true;
const WARM_TICKS = 40;
const TXT_GHOST_ON = '경계 복제 켜짐';
const TXT_GHOST_OFF = '경계 복제 꺼짐';
const TXT_LEN = '길이';
const TXT_DEAD = '갇혀서 끝 — 다시 시작';
function makeScene(inp, reduce) {
const b = makeBoard(GRID_N);
const g = newGame(b, WRAP_MODE, 455606);
const tw = b.cols * b.cell;
const th = b.rows * b.cell;
let acc = 0;
let t = 0;
let ghosts = SEAM_GHOSTS;
let hold = 0;
let route = [];
function logic() {
if (hold > 0) {
hold -= 1;
if (hold === 0) {
resetGame(g);
route = [];
}
return;
}
const p = planMove(g, true);
route = p.path;
if (p.next < 0) {
g.dead = true;
hold = 8;
return;
}
moveSnake(g, p.next);
}
// 판 좌표 (칸 단위, 소수 허용) 에서 판 안쪽 px 로
function toX(gx) {
return ((gx + 0.5) * b.cell % tw + tw) % tw;
}
function toY(gy) {
return ((gy + 0.5) * b.cell % th + th) % th;
}
const scene = {
step() {
t += 1;
acc += FIXED_DT;
while (acc >= TICK_MS) {
acc -= TICK_MS;
logic();
}
},
draw(ctx) {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
ctx.save();
ctx.translate(b.ox, b.oy);
ctx.beginPath();
ctx.rect(0, 0, tw, th);
ctx.clip();
for (let y = 0; y < b.rows; y += 1) {
for (let x = 0; x < b.cols; x += 1) {
ctx.fillStyle = rgba((x + y) % 2 === 0 ? TILE_A : TILE_B, 1);
ctx.fillRect(x * b.cell, y * b.cell, b.cell + 0.5, b.cell + 0.5);
}
}
// 가장자리 띠 — 반대편 칸이 이어진다는 표시
ctx.fillStyle = rgba(LILAC, 0.16 + 0.08 * Math.sin(t * 0.1));
ctx.fillRect(0, 0, b.cell * 0.5, th);
ctx.fillRect(tw - b.cell * 0.5, 0, b.cell * 0.5, th);
ctx.fillRect(0, 0, tw, b.cell * 0.5);
ctx.fillRect(0, th - b.cell * 0.5, tw, b.cell * 0.5);
const p = clamp(acc / TICK_MS, 0, 1);
const s = smooth(p);
// 먹이 — 경계 너머 복제까지
const fgx = (g.food % b.cols) + 0.5;
const fgy = Math.floor(g.food / b.cols) + 0.5;
for (let oy = -1; oy <= 1; oy += 1) {
for (let ox = -1; ox <= 1; ox += 1) {
if ((ox !== 0 || oy !== 0) && !ghosts) {
continue;
}
const fx = fgx * b.cell + ox * tw;
const fy = fgy * b.cell + oy * th;
glow(ctx, fx, fy, b.cell * 1.4, PINK, 0.5);
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.arc(fx, fy, b.cell * 0.3, 0, PI2);
ctx.fill();
}
}
for (let k = 0; k < route.length; k += 1) {
ctx.fillStyle = rgba(SKY, 0.45);
ctx.beginPath();
ctx.arc(cellX(b, route[k]) - b.ox, cellY(b, route[k]) - b.oy, b.cell * 0.1, 0, PI2);
ctx.fill();
}
const len = g.body.length;
for (let i = len - 1; i >= 0; i -= 1) {
const sp = segPos(g, i, s);
const cx = toX(sp[0]);
const cy = toY(sp[1]);
const col = mix(bodyColor(i, len), BG, g.dead ? 0.55 : 0);
const sz = b.cell * 0.86;
for (let oy = -1; oy <= 1; oy += 1) {
for (let ox = -1; ox <= 1; ox += 1) {
if ((ox !== 0 || oy !== 0) && !ghosts) {
continue;
}
const x = cx + ox * tw;
const y = cy + oy * th;
if (x < -sz || x > tw + sz || y < -sz || y > th + sz) {
continue;
}
ctx.fillStyle = rgba(col, 1);
rrPath(ctx, x - sz / 2, y - sz / 2, sz, sz, sz * 0.32);
ctx.fill();
}
}
}
ctx.restore();
ctx.strokeStyle = rgba(LILAC, 0.7);
ctx.lineWidth = 2;
ctx.strokeRect(b.ox + 1, b.oy + 1, tw - 2, th - 2);
},
label() {
if (g.dead) {
return TXT_DEAD;
}
return (ghosts ? TXT_GHOST_ON : TXT_GHOST_OFF) + ' · ' + TXT_LEN + ' ' + g.body.length;
},
tap() {
ghosts = !ghosts;
},
key(k) {
return false;
}
};
for (let i = 0; i < WARM_TICKS; i += 1) {
logic();
}
if (reduce) {
for (let i = 0; i < 4; i += 1) {
scene.step();
}
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./SkWrapTorusGhost.module.scss";
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 = '다음 장면';
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
key(k: string): boolean;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 팔레트 — 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG: RGB = [29, 26, 44];
const TILE_A: RGB = [37, 33, 58];
const TILE_B: RGB = [42, 38, 66];
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 glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: RGB, a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
// 둥근 사각형 경로 (roundRect 가 없는 브라우저에서도 같은 모양)
function rrPath(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.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// ---- 판과 뱀 ----
interface Board { cols: number; rows: number; cell: number; ox: number; oy: number; n: number; }
function makeBoard(rows: number): Board {
const cell = H / rows;
const cols = Math.floor(W / cell + 1e-6);
return { cols: cols, rows: rows, cell: cell, ox: (W - cols * cell) / 2, oy: 0, n: cols * rows };
}
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
// 칸 번호 y*cols+x. 판 밖이면 -1 (wrap 이면 반대편으로 이어진다)
function cellAt(b: Board, x: number, y: number, wrap: boolean): number {
let xx = x;
let yy = y;
if (wrap) {
xx = (x % b.cols + b.cols) % b.cols;
yy = (y % b.rows + b.rows) % b.rows;
} else if (x < 0 || y < 0 || x >= b.cols || y >= b.rows) {
return -1;
}
return yy * b.cols + xx;
}
function neighborCell(b: Board, i: number, d: number, wrap: boolean): number {
return cellAt(b, (i % b.cols) + DX[d], Math.floor(i / b.cols) + DY[d], wrap);
}
// 목표 칸에서 너비 우선으로 퍼지는 거리장. blocked 칸과 닿지 못하는 칸은 -1
function distField(b: Board, target: number, blocked: Uint8Array, wrap: boolean): Int16Array {
const dist = new Int16Array(b.n).fill(-1);
const queue = new Int16Array(b.n);
let qh = 0;
let qt = 0;
dist[target] = 0;
queue[qt] = target;
qt += 1;
while (qh < qt) {
const c = queue[qh];
qh += 1;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb >= 0 && dist[nb] < 0 && blocked[nb] === 0) {
dist[nb] = dist[c] + 1;
queue[qt] = nb;
qt += 1;
}
}
}
return dist;
}
// 거리장에서 값이 가장 낮은 이웃 (없으면 -1)
function lowestNeighbor(b: Board, dist: Int16Array, from: number, wrap: boolean): number {
let best = -1;
let bv = 32000;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, from, d, wrap);
if (nb >= 0 && dist[nb] >= 0 && dist[nb] < bv) {
bv = dist[nb];
best = nb;
}
}
return best;
}
function pathFrom(b: Board, dist: Int16Array, head: number, wrap: boolean): number[] {
const path: number[] = [];
let cur = head;
for (let k = 0; k < b.n; k += 1) {
const nx = lowestNeighbor(b, dist, cur, wrap);
if (nx < 0 || (dist[nx] >= dist[cur] && cur !== head)) {
return [];
}
path.push(nx);
if (dist[nx] === 0) {
return path;
}
cur = nx;
}
return [];
}
interface Game {
b: Board;
wrap: boolean;
body: number[];
prev: number[];
food: number;
rnd: () => number;
eaten: number;
dead: boolean;
resets: number;
}
function placeFood(g: Game): void {
const used = new Uint8Array(g.b.n);
for (let i = 0; i < g.body.length; i += 1) {
used[g.body[i]] = 1;
}
const free: number[] = [];
for (let i = 0; i < g.b.n; i += 1) {
if (used[i] === 0) {
free.push(i);
}
}
g.food = free.length > 0 ? free[Math.floor(g.rnd() * free.length)] : -1;
}
function resetGame(g: Game): void {
const b = g.b;
const hx = Math.floor(b.cols / 2);
const hy = Math.floor(b.rows / 2);
g.body = [hy * b.cols + hx, hy * b.cols + hx - 1, hy * b.cols + hx - 2];
g.prev = g.body.slice();
g.dead = false;
g.resets += 1;
placeFood(g);
}
function newGame(b: Board, wrap: boolean, seed: number): Game {
const g: Game = { b: b, wrap: wrap, body: [], prev: [], food: -1, rnd: lcg(seed), eaten: 0, dead: false, resets: -1 };
resetGame(g);
return g;
}
// 머리를 next 칸으로 옮긴다. 먹으면 true, 몸에 부딪히면 dead
function moveSnake(g: Game, next: number): boolean {
g.prev = g.body.slice();
const ate = next === g.food;
const tail = g.body[g.body.length - 1];
for (let i = 0; i < g.body.length; i += 1) {
if (g.body[i] === next && !(i === g.body.length - 1 && !ate && next === tail)) {
g.dead = true;
return false;
}
}
g.body.unshift(next);
if (ate) {
g.eaten += 1;
placeFood(g);
} else {
g.body.pop();
}
return ate;
}
// 경로를 끝까지 따라가 먹은 뒤의 가상 몸
function simulate(body: number[], path: number[]): number[] {
const v = body.slice();
for (let k = 0; k < path.length; k += 1) {
v.unshift(path[k]);
if (k < path.length - 1) {
v.pop();
}
}
return v;
}
// 가상 몸의 머리에서 번져 나가 꼬리에 닿는가. mark 를 주면 닿은 칸을 표시한다
function tailReachable(b: Board, v: number[], wrap: boolean, mark: Uint8Array | null): boolean {
const blocked = new Uint8Array(b.n);
for (let i = 1; i < v.length - 1; i += 1) {
blocked[v[i]] = 1;
}
const tail = v[v.length - 1];
const head = v[0];
const seen = new Uint8Array(b.n);
const stack: number[] = [head];
seen[head] = 1;
let hit = false;
while (stack.length > 0) {
const c = stack.pop() as number;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb < 0 || seen[nb] === 1) {
continue;
}
if (nb === tail) {
hit = true;
continue;
}
if (blocked[nb] === 1 || nb === head) {
continue;
}
seen[nb] = 1;
stack.push(nb);
}
}
if (mark) {
mark.set(seen);
}
return hit;
}
interface Plan { next: number; path: number[]; rejected: number[]; trap: Uint8Array; chase: boolean; }
// 한 수를 정한다: 먹이까지 최단 경로 → (안전 검사) → 막히면 꼬리를 쫓는다
function planMove(g: Game, safeOn: boolean): Plan {
const b = g.b;
const head = g.body[0];
const tail = g.body[g.body.length - 1];
const plan: Plan = { next: -1, path: [], rejected: [], trap: new Uint8Array(b.n), chase: false };
const blocked = new Uint8Array(b.n);
for (let i = 0; i < g.body.length - 1; i += 1) {
blocked[g.body[i]] = 1;
}
if (g.food >= 0) {
const dist = distField(b, g.food, blocked, g.wrap);
const path = pathFrom(b, dist, head, g.wrap);
plan.path = path;
if (path.length > 0) {
if (!safeOn) {
plan.next = path[0];
return plan;
}
const v = simulate(g.body, path);
if (tailReachable(b, v, g.wrap, plan.trap)) {
plan.next = path[0];
return plan;
}
plan.rejected = path;
}
}
plan.chase = true;
const dist2 = distField(b, tail, blocked, g.wrap);
const nx = lowestNeighbor(b, dist2, head, g.wrap);
if (nx >= 0) {
plan.next = nx;
return plan;
}
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, head, d, g.wrap);
if (nb >= 0 && blocked[nb] === 0 && nb !== head) {
plan.next = nb;
return plan;
}
}
return plan;
}
// 칸 중심의 화면 좌표
function cellX(b: Board, i: number): number { return b.ox + ((i % b.cols) + 0.5) * b.cell; }
function cellY(b: Board, i: number): number { return b.oy + (Math.floor(i / b.cols) + 0.5) * b.cell; }
function wrapDelta(a: number, c: number, n: number, wrap: boolean): number {
let d = c - a;
if (wrap) {
if (d > n / 2) { d -= n; }
if (d < -n / 2) { d += n; }
}
return d;
}
// 마디 i 의 보간 위치 (칸 좌표). 지난 틱의 자리에서 이번 틱의 자리로 p(0~1) 만큼
function segPos(g: Game, i: number, p: number): [number, number] {
const b = g.b;
const cur = g.body[i];
const pv = i < g.prev.length ? g.prev[i] : cur;
const cx = cur % b.cols;
const cy = Math.floor(cur / b.cols);
const px = pv % b.cols;
const py = Math.floor(pv / b.cols);
return [px + wrapDelta(px, cx, b.cols, g.wrap) * p, py + wrapDelta(py, cy, b.rows, g.wrap) * p];
}
function drawTiles(ctx: CanvasRenderingContext2D, b: Board): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
for (let y = 0; y < b.rows; y += 1) {
for (let x = 0; x < b.cols; x += 1) {
ctx.fillStyle = rgba((x + y) % 2 === 0 ? TILE_A : TILE_B, 1);
ctx.fillRect(b.ox + x * b.cell, b.oy + y * b.cell, b.cell + 0.5, b.cell + 0.5);
}
}
}
function drawFood(ctx: CanvasRenderingContext2D, b: Board, food: number, t: number): void {
if (food < 0) {
return;
}
const x = cellX(b, food);
const y = cellY(b, food);
const pulse = 0.5 + 0.5 * Math.sin(t * 0.12);
glow(ctx, x, y, b.cell * (1.3 + 0.35 * pulse), PINK, 0.55);
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.arc(x, y, b.cell * 0.3, 0, PI2);
ctx.fill();
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.arc(x - b.cell * 0.08, y - b.cell * 0.09, b.cell * 0.09, 0, PI2);
ctx.fill();
}
function bodyColor(i: number, len: number): RGB {
return mix(MINT, SKY, len <= 1 ? 0 : i / (len - 1));
}
// 칸 중심 (x,y 칸 좌표, 소수 허용)에 마디 하나
function drawSeg(ctx: CanvasRenderingContext2D, b: Board, gx: number, gy: number, c: RGB, shrink: number): void {
const s = b.cell * (1 - shrink);
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
ctx.fillStyle = rgba(c, 1);
rrPath(ctx, cx - s / 2, cy - s / 2, s, s, s * 0.32);
ctx.fill();
}
function drawEyes(ctx: CanvasRenderingContext2D, b: Board, gx: number, gy: number, dirx: number, diry: number): void {
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
const px = -diry;
const py = dirx;
const r = b.cell * 0.09;
ctx.fillStyle = rgba(BG, 1);
for (let s = -1; s <= 1; s += 2) {
ctx.beginPath();
ctx.arc(cx + dirx * b.cell * 0.16 + px * s * b.cell * 0.18, cy + diry * b.cell * 0.16 + py * s * b.cell * 0.18, r, 0, PI2);
ctx.fill();
}
}
function headDir(g: Game): [number, number] {
if (g.body.length < 2) {
return [1, 0];
}
const b = g.b;
const dx = wrapDelta(g.body[1] % b.cols, g.body[0] % b.cols, b.cols, g.wrap);
const dy = wrapDelta(Math.floor(g.body[1] / b.cols), Math.floor(g.body[0] / b.cols), b.rows, g.wrap);
return [clamp(dx, -1, 1), clamp(dy, -1, 1)];
}
// 보간 없이 칸 그대로 그리는 기본 뱀
function drawSnakeCells(ctx: CanvasRenderingContext2D, g: Game, dim: number): void {
const len = g.body.length;
for (let i = len - 1; i >= 0; i -= 1) {
const c = g.body[i];
const col = mix(bodyColor(i, len), BG, dim);
drawSeg(ctx, g.b, c % g.b.cols, Math.floor(c / g.b.cols), col, 0.08);
}
const hd = headDir(g);
drawEyes(ctx, g.b, g.body[0] % g.b.cols, Math.floor(g.body[0] / g.b.cols), hd[0], hd[1]);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement, make: Maker): () => void {
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: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = 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);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
// 움직임 줄이기에서는 루프가 없으니 입력 직후 한 틱쯤 앞으로 보내 바로 그린다
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 8);
show();
}
}
function toLocal(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;
}
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');
advance(1400);
show();
}
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
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, 'pointermove', function (e: Event): void {
toLocal(e as PointerEvent);
refresh();
});
listen(canvas, 'pointerdown', function (e: Event): void {
toLocal(e as PointerEvent);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
// 이 장면이 쓰는 키만 막는다. 쓰지 않는 키(Tab 등)는 그대로 둔다
if (scene.key(k)) {
e.preventDefault();
refresh();
}
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const TICK_MS = 110;
const GRID_N = 12;
const WRAP_MODE = true;
const SEAM_GHOSTS = true;
const WARM_TICKS = 40;
const TXT_GHOST_ON = '경계 복제 켜짐';
const TXT_GHOST_OFF = '경계 복제 꺼짐';
const TXT_LEN = '길이';
const TXT_DEAD = '갇혀서 끝 — 다시 시작';
function makeScene(inp: Input, reduce: boolean): Scene {
const b = makeBoard(GRID_N);
const g = newGame(b, WRAP_MODE, 455606);
const tw = b.cols * b.cell;
const th = b.rows * b.cell;
let acc = 0;
let t = 0;
let ghosts = SEAM_GHOSTS;
let hold = 0;
let route: number[] = [];
function logic(): void {
if (hold > 0) {
hold -= 1;
if (hold === 0) {
resetGame(g);
route = [];
}
return;
}
const p = planMove(g, true);
route = p.path;
if (p.next < 0) {
g.dead = true;
hold = 8;
return;
}
moveSnake(g, p.next);
}
// 판 좌표 (칸 단위, 소수 허용) 에서 판 안쪽 px 로
function toX(gx: number): number {
return ((gx + 0.5) * b.cell % tw + tw) % tw;
}
function toY(gy: number): number {
return ((gy + 0.5) * b.cell % th + th) % th;
}
const scene: Scene = {
step(): void {
t += 1;
acc += FIXED_DT;
while (acc >= TICK_MS) {
acc -= TICK_MS;
logic();
}
},
draw(ctx: CanvasRenderingContext2D): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
ctx.save();
ctx.translate(b.ox, b.oy);
ctx.beginPath();
ctx.rect(0, 0, tw, th);
ctx.clip();
for (let y = 0; y < b.rows; y += 1) {
for (let x = 0; x < b.cols; x += 1) {
ctx.fillStyle = rgba((x + y) % 2 === 0 ? TILE_A : TILE_B, 1);
ctx.fillRect(x * b.cell, y * b.cell, b.cell + 0.5, b.cell + 0.5);
}
}
// 가장자리 띠 — 반대편 칸이 이어진다는 표시
ctx.fillStyle = rgba(LILAC, 0.16 + 0.08 * Math.sin(t * 0.1));
ctx.fillRect(0, 0, b.cell * 0.5, th);
ctx.fillRect(tw - b.cell * 0.5, 0, b.cell * 0.5, th);
ctx.fillRect(0, 0, tw, b.cell * 0.5);
ctx.fillRect(0, th - b.cell * 0.5, tw, b.cell * 0.5);
const p = clamp(acc / TICK_MS, 0, 1);
const s = smooth(p);
// 먹이 — 경계 너머 복제까지
const fgx = (g.food % b.cols) + 0.5;
const fgy = Math.floor(g.food / b.cols) + 0.5;
for (let oy = -1; oy <= 1; oy += 1) {
for (let ox = -1; ox <= 1; ox += 1) {
if ((ox !== 0 || oy !== 0) && !ghosts) {
continue;
}
const fx = fgx * b.cell + ox * tw;
const fy = fgy * b.cell + oy * th;
glow(ctx, fx, fy, b.cell * 1.4, PINK, 0.5);
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.arc(fx, fy, b.cell * 0.3, 0, PI2);
ctx.fill();
}
}
for (let k = 0; k < route.length; k += 1) {
ctx.fillStyle = rgba(SKY, 0.45);
ctx.beginPath();
ctx.arc(cellX(b, route[k]) - b.ox, cellY(b, route[k]) - b.oy, b.cell * 0.1, 0, PI2);
ctx.fill();
}
const len = g.body.length;
for (let i = len - 1; i >= 0; i -= 1) {
const sp = segPos(g, i, s);
const cx = toX(sp[0]);
const cy = toY(sp[1]);
const col = mix(bodyColor(i, len), BG, g.dead ? 0.55 : 0);
const sz = b.cell * 0.86;
for (let oy = -1; oy <= 1; oy += 1) {
for (let ox = -1; ox <= 1; ox += 1) {
if ((ox !== 0 || oy !== 0) && !ghosts) {
continue;
}
const x = cx + ox * tw;
const y = cy + oy * th;
if (x < -sz || x > tw + sz || y < -sz || y > th + sz) {
continue;
}
ctx.fillStyle = rgba(col, 1);
rrPath(ctx, x - sz / 2, y - sz / 2, sz, sz, sz * 0.32);
ctx.fill();
}
}
}
ctx.restore();
ctx.strokeStyle = rgba(LILAC, 0.7);
ctx.lineWidth = 2;
ctx.strokeRect(b.ox + 1, b.oy + 1, tw - 2, th - 2);
},
label(): string {
if (g.dead) {
return TXT_DEAD;
}
return (ghosts ? TXT_GHOST_ON : TXT_GHOST_OFF) + ' · ' + TXT_LEN + ' ' + g.body.length;
},
tap(): void {
ghosts = !ghosts;
},
key(k: string): boolean {
return false;
}
};
for (let i = 0; i < WARM_TICKS; i += 1) {
logic();
}
if (reduce) {
for (let i = 0; i < 4; i += 1) {
scene.step();
}
}
return scene;
}
export default function SkWrapTorusGhost() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="벽을 지나면 반대편에서 나오고 경계에서 몸이 두 조각으로 그려지는 뱀" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">경계 복제 켜짐</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>벽을 지나면 반대편에서 나오고 경계에서 몸이 두 조각으로 그려지는 뱀</p>
</div>
</main>
);
}06 · 벽을 지나면 반대편에서 나오고 경계에서 몸이 두 조각으로 그려지는 뱀 — 측정 누적면적 20.9% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function wrapDelta(a, c, n, wrap) {
let d = c - a;
if (wrap) {
if (d > n / 2) {
d -= n;
}
if (d < -n / 2) {
d += n;
}
}
return d;
}
07몸통을 따라 비늘 무늬와 밝기 물결이 흐르는 뱀 가죽
마디 번호 k 에 sin(k·SCALE_FREQ − phase) 파형을 걸고 hash2 노이즈를 NOISE_AMP 0.28 만큼 섞어 비늘 밝기를 정하며, phase 가 스텝마다 0.07 씩 늘어 물결이 몸을 따라 흐른다. 마디 하나는 비늘 4개이고, 배경의 원 360개도 같은 파형을 사선으로 받아 판 전체가 비늘 벽지가 된다. 누적면적 41.1%, 평균 강도 59.9, 움직인 프레임 23/23 이다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="몸통을 따라 비늘 무늬와 밝기 물결이 흐르는 뱀 가죽"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">비늘 물결 흐름</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">몸통을 따라 비늘 무늬와 밝기 물결이 흐르는 뱀 가죽</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
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;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__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;
}
.sc__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;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
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: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { 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);
}
.sc {
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;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__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;
}
.sc__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;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
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 BG = [29, 26, 44];
const TILE_A = [37, 33, 58];
const TILE_B = [42, 38, 66];
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 glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
// 둥근 사각형 경로 (roundRect 가 없는 브라우저에서도 같은 모양)
function rrPath(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
function makeBoard(rows) {
const cell = H / rows;
const cols = Math.floor(W / cell + 1e-6);
return { cols: cols, rows: rows, cell: cell, ox: (W - cols * cell) / 2, oy: 0, n: cols * rows };
}
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
// 칸 번호 y*cols+x. 판 밖이면 -1 (wrap 이면 반대편으로 이어진다)
function cellAt(b, x, y, wrap) {
let xx = x;
let yy = y;
if (wrap) {
xx = (x % b.cols + b.cols) % b.cols;
yy = (y % b.rows + b.rows) % b.rows;
}
else if (x < 0 || y < 0 || x >= b.cols || y >= b.rows) {
return -1;
}
return yy * b.cols + xx;
}
function neighborCell(b, i, d, wrap) {
return cellAt(b, (i % b.cols) + DX[d], Math.floor(i / b.cols) + DY[d], wrap);
}
// 목표 칸에서 너비 우선으로 퍼지는 거리장. blocked 칸과 닿지 못하는 칸은 -1
function distField(b, target, blocked, wrap) {
const dist = new Int16Array(b.n).fill(-1);
const queue = new Int16Array(b.n);
let qh = 0;
let qt = 0;
dist[target] = 0;
queue[qt] = target;
qt += 1;
while (qh < qt) {
const c = queue[qh];
qh += 1;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb >= 0 && dist[nb] < 0 && blocked[nb] === 0) {
dist[nb] = dist[c] + 1;
queue[qt] = nb;
qt += 1;
}
}
}
return dist;
}
// 거리장에서 값이 가장 낮은 이웃 (없으면 -1)
function lowestNeighbor(b, dist, from, wrap) {
let best = -1;
let bv = 32000;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, from, d, wrap);
if (nb >= 0 && dist[nb] >= 0 && dist[nb] < bv) {
bv = dist[nb];
best = nb;
}
}
return best;
}
function pathFrom(b, dist, head, wrap) {
const path = [];
let cur = head;
for (let k = 0; k < b.n; k += 1) {
const nx = lowestNeighbor(b, dist, cur, wrap);
if (nx < 0 || (dist[nx] >= dist[cur] && cur !== head)) {
return [];
}
path.push(nx);
if (dist[nx] === 0) {
return path;
}
cur = nx;
}
return [];
}
function placeFood(g) {
const used = new Uint8Array(g.b.n);
for (let i = 0; i < g.body.length; i += 1) {
used[g.body[i]] = 1;
}
const free = [];
for (let i = 0; i < g.b.n; i += 1) {
if (used[i] === 0) {
free.push(i);
}
}
g.food = free.length > 0 ? free[Math.floor(g.rnd() * free.length)] : -1;
}
function resetGame(g) {
const b = g.b;
const hx = Math.floor(b.cols / 2);
const hy = Math.floor(b.rows / 2);
g.body = [hy * b.cols + hx, hy * b.cols + hx - 1, hy * b.cols + hx - 2];
g.prev = g.body.slice();
g.dead = false;
g.resets += 1;
placeFood(g);
}
function newGame(b, wrap, seed) {
const g = { b: b, wrap: wrap, body: [], prev: [], food: -1, rnd: lcg(seed), eaten: 0, dead: false, resets: -1 };
resetGame(g);
return g;
}
// 머리를 next 칸으로 옮긴다. 먹으면 true, 몸에 부딪히면 dead
function moveSnake(g, next) {
g.prev = g.body.slice();
const ate = next === g.food;
const tail = g.body[g.body.length - 1];
for (let i = 0; i < g.body.length; i += 1) {
if (g.body[i] === next && !(i === g.body.length - 1 && !ate && next === tail)) {
g.dead = true;
return false;
}
}
g.body.unshift(next);
if (ate) {
g.eaten += 1;
placeFood(g);
}
else {
g.body.pop();
}
return ate;
}
// 경로를 끝까지 따라가 먹은 뒤의 가상 몸
function simulate(body, path) {
const v = body.slice();
for (let k = 0; k < path.length; k += 1) {
v.unshift(path[k]);
if (k < path.length - 1) {
v.pop();
}
}
return v;
}
// 가상 몸의 머리에서 번져 나가 꼬리에 닿는가. mark 를 주면 닿은 칸을 표시한다
function tailReachable(b, v, wrap, mark) {
const blocked = new Uint8Array(b.n);
for (let i = 1; i < v.length - 1; i += 1) {
blocked[v[i]] = 1;
}
const tail = v[v.length - 1];
const head = v[0];
const seen = new Uint8Array(b.n);
const stack = [head];
seen[head] = 1;
let hit = false;
while (stack.length > 0) {
const c = stack.pop();
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb < 0 || seen[nb] === 1) {
continue;
}
if (nb === tail) {
hit = true;
continue;
}
if (blocked[nb] === 1 || nb === head) {
continue;
}
seen[nb] = 1;
stack.push(nb);
}
}
if (mark) {
mark.set(seen);
}
return hit;
}
// 한 수를 정한다: 먹이까지 최단 경로 → (안전 검사) → 막히면 꼬리를 쫓는다
function planMove(g, safeOn) {
const b = g.b;
const head = g.body[0];
const tail = g.body[g.body.length - 1];
const plan = { next: -1, path: [], rejected: [], trap: new Uint8Array(b.n), chase: false };
const blocked = new Uint8Array(b.n);
for (let i = 0; i < g.body.length - 1; i += 1) {
blocked[g.body[i]] = 1;
}
if (g.food >= 0) {
const dist = distField(b, g.food, blocked, g.wrap);
const path = pathFrom(b, dist, head, g.wrap);
plan.path = path;
if (path.length > 0) {
if (!safeOn) {
plan.next = path[0];
return plan;
}
const v = simulate(g.body, path);
if (tailReachable(b, v, g.wrap, plan.trap)) {
plan.next = path[0];
return plan;
}
plan.rejected = path;
}
}
plan.chase = true;
const dist2 = distField(b, tail, blocked, g.wrap);
const nx = lowestNeighbor(b, dist2, head, g.wrap);
if (nx >= 0) {
plan.next = nx;
return plan;
}
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, head, d, g.wrap);
if (nb >= 0 && blocked[nb] === 0 && nb !== head) {
plan.next = nb;
return plan;
}
}
return plan;
}
// 칸 중심의 화면 좌표
function cellX(b, i) { return b.ox + ((i % b.cols) + 0.5) * b.cell; }
function cellY(b, i) { return b.oy + (Math.floor(i / b.cols) + 0.5) * b.cell; }
function wrapDelta(a, c, n, wrap) {
let d = c - a;
if (wrap) {
if (d > n / 2) {
d -= n;
}
if (d < -n / 2) {
d += n;
}
}
return d;
}
// 마디 i 의 보간 위치 (칸 좌표). 지난 틱의 자리에서 이번 틱의 자리로 p(0~1) 만큼
function segPos(g, i, p) {
const b = g.b;
const cur = g.body[i];
const pv = i < g.prev.length ? g.prev[i] : cur;
const cx = cur % b.cols;
const cy = Math.floor(cur / b.cols);
const px = pv % b.cols;
const py = Math.floor(pv / b.cols);
return [px + wrapDelta(px, cx, b.cols, g.wrap) * p, py + wrapDelta(py, cy, b.rows, g.wrap) * p];
}
function drawTiles(ctx, b) {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
for (let y = 0; y < b.rows; y += 1) {
for (let x = 0; x < b.cols; x += 1) {
ctx.fillStyle = rgba((x + y) % 2 === 0 ? TILE_A : TILE_B, 1);
ctx.fillRect(b.ox + x * b.cell, b.oy + y * b.cell, b.cell + 0.5, b.cell + 0.5);
}
}
}
function drawFood(ctx, b, food, t) {
if (food < 0) {
return;
}
const x = cellX(b, food);
const y = cellY(b, food);
const pulse = 0.5 + 0.5 * Math.sin(t * 0.12);
glow(ctx, x, y, b.cell * (1.3 + 0.35 * pulse), PINK, 0.55);
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.arc(x, y, b.cell * 0.3, 0, PI2);
ctx.fill();
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.arc(x - b.cell * 0.08, y - b.cell * 0.09, b.cell * 0.09, 0, PI2);
ctx.fill();
}
function bodyColor(i, len) {
return mix(MINT, SKY, len <= 1 ? 0 : i / (len - 1));
}
// 칸 중심 (x,y 칸 좌표, 소수 허용)에 마디 하나
function drawSeg(ctx, b, gx, gy, c, shrink) {
const s = b.cell * (1 - shrink);
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
ctx.fillStyle = rgba(c, 1);
rrPath(ctx, cx - s / 2, cy - s / 2, s, s, s * 0.32);
ctx.fill();
}
function drawEyes(ctx, b, gx, gy, dirx, diry) {
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
const px = -diry;
const py = dirx;
const r = b.cell * 0.09;
ctx.fillStyle = rgba(BG, 1);
for (let s = -1; s <= 1; s += 2) {
ctx.beginPath();
ctx.arc(cx + dirx * b.cell * 0.16 + px * s * b.cell * 0.18, cy + diry * b.cell * 0.16 + py * s * b.cell * 0.18, r, 0, PI2);
ctx.fill();
}
}
function headDir(g) {
if (g.body.length < 2) {
return [1, 0];
}
const b = g.b;
const dx = wrapDelta(g.body[1] % b.cols, g.body[0] % b.cols, b.cols, g.wrap);
const dy = wrapDelta(Math.floor(g.body[1] / b.cols), Math.floor(g.body[0] / b.cols), b.rows, g.wrap);
return [clamp(dx, -1, 1), clamp(dy, -1, 1)];
}
// 보간 없이 칸 그대로 그리는 기본 뱀
function drawSnakeCells(ctx, g, dim) {
const len = g.body.length;
for (let i = len - 1; i >= 0; i -= 1) {
const c = g.body[i];
const col = mix(bodyColor(i, len), BG, dim);
drawSeg(ctx, g.b, c % g.b.cols, Math.floor(c / g.b.cols), col, 0.08);
}
const hd = headDir(g);
drawEyes(ctx, g.b, g.body[0] % g.b.cols, Math.floor(g.body[0] / g.b.cols), hd[0], hd[1]);
}
function start(root, canvas, statusEl, btnEl, make) {
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: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = 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);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
// 움직임 줄이기에서는 루프가 없으니 입력 직후 한 틱쯤 앞으로 보내 바로 그린다
function refresh() {
if (reduce) {
advance(FIXED_DT * 8);
show();
}
}
function toLocal(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;
}
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');
advance(1400);
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
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, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
// 이 장면이 쓰는 키만 막는다. 쓰지 않는 키(Tab 등)는 그대로 둔다
if (scene.key(k)) {
e.preventDefault();
refresh();
}
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const TICK_MS = 95;
const GRID_N = 15;
const SCALE_FREQ = 0.62;
const WAVE_SPEED = 0.07;
const NOISE_AMP = 0.28;
const SKIN_MIX = 0.5;
const WARM_TICKS = 133;
const TXT_WAVE = '비늘 물결 흐름';
const TXT_STILL = '위상 정지';
const TXT_LEN = '길이';
const TXT_DEAD = '갇혀서 끝 — 다시 시작';
function makeScene(inp, reduce) {
const b = makeBoard(GRID_N);
const g = newGame(b, false, 455707);
let acc = 0;
let t = 0;
let phase = 0;
let flowing = true;
let hold = 0;
function logic() {
if (hold > 0) {
hold -= 1;
if (hold === 0) {
resetGame(g);
}
return;
}
const p = planMove(g, true);
if (p.next < 0) {
g.dead = true;
hold = 8;
return;
}
moveSnake(g, p.next);
}
// 비늘 하나의 밝기: 인덱스에 걸린 사인파 + 칸별 해시 노이즈
function scaleLight(k, seedA, seedB) {
const wave = 0.5 + 0.5 * Math.sin(k * SCALE_FREQ - phase);
const noise = (hash2(seedA, seedB, 455) - 0.5) * 2 * NOISE_AMP;
return clamp(wave * (1 - NOISE_AMP * 0.5) + noise * 0.5 + 0.25, 0, 1);
}
function drawScales(ctx, cx, cy, size, base, lightBase, id) {
const q = size / 4;
for (let r = 0; r < 2; r += 1) {
for (let c = 0; c < 2; c += 1) {
const l = clamp(lightBase + (hash2(id, r * 2 + c, 77) - 0.5) * NOISE_AMP, 0, 1);
ctx.fillStyle = rgba(mix(mix(base, BG, 0.45), mix(base, CREAM, 0.55), l), 1);
ctx.beginPath();
ctx.arc(cx + (c - 0.5) * q * 2 * 0.95, cy + (r - 0.5) * q * 2 * 0.95, q * 0.98, 0, PI2);
ctx.fill();
}
}
}
const scene = {
step() {
t += 1;
if (flowing) {
phase += WAVE_SPEED;
}
acc += FIXED_DT;
while (acc >= TICK_MS) {
acc -= TICK_MS;
logic();
}
},
draw(ctx) {
drawTiles(ctx, b);
// 배경 비늘 — 같은 사인파가 판 전체를 사선으로 지나간다
for (let y = 0; y < b.rows; y += 1) {
for (let x = 0; x < b.cols; x += 1) {
const l = scaleLight(x * 0.9 + y * 0.6, x, y);
const cx = b.ox + (x + 0.5) * b.cell + (y % 2 === 0 ? 0 : b.cell * 0.18);
const cy = b.oy + (y + 0.5) * b.cell;
ctx.fillStyle = rgba(mix(LILAC, SKY, SKIN_MIX), 0.1 + 0.26 * l);
ctx.beginPath();
ctx.arc(cx, cy, b.cell * 0.4, 0, PI2);
ctx.fill();
}
}
drawFood(ctx, b, g.food, t);
const p = smooth(clamp(acc / TICK_MS, 0, 1));
const len = g.body.length;
for (let i = len - 1; i >= 0; i -= 1) {
const sp = segPos(g, i, p);
const cx = b.ox + (sp[0] + 0.5) * b.cell;
const cy = b.oy + (sp[1] + 0.5) * b.cell;
const base = mix(mix(MINT, SKY, i / Math.max(1, len - 1)), PEACH, SKIN_MIX * 0.35 * (0.5 + 0.5 * Math.sin(i * SCALE_FREQ - phase)));
const dimmed = g.dead ? mix(base, BG, 0.55) : base;
const s = b.cell * 0.92;
ctx.fillStyle = rgba(mix(dimmed, BG, 0.35), 1);
rrPath(ctx, cx - s / 2, cy - s / 2, s, s, s * 0.34);
ctx.fill();
drawScales(ctx, cx, cy, s * 0.9, dimmed, scaleLight(i, i, 0), i);
}
const hs = segPos(g, 0, p);
const hd = headDir(g);
drawEyes(ctx, b, hs[0], hs[1], hd[0], hd[1]);
},
label() {
if (g.dead) {
return TXT_DEAD;
}
return (flowing ? TXT_WAVE : TXT_STILL) + ' · ' + TXT_LEN + ' ' + g.body.length;
},
tap() {
flowing = !flowing;
},
key(k) {
return false;
}
};
for (let i = 0; i < WARM_TICKS; i += 1) {
logic();
}
if (reduce) {
for (let i = 0; i < 4; i += 1) {
scene.step();
}
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./SkScaleSkinPattern.module.scss";
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 = '다음 장면';
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
key(k: string): boolean;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 팔레트 — 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG: RGB = [29, 26, 44];
const TILE_A: RGB = [37, 33, 58];
const TILE_B: RGB = [42, 38, 66];
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 glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: RGB, a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
// 둥근 사각형 경로 (roundRect 가 없는 브라우저에서도 같은 모양)
function rrPath(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.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// ---- 판과 뱀 ----
interface Board { cols: number; rows: number; cell: number; ox: number; oy: number; n: number; }
function makeBoard(rows: number): Board {
const cell = H / rows;
const cols = Math.floor(W / cell + 1e-6);
return { cols: cols, rows: rows, cell: cell, ox: (W - cols * cell) / 2, oy: 0, n: cols * rows };
}
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
// 칸 번호 y*cols+x. 판 밖이면 -1 (wrap 이면 반대편으로 이어진다)
function cellAt(b: Board, x: number, y: number, wrap: boolean): number {
let xx = x;
let yy = y;
if (wrap) {
xx = (x % b.cols + b.cols) % b.cols;
yy = (y % b.rows + b.rows) % b.rows;
} else if (x < 0 || y < 0 || x >= b.cols || y >= b.rows) {
return -1;
}
return yy * b.cols + xx;
}
function neighborCell(b: Board, i: number, d: number, wrap: boolean): number {
return cellAt(b, (i % b.cols) + DX[d], Math.floor(i / b.cols) + DY[d], wrap);
}
// 목표 칸에서 너비 우선으로 퍼지는 거리장. blocked 칸과 닿지 못하는 칸은 -1
function distField(b: Board, target: number, blocked: Uint8Array, wrap: boolean): Int16Array {
const dist = new Int16Array(b.n).fill(-1);
const queue = new Int16Array(b.n);
let qh = 0;
let qt = 0;
dist[target] = 0;
queue[qt] = target;
qt += 1;
while (qh < qt) {
const c = queue[qh];
qh += 1;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb >= 0 && dist[nb] < 0 && blocked[nb] === 0) {
dist[nb] = dist[c] + 1;
queue[qt] = nb;
qt += 1;
}
}
}
return dist;
}
// 거리장에서 값이 가장 낮은 이웃 (없으면 -1)
function lowestNeighbor(b: Board, dist: Int16Array, from: number, wrap: boolean): number {
let best = -1;
let bv = 32000;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, from, d, wrap);
if (nb >= 0 && dist[nb] >= 0 && dist[nb] < bv) {
bv = dist[nb];
best = nb;
}
}
return best;
}
function pathFrom(b: Board, dist: Int16Array, head: number, wrap: boolean): number[] {
const path: number[] = [];
let cur = head;
for (let k = 0; k < b.n; k += 1) {
const nx = lowestNeighbor(b, dist, cur, wrap);
if (nx < 0 || (dist[nx] >= dist[cur] && cur !== head)) {
return [];
}
path.push(nx);
if (dist[nx] === 0) {
return path;
}
cur = nx;
}
return [];
}
interface Game {
b: Board;
wrap: boolean;
body: number[];
prev: number[];
food: number;
rnd: () => number;
eaten: number;
dead: boolean;
resets: number;
}
function placeFood(g: Game): void {
const used = new Uint8Array(g.b.n);
for (let i = 0; i < g.body.length; i += 1) {
used[g.body[i]] = 1;
}
const free: number[] = [];
for (let i = 0; i < g.b.n; i += 1) {
if (used[i] === 0) {
free.push(i);
}
}
g.food = free.length > 0 ? free[Math.floor(g.rnd() * free.length)] : -1;
}
function resetGame(g: Game): void {
const b = g.b;
const hx = Math.floor(b.cols / 2);
const hy = Math.floor(b.rows / 2);
g.body = [hy * b.cols + hx, hy * b.cols + hx - 1, hy * b.cols + hx - 2];
g.prev = g.body.slice();
g.dead = false;
g.resets += 1;
placeFood(g);
}
function newGame(b: Board, wrap: boolean, seed: number): Game {
const g: Game = { b: b, wrap: wrap, body: [], prev: [], food: -1, rnd: lcg(seed), eaten: 0, dead: false, resets: -1 };
resetGame(g);
return g;
}
// 머리를 next 칸으로 옮긴다. 먹으면 true, 몸에 부딪히면 dead
function moveSnake(g: Game, next: number): boolean {
g.prev = g.body.slice();
const ate = next === g.food;
const tail = g.body[g.body.length - 1];
for (let i = 0; i < g.body.length; i += 1) {
if (g.body[i] === next && !(i === g.body.length - 1 && !ate && next === tail)) {
g.dead = true;
return false;
}
}
g.body.unshift(next);
if (ate) {
g.eaten += 1;
placeFood(g);
} else {
g.body.pop();
}
return ate;
}
// 경로를 끝까지 따라가 먹은 뒤의 가상 몸
function simulate(body: number[], path: number[]): number[] {
const v = body.slice();
for (let k = 0; k < path.length; k += 1) {
v.unshift(path[k]);
if (k < path.length - 1) {
v.pop();
}
}
return v;
}
// 가상 몸의 머리에서 번져 나가 꼬리에 닿는가. mark 를 주면 닿은 칸을 표시한다
function tailReachable(b: Board, v: number[], wrap: boolean, mark: Uint8Array | null): boolean {
const blocked = new Uint8Array(b.n);
for (let i = 1; i < v.length - 1; i += 1) {
blocked[v[i]] = 1;
}
const tail = v[v.length - 1];
const head = v[0];
const seen = new Uint8Array(b.n);
const stack: number[] = [head];
seen[head] = 1;
let hit = false;
while (stack.length > 0) {
const c = stack.pop() as number;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb < 0 || seen[nb] === 1) {
continue;
}
if (nb === tail) {
hit = true;
continue;
}
if (blocked[nb] === 1 || nb === head) {
continue;
}
seen[nb] = 1;
stack.push(nb);
}
}
if (mark) {
mark.set(seen);
}
return hit;
}
interface Plan { next: number; path: number[]; rejected: number[]; trap: Uint8Array; chase: boolean; }
// 한 수를 정한다: 먹이까지 최단 경로 → (안전 검사) → 막히면 꼬리를 쫓는다
function planMove(g: Game, safeOn: boolean): Plan {
const b = g.b;
const head = g.body[0];
const tail = g.body[g.body.length - 1];
const plan: Plan = { next: -1, path: [], rejected: [], trap: new Uint8Array(b.n), chase: false };
const blocked = new Uint8Array(b.n);
for (let i = 0; i < g.body.length - 1; i += 1) {
blocked[g.body[i]] = 1;
}
if (g.food >= 0) {
const dist = distField(b, g.food, blocked, g.wrap);
const path = pathFrom(b, dist, head, g.wrap);
plan.path = path;
if (path.length > 0) {
if (!safeOn) {
plan.next = path[0];
return plan;
}
const v = simulate(g.body, path);
if (tailReachable(b, v, g.wrap, plan.trap)) {
plan.next = path[0];
return plan;
}
plan.rejected = path;
}
}
plan.chase = true;
const dist2 = distField(b, tail, blocked, g.wrap);
const nx = lowestNeighbor(b, dist2, head, g.wrap);
if (nx >= 0) {
plan.next = nx;
return plan;
}
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, head, d, g.wrap);
if (nb >= 0 && blocked[nb] === 0 && nb !== head) {
plan.next = nb;
return plan;
}
}
return plan;
}
// 칸 중심의 화면 좌표
function cellX(b: Board, i: number): number { return b.ox + ((i % b.cols) + 0.5) * b.cell; }
function cellY(b: Board, i: number): number { return b.oy + (Math.floor(i / b.cols) + 0.5) * b.cell; }
function wrapDelta(a: number, c: number, n: number, wrap: boolean): number {
let d = c - a;
if (wrap) {
if (d > n / 2) { d -= n; }
if (d < -n / 2) { d += n; }
}
return d;
}
// 마디 i 의 보간 위치 (칸 좌표). 지난 틱의 자리에서 이번 틱의 자리로 p(0~1) 만큼
function segPos(g: Game, i: number, p: number): [number, number] {
const b = g.b;
const cur = g.body[i];
const pv = i < g.prev.length ? g.prev[i] : cur;
const cx = cur % b.cols;
const cy = Math.floor(cur / b.cols);
const px = pv % b.cols;
const py = Math.floor(pv / b.cols);
return [px + wrapDelta(px, cx, b.cols, g.wrap) * p, py + wrapDelta(py, cy, b.rows, g.wrap) * p];
}
function drawTiles(ctx: CanvasRenderingContext2D, b: Board): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
for (let y = 0; y < b.rows; y += 1) {
for (let x = 0; x < b.cols; x += 1) {
ctx.fillStyle = rgba((x + y) % 2 === 0 ? TILE_A : TILE_B, 1);
ctx.fillRect(b.ox + x * b.cell, b.oy + y * b.cell, b.cell + 0.5, b.cell + 0.5);
}
}
}
function drawFood(ctx: CanvasRenderingContext2D, b: Board, food: number, t: number): void {
if (food < 0) {
return;
}
const x = cellX(b, food);
const y = cellY(b, food);
const pulse = 0.5 + 0.5 * Math.sin(t * 0.12);
glow(ctx, x, y, b.cell * (1.3 + 0.35 * pulse), PINK, 0.55);
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.arc(x, y, b.cell * 0.3, 0, PI2);
ctx.fill();
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.arc(x - b.cell * 0.08, y - b.cell * 0.09, b.cell * 0.09, 0, PI2);
ctx.fill();
}
function bodyColor(i: number, len: number): RGB {
return mix(MINT, SKY, len <= 1 ? 0 : i / (len - 1));
}
// 칸 중심 (x,y 칸 좌표, 소수 허용)에 마디 하나
function drawSeg(ctx: CanvasRenderingContext2D, b: Board, gx: number, gy: number, c: RGB, shrink: number): void {
const s = b.cell * (1 - shrink);
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
ctx.fillStyle = rgba(c, 1);
rrPath(ctx, cx - s / 2, cy - s / 2, s, s, s * 0.32);
ctx.fill();
}
function drawEyes(ctx: CanvasRenderingContext2D, b: Board, gx: number, gy: number, dirx: number, diry: number): void {
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
const px = -diry;
const py = dirx;
const r = b.cell * 0.09;
ctx.fillStyle = rgba(BG, 1);
for (let s = -1; s <= 1; s += 2) {
ctx.beginPath();
ctx.arc(cx + dirx * b.cell * 0.16 + px * s * b.cell * 0.18, cy + diry * b.cell * 0.16 + py * s * b.cell * 0.18, r, 0, PI2);
ctx.fill();
}
}
function headDir(g: Game): [number, number] {
if (g.body.length < 2) {
return [1, 0];
}
const b = g.b;
const dx = wrapDelta(g.body[1] % b.cols, g.body[0] % b.cols, b.cols, g.wrap);
const dy = wrapDelta(Math.floor(g.body[1] / b.cols), Math.floor(g.body[0] / b.cols), b.rows, g.wrap);
return [clamp(dx, -1, 1), clamp(dy, -1, 1)];
}
// 보간 없이 칸 그대로 그리는 기본 뱀
function drawSnakeCells(ctx: CanvasRenderingContext2D, g: Game, dim: number): void {
const len = g.body.length;
for (let i = len - 1; i >= 0; i -= 1) {
const c = g.body[i];
const col = mix(bodyColor(i, len), BG, dim);
drawSeg(ctx, g.b, c % g.b.cols, Math.floor(c / g.b.cols), col, 0.08);
}
const hd = headDir(g);
drawEyes(ctx, g.b, g.body[0] % g.b.cols, Math.floor(g.body[0] / g.b.cols), hd[0], hd[1]);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement, make: Maker): () => void {
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: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = 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);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
// 움직임 줄이기에서는 루프가 없으니 입력 직후 한 틱쯤 앞으로 보내 바로 그린다
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 8);
show();
}
}
function toLocal(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;
}
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');
advance(1400);
show();
}
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
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, 'pointermove', function (e: Event): void {
toLocal(e as PointerEvent);
refresh();
});
listen(canvas, 'pointerdown', function (e: Event): void {
toLocal(e as PointerEvent);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
// 이 장면이 쓰는 키만 막는다. 쓰지 않는 키(Tab 등)는 그대로 둔다
if (scene.key(k)) {
e.preventDefault();
refresh();
}
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const TICK_MS = 95;
const GRID_N = 15;
const SCALE_FREQ = 0.62;
const WAVE_SPEED = 0.07;
const NOISE_AMP = 0.28;
const SKIN_MIX = 0.5;
const WARM_TICKS = 133;
const TXT_WAVE = '비늘 물결 흐름';
const TXT_STILL = '위상 정지';
const TXT_LEN = '길이';
const TXT_DEAD = '갇혀서 끝 — 다시 시작';
function makeScene(inp: Input, reduce: boolean): Scene {
const b = makeBoard(GRID_N);
const g = newGame(b, false, 455707);
let acc = 0;
let t = 0;
let phase = 0;
let flowing = true;
let hold = 0;
function logic(): void {
if (hold > 0) {
hold -= 1;
if (hold === 0) {
resetGame(g);
}
return;
}
const p = planMove(g, true);
if (p.next < 0) {
g.dead = true;
hold = 8;
return;
}
moveSnake(g, p.next);
}
// 비늘 하나의 밝기: 인덱스에 걸린 사인파 + 칸별 해시 노이즈
function scaleLight(k: number, seedA: number, seedB: number): number {
const wave = 0.5 + 0.5 * Math.sin(k * SCALE_FREQ - phase);
const noise = (hash2(seedA, seedB, 455) - 0.5) * 2 * NOISE_AMP;
return clamp(wave * (1 - NOISE_AMP * 0.5) + noise * 0.5 + 0.25, 0, 1);
}
function drawScales(ctx: CanvasRenderingContext2D, cx: number, cy: number, size: number, base: RGB, lightBase: number, id: number): void {
const q = size / 4;
for (let r = 0; r < 2; r += 1) {
for (let c = 0; c < 2; c += 1) {
const l = clamp(lightBase + (hash2(id, r * 2 + c, 77) - 0.5) * NOISE_AMP, 0, 1);
ctx.fillStyle = rgba(mix(mix(base, BG, 0.45), mix(base, CREAM, 0.55), l), 1);
ctx.beginPath();
ctx.arc(cx + (c - 0.5) * q * 2 * 0.95, cy + (r - 0.5) * q * 2 * 0.95, q * 0.98, 0, PI2);
ctx.fill();
}
}
}
const scene: Scene = {
step(): void {
t += 1;
if (flowing) {
phase += WAVE_SPEED;
}
acc += FIXED_DT;
while (acc >= TICK_MS) {
acc -= TICK_MS;
logic();
}
},
draw(ctx: CanvasRenderingContext2D): void {
drawTiles(ctx, b);
// 배경 비늘 — 같은 사인파가 판 전체를 사선으로 지나간다
for (let y = 0; y < b.rows; y += 1) {
for (let x = 0; x < b.cols; x += 1) {
const l = scaleLight(x * 0.9 + y * 0.6, x, y);
const cx = b.ox + (x + 0.5) * b.cell + (y % 2 === 0 ? 0 : b.cell * 0.18);
const cy = b.oy + (y + 0.5) * b.cell;
ctx.fillStyle = rgba(mix(LILAC, SKY, SKIN_MIX), 0.1 + 0.26 * l);
ctx.beginPath();
ctx.arc(cx, cy, b.cell * 0.4, 0, PI2);
ctx.fill();
}
}
drawFood(ctx, b, g.food, t);
const p = smooth(clamp(acc / TICK_MS, 0, 1));
const len = g.body.length;
for (let i = len - 1; i >= 0; i -= 1) {
const sp = segPos(g, i, p);
const cx = b.ox + (sp[0] + 0.5) * b.cell;
const cy = b.oy + (sp[1] + 0.5) * b.cell;
const base = mix(mix(MINT, SKY, i / Math.max(1, len - 1)), PEACH, SKIN_MIX * 0.35 * (0.5 + 0.5 * Math.sin(i * SCALE_FREQ - phase)));
const dimmed = g.dead ? mix(base, BG, 0.55) : base;
const s = b.cell * 0.92;
ctx.fillStyle = rgba(mix(dimmed, BG, 0.35), 1);
rrPath(ctx, cx - s / 2, cy - s / 2, s, s, s * 0.34);
ctx.fill();
drawScales(ctx, cx, cy, s * 0.9, dimmed, scaleLight(i, i, 0), i);
}
const hs = segPos(g, 0, p);
const hd = headDir(g);
drawEyes(ctx, b, hs[0], hs[1], hd[0], hd[1]);
},
label(): string {
if (g.dead) {
return TXT_DEAD;
}
return (flowing ? TXT_WAVE : TXT_STILL) + ' · ' + TXT_LEN + ' ' + g.body.length;
},
tap(): void {
flowing = !flowing;
},
key(k: string): boolean {
return false;
}
};
for (let i = 0; i < WARM_TICKS; i += 1) {
logic();
}
if (reduce) {
for (let i = 0; i < 4; i += 1) {
scene.step();
}
}
return scene;
}
export default function SkScaleSkinPattern() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="몸통을 따라 비늘 무늬와 밝기 물결이 흐르는 뱀 가죽" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">비늘 물결 흐름</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>몸통을 따라 비늘 무늬와 밝기 물결이 흐르는 뱀 가죽</p>
</div>
</main>
);
}07 · 몸통을 따라 비늘 무늬와 밝기 물결이 흐르는 뱀 가죽 — 측정 누적면적 41.128% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 비늘 하나의 밝기: 인덱스에 걸린 사인파 + 칸별 해시 노이즈
function scaleLight(k, seedA, seedB) {
const wave = 0.5 + 0.5 * Math.sin(k * SCALE_FREQ - phase);
const noise = (hash2(seedA, seedB, 455) - 0.5) * 2 * NOISE_AMP;
return clamp(wave * (1 - NOISE_AMP * 0.5) + noise * 0.5 + 0.25, 0, 1);
}
08먹이를 먹는 순간 중력을 받는 입자가 터지고 점수 숫자가 튀어 오르는 뱀
먹는 순간 먹이 칸에서 입자 28개가 방사형으로 터져 GRAVITY 0.075 로 떨어지고, 수명이 0.017 씩 줄어 약 0.56~0.94초 만에 사라진다. 점수는 네 자리이고 바뀐 자릿수마다 위로 -4.2 의 속도를 받아 스프링으로 제자리에 돌아온다. 이 장면의 측정은 누적면적 20.1%, 평균 강도 100.2, 움직인 프레임 23/23 이다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="먹이를 먹는 순간 중력을 받는 입자가 터지고 점수 숫자가 튀어 오르는 뱀"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">점수</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">먹이를 먹는 순간 중력을 받는 입자가 터지고 점수 숫자가 튀어 오르는 뱀</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
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;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__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;
}
.sc__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;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
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: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { 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);
}
.sc {
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;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__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;
}
.sc__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;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
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 BG = [29, 26, 44];
const TILE_A = [37, 33, 58];
const TILE_B = [42, 38, 66];
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 glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
// 둥근 사각형 경로 (roundRect 가 없는 브라우저에서도 같은 모양)
function rrPath(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
function makeBoard(rows) {
const cell = H / rows;
const cols = Math.floor(W / cell + 1e-6);
return { cols: cols, rows: rows, cell: cell, ox: (W - cols * cell) / 2, oy: 0, n: cols * rows };
}
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
// 칸 번호 y*cols+x. 판 밖이면 -1 (wrap 이면 반대편으로 이어진다)
function cellAt(b, x, y, wrap) {
let xx = x;
let yy = y;
if (wrap) {
xx = (x % b.cols + b.cols) % b.cols;
yy = (y % b.rows + b.rows) % b.rows;
}
else if (x < 0 || y < 0 || x >= b.cols || y >= b.rows) {
return -1;
}
return yy * b.cols + xx;
}
function neighborCell(b, i, d, wrap) {
return cellAt(b, (i % b.cols) + DX[d], Math.floor(i / b.cols) + DY[d], wrap);
}
// 목표 칸에서 너비 우선으로 퍼지는 거리장. blocked 칸과 닿지 못하는 칸은 -1
function distField(b, target, blocked, wrap) {
const dist = new Int16Array(b.n).fill(-1);
const queue = new Int16Array(b.n);
let qh = 0;
let qt = 0;
dist[target] = 0;
queue[qt] = target;
qt += 1;
while (qh < qt) {
const c = queue[qh];
qh += 1;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb >= 0 && dist[nb] < 0 && blocked[nb] === 0) {
dist[nb] = dist[c] + 1;
queue[qt] = nb;
qt += 1;
}
}
}
return dist;
}
// 거리장에서 값이 가장 낮은 이웃 (없으면 -1)
function lowestNeighbor(b, dist, from, wrap) {
let best = -1;
let bv = 32000;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, from, d, wrap);
if (nb >= 0 && dist[nb] >= 0 && dist[nb] < bv) {
bv = dist[nb];
best = nb;
}
}
return best;
}
function pathFrom(b, dist, head, wrap) {
const path = [];
let cur = head;
for (let k = 0; k < b.n; k += 1) {
const nx = lowestNeighbor(b, dist, cur, wrap);
if (nx < 0 || (dist[nx] >= dist[cur] && cur !== head)) {
return [];
}
path.push(nx);
if (dist[nx] === 0) {
return path;
}
cur = nx;
}
return [];
}
function placeFood(g) {
const used = new Uint8Array(g.b.n);
for (let i = 0; i < g.body.length; i += 1) {
used[g.body[i]] = 1;
}
const free = [];
for (let i = 0; i < g.b.n; i += 1) {
if (used[i] === 0) {
free.push(i);
}
}
g.food = free.length > 0 ? free[Math.floor(g.rnd() * free.length)] : -1;
}
function resetGame(g) {
const b = g.b;
const hx = Math.floor(b.cols / 2);
const hy = Math.floor(b.rows / 2);
g.body = [hy * b.cols + hx, hy * b.cols + hx - 1, hy * b.cols + hx - 2];
g.prev = g.body.slice();
g.dead = false;
g.resets += 1;
placeFood(g);
}
function newGame(b, wrap, seed) {
const g = { b: b, wrap: wrap, body: [], prev: [], food: -1, rnd: lcg(seed), eaten: 0, dead: false, resets: -1 };
resetGame(g);
return g;
}
// 머리를 next 칸으로 옮긴다. 먹으면 true, 몸에 부딪히면 dead
function moveSnake(g, next) {
g.prev = g.body.slice();
const ate = next === g.food;
const tail = g.body[g.body.length - 1];
for (let i = 0; i < g.body.length; i += 1) {
if (g.body[i] === next && !(i === g.body.length - 1 && !ate && next === tail)) {
g.dead = true;
return false;
}
}
g.body.unshift(next);
if (ate) {
g.eaten += 1;
placeFood(g);
}
else {
g.body.pop();
}
return ate;
}
// 경로를 끝까지 따라가 먹은 뒤의 가상 몸
function simulate(body, path) {
const v = body.slice();
for (let k = 0; k < path.length; k += 1) {
v.unshift(path[k]);
if (k < path.length - 1) {
v.pop();
}
}
return v;
}
// 가상 몸의 머리에서 번져 나가 꼬리에 닿는가. mark 를 주면 닿은 칸을 표시한다
function tailReachable(b, v, wrap, mark) {
const blocked = new Uint8Array(b.n);
for (let i = 1; i < v.length - 1; i += 1) {
blocked[v[i]] = 1;
}
const tail = v[v.length - 1];
const head = v[0];
const seen = new Uint8Array(b.n);
const stack = [head];
seen[head] = 1;
let hit = false;
while (stack.length > 0) {
const c = stack.pop();
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb < 0 || seen[nb] === 1) {
continue;
}
if (nb === tail) {
hit = true;
continue;
}
if (blocked[nb] === 1 || nb === head) {
continue;
}
seen[nb] = 1;
stack.push(nb);
}
}
if (mark) {
mark.set(seen);
}
return hit;
}
// 한 수를 정한다: 먹이까지 최단 경로 → (안전 검사) → 막히면 꼬리를 쫓는다
function planMove(g, safeOn) {
const b = g.b;
const head = g.body[0];
const tail = g.body[g.body.length - 1];
const plan = { next: -1, path: [], rejected: [], trap: new Uint8Array(b.n), chase: false };
const blocked = new Uint8Array(b.n);
for (let i = 0; i < g.body.length - 1; i += 1) {
blocked[g.body[i]] = 1;
}
if (g.food >= 0) {
const dist = distField(b, g.food, blocked, g.wrap);
const path = pathFrom(b, dist, head, g.wrap);
plan.path = path;
if (path.length > 0) {
if (!safeOn) {
plan.next = path[0];
return plan;
}
const v = simulate(g.body, path);
if (tailReachable(b, v, g.wrap, plan.trap)) {
plan.next = path[0];
return plan;
}
plan.rejected = path;
}
}
plan.chase = true;
const dist2 = distField(b, tail, blocked, g.wrap);
const nx = lowestNeighbor(b, dist2, head, g.wrap);
if (nx >= 0) {
plan.next = nx;
return plan;
}
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, head, d, g.wrap);
if (nb >= 0 && blocked[nb] === 0 && nb !== head) {
plan.next = nb;
return plan;
}
}
return plan;
}
// 칸 중심의 화면 좌표
function cellX(b, i) { return b.ox + ((i % b.cols) + 0.5) * b.cell; }
function cellY(b, i) { return b.oy + (Math.floor(i / b.cols) + 0.5) * b.cell; }
function wrapDelta(a, c, n, wrap) {
let d = c - a;
if (wrap) {
if (d > n / 2) {
d -= n;
}
if (d < -n / 2) {
d += n;
}
}
return d;
}
// 마디 i 의 보간 위치 (칸 좌표). 지난 틱의 자리에서 이번 틱의 자리로 p(0~1) 만큼
function segPos(g, i, p) {
const b = g.b;
const cur = g.body[i];
const pv = i < g.prev.length ? g.prev[i] : cur;
const cx = cur % b.cols;
const cy = Math.floor(cur / b.cols);
const px = pv % b.cols;
const py = Math.floor(pv / b.cols);
return [px + wrapDelta(px, cx, b.cols, g.wrap) * p, py + wrapDelta(py, cy, b.rows, g.wrap) * p];
}
function drawTiles(ctx, b) {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
for (let y = 0; y < b.rows; y += 1) {
for (let x = 0; x < b.cols; x += 1) {
ctx.fillStyle = rgba((x + y) % 2 === 0 ? TILE_A : TILE_B, 1);
ctx.fillRect(b.ox + x * b.cell, b.oy + y * b.cell, b.cell + 0.5, b.cell + 0.5);
}
}
}
function drawFood(ctx, b, food, t) {
if (food < 0) {
return;
}
const x = cellX(b, food);
const y = cellY(b, food);
const pulse = 0.5 + 0.5 * Math.sin(t * 0.12);
glow(ctx, x, y, b.cell * (1.3 + 0.35 * pulse), PINK, 0.55);
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.arc(x, y, b.cell * 0.3, 0, PI2);
ctx.fill();
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.arc(x - b.cell * 0.08, y - b.cell * 0.09, b.cell * 0.09, 0, PI2);
ctx.fill();
}
function bodyColor(i, len) {
return mix(MINT, SKY, len <= 1 ? 0 : i / (len - 1));
}
// 칸 중심 (x,y 칸 좌표, 소수 허용)에 마디 하나
function drawSeg(ctx, b, gx, gy, c, shrink) {
const s = b.cell * (1 - shrink);
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
ctx.fillStyle = rgba(c, 1);
rrPath(ctx, cx - s / 2, cy - s / 2, s, s, s * 0.32);
ctx.fill();
}
function drawEyes(ctx, b, gx, gy, dirx, diry) {
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
const px = -diry;
const py = dirx;
const r = b.cell * 0.09;
ctx.fillStyle = rgba(BG, 1);
for (let s = -1; s <= 1; s += 2) {
ctx.beginPath();
ctx.arc(cx + dirx * b.cell * 0.16 + px * s * b.cell * 0.18, cy + diry * b.cell * 0.16 + py * s * b.cell * 0.18, r, 0, PI2);
ctx.fill();
}
}
function headDir(g) {
if (g.body.length < 2) {
return [1, 0];
}
const b = g.b;
const dx = wrapDelta(g.body[1] % b.cols, g.body[0] % b.cols, b.cols, g.wrap);
const dy = wrapDelta(Math.floor(g.body[1] / b.cols), Math.floor(g.body[0] / b.cols), b.rows, g.wrap);
return [clamp(dx, -1, 1), clamp(dy, -1, 1)];
}
// 보간 없이 칸 그대로 그리는 기본 뱀
function drawSnakeCells(ctx, g, dim) {
const len = g.body.length;
for (let i = len - 1; i >= 0; i -= 1) {
const c = g.body[i];
const col = mix(bodyColor(i, len), BG, dim);
drawSeg(ctx, g.b, c % g.b.cols, Math.floor(c / g.b.cols), col, 0.08);
}
const hd = headDir(g);
drawEyes(ctx, g.b, g.body[0] % g.b.cols, Math.floor(g.body[0] / g.b.cols), hd[0], hd[1]);
}
function start(root, canvas, statusEl, btnEl, make) {
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: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = 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);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
// 움직임 줄이기에서는 루프가 없으니 입력 직후 한 틱쯤 앞으로 보내 바로 그린다
function refresh() {
if (reduce) {
advance(FIXED_DT * 8);
show();
}
}
function toLocal(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;
}
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');
advance(1400);
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
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, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
// 이 장면이 쓰는 키만 막는다. 쓰지 않는 키(Tab 등)는 그대로 둔다
if (scene.key(k)) {
e.preventDefault();
refresh();
}
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const TICK_MS = 70;
const GRID_N = 12;
const BURST_COUNT = 28;
const GRAVITY = 0.075;
const LIFE_DECAY = 0.017;
const DIGIT_STIFF = 0.2;
const DIGIT_DAMP = 0.13;
const SCORE_PER_FOOD = 10;
const WARM_TICKS = 71;
const TXT_SCORE = '점수';
const TXT_LEN = '길이';
const TXT_DEAD = '갇혀서 끝 — 다시 시작';
const BURST_COLORS = [PINK, PEACH, CREAM, LILAC, SKY];
function makeScene(inp, reduce) {
const b = makeBoard(GRID_N);
const g = newGame(b, false, 455808);
const rnd = lcg(455809);
const sparks = [];
const rings = [];
let acc = 0;
let t = 0;
let score = 0;
let hold = 0;
const digitY = [0, 0, 0, 0];
const digitV = [0, 0, 0, 0];
function burst(x, y) {
for (let i = 0; i < BURST_COUNT; i += 1) {
const a = rnd() * PI2;
const sp = 0.9 + rnd() * 2.7;
sparks.push({ x: x, y: y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 1.2, life: 0.6 + rnd() * 0.4, c: BURST_COLORS[i % BURST_COLORS.length] });
}
rings.push({ x: x, y: y, r: 4, life: 1 });
}
function digits(v) {
let s = String(v);
while (s.length < 4) {
s = '0' + s;
}
return s.slice(-4);
}
function addScore() {
const before = digits(score);
score += SCORE_PER_FOOD;
const after = digits(score);
for (let i = 0; i < 4; i += 1) {
if (before.charAt(i) !== after.charAt(i)) {
digitV[i] = -4.2;
}
}
}
function logic() {
if (hold > 0) {
hold -= 1;
if (hold === 0) {
resetGame(g);
}
return;
}
const foodAt = g.food;
const p = planMove(g, true);
if (p.next < 0) {
g.dead = true;
hold = 8;
return;
}
const ate = moveSnake(g, p.next);
if (ate) {
burst(cellX(b, foodAt), cellY(b, foodAt));
addScore();
}
}
const scene = {
step() {
t += 1;
acc += FIXED_DT;
while (acc >= TICK_MS) {
acc -= TICK_MS;
logic();
}
for (let i = sparks.length - 1; i >= 0; i -= 1) {
const s = sparks[i];
s.vy += GRAVITY;
s.x += s.vx;
s.y += s.vy;
s.life -= LIFE_DECAY;
if (s.life <= 0) {
sparks.splice(i, 1);
}
}
for (let i = rings.length - 1; i >= 0; i -= 1) {
rings[i].r += 3.2;
rings[i].life -= 0.035;
if (rings[i].life <= 0) {
rings.splice(i, 1);
}
}
for (let i = 0; i < 4; i += 1) {
digitV[i] += -DIGIT_STIFF * digitY[i] - DIGIT_DAMP * digitV[i];
digitY[i] += digitV[i];
}
},
draw(ctx) {
drawTiles(ctx, b);
for (let i = 0; i < rings.length; i += 1) {
ctx.strokeStyle = rgba(PEACH, 0.6 * rings[i].life);
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(rings[i].x, rings[i].y, rings[i].r, 0, PI2);
ctx.stroke();
}
drawFood(ctx, b, g.food, t);
const p = smooth(clamp(acc / TICK_MS, 0, 1));
const len = g.body.length;
for (let i = len - 1; i >= 0; i -= 1) {
const sp = segPos(g, i, p);
drawSeg(ctx, b, sp[0], sp[1], mix(bodyColor(i, len), BG, g.dead ? 0.55 : 0), 0.1);
}
const hs = segPos(g, 0, p);
const hd = headDir(g);
drawEyes(ctx, b, hs[0], hs[1], hd[0], hd[1]);
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
glow(ctx, s.x, s.y, 3 + 7 * s.life, s.c, 0.85 * s.life);
}
// 점수 — 자릿수마다 스프링으로 튀어 오른다
const sc = digits(score);
ctx.fillStyle = rgba(BG, 0.72);
rrPath(ctx, W - 118, 10, 108, 44, 12);
ctx.fill();
ctx.font = '700 28px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (let i = 0; i < 4; i += 1) {
ctx.fillStyle = rgba(Math.abs(digitY[i]) > 0.6 ? PEACH : CREAM, 1);
ctx.fillText(sc.charAt(i), W - 105 + 12 + i * 24, 33 + digitY[i]);
}
},
label() {
if (g.dead) {
return TXT_DEAD;
}
return TXT_SCORE + ' ' + score + ' · ' + TXT_LEN + ' ' + g.body.length;
},
tap() {
burst(cellX(b, g.body[0]), cellY(b, g.body[0]));
},
key(k) {
return false;
}
};
for (let i = 0; i < WARM_TICKS; i += 1) {
logic();
}
sparks.length = 0;
rings.length = 0;
if (reduce) {
for (let i = 0; i < 4; i += 1) {
scene.step();
}
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./SkEatBurstScoreRoll.module.scss";
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 = '다음 장면';
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
key(k: string): boolean;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 팔레트 — 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG: RGB = [29, 26, 44];
const TILE_A: RGB = [37, 33, 58];
const TILE_B: RGB = [42, 38, 66];
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 glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: RGB, a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
// 둥근 사각형 경로 (roundRect 가 없는 브라우저에서도 같은 모양)
function rrPath(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.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// ---- 판과 뱀 ----
interface Board { cols: number; rows: number; cell: number; ox: number; oy: number; n: number; }
function makeBoard(rows: number): Board {
const cell = H / rows;
const cols = Math.floor(W / cell + 1e-6);
return { cols: cols, rows: rows, cell: cell, ox: (W - cols * cell) / 2, oy: 0, n: cols * rows };
}
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
// 칸 번호 y*cols+x. 판 밖이면 -1 (wrap 이면 반대편으로 이어진다)
function cellAt(b: Board, x: number, y: number, wrap: boolean): number {
let xx = x;
let yy = y;
if (wrap) {
xx = (x % b.cols + b.cols) % b.cols;
yy = (y % b.rows + b.rows) % b.rows;
} else if (x < 0 || y < 0 || x >= b.cols || y >= b.rows) {
return -1;
}
return yy * b.cols + xx;
}
function neighborCell(b: Board, i: number, d: number, wrap: boolean): number {
return cellAt(b, (i % b.cols) + DX[d], Math.floor(i / b.cols) + DY[d], wrap);
}
// 목표 칸에서 너비 우선으로 퍼지는 거리장. blocked 칸과 닿지 못하는 칸은 -1
function distField(b: Board, target: number, blocked: Uint8Array, wrap: boolean): Int16Array {
const dist = new Int16Array(b.n).fill(-1);
const queue = new Int16Array(b.n);
let qh = 0;
let qt = 0;
dist[target] = 0;
queue[qt] = target;
qt += 1;
while (qh < qt) {
const c = queue[qh];
qh += 1;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb >= 0 && dist[nb] < 0 && blocked[nb] === 0) {
dist[nb] = dist[c] + 1;
queue[qt] = nb;
qt += 1;
}
}
}
return dist;
}
// 거리장에서 값이 가장 낮은 이웃 (없으면 -1)
function lowestNeighbor(b: Board, dist: Int16Array, from: number, wrap: boolean): number {
let best = -1;
let bv = 32000;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, from, d, wrap);
if (nb >= 0 && dist[nb] >= 0 && dist[nb] < bv) {
bv = dist[nb];
best = nb;
}
}
return best;
}
function pathFrom(b: Board, dist: Int16Array, head: number, wrap: boolean): number[] {
const path: number[] = [];
let cur = head;
for (let k = 0; k < b.n; k += 1) {
const nx = lowestNeighbor(b, dist, cur, wrap);
if (nx < 0 || (dist[nx] >= dist[cur] && cur !== head)) {
return [];
}
path.push(nx);
if (dist[nx] === 0) {
return path;
}
cur = nx;
}
return [];
}
interface Game {
b: Board;
wrap: boolean;
body: number[];
prev: number[];
food: number;
rnd: () => number;
eaten: number;
dead: boolean;
resets: number;
}
function placeFood(g: Game): void {
const used = new Uint8Array(g.b.n);
for (let i = 0; i < g.body.length; i += 1) {
used[g.body[i]] = 1;
}
const free: number[] = [];
for (let i = 0; i < g.b.n; i += 1) {
if (used[i] === 0) {
free.push(i);
}
}
g.food = free.length > 0 ? free[Math.floor(g.rnd() * free.length)] : -1;
}
function resetGame(g: Game): void {
const b = g.b;
const hx = Math.floor(b.cols / 2);
const hy = Math.floor(b.rows / 2);
g.body = [hy * b.cols + hx, hy * b.cols + hx - 1, hy * b.cols + hx - 2];
g.prev = g.body.slice();
g.dead = false;
g.resets += 1;
placeFood(g);
}
function newGame(b: Board, wrap: boolean, seed: number): Game {
const g: Game = { b: b, wrap: wrap, body: [], prev: [], food: -1, rnd: lcg(seed), eaten: 0, dead: false, resets: -1 };
resetGame(g);
return g;
}
// 머리를 next 칸으로 옮긴다. 먹으면 true, 몸에 부딪히면 dead
function moveSnake(g: Game, next: number): boolean {
g.prev = g.body.slice();
const ate = next === g.food;
const tail = g.body[g.body.length - 1];
for (let i = 0; i < g.body.length; i += 1) {
if (g.body[i] === next && !(i === g.body.length - 1 && !ate && next === tail)) {
g.dead = true;
return false;
}
}
g.body.unshift(next);
if (ate) {
g.eaten += 1;
placeFood(g);
} else {
g.body.pop();
}
return ate;
}
// 경로를 끝까지 따라가 먹은 뒤의 가상 몸
function simulate(body: number[], path: number[]): number[] {
const v = body.slice();
for (let k = 0; k < path.length; k += 1) {
v.unshift(path[k]);
if (k < path.length - 1) {
v.pop();
}
}
return v;
}
// 가상 몸의 머리에서 번져 나가 꼬리에 닿는가. mark 를 주면 닿은 칸을 표시한다
function tailReachable(b: Board, v: number[], wrap: boolean, mark: Uint8Array | null): boolean {
const blocked = new Uint8Array(b.n);
for (let i = 1; i < v.length - 1; i += 1) {
blocked[v[i]] = 1;
}
const tail = v[v.length - 1];
const head = v[0];
const seen = new Uint8Array(b.n);
const stack: number[] = [head];
seen[head] = 1;
let hit = false;
while (stack.length > 0) {
const c = stack.pop() as number;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb < 0 || seen[nb] === 1) {
continue;
}
if (nb === tail) {
hit = true;
continue;
}
if (blocked[nb] === 1 || nb === head) {
continue;
}
seen[nb] = 1;
stack.push(nb);
}
}
if (mark) {
mark.set(seen);
}
return hit;
}
interface Plan { next: number; path: number[]; rejected: number[]; trap: Uint8Array; chase: boolean; }
// 한 수를 정한다: 먹이까지 최단 경로 → (안전 검사) → 막히면 꼬리를 쫓는다
function planMove(g: Game, safeOn: boolean): Plan {
const b = g.b;
const head = g.body[0];
const tail = g.body[g.body.length - 1];
const plan: Plan = { next: -1, path: [], rejected: [], trap: new Uint8Array(b.n), chase: false };
const blocked = new Uint8Array(b.n);
for (let i = 0; i < g.body.length - 1; i += 1) {
blocked[g.body[i]] = 1;
}
if (g.food >= 0) {
const dist = distField(b, g.food, blocked, g.wrap);
const path = pathFrom(b, dist, head, g.wrap);
plan.path = path;
if (path.length > 0) {
if (!safeOn) {
plan.next = path[0];
return plan;
}
const v = simulate(g.body, path);
if (tailReachable(b, v, g.wrap, plan.trap)) {
plan.next = path[0];
return plan;
}
plan.rejected = path;
}
}
plan.chase = true;
const dist2 = distField(b, tail, blocked, g.wrap);
const nx = lowestNeighbor(b, dist2, head, g.wrap);
if (nx >= 0) {
plan.next = nx;
return plan;
}
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, head, d, g.wrap);
if (nb >= 0 && blocked[nb] === 0 && nb !== head) {
plan.next = nb;
return plan;
}
}
return plan;
}
// 칸 중심의 화면 좌표
function cellX(b: Board, i: number): number { return b.ox + ((i % b.cols) + 0.5) * b.cell; }
function cellY(b: Board, i: number): number { return b.oy + (Math.floor(i / b.cols) + 0.5) * b.cell; }
function wrapDelta(a: number, c: number, n: number, wrap: boolean): number {
let d = c - a;
if (wrap) {
if (d > n / 2) { d -= n; }
if (d < -n / 2) { d += n; }
}
return d;
}
// 마디 i 의 보간 위치 (칸 좌표). 지난 틱의 자리에서 이번 틱의 자리로 p(0~1) 만큼
function segPos(g: Game, i: number, p: number): [number, number] {
const b = g.b;
const cur = g.body[i];
const pv = i < g.prev.length ? g.prev[i] : cur;
const cx = cur % b.cols;
const cy = Math.floor(cur / b.cols);
const px = pv % b.cols;
const py = Math.floor(pv / b.cols);
return [px + wrapDelta(px, cx, b.cols, g.wrap) * p, py + wrapDelta(py, cy, b.rows, g.wrap) * p];
}
function drawTiles(ctx: CanvasRenderingContext2D, b: Board): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
for (let y = 0; y < b.rows; y += 1) {
for (let x = 0; x < b.cols; x += 1) {
ctx.fillStyle = rgba((x + y) % 2 === 0 ? TILE_A : TILE_B, 1);
ctx.fillRect(b.ox + x * b.cell, b.oy + y * b.cell, b.cell + 0.5, b.cell + 0.5);
}
}
}
function drawFood(ctx: CanvasRenderingContext2D, b: Board, food: number, t: number): void {
if (food < 0) {
return;
}
const x = cellX(b, food);
const y = cellY(b, food);
const pulse = 0.5 + 0.5 * Math.sin(t * 0.12);
glow(ctx, x, y, b.cell * (1.3 + 0.35 * pulse), PINK, 0.55);
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.arc(x, y, b.cell * 0.3, 0, PI2);
ctx.fill();
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.arc(x - b.cell * 0.08, y - b.cell * 0.09, b.cell * 0.09, 0, PI2);
ctx.fill();
}
function bodyColor(i: number, len: number): RGB {
return mix(MINT, SKY, len <= 1 ? 0 : i / (len - 1));
}
// 칸 중심 (x,y 칸 좌표, 소수 허용)에 마디 하나
function drawSeg(ctx: CanvasRenderingContext2D, b: Board, gx: number, gy: number, c: RGB, shrink: number): void {
const s = b.cell * (1 - shrink);
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
ctx.fillStyle = rgba(c, 1);
rrPath(ctx, cx - s / 2, cy - s / 2, s, s, s * 0.32);
ctx.fill();
}
function drawEyes(ctx: CanvasRenderingContext2D, b: Board, gx: number, gy: number, dirx: number, diry: number): void {
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
const px = -diry;
const py = dirx;
const r = b.cell * 0.09;
ctx.fillStyle = rgba(BG, 1);
for (let s = -1; s <= 1; s += 2) {
ctx.beginPath();
ctx.arc(cx + dirx * b.cell * 0.16 + px * s * b.cell * 0.18, cy + diry * b.cell * 0.16 + py * s * b.cell * 0.18, r, 0, PI2);
ctx.fill();
}
}
function headDir(g: Game): [number, number] {
if (g.body.length < 2) {
return [1, 0];
}
const b = g.b;
const dx = wrapDelta(g.body[1] % b.cols, g.body[0] % b.cols, b.cols, g.wrap);
const dy = wrapDelta(Math.floor(g.body[1] / b.cols), Math.floor(g.body[0] / b.cols), b.rows, g.wrap);
return [clamp(dx, -1, 1), clamp(dy, -1, 1)];
}
// 보간 없이 칸 그대로 그리는 기본 뱀
function drawSnakeCells(ctx: CanvasRenderingContext2D, g: Game, dim: number): void {
const len = g.body.length;
for (let i = len - 1; i >= 0; i -= 1) {
const c = g.body[i];
const col = mix(bodyColor(i, len), BG, dim);
drawSeg(ctx, g.b, c % g.b.cols, Math.floor(c / g.b.cols), col, 0.08);
}
const hd = headDir(g);
drawEyes(ctx, g.b, g.body[0] % g.b.cols, Math.floor(g.body[0] / g.b.cols), hd[0], hd[1]);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement, make: Maker): () => void {
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: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = 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);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
// 움직임 줄이기에서는 루프가 없으니 입력 직후 한 틱쯤 앞으로 보내 바로 그린다
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 8);
show();
}
}
function toLocal(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;
}
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');
advance(1400);
show();
}
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
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, 'pointermove', function (e: Event): void {
toLocal(e as PointerEvent);
refresh();
});
listen(canvas, 'pointerdown', function (e: Event): void {
toLocal(e as PointerEvent);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
// 이 장면이 쓰는 키만 막는다. 쓰지 않는 키(Tab 등)는 그대로 둔다
if (scene.key(k)) {
e.preventDefault();
refresh();
}
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const TICK_MS = 70;
const GRID_N = 12;
const BURST_COUNT = 28;
const GRAVITY = 0.075;
const LIFE_DECAY = 0.017;
const DIGIT_STIFF = 0.2;
const DIGIT_DAMP = 0.13;
const SCORE_PER_FOOD = 10;
const WARM_TICKS = 71;
const TXT_SCORE = '점수';
const TXT_LEN = '길이';
const TXT_DEAD = '갇혀서 끝 — 다시 시작';
const BURST_COLORS: RGB[] = [PINK, PEACH, CREAM, LILAC, SKY];
interface Spark { x: number; y: number; vx: number; vy: number; life: number; c: RGB; }
interface Ring { x: number; y: number; r: number; life: number; }
function makeScene(inp: Input, reduce: boolean): Scene {
const b = makeBoard(GRID_N);
const g = newGame(b, false, 455808);
const rnd = lcg(455809);
const sparks: Spark[] = [];
const rings: Ring[] = [];
let acc = 0;
let t = 0;
let score = 0;
let hold = 0;
const digitY = [0, 0, 0, 0];
const digitV = [0, 0, 0, 0];
function burst(x: number, y: number): void {
for (let i = 0; i < BURST_COUNT; i += 1) {
const a = rnd() * PI2;
const sp = 0.9 + rnd() * 2.7;
sparks.push({ x: x, y: y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 1.2, life: 0.6 + rnd() * 0.4, c: BURST_COLORS[i % BURST_COLORS.length] });
}
rings.push({ x: x, y: y, r: 4, life: 1 });
}
function digits(v: number): string {
let s = String(v);
while (s.length < 4) {
s = '0' + s;
}
return s.slice(-4);
}
function addScore(): void {
const before = digits(score);
score += SCORE_PER_FOOD;
const after = digits(score);
for (let i = 0; i < 4; i += 1) {
if (before.charAt(i) !== after.charAt(i)) {
digitV[i] = -4.2;
}
}
}
function logic(): void {
if (hold > 0) {
hold -= 1;
if (hold === 0) {
resetGame(g);
}
return;
}
const foodAt = g.food;
const p = planMove(g, true);
if (p.next < 0) {
g.dead = true;
hold = 8;
return;
}
const ate = moveSnake(g, p.next);
if (ate) {
burst(cellX(b, foodAt), cellY(b, foodAt));
addScore();
}
}
const scene: Scene = {
step(): void {
t += 1;
acc += FIXED_DT;
while (acc >= TICK_MS) {
acc -= TICK_MS;
logic();
}
for (let i = sparks.length - 1; i >= 0; i -= 1) {
const s = sparks[i];
s.vy += GRAVITY;
s.x += s.vx;
s.y += s.vy;
s.life -= LIFE_DECAY;
if (s.life <= 0) {
sparks.splice(i, 1);
}
}
for (let i = rings.length - 1; i >= 0; i -= 1) {
rings[i].r += 3.2;
rings[i].life -= 0.035;
if (rings[i].life <= 0) {
rings.splice(i, 1);
}
}
for (let i = 0; i < 4; i += 1) {
digitV[i] += -DIGIT_STIFF * digitY[i] - DIGIT_DAMP * digitV[i];
digitY[i] += digitV[i];
}
},
draw(ctx: CanvasRenderingContext2D): void {
drawTiles(ctx, b);
for (let i = 0; i < rings.length; i += 1) {
ctx.strokeStyle = rgba(PEACH, 0.6 * rings[i].life);
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(rings[i].x, rings[i].y, rings[i].r, 0, PI2);
ctx.stroke();
}
drawFood(ctx, b, g.food, t);
const p = smooth(clamp(acc / TICK_MS, 0, 1));
const len = g.body.length;
for (let i = len - 1; i >= 0; i -= 1) {
const sp = segPos(g, i, p);
drawSeg(ctx, b, sp[0], sp[1], mix(bodyColor(i, len), BG, g.dead ? 0.55 : 0), 0.1);
}
const hs = segPos(g, 0, p);
const hd = headDir(g);
drawEyes(ctx, b, hs[0], hs[1], hd[0], hd[1]);
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
glow(ctx, s.x, s.y, 3 + 7 * s.life, s.c, 0.85 * s.life);
}
// 점수 — 자릿수마다 스프링으로 튀어 오른다
const sc = digits(score);
ctx.fillStyle = rgba(BG, 0.72);
rrPath(ctx, W - 118, 10, 108, 44, 12);
ctx.fill();
ctx.font = '700 28px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (let i = 0; i < 4; i += 1) {
ctx.fillStyle = rgba(Math.abs(digitY[i]) > 0.6 ? PEACH : CREAM, 1);
ctx.fillText(sc.charAt(i), W - 105 + 12 + i * 24, 33 + digitY[i]);
}
},
label(): string {
if (g.dead) {
return TXT_DEAD;
}
return TXT_SCORE + ' ' + score + ' · ' + TXT_LEN + ' ' + g.body.length;
},
tap(): void {
burst(cellX(b, g.body[0]), cellY(b, g.body[0]));
},
key(k: string): boolean {
return false;
}
};
for (let i = 0; i < WARM_TICKS; i += 1) {
logic();
}
sparks.length = 0;
rings.length = 0;
if (reduce) {
for (let i = 0; i < 4; i += 1) {
scene.step();
}
}
return scene;
}
export default function SkEatBurstScoreRoll() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="먹이를 먹는 순간 중력을 받는 입자가 터지고 점수 숫자가 튀어 오르는 뱀" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">점수</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>먹이를 먹는 순간 중력을 받는 입자가 터지고 점수 숫자가 튀어 오르는 뱀</p>
</div>
</main>
);
}08 · 먹이를 먹는 순간 중력을 받는 입자가 터지고 점수 숫자가 튀어 오르는 뱀 — 측정 누적면적 20.14% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function burst(x, y) {
for (let i = 0; i < BURST_COUNT; i += 1) {
const a = rnd() * PI2;
const sp = 0.9 + rnd() * 2.7;
sparks.push({ x: x, y: y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 1.2, life: 0.6 + rnd() * 0.4, c: BURST_COLORS[i % BURST_COLORS.length] });
}
rings.push({ x: x, y: y, r: 4, life: 1 });
}
09먹이에서 퍼지는 거리장 물결과 그 비탈을 타고 내려가는 뱀
먹이에서 너비 우선으로 모든 칸의 거리를 채워 그 값으로 파동 색을 칠하고, 뱀은 머리 이웃 중 가장 낮은 값(lowestNeighbor)으로 내려간다. 방향키는 먹이를 한 칸 옮기고 탭은 물결을 멈춰 같은 값의 칸이 띠로 남는다. 판 전체를 거리 색으로 덮어 누적면적 73.6% 로 아홉 중 가장 크고, 평균 강도 67.0, 움직인 프레임 23/23 이다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="먹이에서 퍼지는 거리장 물결과 그 비탈을 타고 내려가는 뱀"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">거리장 물결</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">먹이에서 퍼지는 거리장 물결과 그 비탈을 타고 내려가는 뱀</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
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;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__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;
}
.sc__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;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
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: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { 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);
}
.sc {
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;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__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;
}
.sc__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;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
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 BG = [29, 26, 44];
const TILE_A = [37, 33, 58];
const TILE_B = [42, 38, 66];
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 glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
// 둥근 사각형 경로 (roundRect 가 없는 브라우저에서도 같은 모양)
function rrPath(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
function makeBoard(rows) {
const cell = H / rows;
const cols = Math.floor(W / cell + 1e-6);
return { cols: cols, rows: rows, cell: cell, ox: (W - cols * cell) / 2, oy: 0, n: cols * rows };
}
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
// 칸 번호 y*cols+x. 판 밖이면 -1 (wrap 이면 반대편으로 이어진다)
function cellAt(b, x, y, wrap) {
let xx = x;
let yy = y;
if (wrap) {
xx = (x % b.cols + b.cols) % b.cols;
yy = (y % b.rows + b.rows) % b.rows;
}
else if (x < 0 || y < 0 || x >= b.cols || y >= b.rows) {
return -1;
}
return yy * b.cols + xx;
}
function neighborCell(b, i, d, wrap) {
return cellAt(b, (i % b.cols) + DX[d], Math.floor(i / b.cols) + DY[d], wrap);
}
// 목표 칸에서 너비 우선으로 퍼지는 거리장. blocked 칸과 닿지 못하는 칸은 -1
function distField(b, target, blocked, wrap) {
const dist = new Int16Array(b.n).fill(-1);
const queue = new Int16Array(b.n);
let qh = 0;
let qt = 0;
dist[target] = 0;
queue[qt] = target;
qt += 1;
while (qh < qt) {
const c = queue[qh];
qh += 1;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb >= 0 && dist[nb] < 0 && blocked[nb] === 0) {
dist[nb] = dist[c] + 1;
queue[qt] = nb;
qt += 1;
}
}
}
return dist;
}
// 거리장에서 값이 가장 낮은 이웃 (없으면 -1)
function lowestNeighbor(b, dist, from, wrap) {
let best = -1;
let bv = 32000;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, from, d, wrap);
if (nb >= 0 && dist[nb] >= 0 && dist[nb] < bv) {
bv = dist[nb];
best = nb;
}
}
return best;
}
function pathFrom(b, dist, head, wrap) {
const path = [];
let cur = head;
for (let k = 0; k < b.n; k += 1) {
const nx = lowestNeighbor(b, dist, cur, wrap);
if (nx < 0 || (dist[nx] >= dist[cur] && cur !== head)) {
return [];
}
path.push(nx);
if (dist[nx] === 0) {
return path;
}
cur = nx;
}
return [];
}
function placeFood(g) {
const used = new Uint8Array(g.b.n);
for (let i = 0; i < g.body.length; i += 1) {
used[g.body[i]] = 1;
}
const free = [];
for (let i = 0; i < g.b.n; i += 1) {
if (used[i] === 0) {
free.push(i);
}
}
g.food = free.length > 0 ? free[Math.floor(g.rnd() * free.length)] : -1;
}
function resetGame(g) {
const b = g.b;
const hx = Math.floor(b.cols / 2);
const hy = Math.floor(b.rows / 2);
g.body = [hy * b.cols + hx, hy * b.cols + hx - 1, hy * b.cols + hx - 2];
g.prev = g.body.slice();
g.dead = false;
g.resets += 1;
placeFood(g);
}
function newGame(b, wrap, seed) {
const g = { b: b, wrap: wrap, body: [], prev: [], food: -1, rnd: lcg(seed), eaten: 0, dead: false, resets: -1 };
resetGame(g);
return g;
}
// 머리를 next 칸으로 옮긴다. 먹으면 true, 몸에 부딪히면 dead
function moveSnake(g, next) {
g.prev = g.body.slice();
const ate = next === g.food;
const tail = g.body[g.body.length - 1];
for (let i = 0; i < g.body.length; i += 1) {
if (g.body[i] === next && !(i === g.body.length - 1 && !ate && next === tail)) {
g.dead = true;
return false;
}
}
g.body.unshift(next);
if (ate) {
g.eaten += 1;
placeFood(g);
}
else {
g.body.pop();
}
return ate;
}
// 경로를 끝까지 따라가 먹은 뒤의 가상 몸
function simulate(body, path) {
const v = body.slice();
for (let k = 0; k < path.length; k += 1) {
v.unshift(path[k]);
if (k < path.length - 1) {
v.pop();
}
}
return v;
}
// 가상 몸의 머리에서 번져 나가 꼬리에 닿는가. mark 를 주면 닿은 칸을 표시한다
function tailReachable(b, v, wrap, mark) {
const blocked = new Uint8Array(b.n);
for (let i = 1; i < v.length - 1; i += 1) {
blocked[v[i]] = 1;
}
const tail = v[v.length - 1];
const head = v[0];
const seen = new Uint8Array(b.n);
const stack = [head];
seen[head] = 1;
let hit = false;
while (stack.length > 0) {
const c = stack.pop();
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb < 0 || seen[nb] === 1) {
continue;
}
if (nb === tail) {
hit = true;
continue;
}
if (blocked[nb] === 1 || nb === head) {
continue;
}
seen[nb] = 1;
stack.push(nb);
}
}
if (mark) {
mark.set(seen);
}
return hit;
}
// 한 수를 정한다: 먹이까지 최단 경로 → (안전 검사) → 막히면 꼬리를 쫓는다
function planMove(g, safeOn) {
const b = g.b;
const head = g.body[0];
const tail = g.body[g.body.length - 1];
const plan = { next: -1, path: [], rejected: [], trap: new Uint8Array(b.n), chase: false };
const blocked = new Uint8Array(b.n);
for (let i = 0; i < g.body.length - 1; i += 1) {
blocked[g.body[i]] = 1;
}
if (g.food >= 0) {
const dist = distField(b, g.food, blocked, g.wrap);
const path = pathFrom(b, dist, head, g.wrap);
plan.path = path;
if (path.length > 0) {
if (!safeOn) {
plan.next = path[0];
return plan;
}
const v = simulate(g.body, path);
if (tailReachable(b, v, g.wrap, plan.trap)) {
plan.next = path[0];
return plan;
}
plan.rejected = path;
}
}
plan.chase = true;
const dist2 = distField(b, tail, blocked, g.wrap);
const nx = lowestNeighbor(b, dist2, head, g.wrap);
if (nx >= 0) {
plan.next = nx;
return plan;
}
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, head, d, g.wrap);
if (nb >= 0 && blocked[nb] === 0 && nb !== head) {
plan.next = nb;
return plan;
}
}
return plan;
}
// 칸 중심의 화면 좌표
function cellX(b, i) { return b.ox + ((i % b.cols) + 0.5) * b.cell; }
function cellY(b, i) { return b.oy + (Math.floor(i / b.cols) + 0.5) * b.cell; }
function wrapDelta(a, c, n, wrap) {
let d = c - a;
if (wrap) {
if (d > n / 2) {
d -= n;
}
if (d < -n / 2) {
d += n;
}
}
return d;
}
// 마디 i 의 보간 위치 (칸 좌표). 지난 틱의 자리에서 이번 틱의 자리로 p(0~1) 만큼
function segPos(g, i, p) {
const b = g.b;
const cur = g.body[i];
const pv = i < g.prev.length ? g.prev[i] : cur;
const cx = cur % b.cols;
const cy = Math.floor(cur / b.cols);
const px = pv % b.cols;
const py = Math.floor(pv / b.cols);
return [px + wrapDelta(px, cx, b.cols, g.wrap) * p, py + wrapDelta(py, cy, b.rows, g.wrap) * p];
}
function drawTiles(ctx, b) {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
for (let y = 0; y < b.rows; y += 1) {
for (let x = 0; x < b.cols; x += 1) {
ctx.fillStyle = rgba((x + y) % 2 === 0 ? TILE_A : TILE_B, 1);
ctx.fillRect(b.ox + x * b.cell, b.oy + y * b.cell, b.cell + 0.5, b.cell + 0.5);
}
}
}
function drawFood(ctx, b, food, t) {
if (food < 0) {
return;
}
const x = cellX(b, food);
const y = cellY(b, food);
const pulse = 0.5 + 0.5 * Math.sin(t * 0.12);
glow(ctx, x, y, b.cell * (1.3 + 0.35 * pulse), PINK, 0.55);
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.arc(x, y, b.cell * 0.3, 0, PI2);
ctx.fill();
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.arc(x - b.cell * 0.08, y - b.cell * 0.09, b.cell * 0.09, 0, PI2);
ctx.fill();
}
function bodyColor(i, len) {
return mix(MINT, SKY, len <= 1 ? 0 : i / (len - 1));
}
// 칸 중심 (x,y 칸 좌표, 소수 허용)에 마디 하나
function drawSeg(ctx, b, gx, gy, c, shrink) {
const s = b.cell * (1 - shrink);
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
ctx.fillStyle = rgba(c, 1);
rrPath(ctx, cx - s / 2, cy - s / 2, s, s, s * 0.32);
ctx.fill();
}
function drawEyes(ctx, b, gx, gy, dirx, diry) {
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
const px = -diry;
const py = dirx;
const r = b.cell * 0.09;
ctx.fillStyle = rgba(BG, 1);
for (let s = -1; s <= 1; s += 2) {
ctx.beginPath();
ctx.arc(cx + dirx * b.cell * 0.16 + px * s * b.cell * 0.18, cy + diry * b.cell * 0.16 + py * s * b.cell * 0.18, r, 0, PI2);
ctx.fill();
}
}
function headDir(g) {
if (g.body.length < 2) {
return [1, 0];
}
const b = g.b;
const dx = wrapDelta(g.body[1] % b.cols, g.body[0] % b.cols, b.cols, g.wrap);
const dy = wrapDelta(Math.floor(g.body[1] / b.cols), Math.floor(g.body[0] / b.cols), b.rows, g.wrap);
return [clamp(dx, -1, 1), clamp(dy, -1, 1)];
}
// 보간 없이 칸 그대로 그리는 기본 뱀
function drawSnakeCells(ctx, g, dim) {
const len = g.body.length;
for (let i = len - 1; i >= 0; i -= 1) {
const c = g.body[i];
const col = mix(bodyColor(i, len), BG, dim);
drawSeg(ctx, g.b, c % g.b.cols, Math.floor(c / g.b.cols), col, 0.08);
}
const hd = headDir(g);
drawEyes(ctx, g.b, g.body[0] % g.b.cols, Math.floor(g.body[0] / g.b.cols), hd[0], hd[1]);
}
function start(root, canvas, statusEl, btnEl, make) {
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: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = 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);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
// 움직임 줄이기에서는 루프가 없으니 입력 직후 한 틱쯤 앞으로 보내 바로 그린다
function refresh() {
if (reduce) {
advance(FIXED_DT * 8);
show();
}
}
function toLocal(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;
}
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');
advance(1400);
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
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, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
// 이 장면이 쓰는 키만 막는다. 쓰지 않는 키(Tab 등)는 그대로 둔다
if (scene.key(k)) {
e.preventDefault();
refresh();
}
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const TICK_MS = 100;
const GRID_N = 16;
const WAVE_K = 0.55;
const WAVE_SPEED = 0.09;
const DIST_COLOR_STEPS = 6;
const WARM_TICKS = 0;
const TXT_WAVE = '거리장 물결';
const TXT_BANDS = '물결 정지';
const TXT_LEN = '길이';
const TXT_DEAD = '갇혀서 끝 — 다시 시작';
const FIELD_RAMP = [[48, 44, 84], [96, 92, 168], LILAC, SKY, MINT, CREAM];
function fieldColor(w, steps) {
const v = Math.floor(clamp(w, 0, 0.999) * steps) / (steps - 1);
const f = clamp(v, 0, 1) * (FIELD_RAMP.length - 1);
const i = Math.min(FIELD_RAMP.length - 2, Math.floor(f));
return mix(FIELD_RAMP[i], FIELD_RAMP[i + 1], f - i);
}
function makeScene(inp, reduce) {
const b = makeBoard(GRID_N);
const g = newGame(b, false, 455909);
let dist = new Int16Array(b.n);
let acc = 0;
let t = 0;
let phase = 0;
let moving = true;
let hold = 0;
let stepTo = -1;
function refreshField() {
const blocked = new Uint8Array(b.n);
for (let i = 0; i < g.body.length - 1; i += 1) {
blocked[g.body[i]] = 1;
}
if (g.food >= 0) {
dist = distField(b, g.food, blocked, false);
}
stepTo = lowestNeighbor(b, dist, g.body[0], false);
}
function logic() {
if (hold > 0) {
hold -= 1;
if (hold === 0) {
resetGame(g);
refreshField();
}
return;
}
refreshField();
if (stepTo < 0) {
g.dead = true;
hold = 8;
return;
}
moveSnake(g, stepTo);
refreshField();
}
refreshField();
const scene = {
step() {
t += 1;
if (moving) {
phase += WAVE_SPEED;
}
acc += FIXED_DT;
while (acc >= TICK_MS) {
acc -= TICK_MS;
logic();
}
},
draw(ctx) {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
for (let i = 0; i < b.n; i += 1) {
const d = dist[i];
let col = (i % b.cols + Math.floor(i / b.cols)) % 2 === 0 ? TILE_A : TILE_B;
if (d >= 0) {
// 먹이에서 멀어질수록 파동이 옅어진다
const w = 0.5 + 0.5 * Math.cos(d * WAVE_K - phase);
const fade = clamp(1 - d / 38, 0.25, 1);
col = mix(col, fieldColor(w, DIST_COLOR_STEPS), 0.35 + 0.6 * fade);
}
ctx.fillStyle = rgba(col, 1);
ctx.fillRect(b.ox + (i % b.cols) * b.cell, b.oy + Math.floor(i / b.cols) * b.cell, b.cell + 0.5, b.cell + 0.5);
}
drawFood(ctx, b, g.food, t);
const len = g.body.length;
for (let i = len - 1; i >= 0; i -= 1) {
const c = g.body[i];
drawSeg(ctx, b, c % b.cols, Math.floor(c / b.cols), mix(bodyColor(i, len), BG, g.dead ? 0.55 : 0.1), 0.12);
}
const head = g.body[0];
const hd = headDir(g);
drawEyes(ctx, b, head % b.cols, Math.floor(head / b.cols), hd[0], hd[1]);
// 머리가 읽는 이웃 값 중 가장 낮은 쪽으로 향하는 화살표
if (stepTo >= 0 && !g.dead) {
ctx.strokeStyle = rgba(CREAM, 0.95);
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(cellX(b, head), cellY(b, head));
ctx.lineTo(cellX(b, stepTo), cellY(b, stepTo));
ctx.stroke();
ctx.fillStyle = rgba(CREAM, 0.95);
ctx.beginPath();
ctx.arc(cellX(b, stepTo), cellY(b, stepTo), 3.5, 0, PI2);
ctx.fill();
}
},
label() {
if (g.dead) {
return TXT_DEAD;
}
return (moving ? TXT_WAVE : TXT_BANDS) + ' · ' + TXT_LEN + ' ' + g.body.length;
},
tap() {
moving = !moving;
},
key(k) {
let d = -1;
if (k === 'ArrowUp') {
d = 0;
}
else if (k === 'ArrowRight') {
d = 1;
}
else if (k === 'ArrowDown') {
d = 2;
}
else if (k === 'ArrowLeft') {
d = 3;
}
else {
return false;
}
const nb = neighborCell(b, g.food, d, false);
if (nb >= 0 && g.body.indexOf(nb) < 0) {
g.food = nb;
refreshField();
}
return true;
}
};
for (let i = 0; i < WARM_TICKS; i += 1) {
logic();
}
if (reduce) {
for (let i = 0; i < 4; i += 1) {
scene.step();
}
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./SkBfsDistanceWave.module.scss";
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 = '다음 장면';
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
key(k: string): boolean;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 팔레트 — 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG: RGB = [29, 26, 44];
const TILE_A: RGB = [37, 33, 58];
const TILE_B: RGB = [42, 38, 66];
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 glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: RGB, a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
// 둥근 사각형 경로 (roundRect 가 없는 브라우저에서도 같은 모양)
function rrPath(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.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// ---- 판과 뱀 ----
interface Board { cols: number; rows: number; cell: number; ox: number; oy: number; n: number; }
function makeBoard(rows: number): Board {
const cell = H / rows;
const cols = Math.floor(W / cell + 1e-6);
return { cols: cols, rows: rows, cell: cell, ox: (W - cols * cell) / 2, oy: 0, n: cols * rows };
}
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
// 칸 번호 y*cols+x. 판 밖이면 -1 (wrap 이면 반대편으로 이어진다)
function cellAt(b: Board, x: number, y: number, wrap: boolean): number {
let xx = x;
let yy = y;
if (wrap) {
xx = (x % b.cols + b.cols) % b.cols;
yy = (y % b.rows + b.rows) % b.rows;
} else if (x < 0 || y < 0 || x >= b.cols || y >= b.rows) {
return -1;
}
return yy * b.cols + xx;
}
function neighborCell(b: Board, i: number, d: number, wrap: boolean): number {
return cellAt(b, (i % b.cols) + DX[d], Math.floor(i / b.cols) + DY[d], wrap);
}
// 목표 칸에서 너비 우선으로 퍼지는 거리장. blocked 칸과 닿지 못하는 칸은 -1
function distField(b: Board, target: number, blocked: Uint8Array, wrap: boolean): Int16Array {
const dist = new Int16Array(b.n).fill(-1);
const queue = new Int16Array(b.n);
let qh = 0;
let qt = 0;
dist[target] = 0;
queue[qt] = target;
qt += 1;
while (qh < qt) {
const c = queue[qh];
qh += 1;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb >= 0 && dist[nb] < 0 && blocked[nb] === 0) {
dist[nb] = dist[c] + 1;
queue[qt] = nb;
qt += 1;
}
}
}
return dist;
}
// 거리장에서 값이 가장 낮은 이웃 (없으면 -1)
function lowestNeighbor(b: Board, dist: Int16Array, from: number, wrap: boolean): number {
let best = -1;
let bv = 32000;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, from, d, wrap);
if (nb >= 0 && dist[nb] >= 0 && dist[nb] < bv) {
bv = dist[nb];
best = nb;
}
}
return best;
}
function pathFrom(b: Board, dist: Int16Array, head: number, wrap: boolean): number[] {
const path: number[] = [];
let cur = head;
for (let k = 0; k < b.n; k += 1) {
const nx = lowestNeighbor(b, dist, cur, wrap);
if (nx < 0 || (dist[nx] >= dist[cur] && cur !== head)) {
return [];
}
path.push(nx);
if (dist[nx] === 0) {
return path;
}
cur = nx;
}
return [];
}
interface Game {
b: Board;
wrap: boolean;
body: number[];
prev: number[];
food: number;
rnd: () => number;
eaten: number;
dead: boolean;
resets: number;
}
function placeFood(g: Game): void {
const used = new Uint8Array(g.b.n);
for (let i = 0; i < g.body.length; i += 1) {
used[g.body[i]] = 1;
}
const free: number[] = [];
for (let i = 0; i < g.b.n; i += 1) {
if (used[i] === 0) {
free.push(i);
}
}
g.food = free.length > 0 ? free[Math.floor(g.rnd() * free.length)] : -1;
}
function resetGame(g: Game): void {
const b = g.b;
const hx = Math.floor(b.cols / 2);
const hy = Math.floor(b.rows / 2);
g.body = [hy * b.cols + hx, hy * b.cols + hx - 1, hy * b.cols + hx - 2];
g.prev = g.body.slice();
g.dead = false;
g.resets += 1;
placeFood(g);
}
function newGame(b: Board, wrap: boolean, seed: number): Game {
const g: Game = { b: b, wrap: wrap, body: [], prev: [], food: -1, rnd: lcg(seed), eaten: 0, dead: false, resets: -1 };
resetGame(g);
return g;
}
// 머리를 next 칸으로 옮긴다. 먹으면 true, 몸에 부딪히면 dead
function moveSnake(g: Game, next: number): boolean {
g.prev = g.body.slice();
const ate = next === g.food;
const tail = g.body[g.body.length - 1];
for (let i = 0; i < g.body.length; i += 1) {
if (g.body[i] === next && !(i === g.body.length - 1 && !ate && next === tail)) {
g.dead = true;
return false;
}
}
g.body.unshift(next);
if (ate) {
g.eaten += 1;
placeFood(g);
} else {
g.body.pop();
}
return ate;
}
// 경로를 끝까지 따라가 먹은 뒤의 가상 몸
function simulate(body: number[], path: number[]): number[] {
const v = body.slice();
for (let k = 0; k < path.length; k += 1) {
v.unshift(path[k]);
if (k < path.length - 1) {
v.pop();
}
}
return v;
}
// 가상 몸의 머리에서 번져 나가 꼬리에 닿는가. mark 를 주면 닿은 칸을 표시한다
function tailReachable(b: Board, v: number[], wrap: boolean, mark: Uint8Array | null): boolean {
const blocked = new Uint8Array(b.n);
for (let i = 1; i < v.length - 1; i += 1) {
blocked[v[i]] = 1;
}
const tail = v[v.length - 1];
const head = v[0];
const seen = new Uint8Array(b.n);
const stack: number[] = [head];
seen[head] = 1;
let hit = false;
while (stack.length > 0) {
const c = stack.pop() as number;
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, c, d, wrap);
if (nb < 0 || seen[nb] === 1) {
continue;
}
if (nb === tail) {
hit = true;
continue;
}
if (blocked[nb] === 1 || nb === head) {
continue;
}
seen[nb] = 1;
stack.push(nb);
}
}
if (mark) {
mark.set(seen);
}
return hit;
}
interface Plan { next: number; path: number[]; rejected: number[]; trap: Uint8Array; chase: boolean; }
// 한 수를 정한다: 먹이까지 최단 경로 → (안전 검사) → 막히면 꼬리를 쫓는다
function planMove(g: Game, safeOn: boolean): Plan {
const b = g.b;
const head = g.body[0];
const tail = g.body[g.body.length - 1];
const plan: Plan = { next: -1, path: [], rejected: [], trap: new Uint8Array(b.n), chase: false };
const blocked = new Uint8Array(b.n);
for (let i = 0; i < g.body.length - 1; i += 1) {
blocked[g.body[i]] = 1;
}
if (g.food >= 0) {
const dist = distField(b, g.food, blocked, g.wrap);
const path = pathFrom(b, dist, head, g.wrap);
plan.path = path;
if (path.length > 0) {
if (!safeOn) {
plan.next = path[0];
return plan;
}
const v = simulate(g.body, path);
if (tailReachable(b, v, g.wrap, plan.trap)) {
plan.next = path[0];
return plan;
}
plan.rejected = path;
}
}
plan.chase = true;
const dist2 = distField(b, tail, blocked, g.wrap);
const nx = lowestNeighbor(b, dist2, head, g.wrap);
if (nx >= 0) {
plan.next = nx;
return plan;
}
for (let d = 0; d < 4; d += 1) {
const nb = neighborCell(b, head, d, g.wrap);
if (nb >= 0 && blocked[nb] === 0 && nb !== head) {
plan.next = nb;
return plan;
}
}
return plan;
}
// 칸 중심의 화면 좌표
function cellX(b: Board, i: number): number { return b.ox + ((i % b.cols) + 0.5) * b.cell; }
function cellY(b: Board, i: number): number { return b.oy + (Math.floor(i / b.cols) + 0.5) * b.cell; }
function wrapDelta(a: number, c: number, n: number, wrap: boolean): number {
let d = c - a;
if (wrap) {
if (d > n / 2) { d -= n; }
if (d < -n / 2) { d += n; }
}
return d;
}
// 마디 i 의 보간 위치 (칸 좌표). 지난 틱의 자리에서 이번 틱의 자리로 p(0~1) 만큼
function segPos(g: Game, i: number, p: number): [number, number] {
const b = g.b;
const cur = g.body[i];
const pv = i < g.prev.length ? g.prev[i] : cur;
const cx = cur % b.cols;
const cy = Math.floor(cur / b.cols);
const px = pv % b.cols;
const py = Math.floor(pv / b.cols);
return [px + wrapDelta(px, cx, b.cols, g.wrap) * p, py + wrapDelta(py, cy, b.rows, g.wrap) * p];
}
function drawTiles(ctx: CanvasRenderingContext2D, b: Board): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
for (let y = 0; y < b.rows; y += 1) {
for (let x = 0; x < b.cols; x += 1) {
ctx.fillStyle = rgba((x + y) % 2 === 0 ? TILE_A : TILE_B, 1);
ctx.fillRect(b.ox + x * b.cell, b.oy + y * b.cell, b.cell + 0.5, b.cell + 0.5);
}
}
}
function drawFood(ctx: CanvasRenderingContext2D, b: Board, food: number, t: number): void {
if (food < 0) {
return;
}
const x = cellX(b, food);
const y = cellY(b, food);
const pulse = 0.5 + 0.5 * Math.sin(t * 0.12);
glow(ctx, x, y, b.cell * (1.3 + 0.35 * pulse), PINK, 0.55);
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.arc(x, y, b.cell * 0.3, 0, PI2);
ctx.fill();
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.arc(x - b.cell * 0.08, y - b.cell * 0.09, b.cell * 0.09, 0, PI2);
ctx.fill();
}
function bodyColor(i: number, len: number): RGB {
return mix(MINT, SKY, len <= 1 ? 0 : i / (len - 1));
}
// 칸 중심 (x,y 칸 좌표, 소수 허용)에 마디 하나
function drawSeg(ctx: CanvasRenderingContext2D, b: Board, gx: number, gy: number, c: RGB, shrink: number): void {
const s = b.cell * (1 - shrink);
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
ctx.fillStyle = rgba(c, 1);
rrPath(ctx, cx - s / 2, cy - s / 2, s, s, s * 0.32);
ctx.fill();
}
function drawEyes(ctx: CanvasRenderingContext2D, b: Board, gx: number, gy: number, dirx: number, diry: number): void {
const cx = b.ox + (gx + 0.5) * b.cell;
const cy = b.oy + (gy + 0.5) * b.cell;
const px = -diry;
const py = dirx;
const r = b.cell * 0.09;
ctx.fillStyle = rgba(BG, 1);
for (let s = -1; s <= 1; s += 2) {
ctx.beginPath();
ctx.arc(cx + dirx * b.cell * 0.16 + px * s * b.cell * 0.18, cy + diry * b.cell * 0.16 + py * s * b.cell * 0.18, r, 0, PI2);
ctx.fill();
}
}
function headDir(g: Game): [number, number] {
if (g.body.length < 2) {
return [1, 0];
}
const b = g.b;
const dx = wrapDelta(g.body[1] % b.cols, g.body[0] % b.cols, b.cols, g.wrap);
const dy = wrapDelta(Math.floor(g.body[1] / b.cols), Math.floor(g.body[0] / b.cols), b.rows, g.wrap);
return [clamp(dx, -1, 1), clamp(dy, -1, 1)];
}
// 보간 없이 칸 그대로 그리는 기본 뱀
function drawSnakeCells(ctx: CanvasRenderingContext2D, g: Game, dim: number): void {
const len = g.body.length;
for (let i = len - 1; i >= 0; i -= 1) {
const c = g.body[i];
const col = mix(bodyColor(i, len), BG, dim);
drawSeg(ctx, g.b, c % g.b.cols, Math.floor(c / g.b.cols), col, 0.08);
}
const hd = headDir(g);
drawEyes(ctx, g.b, g.body[0] % g.b.cols, Math.floor(g.body[0] / g.b.cols), hd[0], hd[1]);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement, make: Maker): () => void {
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: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = 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);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
// 움직임 줄이기에서는 루프가 없으니 입력 직후 한 틱쯤 앞으로 보내 바로 그린다
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 8);
show();
}
}
function toLocal(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;
}
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');
advance(1400);
show();
}
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
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, 'pointermove', function (e: Event): void {
toLocal(e as PointerEvent);
refresh();
});
listen(canvas, 'pointerdown', function (e: Event): void {
toLocal(e as PointerEvent);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
// 이 장면이 쓰는 키만 막는다. 쓰지 않는 키(Tab 등)는 그대로 둔다
if (scene.key(k)) {
e.preventDefault();
refresh();
}
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const TICK_MS = 100;
const GRID_N = 16;
const WAVE_K = 0.55;
const WAVE_SPEED = 0.09;
const DIST_COLOR_STEPS = 6;
const WARM_TICKS = 0;
const TXT_WAVE = '거리장 물결';
const TXT_BANDS = '물결 정지';
const TXT_LEN = '길이';
const TXT_DEAD = '갇혀서 끝 — 다시 시작';
const FIELD_RAMP: RGB[] = [[48, 44, 84], [96, 92, 168], LILAC, SKY, MINT, CREAM];
function fieldColor(w: number, steps: number): RGB {
const v = Math.floor(clamp(w, 0, 0.999) * steps) / (steps - 1);
const f = clamp(v, 0, 1) * (FIELD_RAMP.length - 1);
const i = Math.min(FIELD_RAMP.length - 2, Math.floor(f));
return mix(FIELD_RAMP[i], FIELD_RAMP[i + 1], f - i);
}
function makeScene(inp: Input, reduce: boolean): Scene {
const b = makeBoard(GRID_N);
const g = newGame(b, false, 455909);
let dist: Int16Array = new Int16Array(b.n);
let acc = 0;
let t = 0;
let phase = 0;
let moving = true;
let hold = 0;
let stepTo = -1;
function refreshField(): void {
const blocked = new Uint8Array(b.n);
for (let i = 0; i < g.body.length - 1; i += 1) {
blocked[g.body[i]] = 1;
}
if (g.food >= 0) {
dist = distField(b, g.food, blocked, false);
}
stepTo = lowestNeighbor(b, dist, g.body[0], false);
}
function logic(): void {
if (hold > 0) {
hold -= 1;
if (hold === 0) {
resetGame(g);
refreshField();
}
return;
}
refreshField();
if (stepTo < 0) {
g.dead = true;
hold = 8;
return;
}
moveSnake(g, stepTo);
refreshField();
}
refreshField();
const scene: Scene = {
step(): void {
t += 1;
if (moving) {
phase += WAVE_SPEED;
}
acc += FIXED_DT;
while (acc >= TICK_MS) {
acc -= TICK_MS;
logic();
}
},
draw(ctx: CanvasRenderingContext2D): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
for (let i = 0; i < b.n; i += 1) {
const d = dist[i];
let col: RGB = (i % b.cols + Math.floor(i / b.cols)) % 2 === 0 ? TILE_A : TILE_B;
if (d >= 0) {
// 먹이에서 멀어질수록 파동이 옅어진다
const w = 0.5 + 0.5 * Math.cos(d * WAVE_K - phase);
const fade = clamp(1 - d / 38, 0.25, 1);
col = mix(col, fieldColor(w, DIST_COLOR_STEPS), 0.35 + 0.6 * fade);
}
ctx.fillStyle = rgba(col, 1);
ctx.fillRect(b.ox + (i % b.cols) * b.cell, b.oy + Math.floor(i / b.cols) * b.cell, b.cell + 0.5, b.cell + 0.5);
}
drawFood(ctx, b, g.food, t);
const len = g.body.length;
for (let i = len - 1; i >= 0; i -= 1) {
const c = g.body[i];
drawSeg(ctx, b, c % b.cols, Math.floor(c / b.cols), mix(bodyColor(i, len), BG, g.dead ? 0.55 : 0.1), 0.12);
}
const head = g.body[0];
const hd = headDir(g);
drawEyes(ctx, b, head % b.cols, Math.floor(head / b.cols), hd[0], hd[1]);
// 머리가 읽는 이웃 값 중 가장 낮은 쪽으로 향하는 화살표
if (stepTo >= 0 && !g.dead) {
ctx.strokeStyle = rgba(CREAM, 0.95);
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(cellX(b, head), cellY(b, head));
ctx.lineTo(cellX(b, stepTo), cellY(b, stepTo));
ctx.stroke();
ctx.fillStyle = rgba(CREAM, 0.95);
ctx.beginPath();
ctx.arc(cellX(b, stepTo), cellY(b, stepTo), 3.5, 0, PI2);
ctx.fill();
}
},
label(): string {
if (g.dead) {
return TXT_DEAD;
}
return (moving ? TXT_WAVE : TXT_BANDS) + ' · ' + TXT_LEN + ' ' + g.body.length;
},
tap(): void {
moving = !moving;
},
key(k: string): boolean {
let d = -1;
if (k === 'ArrowUp') { d = 0; }
else if (k === 'ArrowRight') { d = 1; }
else if (k === 'ArrowDown') { d = 2; }
else if (k === 'ArrowLeft') { d = 3; }
else { return false; }
const nb = neighborCell(b, g.food, d, false);
if (nb >= 0 && g.body.indexOf(nb) < 0) {
g.food = nb;
refreshField();
}
return true;
}
};
for (let i = 0; i < WARM_TICKS; i += 1) {
logic();
}
if (reduce) {
for (let i = 0; i < 4; i += 1) {
scene.step();
}
}
return scene;
}
export default function SkBfsDistanceWave() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="먹이에서 퍼지는 거리장 물결과 그 비탈을 타고 내려가는 뱀" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">거리장 물결</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>먹이에서 퍼지는 거리장 물결과 그 비탈을 타고 내려가는 뱀</p>
</div>
</main>
);
}09 · 먹이에서 퍼지는 거리장 물결과 그 비탈을 타고 내려가는 뱀 — 측정 누적면적 73.622% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function refreshField() {
const blocked = new Uint8Array(b.n);
for (let i = 0; i < g.body.length - 1; i += 1) {
blocked[g.body[i]] = 1;
}
if (g.food >= 0) {
dist = distField(b, g.food, blocked, false);
}
stepTo = lowestNeighbor(b, dist, g.body[0], false);
}
값이 뱀의 인상을 바꾸는 곳 — 틱 간격·판 크기·몸통 보간
값 하나로 뱀이 달리 보이는 곳은 셋이다. 이 절의 숫자는 브라우저 캡처가 아니라 소스의 함수를 노드에서 직접 호출해 센 값이라, 맨 아래 표의 렌더 측정과는 따로 읽는다.
틱 간격을 90ms 에서 50ms 로 줄이면 같은 10초에 먹이를 세 배 먹는다
01 의 TICK_MS 만 90 에서 50 으로 바꾸고 같은 상태에서 10초를 돌리면 길이가 54에서 57로 느는 대신 54에서 63으로 는다. 먹이가 3개에서 9개로 세 배가 된 셈이다. 버린 길은 두 설정 모두 2건이라 판단은 그대로이고 속도만 달라진다.
판을 8줄에서 12줄로 키우면 가득 채우는 데 5.9배가 걸린다
02 의 GRID_N 을 8 에서 12 로 올리면 96칸이 228칸이 된다. 처음부터 가득 채우는 데 1,818틱이 아니라 10,665틱이 들고, 60ms 틱으로 따지면 109초가 640초가 된다. 지름길은 171번에서 811번으로 늘지만 순환 한 바퀴가 길어진 효과가 더 크다.
시드 여섯 개를 돌려도 모두 처음부터 가득 채웠고 걸린 틱은 1,818~2,113 이었다. 지름길을 끄면(마진 999) 2,311틱으로 27% 늘고, SHORTCUT_MARGIN 을 0·2·6 으로 두면 1,948·1,818·1,885틱이라 기본값 2 가 가장 짧았다.
보간 비율을 0.35 에서 0.12 로 낮추면 몸이 한 칸 늦게 끌려온다
03 의 BODY_LERP 가 0.35 일 때 그린 마디가 논리 칸 중심에서 뒤처지는 평균 거리는 8.5px, 칸 폭(25px)의 0.34칸이다. 0.12 로 낮추면 26.8px, 1.07칸이 되어 몸이 머리보다 한 칸쯤 늦게 따라온다. 1 로 두면 px[i] = tx 와 같아져 보간이 없는 것과 같은 그림이 나온다.
아홉 장면을 렌더해 잰 값은 다음 표다.
| 항목 | 누적면적 | 평균 강도 | 움직인 프레임 |
|---|---|---|---|
| 01 안전 검사 자동 조종 | 49.8% | 114.0 | 23/23 |
| 02 해밀턴 순환 | 61.4% | 90.0 | 17/23 |
| 03 보간 몸통 | 18.5% | 78.6 | 23/23 |
| 04 사슬 줄 | 7.7% | 50.4 | 23/23 |
| 05 열 궤적 | 22.8% | 62.6 | 23/23 |
| 06 원환 | 20.9% | 95.3 | 23/23 |
| 07 비늘 가죽 | 41.1% | 59.9 | 23/23 |
| 08 먹이 폭발 | 20.1% | 100.2 | 23/23 |
| 09 거리장 물결 | 73.6% | 67.0 | 23/23 |
어디서 깨지는가 — 판이 가득 찰 때·벽 통과·움직임 줄이기 설정
검사를 끄면 같은 시드에서 600초에 6번 갇힌다
01 의 SAFE_CHECK 를 켠 채 600초(길이 209, 버린 길 44건)를 돌리면 갇힘이 0회이고 끄면 같은 시드에서 6회다. 다만 검사는 첫 먹이를 먹은 뒤 꼬리에 닿는지까지만 보고 그다음 먹이는 내다보지 않으므로, 0회는 이 시드와 600초에서 나온 실측일 뿐 갇히지 않는다는 증명이 아니다. 검사 없이 거리장만 따르는 09 도 600초에 6번 갇혔고 03·08 은 0회였다.
순환은 줄 수가 짝수일 때만 닫힌다
02 의 순환은 맨 윗줄을 가로지르고 나머지 줄을 지그재그로 돌아 왼쪽 열을 올라오게 만든다. 7줄 판(11×7=77칸)으로 바꿔 순환을 만들어 보니 한 곳이 이웃하지 않는 칸으로 이어져, 게임은 돌아가도 머리가 그 자리에서 건너뛴다. 줄 수가 짝수인 판을 써야 하는 이유다.
제약 반복이 모자라면 사슬이 늘어난다
04 의 FOLLOW_ITER 가 4 이면 이웃 마디 간격이 평균 9.04px, 최대 10.46px 이고, 1 로 줄이면 평균 9.18px, 최대 12.67px 라 목표 간격 9px 의 1.4배까지 늘어진다. 사슬끼리 부딪히는 검사는 없어서 몸이 자기 몸을 지나가도 막지 않는다.
점수 칸은 네 자리라 10000 에서 0000 으로 돈다
08 의 digits() 는 마지막 네 글자만 자르므로 점수가 10000 이 되면 칸에는 0000 이 보이고 상태 줄에는 10000 이 적힌다. 점수 상한을 정하거나 자릿수를 늘리는 쪽은 쓰는 사람의 몫이다.
움직임 줄이기에서는 입력이 있어야 화면이 바뀐다
prefers-reduced-motion 이 켜져 있으면 아홉 장면 모두 requestAnimationFrame 루프를 시작하지 않고 1,400ms 앞으로 보낸 한 장만 그린다. 02 는 판이 가득 찬 상태를 그대로 두며 탭으로만 새 판을 연다.
여기까지 읽고 값을 직접 바꿔 보고 싶다면 아래 카드에서 압축 파일을 받고, 열 때 쓰는 비밀번호는 9tzpwtwu 이다. 장면마다 HTML·SCSS 한 쌍과 리액트 판이 함께 담겨 있다.
변형 세 가지와 접근성
| 이름 | 값 | 느낌 |
|---|---|---|
| 틱 간격 바꾸기 | 01 TICK_MS 90 → 50 | 같은 상태에서 10초를 돌리면 90ms 는 길이 54→57, 50ms 는 54→63 으로 먹이를 세 배 먹는다. 버린 길은 둘 다 2건이라 판단은 같고 속도만 달라진다. |
| 판 크기 바꾸기 | 02 GRID_N 8 → 12 | 판이 96칸에서 228칸으로 늘면 처음부터 가득 채우는 데 1,818틱이 아니라 10,665틱(약 5.9배)이 걸린다. 60ms 틱이면 109초가 640초가 된다. |
| 몸통 보간 세기 바꾸기 | 03 BODY_LERP 0.35 → 0.12 | 그린 몸통이 논리 칸 중심에서 뒤처지는 평균 거리가 8.5px(0.34칸)에서 26.8px(1.07칸)로 벌어져, 몸이 머리보다 한 칸 가까이 늦게 끌려온다. |
세 변형의 숫자도 위 값 절과 같은 방식으로 소스 함수를 노드에서 직접 호출해 센 것이다.
입력 처리는 아홉 파일이 같은 뼈대를 나눠 쓴다. 장면마다 <button> 이 하나 있고 캔버스는 tabindex="0" 과 aria-label 을 갖는다. Enter·Space·포인터 누름은 장면의 탭 동작으로 이어지고 preventDefault 로 Space 가 페이지를 내리지 못하게 막는다. 방향키를 쓰는 장면은 04 와 09 뿐이라 이 둘에서만 방향키의 기본 동작을 막고, 나머지 일곱 장면에서는 키를 가로채지 않으니 Tab 으로 캔버스에서 버튼으로 넘어간다. 상태 줄은 aria-live="polite" 로 길이·버린 길·점수가 바뀔 때만 갱신된다.
prefers-reduced-motion 이 켜져 있으면 버튼이 다음 장면 으로 바뀌어 누를 때마다 900ms 씩, 포인터 이동이나 키 입력은 128ms 씩 앞으로 보낸 뒤 바로 다시 그린다. Enter 와 Space 는 아홉 장면 모두 그 장면의 탭 동작을 한 번 실행하고, 그 직후 128ms 를 앞으로 보내 다시 그린다.
snake game code html css js 는 라이브러리 없이 충분한가?
snake game code html css js 를 찾는 사람이 보는 소스로는 드물게, 이 편의 아홉 파일에는 라이브러리·CDN·WebGL 이 없고 Math.random·Date·fetch·localStorage 도 없다. 길찾기와 순환과 보간과 입자는 모두 requestAnimationFrame 과 캔버스 2D 만으로 돌아가므로, 뱀이 스스로 움직이는 장면까지는 순수 자바스크립트로 충분하다.
부족한 것은 게임을 서비스로 만들 때의 부분이다. 최고 기록 저장이나 효과음은 없고 사람이 방향키로 조작하는 입력도 04 와 09 의 약한 형태뿐이다. 벽돌이 부서지는 반사각까지 가려면 벽돌깨기 게임 만들기 9가지 — 반사각은 맞은 위치가 정한다가, 블록 쌓기 규칙까지 가려면 HTML 테트리스 코드 — 캔버스로 조립하는 9단계가 그다음 걸음이다. 중력과 충돌 물리가 더 궁금하면 바운싱 볼 애니메이션 9가지 — 충돌·중력 물리 계산을 보면 된다.
snake game html css js github 코드를 가져다 쓰기 전에 자주 묻는 질문
snake game html css js github 저장소에 이 소스를 그대로 올려도 되나?
외부 코드를 끌어 쓰지 않아 올릴 때 따라붙는 의존성 목록이 없다. 파일마다 index.html 하나에 스타일과 스크립트가 들어 있어 붙여 넣으면 돌아가고, 난수가 시드로 고정이라 같은 화면이 재현된다. 라이선스 문구나 저장소 규칙은 올리는 쪽에서 따로 정해야 한다.
먹이 자리를 매번 다르게 하려면 어디를 바꾸나?
01 은 newGame(b, false, 455101) 의 마지막 숫자가 lcg 의 시드이고, 02·03·05·06 도 같은 꼴이라 숫자를 바꾸면 다른 판이 나온다. 매번 무작위로 하고 싶으면 그 자리에 쓸 값을 직접 만들어 넣어야 하고, 그러면 같은 화면이 재현되지 않는다.
판을 더 크게 잡으면 느려지나?
02 는 판이 커지면 가득 채우는 틱 수가 5.9배로 늘고, 한 틱에는 순환 위치 비교와 이웃 네 칸 검사만 돈다. 01 은 매 틱 칸 수만큼 너비 우선 탐색을 다시 돌리므로 칸이 많아질수록 틱당 일이 늘어날 것이다. 키운 판에서 프레임 시간은 재 보지 않았다.









