# AI 꾸러미 — 절차 걷기 · 뛰기 (애니메이션 파일 없이) — Procedural locomotion (gait cycle)
> 속도 하나에서 걸음 폭 · 박자 · 팔 흔들기 · 골반 오르내림을 식으로 계산하고 발은 두 뼈 IK 로 놓아, 애니메이션 파일 없이 가만히 → 걷기 → 뛰기가 이어진다.  
> 견본: https://ai-techstudio.web.app/#t/i458

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: 절차 걷기 · 뛰기 (애니메이션 파일 없이) — Procedural locomotion (gait cycle)

#### 1. 목표
점토 곰 (러닝머신 땅)을(를) 애니메이션 파일 없이 걷고 뛰게 해 줘 — 속도 하나로 걸음 폭 · 박자 · 팔 · 골반이 저절로, 발은 땅에서 미끄러지지 않게. 분위기는 말랑하고 귀여운.

#### 2. 핵심 기술 용어
- **Procedural locomotion (gait cycle)** — 식으로 만드는 걷기 · 뛰기 (걸음 주기)
- **Stance / swing phase · duty factor** — 발이 땅에 있는 구간 / 떠 있는 구간 · 디딤 비율
- **Two-bone IK foot placement** — 발목 목표를 정하고 넓적다리 · 정강이를 IK 로
- **Pelvis bob · arm counter-swing** — 골반 오르내림 · 다리와 반대로 팔 흔들기

#### 3. 환경
- 플랫폼: three.js r186 (ES 모듈 · TypeScript, `import * as THREE from "three"`), WebGL2, 외부 라이브러리 추가 없이
- 화면: 3D · 브라우저 — PC 와 폰(가로 844×390 · 세로 390×844) 모두, 60fps 목표

#### 4. 조건
- 속도는 바로 바꾸지 말고 부드럽게 따라가기 (damp — 시간 상수 0.6초)
- 걸음 폭 · 디딤 비율 · 숙임 · 팔 흔들기를 모두 속도 함수로 — 걷기 ↔ 뛰기 사이는 sstep 으로 섞기
- 디딤 중 발의 뒤로 가는 속도 = 땅이 흐르는 속도 (발 미끄러짐 없음)
- 발은 두 뼈 IK 로, 무릎은 앞쪽 pole
- 발 디딤 자리에 고리를 찍어, 고리 안에서 발이 안 움직이는지 보여 준다

#### 5. 완성 기준 (이게 보이면 성공)
- 가만히(숨쉬기) → 걷기 → 뛰기 → 걷기 → 가만히가 14초마다 끊김 없이 이어진다
- 뛸 때 걸음이 넓어지고 박자가 빨라지며, 몸이 숙고, 팔꿈치가 굽어 크게 흔들린다
- 발 디딤 고리가 땅과 함께 흘러가고 발은 고리 안에 머문다 (미끄러지지 않음)
- 「속도」 0 ~ 3.2 슬라이더로 어떤 속도든 자연스러운 걸음이 나온다

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 걸음 주기 phase 를 0 → 1 로 돌린다: phase += (속도 ÷ 한 주기 길이 Lc) × dt. 오른발은 반 주기 뒤.
- 디딤 비율 D(걷기 0.62 → 뛰기 0.34): phase < D 면 발이 땅에서 뒤로 밀리고(z = Lc·D·(0.5 − u)), 나머지는 들어 올려 앞으로.
- 땅이 속도 × dt 만큼 흐르고 디딤 중 발도 같은 속도로 뒤로 가므로 발이 미끄러지지 않는다.
- 발목 목표를 정하면 두 뼈 IK 가 넓적다리 · 정강이 각도를 정한다. 무릎 방향은 앞(pole).
- 골반은 주기의 두 배 박자로 오르내리고, 몸은 앞으로 숙이고(뛰기 0.16), 팔은 다리와 반대로 흔들며 뛸 땐 팔꿈치를 굽힌다.

