# AI 꾸러미 — 독구름 · 저주 안개
> 녹색 · 보라 연기 덩어리가 부글부글 퍼지고 해골 · 거품  
> 견본: https://ai-techstudio.web.app/#t/i188

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

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i188 견본 항목 — `src/demos/demosSkillB.ts:1555`
```ts
  i188: {
    kind: '3d',
    caption: '독 병이 깨지며 보라 · 녹색 연기가 부글부글 퍼진다 — 거품이 터지고 해골이 떠오르며 안의 적은 중독',
    make() {
      const A = arena({ fx: 900 });
      const smoke = A.addPool(new Pool(260, { normal: true, map: A.tx.puff() }));
      smoke.obj.renderOrder = 3;
      const glowP = A.addPool(new Pool(200, { map: A.tx.puff() }));
      const bubbles = A.addPool(new Pool(60, { map: A.tx.bubble() }));
      const skulls = A.addPool(new Pool(10, { map: A.tx.skull() }));
      skulls.obj.renderOrder = 7;
      const mg = mage(A, 0x3b2a5a, [1.6, 3, 0.8]);
      mg.g.position.set(-3.4, 0, 1.2);
      mg.g.rotation.y = 0.6;
      A.scene.add(mg.g);
      const CEN: V3 = [0.7, 0, -0.2];
      const mons = ([[0.2, 0, 0.4], [1.4, 0, -0.8]] as V3[]).map((p, i) => {
        const m = monster(A, i ? 0x6a4a4a : 0x5d4a78, i * 2);
        m.g.position.set(p[0], 0, p[2]);
        A.scene.add(m.g);
        return m;
      });
      const ground = decal(A.tx.glow(), 5.5, C(0x6dff3a, 1.0), 0);
      ground.position.set(CEN[0], 0.02, CEN[2]);
      A.scene.add(ground);
      const flask = new THREE.Group();
      const glass = new THREE.MeshStandardMaterial({ color: 0x80ff70, emissive: new THREE.Color(0.4, 1.4, 0.2), roughness: 0.15, metalness: 0.1 });
      const fb = new THREE.Mesh(new THREE.SphereGeometry(0.16, 16, 12), glass);
      const fn = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 0.16, 10), glass);
      fn.position.y = 0.18;
      const cork = new THREE.Mesh(new THREE.CylinderGeometry(0.055, 0.05, 0.06, 10), new THREE.MeshStandardMaterial({ color: 0x8a5a30 }));
      cork.position.y = 0.28;
      flask.add(fb, fn, cork);
      A.scene.add(flask);
      const light = new Flash(0x7aff40, 50, 4, 8);
      A.scene.add(light.light);
      const amb = new THREE.PointLight(0x7aff50, 0, 7, 2);
      amb.position.set(CEN[0], 0.6, CEN[2]);
      A.scene.add(amb);
      const loop = new Loop(4.6);
      let dense = 1, skullOn = true;
      const sAcc = { acc: 0 }, gAcc = { acc: 0 }, bAcc = { acc: 0 }, kAcc = { acc: 0 };
      const PUR: V3 = [0.16, 0.04, 0.22];
      const GRN: V3 = [0.07, 0.2, 0.04];
      const GL: V3 = C(0x8aff4a, 1.2);
      let env = 0;
      return base3d(A, (t, dt) => {
        const lt = loop.tick(t);
        // 병 던지기
        const tk = lt / 0.6;
        flask.visible = tk < 1;
        const tip = mg.tip();
        if (flask.visible) {
          const p = mix3(tip, add3(CEN, [0, 0.15, 0]), tk);
          p[1] += Math.sin(tk * Math.PI) * 1.6;
          flask.position.set(p[0], p[1], p[2]);
          flask.rotation.set(tk * 9, 0, tk * 6);
          A.fx.put(p, 0.9, GL, 0.35);
        }
        if (loop.hit(0.6)) {
          const p: V3 = add3(CEN, [0, 0.2, 0]);
          sparks(A.st, p, { n: 26, c: C(0xd8ffc0, 3), c2: GL, speed: 5, life: 0.5, grav: 8, up: 0.6 });
          flashAt(A.fx, p, 3, C(0x9aff60, 2));
          light.kick(p, 1);
          for (let i = 0; i < 26; i++) {
            const d = randDir();
            smoke.emit({ p, v: [d[0] * 3.2, Math.abs(d[1]) * 1.5, d[2] * 3.2], life: rr(1.4, 2.4), size: 0.8, size2: 2.6, c: i % 2 ? PUR : GRN, a: 0.7, spin: rr(-0.8, 0.8), drag: 2.4 });
          }
        }
        const want = lt < 0.6 ? 0 : lt < loop.P - 0.9 ? 1 : 0;
        env = lerp(env, want, 1 - Math.exp(-dt * 2.5));
        const e = env * dense;
        for (let i = rate(sAcc, 26 * e, dt); i > 0; i--) {
          const a = Math.random() * TAU, r = Math.sqrt(Math.random()) * 1.7;
          smoke.emit({ p: add3(CEN, [Math.cos(a) * r, rr(0.1, 0.5), Math.sin(a) * r]), v: [Math.cos(a) * 0.25, rr(0.1, 0.35), Math.sin(a) * 0.25], life: rr(2, 3), size: rr(1, 1.4), size2: rr(2.2, 2.9), c: Math.random() < 0.55 ? PUR : GRN, a: 0.5, fadeIn: 0.3, spin: rr(-0.5, 0.5) });
        }
        for (let i = rate(gAcc, 16 * e, dt); i > 0; i--) {
          const a = Math.random() * TAU, r = Math.sqrt(Math.random()) * 1.6;
          glowP.emit({ p: add3(CEN, [Math.cos(a) * r, rr(0.05, 0.4), Math.sin(a) * r]), v: [0, rr(0.05, 0.25), 0], life: rr(1.5, 2.5), size: rr(1.2, 1.8), size2: 2.4, c: Math.random() < 0.6 ? sc3(GL, 0.5) : C(0xb84aff, 0.6), a: 0.35, fadeIn: 0.3, spin: rr(-0.4, 0.4) });
        }
        for (let i = rate(bAcc, 9 * e, dt); i > 0; i--) {
          const a = Math.random() * TAU, r = Math.sqrt(Math.random()) * 1.5;
          bubbles.emit({ p: add3(CEN, [Math.cos(a) * r, rr(0.05, 0.4), Math.sin(a) * r]), v: [0, rr(0.3, 0.6), 0], life: rr(0.6, 1.1), size: 0.1, size2: rr(0.3, 0.45), c: C(0xb4ff8a, 1.8), a: 1, fadeIn: 0.05, rot: 0, spin: 0 });
        }
        if (skullOn) for (let i = rate(kAcc, 1.1 * env, dt); i > 0; i--) {
          skulls.emit({ p: add3(CEN, [rr(-1.1, 1.1), rr(0.6, 1.0), rr(-0.8, 0.9)]), v: [0, rr(0.35, 0.55), 0], life: 1.8, size: 0.55, size2: 0.75, c: C(0xa6ff70, 1.8), a: 0.85, fadeIn: 0.25, rot: rr(-0.2, 0.2), spin: rr(-0.15, 0.15) });
        }
        // 거품 꺼질 때 반짝 (가끔)
        if (Math.random() < 6 * e * dt) A.fx.emit({ p: add3(CEN, [rr(-1.3, 1.3), rr(0.3, 0.9), rr(-1.1, 1.1)]), life: 0.2, size: 0.4, size2: 0.1, c: C(0xc8ffa0, 2.5), a: 1, fadeIn: 0 });
        ground.material.opacity = 0.75 * env;
        amb.intensity = 14 * env;
        for (const m of mons) {
          const pulse = 0.5 + 0.5 * Math.sin(t * 5 + m.g.position.x * 3);
          if (env > 0.3 && pulse > 0.97) m.hit(C(0x5aff30, 1.2), 0.5);
          m.mat.emissive.lerp(new THREE.Color(0.05 * env, 0.25 * env * pulse, 0.03), 0.5);
          m.tick(dt, t);
          m.mat.emissive.g = Math.max(m.mat.emissive.g, 0.18 * env * pulse);
        }
        orbGlow(A.fx, tip, GL, 0.16, 1);
        light.tick(dt);
        A.fx.step(dt);
        smoke.step(dt);
        glowP.step(dt);
        bubbles.step(dt);
        skulls.step(dt);
        A.st.step(dt);
        A.tickCam(dt);
      }, [
        { type: 'button', label: '다시 던지기', on: () => loop.restart() },
        { type: 'range', label: '연기 짙기', min: 0.3, max: 2, step: 0.1, value: dense, on: (v) => (dense = v) },
        { type: 'toggle', label: '해골', value: skullOn, on: (v) => (skullOn = v) },
      ]);
    },
  }
```

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