# AI 꾸러미 — 그림 한 장으로 도트 보스

## 주문서

### 한 장면 만들기: 그림 한 장으로 도트 보스

#### 1. 목표
AI 로 뽑은 보스 도트 그림을 그림 한 장만으로 정면 전투의 적처럼 움직이게 해 줘 — 먼저 도트 그림을 깨끗하게 정리하고, 부위를 오려 칸 단위로 움직이고, 상태마다 연출을 붙인다. 분위기는 어둡고 서늘한 공포.
아래 기술 6개를 한 장면에 엮어 줘. 한 번에 다 만들지 말고 1번부터 차례로 쌓되, 단계마다 화면에서 확인할 수 있게.

#### 2. 들어갈 기술 (쌓는 순서)
##### 1) AI 픽셀풍 그림 정리 (격자 · 팔레트 되찾기) — Pixel art cleanup (grid detection · downsampling)
- 역할: AI 픽셀풍 그림을 진짜 도트로 — 격자 · 색 줄이기 · 배경 지우기
- 핵심: AI 픽셀풍 그림은 큰 그림(예: 2048px)에 칸(예: 8px)을 흉내 낸 것이라, 칸 경계가 번지고 색이 수천 가지로 섞여 있다.
- 확인: 찾은 격자 선을 원본 위에 겹치면 칸 경계와 정확히 맞는다
- 확인: 결과 그림을 정수 배로 키우면 칸 크기가 모두 같고 경계가 번지지 않는다
##### 2) 픽셀 그림 한 장 살리기 (부위 겹 · 칸 단위 움직임) — Cutout animation for pixel art (part layers)
- 역할: 부위를 오려 칸 단위로 움직이는 몸통 애니메이션과 상태 세트
- 핵심: 도트 그림을 칸마다 휘게(warp) 하면 휘는 양이 다른 곳에서 칸이 겹치거나 빠져 1칸짜리 외곽선이 끊긴다 — 도트는 「통째로 · 칸 단위로」 움직여야 한다.
- 확인: 한 장씩 넘겨 봐도 외곽선이 끊기거나 칸 크기가 달라지는 곳이 없다
- 확인: 「나눈 부위 보기」로 띠 · 머리카락 · 장식 · 손 · 연기 영역이 색으로 보인다
##### 3) 팔레트 색 순환 (그림은 그대로, 색만 흐름) — Palette color cycling
- 역할: 연기 · 불꽃을 색만 돌려 흐르게
- 핵심: 그림은 화소마다 색이 아니라 「색 번호」(Uint8Array)를 가진다. 실제 색은 팔레트 표(256칸)에서 찾는다.
- 확인: 색 순환을 켜면 폭포 흰 줄이 흐르고 호수 물결 · 해 반사가 반짝인다
- 확인: 끄면 같은 그림이 멈춘 그림이 된다 — 그림 자체는 바뀌지 않음
##### 4) 색 바꾸기 · 팔레트 교체 (2D) — Palette swap
- 역할: 분노 상태의 붉은 팔레트로 바꾸기
- 핵심: 그림은 화소마다 번호 0~7 만 가진다: 0 투명 · 1 외곽선 · 2 몸 · 3 그늘 · 4 빛 · 5 눈 흰자 · 6 장식 · 7 볼.
- 확인: 같은 슬라임이 원래 · 파랑 팀 · 피격 · 얼음 · 무지개로 나란히 보인다
- 확인: 피격은 0.45초 동안 빨강 ↔ 흰색으로 깜빡이며 살짝 흔들린다
##### 5) 피격 흰 번쩍 — Hit flash (white flash)
- 역할: 맞는 순간 하얗게 번쩍
- 핵심: 재질에 emissive 흰색(0xffffff)을 미리 넣어 두고 세기를 0 으로 둔다.
- 확인: 블록 셋 중 잘못 누른 블록만 0.1초 하얗게 번쩍하고 튀며 X 표시가 뜬다
- 확인: 왼쪽(끔)은 톡 튀기만 해서 어느 것이 맞았는지 놓치기 쉽다 — 차이가 보인다
##### 6) 스프라이트 녹기 · 나타나기 (잡음 디졸브 2D)
- 역할: 사라지기 · 나타나기
- 핵심: 잡음 문턱으로 타들어 가듯 사라지고 테두리 불빛
- 확인: 견본처럼 「잡음 문턱으로 타들어 가듯 사라지고 테두리 불빛」 이(가) 첫 화면에서 바로 보인다
- 확인: 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다

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

