# AI 꾸러미 — 2D 뼈대 애니 (코드로) — 2D skeletal animation (forward kinematics)
> 팔 · 다리를 뼈로 이어 각도만 돌려 걷게 하고(FK), 앞팔은 손 끝이 목표에 닿도록 두 뼈 각도를 코사인 법칙으로 거꾸로 계산한다(IK).  
> 견본: https://ai-techstudio.web.app/#t/i300

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

## 주문서

### 만들어 줘: 2D 뼈대 애니 (코드로) — 2D skeletal animation (forward kinematics)

#### 1. 목표
걷는 2D 캐릭터를 코드 뼈대로 움직여 줘 — 다리 · 뒷팔은 위상으로 각도를 돌려 걷고(FK), 앞팔은 손 끝이 목표를 따라가게 두 뼈 IK. 그림체는 굵은 선 만화.

#### 2. 핵심 기술 용어
- **2D skeletal animation (forward kinematics)** — 뼈 각도를 정하면 끝 자리가 따라온다
- **Two-bone IK (law of cosines)** — 끝 자리를 정하면 두 뼈 각도를 거꾸로
- **Walk cycle (phase)** — 걷기 위상 — 양다리 반 바퀴 차이
- **Reach clamp** — 팔 길이보다 먼 목표는 길이까지만

#### 3. 환경
- 플랫폼: HTML Canvas 2D (TypeScript), requestAnimationFrame 루프, 라이브러리 없이
- 화면: 2D · 브라우저 — PC 와 폰(390px 폭) 모두, devicePixelRatio 맞춰 또렷하게, 60fps 목표

#### 4. 조건
- FK 는 부모 끝 자리에서 자식 각도를 더해 이어 가기
- IK 목표가 팔 길이보다 멀면 거리를 U1 + U2 − 0.01 로 자르기 (acos 범위 밖 방지)
- cos 값은 −1 ~ 1 로 clamp 한 뒤 acos
- 반대쪽 다리는 위상 π 차이, 걷기 속도 0 이면 다리 · 팔 흔들림도 0
- 뼈 보기 · IK 켬/끔 · 걷기 속도 조절

#### 5. 완성 기준 (이게 보이면 성공)
- 걷기 속도를 올리면 다리 흔들림 · 무릎 굽힘이 함께 커지고, 0 이면 가만히 선다
- IK 를 켜면 앞팔 손 끝이 움직이는 별을 계속 따라간다
- 별이 팔 길이 밖으로 나가도 팔이 쭉 뻗을 뿐 꺾이거나 튀지 않는다
- 뼈 보기를 켜면 관절 점과 뼈 선이 보인다

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

## 원리
- FK: 엉덩이에서 허벅지 각도 a1 로 길이 L1 만큼 가면 무릎, 거기서 a1 + 굽힘 으로 L2 만큼 가면 발.
- 걷기: 허벅지 θ = 0.48·sin(p), 무릎 굽힘 = 0.95·max(0, cos p). 반대쪽 다리는 p + π (반 바퀴 늦게). 엉덩이는 |cos p|·2.5 만큼 위아래로.
- IK: 어깨에서 목표까지 거리 d 를 팔 길이 합보다 조금 짧게 자르고, 코사인 법칙 cos A = (U1² + d² − U2²) / (2·U1·d) 로 위팔이 기준선에서 벌어질 각 A 를 구한다.
- 위팔 각도 = 목표 방향 + A, 팔꿈치 = 어깨 + U1·(cos, sin), 손 = 목표. 굽는 쪽(+A / −A)을 바꾸면 팔꿈치가 반대로 꺾인다.

