# AI 꾸러미 — 탄막 무늬 (극좌표 · 나선) — Bullet hell patterns (danmaku)
> 쏘는 각도를 한 발마다 조금씩 돌리거나 속도를 각도의 식으로 바꾸기만 해서, 나선 · 꽃 · 물결 탄막 무늬를 만든다 — 수식이 곧 무늬.  
> 견본: https://ai-techstudio.web.app/#t/i291

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

## 주문서

### 만들어 줘: 탄막 무늬 (극좌표 · 나선) — Bullet hell patterns (danmaku)

#### 1. 목표
슈팅 게임 보스에 극좌표 탄막 무늬를 만들어 줘 — 각도를 조금씩 돌리며 쏘는 나선 · 꽃 · 물결, 화면에 지금 식을 보여 주기. 분위기는 네온빛 밤하늘.

#### 2. 핵심 기술 용어
- **Bullet hell patterns (danmaku)** — 탄막 — 무늬로 쏟아지는 탄
- **Polar coordinates (r, θ)** — 극좌표 — 거리와 각도로 위치
- **Rose curve r = 1 + ½cos(kθ)** — 각도에 따라 속도를 바꿔 꽃잎 꼴
- **Additive blending (lighter)** — 겹친 탄이 더 밝게 빛나기

#### 3. 환경
- 플랫폼: HTML Canvas 2D (TypeScript), requestAnimationFrame 루프, 라이브러리 없이
- 화면: 2D · 브라우저 — PC 와 폰(390px 폭) 모두, devicePixelRatio 맞춰 또렷하게, 60fps 목표

#### 4. 조건
- 탄 그림은 색마다 화면 밖 캔버스에 한 번 구워 두고 drawImage 만 (탄마다 그라데이션 금지)
- 겹친 탄은 globalCompositeOperation = lighter 로 더 밝게
- 탄 수 상한(900)과 화면 밖으로 나간 탄 지우기
- 지금 무늬의 식을 화면에 함께 보여 주기 (α 값이 바뀌면 글도 바뀌게)

#### 5. 완성 기준 (이게 보이면 성공)
- 「다음 무늬」를 누를 때마다 나선 → 꽃 → 물결 → 맞도는 나선으로 바뀐다
- α 슬라이더를 1° → 60° 로 바꾸면 촘촘한 나선이 성긴 별 모양으로 바뀐다
- 화면 아래에 지금 식(θ = n × α 등)이 보이고, α 숫자도 따라 바뀐다
- 방향키로 직접 피할 수 있고, 손을 떼면 저절로 피한다

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

## 원리
- 탄 하나 = 각도 θ 방향으로 일정 속도로 곧게 나가는 점. 속도 = (cos θ, sin θ) × v.
- 나선: n 번째 발의 각 θ = n × α (α = 13°). 120° 씩 세 갈래를 함께 쏘면 세 팔 나선.
- 꽃: 한 번에 40발을 원으로 쏘되 속도 = v × (1 + ½ cos 5θ) — 빠른 쪽이 꽃잎 5장이 된다. 판마다 7° 씩 돌린다.
- 물결: θ = 90° + 55° × sin(2.2t) 로 아래쪽을 쓸고, 맞도는 나선은 +nα 와 −nα 두 줄을 겹친다.