#### 4. 조건 (공통)
- 모든 움직임 · 효과는 정수 칸 단위 · 같은 팔레트 — 휘기 · 반투명 · 매끈한 선 금지
- 1초에 8장으로 끊고, 상태마다 스프라이트 시트로 내보내기
- 한 번짜리 상태(공격 · 맞음 · 나타나기)는 끝나면 가만히로 돌아오기
- 칸 크기를 사람이 넣지 않아도 되게 격자 간격 · 시작점을 자동으로 찾기 (4~16px 범위를 모두 시험)
- 대표 색은 칸 가장자리를 버린 중앙값으로 (평균 금지 — 탁해진다)
- 그림을 휘지(warp · mesh deform) 말 것 — 부위마다 통째로, 정수 칸 단위로만 옮긴다
- 부위 영역은 그림 칸 단위 마스크(부위 번호 그림)로, 몸 뒤 · 몸 앞을 나눠서
- 그림은 색 번호로 한 번만 만들고, 매 프레임은 팔레트만 돌리기
- 도는 칸 묶음마다 길이(16 · 8)와 빠르기를 따로
- 그림은 번호 배열 하나, 색은 표에서만 — 그림에 색을 직접 칠하지 않기
- 번호의 뜻(외곽선 · 몸 · 그늘 …)을 정해 두고 모든 표가 같은 뜻을 따르기
- 재질은 물체마다 따로 (공유 재질이면 모두가 번쩍인다)
- emissive 는 미리 흰색으로, 세기만 0 ↔ 1 (emissiveMap 같은 맵을 붙였다 뗐다 하면 셰이더가 다시 컴파일된다)
- devicePixelRatio 를 맞춰 또렷하게

#### 5. 완성 기준 (전체)
- 정리 전후를 나란히 보면 칸 경계가 고르고 색이 수십 가지로 줄어 있다
- 상태 단추를 누를 때마다 바로 바뀌고, 한 장씩 넘겨도 외곽선이 끊기지 않는다
- 분노 상태에선 연기 색이 붉게 바뀌고, 맞음 상태에선 하얗게 번쩍였다 밀려난다
- 기술마다 켬/끔 단추가 있어 하나씩 꺼 보며 그 기술이 맡은 차이를 확인할 수 있다
- 콘솔 오류 0 · 폰에서도 끊기지 않는다

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

## 참고 1. AI 픽셀풍 그림 정리 (격자 · 팔레트 되찾기) — 격자 간격 · 시작점 찾기 (세로 경계 기준)
```ts
// d: RGBA 화소, w · h: 크기. 세로줄마다 「왼쪽 이웃과의 색 차이」 합
function findGridX(d: Uint8ClampedArray, w: number, h: number) {
  const col = new Float64Array(w);
  for (let y = 0; y < h; y++)
    for (let x = 0; x < w - 1; x++) {
      const i = (y * w + x) * 4;
      col[x] += Math.abs(d[i] - d[i + 4]) + Math.abs(d[i + 1] - d[i + 5]) + Math.abs(d[i + 2] - d[i + 6]);
    }
  const mean = col.reduce((a, b) => a + b, 0) / w;
  let best = { p: 8, o: 0, score: 0 };
  for (let p = 4; p <= 16; p++)
    for (let o = 0; o < p; o++) {
      let s = 0, k = 0;
      for (let x = o; x < w - 1; x += p) { s += col[x]; k++; }
      const score = s / k / mean; // 경계가 칸 사이에 몰려 있으면 평균보다 몇 배 크다
      if (score > best.score) best = { p, o, score };
    }
  // 경계는 칸 끝 화소와 다음 칸 첫 화소 사이 → 칸 시작은 o + 1
  return { p: best.p, start: (best.o + 1) % best.p, score: best.score };
}
```
- [ ] **흰 배경을 가장자리에서만 채우면 고리 안쪽 흰 칸이 남는다** — 팔 · 띠 고리 안쪽처럼 갇힌 밝은 덩어리도 8칸 이상이면 배경으로. 작은 덩어리(눈 흰자 · 옷깃)는 남긴다.
- [ ] **배경을 지워도 머리카락 끝에 흰 점 테두리가 남는다** — AI 가 흰 바탕 위에 그려 경계 칸이 밝게 섞였다 — 배경에 닿은 아주 밝은 칸을 한두 번 더 걷는다.
- [ ] **RGB 로 색을 줄이면 금빛 · 초록 장식이 회색으로 빠진다** — CIELAB 거리 + 채도에 무게를 준 k-평균, 그리고 배경 칸은 빼고 계산한다.
- [ ] **칸 평균색을 쓰면 그림이 탁해진다** — 번진 가장자리를 버리고 가운데 화소들의 중앙값을 쓴다.

