# AI 꾸러미 — 유성 낙하
> 하늘 표시 → 불타는 바위가 비스듬히 떨어짐 → 큰 폭발 · 먼지 · 화면 흔들림  
> 견본: https://ai-techstudio.web.app/#t/i186

이 문서를 코드 도우미(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: `i186` · 분류: 이펙트 · 연출 › 스킬 VFX (마법 · 미사일) · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i186
- 쓰면 좋을 때: 쓰면 좋을 곳 — 큰 보상 · 보스 문제

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i186 견본 항목 — `src/demos/demosSkillB.ts:1317`
```ts
  i186: {
    kind: '3d',
    caption: '바닥에 떨어질 곳 표시 → 불타는 바위가 비스듬히 떨어짐 → 섬광 · 충격 고리 · 파편 · 먼지 · 화면 흔들림',
    make() {
      const A = arena({ fx: 1400, st: 500 });
      const smoke = A.addPool(new Pool(500, { normal: true, map: A.tx.puff() }));
      smoke.obj.renderOrder = 4;
      const I: V3 = [0.6, 0, 0.1];
      const mons = ([[-0.6, 0, 1.2], [1.9, 0, -0.6], [0.2, 0, -1.4]] as V3[]).map((p, i) => {
        const m = monster(A, [0x5d4a78, 0x4a5c6e, 0x6a4a4a][i]!, i);
        m.g.position.set(p[0], 0, p[2]);
        A.scene.add(m.g);
        return m;
      });
      const warn = new THREE.Mesh(
        new THREE.PlaneGeometry(1, 1),
        new THREE.MeshBasicMaterial({ map: A.tx.rune(), color: new THREE.Color(2.6, 0.45, 0.06), transparent: true, depthWrite: false, blending: THREE.AdditiveBlending }),
      );
      warn.rotation.x = -Math.PI / 2;
      warn.position.set(I[0], 0.025, I[2]);
      const warnFill = decal(A.tx.glow(), 4, C(0xff3010, 1), 0);
      warnFill.position.set(I[0], 0.02, I[2]);
      const shock = decal(A.tx.ring(), 1, C(0xffa040, 2.5), 0);
      shock.position.set(I[0], 0.04, I[2]);
      const crater = decal(A.tx.glow(), 3.5, C(0xff6a10, 2), 0);
      crater.position.set(I[0], 0.03, I[2]);
      const scorch = decal(A.tx.shadow(), 3.6, [1, 1, 1], 0, true);
      scorch.position.set(I[0], 0.015, I[2]);
      // 바위
      const rg = new THREE.IcosahedronGeometry(0.5, 2);
      const pa = rg.attributes.position as THREE.BufferAttribute;
      for (let i = 0; i < pa.count; i++) {
        const x = pa.getX(i), y = pa.getY(i), z = pa.getZ(i);
        const f = 1 + 0.22 * Math.sin(x * 7 + 1) * Math.sin(y * 6 + 2) * Math.sin(z * 8 + 3);
        pa.setXYZ(i, x * f, y * f, z * f);
      }
      rg.computeVertexNormals();
      const lava = A.tx.lava();
      const rock = new THREE.Mesh(rg, new THREE.MeshStandardMaterial({ color: 0x2a1c16, roughness: 0.9, flatShading: true, emissive: new THREE.Color(2.5, 1.0, 0.3), emissiveMap: lava }));
      A.scene.add(warn, warnFill, shock, crater, scorch, rock);
      const boom = new Flash(0xffa050, 900, 4.5, 14);
      const fireL = new THREE.PointLight(0xff7a30, 0, 10, 2);
      A.scene.add(boom.light, fireL);
      const loop = new Loop(4.4);
      let size = 1, shakeOn = true, smokeOn = true;
      const T0 = 0.85, T1 = 1.75;
      const S0: V3 = add3(I, [-4.2, 10, -5]);
      const fireAcc = { acc: 0 }, smokeAcc = { acc: 0 }, emberAcc = { acc: 0 };
      const FIRE: V3 = C(0xffc070, 4);
      const FIRE2: V3 = C(0xff3a08, 1.2);
      return base3d(A, (t, dt) => {
        const lt = loop.tick(t);
        if (loop.wrapped) { scorch.material.opacity = 0; }
        // 예고 원
        const wk = sstep(0, 0.35, lt) * (1 - sstep(T1 - 0.05, T1, lt));
        const pulse = 0.75 + 0.25 * Math.sin(lt * 18);
        warn.material.opacity = wk * pulse;
        warn.scale.setScalar((3.4 - sstep(0, T1, lt) * 0.6) * size);
        warn.rotation.z = lt * 0.8;
        warnFill.material.opacity = wk * 0.45 * (0.6 + 0.4 * sstep(0.3, T1, lt));
        warnFill.scale.setScalar(3.6 * size * sstep(0, T1, lt));
        // 낙하
        const fk = (lt - T0) / (T1 - T0);
        rock.visible = fk >= 0 && fk < 1;
        if (rock.visible) {
          const e = Math.pow(fk, 1.5);
          const p = mix3(S0, add3(I, [0, 0.35 * size, 0]), e);
          rock.position.set(p[0], p[1], p[2]);
          rock.scale.setScalar(size);
          rock.rotation.set(lt * 5, lt * 3, 0);
          const dir = norm3(sub3(I, S0));
          orbGlow(A.fx, p, C(0xff8a30, 1.6), 0.55 * size, 1);
          for (let i = rate(fireAcc, 160 * size, dt); i > 0; i--) {
            A.fx.emit({ p: add3(p, sc3(randDir(), 0.3 * size)), v: add3(sc3(dir, -2.5), sc3(randDir(), 0.6)), life: rr(0.35, 0.7), size: rr(0.7, 1.1) * size, size2: 0.2, c: FIRE, c2: FIRE2, a: 0.6, drag: 1.5 });
          }
          if (smokeOn) for (let i = rate(smokeAcc, 40, dt); i > 0; i--) {
            smoke.emit({ p: add3(p, sc3(randDir(), 0.3)), v: add3(sc3(dir, -1.2), [0, 0.3, 0]), life: rr(1, 1.6), size: 0.8 * size, size2: 2.4 * size, c: [0.12, 0.09, 0.08], a: 0.55, spin: rr(-1, 1), drag: 1 });
          }
          if (Math.random() < 0.6) A.st.spark({ p, v: add3(sc3(dir, -3), sc3(randDir(), 2)), life: 0.4, c: C(0xffd090, 3), c2: FIRE2, len: 0.05, w: 0.025, grav: 3, drag: 1 });
          fireL.position.set(p[0], p[1], p[2]);
          fireL.intensity = 30 + 30 * e;
        } else fireL.intensity = 0;
        // 쾅
        if (loop.hit(T1)) {
          const c: V3 = add3(I, [0, 0.3, 0]);
          flashAt(A.fx, c, 9 * size, C(0xffd2a0, 2.5), 0.3);
          boom.kick(c, size);
          shock.material.opacity = 1;
          crater.material.opacity = 1;
          scorch.material.opacity = 0.9;
          sparks(A.st, c, { n: 70, c: C(0xffd8a0, 3.5), c2: FIRE2, speed: 9 * size, life: 0.9, grav: 9, up: 0.75, len: 0.05, w: 0.035 });
          for (let i = 0; i < 70; i++) {
            const d = randDir();
            A.fx.emit({ p: add3(c, sc3(d, 0.4)), v: [d[0] * 4, Math.abs(d[1]) * 3 + 0.5, d[2] * 4], life: rr(0.4, 0.9), size: rr(0.8, 1.6) * size, size2: 0.3, c: FIRE, c2: FIRE2, a: 0.7, drag: 3.5 });
          }
          if (smokeOn) for (let i = 0; i < 46; i++) {
            const a = (i / 46) * TAU;
            const s = rr(2.5, 5) * size;
            smoke.emit({ p: add3(I, [Math.cos(a) * 0.5, 0.2, Math.sin(a) * 0.5]), v: [Math.cos(a) * s, rr(0.3, 1.4), Math.sin(a) * s], life: rr(1.3, 2.4), size: 1.0 * size, size2: 2.8 * size, c: [0.16, 0.13, 0.11], a: 0.6, spin: rr(-1, 1), drag: 2.4 });
          }
          for (let i = 0; i < 24; i++) {
            const d = randDir();
            smoke.emit({ p: c, v: [d[0] * 4, Math.abs(d[1]) * 6 + 2, d[2] * 4], life: rr(0.7, 1.1), size: 0.16, size2: 0.12, c: [0.05, 0.04, 0.04], a: 1, grav: 12, fadeIn: 0 });
          }
          for (const m of mons) m.hit(C(0xff7a30, 1.5), 1);
          if (shakeOn) A.shake(0.45 * size);
        }
        // 남은 불씨
        const after = lt - T1;
        if (after > 0 && after < 2) for (let i = rate(emberAcc, 30 * (1 - after / 2), dt); i > 0; i--) {
          const a = Math.random() * TAU, r = Math.sqrt(Math.random()) * 1.4 * size;
          A.fx.emit({ p: add3(I, [Math.cos(a) * r, 0.05, Math.sin(a) * r]), v: [0, rr(0.2, 0.8), 0], life: rr(0.6, 1.3), size: rr(0.06, 0.14), size2: 0.02, c: C(0xffb050, 3), c2: FIRE2, a: 1 });
        }
        shock.scale.setScalar((1 + easeOut(clamp(after / 0.6, 0, 1)) * 8) * size);
        shock.material.opacity *= Math.exp(-dt * 4.5);
        crater.material.opacity *= Math.exp(-dt * 1.1);
        crater.scale.setScalar(3.5 * size);
        scorch.scale.setScalar(3.6 * size);
        if (lt > loop.P - 0.5) scorch.material.opacity *= Math.exp(-dt * 6);
        for (const m of mons) m.tick(dt, t);
        boom.tick(dt);
        A.fx.step(dt);
        smoke.step(dt);
        A.st.step(dt);
        A.tickCam(dt);
      }, [
        { type: 'button', label: '다시 떨어뜨리기', on: () => loop.restart() },
        { type: 'range', label: '크기', min: 0.6, max: 1.6, step: 0.1, value: size, on: (v) => (size = v) },
        { type: 'toggle', label: '화면 흔들림', value: shakeOn, on: (v) => (shakeOn = v) },
        { type: 'toggle', label: '연기 · 먼지', value: smokeOn, on: (v) => (smokeOn = v) },
      ]);
    },
  }
```

## 관련 기술
- 이 효과를 만든 기술: [층 쌓기 (핵 · 빛무리 · 불티 · 연기)](https://ai-techstudio.web.app/ai/t/i171.md) `i171`
