# AI 꾸러미 — 비전 미사일 (유도 탄) — Homing missile (steering)
> 빛 구슬 여러 개를 부채꼴로 쏜 뒤 매 프레임 목표 쪽으로 속도를 조금씩 돌려서, 휘어지며 움직이는 목표를 쫓아가 맞는 유도 미사일을 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i185

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

## 주문서

### 만들어 줘: 비전 미사일 (유도 탄) — Homing missile (steering)

#### 1. 목표
움직이는 괴물 하나를 쫓아가는 유도 미사일을 만들어 줘 — 여러 개가 부채꼴로 퍼졌다가 휘어지며 따라가 맞고, 리본 꼬리가 남게. 분위기는 보라 · 분홍 비전 빛.

#### 2. 핵심 기술 용어
- **Homing missile (steering)** — 유도 탄 — 목표 쪽으로 방향을 조금씩 틀기
- **Seek steering behavior** — 원하는 속도와 지금 속도의 차이만큼 꺾기
- **Ribbon trail** — 지나온 점을 이어 가늘어지는 꼬리
- **Fan-out launch** — 부채꼴로 퍼뜨려 쏘기

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

#### 4. 조건
- 유도는 「속도를 원하는 속도 쪽으로 조금씩」 — 위치를 목표로 직접 lerp 하지 않는다 (그러면 휘지 않고 미끄러진다)
- 꺾는 세기는 나이에 따라 커지게 — 처음엔 크게 휘고 끝엔 반드시 맞게
- 최대 비행 시간(2.4초)을 두어 영원히 도는 미사일이 없게
- 꼬리는 지나온 점 목록으로, 맞은 뒤에도 꼬리가 줄어들며 사라진다
- 미사일 수(1~9) · 유도 세기 · 리본 꼬리 켬/끔 조절

#### 5. 완성 기준 (이게 보이면 성공)
- 보라 빛 구슬 다섯 개가 부채꼴로 퍼졌다가 휘어지며 움직이는 괴물을 쫓아가 하나씩 맞는다
- 맞을 때마다 분홍 불티 · 번쩍 · 바닥 빛이 터진다
- 「유도 세기」를 낮추면 크게 돌아서 늦게 맞고, 높이면 거의 곧게 날아간다
- 「리본 꼬리」를 끄면 구슬만 날아간다

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

## 원리
- 쏠 때 k 번째 미사일 옆 속도를 (k/(n−1)·2 − 1)·4.2 로 주어 부채꼴로 퍼뜨린다 (위로도 2.4 ~ 4).
- 매 프레임 원하는 속도 = 목표 방향 × (6.5 + 나이 × 5) — 오래 날수록 빨라진다.
- 속도를 원하는 속도 쪽으로 turn = min(1, (1.5 + 나이 × 9)·유도 세기·dt) 만큼 끌어당긴다. 처음엔 거의 안 꺾여 크게 휘고, 갈수록 날카롭게 꺾여 결국 맞는다.
- 목표까지 0.38 안이거나 2.4초가 지나면 맞음: 불티 16 · 번쩍 · 빛 알갱이 10 · 점광 · 흔들림.
- 꼬리는 지나온 자리 16개를 이어 앞은 굵게(0.12) 뒤는 가늘고 옅게, 색은 분홍 → 보라로 섞는다.

## 핵심 코드 — 쏘기(부채꼴) · 유도(속도 꺾기) · 리본 꼬리
(발췌: demos/demosSkillB.ts i185 make() 를 정리)
```ts
interface Mis { p: V3; v: V3; age: number; trail: V3[]; live: boolean }
const mis: Mis[] = [];
let count = 5, homing = 1;

function fire(k: number, tip: V3): void { // k 번째를 부채꼴로
  const s = count === 1 ? 0 : (k / (count - 1)) * 2 - 1;
  mis.push({ p: tip, v: [1.2 + Math.random(), 2.4 + Math.random() * 1.6, s * 4.2 + (Math.random() - 0.5)], age: 0, trail: [], live: true });
}

function step(dt: number, tgt: V3): void {
  for (const m of mis) {
    if (m.live) {
      m.age += dt;
      const to = sub3(tgt, m.p);
      const want = sc3(norm3(to), 6.5 + m.age * 5);                 // 오래 날수록 빠르게
      const turn = Math.min(1, (1.5 + m.age * 9) * homing * dt);   // 갈수록 날카롭게 꺾임
      m.v = add3(m.v, sc3(sub3(want, m.v), turn));
      m.p = add3(m.p, sc3(m.v, dt));
      m.trail.unshift(m.p);
      if (m.trail.length > 16) m.trail.pop();
      if (len3(to) < 0.38 || m.age > 2.4) { m.live = false; /* 불티 · 번쩍 · 점광 · 흔들림 */ }
    } else if (m.trail.length) m.trail.pop();                       // 맞은 뒤 꼬리 줄이기
    const n = m.trail.length;                                        // 리본: 앞 굵게 → 뒤 가늘게
    for (let i = 0; i < n - 1; i++) {
      const k = 1 - i / (n - 1);
      streaks.seg(m.trail[i + 1], m.trail[i], 0.11 * k + 0.01, mix3(PINK, PURPLE, k), 0.75 * k);
    }
  }
  for (let i = mis.length - 1; i >= 0; i--) if (!mis[i].live && !mis[i].trail.length) mis.splice(i, 1);
}
```