## 핵심 코드 — 네 가지 탄막 식
(발췌: demos/demos2dMove.ts i291 emit() · shoot() 를 정리)
```ts
const D = Math.PI / 180, TAU = Math.PI * 2;
interface Bul { x: number; y: number; vx: number; vy: number; c: number }
const bs: Bul[] = [];
const boss = { x: 140, y: 46 };
let pat = 0, n = 0, em = 0, alpha = 13;          // alpha = 한 발마다 도는 각(도)

function shoot(a: number, sp: number, c: number) {
  if (bs.length > 900) return;                   // 탄 수 상한
  bs.push({ x: boss.x + Math.cos(a) * 8, y: boss.y + Math.sin(a) * 8, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, c });
}
function emit(h: number, t: number) {
  em -= h;
  if (em > 0) return;
  if (pat === 0) {                               // 나선: θ = n·α, 세 갈래
    for (let k = 0; k < 3; k++) shoot(n * alpha * D + (k * TAU) / 3, 62, k);
    em += 0.04;
  } else if (pat === 1) {                        // 꽃: 속도 = v(1 + ½cos5θ)
    const rot = n * 7 * D;
    for (let i = 0; i < 40; i++) {
      const a = (i / 40) * TAU + rot;
      shoot(a, 46 * (1 + 0.5 * Math.cos(5 * (a - rot))), 1);
    }
    em += 0.42;
  } else if (pat === 2) {                        // 물결: θ = 90° + 55°·sin(2.2t)
    const base = 90 * D + 55 * D * Math.sin(2.2 * t);
    for (const o of [-20, 0, 20]) shoot(base + o * D, 78, 2);
    em += 0.045;
  } else {                                       // 맞도는 나선: +nα, −nα
    const a = n * alpha * 0.9 * D;
    for (let k = 0; k < 4; k++) { shoot(a + (k * TAU) / 4, 58, 4); shoot(-a + (k * TAU) / 4 + 0.4, 58, 3); }
    em += 0.07;
  }
  n++;
}
```

## 흔한 실수 · 확인 목록
- [ ] **탄마다 그라데이션을 새로 만들면 수백 개에서 끊긴다** — 색마다 32px 빛 공을 한 번 구워 두고 drawImage(spr, x−5, y−5, 10, 10) 로 찍는다.
- [ ] **α 가 360° 의 약수 근처면 나선이 몇 줄 직선으로 보인다** — 13° 처럼 360 과 서로소인 값이 고른 나선. 직선이 되는 순간도 보여 주면 좋은 수학 이야기다.
- [ ] **쏘는 간격을 프레임마다로 하면 기기마다 무늬가 다르다** — 고정 간격 루프 안에서 em(남은 시간)을 빼며 쏜다 — 60fps · 120fps 에서 같은 무늬.

## 완성 기준 체크리스트
- [ ] 「다음 무늬」를 누를 때마다 나선 → 꽃 → 물결 → 맞도는 나선으로 바뀐다
- [ ] α 슬라이더를 1° → 60° 로 바꾸면 촘촘한 나선이 성긴 별 모양으로 바뀐다
- [ ] 화면 아래에 지금 식(θ = n × α 등)이 보이고, α 숫자도 따라 바뀐다
- [ ] 방향키로 직접 피할 수 있고, 손을 떼면 저절로 피한다