## 핵심 코드 — 속도 → 걸음 주기 → 발목 목표 → 두 뼈 IK
(발췌: demos/demosRigA.ts i458 update() 를 정리)
```ts
cur += (want - cur) * (1 - Math.exp(-dt / 0.6));      // 속도는 부드럽게
const v = cur < 0.01 ? 0 : cur;
const run = sstep(1.3, 2.1, v);                          // 0 걷기 → 1 뛰기
const g = sstep(0.02, 0.35, v);                          // 0 가만히 → 1 움직임
const Lc = lerp(0.3 + 0.5 * Math.min(v, 1) + 0.12 * Math.max(v - 1, 0), 0.8 + 0.3 * v, run); // 한 주기 길이
const D = lerp(0.62, 0.34, run);                         // 디딤 비율
phase = frac(phase + (v / Lc) * dt);
groundTex.offset.y -= (v * dt) / TILE;                   // 땅이 흐름

const mid = 4 * Math.PI * (phase - D / 2);
hips.position.y = 0.8 - (0.012 + 0.02 * Math.min(v, 1) + 0.075 * run) * g
  + lerp(0.022 * Math.min(v, 1) * Math.cos(mid), -0.04 * Math.cos(mid), run) * g;

['L', 'R'].forEach((s, i) => {
  const side = i === 0 ? 1 : -1;
  const p = frac(phase + (i === 0 ? 0 : 0.5));
  const half = (Lc * D) / 2;
  let z: number, lift = 0;
  if (p < D) z = Lc * D * (0.5 - p / D);                 // 디딤: 땅과 같은 속도로 뒤로
  else {
    const u = (p - D) / (1 - D);                          // 들어서 앞으로
    z = -half + 2 * half * (0.5 - 0.5 * Math.cos(Math.PI * u));
    lift = (0.05 + 0.05 * Math.min(v, 1) + 0.07 * run) * Math.sin(Math.PI * u) ** 1.2;
  }
  target.set(side * 0.16, ANKLE_Y + lift * g, z * g);
  char.localToWorld(target);
  twoBoneIK(bone('thigh.' + s), bone('shin.' + s), ANKLE_LOCAL, target, new THREE.Vector3(side * 0.12, 0, 1));
});
// 팔: 다리와 반대로 — upperArm.L = qX(armA * cos(2π·phase)), 뛸 땐 팔꿈치 0.22 → 1.5 rad
```

## 흔한 실수 · 확인 목록
- [ ] **디딤 중 발이 뒤로 가는 속도가 땅과 다르면 발이 스케이트 타듯 미끄러진다** — 한 주기 동안 땅이 가는 거리 = Lc, 디딤 동안 발이 뒤로 가는 거리 = Lc·D 가 되게 박자(v / Lc)를 맞춘다.
- [ ] **속도를 바로 바꾸면 걸음이 툭 끊긴다** — 속도를 damp 로 부드럽게 따라가고, 걷기 ↔ 뛰기 값은 sstep 으로 섞는다.
- [ ] **IK 무릎이 뒤로 꺾인다** — pole 벡터를 캐릭터 앞쪽(+z)으로 준다. 견본은 (±0.12, 0, 1) 을 캐릭터 회전으로 돌려 쓴다.
- [ ] **가만히 있을 때 완전히 멈추면 인형 같다** — 움직임 정도 g 가 0 일 때도 골반 · 가슴을 sin(t × 2.4) 로 아주 조금 숨쉬게 한다.

## 완성 기준 체크리스트
- [ ] 가만히(숨쉬기) → 걷기 → 뛰기 → 걷기 → 가만히가 14초마다 끊김 없이 이어진다
- [ ] 뛸 때 걸음이 넓어지고 박자가 빨라지며, 몸이 숙고, 팔꿈치가 굽어 크게 흔들린다
- [ ] 발 디딤 고리가 땅과 함께 흘러가고 발은 고리 안에 머문다 (미끄러지지 않음)
- [ ] 「속도」 0 ~ 3.2 슬라이더로 어떤 속도든 자연스러운 걸음이 나온다