## 흔한 실수 · 확인 목록
- [ ] **꺾는 세기를 늘 같게 두면 목표 둘레를 영원히 돈다** — 나이에 따라 꺾는 세기 · 속도를 키우고(1.5 + 9·나이), 2.4초가 지나면 맞은 것으로 친다.
- [ ] **위치를 목표로 바로 lerp 하면 휘는 맛이 없다** — 속도를 꺾어야 관성이 남아 곡선이 생긴다.
- [ ] **맞자마자 미사일을 지우면 꼬리가 툭 끊긴다** — live = false 로 두고 꼬리 점을 한 프레임에 하나씩 빼다가 다 빠지면 지운다.

## 완성 기준 체크리스트
- [ ] 보라 빛 구슬 다섯 개가 부채꼴로 퍼졌다가 휘어지며 움직이는 괴물을 쫓아가 하나씩 맞는다
- [ ] 맞을 때마다 분홍 불티 · 번쩍 · 바닥 빛이 터진다
- [ ] 「유도 세기」를 낮추면 크게 돌아서 늦게 맞고, 높이면 거의 곧게 날아간다
- [ ] 「리본 꼬리」를 끄면 구슬만 날아간다

## 이 기술 정보
- id: `i185` · 분류: 이펙트 · 연출 › 스킬 VFX (마법 · 미사일) · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 미사일마다 계산 몇 줄 + 꼬리 선분 16개. 9개면 선분 150개 정도를 띠 한 묶음으로. 폰에서도 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i185
- 쓰면 좋을 때: 움직이는 목표를 반드시 맞혀야 하는 연출 / 여러 개가 한꺼번에 한 곳으로 모이는 장면 (묶음 세기)
- 쓰지 말 때: 정해진 곳에 정확한 시간에 닿아야 할 때 — 유도는 도착 시간이 들쭉날쭉하다. 그땐 베지에 곡선(i208 처럼)으로 시간을 정한다 / 수십 개 동시 — 꼬리 선분이 많아진다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i185 견본 항목 — `src/demos/demosSkillB.ts:1221`
```ts
  i185: {
    kind: '3d',
    caption: '보라 빛 구슬 여러 개가 부채꼴로 퍼졌다가 휘어지며 움직이는 적을 쫓아가 맞는다 (유도 + 리본 꼬리)',
    make() {
      const A = arena({ st: 700 });
      const mg = mage(A, 0x4b2a7a, [2.6, 1.2, 3.6]);
      mg.g.position.set(-3.4, 0, 1.0);
      mg.g.rotation.y = 0.5;
      A.scene.add(mg.g);
      const foe = monster(A, 0x3f5a52, 0.5);
      A.scene.add(foe.g);
      const glow = decal(A.tx.glow(), 3.2, C(0xb26bff, 1.6), 0);
      A.scene.add(glow);
      const hitL = new Flash(0xc88cff, 50, 8);
      const flyL = new THREE.PointLight(0xb070ff, 0, 7, 2);
      A.scene.add(hitL.light, flyL);
      A.scene.updateMatrixWorld(true);
      interface Mis { p: V3; v: V3; age: number; trail: V3[]; live: boolean; acc: { acc: number } }
      const mis: Mis[] = [];
      const loop = new Loop(3.0);
      let count = 5, homing = 1, tail = true;
      const PC = C(0xb36bff, 1.7);
      const PK = C(0xff7ad9, 1.6);
      return base3d(A, (t, dt) => {
        const lt = loop.tick(t);
        // 움직이는 과녁
        const fx = 2.6 + Math.sin(t * 0.7) * 0.5, fz = -0.2 + Math.sin(t * 0.9) * 1.4;
        foe.g.position.set(fx, 0, fz);
        const tgt = foe.center();
        const tip = mg.tip();
        for (let k = 0; k < count; k++) {
          if (loop.hit(0.15 + k * 0.09)) {
            const s = count === 1 ? 0 : (k / (count - 1)) * 2 - 1;
            mis.push({ p: tip, v: [1.2 + Math.random(), 2.4 + Math.random() * 1.6, s * 4.2 + (Math.random() - 0.5)], age: 0, trail: [], live: true, acc: { acc: 0 } });
            flashAt(A.fx, tip, 0.9, PC, 0.15);
          }
        }
        const casting = lt < 0.25 + count * 0.09;
        orbGlow(A.fx, tip, PC, casting ? 0.3 : 0.16, 1);
        let cx = 0, cy = 0, cz = 0, cn = 0;
        for (const m of mis) {
          if (m.live) {
            m.age += dt;
            const to = sub3(tgt, m.p);
            const dist = len3(to);
            const speed = 6.5 + m.age * 5;
            const want = sc3(norm3(to), speed);
            const turn = Math.min(1, (1.5 + m.age * 9) * homing * dt);
            m.v = add3(m.v, sc3(sub3(want, m.v), turn));
            m.p = add3(m.p, sc3(m.v, dt));
            m.trail.unshift(m.p);
            if (m.trail.length > 16) m.trail.pop();
            if (dist < 0.38 || m.age > 2.4) {
              m.live = false;
              sparks(A.st, m.p, { n: 16, c: C(0xe6c2ff, 3), c2: PK, speed: 5.5, life: 0.45, grav: 5, up: 0.4 });
              flashAt(A.fx, m.p, 2.2, PC);
              for (let i = 0; i < 10; i++) A.fx.emit({ p: m.p, v: sc3(randDir(), rr(0.5, 1.6)), life: rr(0.4, 0.8), size: 0.18, size2: 0.02, c: PK, a: 1, drag: 2 });
              hitL.kick(m.p, 1);
              foe.hit(C(0xb070ff, 2), 1);
              glow.material.opacity = 1;
              A.shake(0.04);
            }
            const sc = 0.22 + Math.sin(t * 40 + m.age * 7) * 0.02;
            orbGlow(A.fx, m.p, PC, sc, 1);
            cx += m.p[0]; cy += m.p[1]; cz += m.p[2]; cn++;
            for (let i = rate(m.acc, 60, dt); i > 0; i--) A.fx.emit({ p: add3(m.p, sc3(randDir(), 0.08)), v: sc3(randDir(), 0.3), life: rr(0.3, 0.7), size: rr(0.06, 0.13), size2: 0.01, c: mix3(PC, PK, Math.random()), a: 0.9 });
          } else if (m.trail.length) m.trail.pop();
          // 리본 꼬리
          if (tail) {
            const n = m.trail.length;
            for (let i = 0; i < n - 1; i++) {
              const k = 1 - i / (n - 1);
              A.st.seg(m.trail[i + 1]!, m.trail[i]!, 0.11 * k + 0.01, mix3(PK, PC, k), 0.75 * k);
            }
          }
        }
        for (let i = mis.length - 1; i >= 0; i--) if (!mis[i]!.live && !mis[i]!.trail.length) mis.splice(i, 1);
        if (cn) flyL.position.set(cx / cn, cy / cn, cz / cn);
        flyL.intensity = cn ? 10 + cn * 3 : 0;
        glow.position.set(fx, 0.02, fz);
        glow.material.opacity *= Math.exp(-dt * 3);
        foe.tick(dt, t);
        hitL.tick(dt);
        A.fx.step(dt);
        A.st.step(dt);
        A.tickCam(dt);
      }, [
        { type: 'button', label: '다시 쏘기', on: () => loop.restart() },
        { type: 'range', label: '미사일 수', min: 1, max: 9, step: 1, value: count, on: (v) => (count = v) },
        { type: 'range', label: '유도 세기', min: 0.2, max: 2, step: 0.1, value: homing, on: (v) => (homing = v) },
        { type: 'toggle', label: '리본 꼬리', value: tail, on: (v) => (tail = v) },
      ]);
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [연쇄 번개 (튀는 번개)](https://ai-techstudio.web.app/ai/t/i184.md) `i184`
- 다음에 해 볼 기술: [방패 · 보호막 맞음](https://ai-techstudio.web.app/ai/t/i191.md) `i191`
