# AI 꾸러미 — 경로 따라 움직이기 (방향 포함) — Path following with tangent orientation
> 곡선 위 점의 미분(접선)으로 진행 방향 각도를 구해, 길을 따라가는 물체가 가는 쪽으로 몸을 돌리게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i103

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

## 주문서

### 만들어 줘: 경로 따라 움직이기 (방향 포함) — Path following with tangent orientation

#### 1. 목표
8자 길을 나는 종이비행기가 곡선 길을 따라가며 진행 방향으로 몸을 돌리게 해 줘 — 접선 각도를 쓰고, 각도는 짧은 쪽으로 부드럽게 따라가게. 분위기는 모눈종이 위 남색 밤.

#### 2. 핵심 기술 용어
- **Path following with tangent orientation** — 경로 따라가기 + 접선 방향 맞춤
- **Parametric curve derivative** — 매개변수 곡선의 미분 = 진행 방향
- **Shortest-angle interpolation** — 짧은 쪽으로 각도 따라 돌기

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

#### 4. 조건
- 진행 방향은 미분(또는 이웃 점의 차)으로 — 「다음 프레임 위치 − 지금 위치」 는 속도가 0 일 때 흔들린다
- 각도 차이는 ((목표 − 지금 + π) mod 2π) − π 로 짧은 쪽
- 「방향 맞춤 끔」 과 번갈아 보여 차이를 비교하기
- 접선(점선)을 함께 그려 방향이 어디서 오는지 보이기

#### 5. 완성 기준 (이게 보이면 성공)
- 종이비행기가 8자 길을 따라가며 늘 앞쪽 끝이 진행 방향을 향한다
- 「끔」 일 때는 옆으로 미끄러져 어색해 보인다 — 켬/끔 차이가 바로 보인다
- 8자의 교차점에서도 비행기가 휙 돌지 않고 부드럽게 방향을 바꾼다

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

## 원리
- 길을 매개변수 식으로 둔다. 견본의 8자: P(θ) = (cx + A·sin θ, cy + B·sin θ·cos θ).
- 미분하면 진행 방향: D(θ) = (A·cos θ, B·cos 2θ). 각도 = atan2(Dy, Dx).
- 각도를 바로 넣으면 −180° ↔ 180° 에서 휙 돈다. 차이를 −π ~ π 로 접은 뒤(짧은 쪽) 조금씩 따라간다.
- face += 차이 × min(1, dt × 14) — 빠르게 따라가되 끊기지 않게.

## 핵심 코드 — 8자 길 · 미분으로 방향 · 짧은 쪽으로 따라 돌기
(발췌: demos/demosMotionA.ts i103 의 draw 를 정리)
```ts
const TAU = Math.PI * 2;
const mod = (a: number, b: number) => ((a % b) + b) % b;
const A = 105, B = 105;
const P = (th: number): [number, number] => [cx + A * Math.sin(th), cy + B * Math.sin(th) * Math.cos(th)];
const Dv = (th: number): [number, number] => [A * Math.cos(th), B * Math.cos(2 * th)]; // P 의 미분

let face = 0; // 지금 몸 방향
function draw(g: CanvasRenderingContext2D, tt: number, dt: number) {
  const th = tt * 0.9;
  const [x, y] = P(th);
  const [dx, dy] = Dv(th);
  const ang = Math.atan2(dy, dx);                       // 진행 방향
  const diff = mod(ang - face + Math.PI, TAU) - Math.PI; // 짧은 쪽 (−π ~ π)
  face += diff * Math.min(1, dt * 14);
  g.save();
  g.translate(x, y);
  g.rotate(face);
  g.beginPath();            // 앞(+x)이 뾰족한 종이비행기
  g.moveTo(14, 0); g.lineTo(-10, -9); g.lineTo(-5, 0); g.lineTo(-10, 9);
  g.closePath();
  g.fillStyle = '#fff'; g.fill();
  g.restore();
}
```

## 흔한 실수 · 확인 목록
- [ ] **각도를 그대로 따라가면 180° 근처에서 한 바퀴 휙 돈다** — 차이를 −π ~ π 로 접어 짧은 쪽으로 돌린다.
- [ ] **그림의 「앞」 이 +x 가 아니면 늘 90° 어긋난다** — 그림을 +x 쪽이 앞이 되게 그리거나, 각도에 고정 오프셋을 더한다.
- [ ] **위치 차이로 방향을 구하면 느릴 때 떨린다** — 식이 있으면 미분을, 없으면 조금 떨어진 두 점(앞뒤 0.04)의 차를 쓴다.

## 완성 기준 체크리스트
- [ ] 종이비행기가 8자 길을 따라가며 늘 앞쪽 끝이 진행 방향을 향한다
- [ ] 「끔」 일 때는 옆으로 미끄러져 어색해 보인다 — 켬/끔 차이가 바로 보인다
- [ ] 8자의 교차점에서도 비행기가 휙 돌지 않고 부드럽게 방향을 바꾼다