## 참고 2. 픽셀 그림 한 장 살리기 (부위 겹 · 칸 단위 움직임) — 부위 겹 합치기 — 뒤 부위 → 몸통 → 앞 부위, 모두 정수 칸 이동
```ts
// src: 원본 RGBA(256×256), base: 앞 부위 자리를 메우고 뒤 부위를 비운 몸통, parts[k]: 부위 k 의 칸 번호 목록
// off[k] = [dx, dy] (정수) — 매 장 sin 을 반올림해 -1 · 0 · 1 로
function compose(out: Uint8ClampedArray, src: Uint8ClampedArray, base: Uint8ClampedArray,
                 parts: Record<number, number[]>, off: Record<number, [number, number]>,
                 back: number[], front: number[], N = 256) {
  out.fill(0);
  const put = (x: number, y: number, s: number) => {
    if (x < 0 || y < 0 || x >= N || y >= N) return;
    const p = (y * N + x) * 4;
    out[p] = src[s * 4]; out[p + 1] = src[s * 4 + 1]; out[p + 2] = src[s * 4 + 2]; out[p + 3] = 255;
  };
  for (const k of back) for (const i of parts[k] ?? []) put((i % N) + off[k][0], ((i / N) | 0) + off[k][1], i);
  for (let i = 0; i < N * N; i++) if (base[i * 4 + 3]) out.set(base.subarray(i * 4, i * 4 + 4), i * 4); // 몸통이 뒤 부위를 덮는다
  for (const k of front) for (const i of parts[k] ?? []) put((i % N) + off[k][0], ((i / N) | 0) + off[k][1], i);
}
const sq = (f: number, period: number, ph = 0) => Math.round(Math.sin((2 * Math.PI * f) / period + ph)); // -1 · 0 · 1
```
- [ ] **그림을 칸마다 휘게 하면 외곽선이 끊기고 지글거린다** — 처음 시험판이 이랬다 — 휘는 양이 다른 곳에서 칸이 겹치거나 빠진다. 부위를 오려 통째로 정수 칸만큼 옮긴다.
- [ ] **긴 부위를 두 조각으로 나누면 이음매가 찢어진다** — 이음매를 소매 · 몸통 뒤처럼 가려지는 곳에 둔다. 띠는 위 고리 · 아래 가닥으로.
- [ ] **부위 상자를 네모로 잡으면 뒤의 치마까지 같이 움직인다** — 상자 안에서도 그 부위 색 · 이어진 덩어리만 고른다(연꽃 상자가 치마를 같이 잡았던 일).
- [ ] **사라지기를 무작위 칸으로 흩으면 모래알처럼 지저분하다** — 아래 → 위 순서에 작은 무작위만 섞어 경계선이 보이게 하고, 날아가는 알갱이는 일부 칸만.
- [ ] **0.5 칸처럼 소수점 위치에 그리면 칸 크기가 제각각이 된다(믹셀)** — 모든 위치를 정수 칸으로 반올림하고, 화면 확대도 정수 배로.

