# AI 꾸러미 — 충격 고리 · 땅 파동 — Shockwave ring
> 맞은 자리 바닥에 퍼지는 빛 고리 두 개와 식어 가는 그을음 자국을 붙여서, 폭발 · 착지의 「쾅」이 바닥까지 전해진다.  
> 견본: https://ai-techstudio.web.app/#t/i178

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: 충격 고리 · 땅 파동 — Shockwave ring

#### 1. 목표
떨어지는 불덩이 · 유성이 바닥을 치면 그 자리에서 빛 고리가 퍼지고 그을음 자국이 남았다 식어 가게 해 줘. 분위기는 어두운 돌바닥 주황 불빛.

#### 2. 핵심 기술 용어
- **Shockwave ring** — 충격 고리 — 바닥에서 퍼지는 빛 띠
- **Scorch decal** — 그을음 데칼 — 바닥에 남는 탄 자국
- **polygonOffset (z-fighting fix)** — 바닥과 겹친 판이 깜박이지 않게 살짝 앞으로
- **Object pool (round robin)** — 고리 4개 · 자국 3개를 돌려 쓰기

#### 3. 환경
- 플랫폼: three.js r186 (ES 모듈 · TypeScript, `import * as THREE from "three"`), WebGL2, 외부 라이브러리 추가 없이
- 화면: 3D · 브라우저 — PC 와 폰(가로 844×390 · 세로 390×844) 모두, 60fps 목표

#### 4. 조건
- 고리 · 자국은 미리 몇 개(고리 4 · 자국 3) 만들어 두고 돌려 쓴다 — 터질 때마다 new 하지 않기
- 고리는 AdditiveBlending · depthWrite: false, 바닥보다 0.03 위
- 그을음은 polygonOffset(−2, −2) 로 바닥과 깜박임(z-fighting) 없이, 놓을 때마다 무작위 회전
- 고리 반지름은 easeOut, 밝기는 (1 − k)^1.4 — 등속으로 퍼지면 힘이 없다
- 고리 · 그을음 켬/끔 단추

#### 5. 완성 기준 (이게 보이면 성공)
- 불덩이가 떨어진 자리에서 주황 · 흰 고리 둘이 퍼지고, 바닥에 검은 자국과 주황 불씨 금이 남았다 식는다
- 고리 띠가 군데군데 끊겨 거칠게 보인다
- 「충격 고리」 · 「그을음 데칼」을 하나씩 끄면 그 층만 사라진다
- 자국이 바닥과 겹쳐 깜박이지 않는다

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 고리는 바닥에 눕힌 판 하나의 셰이더: 판 가운데서 거리 r, 띠 = e^(−((r − R)/W)²). 안쪽에 옅은 둘째 띠(0.82R)를 하나 더.
- R 은 easeOut(k)·0.9 로 빨리 퍼졌다 느려지고, 띠 굵기 W 는 0.035 → 0.095 로 굵어지며, 밝기는 (1 − k)^1.4 로 사라진다.
- 띠를 sin(각도·13 + r·30)·sin(각도·7 − 2) 로 조금씩 끊어 완벽한 원이 아니라 거친 충격처럼.
- 폭발 한 번에 고리 둘: 큰 주황(반지름 3.4, 0.6초) + 작은 흰(2.2, 0.4초).
- 그을음은 텍스처 r = 탄 자국, g = 불씨 금. 탄 자국은 덮고(최대 0.92), 불씨는 e^(−1.1t) 로 식으며 빛난다. 2.2초 남았다 1.4초에 걸쳐 사라진다.

## 핵심 코드 — 충격 고리 판 (셰이더) + 시간에 따른 값
(발췌: demos/demosSkillA.ts class Shock 를 정리)
```ts
const ringMat = new THREE.ShaderMaterial({
  uniforms: { uR: { value: 0 }, uW: { value: 0.05 }, uA: { value: 0 }, uCol: { value: new THREE.Color(0xffa850).multiplyScalar(3) } },
  vertexShader: 'varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }',
  fragmentShader:
    'uniform float uR, uW, uA; uniform vec3 uCol; varying vec2 vUv;' +
    'void main(){ vec2 p = vUv*2.0-1.0; float r = length(p); float ang = atan(p.y, p.x);' +
    '  float d = (r - uR) / uW;' +
    '  float band = exp(-d*d) + exp(-pow((r - uR*0.82)/(uW*0.6), 2.0))*0.35;' +   // 바깥 띠 + 안쪽 옅은 띠
    '  float inner = (1.0 - smoothstep(0.0, uR, r)) * step(r, uR) * 0.12;' +
    '  float brk = 0.72 + 0.28 * sin(ang*13.0 + r*30.0) * sin(ang*7.0 - 2.0);' +  // 군데군데 끊김
    '  float a = (band*brk + inner) * uA * (1.0 - smoothstep(0.95, 1.0, r));' +
    '  gl_FragColor = vec4(uCol, clamp(a, 0.0, 1.0)); }',
  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.position.set(x, 0.03, z); ring.scale.setScalar(maxR); age = 0;
function updateRing(dt: number, dur: number): void {
  age += dt;
  const k = Math.min(1, age / dur);
  ringMat.uniforms.uR.value = (1 - Math.pow(1 - k, 3)) * 0.9; // easeOut 으로 퍼짐
  ringMat.uniforms.uW.value = 0.035 + 0.06 * k;               // 점점 굵게
  ringMat.uniforms.uA.value = Math.pow(1 - k, 1.4);           // 사라짐
  ring.visible = age <= dur;
}
// 그을음: polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2
//   덮기 a = min(탄 자국 · uA · 0.95, 0.92), 불씨 = uHeat = exp(-t·1.1) · (0.85 + 0.15·sin(t·23))
```

