# AI 꾸러미 — 스프라이트 동작 상태기 + 섞기 — Animation state machine
> 서기 · 걷기 · 뛰기 · 점프를 상태기가 고르고, 바뀔 때 0.3초 동안 앞뒤 두 자세 · 걸음 빠르기를 섞어 동작이 툭 끊기지 않게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i233

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

## 주문서

### 만들어 줘: 스프라이트 동작 상태기 + 섞기 — Animation state machine

#### 1. 목표
옆으로 달리는 2D 캐릭터의 동작을 상태기로 바꿔 줘 — 서기 · 걷기 · 뛰기 · 점프 중 하나를 고르고, 바뀔 때 0.3초 동안 앞뒤 자세와 걸음 빠르기를 섞는다. 그림체는 밝은 만화.

#### 2. 핵심 기술 용어
- **Animation state machine** — 동작 상태기 — 지금 어떤 동작인지 하나를 고른다
- **Cross-fade blending** — 바뀔 때 두 동작을 잠깐 섞기
- **Pose interpolation (lerp)** — 관절 각도를 사이값으로
- **Phase-synced cycles** — 걷기 · 뛰기가 같은 걸음 위상을 함께 써서 발이 튀지 않음

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

#### 4. 조건
- 상태는 한 번에 하나, 바뀐 순간(changeAt)과 이전 상태(prev)를 기억
- 섞는 시간은 조절 가능하게 (기본 0.3초), 끄면 툭 바뀌는 것과 비교
- 자세뿐 아니라 걸음 빠르기 · 이동 속도도 섞기
- 걷기 · 뛰기는 같은 위상을 함께 쓰기
- 프레임 시간은 고정 간격으로 나눠 돌리기 (느린 폰에서 슬로모션 금지)

#### 5. 완성 기준 (이게 보이면 성공)
- 서기 → 걷기 → 뛰기 → 점프 → 뛰기 → 걷기 → 서기가 이어지는 동안 팔다리가 튀지 않는다
- 「섞기 끔」으로 바꾸면 상태가 바뀌는 순간 자세가 툭 바뀌는 것이 보인다
- 오른쪽 위 상태 그래프에 지금 상태와 섞는 무게가 보인다
- 섞는 시간 슬라이더로 0.1 ~ 0.6초를 바꿔 볼 수 있다

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

## 원리
- 동작마다 「위상 p(0~1) → 자세(관절 각도 묶음)」 함수를 둔다 — 걷기는 엉덩이 ±0.5 rad, 뛰기는 ±0.95 rad.
- 상태가 바뀌면 이전 상태 · 바뀐 시각을 적고, 섞는 무게 w = smooth(0, 0.3, 지난 시간) 을 0 → 1 로 올린다.
- 자세 = lerp(이전 동작 자세, 지금 동작 자세, w). 걸음 빠르기(rate)도 같이 섞어 위상이 툭 튀지 않는다.
- 걷기 · 뛰기는 같은 위상 p 를 함께 쓴다 — 왼발이 앞일 때 바뀌면 두 동작 다 왼발 앞이라 자연스럽다. 점프는 따로 시간(0.8초)으로.