## 이 기술 정보
- id: `i291` · 분류: 2D · 화면 › 2D 움직임 · 충돌 · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 탄 최대 900개, 탄마다 미리 구운 32px 빛 그림을 drawImage 한 번. 폰도 무리 없다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i291
- 쓰면 좋을 때: 보스 · 함정이 「규칙 있는 무늬」로 공격할 때 / 극좌표 · 삼각함수를 눈으로 보여 줄 때
- 쓰지 말 때: 탄이 수천 개 넘는 장면 — 캔버스 drawImage 한계. 대신 WebGL 인스턴싱 / 어린 아이용 판 — 피하기가 어렵다. 탄 수 · 속도를 줄인다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i291 — `src/demos/demos2dMove.ts:3534`
```ts
const i291: Demo2D = {
  kind: '2d',
  caption: '각도를 조금씩 돌려 가며 쏘기만 했는데 — 나선 · 꽃 · 물결이 된다 (수식 = 무늬)',
  make() {
    const inp = new Input(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']);
    const COLS = ['#ff5fa2', '#ffd23f', '#4be3ff', '#9b7bff', '#5cff9a'];
    const spr = COLS.map((c) => {
      const cv = mkCanvas(32, 32);
      const g = cv.getContext('2d')!;
      const gr = g.createRadialGradient(16, 16, 0, 16, 16, 16);
      gr.addColorStop(0, '#ffffff');
      gr.addColorStop(0.28, '#ffffff');
      gr.addColorStop(0.42, c);
      gr.addColorStop(0.7, c + '55');
      gr.addColorStop(1, c + '00');
      g.fillStyle = gr;
      g.fillRect(0, 0, 32, 32);
      return cv;
    });
    let pat = 0;
    let patT = 0;
    let alpha = 13;
    let speedK = 1;
    let showForm = true;
    let debug = false;
    interface Bul {
      x: number;
      y: number;
      vx: number;
      vy: number;
      c: number;
      ax: number;
    }
    const bs: Bul[] = [];
    let n = 0;
    let em = 0;
    let lastAng = 0;
    const boss = { x: 140, y: 46 };
    const pl = { x: 140, y: 152, hit: 0, graze: 0 };
    const sparks: Part[] = [];
    const NAMES = ['나선', '꽃', '물결', '맞도는 나선'];
    const shoot = (a: number, sp: number, c: number, ax = 0): void => {
      if (bs.length > 900) return;
      bs.push({ x: boss.x + Math.cos(a) * 8, y: boss.y + Math.sin(a) * 8, vx: Math.cos(a) * sp * speedK, vy: Math.sin(a) * sp * speedK, c, ax });
      lastAng = a;
    };
    const D = Math.PI / 180;
    const emit = (h: number, t: number): void => {
      em -= h;
      if (em > 0) return;
      if (pat === 0) {
        for (let k = 0; k < 3; k++) shoot(n * alpha * D + (k * TAU) / 3, 62, k % 2 ? 0 : 3);
        n++;
        em += 0.04;
      } else if (pat === 1) {
        const rot = n * 7 * D;
        for (let i = 0; i < 40; i++) {
          const a = (i / 40) * TAU + rot;
          shoot(a, 46 * (1 + 0.5 * Math.cos(5 * (a - rot))), n % 2 ? 0 : 1);
        }
        n++;
        em += 0.42;
      } else if (pat === 2) {
        const base = 90 * D + 55 * D * Math.sin(2.2 * t);
        for (const o of [-20, 0, 20]) shoot(base + o * D, 78, 2);
        n++;
        em += 0.045;
      } else {
        const a = n * alpha * 0.9 * D;
        for (let k = 0; k < 4; k++) {
          shoot(a + (k * TAU) / 4, 58, 4);
          shoot(-a + (k * TAU) / 4 + 0.4, 58, 3);
        }
        n++;
        em += 0.07;
      }
    };
    const FORM = ['θ = n × α  (세 갈래: +120°)', '속도 = v · (1 + ½ cos 5θ)', 'θ = 90° + 55° · sin(2.2t)', 'θ₁ = +n·α ,  θ₂ = −n·α'];
    const next = (): void => {
      pat = (pat + 1) % 4;
      patT = 0;
      n = 0;
      em = 0.3;
    };
    let time = 0;
    const step = fixed(1 / 120);
    return {
      controls: [
        { type: 'button', label: '다음 무늬', on: () => next() },
        { type: 'range', label: '한 발마다 도는 각 α (°)', min: 1, max: 60, step: 0.5, value: 13, on: (v) => (alpha = v) },
        { type: 'range', label: '탄 빠르기', min: 0.4, max: 2, step: 0.05, value: 1, on: (v) => (speedK = v) },
        { type: 'toggle', label: '수식 보기', value: true, on: (v) => (showForm = v) },
        { type: 'toggle', label: '맞는 원 보기', value: false, on: (v) => (debug = v) },
      ] as Control[],
      draw(g, w, h, _t, dt) {
        const v = viewOf(w, h);
        inp.sync(g, v);
        const manual = v.big && inp.idle() < 5;
        step(dt, (H) => {
          time += H;
          patT += H;
          if (patT > 5.5 && !v.big) next();
          else if (patT > 8) next();
          boss.x = 140 + Math.sin(time * 0.6) * 18;
          boss.y = 46 + Math.sin(time * 1.3) * 4;
          if (patT > 0.3) emit(H, time);
          for (let i = bs.length - 1; i >= 0; i--) {
            const b = bs[i]!;
            b.x += b.vx * H;
            b.y += b.vy * H;
            if (b.x < -10 || b.x > 290 || b.y < -10 || b.y > 185) bs.splice(i, 1);
          }
          // 플레이어
          if (manual) {
            const sp = 85;
            pl.x += ((inp.key('ArrowRight') ? 1 : 0) - (inp.key('ArrowLeft') ? 1 : 0)) * sp * H;
            pl.y += ((inp.key('ArrowDown') ? 1 : 0) - (inp.key('ArrowUp') ? 1 : 0)) * sp * H;
          } else {
            let best = pl.x;
            let by = pl.y;
            let bestS = Infinity;
            for (let dx = -12; dx <= 12; dx += 4)
              for (let dy = -8; dy <= 8; dy += 8) {
                const cx = pl.x + dx;
                const cy = pl.y + dy;
                let s = Math.abs(cx - 140) * 0.002 + Math.abs(cy - 150) * 0.004;
                for (const b of bs) {
                  const fx = b.x + b.vx * 0.15;
                  const fy = b.y + b.vy * 0.15;
                  const d2 = (fx - cx) ** 2 + (fy - cy) ** 2;
                  if (d2 < 900) s += 1 / (d2 + 6);
                  const d3 = (b.x - cx) ** 2 + (b.y - cy) ** 2;
                  if (d3 < 400) s += 1 / (d3 + 4);
                }
                if (s < bestS) {
                  bestS = s;
                  best = cx;
                  by = cy;
                }
              }
            pl.x += clamp(best - pl.x, -90 * H, 90 * H);
            pl.y += clamp(by - pl.y, -60 * H, 60 * H);
          }
          pl.x = clamp(pl.x, 10, 270);
          pl.y = clamp(pl.y, 100, 168);
          pl.hit -= H;
          for (const b of bs) {
            const d = Math.hypot(b.x - pl.x, b.y - pl.y);
            if (d < 3.6 && pl.hit < 0) {
              pl.hit = 0.6;
              for (let i = 0; i < 10; i++) {
                const a = Math.random() * TAU;
                sparks.push({ x: pl.x, y: pl.y, vx: Math.cos(a) * 60, vy: Math.sin(a) * 60, life: 0.4, max: 0.4, size: 1.2, color: '#fff', grav: 0 });
              }
            } else if (d < 9 && Math.random() < 0.05) {
              pl.graze++;
              sparks.push({ x: (b.x + pl.x) / 2, y: (b.y + pl.y) / 2, vx: (Math.random() - 0.5) * 30, vy: -20, life: 0.25, max: 0.25, size: 0.8, color: '#fff6a8', grav: 0 });
            }
          }
          stepParts(sparks, H);
        });

        fillBg(g, w, h, '#0b0820', '#1c1240');
        enter(g, v);
        // 극좌표 눈금
        g.strokeStyle = 'rgba(160,140,255,.12)';
        g.lineWidth = 0.6;
        for (let r = 20; r < 200; r += 20) {
          g.beginPath();
          g.arc(boss.x, boss.y, r, 0, TAU);
          g.stroke();
        }
        for (let k = 0; k < 12; k++) {
          const a = (k / 12) * TAU;
          g.beginPath();
          g.moveTo(boss.x, boss.y);
          g.lineTo(boss.x + Math.cos(a) * 300, boss.y + Math.sin(a) * 300);
          g.stroke();
        }
        // 지금 쏘는 각
        g.strokeStyle = 'rgba(255,255,255,.45)';
        g.setLineDash([2, 2]);
        g.beginPath();
        g.moveTo(boss.x, boss.y);
        g.lineTo(boss.x + Math.cos(lastAng) * 28, boss.y + Math.sin(lastAng) * 28);
        g.stroke();
        g.setLineDash([]);
        // 탄
        g.globalCompositeOperation = 'lighter';
        for (const b of bs) g.drawImage(spr[b.c]!, b.x - 5, b.y - 5, 10, 10);
        g.globalCompositeOperation = 'source-over';
        // 보스 (구름 유령)
        g.save();
        g.translate(boss.x, boss.y);
        const bgr = g.createRadialGradient(-4, -5, 1, 0, 0, 15);
        bgr.addColorStop(0, '#fff');
        bgr.addColorStop(1, '#c9b8ff');
        g.fillStyle = bgr;
        g.beginPath();
        g.arc(0, -2, 11, Math.PI, 0);
        for (let k = 0; k <= 4; k++) g.lineTo(11 - k * 5.5, 7 + (k % 2 ? -3 : 0) + Math.sin(time * 6 + k) * 0.8);
        g.closePath();
        g.fill();
        g.strokeStyle = '#6a52c8';
        g.lineWidth = 0.9;
        g.stroke();
        for (const s of [-1, 1]) {
          g.fillStyle = '#2a1850';
          g.beginPath();
          g.ellipse(s * 4, -3, 1.7, 2.4, 0, 0, TAU);
          g.fill();
        }
        g.fillStyle = 'rgba(255,120,170,.6)';
        g.beginPath();
        g.ellipse(-7, 1, 2, 1.1, 0, 0, TAU);
        g.ellipse(7, 1, 2, 1.1, 0, 0, TAU);
        g.fill();
        g.restore();
        // 플레이어
        g.save();
        g.translate(pl.x, pl.y);
        if (pl.hit > 0 && Math.floor(pl.hit * 20) % 2) g.globalAlpha = 0.35;
        g.fillStyle = '#7ef0ff';
        g.beginPath();
        g.moveTo(0, -7);
        g.lineTo(5, 5);
        g.lineTo(0, 3);
        g.lineTo(-5, 5);
        g.closePath();
        g.fill();
        g.strokeStyle = '#1a6c8a';
        g.lineWidth = 0.7;
        g.stroke();
        g.fillStyle = '#fff';
        g.beginPath();
        g.arc(0, 0, 1.4, 0, TAU);
        g.fill();
        g.globalAlpha = 1;
        g.restore();
        drawParts(g, sparks, true);
        if (debug) {
          g.strokeStyle = 'rgba(255,80,120,.8)';
          g.lineWidth = 0.5;
          for (const b of bs) {
            g.beginPath();
            g.arc(b.x, b.y, 2.2, 0, TAU);
            g.stroke();
          }
          g.strokeStyle = '#5cff9a';
          g.beginPath();
          g.arc(pl.x, pl.y, 1.4, 0, TAU);
          g.stroke();
          g.strokeStyle = 'rgba(255,240,140,.4)';
          g.beginPath();
          g.arc(pl.x, pl.y, 9, 0, TAU);
          g.stroke();
        }
        pill(g, NAMES[pat]!, 8, 10, 7.5, COLS[pat === 3 ? 4 : pat]! + 'cc', '#14102a', 'left');
        txt(g, `탄 ${bs.length}`, 272, 10, 6.5, 'rgba(255,255,255,.6)', 'right', 700);
        if (showForm) {
          g.font = `800 7.5px ${F}`;
          const s = FORM[pat]!.replace('α', `α(${alpha}°)`);
          const tw = g.measureText(s).width;
          rr(g, 140 - tw / 2 - 7, 157, tw + 14, 13, 6.5);
          g.fillStyle = 'rgba(10,6,30,.78)';
          g.fill();
          g.strokeStyle = 'rgba(200,180,255,.35)';
          g.lineWidth = 0.6;
          g.stroke();
          txt(g, s, 140, 163.8, 7.5, '#e6dcff', 'center', 800);
        }
        if (v.big) txt(g, manual ? '방향키로 피하는 중' : '방향키로 직접 피해 보기', 272, 20, 6.5, 'rgba(255,255,255,.55)', 'right', 600);
        g.restore();
      },
      dispose() {
        inp.dispose();
      },
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [궤도 · 중력 우물](https://ai-techstudio.web.app/ai/t/i290.md) `i290`
- 다음에 해 볼 기술: [이징 곡선 모음 (트윈 비교)](https://ai-techstudio.web.app/ai/t/i292.md) `i292`
