# AI 꾸러미 — 기 모으기 (차지)
> 주변 입자가 손 · 지팡이 쪽으로 빨려 들어오며 빛이 점점 커짐 → 발사  
> 견본: https://ai-techstudio.web.app/#t/i194

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

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i194 견본 항목 — `src/demos/demosSkillB.ts:2296`
```ts
  i194: {
    kind: '3d',
    caption: '주변 빛 알갱이가 지팡이 끝으로 빨려 들며 구슬이 점점 커지고 고리가 조여든다 → 한 번에 발사 · 폭발',
    make() {
      const A = arena({ fx: 1200, st: 700 });
      const mg = mage(A, 0x3a2a6a, [2, 2, 3.5]);
      mg.g.position.set(-2.6, 0, 0.8);
      mg.g.rotation.y = 0.55;
      A.scene.add(mg.g);
      const foe = monster(A, 0x6a4a4a, 0);
      foe.g.position.set(2.8, 0, -0.6);
      foe.g.rotation.y = -0.6;
      A.scene.add(foe.g);
      A.scene.updateMatrixWorld(true);
      const rings = [0, 1, 2].map(() => {
        const m = new THREE.Sprite(new THREE.SpriteMaterial({ map: A.tx.ring(), color: new THREE.Color(1.4, 1.8, 3), transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, opacity: 0 }));
        m.renderOrder = 8;
        A.scene.add(m);
        return m;
      });
      const groundG = decal(A.tx.glow(), 3, C(0x6a9aff, 1.5), 0);
      const boomG = decal(A.tx.glow(), 3.4, C(0x8ab0ff, 2), 0);
      const shock = decal(A.tx.ring(), 1, C(0x9ac0ff, 2.5), 0);
      A.scene.add(groundG, boomG, shock);
      const orbL = new THREE.PointLight(0x8ab0ff, 0, 8, 2);
      const boom = new Flash(0x9ac0ff, 300, 6, 10);
      A.scene.add(orbL, boom.light);
      const loop = new Loop(3.8);
      let chargeT = 2.2, streak = true;
      const acc = { acc: 0 };
      const BL = C(0x7aa8ff, 1.8);
      const WH = C(0xe0ecff, 3);
      let shotAge = -1;
      let shotFrom: V3 = [0, 0, 0];
      let bAge = 9;
      return base3d(A, (t, dt) => {
        const lt = loop.tick(t);
        const tip = mg.tip();
        const T = chargeT;
        const c = lt < T ? Math.pow(lt / T, 1.4) : 0;
        if (loop.wrapped) { shotAge = -1; }
        // 빨려 드는 알갱이
        if (lt < T - 0.1) for (let i = rate(acc, 60 + 160 * c, dt); i > 0; i--) {
          const d = randDir();
          const r = rr(1.8, 3.0);
          const p = add3(tip, [d[0] * r, d[1] * r * 0.7, d[2] * r]);
          const tan = norm3([-d[2], 0.2, d[0]]);
          const cc = Math.random() < 0.25 ? WH : BL;
          if (streak) A.st.spark({ p, v: sc3(tan, 0.8), life: 3, c: cc, c2: cc, a: 0.9, fadeIn: 0.3, pull: { at: tip, k: 7 + 10 * c }, len: 0.06, w: 0.022, tail: 0 });
          else A.fx.emit({ p, v: sc3(tan, 0.8), life: 3, size: 0.12, c: cc, a: 0.9, fadeIn: 0.3, pull: { at: tip, k: 7 + 10 * c } });
        }
        // 구슬이 커짐 + 떨림
        const jit = c > 0.6 ? (Math.random() - 0.5) * 0.04 * c : 0;
        orbGlow(A.fx, add3(tip, [jit, jit, 0]), BL, 0.15 + c * 0.55, 1);
        if (c > 0.5 && Math.random() < c * 0.6) drawBolt(A.st, boltPts(tip, add3(tip, sc3(randDir(), 0.4 + c * 0.5)), rng(Math.floor(t * 40)), 3, 0.4), 0.01, BL, c);
        // 조여드는 고리
        rings.forEach((rg, i) => {
          const ph = ((lt * 2.4 + i / 3) % 1);
          rg.position.set(tip[0], tip[1], tip[2]);
          rg.scale.setScalar(lerp(3.2, 0.3, ph));
          rg.material.opacity = lt < T ? ph * c * 0.9 : 0;
        });
        groundG.position.set(tip[0], 0.02, tip[2]);
        groundG.material.opacity = c * 0.9;
        orbL.position.set(tip[0], tip[1], tip[2]);
        orbL.intensity = lt < T ? 5 + 55 * c : 4;
        // 발사
        if (loop.hit(T)) {
          shotAge = 0;
          shotFrom = tip;
          flashAt(A.fx, tip, 3, BL, 0.2);
          sparks(A.st, tip, { n: 20, c: WH, c2: BL, speed: 5, life: 0.3, grav: 2 });
          A.shake(0.06);
        }
        const tgt = foe.center();
        const FLY = 0.28;
        if (shotAge >= 0) {
          shotAge += dt;
          const k = Math.min(1, shotAge / FLY);
          const p = mix3(shotFrom, tgt, k);
          if (k < 1) {
            const back = mix3(shotFrom, tgt, Math.max(0, k - 0.35));
            A.st.seg(back, p, 0.22, BL, 0.8, 0);
            A.st.seg(back, p, 0.08, WH, 1, 0.2);
            orbGlow(A.fx, p, BL, 0.55, 1);
          } else if (shotAge - dt < FLY) {
            bAge = 0;
            flashAt(A.fx, tgt, 6, C(0xb0d0ff, 2.5), 0.3);
            sparks(A.st, tgt, { n: 60, c: WH, c2: BL, speed: 9, life: 0.6, grav: 7, up: 0.5, w: 0.03 });
            for (let i = 0; i < 40; i++) A.fx.emit({ p: tgt, v: sc3(randDir(), rr(1, 4)), life: rr(0.4, 0.9), size: rr(0.3, 0.7), size2: 0.05, c: BL, a: 0.8, drag: 3 });
            boom.kick(tgt, 1);
            foe.hit(sc3(BL, 1.4), 1);
            A.shake(0.3);
            shotAge = -1;
          }
        }
        bAge += dt;
        boomG.position.set(tgt[0], 0.02, tgt[2]);
        boomG.material.opacity = Math.max(0, 1 - bAge / 0.9);
        shock.position.set(tgt[0], 0.04, tgt[2]);
        shock.scale.setScalar(0.5 + easeOut(bAge / 0.5) * 5);
        shock.material.opacity = Math.max(0, 1 - bAge / 0.5);
        foe.tick(dt, t);
        boom.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: 3, step: 0.1, value: chargeT, on: (v) => { chargeT = v; loop.P = v + 1.6; } },
        { type: 'toggle', label: '줄무늬 알갱이', value: streak, on: (v) => (streak = v) },
      ]);
    },
  }
```

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