# AI 꾸러미 — 불꽃놀이 (국화 · 버드나무 · 고리)
> 로켓 꼬리 → 꼭대기에서 터짐: 국화(구 방향 + 별마다 꼬리 입자) · 버드나무(무거운 금빛, 긴 꼬리가 처짐) · 고리(한 평면 원 + 죽을 때 탁탁 반짝) + 하늘 · 도시가 터짐 색으로 물듦 + 남는 연기  
> 견본: https://ai-techstudio.web.app/#t/i503

이 문서를 코드 도우미(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: `i503` · 분류: 이펙트 · 연출 › 입자 · 연출 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i503
- 쓰면 좋을 때: 쓰면 좋을 곳 — 단계 통과 · 축하 · 결과 화면

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### starRamp — `src/demos/demosVfx3.ts:198`
```ts
function starRamp(hex: number, I = 5): Float32Array {
  return ramp([[0, 0xffffff, I * 1.6, 1, 1], [0.12, hex, I, 1, 1], [0.7, hex, I * 0.55, 0.85, 1], [1, hex, I * 0.12, 0, 1]]);
}
```

### i503 견본 항목 — `src/demos/demosVfx3.ts:1109`
```ts
  i503: {
    kind: '3d',
    caption: '로켓이 꼬리를 끌고 올라가 터짐 — 국화(색 별이 꼬리 남김) · 버드나무(금빛이 길게 처짐) · 고리(평면 원 + 탁탁 반짝)',
    make(): Scene3D {
      const S = makeStage({ bg: 0x060814, cam: [0, 2.6, 13.5], look: [0, 7.6, 0], fov: 52 });
      S.scene.add(skyDome(0x03040c, 0x1a1636, 0x05060a, 1.4));
      const lab = S.hud.label('tl');
      // 도시 실루엣 (창 불빛)
      const win = cachedCanvas('v3win', 256, 256, (g, s) => {
        const r = rng(5);
        g.fillStyle = '#0a0c18';
        g.fillRect(0, 0, s, s);
        for (let y = 6; y < s; y += 14)
          for (let x = 5; x < s; x += 12) {
            if (r() < 0.07) {
              g.fillStyle = r() < 0.75 ? `rgba(255,${190 + r() * 50},${110 + r() * 60},${0.25 + r() * 0.45})` : 'rgba(170,210,255,0.8)';
              g.fillRect(x, y, 6, 8);
            }
          }
      });
      const winTex = texFrom(win, true);
      winTex.wrapS = winTex.wrapT = THREE.RepeatWrapping;
      const city = new THREE.Group();
      const rr = rng(77);
      for (let i = 0; i < 34; i++) {
        const w = 1 + rr() * 2.2;
        const h = 0.8 + Math.pow(rr(), 1.8) * 4;
        const tex = winTex.clone();
        tex.repeat.set(w / 2, h / 2);
        tex.offset.set(rr(), rr());
        const b = new THREE.Mesh(new THREE.BoxGeometry(w, h, 1.5), new THREE.MeshBasicMaterial({ map: tex, color: 0xffffff, fog: false }));
        b.position.set(-30 + i * 1.8 + rr() * 0.6, h / 2 - 0.5, -8 - rr() * 3);
        city.add(b);
      }
      S.scene.add(city);
      // 물 (도시 · 불꽃이 비침)
      const lake = new THREE.Mesh(new THREE.PlaneGeometry(120, 40), new THREE.MeshBasicMaterial({ color: 0x04050b }));
      lake.rotation.x = -Math.PI / 2;
      lake.position.set(0, -0.5, 10);
      S.scene.add(lake);
      const skyGlow = glowDisc(0xffffff, 0, 46, 2.6);
      skyGlow.mesh.rotation.x = 0;
      skyGlow.mesh.position.set(0, 9, -14);
      S.scene.add(skyGlow.mesh);
      const smoke = S.pool(400, 2);
      const P = S.pool(7000, 4);
      const TRAILR = ramp([[0, 0xffe0a0, 3.2, 1, 1], [0.4, 0xff8a30, 1.4, 0.7, 1], [1, 0x601000, 0.3, 0, 1]]);
      const GOLD = ramp([[0, 0xffffff, 7, 1, 1], [0.1, 0xffd890, 4, 1, 1], [0.6, 0xff9a30, 2, 0.9, 1], [1, 0x803000, 0.5, 0, 1]]);
      const WTRAIL = ramp([[0, 0xffd080, 2.4, 0.9, 1], [0.5, 0xff8a20, 1.1, 0.6, 1], [1, 0x401000, 0.2, 0, 1]]);
      const CRACK = ramp([[0, 0xffffff, 9, 1, 1], [1, 0xfff0d0, 3, 0, 1]]);
      const SMK = ramp([[0, 0x8a8ca0, 0.6, 0, 0], [0.2, 0x6a6c80, 0.55, 0.1, 0], [1, 0x3a3c4a, 0.4, 0, 0]]);
      const COLS = [0xff3050, 0x40ff80, 0x4aa0ff, 0xc060ff, 0xffd040, 0x30f0ff, 0xff60c0];
      const RAMPS = COLS.map((c) => starRamp(c, 4.5));
      const TRAILS = COLS.map((c) => ramp([[0, c, 2.4, 0.8, 1], [1, c, 0.3, 0, 1]]));
      let trails = true;
      let scale = 1;
      let pick = 0;
      let flashI = 0;
      const flashCol = new THREE.Color();
      interface Rocket {
        x: number;
        y: number;
        z: number;
        vx: number;
        vy: number;
        type: number;
        col: number;
      }
      const rockets: Rocket[] = [];
      const NAMES = ['국화 — 공 모양으로 퍼진 색 별이 꼬리를 남김', '버드나무 — 금빛 별이 무겁게 처지며 길게 늘어짐', '고리 — 한 평면 위의 원 + 끝에서 탁탁 반짝'];
      const ACC = ['#ff5070', '#ffc040', '#40e8ff'];
      let order = 0;
      let next = 0.3;
      P.onStep = (p, i, dt) => {
        if (!trails) return;
        const tg = p.tag[i]!;
        const k = p.age[i]! / p.life[i]!;
        if (tg === 1 && Math.random() < dt * 34) P.spawn({ x: p.x[i]!, y: p.y[i]!, z: p.z[i]!, vy: -0.2, life: R(0.35, 0.6), s0: 0.16, s1: 0.05, ramp: TRAILS[p.u[i]!]!, shape: 0, br: 1 - k });
        else if (tg === 2 && Math.random() < dt * 40) P.spawn({ x: p.x[i]!, y: p.y[i]!, z: p.z[i]!, vy: -0.5, life: R(1.5, 2.3), s0: 0.12, s1: 0.04, ramp: WTRAIL, shape: 2, grav: 0.6, br: 1 - k * 0.6 });
        else if (tg === 3 && Math.random() < dt * 18) P.spawn({ x: p.x[i]!, y: p.y[i]!, z: p.z[i]!, life: 0.4, s0: 0.13, s1: 0.04, ramp: TRAILS[p.u[i]!]!, shape: 0 });
      };
      P.onDie = (tg, x, y, z) => {
        if (tg === 3)
          for (let j = 0; j < 3; j++) P.spawn({ x: x + R(-0.15, 0.15), y: y + R(-0.15, 0.15), z: z + R(-0.15, 0.15), life: R(0.08, 0.16), s0: R(0.25, 0.4), ramp: CRACK, shape: 4, rot: R(0, TAU), delay: R(0, 0.25) });
      };
      const burst = (r: Rocket): void => {
        const { x, y, z } = r;
        const sc = scale;
        const ci = r.col;
        P.spawn({ x, y, z, life: 0.28, s0: 3.6 * sc, s1: 5.5 * sc, ramp: ramp([[0, 0xffffff, 5, 1, 1], [1, COLS[ci]!, 1, 0, 1]]), shape: 0 });
        flashI = 1;
        flashCol.setHex(r.type === 1 ? 0xffb050 : COLS[ci]!);
        skyGlow.mesh.position.set(x * 0.8, y, -14);
        if (r.type === 0) {
          const n = Math.round(130 * sc);
          for (let i = 0; i < n; i++) {
            const d = sphereDir();
            const sp = R(5.6, 6.4) * sc;
            P.spawn({ x, y, z, vx: d.x * sp, vy: d.y * sp, vz: d.z * sp, life: R(1.6, 2.0), s0: 0.3, s1: 0.12, ramp: RAMPS[ci]!, shape: 0, drag: 1.25, grav: 1.6, tag: 1, u: ci });
          }
          // 가운데 다른 색 작은 핵 (두 겹 국화)
          const c2 = (ci + 3) % COLS.length;
          for (let i = 0; i < 40 * sc; i++) {
            const d = sphereDir();
            const sp = R(2.3, 2.7) * sc;
            P.spawn({ x, y, z, vx: d.x * sp, vy: d.y * sp, vz: d.z * sp, life: R(1.2, 1.5), s0: 0.26, s1: 0.1, ramp: RAMPS[c2]!, shape: 0, drag: 1.2, grav: 1.4 });
          }
        } else if (r.type === 1) {
          const n = Math.round(62 * sc);
          for (let i = 0; i < n; i++) {
            const d = sphereDir();
            const sp = R(4.2, 5.0) * sc;
            P.spawn({ x, y, z, vx: d.x * sp, vy: d.y * sp + 1, vz: d.z * sp, life: R(2.8, 3.4), s0: 0.28, s1: 0.1, ramp: GOLD, shape: 0, drag: 1.4, grav: 2.4, tag: 2 });
          }
        } else {
          const nrm = new THREE.Vector3(R(-0.5, 0.5), R(0.5, 0.9), 1).normalize();
          const t1 = new THREE.Vector3(1, 0, 0).cross(nrm).normalize();
          const t2 = nrm.clone().cross(t1);
          const n = Math.round(72 * sc);
          for (let i = 0; i < n; i++) {
            const a = (i / n) * TAU;
            const sp = 6 * sc;
            const vx = (Math.cos(a) * t1.x + Math.sin(a) * t2.x) * sp;
            const vy = (Math.cos(a) * t1.y + Math.sin(a) * t2.y) * sp;
            const vz = (Math.cos(a) * t1.z + Math.sin(a) * t2.z) * sp;
            P.spawn({ x, y, z, vx, vy, vz, life: R(1.45, 1.6), s0: 0.3, s1: 0.12, ramp: RAMPS[ci]!, shape: 0, drag: 1.3, grav: 1.2, tag: 3, u: ci });
          }
          // 가운데 반짝 별 몇 개
          for (let i = 0; i < 18; i++) {
            const d = sphereDir();
            P.spawn({ x, y, z, vx: d.x * 1.2, vy: d.y * 1.2, vz: d.z * 1.2, life: R(0.8, 1.2), s0: 0.3, ramp: CRACK, shape: 4, rot: R(0, TAU), drag: 1.5 });
          }
        }
        for (let i = 0; i < 10; i++) {
          const d = sphereDir();
          smoke.spawn({ x: x + d.x * 1.2 * sc, y: y + d.y * 1.2 * sc, z: z + d.z * 1.2 * sc, vx: d.x * 0.5 + 0.25, vy: d.y * 0.5, vz: d.z * 0.5, life: R(3.5, 5), s0: 1.2 * sc, s1: 3.2 * sc, ramp: SMK, shape: 1, rot: R(0, TAU), rv: R(-0.2, 0.2), drag: 0.5, delay: R(0.2, 0.6) });
        }
        lab.set(NAMES[r.type]!, ACC[r.type]!);
      };
      const launch = (): void => {
        const type = pick > 0 ? pick - 1 : order++ % 3;
        rockets.push({ x: R(-5, 5), y: 0, z: R(-2, 1), vx: R(-0.6, 0.6), vy: R(10.8, 11.8), type, col: Math.floor(Math.random() * COLS.length) });
      };
      const controls: Control[] = [
        ctrlRange('종류 (0 번갈아 · 1 국화 · 2 버드나무 · 3 고리)', 0, 3, 1, 0, (v) => (pick = v)),
        ctrlTog('별 꼬리', true, (v) => (trails = v)),
        ctrlRange('터짐 크기', 0.6, 1.5, 0.05, 1, (v) => (scale = v)),
        ctrlBtn('한 발 쏘기', launch),
      ];
      return {
        scene: S.scene,
        camera: S.cam,
        controls,
        update(_t, dt0) {
          const dt = Math.min(dt0, 0.05);
          next -= dt;
          if (next <= 0) {
            launch();
            next = R(1.3, 1.8);
          }
          for (let i = rockets.length - 1; i >= 0; i--) {
            const r = rockets[i]!;
            r.vy -= 9.8 * 0.62 * dt;
            r.x += r.vx * dt;
            r.y += r.vy * dt;
            P.spawn({ x: r.x, y: r.y, z: r.z, life: 0.08, s0: 0.35, ramp: flat(0xfff0c0, 4, 1), shape: 0 });
            for (let j = 0; j < 3; j++) P.spawn({ x: r.x + R(-0.03, 0.03), y: r.y - R(0, 0.2), z: r.z, vx: R(-0.5, 0.5), vy: R(-2.5, -0.5), vz: R(-0.5, 0.5), life: R(0.35, 0.7), s0: 0.06, s1: 0.02, ramp: TRAILR, shape: 2, st: 0.04, grav: 4 });
            if (r.vy < 1.5) {
              burst(r);
              rockets.splice(i, 1);
            }
          }
          flashI *= Math.exp(-dt * 5);
          skyGlow.set(flashI * 0.13, flashCol.getHex());
          for (const b of city.children) ((b as THREE.Mesh).material as THREE.MeshBasicMaterial).color.setScalar(1).lerp(flashCol, flashI * 0.5).multiplyScalar(1 + flashI * 1.4);
          smoke.tick(dt);
          P.tick(dt);
          S.tick(dt);
        },
        resize: (w, h) => S.resize(w, h),
        dispose: () => S.dispose(),
      };
    },
  }
```

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