## 이 기술 정보
- id: `i103` · 분류: 이펙트 · 연출 › 모션 그래픽 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 삼각함수 몇 개. 아주 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i103
- 쓰면 좋을 때: 비행기 · 물고기 · 자동차처럼 「앞」 이 있는 물체가 곡선을 따라갈 때 / 그래프 · 궤적 위 점의 방향(기울기)을 보여 줄 때
- 쓰지 말 때: 공 · 별처럼 앞뒤가 없는 물체 — 돌릴 필요 없다 / 식이 없는 손그림 길 — 점 목록이면 이웃 두 점의 차로 방향을 구한다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i103 — `src/demos/demosMotionA.ts:1525`
```ts
const i103 = {
  kind: '2d' as const,
  caption: '종이비행기가 8자 길을 따라가며 진행 방향(접선)으로 몸을 돌려요 — 「끔」일 때는 옆으로 미끄러져 어색해요',
  make() {
    const o = { s: 1 };
    let tt = 0;
    let face = 0;
    let mode = 0; // 0 자동 번갈아 1 늘 켬
    return {
      controls: [speedCtl(o), { type: 'toggle' as const, label: '방향 맞춤 늘 켜기', value: false, on: (v: boolean) => (mode = v ? 1 : 0) }],
      draw(g: G, w: number, h: number, _t: number, dt: number) {
        tt += Math.max(0, dt) * o.s;
        bg(g, w, h, '#1f2a5e', '#0d1333');
        view(g, w, h);
        // 모눈
        g.strokeStyle = 'rgba(255,255,255,.07)';
        g.lineWidth = 1;
        for (let x = 0; x <= VW; x += 14) {
          g.beginPath();
          g.moveTo(x, 0);
          g.lineTo(x, VH);
          g.stroke();
        }
        for (let y = 0; y <= VH; y += 14) {
          g.beginPath();
          g.moveTo(0, y);
          g.lineTo(VW, y);
          g.stroke();
        }
        const cx = VW / 2;
        const cy = VH / 2 + 6;
        const A = 105;
        const B = 105;
        const P = (th: number): [number, number] => [cx + A * Math.sin(th), cy + B * Math.sin(th) * Math.cos(th)];
        const Dv = (th: number): [number, number] => [A * Math.cos(th), B * Math.cos(2 * th)];
        g.strokeStyle = 'rgba(126,200,255,.35)';
        g.lineWidth = 2;
        g.setLineDash([4, 5]);
        g.beginPath();
        for (let i = 0; i <= 120; i++) {
          const [x, y] = P((i / 120) * TAU);
          if (i) g.lineTo(x, y);
          else g.moveTo(x, y);
        }
        g.stroke();
        g.setLineDash([]);
        const th = tt * 0.9;
        // 꼬리
        for (let i = 1; i < 18; i++) {
          const [x, y] = P(th - i * 0.04);
          g.fillStyle = `rgba(255,209,102,${0.5 * (1 - i / 18)})`;
          g.beginPath();
          g.arc(x, y, 2.4 * (1 - i / 22), 0, TAU);
          g.fill();
        }
        const on = mode === 1 || Math.floor(tt / 4) % 2 === 0;
        const [x, y] = P(th);
        const [dx, dy] = Dv(th);
        const ang = Math.atan2(dy, dx);
        const target = on ? ang : 0;
        // 짧은 쪽으로 따라 돌기
        const diff = mod(target - face + Math.PI, TAU) - Math.PI;
        face += diff * Math.min(1, dt * 14);
        // 접선
        if (on) {
          g.strokeStyle = 'rgba(255,209,102,.8)';
          g.lineWidth = 1.2;
          g.setLineDash([3, 3]);
          g.beginPath();
          g.moveTo(x - Math.cos(ang) * 34, y - Math.sin(ang) * 34);
          g.lineTo(x + Math.cos(ang) * 34, y + Math.sin(ang) * 34);
          g.stroke();
          g.setLineDash([]);
        }
        g.save();
        g.translate(x, y);
        g.rotate(face);
        g.fillStyle = '#ffffff';
        g.beginPath();
        g.moveTo(14, 0);
        g.lineTo(-10, -9);
        g.lineTo(-5, 0);
        g.lineTo(-10, 9);
        g.closePath();
        g.fill();
        g.fillStyle = '#c9d6ff';
        g.beginPath();
        g.moveTo(14, 0);
        g.lineTo(-5, 0);
        g.lineTo(-10, 9);
        g.closePath();
        g.fill();
        g.restore();
        const deg = Math.round(mod((-ang * 180) / Math.PI, 360));
        pill(g, on ? `방향 맞춤 켬 · ${deg}°` : '방향 맞춤 끔', VW / 2, 14, 9, on ? '#ff5c8a' : 'rgba(255,255,255,.2)');
        g.restore();
      },
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [길 따라 흐르는 글자](https://ai-techstudio.web.app/ai/t/i92.md) `i92`
- 다음에 해 볼 기술: [2D 시차 층 · 무한 확대](https://ai-techstudio.web.app/ai/t/i104.md) `i104` · [2D 잔상 꼬리 · 대시 잔상](https://ai-techstudio.web.app/ai/t/i298.md) `i298`
- 참고 문서: [Godot 문서 — PathFollow2D](https://docs.godotengine.org/en/stable/classes/class_pathfollow2d.html)