## 핵심 코드 — 상태 바뀜 → 0.3초 동안 자세 · 빠르기 섞기
(발췌: demos/demosLook2.ts makeSpriteSM() 를 정리)
```ts
type Pose = Record<'hipL' | 'hipR' | 'kneeL' | 'kneeR' | 'shL' | 'shR' | 'bob' | 'lean', number>;
const TAU = Math.PI * 2;
const ANIMS: Record<string, (p: number) => Pose> = {
  idle: (p) => { const s = Math.sin(p * TAU); return { hipL: 0.06, hipR: -0.06, kneeL: 0.05, kneeR: 0.05, shL: 0.12 + s * 0.04, shR: -0.12 - s * 0.04, bob: s * 1.2, lean: 0 }; },
  walk: (p) => { const s = Math.sin(p * TAU), c = Math.cos(p * TAU); return { hipL: s * 0.5, hipR: -s * 0.5, kneeL: Math.max(0, -c) * 0.7 + 0.05, kneeR: Math.max(0, c) * 0.7 + 0.05, shL: -s * 0.45, shR: s * 0.45, bob: -Math.abs(s) * 2.5 + 1, lean: 0.06 }; },
  run: (p) => { const s = Math.sin(p * TAU), c = Math.cos(p * TAU); return { hipL: s * 0.95, hipR: -s * 0.95, kneeL: Math.max(0, -c) * 1.5 + 0.2, kneeR: Math.max(0, c) * 1.5 + 0.2, shL: -s, shR: s, bob: -Math.abs(s) * 5 + 2, lean: 0.22 }; },
};
const RATE: Record<string, number> = { idle: 0.45, walk: 1.25, run: 2.2 }; // 1초에 걸음 몇 번
const smooth = (a: number, b: number, x: number) => { const k = Math.min(1, Math.max(0, (x - a) / (b - a))); return k * k * (3 - 2 * k); };
const lerpPose = (a: Pose, b: Pose, k: number) => {
  const o = {} as Pose;
  for (const key of Object.keys(a) as (keyof Pose)[]) o[key] = a[key] + (b[key] - a[key]) * k;
  return o;
};

let cur = 'idle', prev = 'idle', changeAt = -10, phase = 0;
function step(t: number, h: number, want: string, blendDur = 0.3): Pose {
  if (want !== cur) { prev = cur; cur = want; changeAt = t; } // 상태 바뀜
  const w = smooth(0, blendDur, t - changeAt);               // 0 → 1
  phase = (phase + (RATE[prev]! + (RATE[cur]! - RATE[prev]!) * w) * h) % 1; // 빠르기도 섞기
  return lerpPose(ANIMS[prev]!(phase), ANIMS[cur]!(phase), w); // 같은 위상 · 두 자세 섞기
}
```

## 흔한 실수 · 확인 목록
- [ ] **동작마다 위상을 따로 두면 바뀌는 순간 발이 앞뒤로 튄다** — 걷기 · 뛰기는 같은 위상 p 를 함께 쓰고, 빠르기만 섞는다.
- [ ] **자세만 섞고 이동 속도는 그대로 두면 미끄러지듯 보인다** — 이동 속도도 목표로 천천히 따라가게 (lerp(speed, target, dt·4)).
- [ ] **섞는 중에 또 상태가 바뀌면 자세가 튄다** — 바뀔 때 「지금 섞인 자세」를 이전 자세로 저장해 두고 거기서 섞는다.
- [ ] **프레임 시간을 0.05 로 자르면 느린 폰에서 동작이 슬로모션이 된다** — 실제 시간은 그대로 쓰고 고정 간격 조각으로 나눠 돌린다 (사이트 전체에서 고친 일).

## 완성 기준 체크리스트
- [ ] 서기 → 걷기 → 뛰기 → 점프 → 뛰기 → 걷기 → 서기가 이어지는 동안 팔다리가 튀지 않는다
- [ ] 「섞기 끔」으로 바꾸면 상태가 바뀌는 순간 자세가 툭 바뀌는 것이 보인다
- [ ] 오른쪽 위 상태 그래프에 지금 상태와 섞는 무게가 보인다
- [ ] 섞는 시간 슬라이더로 0.1 ~ 0.6초를 바꿔 볼 수 있다