## 핵심 코드 — 걷는 다리(FK) + 손 끝이 목표를 따라가는 팔(두 뼈 IK)
(발췌: demos/demos2dLook.ts mkI300() 를 정리)
```ts
type V = [number, number];
const L1 = 21, L2 = 21; // 허벅지 · 정강이
function leg(hipX: number, hipY: number, p: number, off: number, speed: number) {
  const s = p + off;
  const th = 0.48 * Math.sin(s) * Math.min(1, speed);       // 허벅지 흔들기
  const bend = 0.95 * Math.max(0, Math.cos(s)) * Math.min(1, speed); // 앞으로 갈 때만 무릎 굽힘
  const a1 = Math.PI / 2 - th;
  const knee: V = [hipX + Math.cos(a1) * L1, hipY + Math.sin(a1) * L1];
  const a2 = a1 + bend;
  const foot: V = [knee[0] + Math.cos(a2) * L2, knee[1] + Math.sin(a2) * L2];
  return { knee, foot };
}

const U1 = 17, U2 = 16; // 위팔 · 아래팔
function twoBoneIK(shX: number, shY: number, tx: number, ty: number) {
  let dx = tx - shX, dy = ty - shY;
  let d = Math.hypot(dx, dy);
  const maxd = U1 + U2 - 0.01; // 너무 멀면 팔 길이까지만
  if (d > maxd) { dx *= maxd / d; dy *= maxd / d; d = maxd; }
  const base = Math.atan2(dy, dx);
  const cosA = Math.max(-1, Math.min(1, (U1 * U1 + d * d - U2 * U2) / (2 * U1 * d))); // 코사인 법칙
  const a = base + Math.acos(cosA); // − 로 바꾸면 팔꿈치가 반대로
  const elbow: V = [shX + Math.cos(a) * U1, shY + Math.sin(a) * U1];
  const hand: V = [shX + dx, shY + dy];
  return { elbow, hand };
}

// 매 프레임: phase += dt * 6.5 * speed
const hipY = 106 - Math.abs(Math.cos(phase)) * 2.5; // 걸음마다 위아래
const back = leg(128, hipY, phase, Math.PI, speed);  // 반 바퀴 늦은 다리
const front = leg(128, hipY, phase, 0, speed);
const arm = twoBoneIK(shX, shY, starX, starY);
```

## 흔한 실수 · 확인 목록
- [ ] **목표가 팔 길이보다 멀면 acos 가 NaN 이 되어 팔이 사라진다** — 거리를 U1 + U2 − 0.01 로 자르고 cos 값을 −1 ~ 1 로 clamp 한다.
- [ ] **두 다리 위상이 같으면 깡충 뛰는 것처럼 보인다** — 한쪽은 p, 다른 쪽은 p + π.
- [ ] **무릎이 앞뒤로 다 굽으면 다리가 거꾸로 꺾여 보인다** — 굽힘은 max(0, cos p) 로 한쪽으로만.
- [ ] **팔꿈치가 몸 안쪽으로 꺾인다** — base + acos 와 base − acos 중 몸 바깥쪽인 것을 고른다.

## 완성 기준 체크리스트
- [ ] 걷기 속도를 올리면 다리 흔들림 · 무릎 굽힘이 함께 커지고, 0 이면 가만히 선다
- [ ] IK 를 켜면 앞팔 손 끝이 움직이는 별을 계속 따라간다
- [ ] 별이 팔 길이 밖으로 나가도 팔이 쭉 뻗을 뿐 꺾이거나 튀지 않는다
- [ ] 뼈 보기를 켜면 관절 점과 뼈 선이 보인다

