# AI 꾸러미 — 플립북 (스프라이트 시트) — Flipbook animation (sprite sheet)
> 폭발 그림 16칸을 한 장에 모아 두고 시간에 따라 칸을 넘겨, 계산 없이 화려한 폭발 · 연기를 싸게 보여 준다.  
> 견본: https://ai-techstudio.web.app/#t/i59

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

## 주문서

### 만들어 줘: 플립북 (스프라이트 시트) — Flipbook animation (sprite sheet)

#### 1. 목표
상자가 터지는 폭발을 4×4 그림 칸 플립북으로 보여 줘 — 1초에 12장씩 넘기기. 분위기는 만화처럼 통통 튀는.

#### 2. 핵심 기술 용어
- **Flipbook animation (sprite sheet)** — 그림 칸을 차례로 넘기는 애니메이션
- **Texture atlas** — 여러 그림을 한 장에 모은 텍스처
- **Frame accumulator** — 흐른 시간 × 초당 장 수를 모아 1 이 넘을 때마다 다음 칸

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

#### 4. 조건
- 칸 넘기기는 시간 누적(acc += dt × fps)으로 — 장면마다 한 칸씩 넘기면 기기마다 빠르기가 다르다
- 「1초에 넘기는 장 수」를 조절 가능하게 (견본 2~30, 기본 12)
- 그림 칸 사이에 1~2px 여백을 둬 이웃 칸이 번져 들어오지 않게
- 설명 화면이면 왼쪽에 시트 전체 + 지금 칸 노란 테두리, 오른쪽에 넘겨 보이는 큰 화면

#### 5. 완성 기준 (이게 보이면 성공)
- 시트에서 노란 테두리가 1 → 16 칸을 지나가고, 오른쪽 화면에서 폭발이 커졌다 연기로 사그라든다
- 「1초에 넘기는 장 수」를 2 로 낮추면 뚝뚝 끊기고 30 이면 매끈하다
- 느린 기기에서도 1초에 넘기는 장 수가 같다

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

## 원리
- 폭발의 16 순간을 128px 칸 4×4 에 미리 그려 한 장(512px)으로 둔다 — 견본은 캔버스로 직접 그렸다.
- 매 장면 「흐른 초 × 초당 장 수」를 모아, 1 이 넘을 때마다 다음 칸으로 넘긴다 (장면 빠르기와 상관없이 같은 빠르기).
- k 번째 칸은 가로 k % 4, 세로 floor(k / 4) 자리 — drawImage 의 원본 사각형만 바꿔 그린다.
- 견본은 16칸 + 6박 쉬기 = 22박을 돌며, 쉬는 동안은 터지기 전 상자를 보여 준다.

## 핵심 코드 — 시간 누적으로 칸 넘기기 + 칸 하나 그리기
(발췌: demos/demosShader.ts flipbookDemo() 를 정리)
```ts
const CELL = 128;           // 시트 = 128px 칸 4 × 4 (512px 한 장)
const sheet = makeSheet();  // 캔버스나 그림 파일
let fps = 12;
let acc = 0;
let frame = 0;

function draw(g: CanvasRenderingContext2D, dt: number, x: number, y: number, size: number): void {
  acc += dt * fps;                       // 장면 빠르기와 상관없이 1초에 fps 장
  while (acc >= 1) {
    acc -= 1;
    frame = (frame + 1) % 22;            // 16칸 + 쉬는 6박
  }
  if (frame >= 16) return;               // 쉬는 중 (터지기 전 상자 등을 그린다)
  const sx = (frame % 4) * CELL;
  const sy = Math.floor(frame / 4) * CELL;
  g.drawImage(sheet, sx, sy, CELL, CELL, x - size / 2, y - size / 2, size, size);
}
```

## 흔한 실수 · 확인 목록
- [ ] **장면마다 한 칸씩 넘기면 기기마다 빠르기가 다르다** — 흐른 시간을 모아 넘긴다 — 120Hz 화면에선 두 배 빨라지는 실수를 막는다.
- [ ] **칸 경계에 옆 칸 그림이 살짝 비친다** — 텍스처 필터가 이웃 픽셀을 섞기 때문. 칸마다 여백을 두거나 원본 사각형을 0.5px 안쪽으로.
- [ ] **끝 칸에서 첫 칸으로 바로 돌면 같은 폭발이 기계처럼 반복된다** — 쉬는 박자를 두거나(견본 6박) 크기 · 회전을 조금씩 바꿔 그린다.

## 완성 기준 체크리스트
- [ ] 시트에서 노란 테두리가 1 → 16 칸을 지나가고, 오른쪽 화면에서 폭발이 커졌다 연기로 사그라든다
- [ ] 「1초에 넘기는 장 수」를 2 로 낮추면 뚝뚝 끊기고 30 이면 매끈하다
- [ ] 느린 기기에서도 1초에 넘기는 장 수가 같다

