07A Spider Walking on Two Alternating Sets of Four Legs
Eight legs split into two sets of four, one set planted while the other moves, and the body rises and dips to match the average of the feet on the ground.
Best forFall event banner
GAIT_SETSSTEP_DISTBODY_CLEAR

<main class="stage">
<div class="cf is-demo" id="cf">
<canvas class="cf__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="A spider walking over bumpy ground, moving its eight legs in two alternating sets of four."></canvas>
<button class="cf__pause" data-role="pause" type="button" aria-pressed="false">Pause</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.cf {
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-cream;
}
.cf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cf__pause {
position: absolute;
right: $sp-3;
bottom: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.cf__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.cf__pause:hover {
transform: translateY(-2px);
}
.cf__pause:active {
transform: translateY(1px);
}
.cf__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 어떤 행동 중인지 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.cf__status {
position: absolute;
left: $sp-3;
top: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
pointer-events: none;
}
@media (max-width: 340px) {
.cf__pause,
.cf__status { font-size: 11px; padding: $sp-1 $sp-2; }
}
// 동작 줄이기 — 장면은 스크립트가 쉬는 자세 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.cf__pause { transition: none !important; }
.cf__pause:hover,
.cf__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cf {
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: #fff7e6;
}
.cf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cf__pause {
position: absolute;
right: 12px;
bottom: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cf__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.cf__pause:hover {
transform: translateY(-2px);
}
.cf__pause:active {
transform: translateY(1px);
}
.cf__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.cf__status {
position: absolute;
left: 12px;
top: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
pointer-events: none;
}
@media (max-width: 340px) {
.cf__pause,
.cf__status {
font-size: 11px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cf__pause {
transition: none !important;
}
.cf__pause:hover,
.cf__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("cf");
if (!root) return;
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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 wrapAngle(a) {
let r = a;
while (r > Math.PI) {
r -= TAU;
}
while (r < -Math.PI) {
r += TAU;
}
return r;
}
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate, dt) { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 흔들림·배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 1차원 값 노이즈 — 정수 자리마다 시드로 정한 값(-1~1)을 두고 사이는 부드러운 보간. 흔들림·배회 방향에 쓴다
const NOISE_TABLE = new Float32Array(256);
const noiseRng = makeRng(480);
for (let i = 0; i < 256; i += 1) {
NOISE_TABLE[i] = noiseRng() * 2 - 1;
}
function smoothNoise(x) {
const i = Math.floor(x);
const f = x - i;
const u = f * f * (3 - 2 * f);
return lerp(NOISE_TABLE[i & 255], NOISE_TABLE[(i + 1) & 255], u);
}
// 8자 길 — 가로로 한 번(sin θ), 세로로 두 번(sin 2θ) 흔들리는 점. 판 가운데를 지나는 두 고리를 period 초에 한 바퀴
function figureEight(t, ax, ay, period) {
const a = TAU * t / period;
return [W / 2 + ax * Math.sin(a), H / 2 + ay * Math.sin(2 * a)];
}
// 두 마디 다리 — 엉덩이(hx, hy)에서 발(fx, fy)까지 거리 d, 허벅지 길이 a, 정강이 길이 b.
// 엉덩이 쪽 무릎 각 A 는 코사인 관계 cos A = (a² + d² − b²) / (2·a·d) 로 푼다.
// guard 가 참이면 d 를 [|a − b|, a + b] 안으로 잘라 cos A 가 −1~1 을 벗어나지 않게 한다 — 거짓이면 발이 닿지 않는 거리에서 Math.acos 가 NaN 을 낸다.
// bend 가 +1 이면 무릎이 엉덩이→발 선의 시계 방향 쪽, −1 이면 반대쪽으로 꺾인다(굽는 방향 고정).
// out[0], out[1] = 무릎 자리, out[2], out[3] = 그려질 발 자리(닿지 않으면 정강이 끝). 돌려주는 값 = Math.acos 에 넣은 cos A
function solveKnee(hx, hy, fx, fy, a, b, bend, guard, out) {
const raw = Math.hypot(fx - hx, fy - hy);
const d = guard ? clamp(raw, Math.abs(a - b) + 0.001, a + b - 0.001) : raw;
const cosA = (a * a + d * d - b * b) / (2 * a * d);
const ang = Math.atan2(fy - hy, fx - hx) + bend * Math.acos(cosA);
const kx = hx + Math.cos(ang) * a;
const ky = hy + Math.sin(ang) * a;
const shin = Math.atan2(fy - ky, fx - kx);
out[0] = kx;
out[1] = ky;
out[2] = kx + Math.cos(shin) * b;
out[3] = ky + Math.sin(shin) * b;
return cosA;
}
// 파스텔 팔레트 — 크림·옅은 민트 바탕 위에 파스텔 생물, 윤곽선과 그림자는 진한 잉크색으로 명도 차를 둔다
const PAPER = [251, 247, 238];
const PAPER_LOW = [222, 242, 234];
const INK = [58, 50, 74];
const CREAM = [255, 251, 242];
const CORAL = [244, 162, 144];
const LAVENDER = [188, 170, 236];
const BUTTER = [246, 222, 140];
const SAGE = [156, 204, 164];
const PINK = [244, 176, 200];
const SKY = [156, 204, 236];
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function shade(c, k) { return [c[0] * k, c[1] * k, c[2] * k]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
// 목표점(판 좌표)과 그 속도(px/초) — 8자 길 또는 포인터. 그리기에서 눈동자 방향·목표 표시에 쓴다
let tx = W / 2;
let ty = H / 2;
let tvx = 0;
let tvy = 0;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearStage(top, bottom) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bottom, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 점들을 지나는 매끈한 닫힌 곡선 — 이웃 두 점의 가운데를 잇고 점 자체는 2차 곡선의 조절점으로 쓴다
function smoothClosed(pts) {
const n = pts.length / 2;
ctx.beginPath();
ctx.moveTo((pts[2 * (n - 1)] + pts[0]) / 2, (pts[2 * (n - 1) + 1] + pts[1]) / 2);
for (let i = 0; i < n; i += 1) {
const j = (i + 1) % n;
ctx.quadraticCurveTo(pts[2 * i], pts[2 * i + 1], (pts[2 * i] + pts[2 * j]) / 2, (pts[2 * i + 1] + pts[2 * j + 1]) / 2);
}
ctx.closePath();
}
function fillShape(pts, fill, line) {
smoothClosed(pts);
ctx.fillStyle = rgba(fill, 1);
ctx.fill();
if (line > 0) {
ctx.lineWidth = line;
ctx.lineJoin = 'round';
ctx.strokeStyle = rgba(INK, 1);
ctx.stroke();
}
}
// 바닥 그림자 — 같은 윤곽을 (ox, oy) 만큼 밀어 옅은 잉크색으로
function dropShadow(pts, ox, oy, alpha) {
ctx.save();
ctx.translate(ox, oy);
smoothClosed(pts);
ctx.fillStyle = rgba(INK, alpha);
ctx.fill();
ctx.restore();
}
// 눈 — 흰자 위 눈동자를 (lookX, lookY) 쪽으로 반지름의 0.4 만큼 민다. open 0~1 은 위 눈꺼풀이 덮는 정도의 반대
function drawEye(x, y, r, lookX, lookY, open) {
const a = Math.atan2(lookY - y, lookX - x);
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(CREAM, 1);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 1);
ctx.stroke();
ctx.beginPath();
ctx.arc(x + Math.cos(a) * r * 0.4, y + Math.sin(a) * r * 0.4, r * 0.55, 0, TAU);
ctx.fillStyle = rgba(INK, 1);
ctx.fill();
if (open < 1) {
ctx.save();
ctx.beginPath();
ctx.arc(x, y, r + 0.5, 0, TAU);
ctx.clip();
ctx.fillStyle = rgba(INK, 0.85);
ctx.fillRect(x - r - 1, y - r - 1, 2 * r + 2, (2 * r + 2) * (1 - open));
ctx.restore();
}
}
// 목표 표시 — 잉크색 고리와 점. 생물이 무엇을 쫓는지 보이게
function drawTarget(x, y) {
// 동작 줄이기의 쉬는 자세에서는 포인터가 올라왔을 때만 보인다
if (still && !ptrOn) {
return;
}
ctx.beginPath();
ctx.arc(x, y, 7, 0, TAU);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(INK, 0.55);
ctx.stroke();
ctx.beginPath();
ctx.arc(x, y, 2.5, 0, TAU);
ctx.fillStyle = rgba(INK, 0.7);
ctx.fill();
}
// 07 거미 — 다리 8개를 네 개씩 두 묶음(GAIT_SETS)으로 나눠, 한 묶음이 발을 드는 동안 다른 묶음은 땅을 딛는다.
// 화면은 비스듬히 내려다본 모습 — 높이 z 는 화면 위쪽으로 z 만큼 올려 그린다
const SPIDER_SPEED = 110;
const SPIDER_TURN = 2.4;
// 다리 — 몸 가운데에서 LEG_REACH(px) 떨어진 자리가 발을 놓을 곳. 허벅지 LEG_UPPER, 정강이 LEG_LOWER
const LEG_REACH = 62;
const LEG_UPPER = 42;
const LEG_LOWER = 46;
const HIP_R = 12;
// 한 걸음 — 묶음 안 어느 발이든 놓을 곳에서 STEP_DIST(px) 넘게 밀리면 그 묶음이 STEP_TIME 초 동안 높이 STEP_HIGH 로 한꺼번에 옮긴다
const STEP_DIST = 30;
const STEP_TIME = 0.14;
const STEP_HIGH = 14;
// 몸 높이 = 땅을 딛은 발들의 땅 높이 평균 + BODY_CLEAR(px)
const BODY_CLEAR = 22;
// 언덕 — 땅 높이(px) = GROUND_AMP × (두 사인 물결의 합)
const GROUND_AMP = 9;
const PREWARM = 3;
function pathAt(t) { return figureEight(t, 160, 70, 9); }
// 다리 방향(몸 진행 방향 기준, 라디안) — 앞에서 뒤로 네 쌍. 오른쪽은 +, 왼쪽은 −
const LEG_ANGLES = [0.5, 1.1, 1.85, 2.55];
// 묶음 — 0 = 왼1·오2·왼3·오4, 1 = 오1·왼2·오3·왼4 (이웃 다리는 늘 다른 묶음)
const GAIT_SETS = 2;
const spiderLegs = [];
for (let i = 0; i < 4; i += 1) {
for (let s = -1; s <= 1; s += 2) {
spiderLegs.push({ ang: s * LEG_ANGLES[i], set: (i + (s > 0 ? 1 : 0)) % GAIT_SETS, fx: 0, fy: 0, sx0: 0, sy0: 0, ex: 0, ey: 0, t: -1, lift: 0 });
}
}
let bx = W / 2;
let by = H / 2;
let bz = BODY_CLEAR;
let bHeading = 0;
let bSpeed = 0;
let bvx = 0;
let bvy = 0;
// 지금 발을 든 묶음(−1 = 없음)과 다음 차례 묶음, 지금까지 바뀐 횟수
let movingSet = -1;
let nextSet = 0;
let setSwaps = 0;
function groundAt(x, y) {
return GROUND_AMP * (Math.sin(x * 0.024 + 0.7) * Math.cos(y * 0.031) + 0.6 * Math.sin((x + y) * 0.017 + 2.1));
}
function legHome(g) {
const a = bHeading + g.ang;
return [bx + Math.cos(a) * LEG_REACH, by + Math.sin(a) * LEG_REACH];
}
function plantSpider() {
for (let k = 0; k < spiderLegs.length; k += 1) {
const h = legHome(spiderLegs[k]);
spiderLegs[k].fx = h[0];
spiderLegs[k].fy = h[1];
spiderLegs[k].t = -1;
spiderLegs[k].lift = 0;
}
movingSet = -1;
}
function plantedHeight() {
let sum = 0;
let n = 0;
for (let k = 0; k < spiderLegs.length; k += 1) {
if (spiderLegs[k].t < 0) {
sum += groundAt(spiderLegs[k].fx, spiderLegs[k].fy);
n += 1;
}
}
return n > 0 ? sum / n : 0;
}
function reset() {
const p = pathAt(0);
bx = p[0] - 40;
by = p[1];
bHeading = 0;
bSpeed = 0;
bvx = 0;
bvy = 0;
nextSet = 0;
setSwaps = 0;
plantSpider();
bz = plantedHeight() + BODY_CLEAR;
}
function update(dt, tx, ty, tvx, tvy) {
const dx = tx - bx;
const dy = ty - by;
const dist = Math.hypot(dx, dy);
bHeading = wrapAngle(bHeading + clamp(wrapAngle(Math.atan2(dy, dx) - bHeading), -SPIDER_TURN * dt, SPIDER_TURN * dt));
bSpeed = lerp(bSpeed, SPIDER_SPEED * clamp((dist - 15) / 70, 0, 1), follow(4, dt));
bvx = Math.cos(bHeading) * bSpeed;
bvy = Math.sin(bHeading) * bSpeed;
bx += bvx * dt;
by += bvy * dt;
if (movingSet >= 0) {
let done = true;
for (let k = 0; k < spiderLegs.length; k += 1) {
const g = spiderLegs[k];
if (g.set !== movingSet || g.t < 0) {
continue;
}
g.t += dt / STEP_TIME;
const s = smooth(g.t);
g.fx = lerp(g.sx0, g.ex, s);
g.fy = lerp(g.sy0, g.ey, s);
g.lift = Math.sin(Math.PI * clamp(g.t, 0, 1)) * STEP_HIGH;
if (g.t >= 1) {
g.t = -1;
g.lift = 0;
}
else {
done = false;
}
}
if (done) {
movingSet = -1;
}
}
else {
// 다음 차례 묶음 안에서 가장 많이 밀린 발이 STEP_DIST 를 넘으면 그 묶음 네 다리가 함께 든다
let worst = 0;
for (let k = 0; k < spiderLegs.length; k += 1) {
const g = spiderLegs[k];
if (g.set !== nextSet) {
continue;
}
const h = legHome(g);
worst = Math.max(worst, Math.hypot(g.fx - h[0], g.fy - h[1]));
}
if (worst > STEP_DIST) {
for (let k = 0; k < spiderLegs.length; k += 1) {
const g = spiderLegs[k];
if (g.set !== nextSet) {
continue;
}
const h = legHome(g);
g.sx0 = g.fx;
g.sy0 = g.fy;
// 몸이 걸음 동안 갈 거리만큼 앞에 놓는다
g.ex = h[0] + bvx * STEP_TIME * 1.6;
g.ey = h[1] + bvy * STEP_TIME * 1.6;
g.t = 0;
}
movingSet = nextSet;
nextSet = (nextSet + 1) % GAIT_SETS;
setSwaps += 1;
}
}
bz = lerp(bz, plantedHeight() + BODY_CLEAR, follow(10, dt));
}
function restPose() {
bx = W / 2;
by = H / 2 + 10;
bHeading = -0.25;
plantSpider();
bz = plantedHeight() + BODY_CLEAR;
}
// 바닥 — 언덕 높이를 한 번 계산해 둔 그림(240×150 을 두 배로 늘려 깐다). 높을수록 밝고, 등고선 띠를 옅게 넣는다
const groundCanvas = document.createElement('canvas');
groundCanvas.width = W / 2;
groundCanvas.height = H / 2;
const groundCtx = groundCanvas.getContext('2d');
if (groundCtx) {
const img = groundCtx.createImageData(W / 2, H / 2);
for (let y = 0; y < H / 2; y += 1) {
for (let x = 0; x < W / 2; x += 1) {
const z = groundAt(x * 2, y * 2) / (GROUND_AMP * 1.6);
const band = Math.abs(((z * 4 + 8) % 1) - 0.5) < 0.06 ? 0.965 : 1;
const k = (0.93 + 0.07 * z) * band;
const o = (y * W / 2 + x) * 4;
img.data[o] = 246 * k;
img.data[o + 1] = 238 * k;
img.data[o + 2] = 226 * k;
img.data[o + 3] = 255;
}
}
groundCtx.putImageData(img, 0, 0);
}
const spiderKnee = new Float64Array(4);
function drawSpiderLeg(g) {
const a = bHeading + g.ang;
const hx = bx + Math.cos(a) * HIP_R;
const hy = by + Math.sin(a) * HIP_R - bz;
const fz = groundAt(g.fx, g.fy) + g.lift;
const fy = g.fy - fz;
// 무릎은 늘 위로 — 두 방향 중 화면 위쪽(작은 y)에 오는 쪽을 고른다
solveKnee(hx, hy, g.fx, fy, LEG_UPPER, LEG_LOWER, 1, true, spiderKnee);
if (spiderKnee[1] > (hy + fy) / 2) {
solveKnee(hx, hy, g.fx, fy, LEG_UPPER, LEG_LOWER, -1, true, spiderKnee);
}
ctx.beginPath();
ctx.moveTo(hx, hy);
ctx.lineTo(spiderKnee[0], spiderKnee[1]);
ctx.lineTo(spiderKnee[2], spiderKnee[3]);
ctx.lineWidth = 4.5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = rgba(INK, 1);
ctx.stroke();
ctx.beginPath();
ctx.arc(spiderKnee[0], spiderKnee[1], 3.2, 0, TAU);
ctx.fillStyle = rgba(LAVENDER, 1);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.stroke();
}
function draw() {
ctx.drawImage(groundCanvas, 0, 0, W, H);
const gz = groundAt(bx, by);
// 그림자 — 몸 아래 땅 위. 몸이 높을수록 크고 옅다
ctx.beginPath();
ctx.ellipse(bx, by - gz + 4, 34 + (bz - gz) * 0.4, 14 + (bz - gz) * 0.15, bHeading, 0, TAU);
ctx.fillStyle = rgba(INK, clamp(0.3 - (bz - gz) * 0.006, 0.08, 0.3));
ctx.fill();
// 뒤쪽 다리(화면 위쪽에 발이 있는 것)부터 그려 앞 다리가 위에 오게
const order = spiderLegs.slice().sort(function (p, q) { return p.fy - q.fy; });
for (let k = 0; k < order.length; k += 1) {
drawSpiderLeg(order[k]);
}
const ca = Math.cos(bHeading);
const sa = Math.sin(bHeading);
const ax = bx - ca * 24;
const ay = by - sa * 24 - bz;
ctx.beginPath();
ctx.ellipse(ax, ay, 25, 20, bHeading, 0, TAU);
ctx.fillStyle = rgba(LAVENDER, 1);
ctx.fill();
ctx.lineWidth = 2.5;
ctx.strokeStyle = rgba(INK, 1);
ctx.stroke();
// 배 무늬 — 진행 방향으로 늘어선 산호색 마름모 둘
ctx.fillStyle = rgba(CORAL, 1);
for (let m = 0; m < 2; m += 1) {
const cx0 = ax - ca * (m * 12 - 4);
const cy0 = ay - sa * (m * 12 - 4);
ctx.beginPath();
ctx.moveTo(cx0 + ca * 6, cy0 + sa * 6);
ctx.lineTo(cx0 - sa * 5, cy0 + ca * 5);
ctx.lineTo(cx0 - ca * 6, cy0 - sa * 6);
ctx.lineTo(cx0 + sa * 5, cy0 - ca * 5);
ctx.closePath();
ctx.fill();
}
const hx = bx + ca * 6;
const hy = by + sa * 6 - bz;
ctx.beginPath();
ctx.ellipse(hx, hy, 15, 13, bHeading, 0, TAU);
ctx.fillStyle = rgba(PINK, 1);
ctx.fill();
ctx.strokeStyle = rgba(INK, 1);
ctx.stroke();
for (let s = -1; s <= 1; s += 2) {
drawEye(hx + ca * 6 - sa * s * 6, hy + sa * 6 + ca * s * 6, 4, tx, ty, 1);
}
drawTarget(tx, ty);
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 미리 돌리기 — 첫 장면을 그리기 전에 PREWARM 초를 1/60초 걸음으로 계산해 생물이 길 위에 자리 잡은 채 시작한다
const PREWARM_STEP = 1 / 60;
// 시연 시계(초) — 8자 길 위의 자리를 정한다
let simTime = 0;
// 포인터(판 좌표) — 판 위에 있으면 목표를 포인터 쪽으로 옮긴다. 떠나면 다시 8자 길로 서서히 돌아간다
let ptrX = W / 2;
let ptrY = H / 2;
let ptrOn = false;
let ptrBlend = 0;
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 알림 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
const live = root.querySelector('[aria-live]');
if (live) {
live.setAttribute('aria-live', 'polite');
}
}
// 목표 옮기기 — 8자 길 자리와 포인터 자리를 ptrBlend 비율로 섞고, 그 변화로 목표 속도를 잰다
function moveTarget(dt) {
const p = pathAt(simTime);
ptrBlend = lerp(ptrBlend, ptrOn ? 1 : 0, follow(4, dt));
const nx = lerp(p[0], ptrX, ptrBlend);
const ny = lerp(p[1], ptrY, ptrBlend);
if (dt > 0) {
tvx = lerp(tvx, (nx - tx) / dt, follow(12, dt));
tvy = lerp(tvy, (ny - ty) / dt, follow(12, dt));
}
tx = nx;
ty = ny;
}
function advance(dt) {
simTime += dt;
moveTarget(dt);
update(dt, tx, ty, tvx, tvy);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 — 루프를 돌리지 않고 쉬는 자세 한 장면만 그린다
function showRest() {
stopLoop();
reset();
restPose();
draw();
}
function play() {
reset();
simTime = 0;
const p = pathAt(0);
tx = p[0];
ty = p[1];
tvx = 0;
tvy = 0;
for (let t = 0; t < PREWARM; t += PREWARM_STEP) {
advance(PREWARM_STEP);
}
draw();
if (!paused) {
startLoop();
}
}
function setPointer(ev) {
const r = cv.getBoundingClientRect();
ptrX = (ev.clientX - r.left) * W / Math.max(1, r.width);
ptrY = (ev.clientY - r.top) * H / Math.max(1, r.height);
ptrOn = true;
wake();
// 동작 줄이기에서도 입력은 화면에 남는다 — 쉬는 자세 그대로 눈과 목표 표시만 포인터 쪽으로 다시 그린다
if (still) {
tx = ptrX;
ty = ptrY;
draw();
}
}
cv.addEventListener('pointermove', setPointer, { signal: off.signal });
cv.addEventListener('pointerdown', setPointer, { signal: off.signal });
function leavePointer() { ptrOn = false; }
cv.addEventListener('pointerleave', leavePointer, { signal: off.signal });
cv.addEventListener('pointercancel', leavePointer, { signal: off.signal });
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 시작한다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 쉬는 자세로 멈추고, 꺼지면 시연을 처음부터
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// SpiderAlternatingGait - vanilla 판(07-creature-spider-alternating-gait) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./SpiderAlternatingGait.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SpiderAlternatingGait() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type Pt = [number, number];
type RGB = [number, number, number];
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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 wrapAngle(a: number): number {
let r = a;
while (r > Math.PI) { r -= TAU; }
while (r < -Math.PI) { r += TAU; }
return r;
}
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate: number, dt: number): number { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 흔들림·배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 1차원 값 노이즈 — 정수 자리마다 시드로 정한 값(-1~1)을 두고 사이는 부드러운 보간. 흔들림·배회 방향에 쓴다
const NOISE_TABLE = new Float32Array(256);
const noiseRng = makeRng(480);
for (let i = 0; i < 256; i += 1) { NOISE_TABLE[i] = noiseRng() * 2 - 1; }
function smoothNoise(x: number): number {
const i = Math.floor(x);
const f = x - i;
const u = f * f * (3 - 2 * f);
return lerp(NOISE_TABLE[i & 255], NOISE_TABLE[(i + 1) & 255], u);
}
// 8자 길 — 가로로 한 번(sin θ), 세로로 두 번(sin 2θ) 흔들리는 점. 판 가운데를 지나는 두 고리를 period 초에 한 바퀴
function figureEight(t: number, ax: number, ay: number, period: number): Pt {
const a = TAU * t / period;
return [W / 2 + ax * Math.sin(a), H / 2 + ay * Math.sin(2 * a)];
}
// 두 마디 다리 — 엉덩이(hx, hy)에서 발(fx, fy)까지 거리 d, 허벅지 길이 a, 정강이 길이 b.
// 엉덩이 쪽 무릎 각 A 는 코사인 관계 cos A = (a² + d² − b²) / (2·a·d) 로 푼다.
// guard 가 참이면 d 를 [|a − b|, a + b] 안으로 잘라 cos A 가 −1~1 을 벗어나지 않게 한다 — 거짓이면 발이 닿지 않는 거리에서 Math.acos 가 NaN 을 낸다.
// bend 가 +1 이면 무릎이 엉덩이→발 선의 시계 방향 쪽, −1 이면 반대쪽으로 꺾인다(굽는 방향 고정).
// out[0], out[1] = 무릎 자리, out[2], out[3] = 그려질 발 자리(닿지 않으면 정강이 끝). 돌려주는 값 = Math.acos 에 넣은 cos A
function solveKnee(hx: number, hy: number, fx: number, fy: number, a: number, b: number, bend: number, guard: boolean, out: Float64Array): number {
const raw = Math.hypot(fx - hx, fy - hy);
const d = guard ? clamp(raw, Math.abs(a - b) + 0.001, a + b - 0.001) : raw;
const cosA = (a * a + d * d - b * b) / (2 * a * d);
const ang = Math.atan2(fy - hy, fx - hx) + bend * Math.acos(cosA);
const kx = hx + Math.cos(ang) * a;
const ky = hy + Math.sin(ang) * a;
const shin = Math.atan2(fy - ky, fx - kx);
out[0] = kx;
out[1] = ky;
out[2] = kx + Math.cos(shin) * b;
out[3] = ky + Math.sin(shin) * b;
return cosA;
}
// 파스텔 팔레트 — 크림·옅은 민트 바탕 위에 파스텔 생물, 윤곽선과 그림자는 진한 잉크색으로 명도 차를 둔다
const PAPER: RGB = [251, 247, 238];
const PAPER_LOW: RGB = [222, 242, 234];
const INK: RGB = [58, 50, 74];
const CREAM: RGB = [255, 251, 242];
const CORAL: RGB = [244, 162, 144];
const LAVENDER: RGB = [188, 170, 236];
const BUTTER: RGB = [246, 222, 140];
const SAGE: RGB = [156, 204, 164];
const PINK: RGB = [244, 176, 200];
const SKY: RGB = [156, 204, 236];
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function shade(c: RGB, k: number): RGB { return [c[0] * k, c[1] * k, c[2] * k]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
// 목표점(판 좌표)과 그 속도(px/초) — 8자 길 또는 포인터. 그리기에서 눈동자 방향·목표 표시에 쓴다
let tx = W / 2;
let ty = H / 2;
let tvx = 0;
let tvy = 0;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearStage(top: RGB, bottom: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bottom, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 점들을 지나는 매끈한 닫힌 곡선 — 이웃 두 점의 가운데를 잇고 점 자체는 2차 곡선의 조절점으로 쓴다
function smoothClosed(pts: number[]): void {
const n = pts.length / 2;
ctx.beginPath();
ctx.moveTo((pts[2 * (n - 1)] + pts[0]) / 2, (pts[2 * (n - 1) + 1] + pts[1]) / 2);
for (let i = 0; i < n; i += 1) {
const j = (i + 1) % n;
ctx.quadraticCurveTo(pts[2 * i], pts[2 * i + 1], (pts[2 * i] + pts[2 * j]) / 2, (pts[2 * i + 1] + pts[2 * j + 1]) / 2);
}
ctx.closePath();
}
function fillShape(pts: number[], fill: RGB, line: number): void {
smoothClosed(pts);
ctx.fillStyle = rgba(fill, 1);
ctx.fill();
if (line > 0) {
ctx.lineWidth = line;
ctx.lineJoin = 'round';
ctx.strokeStyle = rgba(INK, 1);
ctx.stroke();
}
}
// 바닥 그림자 — 같은 윤곽을 (ox, oy) 만큼 밀어 옅은 잉크색으로
function dropShadow(pts: number[], ox: number, oy: number, alpha: number): void {
ctx.save();
ctx.translate(ox, oy);
smoothClosed(pts);
ctx.fillStyle = rgba(INK, alpha);
ctx.fill();
ctx.restore();
}
// 눈 — 흰자 위 눈동자를 (lookX, lookY) 쪽으로 반지름의 0.4 만큼 민다. open 0~1 은 위 눈꺼풀이 덮는 정도의 반대
function drawEye(x: number, y: number, r: number, lookX: number, lookY: number, open: number): void {
const a = Math.atan2(lookY - y, lookX - x);
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(CREAM, 1);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 1);
ctx.stroke();
ctx.beginPath();
ctx.arc(x + Math.cos(a) * r * 0.4, y + Math.sin(a) * r * 0.4, r * 0.55, 0, TAU);
ctx.fillStyle = rgba(INK, 1);
ctx.fill();
if (open < 1) {
ctx.save();
ctx.beginPath();
ctx.arc(x, y, r + 0.5, 0, TAU);
ctx.clip();
ctx.fillStyle = rgba(INK, 0.85);
ctx.fillRect(x - r - 1, y - r - 1, 2 * r + 2, (2 * r + 2) * (1 - open));
ctx.restore();
}
}
// 목표 표시 — 잉크색 고리와 점. 생물이 무엇을 쫓는지 보이게
function drawTarget(x: number, y: number): void {
// 동작 줄이기의 쉬는 자세에서는 포인터가 올라왔을 때만 보인다
if (still && !ptrOn) { return; }
ctx.beginPath();
ctx.arc(x, y, 7, 0, TAU);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(INK, 0.55);
ctx.stroke();
ctx.beginPath();
ctx.arc(x, y, 2.5, 0, TAU);
ctx.fillStyle = rgba(INK, 0.7);
ctx.fill();
}
// 07 거미 — 다리 8개를 네 개씩 두 묶음(GAIT_SETS)으로 나눠, 한 묶음이 발을 드는 동안 다른 묶음은 땅을 딛는다.
// 화면은 비스듬히 내려다본 모습 — 높이 z 는 화면 위쪽으로 z 만큼 올려 그린다
const SPIDER_SPEED = 110;
const SPIDER_TURN = 2.4;
// 다리 — 몸 가운데에서 LEG_REACH(px) 떨어진 자리가 발을 놓을 곳. 허벅지 LEG_UPPER, 정강이 LEG_LOWER
const LEG_REACH = 62;
const LEG_UPPER = 42;
const LEG_LOWER = 46;
const HIP_R = 12;
// 한 걸음 — 묶음 안 어느 발이든 놓을 곳에서 STEP_DIST(px) 넘게 밀리면 그 묶음이 STEP_TIME 초 동안 높이 STEP_HIGH 로 한꺼번에 옮긴다
const STEP_DIST = 30;
const STEP_TIME = 0.14;
const STEP_HIGH = 14;
// 몸 높이 = 땅을 딛은 발들의 땅 높이 평균 + BODY_CLEAR(px)
const BODY_CLEAR = 22;
// 언덕 — 땅 높이(px) = GROUND_AMP × (두 사인 물결의 합)
const GROUND_AMP = 9;
const PREWARM = 3;
function pathAt(t: number): Pt { return figureEight(t, 160, 70, 9); }
// 다리 방향(몸 진행 방향 기준, 라디안) — 앞에서 뒤로 네 쌍. 오른쪽은 +, 왼쪽은 −
const LEG_ANGLES: number[] = [0.5, 1.1, 1.85, 2.55];
type SpiderLeg = { ang: number; set: number; fx: number; fy: number; sx0: number; sy0: number; ex: number; ey: number; t: number; lift: number };
// 묶음 — 0 = 왼1·오2·왼3·오4, 1 = 오1·왼2·오3·왼4 (이웃 다리는 늘 다른 묶음)
const GAIT_SETS = 2;
const spiderLegs: SpiderLeg[] = [];
for (let i = 0; i < 4; i += 1) {
for (let s = -1; s <= 1; s += 2) {
spiderLegs.push({ ang: s * LEG_ANGLES[i], set: (i + (s > 0 ? 1 : 0)) % GAIT_SETS, fx: 0, fy: 0, sx0: 0, sy0: 0, ex: 0, ey: 0, t: -1, lift: 0 });
}
}
let bx = W / 2;
let by = H / 2;
let bz = BODY_CLEAR;
let bHeading = 0;
let bSpeed = 0;
let bvx = 0;
let bvy = 0;
// 지금 발을 든 묶음(−1 = 없음)과 다음 차례 묶음, 지금까지 바뀐 횟수
let movingSet = -1;
let nextSet = 0;
let setSwaps = 0;
function groundAt(x: number, y: number): number {
return GROUND_AMP * (Math.sin(x * 0.024 + 0.7) * Math.cos(y * 0.031) + 0.6 * Math.sin((x + y) * 0.017 + 2.1));
}
function legHome(g: SpiderLeg): Pt {
const a = bHeading + g.ang;
return [bx + Math.cos(a) * LEG_REACH, by + Math.sin(a) * LEG_REACH];
}
function plantSpider(): void {
for (let k = 0; k < spiderLegs.length; k += 1) {
const h = legHome(spiderLegs[k]);
spiderLegs[k].fx = h[0];
spiderLegs[k].fy = h[1];
spiderLegs[k].t = -1;
spiderLegs[k].lift = 0;
}
movingSet = -1;
}
function plantedHeight(): number {
let sum = 0;
let n = 0;
for (let k = 0; k < spiderLegs.length; k += 1) {
if (spiderLegs[k].t < 0) {
sum += groundAt(spiderLegs[k].fx, spiderLegs[k].fy);
n += 1;
}
}
return n > 0 ? sum / n : 0;
}
function reset(): void {
const p = pathAt(0);
bx = p[0] - 40;
by = p[1];
bHeading = 0;
bSpeed = 0;
bvx = 0;
bvy = 0;
nextSet = 0;
setSwaps = 0;
plantSpider();
bz = plantedHeight() + BODY_CLEAR;
}
function update(dt: number, tx: number, ty: number, tvx: number, tvy: number): void {
const dx = tx - bx;
const dy = ty - by;
const dist = Math.hypot(dx, dy);
bHeading = wrapAngle(bHeading + clamp(wrapAngle(Math.atan2(dy, dx) - bHeading), -SPIDER_TURN * dt, SPIDER_TURN * dt));
bSpeed = lerp(bSpeed, SPIDER_SPEED * clamp((dist - 15) / 70, 0, 1), follow(4, dt));
bvx = Math.cos(bHeading) * bSpeed;
bvy = Math.sin(bHeading) * bSpeed;
bx += bvx * dt;
by += bvy * dt;
if (movingSet >= 0) {
let done = true;
for (let k = 0; k < spiderLegs.length; k += 1) {
const g = spiderLegs[k];
if (g.set !== movingSet || g.t < 0) { continue; }
g.t += dt / STEP_TIME;
const s = smooth(g.t);
g.fx = lerp(g.sx0, g.ex, s);
g.fy = lerp(g.sy0, g.ey, s);
g.lift = Math.sin(Math.PI * clamp(g.t, 0, 1)) * STEP_HIGH;
if (g.t >= 1) { g.t = -1; g.lift = 0; } else { done = false; }
}
if (done) { movingSet = -1; }
} else {
// 다음 차례 묶음 안에서 가장 많이 밀린 발이 STEP_DIST 를 넘으면 그 묶음 네 다리가 함께 든다
let worst = 0;
for (let k = 0; k < spiderLegs.length; k += 1) {
const g = spiderLegs[k];
if (g.set !== nextSet) { continue; }
const h = legHome(g);
worst = Math.max(worst, Math.hypot(g.fx - h[0], g.fy - h[1]));
}
if (worst > STEP_DIST) {
for (let k = 0; k < spiderLegs.length; k += 1) {
const g = spiderLegs[k];
if (g.set !== nextSet) { continue; }
const h = legHome(g);
g.sx0 = g.fx;
g.sy0 = g.fy;
// 몸이 걸음 동안 갈 거리만큼 앞에 놓는다
g.ex = h[0] + bvx * STEP_TIME * 1.6;
g.ey = h[1] + bvy * STEP_TIME * 1.6;
g.t = 0;
}
movingSet = nextSet;
nextSet = (nextSet + 1) % GAIT_SETS;
setSwaps += 1;
}
}
bz = lerp(bz, plantedHeight() + BODY_CLEAR, follow(10, dt));
}
function restPose(): void {
bx = W / 2;
by = H / 2 + 10;
bHeading = -0.25;
plantSpider();
bz = plantedHeight() + BODY_CLEAR;
}
// 바닥 — 언덕 높이를 한 번 계산해 둔 그림(240×150 을 두 배로 늘려 깐다). 높을수록 밝고, 등고선 띠를 옅게 넣는다
const groundCanvas = document.createElement('canvas');
groundCanvas.width = W / 2;
groundCanvas.height = H / 2;
const groundCtx = groundCanvas.getContext('2d');
if (groundCtx) {
const img = groundCtx.createImageData(W / 2, H / 2);
for (let y = 0; y < H / 2; y += 1) {
for (let x = 0; x < W / 2; x += 1) {
const z = groundAt(x * 2, y * 2) / (GROUND_AMP * 1.6);
const band = Math.abs(((z * 4 + 8) % 1) - 0.5) < 0.06 ? 0.965 : 1;
const k = (0.93 + 0.07 * z) * band;
const o = (y * W / 2 + x) * 4;
img.data[o] = 246 * k;
img.data[o + 1] = 238 * k;
img.data[o + 2] = 226 * k;
img.data[o + 3] = 255;
}
}
groundCtx.putImageData(img, 0, 0);
}
const spiderKnee = new Float64Array(4);
function drawSpiderLeg(g: SpiderLeg): void {
const a = bHeading + g.ang;
const hx = bx + Math.cos(a) * HIP_R;
const hy = by + Math.sin(a) * HIP_R - bz;
const fz = groundAt(g.fx, g.fy) + g.lift;
const fy = g.fy - fz;
// 무릎은 늘 위로 — 두 방향 중 화면 위쪽(작은 y)에 오는 쪽을 고른다
solveKnee(hx, hy, g.fx, fy, LEG_UPPER, LEG_LOWER, 1, true, spiderKnee);
if (spiderKnee[1] > (hy + fy) / 2) { solveKnee(hx, hy, g.fx, fy, LEG_UPPER, LEG_LOWER, -1, true, spiderKnee); }
ctx.beginPath();
ctx.moveTo(hx, hy);
ctx.lineTo(spiderKnee[0], spiderKnee[1]);
ctx.lineTo(spiderKnee[2], spiderKnee[3]);
ctx.lineWidth = 4.5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = rgba(INK, 1);
ctx.stroke();
ctx.beginPath();
ctx.arc(spiderKnee[0], spiderKnee[1], 3.2, 0, TAU);
ctx.fillStyle = rgba(LAVENDER, 1);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.stroke();
}
function draw(): void {
ctx.drawImage(groundCanvas, 0, 0, W, H);
const gz = groundAt(bx, by);
// 그림자 — 몸 아래 땅 위. 몸이 높을수록 크고 옅다
ctx.beginPath();
ctx.ellipse(bx, by - gz + 4, 34 + (bz - gz) * 0.4, 14 + (bz - gz) * 0.15, bHeading, 0, TAU);
ctx.fillStyle = rgba(INK, clamp(0.3 - (bz - gz) * 0.006, 0.08, 0.3));
ctx.fill();
// 뒤쪽 다리(화면 위쪽에 발이 있는 것)부터 그려 앞 다리가 위에 오게
const order = spiderLegs.slice().sort(function (p: SpiderLeg, q: SpiderLeg): number { return p.fy - q.fy; });
for (let k = 0; k < order.length; k += 1) { drawSpiderLeg(order[k]); }
const ca = Math.cos(bHeading);
const sa = Math.sin(bHeading);
const ax = bx - ca * 24;
const ay = by - sa * 24 - bz;
ctx.beginPath();
ctx.ellipse(ax, ay, 25, 20, bHeading, 0, TAU);
ctx.fillStyle = rgba(LAVENDER, 1);
ctx.fill();
ctx.lineWidth = 2.5;
ctx.strokeStyle = rgba(INK, 1);
ctx.stroke();
// 배 무늬 — 진행 방향으로 늘어선 산호색 마름모 둘
ctx.fillStyle = rgba(CORAL, 1);
for (let m = 0; m < 2; m += 1) {
const cx0 = ax - ca * (m * 12 - 4);
const cy0 = ay - sa * (m * 12 - 4);
ctx.beginPath();
ctx.moveTo(cx0 + ca * 6, cy0 + sa * 6);
ctx.lineTo(cx0 - sa * 5, cy0 + ca * 5);
ctx.lineTo(cx0 - ca * 6, cy0 - sa * 6);
ctx.lineTo(cx0 + sa * 5, cy0 - ca * 5);
ctx.closePath();
ctx.fill();
}
const hx = bx + ca * 6;
const hy = by + sa * 6 - bz;
ctx.beginPath();
ctx.ellipse(hx, hy, 15, 13, bHeading, 0, TAU);
ctx.fillStyle = rgba(PINK, 1);
ctx.fill();
ctx.strokeStyle = rgba(INK, 1);
ctx.stroke();
for (let s = -1; s <= 1; s += 2) {
drawEye(hx + ca * 6 - sa * s * 6, hy + sa * 6 + ca * s * 6, 4, tx, ty, 1);
}
drawTarget(tx, ty);
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 미리 돌리기 — 첫 장면을 그리기 전에 PREWARM 초를 1/60초 걸음으로 계산해 생물이 길 위에 자리 잡은 채 시작한다
const PREWARM_STEP = 1 / 60;
// 시연 시계(초) — 8자 길 위의 자리를 정한다
let simTime = 0;
// 포인터(판 좌표) — 판 위에 있으면 목표를 포인터 쪽으로 옮긴다. 떠나면 다시 8자 길로 서서히 돌아간다
let ptrX = W / 2;
let ptrY = H / 2;
let ptrOn = false;
let ptrBlend = 0;
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 알림 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
const live = root.querySelector('[aria-live]');
if (live) { live.setAttribute('aria-live', 'polite'); }
}
// 목표 옮기기 — 8자 길 자리와 포인터 자리를 ptrBlend 비율로 섞고, 그 변화로 목표 속도를 잰다
function moveTarget(dt: number): void {
const p = pathAt(simTime);
ptrBlend = lerp(ptrBlend, ptrOn ? 1 : 0, follow(4, dt));
const nx = lerp(p[0], ptrX, ptrBlend);
const ny = lerp(p[1], ptrY, ptrBlend);
if (dt > 0) {
tvx = lerp(tvx, (nx - tx) / dt, follow(12, dt));
tvy = lerp(tvy, (ny - ty) / dt, follow(12, dt));
}
tx = nx;
ty = ny;
}
function advance(dt: number): void {
simTime += dt;
moveTarget(dt);
update(dt, tx, ty, tvx, tvy);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 — 루프를 돌리지 않고 쉬는 자세 한 장면만 그린다
function showRest(): void {
stopLoop();
reset();
restPose();
draw();
}
function play(): void {
reset();
simTime = 0;
const p = pathAt(0);
tx = p[0];
ty = p[1];
tvx = 0;
tvy = 0;
for (let t = 0; t < PREWARM; t += PREWARM_STEP) { advance(PREWARM_STEP); }
draw();
if (!paused) { startLoop(); }
}
function setPointer(ev: PointerEvent): void {
const r = cv.getBoundingClientRect();
ptrX = (ev.clientX - r.left) * W / Math.max(1, r.width);
ptrY = (ev.clientY - r.top) * H / Math.max(1, r.height);
ptrOn = true;
wake();
// 동작 줄이기에서도 입력은 화면에 남는다 — 쉬는 자세 그대로 눈과 목표 표시만 포인터 쪽으로 다시 그린다
if (still) {
tx = ptrX;
ty = ptrY;
draw();
}
}
cv.addEventListener('pointermove', setPointer, { signal: off.signal });
cv.addEventListener('pointerdown', setPointer, { signal: off.signal });
function leavePointer(): void { ptrOn = false; }
cv.addEventListener('pointerleave', leavePointer, { signal: off.signal });
cv.addEventListener('pointercancel', leavePointer, { signal: off.signal });
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 시작한다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 쉬는 자세로 멈추고, 꺼지면 시연을 처음부터
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("cf") + " is-demo"} ref={rootRef}>
<canvas className={cx("cf__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label="여덟 다리를 네 개씩 두 묶음으로 번갈아 옮기며 언덕진 바닥을 걷는 거미."></canvas>
<button className={cx("cf__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}07 A Spider Walking on Two Alternating Sets of Four Legs — Measured area 19.912% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Cursor Follow Animation: 9 Procedural Creatures
The post this demo lives in
Cursor Follow Animation: 9 Procedural CreaturesIn a cursor follow animation, with no joint keyframed, how does a creature swim and walk after a target? 01 keeps only t See all demos in this post
If you like this effect - similar demos
Interactions05 Two-key sequenceKeyboard Shortcuts UI Design: 9 Working Parts
Interactions04 Two-button split — a row where primary and secondary share the success state in stagger9 Copy Button CSS UI Patterns (Copy to Clipboard)
Interactions05 Two Dice Whose Sum Stacks into BarsDice Roll Animation: 9 Scenes with Top-Face Math