# AI 꾸러미 — 마법진 (돌아가는 룬 고리) — Magic circle (rotating rune rings)
> 룬 무늬 판 세 겹을 바닥에 눕혀 각도로 그려지게 하고 반대로 돌리다가, 차오르면 흐르는 무늬 빛기둥이 솟게 해서 시전 준비를 보여 준다.  
> 견본: https://ai-techstudio.web.app/#t/i180

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

## 주문서

### 만들어 줘: 마법진 (돌아가는 룬 고리) — Magic circle (rotating rune rings)

#### 1. 목표
소환 · 레벨 업 순간에 바닥 마법진을 만들어 줘 — 세 겹 룬 고리가 시계 방향으로 그려지며 서로 반대로 돌고, 차오르면 빛기둥이 솟게. 분위기는 금빛 · 분홍 룬.

#### 2. 핵심 기술 용어
- **Magic circle (rotating rune rings)** — 마법진 — 겹겹이 반대로 도는 룬 고리
- **Angular reveal (polar wipe)** — 각도로 시계 방향 그려지기
- **CanvasTexture rune pattern** — 캔버스로 그린 룬 · 기하 무늬
- **Charge → burst timeline** — 그리기 → 차오름 → 발동 → 사라짐 시간표

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

#### 4. 조건
- 룬 무늬는 그림 파일 대신 캔버스로 그린다 (번역 · 색 바꾸기 쉽게)
- 그려지기는 셰이더의 각도 비교(step(ang, uRev))로 — 텍스처를 다시 그리지 않는다
- 세 겹은 서로 다른 빠르기 · 반대 방향으로 돈다
- 그리기 → 차오름 → 발동 → 사라짐 시간표가 분명하게, 점광 세기도 함께
- 회전 속도 · 겹 수(1~3) · 빛기둥 켬/끔 조절

#### 5. 완성 기준 (이게 보이면 성공)
- 바닥에 세 겹 룬 고리가 시계 방향으로 그려지고, 그려지는 끝이 반짝인다
- 다 그려지면 겹마다 반대로 돌며 밝아지고, 금빛 알갱이가 떠오른다
- 발동 순간 금빛 빛기둥이 솟았다가 마법진과 함께 사라진다
- 「겹 수」 1 로 두면 바깥 고리 하나만 남는다

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

## 원리
- 겹마다 캔버스로 그린 룬 텍스처(원 · 다각형 · 글자) 한 장을 판에 붙여 바닥에 눕힌다. 크기 4.4 · 3.0 · 1.6.
- 셰이더가 판 가운데 기준 각도 ang(0~1)을 구해, ang < uRev 인 곳만 보인다 → uRev 를 0 → 1 로 올리면 시계 방향으로 그려진다. 그려지는 끝은 e^(−((uRev − ang)·30)²) 로 5배 밝게.
- 세 겹을 0.25 · −0.45 · 0.8 배 빠르기로 서로 반대로 돌리고, 밝기는 0.85 + 0.15·sin(5t − 14r) 로 안에서 밖으로 맥박친다.
- 5초 주기: 0 ~ 1.3초 그리기 → 1.3 ~ 2.5초 차오름(밝기 · 점광 · 금빛 알갱이 늘어남) → 2.5 ~ 4.3초 발동(흐르는 무늬 원통 빛기둥) → 4.1 ~ 4.9초 사라짐.

