# AI 꾸러미 — 캔버스 입자 엔진 (2D) — Particle system (emitter · lifetime)
> 방출기마다 수명 · 힘 · 크기 · 색이 다른 입자를 쏘고 미리 구운 점 그림으로 그려, 불 · 연기 · 반짝이 · 꽃잎을 캔버스 2D 하나로 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i302

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

## 주문서

### 만들어 줘: 캔버스 입자 엔진 (2D) — Particle system (emitter · lifetime)

#### 1. 목표
모닥불 · 연기 · 요술봉 반짝이 · 꽃잎을 캔버스 2D 입자로 만들어 줘 — 방출기마다 수명 · 힘 · 크기 · 색을 정하고, 불 · 반짝이는 더하기 혼합, 연기 · 꽃잎은 보통 혼합. 분위기는 따뜻한 밤.

#### 2. 핵심 기술 용어
- **Particle system (emitter · lifetime)** — 입자 체계 — 방출기 · 수명
- **Emission rate accumulator** — 초당 방출 수를 쌓아 두었다 정수만큼 쏘기
- **Additive blending ('lighter')** — 불 · 반짝이는 더하기 혼합
- **Swap-and-pop removal** — 죽은 입자를 맨 끝 것과 바꿔 빼기

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

#### 4. 조건
- 방출은 「초당 개수 × 시간」을 쌓아서 — 프레임 빠르기와 상관없이 같은 양
- 입자 그림은 미리 구워 drawImage (매번 그러데이션 · shadowBlur 금지)
- 전체 입자 수 상한 (예: 900)
- 죽은 입자 빼기는 swap-and-pop (splice 금지)
- 프레임 시간을 잘라 느리게 만들지 말고, 긴 프레임은 고정 조각으로 나눠 돌리기

#### 5. 완성 기준 (이게 보이면 성공)
- 모닥불 · 냄비 연기 · 요술봉 반짝이 · 나뭇가지 꽃잎이 동시에 돈다
- 「더하기 혼합」을 끄면 불 · 반짝이가 겹친 곳이 덜 빛난다
- 방출량 0.2 ~ 2.5 슬라이더로 입자 수가 바뀌고, 현재 입자 수가 보인다
- 폰에서도 60fps

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

## 원리
- 방출기마다 초당 방출 수(불 70 · 연기 14 · 반짝이 40 · 꽃잎 6)를 시간만큼 누적해 1 넘을 때마다 하나씩 쏜다. 전체는 900개까지.
- 입자 = 자리 · 속도 · 나이 · 수명 · 크기 · 돌기. 매 프레임 종류마다 다른 힘: 불은 가운데로 끌리며 위로, 연기는 옆바람에 흔들리며 느려짐, 반짝이는 감속 + 약한 중력, 꽃잎은 좌우로 살랑.
- 그림은 방사 그러데이션 점 · 별 · 꽃잎을 미리 구워 drawImage — 불은 나이에 따라 흰 → 노랑 → 주황 → 빨강 4장 중 고른다.
- 그리는 순서: 연기 · 꽃잎(보통 혼합) 먼저, 불 · 반짝이는 'lighter' 로 나중에. 죽은 입자는 맨 끝 것과 바꿔 pop.

## 핵심 코드 — 방출 누적 · 종류별 힘 · swap-and-pop · 혼합 나눠 그리기
(발췌: demos/demos2dLook.ts mkI302() 를 정리)
```ts
interface P { k: number; x: number; y: number; vx: number; vy: number; life: number; max: number; size: number }
const ps: P[] = [];
const RATES = [70, 14, 40, 6]; // 불 · 연기 · 반짝이 · 꽃잎 (초당)
const acc = [0, 0, 0, 0];
const R = Math.random;
function emit(k: number) {
  if (k === 0) ps.push({ k, x: 40 + (R() - 0.5) * 16, y: 132, vx: (R() - 0.5) * 8, vy: -38 - R() * 30, life: 0, max: 0.6 + R() * 0.5, size: 9 + R() * 6 });
  else if (k === 1) ps.push({ k, x: 108 + (R() - 0.5) * 6, y: 112, vx: (R() - 0.5) * 6, vy: -16 - R() * 10, life: 0, max: 2.2 + R(), size: 5 });
  // 반짝이 · 꽃잎도 같은 식
}
function update(dt: number, t: number, rate = 1) {
  for (let k = 0; k < 4; k++) {
    acc[k]! += RATES[k]! * rate * dt;
    while (acc[k]! >= 1 && ps.length < 900) { acc[k]! -= 1; emit(k); }
  }
  for (let i = ps.length - 1; i >= 0; i--) {
    const p = ps[i]!;
    p.life += dt;
    if (p.life >= p.max) { ps[i] = ps[ps.length - 1]!; ps.pop(); continue; } // swap-and-pop
    if (p.k === 0) { p.vx += (40 - p.x) * 1.5 * dt; p.vy -= 10 * dt; }                 // 불: 가운데로 · 위로
    else if (p.k === 1) { p.vx += Math.sin(t * 0.8 + p.y * 0.05) * 6 * dt + 4 * dt; p.vy *= 1 - 0.3 * dt; } // 연기: 바람
    p.x += p.vx * dt;
    p.y += p.vy * dt;
  }
}
function draw(g: CanvasRenderingContext2D, add = true) {
  g.globalCompositeOperation = 'source-over'; // 연기 먼저
  for (const p of ps) if (p.k === 1) {
    const k = p.life / p.max, s = p.size + k * 22;
    g.globalAlpha = 0.45 * Math.sin(Math.min(1, k * 4) * Math.PI * 0.5) * (1 - k);
    g.drawImage(SMOKE, p.x - s, p.y - s, s * 2, s * 2);
  }
  g.globalCompositeOperation = add ? 'lighter' : 'source-over'; // 불은 더하기
  for (const p of ps) if (p.k === 0) {
    const k = p.life / p.max, s = p.size * (1 - k * 0.7);
    g.globalAlpha = (1 - k) * 0.9;
    g.drawImage(FIRE[Math.min(3, Math.floor(k * 4))]!, p.x - s, p.y - s, s * 2, s * 2); // 흰 → 노랑 → 주황 → 빨강
  }
  g.globalAlpha = 1;
  g.globalCompositeOperation = 'source-over';
}
```

