# AI 꾸러미 — 치유 오라 · 버프 빛
> 발밑 빛 고리 + 위로 떠오르는 초록 빛 알갱이 + 몸을 감싸는 은은한 빛  
> 견본: https://ai-techstudio.web.app/#t/i187

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

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i187 견본 항목 — `src/demos/demosSkillB.ts:1452`
```ts
  i187: {
    kind: '3d',
    caption: '발밑 마법진 + 휘감아 오르는 초록 빛 알갱이 · + 표시 + 몸을 감싸는 빛 — 주기마다 회복 파동',
    make() {
      const A = arena({ cam: [0, 4.4, 5.0], look: [0, 0.85, 0] });
      const plus = A.addPool(new Pool(80, { map: A.tx.plus() }));
      const hero = mage(A, 0x2f6a4a, [1.4, 3, 1.6]);
      hero.g.position.set(0, 0, 0.2);
      A.scene.add(hero.g);
      const G = C(0x5dff9a, 1.6);
      const G2 = C(0x1fd36a, 1);
      const rune = decal(A.tx.rune(), 3.6, sc3(G, 1.3), 0.9);
      rune.position.set(0, 0.03, 0.2);
      const rune2 = decal(A.tx.rune(), 2.2, sc3(G, 0.9), 0.5);
      rune2.position.set(0, 0.035, 0.2);
      const pool = decal(A.tx.glow(), 4.5, G2, 0.6);
      pool.position.set(0, 0.02, 0.2);
      const wave = decal(A.tx.ring(), 1, sc3(G, 1.6), 0);
      wave.position.set(0, 0.04, 0.2);
      A.scene.add(rune, rune2, pool, wave);
      // 감싸는 빛 (가장자리 빛)
      const shell = new THREE.Mesh(
        new THREE.CapsuleGeometry(0.55, 1.0, 8, 20),
        new THREE.ShaderMaterial({
          uniforms: { uC: { value: new THREE.Color(G[0], G[1], G[2]) }, uA: { value: 1 }, uT: { value: 0 } },
          vertexShader: FRESNEL_V,
          fragmentShader: `uniform vec3 uC; uniform float uA; uniform float uT; varying vec3 vN; varying vec3 vV; varying vec2 vUv; varying vec3 vP;
            void main(){ float f = 1.0 - abs(dot(normalize(vN), normalize(vV)));
              float band = 0.6 + 0.4 * sin(vP.y * 9.0 - uT * 4.0);
              float a = pow(f, 2.2) * uA * band * smoothstep(-1.05, -0.4, vP.y);
              gl_FragColor = vec4(uC, a); ${TONE} }`,
          transparent: true,
          depthWrite: false,
          blending: THREE.AdditiveBlending,
        }),
      );
      shell.position.set(0, 0.95, 0.2);
      // 빛 장막 기둥
      const veil = new THREE.Mesh(
        new THREE.CylinderGeometry(1.45, 1.6, 2.4, 48, 1, true),
        new THREE.ShaderMaterial({
          uniforms: { uC: { value: new THREE.Color(G[0], G[1], G[2]) }, uT: { value: 0 } },
          vertexShader: FRESNEL_V,
          fragmentShader: `${NOISE} uniform vec3 uC; uniform float uT; varying vec2 vUv; varying vec3 vN; varying vec3 vV; varying vec3 vP;
            void main(){ float n = vn(vec2(vUv.x * 24.0, vUv.y * 3.0 - uT * 1.2));
              float a = pow(1.0 - vUv.y, 1.8) * (0.2 + 0.8 * n * n) * 0.6;
              gl_FragColor = vec4(uC, a); ${TONE} }`,
          transparent: true,
          depthWrite: false,
          side: THREE.DoubleSide,
          blending: THREE.AdditiveBlending,
        }),
      );
      veil.position.set(0, 1.2, 0.2);
      A.scene.add(shell, veil);
      const light = new Flash(0x6aff9a, 40, 3, 8);
      light.light.position.set(0, 0.8, 0.6);
      A.scene.add(light.light);
      const loop = new Loop(2.6);
      let amount = 1, wrapOn = true;
      const acc = { acc: 0 }, pacc = { acc: 0 };
      return base3d(A, (t, dt) => {
        const lt = loop.tick(t);
        rune.rotation.z = t * 0.35;
        rune2.rotation.z = -t * 0.6;
        const breath = 0.8 + 0.2 * Math.sin(t * 2.4);
        pool.material.opacity = 0.5 * breath;
        (shell.material as THREE.ShaderMaterial).uniforms.uA!.value = wrapOn ? 0.9 * breath + light.lv : 0;
        (shell.material as THREE.ShaderMaterial).uniforms.uT!.value = t;
        (veil.material as THREE.ShaderMaterial).uniforms.uT!.value = t;
        if (loop.hit(0.05)) {
          wave.material.opacity = 1;
          light.kick([0, 0.8, 0.6], 1);
          for (let i = 0; i < 50; i++) {
            const a = Math.random() * TAU, r = rr(0.2, 1.4);
            A.fx.emit({ p: [Math.cos(a) * r, 0.1, 0.2 + Math.sin(a) * r], v: [-Math.sin(a) * 0.8, rr(1.5, 3.2), Math.cos(a) * 0.8], life: rr(0.7, 1.3), size: rr(0.1, 0.22), size2: 0.02, c: C(0xc8ffd8, 3), c2: G2, a: 1, drag: 1 });
          }
          for (let i = 0; i < 4; i++) plus.emit({ p: [rr(-0.7, 0.7), rr(1.2, 1.8), 0.2 + rr(-0.3, 0.5)], v: [0, rr(0.6, 1), 0], life: 1.1, size: rr(0.35, 0.5), size2: 0.5, c: sc3(G, 1.4), a: 1, fadeIn: 0.15, rot: 0, spin: 0 });
        }
        const wk = clamp(lt / 0.9, 0, 1);
        wave.scale.setScalar(0.6 + easeOut(wk) * 3.4);
        wave.material.opacity *= Math.exp(-dt * 2.6);
        // 휘감아 오르는 알갱이
        for (let i = rate(acc, 70 * amount, dt); i > 0; i--) {
          const a = Math.random() * TAU, r = rr(0.35, 1.35);
          A.fx.emit({ p: [Math.cos(a) * r, rr(0, 0.2), 0.2 + Math.sin(a) * r], v: [-Math.sin(a) * 0.7 * (1.4 - r), rr(0.7, 1.6), Math.cos(a) * 0.7 * (1.4 - r)], life: rr(1.1, 2.0), size: rr(0.12, 0.26), size2: 0.03, c: Math.random() < 0.25 ? C(0xeaffb0, 3) : sc3(G, 2), c2: G2, a: 1, fadeIn: 0.2 });
        }
        for (let i = rate(pacc, 1.5 * amount, dt); i > 0; i--) plus.emit({ p: [rr(-1, 1), rr(0.3, 1.0), 0.2 + rr(-0.6, 0.8)], v: [0, rr(0.5, 0.9), 0], life: 1.4, size: rr(0.2, 0.3), size2: 0.3, c: sc3(G, 1.2), a: 0.9, fadeIn: 0.2, rot: 0, spin: 0 });
        orbGlow(A.fx, hero.tip(), G, 0.2 + light.lv * 0.2, 1);
        light.tick(dt, 10);
        A.fx.step(dt);
        plus.step(dt);
        A.st.step(dt);
        A.tickCam(dt);
      }, [
        { type: 'button', label: '회복 파동', on: () => loop.restart() },
        { type: 'range', label: '빛 알갱이 양', min: 0.2, max: 2.5, step: 0.1, value: amount, on: (v) => (amount = v) },
        { type: 'toggle', label: '몸을 감싸는 빛', value: wrapOn, on: (v) => (wrapOn = v) },
      ]);
    },
  }
```

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