# AI 꾸러미 — 던전 스킬 연출 (마법 · 피격)
> 불덩이 · 회오리 · 번개를 던전 위에 — 바닥 빛 · 그림자 · 맞은 적 반응까지  
> 견본: https://ai-techstudio.web.app/#t/i490

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

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeSkills — `src/demos/demosDiablo.ts:3354`
```ts
function makeSkills(): Scene3D {
  const BG = 0x050407;
  const scene = new THREE.Scene();
  scene.background = new THREE.Color(BG);
  scene.fog = new THREE.Fog(BG, 14, 30);
  const cam = new THREE.PerspectiveCamera(34, 1.6, 0.5, 60);
  const hud = new Hud();
  const msg = hud.label('던전 짓는 중 …', 0.5, 0.5, 0.5, 0.5, 0.8);
  const tag = hud.label('', 0.03, 0.95, 0, 1, 0.62, '#ffcf4a', '#22180a');
  const dmg = hud.label('', 0.5, 0.5, 0.5, 0, 0.9, 'none', '#ffd25a');
  tag.visible = dmg.visible = false;
  const flashM = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ color: 0xcfe0ff, transparent: true, opacity: 0, blending: THREE.AdditiveBlending, depthTest: false, depthWrite: false, toneMapped: false }));
  flashM.renderOrder = 40;
  hud.scene.add(flashM);
  const hemi = new THREE.HemisphereLight(0x46557a, 0x0e0907, 0.55);
  scene.add(hemi);
  const moon = new THREE.DirectionalLight(0x8ea4d8, 1.1);
  moon.position.set(2.5, 10, 7);
  moon.castShadow = true;
  moon.shadow.mapSize.set(1024, 1024);
  Object.assign(moon.shadow.camera, { left: -7, right: 7, top: 6, bottom: -6, near: 1, far: 30 });
  moon.shadow.bias = -0.0006;
  moon.shadow.normalBias = 0.03;
  scene.add(moon);
  const tl = [0, 1].map(() => {
    const l = new THREE.PointLight(0xff9c58, 9, 7, 2);
    scene.add(l);
    return l;
  });
  const fx = new THREE.PointLight(0xff8a3a, 0, 9, 1.8);
  fx.position.set(0, -20, 0);
  scene.add(fx);
  const world = new THREE.Group();
  scene.add(world);
  const uScale = { value: 400 };
  const fire = new Parts(1100, true, uScale);
  const smoke = new Parts(260, false, uScale);
  scene.add(fire.points, smoke.points);
  const orb = [glowSprite(0xffffff, 0.3, 1), glowSprite(0xffa040, 0.75, 0.9), glowSprite(0xff4a10, 1.9, 0.45)];
  for (const s of orb) {
    s.visible = false;
    s.renderOrder = 8;
    scene.add(s);
  }
  // 충격파 고리
  const ringMat = new THREE.ShaderMaterial({
    uniforms: { uCol: { value: new THREE.Color(1, 0.55, 0.2) }, uA: { value: 0 } },
    vertexShader: 'varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }',
    fragmentShader: 'uniform vec3 uCol; uniform float uA; varying vec2 vUv; void main(){ float r = length(vUv - 0.5) * 2.0; float a = smoothstep(0.62, 0.92, r) * (1.0 - smoothstep(0.92, 1.0, r)) * uA; gl_FragColor = vec4(uCol, a); }',
    transparent: true,
    depthWrite: false,
    blending: THREE.AdditiveBlending,
  });
  const ring = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), ringMat);
  ring.rotation.x = -Math.PI / 2;
  ring.renderOrder = 4;
  scene.add(ring);
  // 그을음 · 숯불 데칼
  const decal = (tex: THREE.Texture, add: boolean, color: number): THREE.Mesh => {
    const m = new THREE.Mesh(
      new THREE.PlaneGeometry(2.4, 2.4),
      new THREE.MeshBasicMaterial({ map: tex, color, transparent: true, opacity: 0, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -3, blending: add ? THREE.AdditiveBlending : THREE.NormalBlending, toneMapped: !add }),
    );
    m.rotation.x = -Math.PI / 2;
    m.renderOrder = 2;
    scene.add(m);
    return m;
  };
  const blank = new THREE.DataTexture(new Uint8Array([0, 0, 0, 0]), 1, 1);
  blank.needsUpdate = true;
  const scorchA = decal(blank, false, 0xffffff);
  const emberA = decal(blank, true, 0xff8a40);
  const scorchB = decal(blank, false, 0xffffff);
  emberA.scale.setScalar(0.75);
  // 회오리 (안 · 밖 원뿔) + 먼지 고리 + 돌 조각
  const uT = { value: 0 };
  const torn = [
    { col: new THREE.Color(0.6, 0.5, 0.4), spd: 1.6, r0: 0.34, r1: 1.15, a: 0.45 },
    { col: new THREE.Color(0.55, 0.78, 1.0), spd: 2.6, r0: 0.18, r1: 0.8, a: 0.5 },
  ].map((o) => {
    const m = new THREE.Mesh(
      new THREE.CylinderGeometry(o.r1, o.r0, 2.7, 48, 14, true).translate(0, 1.35, 0),
      new THREE.ShaderMaterial({
        vertexShader: TORN_VS,
        fragmentShader: TORN_FS,
        uniforms: { uT, uA: { value: 0 }, uCol: { value: o.col }, uSpd: { value: o.spd }, uNoise: { value: null } },
        transparent: true,
        depthWrite: false,
        blending: THREE.AdditiveBlending,
        side: THREE.DoubleSide,
      }),
    );
    m.renderOrder = 6;
    m.visible = false;
    scene.add(m);
    return { m, a: o.a };
  });
  const rocks = new THREE.InstancedMesh(new THREE.IcosahedronGeometry(0.045, 0), new THREE.MeshStandardMaterial({ color: 0x9a8a76, roughness: 0.85, emissive: 0x3a3026 }), 34);
  rocks.visible = false;
  rocks.castShadow = true;
  scene.add(rocks);
  const bolt = new Bolt();
  scene.add(bolt.glowM, bolt.coreM);
  const blobK = blob(1.1, 0.6);
  const blobD = blob(1.0, 0.55);
  scene.add(blobK, blobD);

  const KP = V3(-2.0, 0, 0.6);
  const DP = V3(1.95, 0, 0.35);
  let d: Dungeon | null = null;
  let torch: Torches | null = null;
  let dummy: Dummy | null = null;
  let K: Knight | null = null;
  let kit: Kit | null = null;
  let dead = false;
  let texs: THREE.Texture[] = [];
  const job = new Job();
  const warm = new Warm();
  loadKit().then((k) => {
    if (dead) return;
    kit = k;
    const out: { d?: Dungeon } = {};
    job.start(
      (function* (): Gen {
        yield* buildDungeon(k, { map: ROOM, C: 1.3, WH: 2.3, torches: [[3, 1, 0, -1], [5, 1, 0, -1]] }, out);
        d = out.d!;
        world.add(d.group);
        torch = new Torches(d.flames);
        world.add(torch.group);
        (scorchA.material as THREE.MeshBasicMaterial).map = scorchTex(3, [14, 8, 4]);
        yield;
        (emberA.material as THREE.MeshBasicMaterial).map = scorchTex(4, [255, 120, 30]);
        yield;
        (scorchB.material as THREE.MeshBasicMaterial).map = scorchTex(8, [8, 8, 14]);
        yield;
        dummy = makeDummy(k);
        dummy.g.position.copy(DP);
        dummy.g.rotation.y = -Math.PI / 2;
        world.add(dummy.g);
      })(),
    );
  });
  // 상태
  const P: Pose = { t: 0, ph: 0, walk: 0, swing: -1, cast: 0, castKind: 0, guard: 0 };
  let t0 = 0;
  let prevT = -1;
  let only = -1;
  let restart = false;
  let showScorch = true;
  let showFlash = true;
  let tilt = 0;
  let tiltV = 0;
  let hit = 0;
  let lift = 0;
  let spin = 0;
  let flash = 0;
  let boltAge = 9;
  let dmgAge = 9;
  let dmgText = '';
  let dmgCol = '#ffd25a';
  let orbFrom = V3();
  let ringAge = 9;
  let ringK = 1;
  const rnd = rng(77);
  const tip = V3();
  const base = V3();
  const tmp = V3();
  const C = (r: number, g: number, b: number): THREE.Color => new THREE.Color(r, g, b);
  const sph = (k: number): THREE.Vector3 => V3(rnd() - 0.5, rnd() - 0.5, rnd() - 0.5).normalize().multiplyScalar(k);
  const hitDummy = (push: number, text: string, col: string): void => {
    hit = 1;
    tiltV -= push;
    dmgAge = 0;
    dmgText = text;
    dmgCol = col;
  };
  const NAMES = ['① 불덩이 — 빛 · 불씨 · 그을음', '② 회오리 — 먼지 · 돌 조각 · 띄우기', '③ 번개 — 갈래 · 번쩍 · 불꽃'];
  return {
    scene,
    camera: cam,
    update(t, dt) {
      if (job.busy) job.step();
      if (d && !K && knightReady()) {
        K = makeKnight(world);
        K.root.position.copy(KP);
        K.root.rotation.y = Math.PI / 2 - 0.12;
        texs = sceneTex(scene);
        for (const tw of torn) (tw.m.material as THREE.ShaderMaterial).uniforms.uNoise!.value = NOISE;
        warm.reset();
        t0 = t;
      }
      if (!K || !d || !dummy) return;
      dt = Math.min(dt, 0.05);
      if (restart) {
        restart = false;
        t0 = t;
        prevT = -1;
      }
      const tt = t - t0;
      const T = only >= 0 ? (tt % 3.2) + only * 3.2 : tt % 9.6;
      if (prevT < 0 || T < prevT) {
        // 처음으로 — 남은 효과 정리
        fire.clear();
        smoke.clear();
        prevT = T - 1e-4;
      }
      const ph = Math.floor(T / 3.2);
      const lt = T - ph * 3.2;
      const plt = prevT - ph * 3.2;
      const cross = (a: number): boolean => plt < a && lt >= a;
      tag.set(NAMES[ph]!);
      // 기사 자세
      const castOn = sstep(0.0, 0.4, lt) * (1 - sstep(ph === 0 ? 1.3 : ph === 1 ? 2.6 : 1.6, (ph === 0 ? 1.3 : ph === 1 ? 2.6 : 1.6) + 0.5, lt));
      P.t = t;
      P.cast = castOn;
      P.castKind = ph === 0 ? 0 : ph === 1 ? 2 : 1;
      P.guard = 0.35;
      K.pose(P);
      K.cloth(dt, ph === 1 ? Math.sin(t * 3) * 3 * castOn : Math.sin(t * 0.7) * 0.4);
      K.blade(base, tip);
      blobK.position.set(KP.x, 0.009, KP.z);
      const head = V3(DP.x, 1.45 + lift, DP.z);
      const chest = V3(DP.x, 1.05 + lift, DP.z);
      // ── ① 불덩이 ──
      for (const s of orb) s.visible = false;
      if (ph === 0) {
        if (lt > 0.12 && lt < 0.58) {
          const k = (lt - 0.12) / 0.46;
          for (let i = 0; i < 3; i++) {
            const p = tip.clone().add(sph(0.5 + rnd() * 0.4));
            fire.spawn({ p, v: tip.clone().sub(p).multiplyScalar(1 / 0.28), life: 0.28, s0: 0.05, s1: 0.12, c0: C(1, 0.8, 0.45), c1: C(1, 0.4, 0.1) });
          }
          orb.forEach((s, i) => {
            s.visible = true;
            s.position.copy(tip);
            s.scale.setScalar([0.3, 0.75, 1.9][i]! * k * (0.9 + 0.1 * Math.sin(t * 40)));
          });
        }
        if (cross(0.58)) orbFrom = tip.clone();
        if (lt >= 0.58 && lt < 1.05) {
          const u = (lt - 0.58) / 0.47;
          const p = orbFrom.clone().lerp(chest, u);
          p.y += Math.sin(Math.PI * u) * 0.45;
          orb.forEach((s, i) => {
            s.visible = true;
            s.position.copy(p);
            s.scale.setScalar([0.32, 0.8, 2.0][i]! * (0.92 + 0.12 * Math.sin(t * 50 + i)));
          });
          for (let i = 0; i < 9; i++)
            fire.spawn({ p: p.clone().add(sph(0.09)), v: sph(0.5).add(V3(-1.2, 0.6, 0)), life: 0.3 + rnd() * 0.3, s0: 0.42, s1: 0.06, c0: C(1, 0.86, 0.5), c1: C(0.7, 0.12, 0.02), drag: 2, grav: 1.2 });
          if (rnd() < 0.5) smoke.spawn({ p: p.clone(), v: V3(-0.4, 0.5, 0), life: 1.1, s0: 0.2, s1: 0.7, c0: C(0.16, 0.13, 0.11), c1: C(0.05, 0.05, 0.05), a: 0.45, drag: 1 });
          fx.color.setHex(0xff8a3a);
          fx.position.copy(p);
          fx.intensity = 30;
        }
        if (cross(1.05)) {
          for (let i = 0; i < 110; i++) {
            const v = sph(2 + rnd() * 3.5);
            v.y = Math.abs(v.y) * 0.9 + 0.6;
            fire.spawn({ p: chest.clone().add(sph(0.15)), v, life: 0.45 + rnd() * 0.5, s0: 0.5, s1: 0.08, c0: C(1, 0.9, 0.6), c1: C(0.6, 0.08, 0.02), drag: 3.2, grav: 1.6 });
          }
          for (let i = 0; i < 60; i++) {
            const v = sph(5 + rnd() * 5);
            v.y = Math.abs(v.y) + 1;
            fire.spawn({ p: chest.clone(), v, life: 0.5 + rnd() * 0.6, s0: 0.07, s1: 0.02, c0: C(1, 0.95, 0.75), c1: C(1, 0.4, 0.05), drag: 0.6, grav: -9 });
          }
          for (let i = 0; i < 26; i++) smoke.spawn({ p: chest.clone().add(sph(0.3)), v: sph(0.8).add(V3(0, 0.9, 0)), life: 1.4 + rnd() * 0.9, s0: 0.45, s1: 1.4, c0: C(0.2, 0.16, 0.13), c1: C(0.06, 0.06, 0.06), a: 0.55, drag: 1.4 });
          ringAge = 0;
          ringK = 1;
          ringMat.uniforms.uCol!.value.setRGB(1, 0.55, 0.2);
          ring.position.set(DP.x, 0.02, DP.z);
          scorchA.position.set(DP.x, 0.006, DP.z);
          emberA.position.set(DP.x, 0.007, DP.z);
          hitDummy(5.5, '−248', '#ffb347');
        }
        if (lt >= 1.05) fx.intensity = 90 * Math.exp(-(lt - 1.05) * 5) + 6 * Math.exp(-(lt - 1.05) * 1.5) * flick(t, 3);
        const sc = lt >= 1.05 ? (1 - sstep(2.5, 3.15, lt)) * (showScorch ? 1 : 0) : 0;
        (scorchA.material as THREE.MeshBasicMaterial).opacity = 0.9 * sc;
        (emberA.material as THREE.MeshBasicMaterial).opacity = lt >= 1.05 ? 1.4 * Math.exp(-(lt - 1.05) * 1.6) * (showScorch ? 1 : 0) : 0;
        if (lt > 1.2 && lt < 2.6 && rnd() < 0.35)
          fire.spawn({ p: V3(DP.x + (rnd() - 0.5) * 1.2, 0.05, DP.z + (rnd() - 0.5) * 1.2), v: V3(0, 0.5 + rnd(), 0), life: 0.8, s0: 0.04, s1: 0.01, c0: C(1, 0.6, 0.2), c1: C(0.8, 0.2, 0.05) });
      } else {
        (scorchA.material as THREE.MeshBasicMaterial).opacity = 0;
        (emberA.material as THREE.MeshBasicMaterial).opacity = 0;
      }
      // ── ② 회오리 ──
      const A = ph === 1 ? sstep(0.35, 0.8, lt) * (1 - sstep(2.35, 2.8, lt)) : 0;
      uT.value = t;
      for (const tw of torn) {
        tw.m.visible = A > 0.001;
        tw.m.position.set(DP.x, 0, DP.z);
        tw.m.scale.set(0.5 + 0.5 * A, 0.4 + 0.6 * A, 0.5 + 0.5 * A);
        (tw.m.material as THREE.ShaderMaterial).uniforms.uA!.value = A * tw.a;
      }
      rocks.visible = A > 0.001;
      if (rocks.visible) {
        const ob = new THREE.Object3D();
        for (let i = 0; i < 34; i++) {
          const k = (i * 0.618034) % 1;
          const yy = ((t * (0.35 + k * 0.3) + k) % 1) * 2.4 * A;
          const r = (0.35 + yy * 0.32) * (0.8 + 0.4 * k);
          const a = t * (5 + k * 3) + i * 2.4;
          ob.position.set(DP.x + Math.cos(a) * r, yy + 0.05, DP.z + Math.sin(a) * r);
          ob.rotation.set(a, a * 1.3, k * 6);
          ob.scale.setScalar((0.6 + k) * A);
          ob.updateMatrix();
          rocks.setMatrixAt(i, ob.matrix);
        }
        rocks.instanceMatrix.needsUpdate = true;
        for (let i = 0; i < 3; i++) {
          const a = rnd() * TAU;
          const r = 0.3 + rnd() * 0.6;
          const y = rnd() * 2.2;
          tmp.set(-Math.sin(a), 0, Math.cos(a)).multiplyScalar(4 + rnd() * 2);
          fire.spawn({ p: V3(DP.x + Math.cos(a) * r, y, DP.z + Math.sin(a) * r), v: tmp.clone().add(V3(0, 1.5, 0)), life: 0.35, s0: 0.05, s1: 0.02, c0: C(0.8, 0.95, 1), c1: C(0.3, 0.5, 0.8), a: A });
        }
        if (rnd() < 0.6) {
          const a = rnd() * TAU;
          smoke.spawn({ p: V3(DP.x + Math.cos(a) * 0.9, 0.08, DP.z + Math.sin(a) * 0.9), v: V3(-Math.sin(a) * 2.5, 0.4, Math.cos(a) * 2.5), life: 0.9, s0: 0.3, s1: 0.8, c0: C(0.36, 0.3, 0.24), c1: C(0.2, 0.18, 0.15), a: 0.4 * A, drag: 1.2 });
        }
        fx.color.setHex(0xa8d8ff);
        fx.position.set(DP.x, 1.3, DP.z);
        fx.intensity = 16 * A * (0.85 + 0.15 * Math.sin(t * 20));
      }
      if (ph === 1) {
        if (cross(0.55)) hitDummy(1.5, '−38', '#bfe6ff');
        if (cross(1.3)) hitDummy(1.0, '−41', '#bfe6ff');
        if (cross(2.05)) hitDummy(1.0, '−44', '#bfe6ff');
        if (cross(2.75)) hitDummy(6, '−130', '#bfe6ff');
        lift = 0.32 * A * (1 + 0.25 * Math.sin(t * 6));
        spin += dt * 10 * A;
      } else {
        lift *= 1 - damp(dt, 0.12);
      }
      // ── ③ 번개 ──
      if (ph === 2) {
        if (lt > 0.15 && lt < 0.62 && rnd() < 0.8)
          fire.spawn({ p: tip.clone().add(sph(0.12)), v: sph(2.5), life: 0.18, s0: 0.05, s1: 0.01, c0: C(0.8, 0.9, 1), c1: C(0.3, 0.45, 1) });
        for (const at of [0.62, 0.88, 1.08]) {
          if (!cross(at)) continue;
          const from = V3(DP.x - 0.6 + (rnd() - 0.5) * 1.6, 6.2, DP.z - 2.6 - rnd());
          bolt.build(boltLines(from, head, rnd), cam.position);
          boltAge = 0;
          flash = 1;
          for (let i = 0; i < 45; i++) {
            const v = sph(3 + rnd() * 5);
            v.y = Math.abs(v.y) + 1.5;
            fire.spawn({ p: head.clone(), v, life: 0.35 + rnd() * 0.5, s0: 0.06, s1: 0.015, c0: C(0.9, 0.95, 1), c1: C(0.3, 0.5, 1), drag: 0.5, grav: -9 });
          }
          ringAge = 0;
          ringK = 0.42;
          ringMat.uniforms.uCol!.value.setRGB(0.55, 0.7, 1);
          ring.position.set(DP.x, 0.02, DP.z);
          scorchB.position.set(DP.x, 0.006, DP.z);
          hitDummy(3.2, at < 0.7 ? '−87' : at < 1 ? '−92' : '−105', '#d8e8ff');
        }
        (scorchB.material as THREE.MeshBasicMaterial).opacity = lt >= 0.62 ? 0.85 * (1 - sstep(2.5, 3.15, lt)) * (showScorch ? 1 : 0) : 0;
        if (boltAge < 0.5) {
          fx.color.setHex(0xb8d0ff);
          fx.position.copy(head).y += 0.6;
          fx.intensity = 90 * flash;
        } else if (fx.color.getHex() === 0xb8d0ff) fx.intensity *= 0.8;
      } else (scorchB.material as THREE.MeshBasicMaterial).opacity = 0;
      boltAge += dt;
      bolt.uA.value = boltAge < 0.22 ? (1 - boltAge / 0.22) * (0.7 + 0.3 * Math.sin(boltAge * 120)) : 0;
      bolt.glowM.visible = bolt.coreM.visible = bolt.uA.value > 0.001;
      flash *= 1 - damp(dt, 0.06);
      hemi.intensity = 0.55 + (showFlash ? 1.5 * flash : 0);
      (flashM.material as THREE.MeshBasicMaterial).opacity = showFlash ? 0.18 * flash : 0;
      if (ph !== 1 && ph !== 0 && boltAge > 0.5) fx.intensity *= 1 - damp(dt, 0.2);
      if (ph === 1 && A <= 0.001) fx.intensity *= 1 - damp(dt, 0.1);
      // 충격파
      ringAge += dt;
      const rk = clamp(ringAge / 0.45, 0, 1);
      ring.scale.setScalar(0.2 + 3.0 * ringK * (1 - (1 - rk) * (1 - rk)));
      ringMat.uniforms.uA!.value = ringAge < 0.45 ? (1 - rk) * 1.6 : 0;
      // 허수아비 반응 — 번쩍 · 밀려남 (용수철)
      tiltV += (-70 * tilt - 7 * tiltV) * dt;
      tilt += tiltV * dt;
      hit *= 1 - damp(dt, 0.07);
      dummy.g.position.set(DP.x, lift, DP.z);
      dummy.g.rotation.set(0, -Math.PI / 2 + spin, 0);
      dummy.g.rotateOnWorldAxis(V3(0, 0, 1), tilt * 0.12);
      for (const m of dummy.mats) {
        m.emissive.setRGB(1, 0.95, 0.85);
        m.emissiveIntensity = hit * 1.05;
      }
      blobD.position.set(DP.x, 0.009, DP.z);
      blobD.scale.setScalar(1 - lift * 0.8);
      dmgAge += dt;
      prevT = T;
      // 빛 · 횃불
      torch!.update(t);
      d.flames.slice(0, 2).forEach((f, i) => {
        tl[i]!.position.copy(f).y += 0.12;
        tl[i]!.intensity = 9 * flick(t, i * 2.1);
      });
      fire.update(dt);
      smoke.update(dt);
      cam.position.set(0.2, 4.7, 6.6);
// … (80줄 더 — 전체는 라이브 견본 페이지의 「코드」 탭)
```

### i490 견본 항목 — `src/demos/demosDiablo.ts:4453`
```ts
  i490: { kind: '3d', caption: '던전 바닥 위 불덩이 · 회오리 · 번개 — 빛 · 입자 · 그을음 데칼, 맞은 허수아비가 번쩍이며 밀려남', make: makeSkills }
```

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