07An Adaptive Walk Where the Body Leans on a Slope
On a sloped floor the foot heights come from the slope equation and the body leans forward downhill and back uphill. Leaning forward downhill buys the legs time to recover and adds a near-falling urgency, on a 14 degree downhill the body leans 7 degrees forward and the step rate climbs from 1.6 Hz to about 2.0 Hz, while on a 10 degree uphill it leans 2.5 degrees back. The ankles fold by the slope angle so they stay on the ground.
Best forAn adaptation that lets a character walk naturally in a hilly scene
leanDeggroundYAtsolveFootSlopeSLOPES

<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="On a sloped floor the foot heights come from the slope equation, and the body leans forward downhill and back uphill. Downhill the legs gain time to recover so the step rate rises just enough, and the ankles fold by the slope angle to stay on the ground. The right side shows the slope angle with the resulting body lean and step rate. 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;
} // 07 — 바닥이 기울어지면 발 높이를 경사식에서 구하고 몸은 내리막에서 앞으로 오르막에서 뒤로 기운다.
// 내리막에서는 걸음 수가 그만큼 올라가고 발목은 Slope 각도만큼 꺾여 바닥에 붙는다.
const LAP_S = 8.4;
const REST_S = 2.8;
const BASE_Y = 192;
const CHAR_X = 140;
const PHX = 268;
// Slope 사이클 — 평지 → 내리막 -14° → 평지 → 오르막 +10° 로 돌아간다
const SLOPES = [0, -14, 0, 10];
let slopeNow = 0;
let baseStepHz = 1.6;
function build() {
baseStepHz = 1.6;
groundV = Math.sin(rad(GAIT.strideDeg)) * GAIT.legPx * 2 * GAIT.stepHz;
}
function slopeAt() {
// 한 판을 네 구간으로 — 구간마다 0.3s 전환 보간
const seg = 4;
const t = sceneT / LAP_S * seg;
const i = Math.min(seg - 1, Math.floor(t));
const k = clamp((t - i) * seg * 0.25 / 0.25, 0, 1);
const a = SLOPES[i];
const b = SLOPES[(i + 1) % seg];
return lerp(a, b, easeOut(clamp((t - i - 0.7) * 2.5, 0, 1)));
}
function update(dt) {
if (dt <= 0) {
return;
}
slopeNow = slopeAt();
// 내리막에서 걸음 수를 올린다 — 앞으로 기울기(-lean)가 클수록 stepHz 증가
const lean = leanDeg(slopeNow);
GAIT.stepHz = clamp(baseStepHz + Math.max(0, -lean) * 0.06, 0.8, 2.8);
}
function draw() {
drawScenery();
drawGround(BASE_Y, slopeNow);
const gy = groundYAt(CHAR_X, BASE_Y, slopeNow);
const lean = leanDeg(slopeNow);
const hipY = gy - hipHeight(legPhase(false)) * Math.cos(rad(slopeNow));
const hipX = CHAR_X + Math.sin(sceneT * 0.5) * 0;
lockPlants(hipX);
// 몸통 — leanDeg 만큼 기울여 그린다(내리막 = 앞으로 숙임)
const topX = hipX - Math.sin(rad(lean)) * 34; // lean<0(내리막)=앞으로 숙임: 머리를 진행 방향 +x 쪽으로
const topY = hipY - Math.cos(rad(lean)) * 34;
limb(hipX, hipY, topX, topY, NEU, 14);
disc(topX, topY - 8, 11, PAPER, 1);
// Body lean 표시 — 수직 점선과의 각
seg(hipX, hipY, hipX, hipY - 44, DIM, 0.4, 1);
ctx.beginPath();
ctx.arc(hipX, hipY, 26, -Math.PI / 2, -Math.PI / 2 - rad(lean), lean > 0);
ctx.strokeStyle = rgba(SUN, 0.85);
ctx.lineWidth = 2;
ctx.stroke();
for (let r = 0; r < 2; r += 1) {
const ph = legPhase(r === 1);
// 발 Contact 높이 — 경사식 groundYAt 에서. plant 도 경사면 위 위치에 얼어 있다
let px = plantXOf(r, hipX);
const fs = solveFootSlope(hipX, hipY, ph, px);
limb(hipX, hipY, fs.kneeX, fs.kneeY, r === 0 ? MINT : ROSE, 7);
limb(fs.kneeX, fs.kneeY, fs.ax, fs.ay, r === 0 ? MINT : ROSE, 6);
// 발목 — 경사각만큼 꺾여 바닥에 붙는다(스윙 중 등도 Slope 보정)
footShape(fs.ax, fs.ay, fs.planted ? slopeNow : fs.ankleDeg + slopeNow * 0.5, r === 0 ? MINT : ROSE);
if (fs.planted) {
disc(fs.ax + 4, fs.ay + 2, 3, SUN, 0.9);
}
}
// 오른쪽 패널 — Slope·기울기·Step rate
panel(PHX, 26, 196, 170);
text('Slope adaptation', PHX + 12, 40, DIM, 11, 700, 'left');
text('Slope ' + Math.round(slopeNow) + '°', PHX + 12, 62, PAPER, 13, 800, 'left');
text('Body lean ' + r1(lean) + '°', PHX + 12, 84, SUN, 12, 700, 'left');
text('Step rate ' + r1(GAIT.stepHz) + 'Hz', PHX + 12, 106, SKY, 12, 700, 'left');
// Slope 눈금 막대 — -20° ~ +20°
box(PHX + 12, 124, 146, 10, 5, [78, 72, 104], 1);
const gx = PHX + 85 + slopeNow / 20 * 73;
box(gx - 2, 122, 4, 14, 2, MINT, 1);
text('-20°', PHX + 12, 146, DIM, 9, 700, 'left');
text('0', PHX + 83, 146, DIM, 9, 700, 'center');
text('+20°', PHX + 138, 146, DIM, 9, 700, 'left');
text('Downhill leans forward and raises the step rate', PHX + 12, 170, DIM, 10, 700, 'left');
hud('SLOPE', String(Math.round(slopeNow)), (slopeNow + 20) / 40);
}
function solveFootSlope(hipX, hipY, ph, plantX) {
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 = groundYAt(plantX, BASE_Y, slopeNow);
ankleDeg = -slopeNow;
}
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 = groundYAt(ax, BASE_Y, slopeNow) - Math.abs(Math.sin(Math.PI * k)) * leg * 0.22;
ankleDeg = 90 - slopeNow;
}
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 };
}
function statusText() {
return fill('Slope {a} deg, lean {b} deg, steps {c} Hz', String(Math.round(slopeNow)), r1(leanDeg(slopeNow)), r1(GAIT.stepHz));
}
function modeId() { return modeBase(Math.min(3, Math.floor(sceneT / 2.8))); }
function speakText() { return fill('On a {a} degree slope the body walks leaning {b} degrees', String(Math.round(slopeNow)), r1(leanDeg(slopeNow))); }
function onDir(d) { keyMove(d === 0 ? 1 : d === 1 ? -1 : 0, 0); }
function onPtr(kind, x, y) { if (kind !== 0) {
return;
} baseStepHz = clamp(baseStepHz + (y > H / 2 ? 0.2 : -0.2), 0.8, 2.4); }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// SlopeAdaptLean - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./SlopeAdaptLean.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SlopeAdaptLean() {
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;
}// 07 — 바닥이 기울어지면 발 높이를 경사식에서 구하고 몸은 내리막에서 앞으로 오르막에서 뒤로 기운다.
// 내리막에서는 걸음 수가 그만큼 올라가고 발목은 경사 각도만큼 꺾여 바닥에 붙는다.
const LAP_S = 8.4;
const REST_S = 2.8;
const BASE_Y = 192;
const CHAR_X = 140;
const PHX = 268;
// 경사 사이클 — 평지 → 내리막 -14° → 평지 → 오르막 +10° 로 돌아간다
const SLOPES = [0, -14, 0, 10];
let slopeNow = 0;
let baseStepHz = 1.6;
function build(): void {
baseStepHz = 1.6;
groundV = Math.sin(rad(GAIT.strideDeg)) * GAIT.legPx * 2 * GAIT.stepHz;
}
function slopeAt(): number {
// 한 판을 네 구간으로 — 구간마다 0.3초 전환 보간
const seg = 4;
const t = sceneT / LAP_S * seg;
const i = Math.min(seg - 1, Math.floor(t));
const k = clamp((t - i) * seg * 0.25 / 0.25, 0, 1);
const a = SLOPES[i];
const b = SLOPES[(i + 1) % seg];
return lerp(a, b, easeOut(clamp((t - i - 0.7) * 2.5, 0, 1)));
}
function update(dt: number): void {
if (dt <= 0) { return; }
slopeNow = slopeAt();
// 내리막에서 걸음 수를 올린다 — 앞으로 기울기(-lean)가 클수록 stepHz 증가
const lean = leanDeg(slopeNow);
GAIT.stepHz = clamp(baseStepHz + Math.max(0, -lean) * 0.06, 0.8, 2.8);
}
function draw(): void {
drawScenery();
drawGround(BASE_Y, slopeNow);
const gy = groundYAt(CHAR_X, BASE_Y, slopeNow);
const lean = leanDeg(slopeNow);
const hipY = gy - hipHeight(legPhase(false)) * Math.cos(rad(slopeNow));
const hipX = CHAR_X + Math.sin(sceneT * 0.5) * 0;
lockPlants(hipX);
// 몸통 — leanDeg 만큼 기울여 그린다(내리막 = 앞으로 숙임)
const topX = hipX - Math.sin(rad(lean)) * 34; // lean<0(내리막)=앞으로 숙임: 머리를 진행 방향 +x 쪽으로
const topY = hipY - Math.cos(rad(lean)) * 34;
limb(hipX, hipY, topX, topY, NEU, 14);
disc(topX, topY - 8, 11, PAPER, 1);
// 몸 기울기 표시 — 수직 점선과의 각
seg(hipX, hipY, hipX, hipY - 44, DIM, 0.4, 1);
ctx.beginPath();
ctx.arc(hipX, hipY, 26, -Math.PI / 2, -Math.PI / 2 - rad(lean), lean > 0);
ctx.strokeStyle = rgba(SUN, 0.85);
ctx.lineWidth = 2;
ctx.stroke();
for (let r = 0; r < 2; r += 1) {
const ph = legPhase(r === 1);
// 발 접지 높이 — 경사식 groundYAt 에서. plant 도 경사면 위 위치에 얼어 있다
let px = plantXOf(r, hipX);
const fs = solveFootSlope(hipX, hipY, ph, px);
limb(hipX, hipY, fs.kneeX, fs.kneeY, r === 0 ? MINT : ROSE, 7);
limb(fs.kneeX, fs.kneeY, fs.ax, fs.ay, r === 0 ? MINT : ROSE, 6);
// 발목 — 경사각만큼 꺾여 바닥에 붙는다(스윙 중 등도 경사 보정)
footShape(fs.ax, fs.ay, fs.planted ? slopeNow : fs.ankleDeg + slopeNow * 0.5, r === 0 ? MINT : ROSE);
if (fs.planted) { disc(fs.ax + 4, fs.ay + 2, 3, SUN, 0.9); }
}
// 오른쪽 패널 — 경사·기울기·걸음 수
panel(PHX, 26, 196, 170);
text('경사 적응', PHX + 12, 40, DIM, 11, 700, 'left');
text('경사 ' + Math.round(slopeNow) + '°', PHX + 12, 62, PAPER, 13, 800, 'left');
text('몸 기울기 ' + r1(lean) + '°', PHX + 12, 84, SUN, 12, 700, 'left');
text('걸음 수 ' + r1(GAIT.stepHz) + 'Hz', PHX + 12, 106, SKY, 12, 700, 'left');
// 경사 눈금 막대 — -20° ~ +20°
box(PHX + 12, 124, 146, 10, 5, [78, 72, 104], 1);
const gx = PHX + 85 + slopeNow / 20 * 73;
box(gx - 2, 122, 4, 14, 2, MINT, 1);
text('-20°', PHX + 12, 146, DIM, 9, 700, 'left');
text('0', PHX + 83, 146, DIM, 9, 700, 'center');
text('+20°', PHX + 138, 146, DIM, 9, 700, 'left');
text('내리막 = 앞으로 기울고 걸음 수 상승', PHX + 12, 170, DIM, 10, 700, 'left');
hud('SLOPE', String(Math.round(slopeNow)), (slopeNow + 20) / 40);
}
// 경사판 발 풀이 — 발끝 y 를 groundYAt 으로. core solveFoot 의 경사 확장판
type FootSlope = { ax: number; ay: number; kneeX: number; kneeY: number; ankleDeg: number; planted: boolean };
function solveFootSlope(hipX: number, hipY: number, ph: number, plantX: number): FootSlope {
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 = groundYAt(plantX, BASE_Y, slopeNow);
ankleDeg = -slopeNow;
} 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 = groundYAt(ax, BASE_Y, slopeNow) - Math.abs(Math.sin(Math.PI * k)) * leg * 0.22;
ankleDeg = 90 - slopeNow;
}
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 };
}
function statusText(): string {
return fill('경사 {a}° · 기울기 {b}° · 걸음 {c}Hz', String(Math.round(slopeNow)), r1(leanDeg(slopeNow)), r1(GAIT.stepHz));
}
function modeId(): number { return modeBase(Math.min(3, Math.floor(sceneT / 2.8))); }
function speakText(): string { return fill('경사 {a}도, 몸을 {b}도로 기울여 걷는다', String(Math.round(slopeNow)), r1(leanDeg(slopeNow))); }
function onDir(d: number): void { keyMove(d === 0 ? 1 : d === 1 ? -1 : 0, 0); }
function onPtr(kind: number, x: number, y: number): void { if (kind !== 0) { return; } baseStepHz = clamp(baseStepHz + (y > H / 2 ? 0.2 : -0.2), 0.8, 2.4); }
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>
);
}07 An Adaptive Walk Where the Body Leans on a Slope — Measured area 20.211% · 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
game04 A Gridless Snake Whose Chain Body Is Dragged Behind the Head by Distance ConstraintsCSS Snake Game: 9 Boards That Find Their Own Path
game03 A Board Where Every Empty Cell Is Tinted by a Score Read Out to the Very End and the Best Cell Lights UpCSS Tic Tac Toe: 9 Boards That Count Ahead
game05 A Maze Where a Spreading Wave Computes the Distance to Every CellMaze Generator: 9 Carve-and-Solve Scenes