## 이 기술 정보
- id: `i458` · 분류: 3D 모델 · 캐릭터 › 캐릭터 · 리깅 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 뼈 몇 개의 회전 계산 + 다리 두 개 IK — 한 캐릭터당 0.1ms 도 안 된다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i458
- 쓰면 좋을 때: 속도가 계속 바뀌는 캐릭터 — 클립 섞기 없이 부드럽게 / 지형 · 크기가 다른 캐릭터마다 걸음을 맞춰야 할 때 / 애니메이션 파일을 만들 수 없을 때
- 쓰지 말 때: 춤 · 공격 같은 개성 있는 동작 — 손으로 만든 클립이 낫다(i464) / 사실적인 사람 걸음 — 모션 캡처 클립이 자연스럽다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i458 견본 항목 — `src/demos/demosRigA.ts:1309`
```ts
  i458: {
    kind: '3d',
    caption: '속도 하나로 걸음 폭 · 박자 · 팔 흔들기 · 골반 오르내림이 저절로 — 가만히 → 걷기 → 뛰기, 발은 땅에서 안 미끄러져요',
    make() {
      const st = makeStage([4.3, 1.8, 3.0], [0, 1.0, 0.1], 30, 2.0, '#2c3a5e', '#11182a');
      // 움직이는 땅 (러닝머신처럼) — 타일 하나 = 0.4
      const tc = document.createElement('canvas');
      tc.width = tc.height = 128;
      const tg = tc.getContext('2d')!;
      tg.fillStyle = '#6d7cb4';
      tg.fillRect(0, 0, 128, 128);
      tg.fillStyle = '#5c6aa0';
      tg.fillRect(0, 0, 64, 64);
      tg.fillRect(64, 64, 64, 64);
      tg.strokeStyle = 'rgba(255,255,255,0.10)';
      tg.lineWidth = 2;
      tg.strokeRect(1, 1, 126, 126);
      const tex = new THREE.CanvasTexture(tc);
      tex.colorSpace = THREE.SRGBColorSpace;
      tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
      tex.anisotropy = 8;
      const R0 = 2.0;
      const TILE = 0.8; // 텍스처 한 장 = 0.8 (칸 2 × 2)
      tex.repeat.set((2 * R0) / TILE, (2 * R0) / TILE);
      const gTop = new THREE.CircleGeometry(R0 * 0.995, 72);
      const mTop = new THREE.MeshStandardMaterial({ map: tex, roughness: 0.85 });
      const top = new THREE.Mesh(gTop, mTop);
      top.rotation.x = -Math.PI / 2;
      top.position.y = 0.002;
      top.receiveShadow = true;
      st.scene.add(top);
      let cc: Char | null = null;
      let vv: BoneViz | null = null;
      const loader = makeLoader(st.scene, [0, 1.1, 0]);
      const bt = blobTex();
      const sh = blob(bt, 1.1);
      sh.position.y = 0.006;
      st.scene.add(sh);
      // 발 디딤 표시 고리 (땅과 함께 흘러감 — 발이 고리 안에 그대로 있으면 안 미끄러진 것)
      const gRing = new THREE.RingGeometry(0.1, 0.13, 32);
      const rings: { m: THREE.Mesh; mat: THREE.MeshBasicMaterial; life: number }[] = [];
      for (let i = 0; i < 10; i++) {
        const mat = new THREE.MeshBasicMaterial({ color: 0xffe066, transparent: true, opacity: 0, depthWrite: false });
        const m = new THREE.Mesh(gRing, mat);
        m.rotation.x = -Math.PI / 2;
        m.position.y = 0.008;
        m.scale.set(1, 1.5, 1);
        m.renderOrder = 2;
        st.scene.add(m);
        rings.push({ m, mat, life: 0 });
      }
      let ri = 0;
      const labels = [makeLabel('가만히', '#d8e1ff'), makeLabel('걷기', '#b8f5c8'), makeLabel('뛰기', '#ffcf8a')];
      labels.forEach((l) => {
        l.position.set(0, 2.2, 0);
        st.scene.add(l);
      });
      let auto = true;
      let speed = 1;
      let cur = 0;
      let phase = 0;
      let showRings = true;
      let showBones = false;
      const inStance = [false, false];
      const target = new THREE.Vector3();
      return {
        scene: st.scene,
        camera: st.cam,
        update(t, dt) {
          if (!cc || !vv) {
            if (!bearReady() || !createSlot()) {
              loader.set(PROG, t);
              return;
            }
            cc = makeChar('smooth');
            vv = new BoneViz(cc);
            vv.group.visible = false;
            st.later([cc.group, vv.group]);
            st.onShown.push(() => loader.done());
          }
          const c = cc;
          const viz = vv;
          dt = Math.min(dt, 0.05);
          // 자동: 가만히 2초 → 걷기 → 뛰기 → 걷기 → 가만히 (14초)
          const want = auto ? (() => {
            const u = t % 14;
            if (u < 2) return 0;
            if (u < 5) return 1.0;
            if (u < 9) return 2.8;
            if (u < 12) return 1.0;
            return 0;
          })() : speed;
          cur += (want - cur) * damp(dt, 0.6);
          const v = cur < 0.01 ? 0 : cur;
          const run = sstep(1.3, 2.1, v);
          const g = sstep(0.02, 0.35, v);
          const walkLc = 0.3 + 0.5 * Math.min(v, 1) + 0.12 * Math.max(v - 1, 0);
          const runLc = 0.8 + 0.3 * v;
          const Lc = lerp(walkLc, runLc, run);
          const D = lerp(0.62, 0.34, run);
          const f = v / Lc;
          phase = frac(phase + f * dt);
          const ph = phase;
          tex.offset.y -= (v * dt) / TILE;
          c.rest();
          const hips = c.b('hips');
          const mid = 4 * Math.PI * (ph - D / 2);
          const bob = lerp(0.022 * Math.min(v, 1) * Math.cos(mid), -0.04 * Math.cos(mid), run) * g;
          const drop = (0.012 + 0.02 * Math.min(v, 1) + 0.075 * run) * g + 0.012;
          hips.position.y = 0.8 - drop + bob + (1 - g) * 0.006 * Math.sin(t * 2.4);
          hips.position.x = 0.022 * Math.cos(2 * Math.PI * (ph - D / 2)) * (1 - run) * g;
          const yaw = -(0.14 + 0.06 * run) * g * Math.cos(2 * Math.PI * ph);
          hips.quaternion.copy(qY(yaw));
          const lean = (0.05 * Math.min(v, 1) + 0.16 * run) * g;
          c.b('spine').quaternion.copy(qX(lean * 0.6));
          c.b('chest').quaternion.copy(qY(-yaw * 1.6).multiply(qX(lean * 0.4 + (1 - g) * 0.015 * Math.sin(t * 2.4))));
          c.b('head').quaternion.copy(qX(-lean * 0.8 - bob * 1.2));
          // 다리: 걸음 주기 → 발목 목표 → 두 뼈 IK
          c.group.updateMatrixWorld(true);
          const gq = c.group.quaternion;
          ['L', 'R'].forEach((s, i) => {
            const side = i === 0 ? 1 : -1;
            const p = frac(ph + (i === 0 ? 0 : 0.5));
            let z: number;
            let lift = 0;
            let pitch = 0;
            let heel = 0;
            const half = (Lc * D) / 2;
            const stance = p < D;
            if (stance) {
              const u = p / D;
              z = Lc * D * (0.5 - u);
              const hr = sstep(0.65, 1, u);
              pitch = 0.55 * hr * g;
              heel = 0.05 * hr * g;
            } else {
              const u = (p - D) / (1 - D);
              z = -half + 2 * half * (0.5 - 0.5 * Math.cos(Math.PI * u));
              lift = (0.05 + 0.05 * Math.min(v, 1) + 0.07 * run) * Math.sin(Math.PI * u) ** 1.2;
              pitch = lerp(0.55, -0.35, sstep(0, 0.85, u)) * g;
              pitch = u > 0.85 ? lerp(-0.35, 0, (u - 0.85) / 0.15) * g : pitch;
            }
            z *= g;
            lift *= g;
            target.set(side * 0.16, AN[1] + lift + heel, z);
            c.group.localToWorld(target);
            const pole = new THREE.Vector3(side * 0.12, 0, 1).applyQuaternion(gq);
            twoBoneIK(c.b('thigh.' + s), c.b('shin.' + s), ANK_L, target, pole);
            setWorldQuat(c.b('foot.' + s), gq.clone().multiply(qX(pitch)));
            if (stance && !inStance[i] && v > 0.15 && showRings) {
              const r = rings[ri++ % rings.length]!;
              r.m.position.set(target.x, 0.008, target.z + 0.06);
              r.life = 1;
            }
            inStance[i] = stance;
          });
          // 팔: 다리와 반대로 흔들기, 뛸 땐 팔꿈치를 굽혀 크게
          const armA = (0.28 * Math.min(v, 1) + 0.5 * run) * g;
          const sw = Math.cos(2 * Math.PI * ph);
          const down = 0.42 - 0.12 * run;
          const elbow = 0.22 + 0.25 * Math.min(v, 1) * g + 1.05 * run;
          c.b('upperArm.L').quaternion.copy(qX(armA * sw).multiply(qZ(-down)));
          c.b('upperArm.R').quaternion.copy(qX(-armA * sw).multiply(qZ(down)));
          const axL = new THREE.Vector3(AD[1], -AD[0], 0).normalize();
          const axR = new THREE.Vector3(AD[1], AD[0], 0).normalize();
          c.b('foreArm.L').quaternion.setFromAxisAngle(axL, elbow + 0.2 * run * Math.max(0, sw));
          c.b('foreArm.R').quaternion.setFromAxisAngle(axR, elbow + 0.2 * run * Math.max(0, -sw));
          c.group.updateMatrixWorld(true);
          for (const r of rings) {
            if (r.life <= 0) {
              r.mat.opacity = 0;
              continue;
            }
            r.m.position.z -= v * dt;
            r.life -= dt / 1.6;
            r.mat.opacity = 0.85 * Math.min(1, r.life * 2) * (Math.hypot(r.m.position.x, r.m.position.z) < R0 - 0.15 ? 1 : 0);
          }
          const state = v < 0.15 ? 0 : run < 0.5 ? 1 : 2;
          labels.forEach((l, i) => (l.visible = i === state));
          viz.group.visible = showBones;
          if (showBones) viz.update();
        },
        render(r, w, h) {
          void w;
          void h;
          stageRender(st, r);
        },
        controls: [
          { type: 'toggle', label: '자동 (가만히 → 걷기 → 뛰기)', value: true, on: (v) => (auto = v) },
          { type: 'range', label: '속도 (자동 끄고)', min: 0, max: 3.2, step: 0.05, value: 1, on: (v) => ((speed = v), (auto = false)) },
          { type: 'toggle', label: '발 디딤 표시', value: true, on: (v) => (showRings = v) },
          { type: 'toggle', label: '뼈 보기', value: false, on: (v) => (showBones = v) },
        ] as Control[],
        dispose() {
          cc?.dispose();
          vv?.dispose();
          loader.dispose();
          tex.dispose();
          gTop.dispose();
          mTop.dispose();
          disposeMesh(sh);
          bt.dispose();
          gRing.dispose();
          rings.forEach((r) => r.mat.dispose());
          labels.forEach(disposeSprite);
          st.own.forEach((o) => o.dispose());
        },
      } satisfies Scene3D;
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [두 뼈 IK (팔 · 다리가 목표를 잡기)](https://ai-techstudio.web.app/ai/t/i459.md) `i459` · [코드로 뼈대 만들기 (Bone · SkinnedMesh)](https://ai-techstudio.web.app/ai/t/i456.md) `i456`
- 다음에 해 볼 기술: [발 땅에 붙이기 (울퉁불퉁한 지형)](https://ai-techstudio.web.app/ai/t/i465.md) `i465` · [애니메이션 섞기 (가만히 ↔ 걷기 ↔ 뛰기)](https://ai-techstudio.web.app/ai/t/i464.md) `i464`