## 흔한 실수 · 확인 목록
- [ ] **프레임마다 정해진 개수를 쏘면 화면 빠르기에 따라 양이 달라진다** — 초당 개수 × dt 를 쌓아 두었다가 1 넘을 때마다 쏜다.
- [ ] **죽은 입자를 splice 로 빼면 입자가 많을 때 느려진다** — 맨 끝 입자와 바꿔 pop 한다 (뒤에서부터 돌면서).
- [ ] **프레임 시간을 Math.min(dt, 0.05) 로 자르면 느린 폰에서 연출이 슬로모션이 된다** — 실제 시간을 쓰고 긴 프레임은 고정 조각으로 나눠 돌린다 — 사이트 게임 34개를 이렇게 고쳤다.
- [ ] **연기를 더하기 혼합으로 그리면 하얗게 떠서 연기 같지 않다** — 연기 · 꽃잎은 보통 혼합으로 먼저, 불 · 반짝이만 lighter.

## 완성 기준 체크리스트
- [ ] 모닥불 · 냄비 연기 · 요술봉 반짝이 · 나뭇가지 꽃잎이 동시에 돈다
- [ ] 「더하기 혼합」을 끄면 불 · 반짝이가 겹친 곳이 덜 빛난다
- [ ] 방출량 0.2 ~ 2.5 슬라이더로 입자 수가 바뀌고, 현재 입자 수가 보인다
- [ ] 폰에서도 60fps