## 흔한 실수 · 확인 목록
- [ ] **바닥과 같은 높이에 판을 놓으면 깜박인다** — 고리는 0.03 위로, 그을음은 polygonOffset(−2, −2) 로 앞에 그리게.
- [ ] **터질 때마다 고리 메시를 새로 만들면 쓰레기가 쌓인다** — 고리 4개 · 자국 3개를 미리 만들고 돌아가며(i++ % 개수) 다시 쓴다.
- [ ] **고리를 매끈한 원으로만 두면 수학 도형처럼 보인다** — 각도 sin 두 개를 곱한 끊김(0.72 ~ 1)으로 거칠게.

## 완성 기준 체크리스트
- [ ] 불덩이가 떨어진 자리에서 주황 · 흰 고리 둘이 퍼지고, 바닥에 검은 자국과 주황 불씨 금이 남았다 식는다
- [ ] 고리 띠가 군데군데 끊겨 거칠게 보인다
- [ ] 「충격 고리」 · 「그을음 데칼」을 하나씩 끄면 그 층만 사라진다
- [ ] 자국이 바닥과 겹쳐 깜박이지 않는다

## 이 기술 정보
- id: `i178` · 분류: 이펙트 · 연출 › 스킬 VFX (마법 · 미사일) · 3D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 판 하나에 짧은 셰이더. 고리 4장 · 자국 3장을 돌려 써서 새로 만들지 않는다. 폰에서도 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i178
- 쓰면 좋을 때: 폭발 · 착지 · 내려찍기처럼 바닥이 있는 충격 / 「어디에」 떨어졌는지 바닥에 남겨 보여 주고 싶을 때
- 쓰지 말 때: 바닥이 없는 공중 폭발 — 고리를 카메라 쪽으로 세우거나 빛 번쩍(점광)만 / 울퉁불퉁한 지형 — 평평한 판이 떠 보인다. 그땐 Decal 투영을 쓴다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i178 견본 항목 — `src/demos/demosSkillA.ts:2289`
```ts
  i178: {
    kind: '3d',
    caption: '떨어진 자리에서 빛 고리가 퍼지고 먼지가 바닥을 쓸며, 그을음 자국이 식어 간다',
    make(): Scene3D {
      const S = makeStage();
      const fx = new Fx(S, 1600);
      const lab = S.hud.label('tl');
      let ringOn = true;
      let scorchOn = true;
      let timer = 0.2;
      let shot = 0;
      let fall = -1;
      const from = new THREE.Vector3();
      const to = new THREE.Vector3();
      const p = new THREE.Vector3();
      const SPOTS: V3[] = [[-1.4, 0, 0.2], [1.3, 0, -0.6], [0.2, 0, 1.1]];
      const start = (): void => {
        const s = SPOTS[shot++ % SPOTS.length]!;
        to.set(s[0], 0.05, s[2]);
        from.set(s[0] + 2.2, 7.5, s[2] - 2.5);
        fall = 0;
        timer = 2.3;
      };
      const controls: Control[] = [ctrlTog('충격 고리', true, (v) => (ringOn = v)), ctrlTog('그을음 데칼', true, (v) => (scorchOn = v)), ctrlBtn('다시 떨어뜨리기', start)];
      return {
        scene: S.scene,
        camera: S.cam,
        controls,
        update(t, dt0) {
          const dt = Math.min(dt0, 0.05);
          timer -= dt;
          if (timer <= 0) start();
          fx.orb.clear();
          if (fall >= 0) {
            fall += dt / 0.38;
            if (fall >= 1) {
              fall = -1;
              fx.boom(to.x, 0.05, to.z, { k: 0.7, sc: 0.9, ring: ringOn, scorch: scorchOn, debris: true });
            } else {
              p.lerpVectors(from, to, fall * fall);
              const vx = (to.x - from.x) * 2 * fall / 0.38;
              const vy = (to.y - from.y) * 2 * fall / 0.38;
              const vz = (to.z - from.z) * 2 * fall / 0.38;
              fx.orb.spawn({ x: p.x, y: p.y, z: p.z, life: 1, s0: 0.35, ramp: flat(0xfff0d0, 8, 1), shape: 0 });
              fx.orb.spawn({ x: p.x, y: p.y, z: p.z, vx, vy, vz, life: 1, s0: 0.4, ramp: flat(0xff9030, 3, 0.8), st: 0.05, shape: 2 });
              const pk = Math.max(0, fall - dt / 0.38);
              for (let j = 0; j < 8; j++) {
                const f = lerp(pk, fall, j / 8);
                const f2 = f * f;
                fx.fire.spawn({ x: lerp(from.x, to.x, f2), y: lerp(from.y, to.y, f2), z: lerp(from.z, to.z, f2), vx: R(-0.3, 0.3), vy: R(0, 0.5), vz: R(-0.3, 0.3), life: R(0.25, 0.45), s0: 0.3, s1: 0.04, ramp: RAMP.fire, br: 0.6, shape: 1, rot: R(0, TAU) });
              }
            }
          }
          lab.set([ringOn ? '충격 고리' : '', scorchOn ? '그을음 데칼' : '', '먼지 고리'].filter(Boolean).join(' + '), '#ffb050');
          fx.update(dt, t);
          S.tick(dt);
        },
        resize: (w, h) => S.resize(w, h),
        dispose: () => S.dispose(),
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [층 쌓기 (핵 · 빛무리 · 불티 · 연기)](https://ai-techstudio.web.app/ai/t/i171.md) `i171`
- 다음에 해 볼 기술: [카메라 흔들림 (잡음 기반 · 감쇠)](https://ai-techstudio.web.app/ai/t/i429.md) `i429` · [광역 표시 (바닥 범위 원)](https://ai-techstudio.web.app/ai/t/i181.md) `i181`
