# AI 꾸러미 — 광역 표시 (바닥 범위 원) — AoE telegraph (area indicator)
> 떨어질 곳을 바닥 원 · 부채꼴이 가장자리부터 안쪽까지 차오르며 미리 보여 주고, 다 차면 그 범위에 터지게 해서 피하거나 노릴 시간을 준다.  
> 견본: https://ai-techstudio.web.app/#t/i181

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

## 주문서

### 만들어 줘: 광역 표시 (바닥 범위 원) — AoE telegraph (area indicator)

#### 1. 목표
유성 낙하 예고가 떨어질 곳을 바닥 원 · 부채꼴로 미리 보여 줘 — 테두리 · 흐르는 줄무늬 · 안에서 밖으로 차오르는 앞머리, 다 차면 그 범위에 터지게. 분위기는 빨간 경고 빛.

#### 2. 핵심 기술 용어
- **AoE telegraph (area indicator)** — 광역 표시 — 맞을 범위를 미리 보여 주기
- **Radial fill indicator** — 반지름으로 차오르는 진행 표시
- **Sector mask (atan angle test)** — 각도로 부채꼴만 남기기
- **Animated hazard stripes** — 흐르는 사선 경고 줄무늬

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

#### 4. 조건
- 원 · 부채꼴은 같은 셰이더 하나(define SECTOR)로 — 각도 검사만 다르게
- 테두리 · 옅은 안쪽 · 흐르는 줄무늬 · 차오르는 앞머리 넷이 다 보이게
- 차오르는 시간(0.6 ~ 3초)을 조절 값으로, 다 찬 순간에 정확히 터지게
- 바닥보다 살짝 위(0.025), 더하기 섞기 · 깊이 안 씀

#### 5. 완성 기준 (이게 보이면 성공)
- 왼쪽 원 · 오른쪽 부채꼴이 가운데부터 바깥으로 차오르고, 테두리와 흐르는 줄무늬가 보인다
- 다 차면 원에는 폭발이, 부채꼴에는 그 방향으로 불 숨결이 뿜어진다
- 「차오르는 시간」을 바꾸면 예고 길이가 바로 바뀐다
- 「모양」으로 원만 · 부채꼴만 · 둘 다 고를 수 있다

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

## 원리
- 바닥 판 하나에서 가운데까지 거리 r · 각도를 구한다. 원은 r ≤ 1 안쪽, 부채꼴은 |atan(x, y)| ≤ 반각(견본 0.55) 까지만.
- 테두리는 r = 0.975 근처 가는 빛 띠, 부채꼴은 양옆 곧은 선도 같은 굵기로 그린다.
- 안쪽은 아주 옅게(0.09) + 흐르는 사선 줄무늬 sin((x + y)·26 − 5t), 진행 uFill 까지는 0.32 로 채우고 그 끝(앞머리)은 밝은 띠로.
- uFill 을 0 → 1 로 1.6초에 걸쳐 올린다. 다 차면 그 자리에 폭발(원) · 불 숨결(부채꼴: 그 방향 ±0.5 rad 로 불꽃 70 + 불티 50) 을 터뜨린다.

## 핵심 코드 — 원 · 부채꼴 예고 셰이더 (SECTOR 를 define 하면 부채꼴)
(발췌: demos/demosSkillA.ts i181 teleMat() 을 정리)
```glsl
uniform float uFill, uHalf, uA, uTime; // 진행 0~1 · 부채꼴 반각 · 투명도 · 시간
uniform vec3 uCol;
varying vec2 vUv;
void main(){
  vec2 p = vUv * 2.0 - 1.0;
  float r = length(p);
  float inside = step(r, 1.0);
  float edge = exp(-pow((r - 0.975) / 0.022, 2.0));            // 둥근 테두리
#ifdef SECTOR
  float ang = abs(atan(p.x, p.y));
  inside *= step(ang, uHalf);                                   // 부채꼴만
  float side = exp(-pow(r * sin(uHalf - ang) / 0.022, 2.0)) * step(ang, uHalf + 0.05);
  edge = max(edge * step(ang, uHalf), side * step(r, 1.0));     // 양옆 곧은 선
#endif
  float filled = step(r, uFill) * inside;                       // 차오른 곳
  float front = exp(-pow((r - uFill) / 0.035, 2.0)) * inside;   // 앞머리
  float stripes = 0.5 + 0.5 * sin((p.x + p.y) * 26.0 - uTime * 5.0);
  float a = inside * (0.09 + 0.06 * stripes) + filled * 0.32 + front * 1.1 + edge;
  gl_FragColor = vec4(uCol * (1.0 + front * 1.5 + edge), clamp(a * uA, 0.0, 1.0));
}
// JS: uFill += dt / fillT (fillT 기본 1.6초), 1 이 되면 터뜨린다.
//     재질: transparent, depthWrite: false, AdditiveBlending, uCol = 0xff4020 × 2.6
```