## 참고 3. 팔레트 색 순환 (그림은 그대로, 색만 흐름) — 색 번호 그림 + 팔레트 칸만 돌리기
```ts
const W = 192, H = 120;
const idx = new Uint8Array(W * H);  // 그림 = 색 번호 (한 번만 그린다)
const pal = new Uint32Array(256);   // 원래 팔레트 (ABGR 한 덩이)
const rgb = (r: number, g: number, b: number) => (255 << 24) | (b << 16) | (g << 8) | r;
// 폭포 32..47: 흰 줄이 섞인 16색
[255, 220, 170, 120, 90, 70, 60, 70, 90, 120, 150, 110, 80, 70, 100, 180]
  .forEach((v, i) => (pal[32 + i] = rgb(Math.round(v * 0.75), Math.round(v * 0.92), Math.min(255, v + 40))));
// … 하늘 0..15 · 바위 16..27 · 호수 48..63 · 해 반사 64..71 도 같은 식으로
// 그림 칠하기 예: 폭포 칸은 세로로 번호가 이어지게 → idx[i] = 32 + (((y - col) % 16) + 16) % 16

const cv = document.createElement('canvas');
cv.width = W; cv.height = H;
const cg = cv.getContext('2d')!;
const img = cg.createImageData(W, H);
const buf = new Uint32Array(img.data.buffer);
const shown = new Uint32Array(256);
let off = 0;
const rot = (base: number, n: number, o: number) => {
  for (let i = 0; i < n; i++) shown[base + i] = pal[base + ((((i - o) % n) + n) % n)]!;
};
function draw(g: CanvasRenderingContext2D, dt: number, speed = 1) {
  off += dt * speed * 12;
  shown.set(pal);
  rot(32, 16, Math.floor(off));       // 폭포
  rot(48, 16, Math.floor(off * 0.5)); // 호수 (느리게)
  rot(64, 8, Math.floor(off * 0.7));  // 해 반사
  for (let i = 0; i < W * H; i++) buf[i] = shown[idx[i]!]!;
  cg.putImageData(img, 0, 0);
  g.imageSmoothingEnabled = false;   // 번호 경계를 섞지 않게
  g.drawImage(cv, 0, 0, W * 4, H * 4);
}
```
- [ ] **음수 나머지를 그대로 쓰면 팔레트 칸 밖을 읽는다** — JS 의 % 는 음수를 돌려주니 ((i − o) % n + n) % n 으로.
- [ ] **그림에 번호를 줄무늬 없이 한 칸으로 칠하면 돌려도 깜빡이기만 한다** — 흐르는 쪽으로 번호가 차례로 이어지게 (y − 열 값) mod 16 으로 칠한다.
- [ ] **Uint32 색을 RGBA 순서로 넣으면 색이 뒤바뀐다** — 보통 컴퓨터(리틀 엔디언)는 (A << 24) | (B << 16) | (G << 8) | R 순서.
- [ ] **키울 때 부드럽게 그리면 번호 경계가 흐려진다** — imageSmoothingEnabled = false 로 정수 배.

## 참고 4. 색 바꾸기 · 팔레트 교체 (2D) — 색 번호 그림 × 색 표 → 구워 두고 골라 쓰기
```ts
const PN = 16;
type Pal = (string | null)[]; // 번호 → 색 (0 = 투명)
const PALS: Record<string, Pal> = {
  orig:  [null, '#1d3b2a', '#7ad65a', '#3f9c46', '#e4ffd2', '#ffffff', '#ffb43c', '#ff8fa8'],
  team:  [null, '#16213e', '#5ea8ff', '#2f5fd0', '#dff0ff', '#ffffff', '#ffd43c', '#ff9ac0'],
  hit:   [null, '#5a0f14', '#ff4d5e', '#c21f35', '#ffd0d6', '#ffffff', '#ffe0a0', '#ffffff'],
  white: [null, '#ffffff', '#ffffff', '#ffe9ec', '#ffffff', '#ffffff', '#ffffff', '#ffffff'],
  ice:   [null, '#1c4b66', '#bdf4ff', '#7ccde8', '#ffffff', '#ffffff', '#9be7ff', '#ffc9e0'],
};
function pixCanvas(idx: Uint8Array, pal: Pal) {
  const c = document.createElement('canvas');
  c.width = c.height = PN;
  const x = c.getContext('2d')!;
  const im = x.createImageData(PN, PN);
  for (let i = 0; i < PN * PN; i++) {
    const col = pal[idx[i]!];
    if (!col) continue;
    const n = parseInt(col.slice(1), 16);
    im.data.set([(n >> 16) & 255, (n >> 8) & 255, n & 255, 255], i * 4);
  }
  x.putImageData(im, 0, 0);
  return c;
}
// 표마다 한 번 굽기
const baked = Object.fromEntries(Object.entries(PALS).map(([k, p]) => [k, pixCanvas(SLIME, p)]));

function spriteFor(state: 'orig' | 'team' | 'ice' | 'hit', hitT: number) {
  if (state === 'hit' && hitT < 0.45) return Math.floor(hitT / 0.07) % 2 ? baked.hit! : baked.white!; // 깜빡
  return baked[state]!;
}
// 그리기: g.imageSmoothingEnabled = false; g.drawImage(spriteFor(s, t % 2.2), x, y, 32, 32);
```
- [ ] **색을 그림에 직접 칠해 두면 팀 색을 바꿀 때마다 그림을 새로 그려야 한다** — 그림은 번호로, 색은 표로 나눈다.
- [ ] **번호 뜻이 표마다 다르면 눈 · 볼 자리 색이 뒤죽박죽된다** — 번호마다 부위를 정해 두고(1 외곽선 · 2 몸 …) 모든 표가 따른다.
- [ ] **피격 깜빡임을 매 프레임 바꾸면 너무 빨라 회색으로 보인다** — 0.07초 간격으로 표 두 개를 번갈아 쓴다.
- [ ] **무지개 표를 매 프레임 다시 구우면 쓸데없이 바쁘다** — hue 정수가 바뀔 때만 다시 굽는다.