## 이 기술 정보
- id: `i302` · 분류: 2D · 화면 › 2D 그림 효과 · 2D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 입자 하나 = drawImage 한 번. 견본 상한 900개는 PC 는 가볍고, 폰에선 300~500개 안쪽이 좋다. shadowBlur 는 쓰지 않는다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i302
- 쓰면 좋을 때: 2D 게임의 불 · 연기 · 반짝 · 날씨 연출 / 라이브러리 없이 가벼운 효과가 필요할 때
- 쓰지 말 때: 입자 수천 개를 넘게 — 캔버스 2D drawImage 가 버겁다. 대신 WebGL(three.js Points · 인스턴싱) / 물리 충돌이 필요한 입자 — 대신 물리 엔진

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### mkI302 — `src/demos/demos2dLook.ts:1768`
```ts
function mkI302() {
  const dot = (col: V3, R = 32): HTMLCanvasElement => {
    const c = mkCanvas(R * 2, R * 2);
    const x = c2(c);
    const gr = x.createRadialGradient(R, R, 0, R, R, R);
    gr.addColorStop(0, rgba(col, 1));
    gr.addColorStop(0.4, rgba(col, 0.55));
    gr.addColorStop(1, rgba(col, 0));
    x.fillStyle = gr;
    x.fillRect(0, 0, R * 2, R * 2);
    return c;
  };
  const FIRE = [dot([255, 248, 200]), dot([255, 190, 60]), dot([255, 100, 30]), dot([200, 40, 30])];
  const SMOKE = dot([150, 150, 170]);
  const starC = (col: V3): HTMLCanvasElement => {
    const c = mkCanvas(48, 48);
    const x = c2(c);
    const gr = x.createRadialGradient(24, 24, 0, 24, 24, 24);
    gr.addColorStop(0, rgba(col, 0.6));
    gr.addColorStop(1, rgba(col, 0));
    x.fillStyle = gr;
    x.fillRect(0, 0, 48, 48);
    x.fillStyle = '#fff';
    x.beginPath();
    x.moveTo(24, 4);
    x.quadraticCurveTo(24, 24, 44, 24);
    x.quadraticCurveTo(24, 24, 24, 44);
    x.quadraticCurveTo(24, 24, 4, 24);
    x.quadraticCurveTo(24, 24, 24, 4);
    x.fill();
    return c;
  };
  const STARS = [starC([255, 230, 90]), starC([110, 230, 255]), starC([255, 120, 220])];
  const petal = mkCanvas(24, 24);
  {
    const x = c2(petal);
    x.translate(12, 12);
    x.beginPath();
    x.moveTo(0, -10);
    x.bezierCurveTo(8, -6, 7, 6, 0, 10);
    x.bezierCurveTo(-7, 6, -8, -6, 0, -10);
    const gr = x.createLinearGradient(0, -10, 0, 10);
    gr.addColorStop(0, '#ffe3ef');
    gr.addColorStop(1, '#ff8fbd');
    x.fillStyle = gr;
    x.fill();
    x.fillStyle = 'rgba(255,255,255,.6)';
    x.beginPath();
    x.ellipse(-2, -3, 1.5, 4, 0.2, 0, TAU);
    x.fill();
  }
  interface P {
    k: number;
    x: number;
    y: number;
    vx: number;
    vy: number;
    life: number;
    max: number;
    size: number;
    rot: number;
    vr: number;
    c: number;
  }
  const ps: P[] = [];
  let rate = 1;
  let add = true;
  const acc = [0, 0, 0, 0];
  const controls: Control[] = [
    { type: 'range', label: '방출량', min: 0.2, max: 2.5, step: 0.1, value: rate, on: (v) => (rate = v) },
    { type: 'toggle', label: '더하기 혼합 (불 · 반짝이)', value: add, on: (v) => (add = v) },
  ];
  const R = Math.random;
  const emit = (k: number, t: number): void => {
    if (k === 0) ps.push({ k, x: 40 + (R() - 0.5) * 16, y: 132, vx: (R() - 0.5) * 8, vy: -38 - R() * 30, life: 0, max: 0.6 + R() * 0.5, size: 9 + R() * 6, rot: 0, vr: 0, c: 0 });
    else if (k === 1) ps.push({ k, x: 108 + (R() - 0.5) * 6, y: 112, vx: (R() - 0.5) * 6, vy: -16 - R() * 10, life: 0, max: 2.2 + R(), size: 5, rot: 0, vr: 0, c: 0 });
    else if (k === 2) {
      const ax = 175 + Math.cos(t * 2.2) * 22;
      const ay = 82 + Math.sin(t * 3.1) * 22;
      const a = R() * TAU;
      const s = 6 + R() * 22;
      ps.push({ k, x: ax, y: ay, vx: Math.cos(a) * s, vy: Math.sin(a) * s, life: 0, max: 0.6 + R() * 0.6, size: 5 + R() * 6, rot: R() * TAU, vr: 0, c: Math.floor(R() * 3) });
    } else ps.push({ k, x: 220 + R() * 60, y: 18, vx: -6 - R() * 6, vy: 14 + R() * 10, life: 0, max: 4.5, size: 7 + R() * 4, rot: R() * TAU, vr: (R() - 0.5) * 4, c: R() * TAU });
  };
  const RATES = [70, 14, 40, 6];
  return {
    controls,
    draw(g: G, w: number, h: number, t: number, dt0: number) {
      const dt = Math.min(dt0, 0.05);
      reset(g);
      const f = frame(g, w, h);
      vbg(g, f, '#151a33', '#0a0c1a');
      for (let k = 0; k < 4; k++) {
        acc[k]! += RATES[k]! * rate * dt;
        while (acc[k]! >= 1 && ps.length < 900) {
          acc[k]! -= 1;
          emit(k, t);
        }
        if (acc[k]! > 5) acc[k] = 0;
      }
      // 소품
      spot(g, 40, 130, 50, [255, 130, 40], 0.25 + Math.sin(t * 13) * 0.04);
      g.fillStyle = '#6b3d22';
      g.save();
      g.translate(40, 138);
      g.rotate(0.25);
      rr(g, -16, -3, 32, 7, 3);
      g.fill();
      g.rotate(-0.5);
      rr(g, -16, -3, 32, 7, 3);
      g.fill();
      g.restore();
      // 굴뚝 냄비
      g.fillStyle = '#4b5068';
      rr(g, 98, 112, 20, 26, 3);
      g.fill();
      g.fillStyle = '#5d637f';
      rr(g, 95, 109, 26, 6, 2);
      g.fill();
      // 요술봉
      const ax = 175 + Math.cos(t * 2.2) * 22;
      const ay = 82 + Math.sin(t * 3.1) * 22;
      g.strokeStyle = '#c9a6ff';
      g.lineWidth = 2.4;
      g.lineCap = 'round';
      g.beginPath();
      g.moveTo(175, 140);
      g.lineTo(ax, ay);
      g.stroke();
      // 나뭇가지
      g.strokeStyle = '#5a3a2a';
      g.lineWidth = 3;
      g.beginPath();
      g.moveTo(f.R, 8);
      g.quadraticCurveTo(250, 12, 225, 4);
      g.stroke();
      for (let i = ps.length - 1; i >= 0; i--) {
        const p = ps[i]!;
        p.life += dt;
        if (p.life >= p.max) {
          ps[i] = ps[ps.length - 1]!;
          ps.pop();
          continue;
        }
        if (p.k === 0) {
          p.vx += (40 - p.x) * 1.5 * dt;
          p.vy -= 10 * dt;
        } else if (p.k === 1) {
          p.vx += Math.sin(t * 0.8 + p.y * 0.05) * 6 * dt + 4 * dt;
          p.vy *= 1 - 0.3 * dt;
        } else if (p.k === 2) {
          p.vx *= 1 - 2 * dt;
          p.vy *= 1 - 2 * dt;
          p.vy += 8 * dt;
        } else {
          p.vx = -8 + Math.sin(t * 1.5 + p.c) * 14;
        }
        p.x += p.vx * dt;
        p.y += p.vy * dt;
        p.rot += p.vr * dt;
      }
      // 연기 (보통 혼합) 먼저
      g.globalCompositeOperation = 'source-over';
      for (const p of ps) {
        if (p.k !== 1) continue;
        const k = p.life / p.max;
        const s = p.size + k * 22;
        g.globalAlpha = 0.45 * Math.sin(Math.min(1, k * 4) * Math.PI * 0.5) * (1 - k);
        g.drawImage(SMOKE, p.x - s, p.y - s, s * 2, s * 2);
      }
      for (const p of ps) {
        if (p.k !== 3) continue;
        const k = p.life / p.max;
        g.globalAlpha = k > 0.85 ? (1 - k) / 0.15 : 1;
        g.save();
        g.translate(p.x, p.y);
        g.rotate(p.rot);
        g.scale(Math.cos(p.life * 3 + p.c), 1);
        g.drawImage(petal, -p.size / 2, -p.size / 2, p.size, p.size);
        g.restore();
      }
      g.globalCompositeOperation = add ? 'lighter' : 'source-over';
      for (const p of ps) {
        const k = p.life / p.max;
        if (p.k === 0) {
          const img = FIRE[Math.min(3, Math.floor(k * 4))]!;
          const s = p.size * (1 - k * 0.7);
          g.globalAlpha = (1 - k) * 0.9;
          g.drawImage(img, p.x - s, p.y - s, s * 2, s * 2);
        } else if (p.k === 2) {
          const s = p.size * (1 - k) * (0.6 + 0.4 * Math.abs(Math.sin(p.life * 18 + p.rot)));
          g.globalAlpha = 1 - k;
          g.drawImage(STARS[p.c]!, p.x - s, p.y - s, s * 2, s * 2);
        }
      }
      g.globalAlpha = 1;
      g.globalCompositeOperation = 'source-over';
      const lab = ['불 · 더하기', '연기 · 보통', '반짝이 · 더하기', '꽃잎 · 돌기'];
      for (let k = 0; k < 4; k++) txt(g, lab[k]!, [40, 108, 175, 245][k]!, 158, 7.5, '#aab4dd');
      pill(g, `입자 ${ps.length}개 — 방출 · 수명 · 움직임 · 혼합`, 120, 14, 8, 'rgba(255,255,255,.1)', '#e8ecff');
      g.restore();
    },
  };
}
```

### i302 견본 항목 — `src/demos/demos2dLook.ts:3785`
```ts
  i302: { kind: '2d', caption: '불 · 연기 · 반짝이 · 꽃잎 — 방출기마다 수명 · 힘 · 크기 · 색이 다른 입자, 불은 더하기 혼합', make: mkI302 }
```

## 관련 기술
- 먼저 알면 좋은 기술: [캔버스 그러데이션 · 흐림 · 빛 섞기](https://ai-techstudio.web.app/ai/t/u52.md) `u52` · [2D 잔상 꼬리 · 대시 잔상](https://ai-techstudio.web.app/ai/t/i298.md) `i298`
- 다음에 해 볼 기술: [스프라이트 묶기 · 텍스처 아틀라스](https://ai-techstudio.web.app/ai/t/i303.md) `i303`
- 참고 문서: [Wikipedia — Particle system](https://en.wikipedia.org/wiki/Particle_system)
