03A Snake Body That Glides by Pixels Between Ticks Instead of Jumping by Cells
Keep the logic on cell ticks and let only the drawn position chase each logic cell center by the BODY_LERP ratio every frame, removing the jerk of cell steps.
Best forMobile game screens where smooth motion matters
BODY_LERPTICK_MSTUBE_FRACTRAIL_LEN

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="A Snake Body That Glides by Pixels Between Ticks Instead of Jumping by Cells"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">Interpolation on</p>
<button class="sc__btn" id="scBtn" type="button">Pause</button>
<p class="sc__caption">A Snake Body That Glides by Pixels Between Ticks Instead of Jumping by Cells</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 = 'Pause';
const BTN_PLAY = 'Play';
const BTN_NEXT = 'Next scene';
// 파스텔 팔레트 — 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
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;
}
// Segments 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 칸 좌표, 소수 허용)에 Segments 하나
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 = 'Interpolation on';
const TXT_SNAP = 'Interpolation off';
const TXT_LEN = 'Length';
const TXT_DEAD = 'Trapped — restarting';
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 A Snake Body That Glides by Pixels Between Ticks Instead of Jumping by Cells — Measured area 18.462% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS Snake Game: 9 Boards That Find Their Own Path
The post this demo lives in
CSS Snake Game: 9 Boards That Find Their Own PathOpen a css snake game and the snake already turns toward the food before you touch a key — scene 01's shortest path and See all demos in this post