## 핵심 코드 — 각도로 그려지는 룬 고리 재질 + 시간표
(발췌: demos/demosSkillA.ts runeMat() · i180 update() 를 정리)
```ts
const runeMat = (tex: THREE.Texture, hex: number, I: number) => new THREE.ShaderMaterial({
  uniforms: { uTex: { value: tex }, uCol: { value: new THREE.Color(hex).multiplyScalar(I) }, uA: { value: 1 }, uRev: { value: 1 }, uTime: { value: 0 } },
  vertexShader: 'varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }',
  fragmentShader:
    'uniform sampler2D uTex; uniform vec3 uCol; uniform float uA, uRev, uTime; varying vec2 vUv;' +
    'void main(){ vec2 p = vUv*2.0-1.0; float r = length(p);' +
    '  float ang = atan(p.x, p.y) / 6.2831853 + 0.5;' +                       // 0 ~ 1 각도
    '  float vis = step(ang, uRev);' +                                        // 그려진 곳만
    '  float edge = exp(-pow((uRev - ang) * 30.0, 2.0)) * step(ang, uRev) * step(uRev, 0.999);' +
    '  float pulse = 0.85 + 0.15 * sin(uTime * 5.0 - r * 14.0);' +
    '  vec3 col = uCol * pulse * (1.0 + edge * 5.0);' +
    '  gl_FragColor = vec4(col, clamp(texture2D(uTex, vUv).r * vis * uA, 0.0, 1.0)); }',
  transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
});

// 시간표 (5초 주기). layers = 바닥에 눕힌 판 세 장 (크기 4.4 · 3.0 · 1.6)
function update(t: number, rot: number): void {
  const k = t % 5;
  const reveal = Math.min(1, k / 1.3);
  const fade = 1 - Math.min(1, Math.max(0, (k - 4.1) / 0.8));
  const charge = Math.min(1, Math.max(0, (k - 1.3) / 1.2));
  const burst = k > 2.5 && k < 4.3 ? Math.sin(((k - 2.5) / 1.8) * Math.PI) : 0;
  layers.forEach((m, i) => {
    const u = m.material.uniforms;
    u.uRev.value = Math.min(1, Math.max(0, reveal * 1.15 - i * 0.08)); // 겹마다 살짝 늦게
    u.uA.value = fade * (0.75 + 0.5 * charge + 0.6 * burst);
    u.uTime.value = t;
    m.rotation.z = [0.25, -0.45, 0.8][i] * rot;                         // 반대로 돌기
  });
  light.intensity = fade * (3 + charge * 10 + burst * 26);
  beamMat.uniforms.uA.value = burst;                                    // 흐르는 무늬 원통 (i172)
}
```

## 흔한 실수 · 확인 목록
- [ ] **그려지기를 텍스처를 다시 그려서 하면 매 프레임 업로드로 버벅인다** — 텍스처는 한 번만 그리고, 보일 각도만 uniform(uRev)으로 바꾼다.
- [ ] **세 겹이 같은 방향 · 같은 빠르기로 돌면 한 장처럼 보인다** — 빠르기와 방향을 겹마다 다르게(0.25 · −0.45 · 0.8).
- [ ] **더하기 판 세 장이 겹친 곳이 하얗게 날아간다** — 겹마다 색을 다르게(금 · 분홍) 하고 세기를 2.2 ~ 2.6 정도로, 차오를 때만 밝힌다.

## 완성 기준 체크리스트
- [ ] 바닥에 세 겹 룬 고리가 시계 방향으로 그려지고, 그려지는 끝이 반짝인다
- [ ] 다 그려지면 겹마다 반대로 돌며 밝아지고, 금빛 알갱이가 떠오른다
- [ ] 발동 순간 금빛 빛기둥이 솟았다가 마법진과 함께 사라진다
- [ ] 「겹 수」 1 로 두면 바깥 고리 하나만 남는다