## 참고 5. 피격 흰 번쩍 — 물체마다 재질 + emissive 세기 1 → 0
```ts
// 물체마다 따로 — emissive 는 처음부터 흰색, 세기만 0
const mats = NUM.map((n, i) => new THREE.MeshStandardMaterial({
  map: faceTex(n, COLORS[i]), roughness: 0.42, emissive: 0xffffff, emissiveIntensity: 0,
}));
const cubes = mats.map((m, i) => {
  const c = new THREE.Mesh(roundBoxGeo, m);
  c.position.set((i - 1) * 1.4, 0.45, 0);
  scene.add(c);
  return c;
});

// 맞은 순간부터 a 초 지남, idx = 맞은 블록
const flash = Math.max(0, Math.min(1, 1 - a / dur)); // dur = 0.1초
mats.forEach((m, i) => (m.emissiveIntensity = i === idx ? flash : 0));

// 함께: 톡 튀기 + 찌그러짐
const c = cubes[idx];
const hop = Math.sin(Math.min(1, a / 0.3) * Math.PI) * 0.22;
const sq = 0.14 * Math.exp(-a * 10) * Math.cos(a * 30);
c.position.y = 0.45 + hop;
c.scale.set(1 + sq, 1 - sq, 1 + sq);
```
- [ ] **여러 물체가 재질 하나를 같이 쓰면 다 같이 번쩍인다** — 번쩍일 물체는 재질을 따로 둔다 (견본은 블록마다 MeshStandardMaterial 하나씩).
- [ ] **번쩍일 때 emissiveMap 을 처음 붙이면 첫 번쩍에 멈칫한다** — 맵이 생기면 셰이더 열쇠가 바뀌어 다시 컴파일된다. 처음부터 emissive 흰색 · 세기 0 으로 만들어 두고 세기(uniform)만 바꾼다.
- [ ] **색을 흰색으로 바꾸는 방식(color.set)은 그늘진 면이 덜 하얘진다** — emissive 는 빛과 상관없이 더해지므로 모든 면이 고르게 하얘진다.

## 참고 6. 스프라이트 녹기 · 나타나기 (잡음 디졸브 2D) — 
```ts

```

## 들어간 기술 (하나씩 자세히)
- [AI 픽셀풍 그림 정리 (격자 · 팔레트 되찾기)](https://ai-techstudio.web.app/ai/t/i500.md) `i500`
- [픽셀 그림 한 장 살리기 (부위 겹 · 칸 단위 움직임)](https://ai-techstudio.web.app/ai/t/i501.md) `i501`
- [팔레트 색 순환 (그림은 그대로, 색만 흐름)](https://ai-techstudio.web.app/ai/t/i236.md) `i236`
- [색 바꾸기 · 팔레트 교체 (2D)](https://ai-techstudio.web.app/ai/t/i296.md) `i296`
- [피격 흰 번쩍](https://ai-techstudio.web.app/ai/t/i201.md) `i201`
- [스프라이트 녹기 · 나타나기 (잡음 디졸브 2D)](https://ai-techstudio.web.app/ai/t/i295.md) `i295`
