03A Lizard That Steps Only When a Foot Falls Too Far Behind
Each leg solves its knee angle from two bone lengths, and a planted foot that drifts too far from its predicted spot ahead of the body arcs to a new spot, diagonal pairs taking turns.
Best forWandering mascot on a 404 page
STEP_TRIGGERTHIGH_LENSTEP_LIFT

<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 green lizard whose diagonal leg pairs take turns stepping whenever a foot falls too far behind."></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)];
}
// 마디 사슬 — xs[0], ys[0] 이 머리. 머리를 옮긴 뒤 부르면 뒤 마디마다 「앞 마디에서 지금 자기 쪽으로 향한 방향 × gap」 자리로 끌어온다.
// 간격만 지키므로 마디는 늘지도 줄지도 않고, 몸은 머리가 지나간 길을 대충 따라 눕는다.
// maxBend 가 π 보다 작으면 이웃 두 토막이 꺾이는 각을 ±maxBend(라디안)로 자른다 — π 이상(예: Math.PI)이면 각 한계 없음.
// backAngle 은 머리 뒤쪽 방향(머리 진행 방향 + π). 숫자면 첫 토막도 그 방향을 기준으로 자른다, NaN 이면 첫 토막은 자유
function followChain(xs, ys, gap, maxBend, backAngle) {
const n = xs.length;
let prevAng = backAngle;
for (let i = 1; i < n; i += 1) {
let ang = Math.atan2(ys[i] - ys[i - 1], xs[i] - xs[i - 1]);
if (maxBend < Math.PI && !isNaN(prevAng)) {
const d = wrapAngle(ang - prevAng);
if (d > maxBend) {
ang = prevAng + maxBend;
}
else if (d < -maxBend) {
ang = prevAng - maxBend;
}
}
xs[i] = xs[i - 1] + Math.cos(ang) * gap;
ys[i] = ys[i - 1] + Math.sin(ang) * gap;
prevAng = ang;
}
}
// 마디 i 에서 머리 쪽을 향한 방향(라디안) — 양 끝은 이웃 한 토막, 가운데는 앞뒤 마디를 이은 방향
function jointAngle(xs, ys, i) {
const n = xs.length;
if (i <= 0) {
return Math.atan2(ys[0] - ys[1], xs[0] - xs[1]);
}
if (i >= n - 1) {
return Math.atan2(ys[n - 2] - ys[n - 1], xs[n - 2] - xs[n - 1]);
}
return Math.atan2(ys[i - 1] - ys[i + 1], xs[i - 1] - xs[i + 1]);
}
// 이웃 두 토막 사이 꺾인 각(라디안, 부호 있음) — 마디 i 앞 토막과 뒤 토막의 방향 차
function bendAt(xs, ys, i) {
const a = Math.atan2(ys[i] - ys[i - 1], xs[i] - xs[i - 1]);
const b = Math.atan2(ys[i + 1] - ys[i], xs[i + 1] - xs[i]);
return wrapAngle(b - a);
}
// 몸 윤곽 — 마디마다 방향의 법선(직각) 쪽으로 radii[i] 만큼 좌우로 민 점을 잇는다.
// 순서: 머리 왼쪽 → 왼쪽 옆구리를 따라 꼬리 → 꼬리 끝 → 오른쪽 옆구리를 따라 머리 → 머리 앞쪽 반원(HEAD_CAP_STEPS 조각). 닫힌 다각형, 점마다 x 와 y 를 차례로 넣은 한 줄 배열
const HEAD_CAP_STEPS = 6;
function bodyOutline(xs, ys, radii) {
const n = xs.length;
const pts = [];
for (let i = 0; i < n; i += 1) {
const a = jointAngle(xs, ys, i) + Math.PI / 2;
pts.push(xs[i] + Math.cos(a) * radii[i], ys[i] + Math.sin(a) * radii[i]);
}
const tailA = jointAngle(xs, ys, n - 1) + Math.PI;
pts.push(xs[n - 1] + Math.cos(tailA) * radii[n - 1], ys[n - 1] + Math.sin(tailA) * radii[n - 1]);
for (let i = n - 1; i >= 0; i -= 1) {
const a = jointAngle(xs, ys, i) - Math.PI / 2;
pts.push(xs[i] + Math.cos(a) * radii[i], ys[i] + Math.sin(a) * radii[i]);
}
const a0 = jointAngle(xs, ys, 0);
for (let k = 1; k < HEAD_CAP_STEPS; k += 1) {
const a = a0 - Math.PI / 2 + Math.PI * k / HEAD_CAP_STEPS;
pts.push(xs[0] + Math.cos(a) * radii[0], ys[0] + Math.sin(a) * radii[0]);
}
return pts;
}
// 몸이 스스로 겹친 정도 — 사슬에서 3칸 이상 떨어진 두 마디가 두 반지름 합보다 가까운 쌍의 수와 가장 가까운 거리
function selfOverlap(xs, ys, radii) {
const n = xs.length;
let count = 0;
let nearest = Infinity;
for (let i = 0; i < n; i += 1) {
for (let j = i + 3; j < n; j += 1) {
const d = Math.hypot(xs[i] - xs[j], ys[i] - ys[j]);
if (d < nearest) {
nearest = d;
}
if (d < radii[i] + radii[j]) {
count += 1;
}
}
}
return [count, nearest];
}
// 두 마디 다리 — 엉덩이(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();
}
// 03 도마뱀 — 척추 사슬(각 한계 LIZARD_BEND) + 다리 4개. 다리마다 두 마디 풀이로 무릎을 정하고, 발은 땅에 붙여 둔다
const LIZARD_JOINTS = 18;
const SPINE_GAP = 11;
const LIZARD_BEND = 0.32;
// 걷기 — 최고 속도 WALK_SPEED(px/초), 머리가 1초에 도는 각 LIZARD_TURN(라디안)
const WALK_SPEED = 120;
const LIZARD_TURN = 2.6;
// 다리 뼈 길이(px) — 허벅지 THIGH_LEN, 정강이 SHIN_LEN. 두 합 42px 이 발이 닿는 한계
const THIGH_LEN = 22;
const SHIN_LEN = 20;
// 발 놓을 자리 — 다리가 붙은 마디에서 앞으로 FOOT_AHEAD, 옆으로 FOOT_SIDE(px)
const FOOT_AHEAD = 10;
const FOOT_SIDE = 30;
// 한 걸음 — 붙은 발과 놓을 자리 사이가 STEP_TRIGGER(px)를 넘으면, 놓을 자리에서 진행 방향으로 STEP_TRIGGER × STEP_LEAD 더 앞에
// STEP_TIME 초 동안 높이 STEP_LIFT(px) 의 호(sin 반 바퀴)를 그리며 옮긴다
const STEP_TRIGGER = 38;
const STEP_LEAD = 0.5;
const STEP_TIME = 0.16;
const STEP_LIFT = 7;
const PREWARM = 3;
function pathAt(t) { return figureEight(t, 175, 95, 9); }
function makeLeg(joint, side, front) {
const pair = (front ? (side > 0 ? 0 : 1) : (side > 0 ? 1 : 0));
return { joint: joint, side: side, front: front, pair: pair, fx: 0, fy: 0, sx0: 0, sy0: 0, ex: 0, ey: 0, t: -1, lift: 0 };
}
const lx = new Float64Array(LIZARD_JOINTS);
const ly = new Float64Array(LIZARD_JOINTS);
const lr = new Float64Array(LIZARD_JOINTS);
const legs = [makeLeg(3, 1, true), makeLeg(3, -1, true), makeLeg(9, 1, false), makeLeg(9, -1, false)];
let heading = 0;
let walkSpeed = 0;
// 지금까지 내디딘 걸음 수와 걸음마다 옮긴 거리 합(변형 비교용)
let stepCount = 0;
let strideSum = 0;
// 도마뱀 굵기 — 머리 9, 목 6, 몸통 11, 꼬리로 1.5 까지
function lizardRadius(i) {
if (i === 0) {
return 8;
}
if (i === 1) {
return 9;
}
if (i === 2) {
return 6.5;
}
if (i <= 10) {
return 9 + 2.5 * Math.sin(Math.PI * (i - 2) / 8);
}
return Math.max(1.5, 9 * (1 - (i - 10) / (LIZARD_JOINTS - 10)));
}
function homeOf(g) {
const a = jointAngle(lx, ly, g.joint);
const n = a + g.side * Math.PI / 2;
return [lx[g.joint] + Math.cos(a) * FOOT_AHEAD + Math.cos(n) * FOOT_SIDE, ly[g.joint] + Math.sin(a) * FOOT_AHEAD + Math.sin(n) * FOOT_SIDE];
}
function hipOf(g) {
const a = jointAngle(lx, ly, g.joint) + g.side * Math.PI / 2;
return [lx[g.joint] + Math.cos(a) * lr[g.joint] * 0.7, ly[g.joint] + Math.sin(a) * lr[g.joint] * 0.7];
}
function plantAll() {
for (let k = 0; k < legs.length; k += 1) {
const h = homeOf(legs[k]);
legs[k].fx = h[0];
legs[k].fy = h[1];
legs[k].t = -1;
legs[k].lift = 0;
}
}
function reset() {
const p = pathAt(0);
heading = 0;
walkSpeed = 0;
stepCount = 0;
strideSum = 0;
for (let i = 0; i < LIZARD_JOINTS; i += 1) {
lx[i] = p[0] - 30 - i * SPINE_GAP;
ly[i] = p[1];
lr[i] = lizardRadius(i);
}
plantAll();
}
function stepping(pair) {
for (let k = 0; k < legs.length; k += 1) {
if (legs[k].pair === pair && legs[k].t >= 0) {
return true;
}
}
return false;
}
function update(dt, tx, ty, tvx, tvy) {
const dx = tx - lx[0];
const dy = ty - ly[0];
const dist = Math.hypot(dx, dy);
heading = wrapAngle(heading + clamp(wrapAngle(Math.atan2(dy, dx) - heading), -LIZARD_TURN * dt, LIZARD_TURN * dt));
walkSpeed = lerp(walkSpeed, WALK_SPEED * clamp((dist - 20) / 80, 0, 1), follow(4, dt));
lx[0] += Math.cos(heading) * walkSpeed * dt;
ly[0] += Math.sin(heading) * walkSpeed * dt;
followChain(lx, ly, SPINE_GAP, LIZARD_BEND, heading + Math.PI);
const fwdA = jointAngle(lx, ly, 0);
for (let k = 0; k < legs.length; k += 1) {
const g = legs[k];
if (g.t >= 0) {
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_LIFT;
if (g.t >= 1) {
g.t = -1;
g.lift = 0;
}
continue;
}
const h = homeOf(g);
const drift = Math.hypot(g.fx - h[0], g.fy - h[1]);
// 대각선 짝 교대 — 다른 짝이 발을 들고 있는 동안에는 이 짝은 기다린다
if (drift > STEP_TRIGGER && !stepping(1 - g.pair)) {
g.sx0 = g.fx;
g.sy0 = g.fy;
g.ex = h[0] + Math.cos(fwdA) * STEP_TRIGGER * STEP_LEAD;
g.ey = h[1] + Math.sin(fwdA) * STEP_TRIGGER * STEP_LEAD;
g.t = 0;
stepCount += 1;
strideSum += Math.hypot(g.ex - g.sx0, g.ey - g.sy0);
}
}
}
// 쉬는 자세 — 완만한 S 자 척추에 네 발을 놓을 자리에 딛는다
function restPose() {
heading = 0;
for (let i = 0; i < LIZARD_JOINTS; i += 1) {
const u = i / (LIZARD_JOINTS - 1);
lx[i] = W / 2 + 95 - i * SPINE_GAP * 0.95;
ly[i] = H / 2 + Math.sin(u * TAU) * 22;
}
followChain(lx, ly, SPINE_GAP, LIZARD_BEND, Math.PI);
plantAll();
}
const knee = new Float64Array(4);
// 다리 한 개 — 엉덩이에서 무릎, 무릎에서 발까지 두 토막. 앞다리는 팔꿈치가 뒤로, 뒷다리는 무릎이 앞으로 꺾인다
// 발을 든 동안에는 화면 위쪽으로 lift 만큼 띄우고 바닥에 작은 그림자를 남긴다
function drawLeg(g) {
const hip = hipOf(g);
const fy = g.fy - g.lift;
const bend = g.front ? -g.side : g.side;
solveKnee(hip[0], hip[1], g.fx, fy, THIGH_LEN, SHIN_LEN, bend, true, knee);
if (g.lift > 0) {
ctx.beginPath();
ctx.ellipse(g.fx, g.fy + 2, 5, 2.5, 0, 0, TAU);
ctx.fillStyle = rgba(INK, 0.15);
ctx.fill();
}
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
for (let pass = 0; pass < 2; pass += 1) {
ctx.beginPath();
ctx.moveTo(hip[0], hip[1]);
ctx.lineTo(knee[0], knee[1]);
ctx.lineTo(knee[2], knee[3]);
ctx.lineWidth = pass === 0 ? 8.5 : 5;
ctx.strokeStyle = pass === 0 ? rgba(INK, 1) : rgba(SAGE, 1);
ctx.stroke();
}
// 발가락 셋 — 정강이 방향을 가운데로 부채꼴
const sa = Math.atan2(knee[3] - knee[1], knee[2] - knee[0]);
ctx.lineWidth = 2.2;
ctx.strokeStyle = rgba(INK, 1);
for (let t = -1; t <= 1; t += 1) {
ctx.beginPath();
ctx.moveTo(knee[2], knee[3]);
ctx.lineTo(knee[2] + Math.cos(sa + t * 0.6) * 7, knee[3] + Math.sin(sa + t * 0.6) * 7);
ctx.stroke();
}
}
function draw() {
clearStage(PAPER, PAPER_LOW);
const outline = bodyOutline(lx, ly, lr);
dropShadow(outline, 4, 7, 0.14);
for (let k = 0; k < legs.length; k += 1) {
drawLeg(legs[k]);
}
fillShape(outline, SAGE, 2.5);
// 등 점무늬 — 3번부터 꼬리 앞까지 마디마다 버터색 점, 크기는 마디 반지름에서
ctx.fillStyle = rgba(BUTTER, 1);
for (let i = 3; i < LIZARD_JOINTS - 3; i += 1) {
const a = jointAngle(lx, ly, i) + Math.PI / 2;
const off = (i % 2 === 0 ? 0.35 : -0.35) * lr[i];
ctx.beginPath();
ctx.arc(lx[i] + Math.cos(a) * off, ly[i] + Math.sin(a) * off, lr[i] * 0.28, 0, TAU);
ctx.fill();
}
const a0 = jointAngle(lx, ly, 0);
for (let s = -1; s <= 1; s += 2) {
const ex = lx[1] + Math.cos(a0 + s * Math.PI / 2) * lr[1] * 0.75 + Math.cos(a0) * 3;
const ey = ly[1] + Math.sin(a0 + s * Math.PI / 2) * lr[1] * 0.75 + Math.sin(a0) * 3;
drawEye(ex, ey, 3.6, 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();
};
})();// LizardTwoBoneLegStep - vanilla 판(03-creature-lizard-two-bone-leg-step) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./LizardTwoBoneLegStep.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function LizardTwoBoneLegStep() {
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)];
}
// 마디 사슬 — xs[0], ys[0] 이 머리. 머리를 옮긴 뒤 부르면 뒤 마디마다 「앞 마디에서 지금 자기 쪽으로 향한 방향 × gap」 자리로 끌어온다.
// 간격만 지키므로 마디는 늘지도 줄지도 않고, 몸은 머리가 지나간 길을 대충 따라 눕는다.
// maxBend 가 π 보다 작으면 이웃 두 토막이 꺾이는 각을 ±maxBend(라디안)로 자른다 — π 이상(예: Math.PI)이면 각 한계 없음.
// backAngle 은 머리 뒤쪽 방향(머리 진행 방향 + π). 숫자면 첫 토막도 그 방향을 기준으로 자른다, NaN 이면 첫 토막은 자유
function followChain(xs: Float64Array, ys: Float64Array, gap: number, maxBend: number, backAngle: number): void {
const n = xs.length;
let prevAng = backAngle;
for (let i = 1; i < n; i += 1) {
let ang = Math.atan2(ys[i] - ys[i - 1], xs[i] - xs[i - 1]);
if (maxBend < Math.PI && !isNaN(prevAng)) {
const d = wrapAngle(ang - prevAng);
if (d > maxBend) { ang = prevAng + maxBend; } else if (d < -maxBend) { ang = prevAng - maxBend; }
}
xs[i] = xs[i - 1] + Math.cos(ang) * gap;
ys[i] = ys[i - 1] + Math.sin(ang) * gap;
prevAng = ang;
}
}
// 마디 i 에서 머리 쪽을 향한 방향(라디안) — 양 끝은 이웃 한 토막, 가운데는 앞뒤 마디를 이은 방향
function jointAngle(xs: Float64Array, ys: Float64Array, i: number): number {
const n = xs.length;
if (i <= 0) { return Math.atan2(ys[0] - ys[1], xs[0] - xs[1]); }
if (i >= n - 1) { return Math.atan2(ys[n - 2] - ys[n - 1], xs[n - 2] - xs[n - 1]); }
return Math.atan2(ys[i - 1] - ys[i + 1], xs[i - 1] - xs[i + 1]);
}
// 이웃 두 토막 사이 꺾인 각(라디안, 부호 있음) — 마디 i 앞 토막과 뒤 토막의 방향 차
function bendAt(xs: Float64Array, ys: Float64Array, i: number): number {
const a = Math.atan2(ys[i] - ys[i - 1], xs[i] - xs[i - 1]);
const b = Math.atan2(ys[i + 1] - ys[i], xs[i + 1] - xs[i]);
return wrapAngle(b - a);
}
// 몸 윤곽 — 마디마다 방향의 법선(직각) 쪽으로 radii[i] 만큼 좌우로 민 점을 잇는다.
// 순서: 머리 왼쪽 → 왼쪽 옆구리를 따라 꼬리 → 꼬리 끝 → 오른쪽 옆구리를 따라 머리 → 머리 앞쪽 반원(HEAD_CAP_STEPS 조각). 닫힌 다각형, 점마다 x 와 y 를 차례로 넣은 한 줄 배열
const HEAD_CAP_STEPS = 6;
function bodyOutline(xs: Float64Array, ys: Float64Array, radii: Float64Array): number[] {
const n = xs.length;
const pts: number[] = [];
for (let i = 0; i < n; i += 1) {
const a = jointAngle(xs, ys, i) + Math.PI / 2;
pts.push(xs[i] + Math.cos(a) * radii[i], ys[i] + Math.sin(a) * radii[i]);
}
const tailA = jointAngle(xs, ys, n - 1) + Math.PI;
pts.push(xs[n - 1] + Math.cos(tailA) * radii[n - 1], ys[n - 1] + Math.sin(tailA) * radii[n - 1]);
for (let i = n - 1; i >= 0; i -= 1) {
const a = jointAngle(xs, ys, i) - Math.PI / 2;
pts.push(xs[i] + Math.cos(a) * radii[i], ys[i] + Math.sin(a) * radii[i]);
}
const a0 = jointAngle(xs, ys, 0);
for (let k = 1; k < HEAD_CAP_STEPS; k += 1) {
const a = a0 - Math.PI / 2 + Math.PI * k / HEAD_CAP_STEPS;
pts.push(xs[0] + Math.cos(a) * radii[0], ys[0] + Math.sin(a) * radii[0]);
}
return pts;
}
// 몸이 스스로 겹친 정도 — 사슬에서 3칸 이상 떨어진 두 마디가 두 반지름 합보다 가까운 쌍의 수와 가장 가까운 거리
function selfOverlap(xs: Float64Array, ys: Float64Array, radii: Float64Array): [number, number] {
const n = xs.length;
let count = 0;
let nearest = Infinity;
for (let i = 0; i < n; i += 1) {
for (let j = i + 3; j < n; j += 1) {
const d = Math.hypot(xs[i] - xs[j], ys[i] - ys[j]);
if (d < nearest) { nearest = d; }
if (d < radii[i] + radii[j]) { count += 1; }
}
}
return [count, nearest];
}
// 두 마디 다리 — 엉덩이(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();
}
// 03 도마뱀 — 척추 사슬(각 한계 LIZARD_BEND) + 다리 4개. 다리마다 두 마디 풀이로 무릎을 정하고, 발은 땅에 붙여 둔다
const LIZARD_JOINTS = 18;
const SPINE_GAP = 11;
const LIZARD_BEND = 0.32;
// 걷기 — 최고 속도 WALK_SPEED(px/초), 머리가 1초에 도는 각 LIZARD_TURN(라디안)
const WALK_SPEED = 120;
const LIZARD_TURN = 2.6;
// 다리 뼈 길이(px) — 허벅지 THIGH_LEN, 정강이 SHIN_LEN. 두 합 42px 이 발이 닿는 한계
const THIGH_LEN = 22;
const SHIN_LEN = 20;
// 발 놓을 자리 — 다리가 붙은 마디에서 앞으로 FOOT_AHEAD, 옆으로 FOOT_SIDE(px)
const FOOT_AHEAD = 10;
const FOOT_SIDE = 30;
// 한 걸음 — 붙은 발과 놓을 자리 사이가 STEP_TRIGGER(px)를 넘으면, 놓을 자리에서 진행 방향으로 STEP_TRIGGER × STEP_LEAD 더 앞에
// STEP_TIME 초 동안 높이 STEP_LIFT(px) 의 호(sin 반 바퀴)를 그리며 옮긴다
const STEP_TRIGGER = 38;
const STEP_LEAD = 0.5;
const STEP_TIME = 0.16;
const STEP_LIFT = 7;
const PREWARM = 3;
function pathAt(t: number): Pt { return figureEight(t, 175, 95, 9); }
// 다리 — 붙은 마디, 좌우(+1 왼쪽 법선 / −1 오른쪽), 앞다리 여부, 대각선 짝 번호(0 = 왼앞·오른뒤, 1 = 오른앞·왼뒤)
type Leg = { joint: number; side: number; front: boolean; pair: number; fx: number; fy: number; sx0: number; sy0: number; ex: number; ey: number; t: number; lift: number };
function makeLeg(joint: number, side: number, front: boolean): Leg {
const pair = (front ? (side > 0 ? 0 : 1) : (side > 0 ? 1 : 0));
return { joint: joint, side: side, front: front, pair: pair, fx: 0, fy: 0, sx0: 0, sy0: 0, ex: 0, ey: 0, t: -1, lift: 0 };
}
const lx = new Float64Array(LIZARD_JOINTS);
const ly = new Float64Array(LIZARD_JOINTS);
const lr = new Float64Array(LIZARD_JOINTS);
const legs: Leg[] = [makeLeg(3, 1, true), makeLeg(3, -1, true), makeLeg(9, 1, false), makeLeg(9, -1, false)];
let heading = 0;
let walkSpeed = 0;
// 지금까지 내디딘 걸음 수와 걸음마다 옮긴 거리 합(변형 비교용)
let stepCount = 0;
let strideSum = 0;
// 도마뱀 굵기 — 머리 9, 목 6, 몸통 11, 꼬리로 1.5 까지
function lizardRadius(i: number): number {
if (i === 0) { return 8; }
if (i === 1) { return 9; }
if (i === 2) { return 6.5; }
if (i <= 10) { return 9 + 2.5 * Math.sin(Math.PI * (i - 2) / 8); }
return Math.max(1.5, 9 * (1 - (i - 10) / (LIZARD_JOINTS - 10)));
}
function homeOf(g: Leg): Pt {
const a = jointAngle(lx, ly, g.joint);
const n = a + g.side * Math.PI / 2;
return [lx[g.joint] + Math.cos(a) * FOOT_AHEAD + Math.cos(n) * FOOT_SIDE, ly[g.joint] + Math.sin(a) * FOOT_AHEAD + Math.sin(n) * FOOT_SIDE];
}
function hipOf(g: Leg): Pt {
const a = jointAngle(lx, ly, g.joint) + g.side * Math.PI / 2;
return [lx[g.joint] + Math.cos(a) * lr[g.joint] * 0.7, ly[g.joint] + Math.sin(a) * lr[g.joint] * 0.7];
}
function plantAll(): void {
for (let k = 0; k < legs.length; k += 1) {
const h = homeOf(legs[k]);
legs[k].fx = h[0];
legs[k].fy = h[1];
legs[k].t = -1;
legs[k].lift = 0;
}
}
function reset(): void {
const p = pathAt(0);
heading = 0;
walkSpeed = 0;
stepCount = 0;
strideSum = 0;
for (let i = 0; i < LIZARD_JOINTS; i += 1) {
lx[i] = p[0] - 30 - i * SPINE_GAP;
ly[i] = p[1];
lr[i] = lizardRadius(i);
}
plantAll();
}
function stepping(pair: number): boolean {
for (let k = 0; k < legs.length; k += 1) {
if (legs[k].pair === pair && legs[k].t >= 0) { return true; }
}
return false;
}
function update(dt: number, tx: number, ty: number, tvx: number, tvy: number): void {
const dx = tx - lx[0];
const dy = ty - ly[0];
const dist = Math.hypot(dx, dy);
heading = wrapAngle(heading + clamp(wrapAngle(Math.atan2(dy, dx) - heading), -LIZARD_TURN * dt, LIZARD_TURN * dt));
walkSpeed = lerp(walkSpeed, WALK_SPEED * clamp((dist - 20) / 80, 0, 1), follow(4, dt));
lx[0] += Math.cos(heading) * walkSpeed * dt;
ly[0] += Math.sin(heading) * walkSpeed * dt;
followChain(lx, ly, SPINE_GAP, LIZARD_BEND, heading + Math.PI);
const fwdA = jointAngle(lx, ly, 0);
for (let k = 0; k < legs.length; k += 1) {
const g = legs[k];
if (g.t >= 0) {
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_LIFT;
if (g.t >= 1) {
g.t = -1;
g.lift = 0;
}
continue;
}
const h = homeOf(g);
const drift = Math.hypot(g.fx - h[0], g.fy - h[1]);
// 대각선 짝 교대 — 다른 짝이 발을 들고 있는 동안에는 이 짝은 기다린다
if (drift > STEP_TRIGGER && !stepping(1 - g.pair)) {
g.sx0 = g.fx;
g.sy0 = g.fy;
g.ex = h[0] + Math.cos(fwdA) * STEP_TRIGGER * STEP_LEAD;
g.ey = h[1] + Math.sin(fwdA) * STEP_TRIGGER * STEP_LEAD;
g.t = 0;
stepCount += 1;
strideSum += Math.hypot(g.ex - g.sx0, g.ey - g.sy0);
}
}
}
// 쉬는 자세 — 완만한 S 자 척추에 네 발을 놓을 자리에 딛는다
function restPose(): void {
heading = 0;
for (let i = 0; i < LIZARD_JOINTS; i += 1) {
const u = i / (LIZARD_JOINTS - 1);
lx[i] = W / 2 + 95 - i * SPINE_GAP * 0.95;
ly[i] = H / 2 + Math.sin(u * TAU) * 22;
}
followChain(lx, ly, SPINE_GAP, LIZARD_BEND, Math.PI);
plantAll();
}
const knee = new Float64Array(4);
// 다리 한 개 — 엉덩이에서 무릎, 무릎에서 발까지 두 토막. 앞다리는 팔꿈치가 뒤로, 뒷다리는 무릎이 앞으로 꺾인다
// 발을 든 동안에는 화면 위쪽으로 lift 만큼 띄우고 바닥에 작은 그림자를 남긴다
function drawLeg(g: Leg): void {
const hip = hipOf(g);
const fy = g.fy - g.lift;
const bend = g.front ? -g.side : g.side;
solveKnee(hip[0], hip[1], g.fx, fy, THIGH_LEN, SHIN_LEN, bend, true, knee);
if (g.lift > 0) {
ctx.beginPath();
ctx.ellipse(g.fx, g.fy + 2, 5, 2.5, 0, 0, TAU);
ctx.fillStyle = rgba(INK, 0.15);
ctx.fill();
}
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
for (let pass = 0; pass < 2; pass += 1) {
ctx.beginPath();
ctx.moveTo(hip[0], hip[1]);
ctx.lineTo(knee[0], knee[1]);
ctx.lineTo(knee[2], knee[3]);
ctx.lineWidth = pass === 0 ? 8.5 : 5;
ctx.strokeStyle = pass === 0 ? rgba(INK, 1) : rgba(SAGE, 1);
ctx.stroke();
}
// 발가락 셋 — 정강이 방향을 가운데로 부채꼴
const sa = Math.atan2(knee[3] - knee[1], knee[2] - knee[0]);
ctx.lineWidth = 2.2;
ctx.strokeStyle = rgba(INK, 1);
for (let t = -1; t <= 1; t += 1) {
ctx.beginPath();
ctx.moveTo(knee[2], knee[3]);
ctx.lineTo(knee[2] + Math.cos(sa + t * 0.6) * 7, knee[3] + Math.sin(sa + t * 0.6) * 7);
ctx.stroke();
}
}
function draw(): void {
clearStage(PAPER, PAPER_LOW);
const outline = bodyOutline(lx, ly, lr);
dropShadow(outline, 4, 7, 0.14);
for (let k = 0; k < legs.length; k += 1) { drawLeg(legs[k]); }
fillShape(outline, SAGE, 2.5);
// 등 점무늬 — 3번부터 꼬리 앞까지 마디마다 버터색 점, 크기는 마디 반지름에서
ctx.fillStyle = rgba(BUTTER, 1);
for (let i = 3; i < LIZARD_JOINTS - 3; i += 1) {
const a = jointAngle(lx, ly, i) + Math.PI / 2;
const off = (i % 2 === 0 ? 0.35 : -0.35) * lr[i];
ctx.beginPath();
ctx.arc(lx[i] + Math.cos(a) * off, ly[i] + Math.sin(a) * off, lr[i] * 0.28, 0, TAU);
ctx.fill();
}
const a0 = jointAngle(lx, ly, 0);
for (let s = -1; s <= 1; s += 2) {
const ex = lx[1] + Math.cos(a0 + s * Math.PI / 2) * lr[1] * 0.75 + Math.cos(a0) * 3;
const ey = ly[1] + Math.sin(a0 + s * Math.PI / 2) * lr[1] * 0.75 + Math.sin(a0) * 3;
drawEye(ex, ey, 3.6, 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>
);
}03 A Lizard That Steps Only When a Foot Falls Too Far Behind — Measured area 12.678% · 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 Pointer parallax — the math behind a diorama that gently followsIsometric CSS: 9 True-Projection 3D Dioramas
Interactions03 Fallback selection — a card that leaves a manual path when the fast one fails9 Copy Button CSS UI Patterns (Copy to Clipboard)
Interactions09 A Table Hero That Tosses to Decide Between Two ChoicesCoin Flip: 9 Canvas Toss Scenes