# AI 꾸러미 — 스프라이트 쌓기 (sprite stacking)
> 물체를 가로로 썬 단면 도트 그림 여러 장을 아래부터 한 칸씩 위로 쌓아 함께 돌려, 3D 모델 없이 차 · 집 · 나무가 입체처럼 돌아 보이게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i308

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

## 주문서

### 만들어 줘: 스프라이트 쌓기 (sprite stacking)

#### 1. 목표
도트 차 · 탈것을 스프라이트 쌓기로 그려 줘 — 가로 단면 도트 그림을 층마다 위로 쌓고 함께 돌려 입체처럼. 분위기는 밝은 도트 장난감.

#### 2. 핵심 기술 용어
- **Sprite stacking** — 스프라이트 쌓기 — 단면 그림을 층층이
- **Voxel slices** — 높이마다 한 장인 복셀 단면
- **Canvas transform (translate · scale · rotate)** — 층마다 위로 옮기고 납작하게 눌러 돌리기
- **Nearest-neighbor scaling** — 도트를 흐리지 않고 키우기

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

#### 4. 조건
- 단면은 처음 한 번 화면 밖 캔버스로 구워 두기 (매 프레임 픽셀 다시 쓰지 않기)
- 그릴 때 imageSmoothingEnabled = false — 도트가 흐려지지 않게
- 층마다 translate(위로) → scale(1, 0.62) → rotate(각) 순서 — 순서가 바뀌면 찌그러진다
- 아래 층을 조금 어둡게 해 입체감
- 층 펼쳐 보기 토글로 원리를 보여 주기

#### 5. 완성 기준 (이게 보이면 성공)
- 차 · 집 · 나무가 제자리에서 돌며 입체처럼 보인다
- 「층 펼쳐 보기」를 켜면 층 사이가 벌어져 단면 그림이 쌓인 것이 보인다
- 아래 띠에 층마다 단면 그림이 나란히 보인다
- 회전 속도 슬라이더로 빠르기가 바뀐다

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

## 원리
- 물체를 높이 1칸마다 가로로 썬 단면을 한 장씩 그린다 (차 10장 · 집 15장). 아래 층일수록 0.72배까지 어둡게.
- 아래 층부터 차례로: 그 층 높이만큼 위로 옮기고 → 세로를 0.62배로 눌러 비스듬히 보이게 → 같은 각도로 돌려 → 그린다.
- 층 사이가 틈 없이 메워지게 반 칸 위에 한 번 더 찍는다 (펼쳐 보기일 땐 한 번만).
- 모든 층이 같은 각도로 돌기만 해도 눈은 하나의 입체로 본다 — 진짜 3D 계산은 없다.

## 핵심 코드 — 단면 그림을 층층이 쌓아 돌리기
(발췌: demos/demos2dLook.ts mkI308() drawStack() 을 정리)
```ts
/** sl = 아래층부터 단면 캔버스 목록, s = 확대, a = 회전, gap = 층 간격(1 = 붙임) */
function drawStack(g: CanvasRenderingContext2D, sl: HTMLCanvasElement[],
                   x: number, y: number, s: number, a: number, gap = 1) {
  g.imageSmoothingEnabled = false;            // 도트 그대로
  for (let l = 0; l < sl.length; l++) {
    const c = sl[l];
    for (let k = 0; k < 2; k++) {             // 반 칸 위에 한 번 더 — 층 틈 메우기
      g.save();
      g.translate(x, y - (l + k * 0.5) * s * gap); // 층 높이만큼 위로
      g.scale(1, 0.62);                       // 비스듬히 내려다보기
      g.rotate(a);                            // 모든 층 같은 각
      g.drawImage(c, (-c.width * s) / 2, (-c.height * s) / 2, c.width * s, c.height * s);
      g.restore();
      if (gap > 1.6) break;                   // 펼쳐 볼 땐 한 번만
    }
  }
  g.imageSmoothingEnabled = true;
}
// 단면 만들기: 층 l 마다 캔버스 하나, fn(x, y, l) 이 색(없으면 null)을 돌려준다.
// 아래층 어둡게: dk = 0.72 + 0.28 * (l / (L - 1)) 를 색에 곱한다.
```

## 흔한 실수 · 확인 목록
- [ ] **scale 을 rotate 뒤에 하면 돌 때 물체가 늘었다 줄었다 한다** — translate → scale(1, 0.62) → rotate 순서. 누르는 것은 화면 축, 도는 것은 물체 축.
- [ ] **확대하면 층 사이에 틈이 보인다** — 견본은 반 칸 위에 한 번 더 찍어 메운다 (층 간격이 넓은 펼쳐 보기에선 생략).
- [ ] **매끈하게 확대되어 도트가 뭉갠다** — drawImage 전에 imageSmoothingEnabled = false.