## 이 기술 정보
- id: `i180` · 분류: 이펙트 · 연출 › 스킬 VFX (마법 · 미사일) · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 판 3장 + 원통 2개 + 금빛 알갱이 최대 900개. 텍스처는 처음 한 번 그린다. 폰에서도 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i180
- 쓰면 좋을 때: 소환 · 레벨 업 · 큰 기술 직전 「준비 중」을 보여 줄 때 / 원 · 정다각형을 겹쳐 그리는 수학 무늬를 멋지게 보여 줄 때
- 쓰지 말 때: 짧은 일반 공격 — 1초 넘는 준비가 답답하다 / 위에서 내려다보지 않는 구도(옆에서 보기) — 바닥 무늬가 납작해 안 보인다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i180 견본 항목 — `src/demos/demosSkillA.ts:2430`
```ts
  i180: {
    kind: '3d',
    caption: '바닥에 세 겹 룬 고리가 그려지며 반대로 돌고, 차오르면 빛기둥이 솟는다 (수학 기호 룬)',
    make(): Scene3D {
      const S = makeStage({ cam: [0, 6.0, 6.4], look: [0, 0.6, 0] });
      const P = new Pool(900, S.puff);
      P.mesh.renderOrder = 3;
      S.scene.add(P.mesh);
      const lab = S.hud.label('tl');
      const GOLD = 0xffc860;
      const layers = ([0, 1, 2] as const).map((l, i) => {
        const m = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), runeMat(l, i === 1 ? 0xffa0ff : GOLD, i === 1 ? 2.2 : 2.6));
        m.rotation.x = -Math.PI / 2;
        m.position.y = 0.02 + i * 0.004;
        m.scale.setScalar([4.4, 3.0, 1.6][i]!);
        S.scene.add(m);
        return m;
      });
      const gd = glowDisc(GOLD, 1.2, 6);
      S.scene.add(gd.mesh);
      const pillarM = scrollMat(0xfff0c0, 0xffa020, { per: 6, sy: 2.5, twist: 1.2, bright: 1.3, speed: 1.6 });
      const beam = new THREE.Mesh(new THREE.CylinderGeometry(1.2, 1.4, 6, 48, 1, true), pillarM);
      beam.position.y = 3;
      const beamCore = new THREE.Mesh(new THREE.CylinderGeometry(0.45, 0.6, 6, 32, 1, true), scrollMat(0xffffff, 0xffd080, { per: 4, sy: 3, twist: 2, bright: 1.6, speed: 2.4 }));
      beamCore.position.y = 3;
      S.scene.add(beam, beamCore);
      const pl = new THREE.PointLight(GOLD, 0, 9, 1.4);
      pl.position.set(0, 0.8, 0);
      S.scene.add(pl);
      let spd = 1;
      let nLayers = 3;
      let pillarOn = true;
      let rot = 0;
      const controls: Control[] = [
        ctrlRange('회전 속도', 0, 3, 0.1, 1, (v) => (spd = v)),
        ctrlRange('겹 수', 1, 3, 1, 3, (v) => (nLayers = v)),
        ctrlTog('빛기둥', true, (v) => (pillarOn = v)),
      ];
      let acc = 0;
      return {
        scene: S.scene,
        camera: S.cam,
        controls,
        update(t, dt0) {
          const dt = Math.min(dt0, 0.05);
          rot += dt * spd;
          const C = 5;
          const k = t % C;
          const reveal = clamp(k / 1.3, 0, 1);
          const fade = 1 - clamp((k - 4.1) / 0.8, 0, 1);
          const charge = clamp((k - 1.3) / 1.2, 0, 1);
          const burst = k > 2.5 && k < 4.3 ? Math.sin(clamp((k - 2.5) / 1.8, 0, 1) * Math.PI) : 0;
          layers.forEach((m, i) => {
            const u = (m.material as THREE.ShaderMaterial).uniforms;
            u.uRev!.value = clamp(reveal * 1.15 - i * 0.08, 0, 1);
            u.uA!.value = fade * (0.75 + 0.5 * charge + 0.6 * burst);
            u.uTime!.value = t;
            m.visible = i < nLayers;
            m.rotation.z = [0.25, -0.45, 0.8][i]! * rot;
          });
          gd.set(0.4 + charge * 1.2 + burst * 1.5);
          gd.mesh.visible = fade > 0.01;
          pillarM.uniforms.uTime!.value = t;
          (beamCore.material as THREE.ShaderMaterial).uniforms.uTime!.value = t;
          const bA = pillarOn ? burst : 0;
          pillarM.uniforms.uA!.value = bA;
          (beamCore.material as THREE.ShaderMaterial).uniforms.uA!.value = bA;
          beam.visible = beamCore.visible = bA > 0.01;
          beam.scale.set(0.6 + bA * 0.4, 1, 0.6 + bA * 0.4);
          pl.intensity = fade * (3 + charge * 10 + burst * 26);
          // 그려지는 끝의 불꽃 + 차오를 때 떠오르는 빛 알갱이
          if (reveal < 1) {
            const a = reveal * TAU;
            for (let j = 0; j < 3; j++) {
              const rr = [2.1, 1.4, 0.75][j]!;
              P.spawn({ x: Math.sin(a + rot * 0.25) * rr, y: 0.05, z: -Math.cos(a + rot * 0.25) * rr * -1, vy: R(0.3, 0.9), life: R(0.4, 0.7), s0: 0.12, s1: 0.03, ramp: RAMP.gold, shape: 2 });
            }
          }
          acc += dt * (charge * 60 + burst * 100) * fade;
          while (acc >= 1) {
            acc--;
            const a = Math.random() * TAU;
            const r = Math.sqrt(Math.random()) * 2.1;
            P.spawn({ x: Math.cos(a) * r, y: 0.05, z: Math.sin(a) * r, vy: R(0.8, 2.6) * (1 + burst), life: R(0.9, 1.6), s0: 0.06, s1: 0.03, ramp: RAMP.gold, st: burst > 0.2 ? 0.05 : 0, shape: Math.random() < 0.25 ? 4 : 2, curl: 0.3 });
          }
          lab.set(k < 1.3 ? '마법진 그리기' : k < 2.5 ? '룬 고리가 반대로 돌며 차오름' : k < 4.2 ? (pillarOn ? '빛기둥!' : '발동') : '사라짐', '#ffc860');
          P.step(dt);
          P.upload();
          S.tick(dt);
        },
        resize: (w, h) => S.resize(w, h),
        dispose: () => S.dispose(),
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [흐르는 무늬 메시 (UV 스크롤)](https://ai-techstudio.web.app/ai/t/i172.md) `i172`
- 다음에 해 볼 기술: [광역 표시 (바닥 범위 원)](https://ai-techstudio.web.app/ai/t/i181.md) `i181`