## 흔한 실수 · 확인 목록
- [ ] **예고를 꽉 찬 빨강 원으로만 두면 남은 시간을 모른다** — 안에서 밖으로 차오르는 앞머리(uFill)로 남은 시간을 보여 준다.
- [ ] **부채꼴을 원 셰이더에 마스크로만 자르면 양옆 선이 없어 범위가 흐릿하다** — 양옆 경계까지의 거리 r·sin(반각 − 각도) 로 곧은 선을 그린다.
- [ ] **판이 바닥과 같은 높이면 깜박인다** — 0.025 위로 띄우고 depthWrite: false.

## 완성 기준 체크리스트
- [ ] 왼쪽 원 · 오른쪽 부채꼴이 가운데부터 바깥으로 차오르고, 테두리와 흐르는 줄무늬가 보인다
- [ ] 다 차면 원에는 폭발이, 부채꼴에는 그 방향으로 불 숨결이 뿜어진다
- [ ] 「차오르는 시간」을 바꾸면 예고 길이가 바로 바뀐다
- [ ] 「모양」으로 원만 · 부채꼴만 · 둘 다 고를 수 있다

## 이 기술 정보
- id: `i181` · 분류: 이펙트 · 연출 › 스킬 VFX (마법 · 미사일) · 3D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 바닥 판 하나에 짧은 셰이더. 터질 때 입자 120개. 폰에서도 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i181
- 쓰면 좋을 때: 큰 공격 · 낙하가 「어디에 언제」 떨어지는지 미리 알려 줘야 공정할 때 / 넓이 · 범위 · 각도를 눈으로 보여 주는 수학 문제
- 쓰지 말 때: 아주 빠른 공격 — 0.5초 안이면 표시를 읽기 전에 끝난다 / 판 칸을 가리면 안 되는 퍼즐 — 칸 테두리만 깜박이는 표시가 낫다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i181 견본 항목 — `src/demos/demosSkillA.ts:2527`
```ts
  i181: {
    kind: '3d',
    caption: '떨어질 곳을 바닥 원 · 부채꼴이 차오르며 미리 알려 주고, 다 차면 그 범위에 터진다',
    make(): Scene3D {
      const S = makeStage();
      const fx = new Fx(S, 2000);
      const lab = S.hud.label('tl');
      const teleMat = (sector: boolean): THREE.ShaderMaterial =>
        new THREE.ShaderMaterial({
          uniforms: { uFill: { value: 0 }, uHalf: { value: 0.6 }, uA: { value: 0 }, uTime: { value: 0 }, uCol: { value: new THREE.Color(0xff4020).multiplyScalar(2.6) } },
          defines: sector ? { SECTOR: '' } : {},
          vertexShader: FLAT_VS,
          fragmentShader: /* glsl */ `uniform float uFill, uHalf, uA, uTime; uniform vec3 uCol; varying vec2 vUv;
            void main(){ vec2 p = vUv*2.0-1.0; float r = length(p);
              float inside = step(r, 1.0);
              float edge = exp(-pow((r - 0.975)/0.022, 2.0));
            #ifdef SECTOR
              float ang = abs(atan(p.x, p.y));
              inside *= step(ang, uHalf);
              float side = exp(-pow(r * sin(uHalf - ang) / 0.022, 2.0)) * step(ang, uHalf + 0.05);
              edge = max(edge * step(ang, uHalf), side * step(r, 1.0));
              float fr = r;
            #else
              float fr = r;
            #endif
              float filled = step(fr, uFill) * inside;
              float front = exp(-pow((fr - uFill)/0.035, 2.0)) * inside;
              float stripes = 0.5 + 0.5 * sin((p.x + p.y) * 26.0 - uTime * 5.0);
              float a = inside * (0.09 + 0.06 * stripes) + filled * 0.32 + front * 1.1 + edge;
              gl_FragColor = vec4(uCol * (1.0 + front * 1.5 + edge), 1.0); ${TONE}
              gl_FragColor.a = clamp(a * uA, 0.0, 1.0); }`,
          transparent: true,
          depthWrite: false,
          blending: THREE.AdditiveBlending,
        });
      const circ = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), teleMat(false));
      circ.rotation.x = -Math.PI / 2;
      circ.position.set(-1.5, 0.025, 0.1);
      circ.scale.setScalar(1.5);
      const secM = teleMat(true);
      secM.uniforms.uHalf!.value = 0.55;
      const sec = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), secM);
      sec.rotation.x = -Math.PI / 2;
      sec.position.set(1.4, 0.026, 1.9);
      sec.scale.setScalar(2.9);
      S.scene.add(circ, sec);
      let mode = 2;
      let fillT = 1.6;
      let k = 0;
      let state = 0; // 0 채우기 1 터짐 대기
      let wait = 0;
      let meteor = -1;
      const pA = new THREE.Vector3();
      const meteorFrom = new THREE.Vector3();
      const controls: Control[] = [
        ctrlRange('모양 (0 원 · 1 부채꼴 · 2 둘 다)', 0, 2, 1, 2, (v) => (mode = v)),
        ctrlRange('차오르는 시간 (초)', 0.6, 3, 0.1, 1.6, (v) => (fillT = v)),
        ctrlBtn('처음부터', () => {
          k = 0;
          state = 0;
        }),
      ];
      const breath = (): void => {
        const ox = sec.position.x;
        const oz = sec.position.z;
        for (let i = 0; i < 70; i++) {
          const a = R(-0.5, 0.5);
          const sp = R(4, 7.5);
          // 부채꼴은 -z 쪽(화면 안쪽)을 본다
          fx.fire.spawn({ x: ox, y: 0.35, z: oz, vx: Math.sin(a) * sp, vy: R(0.2, 0.9), vz: -Math.cos(a) * sp, life: R(0.45, 0.6), s0: 0.3, s1: 1.1, ramp: RAMP.fire, shape: 1, rot: R(0, TAU), rv: R(-2, 2), drag: 1.2, delay: R(0, 0.25) });
        }
        for (let i = 0; i < 50; i++) {
          const a = R(-0.5, 0.5);
          const sp = R(5, 9);
          fx.fire.spawn({ x: ox, y: 0.3, z: oz, vx: Math.sin(a) * sp, vy: R(0.5, 2), vz: -Math.cos(a) * sp, life: R(0.4, 0.8), s0: 0.07, s1: 0.02, ramp: RAMP.spark, st: 0.05, shape: 2, drag: 0.8, grav: 5, delay: R(0, 0.25) });
        }
        fx.flash(ox, 0.8, oz - 1.2, 70, 0xff8030);
        fx.shake(0.05);
      };
      return {
        scene: S.scene,
        camera: S.cam,
        controls,
        update(t, dt0) {
          const dt = Math.min(dt0, 0.05);
          circ.visible = mode !== 1;
          sec.visible = mode !== 0;
          const cm = circ.material as THREE.ShaderMaterial;
          if (state === 0) {
            k += dt / fillT;
            if (k >= 1) {
              k = 1;
              state = 1;
              wait = 0;
              if (mode !== 1) {
                meteor = 0;
                meteorFrom.set(circ.position.x + 2, 7, circ.position.z - 2.5);
              }
              if (mode !== 0) breath();
            }
          } else {
            wait += dt;
            if (wait > 2.0) {
              state = 0;
              k = 0;
            }
          }
          const blink = state === 0 && k > 0.75 ? 0.75 + 0.25 * Math.sin(t * 40) : 1;
          const A = state === 0 ? clamp(k * 6, 0, 1) * blink : Math.max(0, 1 - wait * 4);
          for (const m of [cm, secM]) {
            m.uniforms.uFill!.value = easeOut(k) * 1.0;
            m.uniforms.uA!.value = A;
            m.uniforms.uTime!.value = t;
          }
          fx.orb.clear();
          if (meteor >= 0) {
            meteor += dt / 0.25;
            if (meteor >= 1) {
              meteor = -1;
              fx.boom(circ.position.x, 0.05, circ.position.z, { k: 0.8, sc: 0.95 });
            } else {
              pA.lerpVectors(meteorFrom, circ.position, meteor);
              fx.orb.spawn({ x: pA.x, y: pA.y, z: pA.z, life: 1, s0: 0.4, ramp: flat(0xfff0d0, 8, 1), shape: 0 });
              fx.orb.spawn({ x: pA.x, y: pA.y, z: pA.z, vx: -8, vy: -28, vz: 10, life: 1, s0: 0.45, ramp: flat(0xff9030, 3, 0.8), st: 0.04, shape: 2 });
            }
          }
          lab.set(state === 0 ? `범위 표시 차오르는 중 ${Math.round(k * 100)}%` : '쾅! 표시한 범위에', '#ff6040');
          fx.update(dt, t);
          S.tick(dt);
        },
        resize: (w, h) => S.resize(w, h),
        dispose: () => S.dispose(),
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [마법진 (돌아가는 룬 고리)](https://ai-techstudio.web.app/ai/t/i180.md) `i180`
- 다음에 해 볼 기술: [충격 고리 · 땅 파동](https://ai-techstudio.web.app/ai/t/i178.md) `i178`