## 이 기술 정보
- id: `i300` · 분류: 2D · 화면 › 2D 그림 효과 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 관절마다 삼각함수 몇 번. 캐릭터 수십 명도 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i300
- 쓰면 좋을 때: 프레임 그림 없이 걷기 · 손 뻗기 동작을 만들 때 / 손 · 발이 물체에 정확히 닿아야 할 때 (잡기 · 가리키기 · 발 디딤)
- 쓰지 말 때: 관절이 3개 넘는 사슬 (꼬리 · 촉수) — 두 뼈 공식이 안 맞는다. 대신 FABRIK · CCD 반복 풀이 / 그림이 한 장으로 붙은 캐릭터 — 뼈로 나누기 어렵다. 대신 격자 변형(i299)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### mkI300 — `src/demos/demos2dLook.ts:1305`
```ts
function mkI300() {
  let bones = true;
  let ik = true;
  let speed = 1;
  const controls: Control[] = [
    { type: 'toggle', label: '뼈 보기', value: bones, on: (v) => (bones = v) },
    { type: 'toggle', label: '역운동학 (손이 별 따라가기)', value: ik, on: (v) => (ik = v) },
    { type: 'range', label: '걷기 속도', min: 0, max: 2, step: 0.1, value: speed, on: (v) => (speed = v) },
  ];
  let phase = 0;
  let scroll = 0;
  return {
    controls,
    draw(g: G, w: number, h: number, t: number, dt: number) {
      reset(g);
      const f = frame(g, w, h);
      phase += dt * 6.5 * speed;
      scroll += dt * 44 * speed;
      const sky = g.createLinearGradient(0, f.T, 0, 150);
      sky.addColorStop(0, '#7ec8ff');
      sky.addColorStop(1, '#d9f1ff');
      g.fillStyle = sky;
      g.fillRect(f.L, f.T, f.R - f.L, 150 - f.T);
      // 구름 · 언덕 (시차)
      g.fillStyle = '#ffffff';
      for (let i = 0; i < 4; i++) {
        const x = ((((i * 97 - scroll * 0.15) % 360) + 360) % 360) - 40;
        g.beginPath();
        g.arc(x, 30 + (i % 2) * 14, 9, 0, TAU);
        g.arc(x + 11, 27 + (i % 2) * 14, 12, 0, TAU);
        g.arc(x + 24, 31 + (i % 2) * 14, 8, 0, TAU);
        g.fill();
      }
      g.fillStyle = '#9fdc8a';
      g.beginPath();
      g.moveTo(f.L, 150);
      for (let x = f.L; x <= f.R + 4; x += 6) g.lineTo(x, 126 + Math.sin((x + scroll * 0.4) * 0.035) * 8);
      g.lineTo(f.R, 150);
      g.fill();
      g.fillStyle = '#6cc35a';
      g.fillRect(f.L, 150, f.R - f.L, f.B - 150);
      g.fillStyle = '#58a84a';
      for (let x = -((scroll % 20) + 20); x < f.R + 20; x += 20) {
        g.beginPath();
        g.moveTo(x, 150);
        g.lineTo(x + 3, 145);
        g.lineTo(x + 6, 150);
        g.fill();
      }
      const p = phase;
      const hipX = 128;
      const hipY = 106 - Math.abs(Math.cos(p)) * 2.5;
      const L1 = 21;
      const L2 = 21;
      const leg = (off: number) => {
        const s = p + off;
        const th = 0.48 * Math.sin(s) * Math.min(1, speed + 0.001);
        const bend = 0.95 * Math.max(0, Math.cos(s)) * Math.min(1, speed);
        const a1 = Math.PI / 2 - th;
        const kx = hipX + Math.cos(a1) * L1;
        const ky = hipY + Math.sin(a1) * L1;
        const a2 = a1 + bend;
        return { k: [kx, ky] as [number, number], f: [kx + Math.cos(a2) * L2, ky + Math.sin(a2) * L2] as [number, number], a2 };
      };
      const lb = leg(Math.PI);
      const lf = leg(0);
      const lean = 0.08;
      const shX = hipX + Math.sin(lean) * 30;
      const shY = hipY - 30;
      const headX = shX + 3;
      const headY = shY - 20;
      const U1 = 17;
      const U2 = 16;
      // 뒷팔 (앞뒤로 흔들기 FK)
      const ba1 = Math.PI / 2 + 0.6 * Math.sin(p) * Math.min(1, speed);
      const be = [shX + Math.cos(ba1) * U1, shY + Math.sin(ba1) * U1] as [number, number];
      const ba2 = ba1 - 0.5;
      const bh = [be[0] + Math.cos(ba2) * U2, be[1] + Math.sin(ba2) * U2] as [number, number];
      // 별 목표
      const tx = shX + 30 + Math.cos(t * 1.1) * 18;
      const ty = shY - 12 + Math.sin(t * 1.7) * 30;
      let fe: [number, number];
      let fh: [number, number];
      if (ik) {
        let dx = tx - shX;
        let dy = ty - shY;
        let d = Math.hypot(dx, dy);
        const maxd = U1 + U2 - 0.01;
        if (d > maxd) {
          dx *= maxd / d;
          dy *= maxd / d;
          d = maxd;
        }
        const base = Math.atan2(dy, dx);
        const cosA = clamp((U1 * U1 + d * d - U2 * U2) / (2 * U1 * d), -1, 1);
        const a = base + Math.acos(cosA);
        fe = [shX + Math.cos(a) * U1, shY + Math.sin(a) * U1];
        fh = [shX + dx, shY + dy];
      } else {
        const a = -2.3 + 0.25 * Math.sin(t * 6);
        fe = [shX + Math.cos(a) * U1, shY + Math.sin(a) * U1];
        const a2 = a + 0.7 + 0.5 * Math.sin(t * 6);
        fh = [fe[0] + Math.cos(a2) * U2, fe[1] + Math.sin(a2) * U2];
      }
      // 그림자
      g.fillStyle = 'rgba(0,40,0,.25)';
      g.beginPath();
      g.ellipse(hipX + 4, 150, 26, 4, 0, 0, TAU);
      g.fill();
      const limb = (pts: [number, number][], col: string, wdt: number): void => {
        g.lineCap = 'round';
        g.lineJoin = 'round';
        g.beginPath();
        g.moveTo(pts[0]![0], pts[0]![1]);
        for (let i = 1; i < pts.length; i++) g.lineTo(pts[i]![0], pts[i]![1]);
        g.strokeStyle = '#2b2440';
        g.lineWidth = wdt + 2.4;
        g.stroke();
        g.strokeStyle = col;
        g.lineWidth = wdt;
        g.stroke();
      };
      const shoe = (ft: [number, number], col: string): void => {
        g.fillStyle = col;
        g.strokeStyle = '#2b2440';
        g.lineWidth = 1.2;
        g.beginPath();
        g.ellipse(ft[0] + 3, ft[1] + 1, 7, 4, 0, 0, TAU);
        g.fill();
        g.stroke();
      };
      const hand = (p2: [number, number]): void => {
        g.fillStyle = '#ffd9b3';
        g.strokeStyle = '#2b2440';
        g.lineWidth = 1.2;
        g.beginPath();
        g.arc(p2[0], p2[1], 4.2, 0, TAU);
        g.fill();
        g.stroke();
      };
      limb([[shX, shY], be, bh], '#e8794a', 7);
      hand(bh);
      limb([[hipX, hipY], lb.k, lb.f], '#3d63c9', 8.5);
      shoe(lb.f, '#c2354a');
      // 몸통
      g.save();
      g.translate(hipX, hipY);
      g.rotate(lean);
      rr(g, -11, -34, 22, 34, 9);
      g.fillStyle = '#ff9a5c';
      g.fill();
      g.lineWidth = 2;
      g.strokeStyle = '#2b2440';
      g.stroke();
      rr(g, -11, -8, 22, 10, 4);
      g.fillStyle = '#4f78e0';
      g.fill();
      g.stroke();
      g.fillStyle = '#ffe07a';
      g.beginPath();
      g.arc(0, -20, 3.5, 0, TAU);
      g.fill();
      g.restore();
      limb([[hipX, hipY], lf.k, lf.f], '#4f78e0', 8.5);
      shoe(lf.f, '#e04a60');
      // 머리
      g.fillStyle = '#ffd9b3';
      g.strokeStyle = '#2b2440';
      g.lineWidth = 2;
      g.beginPath();
      g.arc(headX, headY, 17, 0, TAU);
      g.fill();
      g.stroke();
      g.fillStyle = '#6b3d22';
      g.beginPath();
      g.arc(headX, headY - 2, 17.5, Math.PI * 1.05, Math.PI * 1.95);
      g.quadraticCurveTo(headX + 4, headY - 8, headX - 16.5, headY - 4);
      g.fill();
      g.fillStyle = '#2b2440';
      g.beginPath();
      g.ellipse(headX + 8, headY + 2, 2.2, 3, 0, 0, TAU);
      g.ellipse(headX - 3, headY + 2, 2.2, 3, 0, 0, TAU);
      g.fill();
      g.fillStyle = 'rgba(255,110,140,.45)';
      g.beginPath();
      g.ellipse(headX + 12, headY + 8, 3, 1.8, 0, 0, TAU);
      g.ellipse(headX - 7, headY + 8, 3, 1.8, 0, 0, TAU);
      g.fill();
      g.strokeStyle = '#2b2440';
      g.lineWidth = 1.3;
      g.beginPath();
      g.arc(headX + 3, headY + 8, 3.2, 0.15 * Math.PI, 0.85 * Math.PI);
      g.stroke();
      limb([[shX, shY], fe, fh], '#ff9a5c', 7);
      hand(fh);
      // 별
      const star = (x: number, y: number, r: number): void => {
        g.beginPath();
        for (let i = 0; i < 10; i++) {
          const a = -Math.PI / 2 + (i * Math.PI) / 5 + t;
          const rr2 = i % 2 ? r * 0.45 : r;
          g.lineTo(x + Math.cos(a) * rr2, y + Math.sin(a) * rr2);
        }
        g.closePath();
      };
      if (ik) {
        spot(g, tx, ty, 18, [255, 230, 90], 0.5);
        star(tx, ty, 7);
        g.fillStyle = '#ffd93b';
        g.fill();
        g.strokeStyle = '#a86b00';
        g.lineWidth = 1;
        g.stroke();
      }
      if (bones) {
        g.lineCap = 'round';
        g.strokeStyle = 'rgba(40,255,230,.95)';
        g.lineWidth = 1.4;
        const segs: [number, number][][] = [
          [[hipX, hipY], [shX, shY], [headX, headY]],
          [[hipX, hipY], lb.k, lb.f],
          [[hipX, hipY], lf.k, lf.f],
          [[shX, shY], be, bh],
          [[shX, shY], fe, fh],
        ];
        for (const s of segs) {
          g.beginPath();
          g.moveTo(s[0]![0], s[0]![1]);
          for (let i = 1; i < s.length; i++) g.lineTo(s[i]![0], s[i]![1]);
          g.stroke();
        }
        for (const s of segs)
          for (const q of s) {
            g.fillStyle = '#ffffff';
            g.beginPath();
            g.arc(q[0], q[1], 2, 0, TAU);
            g.fill();
            g.strokeStyle = '#0aa';
            g.lineWidth = 0.8;
            g.stroke();
          }
        if (ik) {
          g.setLineDash([2, 2]);
          g.strokeStyle = 'rgba(255,230,90,.9)';
          g.beginPath();
          g.moveTo(shX, shY);
          g.lineTo(tx, ty);
          g.stroke();
          g.setLineDash([]);
        }
      }
      pill(g, ik ? '다리 = 각도 돌리기(FK) · 앞팔 = 별에 손 끝 맞추기(IK)' : '모든 뼈 = 각도만 돌리기 (FK)', 140, 13, 8, 'rgba(20,40,80,.55)', '#fff');
      g.restore();
    },
  };
}
```

### i300 견본 항목 — `src/demos/demos2dLook.ts:3783`
```ts
  i300: { kind: '2d', caption: '뼈마다 각도만 돌려 걷고(FK), 앞팔은 손 끝이 별에 닿도록 두 뼈 각도를 거꾸로 계산(IK)', make: mkI300 }
```

## 관련 기술
- 먼저 알면 좋은 기술: [두 뼈 IK (팔 · 다리가 목표를 잡기)](https://ai-techstudio.web.app/ai/t/i459.md) `i459`
- 다음에 해 볼 기술: [스프라이트 동작 상태기 + 섞기](https://ai-techstudio.web.app/ai/t/i233.md) `i233` · [변형 메시 (그림을 쭈욱 늘리기)](https://ai-techstudio.web.app/ai/t/i299.md) `i299`
- 참고 문서: [Wikipedia — Law of cosines](https://en.wikipedia.org/wiki/Law_of_cosines) · [Wikipedia — Inverse kinematics](https://en.wikipedia.org/wiki/Inverse_kinematics)
