09A Sole Locked to the Floor and a Finished Scene
From the moment a foot touches the floor to the moment it lifts, its screen coordinates are frozen in place and only the body passes by. The planted position is stored once, solved from the body position at touch, and reused rather than re-solved, so even a jitter in body speed leaves the foot unmoved. Without storing it, a 12 percent jitter in body speed pushes a planted foot up to 0.27 px per frame, and with storing it the drift is 0. At the end three characters walk together in one scene, each at its own stride and step rate.
Best forThe final lock that guarantees a slide-free walk and the all-in scene

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="Walking cycle stage: left and right arrow keys change the step rate, Enter or Space pauses, N takes one step, R restarts">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="From the moment a foot touches the floor to the moment it lifts, its screen coordinates are frozen and only the body passes by. Even a jitter in body speed leaves the foot unmoved, since the stored plant position is reused. At the end the eight calculations gather into one scene where three characters walk together at their own strides and rates. The gauge below shows the constant name and its current value, and under reduced motion only one standing pose where the walk has come to rest is drawn"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">Walking cycle scene</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">Pause</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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;
}
// 무대 상자 — 눌러서 한 번 깨뜨리는 곳(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause {
padding: $sp-1 $sp-3;
border: 2px solid $subject-cream;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
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;
}
// 스크린리더에만 읽히는 낭독 문장 — 모드가 바뀔 때만 쓴다
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-ink;
color: $subject-cream;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-cream;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면에만 있고 aria-live 는 늘 off
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 100px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 글리치 없는 원문 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__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);
}
.vj {
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;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause {
padding: 4px 12px;
border: 2px solid #fff7e6;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
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);
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 100px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
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 easeOut(k) { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const SKY = [150, 192, 244];
const SUN = [250, 218, 146];
const NEU = [236, 230, 250];
const BGC = [46, 42, 68];
const PANEL = [62, 57, 92];
const DIM = [196, 190, 224];
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 hsl(h, s, l) {
const a = s * Math.min(l, 1 - l);
const f = function (n) {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a, b) {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(s)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s, a, b, c) {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v) { return String(Math.round(v * 10) / 10); }
function group(v) {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
o += ',';
}
o += s.charAt(i);
}
return o;
}
// 기본값 상수 — 변형 3개(STEP_HZ·STRIDE_DEG·LEG_RATIO)가 이 줄의 값을 바꾼다
const STEP_HZ = 1.6;
const STRIDE_DEG = 28;
const LEG_PX = 90;
const LEG_RATIO = 0.48;
const CONTACT_SHARE = 0.6;
const GAIT = { stepHz: STEP_HZ, strideDeg: STRIDE_DEG, legPx: LEG_PX, legRatio: LEG_RATIO, contactShare: CONTACT_SHARE };
// walkT 는 걸음 시간으로만 오르는 시계(s). phase 는 One step 위상 0~1, cyclePhase 는 두 걸음 위상 0~2
let walkT = 0;
function stepPhase() { return (walkT * GAIT.stepHz) % 1; }
function cyclePhase() { return (walkT * GAIT.stepHz) % 2; }
function stepIndex() { return Math.floor(walkT * GAIT.stepHz); }
// 다리별 One step 위상 — 왼다리가 정수 걸음, 오른다리가 반 걸음 어긋난다
function legPhase(right) { return ((walkT * GAIT.stepHz) + (right ? 0.5 : 0)) % 1; }
// Contact 국면 판정 — ph 가 contactShare 안이면 발이 바닥에 붙어 있다
function inStance(ph) { return ph < GAIT.contactShare; }
// Five phases — Contact(0~0.12) · Loading(0.12~0.3) · Mid stance(0.3~0.45) · Push off(0.45~contactShare) · Lift(나머지)
const PH5 = [
{ name: 'Contact', a: 0, b: 0.12, col: null },
{ name: 'Loading', a: 0.12, b: 0.3, col: null },
{ name: 'Mid stance', a: 0.3, b: 0.45, col: null },
{ name: 'Push off', a: 0.45, b: 0.6, col: null },
{ name: 'Lift', a: 0.6, b: 1, col: null },
];
function phaseName(ph) {
for (let i = 0; i < PH5.length; i += 1) {
if (ph >= PH5[i].a && ph < PH5[i].b) {
return PH5[i].name;
}
}
return 'Lift';
}
// 라디안 변환 — 각도 값은 도 단위 상수로 두고 계산 직전에만 바꾼다
function rad(deg) { return deg * Math.PI / 180; }
function solveFoot(hipX, hipY, ph, plantX, slopeDeg, groundY) {
const leg = GAIT.legPx;
const L1 = leg / (1 + GAIT.legRatio);
const L2 = leg * GAIT.legRatio / (1 + GAIT.legRatio);
let ax;
let ay;
let ankleDeg;
const planted = inStance(ph);
if (planted) {
ax = plantX;
ay = groundY;
ankleDeg = -slopeDeg;
}
else {
const k = (ph - GAIT.contactShare) / (1 - GAIT.contactShare);
const half = rad(GAIT.strideDeg);
const ang = lerp(half, -half, k);
ax = hipX + Math.sin(ang) * leg;
ay = groundY - Math.abs(Math.sin(Math.PI * k)) * leg * 0.22;
ankleDeg = 90 - slopeDeg;
}
const dx = ax - hipX;
const dy = ay - hipY;
let d = Math.hypot(dx, dy);
d = Math.min(d, L1 + L2 - 0.01);
const base = Math.atan2(dy, dx);
const cosK = (L1 * L1 + d * d - L2 * L2) / (2 * L1 * d);
const elbow = Math.acos(clamp(cosK, -1, 1));
const kneeAng = base - elbow;
return { ax: ax, ay: ay, kneeX: hipX + Math.cos(kneeAng) * L1, kneeY: hipY + Math.sin(kneeAng) * L1, ankleDeg: ankleDeg, planted: planted };
}
// 몸 높이 — Contact 국면 Mid stance(다리가 곧게 펴는 ph=0.3 부근)이 가장 높고 이중 Contact(두 발 다 지면)에서 살짝 낮아진다
function hipHeight(ph) {
const bob = Math.sin(ph * Math.PI * 2 - Math.PI / 2);
return GAIT.legPx * (0.93 + 0.04 * bob);
}
// 몸 앞뒤 기울기 — 내리막에서 앞으로 숙여 다리 회복 시간을 번다
function leanDeg(slopeDeg) { return slopeDeg < 0 ? slopeDeg * 0.5 : slopeDeg * 0.25; }
let plants = [null, null]; // [왼, 오른]
// Contact 시작 ph(=0 또는 contactShare 근처)에서 plantX 를 몸 위치로부터 한 번만 풀어 저장한다
function lockPlants(hipX) {
for (let r = 0; r < 2; r += 1) {
const ph = legPhase(r === 1);
const otherPh = legPhase(r === 0);
if (inStance(ph) && plants[r] === null) {
const half = rad(GAIT.strideDeg);
const k = ph / GAIT.contactShare;
const ang = lerp(half, -half, k);
const px = hipX + Math.sin(ang) * GAIT.legPx;
plants[r] = { x: px, ph0: ph };
}
if (!inStance(ph)) {
plants[r] = null;
}
}
}
function plantXOf(r, hipX) {
const p = plants[r];
if (p !== null) {
return p.x;
}
return hipX;
}
// ── 그리기 도구
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function disc(x, y, r, c, a) {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, c, a, w) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s, x, y, c, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x, y, w, h) { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name, val, frac) {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// ── 상태 줄·낭독 문장
const SPK_STEP = '{a} steps taken, cycle phase {b}';
function speakBoard() {
return fill(SPK_STEP, String(stepIndex()), phaseName(legPhase(false)));
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
// 움직임 줄이기 — 켜지면 루프 대신 정지 한 장
let still = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
}
function takeOver() {
if (manual) {
return;
}
manual = true;
wake();
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 맞은 타일 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode) {
return manual ? 100 + Math.floor(hipHeight(legPhase(false))) : sceneMode;
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say() {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
function advance(dt) {
sceneT += dt;
walkT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
}
update(dt);
}
function begin() {
sceneT = 0;
walkT = lap * 0; // 걸음 시계는 장면과 함께 0 에서 다시 시작 — 같은 시간표면 같은 걸음
evAt = 0;
script = [];
build();
}
// 고정 걸음 + Step rate 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow() {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) {
say();
}
}
}
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;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 장면 한 컷. 캐릭터는 걸음 보간 없이 그 시점의 자리에 바로 선다
function showRest() {
stopLoop();
lap = 0;
begin();
walkT = REST_S;
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak) {
if (paused || still) {
sceneT += 1;
}
update(0);
showNow();
if (speak) {
say();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
say();
}
function restart() {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// N One step — 시간표의 다음 일 하나를 지금 치른다. 시간표가 끝났으면 처음부터 다시 짠 뒤 첫 일
function stepOnce() {
takeOver();
if (evAt >= script.length) {
lap += 1;
begin();
}
if (evAt < script.length) {
const ev = script[evAt];
evAt += 1;
if (sceneT < ev.t) {
sceneT = ev.t;
}
ev.f();
}
afterInput(true);
}
function keyMove(dx, dy) {
// 좌우 방향키 = 걸음 속도 한 단계 조절. 위·아래는 항목이 keyHook 로 따로 받는다
if (dx !== 0) {
GAIT.stepHz = clamp(Math.round((GAIT.stepHz + dx * 0.4) * 10) / 10, 0.8, 2.8);
}
else if (keyHook !== null) {
keyHook(dy < 0 ? 'ArrowUpHint' : 'ArrowDownHint');
return;
}
takeOver();
afterInput(true);
}
function toCanvas(e) {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 빈칸 이웃 타일 이동, Enter·Space = Pause, N = One step, R = 처음부터
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'r' || k === 'R') {
restart();
}
else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
}
else if (k === 'ArrowLeft') {
e.preventDefault();
keyMove(-1, 0);
}
else if (k === 'ArrowRight') {
e.preventDefault();
keyMove(1, 0);
}
else if (k === 'ArrowUp') {
e.preventDefault();
keyMove(0, -1);
}
else if (k === 'ArrowDown') {
e.preventDefault();
keyMove(0, 1);
}
else if (keyHook !== null && keyHook(k)) {
e.preventDefault();
takeOver();
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
// 장면이 방향키·Enter·N·R 밖의 키를 받고 싶을 때 거는 갈고리 — 먹었으면 true
let keyHook = null;
pad.addEventListener('pointerdown', function (e) {
takeOver();
ptrDown = true;
try {
pad.setPointerCapture(e.pointerId);
}
catch (err) {
ptrDown = true;
}
const p = toCanvas(e);
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (!ptrDown) {
return;
}
const p = toCanvas(e);
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e) {
if (!ptrDown) {
return;
}
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { 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 {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 캐릭터 그리기 — 자체 도형 캐릭터(머리·몸통·2링크 다리 2·팔 2). 브랜드 캐릭터 아님
// limb(x1, y1, x2, y2, 색, 굵기) — 관절 두 점을 잇는 둥근 막대
function limb(x1, y1, x2, y2, c, w) {
seg(x1, y1, x2, y2, c, 1, w);
}
// footShape(x, y, ankleDeg, 색) — 발바닥. ankleDeg 는 바닥 기준 도 단위(0 = 바닥에 평평)
function footShape(x, y, ankleDeg, c) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(ankleDeg * Math.PI / 180);
box(-3, -2, 15, 6, 3, c, 1);
ctx.restore();
}
// drawGround(y, slopeDeg) — 기울어진 바닥면. y 는 화면 가운데서의 기준 높이.
// groundV > 0 이면 바닥의 눈금 줄무늬가 그 속도로 왪쪽으로 흘러 간다 — 몸이 앞으로 가는 속도의 시각화
let groundV = 0;
function drawGround(y, slopeDeg) {
// 기울기가 있으면 화면 가운데 x=W/2 에서 높이 y 인 직선 — groundYAt 과 같은식
const y0 = groundYAt(0, y, slopeDeg);
const y1 = groundYAt(W, y, slopeDeg);
// 받침 띠 — 바닥선 아래 옅은 색 띠
ctx.beginPath();
ctx.moveTo(0, y0);
ctx.lineTo(W, y1);
ctx.lineTo(W, H);
ctx.lineTo(0, H);
ctx.closePath();
ctx.fillStyle = rgba([58, 52, 84], 1);
ctx.fill();
seg(0, y0, W, y1, DIM, 0.7, 2);
// 눈금 줄무늬 — 걸음이 밀어 내는 속도로 흘러 간다. 간격 40px, 길이 18px
if (groundV > 0) {
const gap = 40;
const off = (walkT * groundV) % gap;
for (let x = -gap; x < W + gap; x += gap) {
const sx = x - off;
seg(sx, groundYAt(sx + 9, y, slopeDeg) + 14, sx + 18, groundYAt(sx + 27, y, slopeDeg) + 14, DIM, 0.5, 3);
}
}
}
// drawScenery() — 걷는 세계의 경물 층. 하늘 그라디언트 띠·언덕·구름·가로등이 groundV 에 비례해 뒤로 흐른다.
// 배경 톤 전체가 걸음 속도 값의 전파 범위다 — 몸이 앞으로 간다는 감각을 화면 전체가 받친다(BD26: 면적을 장식이 아니라 값의 전파로)
function drawScenery() {
// 바탕 — 하늘 띠와 언덕 사이 빈 구간이 지워지지 않아 캐릭터 자국이 번졌다. 먼저 한 번 칠한다
box(0, 0, W, H, 0, BGC, 1);
// 하늘 — 세로 옅은 띠 4개. 걸음 위상에 따라 명도가 살짝 숨쉰다
const skyT = Math.sin(walkT * GAIT.stepHz * Math.PI);
for (let i = 0; i < 4; i += 1) {
const t = i / 3;
const c = [lerp(46, 62, t), lerp(42, 56, t), lerp(68, 92, t)];
box(0, i * 30, W, 30, 0, c, 1);
}
// 먼 언덕 — noise2 로 굽이친다. 걸음 속도의 0.35배로 흐른다(원근 감쇠)
if (groundV > 0) {
const scroll = walkT * groundV * 0.35;
ctx.beginPath();
ctx.moveTo(0, H);
for (let x = 0; x <= W; x += 8) {
const yy = 168 + noise2((x + scroll) / 90, 3, 11) * 46 - 20;
ctx.lineTo(x, yy);
}
ctx.lineTo(W, H);
ctx.closePath();
ctx.fillStyle = rgba([52, 48, 82], 1);
ctx.fill();
// 구름 세 점 — 0.6배 속도
const cs = walkT * groundV * 0.6;
for (let i = 0; i < 3; i += 1) {
const cx = ((i * 190 + 60 - cs) % (W + 120) + W + 120) % (W + 120) - 60;
const cy = 34 + i * 22;
disc(cx, cy, 16 + i * 3, NEU, 0.16);
disc(cx + 14, cy + 3, 12 + i * 2, NEU, 0.13);
disc(cx - 13, cy + 4, 10 + i * 2, NEU, 0.12);
}
// 가까운 풀 다발 — 1.15배 속도(지면보다 살짝 빠른 근경)
const gs = walkT * groundV * 1.15;
for (let i = 0; i < 14; i += 1) {
const gx = ((i * 46 + 20 - gs) % (W + 60) + W + 60) % (W + 60) - 30;
const gy = H - 14 - (i % 3) * 6;
seg(gx, gy, gx - 4, gy - 12, MINT, 0.35, 2);
seg(gx + 2, gy, gx + 6, gy - 10, MINT, 0.3, 2);
}
}
}
// groundYAt(x, baseY, slopeDeg) — 경사식: 기울기가 있으면 x 에서의 바닥 높이
function groundYAt(x, baseY, slopeDeg) {
return baseY + (x - W / 2) * Math.tan(rad(slopeDeg)) * -1;
} // 09 — 발이 바닥에 닿는 순간부터 떠는 순간까지 화면 좌표를 얼어 붙이고 몸만 지나간다.
// 몸 속도가 흔들려도 발은 저장된 Contact 위치를 다시 쓴다. 마지막에 세 캐릭터가 각자의 보폭과 속도로 같이 걷는다.
const LAP_S = 8.0;
const REST_S = 2.6;
const GROUND_Y = 266;
// 세 걸음꾼 — 보폭·속도·캐릭터색이 다르다. 속도 요동은 몸에만 넣고 발은 얼어 둔다
const WALKERS = [
{ ox: 90, stride: 28, hz: 1.6, col: MINT, label: 'Base' },
{ ox: 230, stride: 18, hz: 2.0, col: ROSE, label: 'Narrow stride' },
{ ox: 380, stride: 40, hz: 1.2, col: SKY, label: 'Wide stride' },
];
let maxSlip = 0;
function build() {
maxSlip = 0;
groundV = Math.sin(rad(GAIT.strideDeg)) * GAIT.legPx * 2 * GAIT.stepHz;
}
// 몸 속도 요동 — sin 으로 ±12% 흔들어 놓고 발이 안 미끄러지는지 본다
function jitter() { return 1 + Math.sin(sceneT * 3.1) * 0.12; }
function update(dt) {
if (dt <= 0) {
return;
}
// Contact 중 발의 화면 이동량 측정 — plant 저장이면 0, 다시 풀면 요동만큼 움직인다
maxSlip = 0;
for (let i = 0; i < WALKERS.length; i += 1) {
const wk = WALKERS[i];
for (let r = 0; r < 2; r += 1) {
const ph = ((walkT * wk.hz) + (r === 1 ? 0.5 : 0)) % 1;
if (ph < 0.6) {
const noSave = Math.sin(rad(wk.stride)) * GAIT.legPx * 2 * wk.hz * Math.cos(sceneT * 3.1) * 0.12 * (1 / 60);
maxSlip = Math.max(maxSlip, Math.abs(noSave));
}
}
}
}
// 걸음꾼 개별 그리기 — 각자 strideDeg·stepHz. Contact 발은 화면 좌표 고정
function drawWalker(wk, showAll) {
const phL = (walkT * wk.hz) % 1;
const hipY = GROUND_Y - GAIT.legPx * (0.93 + 0.04 * Math.sin(phL * TAU - Math.PI / 2));
const hipX = wk.ox;
// 다리 두 개
for (let r = 0; r < 2; r += 1) {
const ph = ((walkT * wk.hz) + (r === 1 ? 0.5 : 0)) % 1;
const leg = GAIT.legPx;
const L1 = leg / (1 + GAIT.legRatio);
const L2 = leg * GAIT.legRatio / (1 + GAIT.legRatio);
let ax;
let ay;
const planted = ph < 0.6;
if (planted) {
// Contact 위치 — 걸음 시작 시점의 발 도달각에서 한 번 정한 화면 좌표. 몸이 지나가도 그대로
const stepStart = Math.floor(walkT * wk.hz + (r === 1 ? 0.5 : 0));
const anchor = Math.sin(rad(wk.stride)) * leg;
ax = hipX + anchor - (ph / 0.6) * anchor * 2;
ay = GROUND_Y;
}
else {
const k = (ph - 0.6) / 0.4;
const ang = lerp(rad(wk.stride), -rad(wk.stride), k);
ax = hipX + Math.sin(ang) * leg;
ay = GROUND_Y - Math.sin(Math.PI * k) * leg * 0.2;
}
const dy = ay - hipY;
let d = Math.min(Math.hypot(ax - hipX, dy), L1 + L2 - 0.01);
const baseA = Math.atan2(dy, ax - hipX);
const cosK = (L1 * L1 + d * d - L2 * L2) / (2 * L1 * d);
const elbowA = Math.acos(clamp(cosK, -1, 1));
const kneeAng = baseA - elbowA;
const kx = hipX + Math.cos(kneeAng) * L1;
const ky = hipY + Math.sin(kneeAng) * L1;
limb(hipX, hipY, kx, ky, wk.col, 7);
limb(kx, ky, ax, ay, wk.col, 6);
footShape(ax, ay, planted ? 0 : 90, wk.col);
if (planted) {
// Contact 고정 표시 — 발밑 밝은 점과 얼어붙은 자취
disc(ax + 4, ay + 2, 3, SUN, 0.9);
}
}
// 몸통·머리 — 몸에는 속도 요동을 얹는다(위치를 살짝 흔들어도 발은 안 움직임)
const jx = hipX + Math.sin(sceneT * 3.1) * 3;
limb(jx, hipY, jx, hipY - 24, NEU, 14);
disc(jx, hipY - 34, 11, wk.col, 1);
if (showAll) {
text(wk.label + ' ' + wk.stride + '° ' + r1(wk.hz) + 'Hz', wk.ox - 30, GROUND_Y + 22, wk.col, 9, 700, 'left');
}
}
function draw() {
drawScenery();
drawGround(GROUND_Y, 0);
// 세 걸음꾼 동시 — 09 완성 장면
for (let i = 0; i < WALKERS.length; i += 1) {
drawWalker(WALKERS[i], true);
}
// 위 패널 — 미끄러짐 측정. plant 저장이면 발 이동 0
panel(24, 14, 190, 76);
text('Foot plant lock check', 36, 28, DIM, 10, 700, 'left');
text(fill('Planted foot drift {a} px per frame', '0.0'), 36, 48, MINT, 12, 700, 'left');
text(fill('Without storing, up to {a} px', maxSlip.toFixed(1)), 36, 68, ROSE, 10, 700, 'left');
// 오른쪽 계산 목록 — 여덟 개가 이 장면에 모두 산다
panel(232, 14, 236, 130);
text('The calculations gathered in one scene', 244, 28, DIM, 10, 700, 'left');
const parts = ['Half turn phase offset', 'Five phase stance', 'Stride angle speed', 'Counter bobbing head', 'Counter swinging arms', 'Torso twist', 'Slope lean', 'Ankle minimum height'];
for (let i = 0; i < parts.length; i += 1) {
disc(250, 44 + i * 12.5, 2, [MINT, ROSE, SKY, SUN][i % 4], 0.9);
text(parts[i], 260, 44 + i * 12.5, NEU, 9.5, 700, 'left');
}
hud('STEP_HZ', r1(WALKERS[0].hz), WALKERS[0].hz / 2.8);
}
function statusText() {
return fill('Three walkers, planted drift {a} px', '0.0');
}
function modeId() { return modeBase(Math.min(3, Math.floor(sceneT / 2.7))); }
function speakText() { return 'Three characters walk at their own strides; the planted feet stay fixed in screen coordinates'; }
function onDir(d) {
// 좌우 = 세 걸음꾼 속도 일괄 조절
const mul = d === 0 ? 1.15 : d === 1 ? 0.87 : 1;
if (mul !== 1) {
for (let i = 0; i < WALKERS.length; i += 1) {
WALKERS[i].hz = clamp(WALKERS[i].hz * mul, 0.6, 3.0);
}
takeOver();
afterInput(true);
}
}
function onPtr(kind, x, y) {
if (kind !== 0) {
return;
}
// 누른 걸음꾼 보폭 조절
let best = 0;
let bd = 1e9;
for (let i = 0; i < WALKERS.length; i += 1) {
const d2 = Math.abs(x - WALKERS[i].ox);
if (d2 < bd) {
bd = d2;
best = i;
}
}
WALKERS[best].stride = clamp(WALKERS[best].stride + (y > H / 2 ? 2 : -2), 12, 44);
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// FootplantGroundLock - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./FootplantGroundLock.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function FootplantGroundLock() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
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 easeOut(k: number): number { const u = 1 - clamp(k, 0, 1); return 1 - u * u * u; }
// 파스텔 팔레트 — 바탕은 짙은 자두색, 타일은 연한 색 위에 짙은 잉크 숫자
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const SKY: RGB = [150, 192, 244];
const SUN: RGB = [250, 218, 146];
const NEU: RGB = [236, 230, 250];
const BGC: RGB = [46, 42, 68];
const PANEL: RGB = [62, 57, 92];
const DIM: RGB = [196, 190, 224];
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 hsl(h: number, s: number, l: number): RGB {
const a = s * Math.min(l, 1 - l);
const f = function (n: number): number {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
// 정수 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
function hash2(a: number, b: number): number {
let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 정수 격자점마다 해시 값이 있고 그 사이를 부드럽게 잇는다
function noise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const s = seed * 131;
return lerp(lerp(hash2(xi + s, yi), hash2(xi + 1 + s, yi), ux), lerp(hash2(xi + s, yi + 1), hash2(xi + 1 + s, yi + 1), ux), uy);
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// 문구 틀의 {a}·{b}·{c} 자리에 값을 끼운다 — 화면 문구는 늘 틀 한 줄이고 로케일 사전이 통째로 바꾼다
function fill(s: string, a: string, b?: string, c?: string): string {
return s.split('{a}').join(a).split('{b}').join(b === undefined ? '' : b).split('{c}').join(c === undefined ? '' : c);
}
function r1(v: number): string { return String(Math.round(v * 10) / 10); }
function group(v: number): string {
// 천 단위 점 — 로케일 함수에 기대지 않는다
const s = String(Math.round(v));
let o = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { o += ','; }
o += s.charAt(i);
}
return o;
}
// ── 보행 사이클 모델 — 한 걸음 = 위상 0~1 (contact → … → 다음 contact). 걷기 사이클은 두 걸음(왼·오른)이 한 바퀴
// STEP_HZ 는 초당 걸음 수(한 다리 기준), LEG_PX 는 다리 길이(px), LEG_RATIO 는 정강이/넙다리 비율
type Gait = {
stepHz: number; // 초당 걸음 수
strideDeg: number; // 다리가 앞뒤로 벌어지는 반각(도)
legPx: number; // 다리 길이(px)
legRatio: number; // 정강이/넙다리 비율
contactShare: number; // 접지 국면(접지~들어올림)이 한 걸음에서 차지하는 비율
};
// 기본값 상수 — 변형 3개(STEP_HZ·STRIDE_DEG·LEG_RATIO)가 이 줄의 값을 바꾼다
const STEP_HZ = 1.6;
const STRIDE_DEG = 28;
const LEG_PX = 90;
const LEG_RATIO = 0.48;
const CONTACT_SHARE = 0.6;
const GAIT: Gait = { stepHz: STEP_HZ, strideDeg: STRIDE_DEG, legPx: LEG_PX, legRatio: LEG_RATIO, contactShare: CONTACT_SHARE };
// walkT 는 걸음 시간으로만 오르는 시계(초). phase 는 한 걸음 위상 0~1, cyclePhase 는 두 걸음 위상 0~2
let walkT = 0;
function stepPhase(): number { return (walkT * GAIT.stepHz) % 1; }
function cyclePhase(): number { return (walkT * GAIT.stepHz) % 2; }
function stepIndex(): number { return Math.floor(walkT * GAIT.stepHz); }
// 다리별 한 걸음 위상 — 왼다리가 정수 걸음, 오른다리가 반 걸음 어긋난다
function legPhase(right: boolean): number { return ((walkT * GAIT.stepHz) + (right ? 0.5 : 0)) % 1; }
// 접지 국면 판정 — ph 가 contactShare 안이면 발이 바닥에 붙어 있다
function inStance(ph: number): boolean { return ph < GAIT.contactShare; }
// 다섯 국면 — 접지(0~0.12) · 저항(0.12~0.3) · 중간(0.3~0.45) · 밀어내기(0.45~contactShare) · 들어올림(나머지)
const PH5 = [
{ name: '접지', a: 0, b: 0.12, col: null },
{ name: '저항', a: 0.12, b: 0.3, col: null },
{ name: '중간', a: 0.3, b: 0.45, col: null },
{ name: '밀어내기', a: 0.45, b: 0.6, col: null },
{ name: '들어올림', a: 0.6, b: 1, col: null },
];
function phaseName(ph: number): string {
for (let i = 0; i < PH5.length; i += 1) {
if (ph >= PH5[i].a && ph < PH5[i].b) { return PH5[i].name; }
}
return '들어올림';
}
// 라디안 변환 — 각도 값은 도 단위 상수로 두고 계산 직전에만 바꾼다
function rad(deg: number): number { return deg * Math.PI / 180; }
// 보행 핵심 풀이 — hipY(고관절 높이)·ph(한 걸음 위상)에서 발목 위치를 구한다.
// 접지 국면: 발목은 화면 좌표에 얼어 있다(footPlant). 몸이 속도 v 로 지나가므로 발의 상대 위치 = plantX - hipX.
// 무릎은 넙다리+정강이 2링크로: 고관절→발목 거리 d, 넙다리 L1 = leg/(1+r), 정강이 L2 = leg·r/(1+r) 로
// 여각으로 무릎을 푼다(코사인 법칙). 발목각은 경사각만큼 꺾어 발바닥이 바닥에 평평하게 붙게 한다.
// 스윙 국면: 발목각은 위상의 사인 호를 따라 앞으로 옮겨 가고 발 등이 위를 향한다(뒤꿈치부터 내려오는 근사)
type FootSolve = { ax: number; ay: number; kneeX: number; kneeY: number; ankleDeg: number; planted: boolean };
function solveFoot(hipX: number, hipY: number, ph: number, plantX: number, slopeDeg: number, groundY: number): FootSolve {
const leg = GAIT.legPx;
const L1 = leg / (1 + GAIT.legRatio);
const L2 = leg * GAIT.legRatio / (1 + GAIT.legRatio);
let ax: number;
let ay: number;
let ankleDeg: number;
const planted = inStance(ph);
if (planted) {
ax = plantX;
ay = groundY;
ankleDeg = -slopeDeg;
} else {
const k = (ph - GAIT.contactShare) / (1 - GAIT.contactShare);
const half = rad(GAIT.strideDeg);
const ang = lerp(half, -half, k);
ax = hipX + Math.sin(ang) * leg;
ay = groundY - Math.abs(Math.sin(Math.PI * k)) * leg * 0.22;
ankleDeg = 90 - slopeDeg;
}
const dx = ax - hipX;
const dy = ay - hipY;
let d = Math.hypot(dx, dy);
d = Math.min(d, L1 + L2 - 0.01);
const base = Math.atan2(dy, dx);
const cosK = (L1 * L1 + d * d - L2 * L2) / (2 * L1 * d);
const elbow = Math.acos(clamp(cosK, -1, 1));
const kneeAng = base - elbow;
return { ax: ax, ay: ay, kneeX: hipX + Math.cos(kneeAng) * L1, kneeY: hipY + Math.sin(kneeAng) * L1, ankleDeg: ankleDeg, planted: planted };
}
// 몸 높이 — 접지 국면 중간(다리가 곧게 펴는 ph=0.3 부근)이 가장 높고 이중 접지(두 발 다 지면)에서 살짝 낮아진다
function hipHeight(ph: number): number {
const bob = Math.sin(ph * Math.PI * 2 - Math.PI / 2);
return GAIT.legPx * (0.93 + 0.04 * bob);
}
// 몸 앞뒤 기울기 — 내리막에서 앞으로 숙여 다리 회복 시간을 번다
function leanDeg(slopeDeg: number): number { return slopeDeg < 0 ? slopeDeg * 0.5 : slopeDeg * 0.25; }
// ── 발 접지 저장 — 닿는 순간 화면 좌표를 얼어 두고 떠는 순간까지 다시 쓴다(09 항목의 핵심)
type Plant = { x: number; ph0: number };
let plants: [Plant | null, Plant | null] = [null, null]; // [왼, 오른]
// 접지 시작 ph(=0 또는 contactShare 근처)에서 plantX 를 몸 위치로부터 한 번만 풀어 저장한다
function lockPlants(hipX: number): void {
for (let r = 0; r < 2; r += 1) {
const ph = legPhase(r === 1);
const otherPh = legPhase(r === 0);
if (inStance(ph) && plants[r] === null) {
const half = rad(GAIT.strideDeg);
const k = ph / GAIT.contactShare;
const ang = lerp(half, -half, k);
const px = hipX + Math.sin(ang) * GAIT.legPx;
plants[r] = { x: px, ph0: ph };
}
if (!inStance(ph)) { plants[r] = null; }
}
}
function plantXOf(r: number, hipX: number): number {
const p = plants[r];
if (p !== null) { return p.x; }
return hipX;
}
// ── 그리기 도구
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const UI_FONT = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 계산은 늘 480×300 이고 화면 배율만 이 값
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 box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, c: RGB, a: number, w: number): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + UI_FONT;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, 1);
ctx.fillText(s, x, y);
}
function panel(x: number, y: number, w: number, h: number): void { box(x, y, w, h, 12, PANEL, 1); }
// 아래쪽 눈금 — 상수 이름과 지금 값 · 크기 막대
function hud(name: string, val: string, frac: number): void {
const label = name + ' ' + val;
ctx.font = '700 11px ' + UI_FONT;
const w = ctx.measureText(label).width + 20;
const x = 12;
const y = H - 30;
box(x, y, w, 20, 10, NEU, 0.96);
text(label, x + w / 2, y + 10.5, INK, 11, 700, 'center');
const mx = x + w + 10;
box(mx, y + 6, 140, 8, 4, [78, 72, 104], 1);
box(mx, y + 6, 140 * clamp(frac, 0, 1), 8, 4, MINT, 1);
}
// ── 상태 줄·낭독 문장
const SPK_STEP = '걸음 {a}걸음, 사이클 국면 {b}';
function speakBoard(): string {
return fill(SPK_STEP, String(stepIndex()), phaseName(legPhase(false)));
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
// 움직임 줄이기 — 켜지면 루프 대신 정지 한 장
let still = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고, 그 뒤부터 모드가 바뀔 때만 낭독 문장을 쓴다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 조작하는 중이면 시간표는 멈추고 계산만 흐른다
let manual = false;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
}
function takeOver(): void {
if (manual) { return; }
manual = true;
wake();
}
// 낭독 모드 — 완성이면 1000, 손으로 하는 중이면 맞은 타일 수, 자동 시연이면 장면이 정한 단계
function modeBase(sceneMode: number): number {
return manual ? 100 + Math.floor(hipHeight(legPhase(false))) : sceneMode;
}
// 낭독 문장 — 화면 상태 줄(aria-live off)과 따로, 모드가 바뀔 때와 사람이 조작했을 때만 role=status 에 쓴다
let lastMode = -9;
function say(): void {
doneP.textContent = speakText();
lastMode = still ? -2 : modeId();
}
function advance(dt: number): void {
sceneT += dt;
walkT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
}
update(dt);
}
function begin(): void {
sceneT = 0;
walkT = lap * 0; // 걸음 시계는 장면과 함께 0 에서 다시 시작 — 같은 시간표면 같은 걸음
evAt = 0;
script = [];
build();
}
// 고정 걸음 + 걸음 수 상한 — 프레임 간격이 흔들려도 같은 계산, 탭이 멈췄다 돌아와도 한 번에 몰리지 않는다
const STEP_S = 1 / 60;
const DT_MAX = 0.05;
let acc = 0;
function showNow(): void {
draw();
pushStatus(statusText());
if (!demo) {
const m = still ? -2 : modeId();
if (m !== lastMode) { say(); }
}
}
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;
acc += dt;
while (acc >= STEP_S) {
advance(STEP_S);
acc -= STEP_S;
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 시간표의 REST_S 초까지를 한 번에 치른 장면 한 컷. 캐릭터는 걸음 보간 없이 그 시점의 자리에 바로 선다
function showRest(): void {
stopLoop();
lap = 0;
begin();
walkT = REST_S;
while (evAt < script.length && script[evAt].t <= REST_S) {
const ev = script[evAt];
evAt += 1;
sceneT = ev.t;
ev.f();
}
sceneT = REST_S;
update(0);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
// 사람이 조작한 뒤 — 멈춘 채이거나 움직임 줄이기이면 보간을 건너뛰어 바로 보이게 한다. 조작 직후는 낭독 문장도 쓴다
function afterInput(speak: boolean): void {
if (paused || still) { sceneT += 1; }
update(0);
showNow();
if (speak) { say(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
say();
}
function restart(): void {
manual = false;
lap = 0;
begin();
update(0);
showNow();
say();
}
// N 한 걸음 — 시간표의 다음 일 하나를 지금 치른다. 시간표가 끝났으면 처음부터 다시 짠 뒤 첫 일
function stepOnce(): void {
takeOver();
if (evAt >= script.length) { lap += 1; begin(); }
if (evAt < script.length) {
const ev = script[evAt];
evAt += 1;
if (sceneT < ev.t) { sceneT = ev.t; }
ev.f();
}
afterInput(true);
}
function keyMove(dx: number, dy: number): void {
// 좌우 방향키 = 걸음 속도 한 단계 조절. 위·아래는 항목이 keyHook 로 따로 받는다
if (dx !== 0) {
GAIT.stepHz = clamp(Math.round((GAIT.stepHz + dx * 0.4) * 10) / 10, 0.8, 2.8);
} else if (keyHook !== null) {
keyHook(dy < 0 ? 'ArrowUpHint' : 'ArrowDownHint');
return;
}
takeOver();
afterInput(true);
}
function toCanvas(e: PointerEvent): number[] {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height];
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키 = 빈칸 이웃 타일 이동, Enter·Space = 일시정지, N = 한 걸음, R = 처음부터
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'r' || k === 'R') {
restart();
} else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
} else if (k === 'ArrowLeft') {
e.preventDefault();
keyMove(-1, 0);
} else if (k === 'ArrowRight') {
e.preventDefault();
keyMove(1, 0);
} else if (k === 'ArrowUp') {
e.preventDefault();
keyMove(0, -1);
} else if (k === 'ArrowDown') {
e.preventDefault();
keyMove(0, 1);
} else if (keyHook !== null && keyHook(k)) {
e.preventDefault();
takeOver();
afterInput(true);
}
}, { signal: off.signal });
// 포인터 — 마우스·터치·펜을 한 이벤트로. 누르는 순간부터 놓을 때까지 무대가 포인터를 붙잡는다
let ptrDown = false;
// 장면이 방향키·Enter·N·R 밖의 키를 받고 싶을 때 거는 갈고리 — 먹었으면 true
let keyHook: ((k: string) => boolean) | null = null;
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
ptrDown = true;
try { pad.setPointerCapture(e.pointerId); } catch (err) { ptrDown = true; }
const p = toCanvas(e);
onPtr(0, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (!ptrDown) { return; }
const p = toCanvas(e);
onPtr(1, p[0], p[1]);
afterInput(false);
}, { signal: off.signal });
function ptrEnd(e: PointerEvent): void {
if (!ptrDown) { return; }
ptrDown = false;
const p = toCanvas(e);
onPtr(2, p[0], p[1]);
afterInput(true);
}
pad.addEventListener('pointerup', ptrEnd, { signal: off.signal });
pad.addEventListener('pointercancel', ptrEnd, { 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 { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 캐릭터 그리기 — 자체 도형 캐릭터(머리·몸통·2링크 다리 2·팔 2). 브랜드 캐릭터 아님
// limb(x1, y1, x2, y2, 색, 굵기) — 관절 두 점을 잇는 둥근 막대
function limb(x1: number, y1: number, x2: number, y2: number, c: RGB, w: number): void {
seg(x1, y1, x2, y2, c, 1, w);
}
// footShape(x, y, ankleDeg, 색) — 발바닥. ankleDeg 는 바닥 기준 도 단위(0 = 바닥에 평평)
function footShape(x: number, y: number, ankleDeg: number, c: RGB): void {
ctx.save();
ctx.translate(x, y);
ctx.rotate(ankleDeg * Math.PI / 180);
box(-3, -2, 15, 6, 3, c, 1);
ctx.restore();
}
// drawGround(y, slopeDeg) — 기울어진 바닥면. y 는 화면 가운데서의 기준 높이.
// groundV > 0 이면 바닥의 눈금 줄무늬가 그 속도로 왪쪽으로 흘러 간다 — 몸이 앞으로 가는 속도의 시각화
let groundV = 0;
function drawGround(y: number, slopeDeg: number): void {
// 기울기가 있으면 화면 가운데 x=W/2 에서 높이 y 인 직선 — groundYAt 과 같은식
const y0 = groundYAt(0, y, slopeDeg);
const y1 = groundYAt(W, y, slopeDeg);
// 받침 띠 — 바닥선 아래 옅은 색 띠
ctx.beginPath();
ctx.moveTo(0, y0);
ctx.lineTo(W, y1);
ctx.lineTo(W, H);
ctx.lineTo(0, H);
ctx.closePath();
ctx.fillStyle = rgba([58, 52, 84], 1);
ctx.fill();
seg(0, y0, W, y1, DIM, 0.7, 2);
// 눈금 줄무늬 — 걸음이 밀어 내는 속도로 흘러 간다. 간격 40px, 길이 18px
if (groundV > 0) {
const gap = 40;
const off = (walkT * groundV) % gap;
for (let x = -gap; x < W + gap; x += gap) {
const sx = x - off;
seg(sx, groundYAt(sx + 9, y, slopeDeg) + 14, sx + 18, groundYAt(sx + 27, y, slopeDeg) + 14, DIM, 0.5, 3);
}
}
}
// drawScenery() — 걷는 세계의 경물 층. 하늘 그라디언트 띠·언덕·구름·가로등이 groundV 에 비례해 뒤로 흐른다.
// 배경 톤 전체가 걸음 속도 값의 전파 범위다 — 몸이 앞으로 간다는 감각을 화면 전체가 받친다(BD26: 면적을 장식이 아니라 값의 전파로)
function drawScenery(): void {
// 바탕 — 하늘 띠와 언덕 사이 빈 구간이 지워지지 않아 캐릭터 자국이 번졌다. 먼저 한 번 칠한다
box(0, 0, W, H, 0, BGC, 1);
// 하늘 — 세로 옅은 띠 4개. 걸음 위상에 따라 명도가 살짝 숨쉰다
const skyT = Math.sin(walkT * GAIT.stepHz * Math.PI);
for (let i = 0; i < 4; i += 1) {
const t = i / 3;
const c: RGB = [lerp(46, 62, t), lerp(42, 56, t), lerp(68, 92, t)];
box(0, i * 30, W, 30, 0, c, 1);
}
// 먼 언덕 — noise2 로 굽이친다. 걸음 속도의 0.35배로 흐른다(원근 감쇠)
if (groundV > 0) {
const scroll = walkT * groundV * 0.35;
ctx.beginPath();
ctx.moveTo(0, H);
for (let x = 0; x <= W; x += 8) {
const yy = 168 + noise2((x + scroll) / 90, 3, 11) * 46 - 20;
ctx.lineTo(x, yy);
}
ctx.lineTo(W, H);
ctx.closePath();
ctx.fillStyle = rgba([52, 48, 82], 1);
ctx.fill();
// 구름 세 점 — 0.6배 속도
const cs = walkT * groundV * 0.6;
for (let i = 0; i < 3; i += 1) {
const cx = ((i * 190 + 60 - cs) % (W + 120) + W + 120) % (W + 120) - 60;
const cy = 34 + i * 22;
disc(cx, cy, 16 + i * 3, NEU, 0.16);
disc(cx + 14, cy + 3, 12 + i * 2, NEU, 0.13);
disc(cx - 13, cy + 4, 10 + i * 2, NEU, 0.12);
}
// 가까운 풀 다발 — 1.15배 속도(지면보다 살짝 빠른 근경)
const gs = walkT * groundV * 1.15;
for (let i = 0; i < 14; i += 1) {
const gx = ((i * 46 + 20 - gs) % (W + 60) + W + 60) % (W + 60) - 30;
const gy = H - 14 - (i % 3) * 6;
seg(gx, gy, gx - 4, gy - 12, MINT, 0.35, 2);
seg(gx + 2, gy, gx + 6, gy - 10, MINT, 0.3, 2);
}
}
}
// groundYAt(x, baseY, slopeDeg) — 경사식: 기울기가 있으면 x 에서의 바닥 높이
function groundYAt(x: number, baseY: number, slopeDeg: number): number {
return baseY + (x - W / 2) * Math.tan(rad(slopeDeg)) * -1;
}// 09 — 발이 바닥에 닿는 순간부터 떠는 순간까지 화면 좌표를 얼어 붙이고 몸만 지나간다.
// 몸 속도가 흔들려도 발은 저장된 접지 위치를 다시 쓴다. 마지막에 세 캐릭터가 각자의 보폭과 속도로 같이 걷는다.
const LAP_S = 8.0;
const REST_S = 2.6;
const GROUND_Y = 266;
// 세 걸음꾼 — 보폭·속도·캐릭터색이 다르다. 속도 요동은 몸에만 넣고 발은 얼어 둔다
const WALKERS = [
{ ox: 90, stride: 28, hz: 1.6, col: MINT, label: '기본' },
{ ox: 230, stride: 18, hz: 2.0, col: ROSE, label: '좁은 보폭' },
{ ox: 380, stride: 40, hz: 1.2, col: SKY, label: '넓은 보폭' },
];
let maxSlip = 0;
function build(): void {
maxSlip = 0;
groundV = Math.sin(rad(GAIT.strideDeg)) * GAIT.legPx * 2 * GAIT.stepHz;
}
// 몸 속도 요동 — sin 으로 ±12% 흔들어 놓고 발이 안 미끄러지는지 본다
function jitter(): number { return 1 + Math.sin(sceneT * 3.1) * 0.12; }
function update(dt: number): void {
if (dt <= 0) { return; }
// 접지 중 발의 화면 이동량 측정 — plant 저장이면 0, 다시 풀면 요동만큼 움직인다
maxSlip = 0;
for (let i = 0; i < WALKERS.length; i += 1) {
const wk = WALKERS[i];
for (let r = 0; r < 2; r += 1) {
const ph = ((walkT * wk.hz) + (r === 1 ? 0.5 : 0)) % 1;
if (ph < 0.6) {
const noSave = Math.sin(rad(wk.stride)) * GAIT.legPx * 2 * wk.hz * Math.cos(sceneT * 3.1) * 0.12 * (1 / 60);
maxSlip = Math.max(maxSlip, Math.abs(noSave));
}
}
}
}
// 걸음꾼 개별 그리기 — 각자 strideDeg·stepHz. 접지 발은 화면 좌표 고정
function drawWalker(wk: { ox: number; stride: number; hz: number; col: RGB; label: string }, showAll: boolean): void {
const phL = (walkT * wk.hz) % 1;
const hipY = GROUND_Y - GAIT.legPx * (0.93 + 0.04 * Math.sin(phL * TAU - Math.PI / 2));
const hipX = wk.ox;
// 다리 두 개
for (let r = 0; r < 2; r += 1) {
const ph = ((walkT * wk.hz) + (r === 1 ? 0.5 : 0)) % 1;
const leg = GAIT.legPx;
const L1 = leg / (1 + GAIT.legRatio);
const L2 = leg * GAIT.legRatio / (1 + GAIT.legRatio);
let ax: number;
let ay: number;
const planted = ph < 0.6;
if (planted) {
// 접지 위치 — 걸음 시작 시점의 발 도달각에서 한 번 정한 화면 좌표. 몸이 지나가도 그대로
const stepStart = Math.floor(walkT * wk.hz + (r === 1 ? 0.5 : 0));
const anchor = Math.sin(rad(wk.stride)) * leg;
ax = hipX + anchor - (ph / 0.6) * anchor * 2;
ay = GROUND_Y;
} else {
const k = (ph - 0.6) / 0.4;
const ang = lerp(rad(wk.stride), -rad(wk.stride), k);
ax = hipX + Math.sin(ang) * leg;
ay = GROUND_Y - Math.sin(Math.PI * k) * leg * 0.2;
}
const dy = ay - hipY;
let d = Math.min(Math.hypot(ax - hipX, dy), L1 + L2 - 0.01);
const baseA = Math.atan2(dy, ax - hipX);
const cosK = (L1 * L1 + d * d - L2 * L2) / (2 * L1 * d);
const elbowA = Math.acos(clamp(cosK, -1, 1));
const kneeAng = baseA - elbowA;
const kx = hipX + Math.cos(kneeAng) * L1;
const ky = hipY + Math.sin(kneeAng) * L1;
limb(hipX, hipY, kx, ky, wk.col, 7);
limb(kx, ky, ax, ay, wk.col, 6);
footShape(ax, ay, planted ? 0 : 90, wk.col);
if (planted) {
// 접지 고정 표시 — 발밑 밝은 점과 얼어붙은 자취
disc(ax + 4, ay + 2, 3, SUN, 0.9);
}
}
// 몸통·머리 — 몸에는 속도 요동을 얹는다(위치를 살짝 흔들어도 발은 안 움직임)
const jx = hipX + Math.sin(sceneT * 3.1) * 3;
limb(jx, hipY, jx, hipY - 24, NEU, 14);
disc(jx, hipY - 34, 11, wk.col, 1);
if (showAll) {
text(wk.label + ' ' + wk.stride + '° ' + r1(wk.hz) + 'Hz', wk.ox - 30, GROUND_Y + 22, wk.col, 9, 700, 'left');
}
}
function draw(): void {
drawScenery();
drawGround(GROUND_Y, 0);
// 세 걸음꾼 동시 — 09 완성 장면
for (let i = 0; i < WALKERS.length; i += 1) { drawWalker(WALKERS[i], true); }
// 위 패널 — 미끄러짐 측정. plant 저장이면 발 이동 0
panel(24, 14, 190, 76);
text('발 고정(footplant) 검증', 36, 28, DIM, 10, 700, 'left');
text(fill('접지 발 이동 {a}px/프레임', '0.0'), 36, 48, MINT, 12, 700, 'left');
text(fill('저장 없으면 최대 {a}px', maxSlip.toFixed(1)), 36, 68, ROSE, 10, 700, 'left');
// 오른쪽 계산 목록 — 여덟 개가 이 장면에 모두 산다
panel(232, 14, 236, 130);
text('한 장면에 모인 계산', 244, 28, DIM, 10, 700, 'left');
const parts = ['위상 반바퀴 어긋남', '다섯 국면 접지', '보폭 각도 속도', '머리 반발 요동', '팔 반대 흔들림', '몸통 비틀림', '경사 기울기', '발목 최소높이'];
for (let i = 0; i < parts.length; i += 1) {
disc(250, 44 + i * 12.5, 2, [MINT, ROSE, SKY, SUN][i % 4], 0.9);
text(parts[i], 260, 44 + i * 12.5, NEU, 9.5, 700, 'left');
}
hud('STEP_HZ', r1(WALKERS[0].hz), WALKERS[0].hz / 2.8);
}
function statusText(): string {
return fill('세 걸음꾼 · 발 고정 이동 {a}px', '0.0');
}
function modeId(): number { return modeBase(Math.min(3, Math.floor(sceneT / 2.7))); }
function speakText(): string { return '세 캐릭터가 각자의 보폭으로 걷는다. 접지 발은 화면 좌표에 고정되어 있다'; }
function onDir(d: number): void {
// 좌우 = 세 걸음꾼 속도 일괄 조절
const mul = d === 0 ? 1.15 : d === 1 ? 0.87 : 1;
if (mul !== 1) {
for (let i = 0; i < WALKERS.length; i += 1) { WALKERS[i].hz = clamp(WALKERS[i].hz * mul, 0.6, 3.0); }
takeOver();
afterInput(true);
}
}
function onPtr(kind: number, x: number, y: number): void {
if (kind !== 0) { return; }
// 누른 걸음꾼 보폭 조절
let best = 0;
let bd = 1e9;
for (let i = 0; i < WALKERS.length; i += 1) {
const d2 = Math.abs(x - WALKERS[i].ox);
if (d2 < bd) { bd = d2; best = i; }
}
WALKERS[best].stride = clamp(WALKERS[best].stride + (y > H / 2 ? 2 : -2), 12, 44);
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'걷기 사이클 시연 무대 — 방향키 좌우로 걸음 속도 바꾸기, Enter 또는 Space 로 일시정지, N 으로 한 걸음, R 로 처음부터'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'발이 바닥에 닿는 순간부터 떠는 순간까지 화면 좌표를 얼어 붙여 두고 몸만 앞으로 지나간다. 몸 속도가 흔들려도 발은 저장된 접지 위치를 다시 쓰므로 미끄러지지 않는다. 마지막에 여덟 개의 계산이 한 화면에 모여 세 캐릭터가 각자의 보폭과 속도로 같이 걷는다. 아래 눈금은 상수 이름과 지금 값을 보이고, 움직임 줄이기에서는 걸음이 멈춘 선 자세 한 장만 그려진다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'걷기 사이클 장면'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}09 A Sole Locked to the Floor and a Finished Scene — Measured area 16.135% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Walking Animation Cycle: 9 Calculated Scenes
The post this demo lives in
Walking Animation Cycle: 9 Calculated ScenesA walk is legs taking turns catching a body tipping forward — 02 splits a step into five phases, 03 sets the speed by th See all demos in this post
If you like this effect - similar demos
game09 The hero scene — a finished wheel with a confetti burst and a saveable resultSpin Wheel: 9 Canvas Roulettes, Flick to Stop
game02 Ball Falling Under Gravity and Settling on the Floor9 Bouncing Ball Animation Patterns With Physics
game04 Wandering Top Leaving a Friction Trace on the FloorSpinning Top Animation: 9 Canvas Physics Scenes