## 이 기술 정보
- id: `i59` · 분류: 이펙트 · 연출 › 화려한 효과 (VFX) · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 그림 한 장 + 사각형 하나 그리기. 계산은 거의 없고 텍스처 메모리 512×512 하나.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i59
- 쓰면 좋을 때: 폭발 · 연기처럼 계산하면 비싼 모양을 싸게 보여 줄 때 / 같은 효과가 여러 곳에서 동시에 터질 때
- 쓰지 말 때: 카메라가 가까이 돌아가며 보는 큰 효과 — 판이 납작한 게 드러난다. 입자 층 쌓기(i171)로 / 모양이 판마다 달라야 할 때 — 코드 생성 효과로

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### flipbookDemo — `src/demos/demosShader.ts:1305`
```ts
function flipbookDemo(): { draw(g: CanvasRenderingContext2D, w: number, h: number, t: number, dt: number): void; controls: Control[] } {
  const sheet = makeSheet();
  let fps = 12;
  let acc = 0;
  let frame = 0;
  return {
    draw(g, w, h, _t, dt) {
      acc += dt * fps;
      while (acc >= 1) {
        acc -= 1;
        frame = (frame + 1) % 22; // 16칸 + 쉬는 6박
      }
      const bg = g.createLinearGradient(0, 0, 0, h);
      bg.addColorStop(0, '#141a3e');
      bg.addColorStop(1, '#2b2462');
      g.fillStyle = bg;
      g.fillRect(0, 0, w, h);
      const pad = Math.min(w, h) * 0.06;
      const ss = Math.min(h - pad * 2 - h * 0.1, w * 0.42);
      const sx = pad;
      const sy = (h - ss) / 2 + h * 0.03;
      const cell = ss / 4;
      // 시트
      g.fillStyle = '#0c1028';
      g.fillRect(sx, sy, ss, ss);
      g.drawImage(sheet, sx, sy, ss, ss);
      g.strokeStyle = 'rgba(255,255,255,0.18)';
      g.lineWidth = 1;
      for (let i = 1; i < 4; i++) {
        g.beginPath();
        g.moveTo(sx + i * cell, sy);
        g.lineTo(sx + i * cell, sy + ss);
        g.moveTo(sx, sy + i * cell);
        g.lineTo(sx + ss, sy + i * cell);
        g.stroke();
      }
      g.font = `600 ${Math.max(9, cell * 0.16)}px "Pretendard Variable", system-ui, sans-serif`;
      g.fillStyle = 'rgba(255,255,255,0.55)';
      g.textBaseline = 'top';
      for (let k = 0; k < 16; k++) g.fillText(String(k + 1), sx + (k % 4) * cell + 3, sy + Math.floor(k / 4) * cell + 2);
      const fs = clamp(h * 0.05, 11, 22);
      g.font = `700 ${fs}px "Pretendard Variable", system-ui, sans-serif`;
      g.fillStyle = '#cfd8ff';
      g.textAlign = 'center';
      g.fillText('그림 칸 16장 (한 장의 그림)', sx + ss / 2, Math.max(2, sy - fs * 1.4));
      if (frame < 16) {
        g.strokeStyle = '#ffd84a';
        g.lineWidth = Math.max(2, cell * 0.05);
        g.strokeRect(sx + (frame % 4) * cell + 1, sy + Math.floor(frame / 4) * cell + 1, cell - 2, cell - 2);
      }
      // 화살표
      const bx0 = sx + ss + pad * 0.5;
      const bs = Math.min(h - pad * 2 - h * 0.1, w - bx0 - pad * 1.8);
      const ax = bx0;
      const ay = sy + ss / 2;
      g.fillStyle = '#ffd84a';
      g.beginPath();
      g.moveTo(ax, ay - 7);
      g.lineTo(ax + pad * 0.9, ay);
      g.lineTo(ax, ay + 7);
      g.fill();
      // 넘겨 보이는 큰 화면
      const bx = ax + pad * 1.3;
      const by = (h - bs) / 2 + h * 0.03;
      const sky = g.createLinearGradient(0, by, 0, by + bs);
      sky.addColorStop(0, '#5aa6e8');
      sky.addColorStop(1, '#bfe6ff');
      g.save();
      g.beginPath();
      g.roundRect(bx, by, bs, bs, 12);
      g.clip();
      g.fillStyle = sky;
      g.fillRect(bx, by, bs, bs);
      g.fillStyle = '#6cc24a';
      g.fillRect(bx, by + bs * 0.72, bs, bs * 0.28);
      if (frame >= 16 || frame < 2) {
        // 터지기 전 상자
        g.fillStyle = '#c8873e';
        const b = bs * 0.16;
        g.fillRect(bx + bs / 2 - b / 2, by + bs * 0.72 - b, b, b);
        g.strokeStyle = '#7a4a1e';
        g.lineWidth = 2;
        g.strokeRect(bx + bs / 2 - b / 2, by + bs * 0.72 - b, b, b);
      }
      if (frame < 16) {
        const k = frame;
        const big = bs * 1.0;
        g.drawImage(sheet, (k % 4) * 128, Math.floor(k / 4) * 128, 128, 128, bx + bs / 2 - big / 2, by + bs * 0.72 - big * 0.62, big, big);
      }
      g.restore();
      g.strokeStyle = 'rgba(255,255,255,0.5)';
      g.lineWidth = 2;
      g.beginPath();
      g.roundRect(bx, by, bs, bs, 12);
      g.stroke();
      g.fillStyle = '#fff';
      g.fillText(frame < 16 ? `${frame + 1}번째 칸 · 1초에 ${fps}장` : '쉬는 중…', bx + bs / 2, Math.max(2, by - fs * 1.4));
      g.textAlign = 'start';
    },
    controls: [{ type: 'range', label: '1초에 넘기는 장 수', min: 2, max: 30, step: 1, value: 12, on: (v) => (fps = v) }],
  };
}
```

## 관련 기술
- 다음에 해 볼 기술: [입자 방출기 (수명 곡선)](https://ai-techstudio.web.app/ai/t/i170.md) `i170` · [층 쌓기 (핵 · 빛무리 · 불티 · 연기)](https://ai-techstudio.web.app/ai/t/i171.md) `i171`
- 참고 문서: [MDN — CanvasRenderingContext2D.drawImage()](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage)