## 이 기술 정보
- id: `i233` · 분류: 2D · 화면 › 2D 그림 효과 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 관절 각도 11개를 섞는 정도라 계산은 거의 없다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i233
- 쓰면 좋을 때: 동작이 3개 넘는 캐릭터 / 달리다 멈추기 · 점프 뒤 착지가 툭 끊겨 보일 때
- 쓰지 말 때: 동작 하나뿐인 소품 · 장식 — 그냥 반복 애니메이션 / 프레임 그림만 있는 스프라이트 — 자세를 섞을 수 없다. 대신 아주 짧은 전환 프레임을 넣거나 겹쳐 흐리기

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeSpriteSM — `src/demos/demosLook2.ts:2304`
```ts
function makeSpriteSM() {
  let blendOn = true;
  let blendDur = 0.3;
  let cur = 'idle';
  let prev = 'idle';
  let changeAt = -10;
  let phase = 0;
  let jumpT = 0;
  let scroll = 0;
  let speed = 0;
  let lastT = 0;
  const stateAt = (tt: number): string => {
    let x = tt % TL_LEN;
    for (const [s, d] of TIMELINE) {
      if (x < d) return s;
      x -= d;
    }
    return 'idle';
  };
  return {
    draw(g: CanvasRenderingContext2D, w: number, h: number, t: number, dt: number) {
      dt = Math.min(dt, 0.05);
      lastT = t;
      const u = Math.min(w / 280, h / 175);
      const st = stateAt(t);
      if (st !== cur) {
        prev = cur;
        cur = st;
        changeAt = t;
        if (st === 'jump') jumpT = 0;
      }
      const wgt = blendOn ? smooth(0, blendDur, t - changeAt) : 1;
      const rate = lerp(RATE[prev]!, RATE[cur]!, wgt);
      phase = (phase + rate * dt) % 1;
      if (cur === 'jump' || prev === 'jump') jumpT += dt;
      const targetSpeed = SPEED[cur]!;
      speed = blendOn ? lerp(speed, targetSpeed, Math.min(1, dt * 4)) : targetSpeed;
      scroll += speed * dt;
      const pa = prev === 'jump' ? ANIMS.jump!(jumpT / 0.8) : ANIMS[prev]!(phase);
      const pb = cur === 'jump' ? ANIMS.jump!(jumpT / 0.8) : ANIMS[cur]!(phase);
      const pose = lerpPose(pa, pb, wgt);
      const jumpY = cur === 'jump' ? Math.sin(Math.min(1, jumpT / 0.8) * Math.PI) * 42 : 0;

      // 배경
      g.globalAlpha = 1;
      g.globalCompositeOperation = 'source-over';
      const sky = g.createLinearGradient(0, 0, 0, h);
      sky.addColorStop(0, '#5ab0ff');
      sky.addColorStop(1, '#c8ecff');
      g.fillStyle = sky;
      g.fillRect(0, 0, w, h);
      const gy = h * 0.8;
      // 구름
      g.fillStyle = 'rgba(255,255,255,0.9)';
      for (let i = 0; i < 4; i++) {
        const x = ((((i * 97 - scroll * 0.1) % (w + 80)) + w + 80) % (w + 80)) - 40;
        const y = (18 + (i % 2) * 22) * u;
        g.beginPath();
        g.arc(x, y, 10 * u, 0, TAU);
        g.arc(x + 12 * u, y - 5 * u, 12 * u, 0, TAU);
        g.arc(x + 25 * u, y, 9 * u, 0, TAU);
        g.fill();
      }
      // 먼 언덕
      g.fillStyle = '#86cf7a';
      g.beginPath();
      g.moveTo(0, gy);
      for (let x = 0; x <= w; x += 6) g.lineTo(x, gy - (22 + 12 * Math.sin((x + scroll * 0.3) * 0.02 / u) + 6 * Math.sin((x + scroll * 0.3) * 0.051 / u)) * u);
      g.lineTo(w, gy);
      g.fill();
      // 땅
      g.fillStyle = '#5bb04c';
      g.fillRect(0, gy, w, h - gy);
      g.fillStyle = '#c98b52';
      g.fillRect(0, gy + 7 * u, w, h - gy);
      g.fillStyle = '#b27640';
      const tile = 24 * u;
      for (let x = -((scroll * u) % tile); x < w; x += tile) g.fillRect(x, gy + 7 * u, 2 * u, h);
      g.fillStyle = '#3f9a3a';
      for (let x = -((scroll * u) % (14 * u)); x < w; x += 14 * u) {
        g.beginPath();
        g.moveTo(x, gy + 1);
        g.lineTo(x + 3 * u, gy - 4 * u);
        g.lineTo(x + 6 * u, gy + 1);
        g.fill();
      }
      // 그림자
      const cx = w * 0.3;
      g.fillStyle = 'rgba(0,0,0,0.18)';
      g.beginPath();
      g.ellipse(cx, gy + 2 * u, (14 - jumpY * 0.12) * u, 3 * u, 0, 0, TAU);
      g.fill();
      // 캐릭터
      g.save();
      g.translate(cx, gy - (jumpY + 27 + pose.bob) * u);
      g.rotate(pose.lean);
      g.scale(1 + pose.squash, 1 - pose.squash);
      const limb = (ang: number, bend: number, l1: number, l2: number, col: string, wdt: number, back: boolean, leg: boolean) => {
        g.strokeStyle = col;
        g.lineWidth = wdt * u;
        g.lineCap = 'round';
        g.lineJoin = 'round';
        const x1 = Math.sin(ang) * l1 * u;
        const y1 = Math.cos(ang) * l1 * u;
        const a2 = leg ? ang - bend : ang + bend;
        const x2 = x1 + Math.sin(a2) * l2 * u;
        const y2 = y1 + Math.cos(a2) * l2 * u;
        g.beginPath();
        g.moveTo(0, 0);
        g.lineTo(x1, y1);
        g.lineTo(x2, y2);
        g.stroke();
        if (leg) {
          g.fillStyle = back ? '#2a2a3a' : '#3a3a52';
          g.beginPath();
          g.ellipse(x2 + 2.5 * u, y2 + 1 * u, 4.5 * u, 2.6 * u, 0, 0, TAU);
          g.fill();
        } else {
          g.fillStyle = back ? '#e0a37a' : '#ffc89a';
          g.beginPath();
          g.arc(x2, y2, 2.6 * u, 0, TAU);
          g.fill();
        }
      };
      // 뒤 팔 · 다리
      g.save();
      g.translate(0, 0);
      limb(pose.hipR, pose.kneeR, 12, 12, '#2f4f9a', 6, true, true);
      g.restore();
      g.save();
      g.translate(0, -19 * u);
      limb(pose.shR, pose.elR, 9, 9, '#d24a4a', 5, true, false);
      g.restore();
      // 몸
      g.fillStyle = '#ff5a5a';
      g.beginPath();
      g.roundRect(-7 * u, -24 * u, 14 * u, 22 * u, 5 * u);
      g.fill();
      g.fillStyle = '#3b63c4';
      g.beginPath();
      g.roundRect(-7 * u, -6 * u, 14 * u, 7 * u, 2 * u);
      g.fill();
      // 머리
      g.fillStyle = '#ffc89a';
      g.beginPath();
      g.arc(1 * u, -34 * u, 10 * u, 0, TAU);
      g.fill();
      g.fillStyle = '#4a2a18';
      g.beginPath();
      g.arc(0, -37 * u, 10 * u, Math.PI * 1.05, Math.PI * 2.05);
      g.fill();
      g.beginPath();
      g.arc(-6 * u, -36 * u, 5 * u, 0, TAU);
      g.fill();
      g.fillStyle = '#1a1a2a';
      g.beginPath();
      g.ellipse(6 * u, -34 * u, 1.6 * u, 2.4 * u, 0, 0, TAU);
      g.fill();
      g.fillStyle = 'rgba(255,110,110,0.55)';
      g.beginPath();
      g.arc(5 * u, -29.5 * u, 2.2 * u, 0, TAU);
      g.fill();
      // 앞 다리 · 팔
      limb(pose.hipL, pose.kneeL, 12, 12, '#3b63c4', 6, false, true);
      g.save();
      g.translate(0, -19 * u);
      limb(pose.shL, pose.elL, 9, 9, '#ff6a6a', 5, false, false);
      g.restore();
      g.restore();

      // 상태 그래프 (오른쪽 위)
      const px = w - 132 * u;
      const py = 10 * u;
      g.fillStyle = 'rgba(10,20,50,0.72)';
      g.beginPath();
      g.roundRect(px, py, 124 * u, 50 * u, 8 * u);
      g.fill();
      const nx = (i: number) => px + 17 * u + i * 30 * u;
      const ny = py + 20 * u;
      g.font = `800 ${8 * u}px ${F}`;
      g.textAlign = 'center';
      g.textBaseline = 'middle';
      const ia = STATES.indexOf(prev as (typeof STATES)[number]);
      const ib = STATES.indexOf(cur as (typeof STATES)[number]);
      for (let i = 0; i < 3; i++) {
        g.strokeStyle = 'rgba(255,255,255,0.3)';
        g.lineWidth = 1.5 * u;
        g.beginPath();
        g.moveTo(nx(i) + 11 * u, ny);
        g.lineTo(nx(i + 1) - 11 * u, ny);
        g.stroke();
      }
      if (ia !== ib && wgt < 1) {
        const x0 = nx(ia);
        const x1 = nx(ib);
        const xm = lerp(x0, x1, wgt);
        g.strokeStyle = '#ffd23f';
        g.lineWidth = 3 * u;
        g.beginPath();
        g.moveTo(x0, ny);
        g.lineTo(xm, ny);
        g.stroke();
      }
      STATES.forEach((s, i) => {
        const on = i === ib ? wgt : i === ia ? 1 - wgt : 0;
        g.fillStyle = on > 0.01 ? `rgba(255,${Math.round(210 - 40 * on)},63,${0.35 + on * 0.65})` : 'rgba(255,255,255,0.12)';
        g.beginPath();
        g.arc(nx(i), ny, 11 * u, 0, TAU);
        g.fill();
        g.fillStyle = on > 0.5 ? '#2a1a00' : '#fff';
        g.fillText(STATE_KO[s]!, nx(i), ny + 0.5 * u);
      });
      g.fillStyle = '#fff';
      g.font = `700 ${7 * u}px ${F}`;
      g.textAlign = 'left';
      g.fillText(blendOn ? `섞기 ${Math.round(wgt * 100)}%` : '섞기 끔 — 툭 바뀜', px + 8 * u, py + 41 * u);
      g.fillStyle = 'rgba(255,255,255,0.18)';
      g.fillRect(px + 62 * u, py + 39 * u, 54 * u, 4 * u);
      g.fillStyle = '#ffd23f';
      g.fillRect(px + 62 * u, py + 39 * u, 54 * u * wgt, 4 * u);
      void lastT;
    },
    controls: [
      { type: 'toggle', label: '섞기 (끄면 동작이 툭 바뀜)', value: true, on: (v: boolean) => (blendOn = v) },
      { type: 'range', label: '섞는 시간 (초)', min: 0.05, max: 0.8, step: 0.05, value: 0.3, on: (v: number) => (blendDur = v) },
    ] as Control[],
  };
}
```

### i233 견본 항목 — `src/demos/demosLook2.ts:4517`
```ts
  i233: { kind: '2d', caption: '서기 → 걷기 → 뛰기 → 점프를 상태기가 고르고, 바뀔 때 0.3초 동안 두 자세를 섞어 끊김 없이', make: () => makeSpriteSM() }
```

## 관련 기술
- 먼저 알면 좋은 기술: [2D 뼈대 애니 (코드로)](https://ai-techstudio.web.app/ai/t/i300.md) `i300`
- 다음에 해 볼 기술: [플랫폼 점프 손맛 (코요테 · 버퍼 · 가변 점프)](https://ai-techstudio.web.app/ai/t/i282.md) `i282`
- 참고 문서: [Unity 매뉴얼 — Animation State Machines](https://docs.unity3d.com/Manual/AnimationStateMachines.html)
