# AI 꾸러미 — 하위 방출 (터질 때 또 터짐)
> 미사일이 닿는 순간 폭발 입자 · 파편 · 연기 고리가 새로 생김 — 맞는 순간이 살아남  
> 견본: https://ai-techstudio.web.app/#t/i176

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

## 주문서

### 만들어 줘: 하위 방출 (터질 때 또 터짐)

#### 1. 목표
불덩이 명중에 「하위 방출 (터질 때 또 터짐)」 기술을 깔끔한 기본 느낌으로 넣어 줘. — 미사일이 닿는 순간 폭발 입자 · 파편 · 연기 고리가 새로 생김 — 맞는 순간이 살아남

#### 2. 핵심 기술 용어
- 하위 방출 (터질 때 또 터짐) — 미사일이 닿는 순간 폭발 입자 · 파편 · 연기 고리가 새로 생김 — 맞는 순간이 살아남

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

#### 4. 조건
- 한 프레임 16ms 안 (폰 포함) — 무거운 계산은 처음에 한 번 또는 여러 프레임에 나눠서
- 새 재질 · 셰이더는 첫 화면 전에 미리 컴파일 (renderer.compileAsync)
- 화면 가장자리 흐림 · 비네트로 얼버무리지 않기
- 외부 라이브러리 추가 없이 three.js 와 three/examples 만

#### 5. 완성 기준 (이게 보이면 성공)
- 견본처럼 「미사일이 닿는 순간 폭발 입자 · 파편 · 연기 고리가 새로 생김 — 맞는 순간이 살아남」 이(가) 첫 화면에서 바로 보인다
- 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

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

## 원리
- 미사일이 닿는 순간 폭발 입자 · 파편 · 연기 고리가 새로 생김 — 맞는 순간이 살아남


## 흔한 실수 · 확인 목록
- (아직 정리된 것이 없어요)

## 완성 기준 체크리스트
- [ ] 견본처럼 「미사일이 닿는 순간 폭발 입자 · 파편 · 연기 고리가 새로 생김 — 맞는 순간이 살아남」 이(가) 첫 화면에서 바로 보인다
- [ ] 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- [ ] 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- [ ] 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