## 완성 기준 체크리스트
- [ ] 차 · 집 · 나무가 제자리에서 돌며 입체처럼 보인다
- [ ] 「층 펼쳐 보기」를 켜면 층 사이가 벌어져 단면 그림이 쌓인 것이 보인다
- [ ] 아래 띠에 층마다 단면 그림이 나란히 보인다
- [ ] 회전 속도 슬라이더로 빠르기가 바뀐다

## 이 기술 정보
- id: `i308` · 분류: 2D · 화면 › 2D 의사 3D · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 물체 하나 = 층 수 × 2번 drawImage (차 20번). 수십 개까지는 폰도 괜찮다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i308
- 쓰면 좋을 때: 위에서 비스듬히 보는 도트 게임의 탈것 · 소품이 돌아야 할 때 / 3D 없이 「장난감 같은 입체」 느낌
- 쓰지 말 때: 옆에서 보는 각도 — 층이 납작한 판으로 드러난다. 대신 three.js 복셀 모델 / 층 수가 수십 장 넘는 큰 물체가 많을 때 — drawImage 수가 커진다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### mkI308 — `src/demos/demos2dLook.ts:3064`
```ts
function mkI308() {
  type Slice = HTMLCanvasElement;
  const mkModel = (W: number, H: number, L: number, fn: (x: number, y: number, l: number) => string | null): Slice[] => {
    const out: Slice[] = [];
    for (let l = 0; l < L; l++) {
      const c = mkCanvas(W, H);
      const x = c2(c);
      const im = x.createImageData(W, H);
      const dk = 0.72 + 0.28 * (l / Math.max(1, L - 1));
      for (let yy = 0; yy < H; yy++)
        for (let xx = 0; xx < W; xx++) {
          const col = fn(xx, yy, l);
          if (!col) continue;
          const v = hexRgb(col);
          const i = (yy * W + xx) * 4;
          im.data[i] = v[0] * dk;
          im.data[i + 1] = v[1] * dk;
          im.data[i + 2] = v[2] * dk;
          im.data[i + 3] = 255;
        }
      x.putImageData(im, 0, 0);
      out.push(c);
    }
    return out;
  };
  const car = mkModel(12, 20, 10, (x, y, l) => {
    const wheel = (x <= 1 || x >= 10) && ((y >= 3 && y <= 6) || (y >= 13 && y <= 16));
    if (l <= 1) return wheel ? '#2a2a33' : x >= 2 && x <= 9 && y >= 1 && y <= 18 ? '#3a3a44' : null;
    if (l <= 4) {
      if (x < 1 || x > 10 || y < 0 || y > 19) return null;
      if (l === 3 && y === 0 && (x === 2 || x === 9)) return '#fff3a0';
      if (l === 3 && y === 19 && (x === 2 || x === 9)) return '#ff3048';
      if (l === 4 && (x === 5 || x === 6)) return '#ffffff';
      return '#ff4b5c';
    }
    if (l <= 7) {
      if (x < 2 || x > 9 || y < 6 || y > 14) return null;
      if (l === 7) return x === 5 || x === 6 ? '#ffffff' : '#ff6b78';
      const edge = x === 2 || x === 9 || y === 6 || y === 14;
      const pillar = (x === 2 || x === 9) && (y === 6 || y === 14 || y === 10);
      if (!edge) return '#ff4b5c';
      return pillar ? '#e0304a' : '#9fe4ff';
    }
    return null;
  });
  const house = mkModel(16, 16, 15, (x, y, l) => {
    if (l < 8) {
      if (x < 1 || x > 14 || y < 1 || y > 14) return null;
      const edge = x === 1 || x === 14 || y === 1 || y === 14;
      if (!edge) return '#fff1d6';
      if (y === 14 && x >= 6 && x <= 9 && l < 5) return '#8a5a3a';
      if (l >= 3 && l <= 5 && ((y === 14 || y === 1) && (x === 3 || x === 4 || x === 11 || x === 12))) return '#7fd0ff';
      if (l >= 3 && l <= 5 && ((x === 1 || x === 14) && (y === 5 || y === 6 || y === 10))) return '#7fd0ff';
      return l === 0 ? '#c9b48e' : '#ffe9c2';
    }
    const k = l - 8;
    if (x >= 11 && x <= 12 && y >= 3 && y <= 4 && l <= 14) return l === 14 ? '#555' : '#b0563a';
    if (y < k || y > 15 - k) return null;
    if (x < 0 || x > 15) return null;
    const ridge = y === k || y === 15 - k;
    return ridge ? '#c2410c' : (x + l) % 2 ? '#f97316' : '#fb923c';
  });
  const tr = mkModel(16, 16, 15, (x, y, l) => {
    const dx = x - 7.5;
    const dy = y - 7.5;
    const d = Math.hypot(dx, dy);
    if (l < 5) return d < 1.6 ? '#8a5a3a' : null;
    const k = (l - 4) / 10;
    const rad = 7.5 * Math.sin(Math.PI * Math.min(1, k * 0.95 + 0.05));
    if (d > rad) return null;
    if (d > rad - 1 && hash2(x, y, l) > 0.85) return '#ff4d5e';
    return hash2(x, y, l + 5) > 0.55 ? '#3fbf5a' : '#5ed36f';
  });
  let explode = false;
  let spd = 1;
  let ang = 0;
  let exK = 0;
  const controls: Control[] = [
    { type: 'toggle', label: '층 펼쳐 보기', value: explode, on: (v) => (explode = v) },
    { type: 'range', label: '회전 속도', min: 0, max: 3, step: 0.1, value: spd, on: (v) => (spd = v) },
  ];
  const drawStack = (g: G, sl: Slice[], x: number, y: number, s: number, a: number, gap: number): void => {
    g.fillStyle = 'rgba(0,0,0,.28)';
    g.beginPath();
    g.ellipse(x, y + 2, sl[0]!.width * s * 0.7, sl[0]!.width * s * 0.32, 0, 0, TAU);
    g.fill();
    for (let l = 0; l < sl.length; l++) {
      const c = sl[l]!;
      for (let k = 0; k < 2; k++) {
        g.save();
        g.translate(x, y - (l + k * 0.5) * s * gap);
        g.scale(1, 0.62);
        g.rotate(a);
        g.drawImage(c, (-c.width * s) / 2, (-c.height * s) / 2, c.width * s, c.height * s);
        g.restore();
        if (gap > 1.6) break;
      }
    }
  };
  return {
    controls,
    draw(g: G, w: number, h: number, t: number, dt: number) {
      reset(g);
      const f = frame(g, w, h);
      vbg(g, f, '#22305a', '#121a35');
      g.fillStyle = '#2e3f72';
      g.beginPath();
      g.ellipse(140, 130, 170, 40, 0, 0, TAU);
      g.fill();
      ang += dt * 0.9 * spd;
      const auto = (Math.sin(t * 0.8) * 0.5 + 0.5) > 0.82;
      exK = lerp(exK, explode || auto ? 1 : 0, Math.min(1, dt * 5));
      const gap = 1 + exK * 2.2;
      g.imageSmoothingEnabled = false;
      drawStack(g, car, 52, 128, 3, ang, gap);
      drawStack(g, house, 140, 132, 3.4, -ang * 0.7 + 0.5, gap);
      drawStack(g, tr, 228, 130, 3, ang * 0.5, gap);
      g.imageSmoothingEnabled = true;
      // 단면 띠
      const strip = (sl: Slice[], x0: number): void => {
        const n = sl.length;
        const cw = 7;
        for (let l = 0; l < n; l++) {
          g.imageSmoothingEnabled = false;
          g.drawImage(sl[l]!, x0 + l * (cw + 1), 156, cw, cw * (sl[l]!.height / sl[l]!.width));
          g.imageSmoothingEnabled = true;
        }
      };
      strip(car, 6);
      strip(house, 92);
      strip(tr, 166);
      pill(g, `가로 단면 그림을 층마다 위로 ${exK > 0.5 ? '(펼침)' : ''} 쌓고 돌림`, 140, 13, 8, 'rgba(255,255,255,.1)', '#eef');
      g.restore();
    },
  };
}
```

### i308 견본 항목 — `src/demos/demos2dLook.ts:3791`
```ts
  i308: { kind: '2d', caption: '가로 단면 도트 그림(아래 띠)을 층층이 위로 쌓아 돌리면 차 · 집 · 나무가 입체처럼', make: mkI308 }
```

## 관련 기술
- 먼저 알면 좋은 기술: [픽셀 아트 확대 (계단 그대로 · 서브픽셀)](https://ai-techstudio.web.app/ai/t/i304.md) `i304`
- 다음에 해 볼 기술: [등각 깊이 정렬 (큰 물체 · 겹침)](https://ai-techstudio.web.app/ai/t/i310.md) `i310`