## 이 기술 정보
- id: `i176` · 분류: 이펙트 · 연출 › 스킬 VFX (마법 · 미사일) · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i176
- 쓰면 좋을 때: 쓰면 좋을 곳 — 불덩이 명중 · 폭죽

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i176 견본 항목 — `src/demos/demosSkillA.ts:2027`
```ts
  i176: {
    kind: '3d',
    caption: '보라 미사일이 닿으면 파편이 새로 방출되고, 그 파편이 또 터진다 (하위 방출 두 단계)',
    make(): Scene3D {
      const S = makeStage();
      const fx = new Fx(S, 2400);
      const lab = S.hud.label('tl');
      let sub = true;
      let count = 3;
      let timer = 0.2;
      let since = 99;
      const MCORE = flat(0xfff0ff, 8, 1);
      const MHALO = flat(0xa050ff, 1.4, 0.6);
      interface Missile {
        a: THREE.Vector3;
        c: THREE.Vector3;
        b: THREE.Vector3;
        k: number;
        dur: number;
        delay: number;
        live: boolean;
      }
      const ms: Missile[] = [];
      const p = new THREE.Vector3();
      const q = new THREE.Vector3();
      const caster = new THREE.Vector3(-3.4, 1.2, -1.4);
      const launch = (): void => {
        ms.length = 0;
        for (let i = 0; i < count; i++) {
          const b = new THREE.Vector3(1.6 + R(-0.9, 0.9), 0.05, 0.8 + R(-0.9, 0.9));
          const c = new THREE.Vector3(R(-1.5, 0.5), R(2.5, 3.8), R(-2.5, 1.5));
          ms.push({ a: caster.clone(), c, b, k: 0, dur: R(0.75, 0.95), delay: i * 0.16, live: true });
        }
        since = 0;
        timer = 2.6;
      };
      // 2차: 파편(tag 1)이 날며 꼬리, 죽을 때 또 터짐(3차)
      fx.extraStep = (pl, i, dt) => {
        if (pl.tag[i] === 1 && Math.random() < dt * 60) {
          fx.fire.spawn({ x: pl.x[i]!, y: pl.y[i]!, z: pl.z[i]!, life: R(0.25, 0.4), s0: 0.1, s1: 0.02, ramp: RAMP.gold, shape: 0 });
        }
      };
      fx.fire.onDie = (tag, x, y, z) => {
        if (tag !== 1) return;
        fx.fire.spawn({ x, y, z, life: 0.2, s0: 0.5, s1: 1.1, ramp: RAMP.flash, br: 0.6, shape: 0 });
        for (let i = 0; i < 16; i++) {
          const d = sphereDir();
          const sp = R(1.5, 3.5);
          fx.fire.spawn({ x, y, z, vx: d.x * sp, vy: d.y * sp, vz: d.z * sp, life: R(0.35, 0.7), s0: 0.06, s1: 0.02, ramp: RAMP.gold, st: 0.06, shape: 2, drag: 1.5, grav: 4 });
        }
        fx.smoke.spawn({ x, y, z, vy: 0.3, life: 1.2, s0: 0.2, s1: 0.7, ramp: RAMP.magicSmoke, shape: 1, rot: R(0, TAU) });
      };
      const hit = (x: number, z: number): void => {
        if (!sub) {
          fx.fire.spawn({ x, y: 0.2, z, life: 0.15, s0: 0.5, s1: 0.2, ramp: RAMP.magic, shape: 0 });
          return;
        }
        fx.fire.spawn({ x, y: 0.3, z, life: 0.25, s0: 1.4, s1: 2.6, ramp: flat(0xe0b0ff, 6, 1), shape: 0 });
        for (let i = 0; i < 40; i++) {
          const d = sphereDir();
          const sp = R(2, 6);
          fx.fire.spawn({ x, y: 0.2, z, vx: d.x * sp, vy: Math.abs(d.y) * sp, vz: d.z * sp, life: R(0.3, 0.6), s0: 0.08, s1: 0.02, ramp: RAMP.magicSpark, st: 0.05, shape: 2, drag: 2, grav: 4 });
        }
        for (let i = 0; i < 9; i++) {
          const d = coneDir(0.75);
          const sp = R(3.5, 5.5);
          fx.fire.spawn({ x, y: 0.2, z, vx: d.x * sp, vy: d.y * sp, vz: d.z * sp, life: R(0.5, 0.75), s0: 0.16, s1: 0.12, ramp: RAMP.gold, shape: 0, grav: 6, tag: 1 });
        }
        for (let i = 0; i < 16; i++) {
          const a = (i / 16) * TAU;
          fx.smoke.spawn({ x, y: 0.15, z, vx: Math.cos(a) * 3, vy: 0.2, vz: Math.sin(a) * 3, life: R(0.8, 1.2), s0: 0.25, s1: 0.9, ramp: RAMP.magicSmoke, shape: 1, rot: R(0, TAU), drag: 3 });
        }
        fx.ring(x, z, 1.6, 0.4, 0xc080ff, 3);
        fx.flash(x, 0.6, z, 60, 0xb070ff);
        fx.shake(0.04);
      };
      const controls: Control[] = [
        ctrlTog('하위 방출', true, (v) => (sub = v)),
        ctrlRange('미사일 수', 1, 6, 1, 3, (v) => (count = v)),
        ctrlBtn('다시 쏘기', launch),
      ];
      return {
        scene: S.scene,
        camera: S.cam,
        controls,
        update(t, dt0) {
          const dt = Math.min(dt0, 0.05);
          timer -= dt;
          since += dt;
          if (timer <= 0) launch();
          fx.orb.clear();
          // 시전자 표시
          fx.orb.spawn({ x: caster.x, y: caster.y, z: caster.z, life: 1, s0: 0.5 + Math.sin(t * 8) * 0.05, ramp: MHALO, shape: 0 });
          for (const m of ms) {
            if (!m.live) continue;
            if (m.delay > 0) {
              m.delay -= dt;
              continue;
            }
            q.copy(bez(m.a, m.c, m.b, m.k, p));
            m.k += dt / m.dur;
            if (m.k >= 1) {
              m.live = false;
              hit(m.b.x, m.b.z);
              continue;
            }
            bez(m.a, m.c, m.b, m.k, p);
            const vx = (p.x - q.x) / dt;
            const vy = (p.y - q.y) / dt;
            const vz = (p.z - q.z) / dt;
            fx.orb.spawn({ x: p.x, y: p.y, z: p.z, life: 1, s0: 0.28, ramp: MCORE, shape: 0 });
            fx.orb.spawn({ x: p.x, y: p.y, z: p.z, life: 1, s0: 0.9, ramp: MHALO, shape: 0 });
            fx.orb.spawn({ x: p.x, y: p.y, z: p.z, vx, vy, vz, life: 1, s0: 0.22, ramp: flat(0xd090ff, 3, 0.8), st: 0.06, shape: 2 });
            for (let j = 0; j < 3; j++) fx.fire.spawn({ x: p.x + R(-0.05, 0.05), y: p.y + R(-0.05, 0.05), z: p.z + R(-0.05, 0.05), vx: R(-0.4, 0.4), vy: R(-0.4, 0.4), vz: R(-0.4, 0.4), life: R(0.25, 0.45), s0: 0.22, s1: 0.04, ramp: RAMP.magic, shape: 0 });
          }
          const live = ms.some((m) => m.live);
          lab.set(!sub ? '하위 방출 끔 — 닿으면 그냥 사라짐' : live ? '① 미사일 날아감' : since < 1.6 ? '② 명중 → 파편 방출 → ③ 파편이 또 터짐' : '하위 방출 켬', sub ? '#c08cff' : '#888');
          fx.update(dt, t);
          S.tick(dt);
        },
        resize: (w, h) => S.resize(w, h),
        dispose: () => S.dispose(),
      };
    },
  }
```

## 관련 기술
- 이 효과를 만든 기술: [입자 방출기 (수명 곡선)](https://ai-techstudio.web.app/ai/t/i170.md) `i170`
