# AI 꾸러미 — 팔레트 색 순환 (그림은 그대로, 색만 흐름) — Palette color cycling
> 그림은 색 번호로만 그려 두고 팔레트의 몇 칸만 매 프레임 돌려, 그림을 다시 그리지 않고도 폭포 · 물결 · 반짝이가 흐르게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i236

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

## 주문서

### 만들어 줘: 팔레트 색 순환 (그림은 그대로, 색만 흐름) — Palette color cycling

#### 1. 목표
폭포 · 호수가 있는 배경을 팔레트 색 순환으로 움직여 줘 — 그림은 색 번호로 한 번만 그리고, 폭포 · 물결 칸의 팔레트만 매 프레임 돌린다. 그림체는 레트로 도트 노을.

#### 2. 핵심 기술 용어
- **Palette color cycling** — 팔레트 색 순환 — 색 표만 돌려 움직임
- **Indexed color image** — 색 번호(0~255)로 그린 그림
- **Palette rotation (modulo)** — 나머지 연산으로 색 칸 돌리기
- **Uint32Array pixel buffer** — 화소를 32비트 한 덩이로 빠르게 쓰기

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

#### 4. 조건
- 그림은 색 번호로 한 번만 만들고, 매 프레임은 팔레트만 돌리기
- 도는 칸 묶음마다 길이(16 · 8)와 빠르기를 따로
- 음수 나머지 주의 — ((i − o) % n + n) % n
- 키울 때 imageSmoothingEnabled = false (번호 경계가 섞이지 않게)
- 도는 팔레트 띠를 아래에 보여 주기

#### 5. 완성 기준 (이게 보이면 성공)
- 색 순환을 켜면 폭포 흰 줄이 흐르고 호수 물결 · 해 반사가 반짝인다
- 끄면 같은 그림이 멈춘 그림이 된다 — 그림 자체는 바뀌지 않음
- 아래 팔레트 띠의 칸들이 옆으로 도는 것이 보인다
- 빠르기 0 ~ 4 슬라이더

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

## 원리
- 그림은 화소마다 색이 아니라 「색 번호」(Uint8Array)를 가진다. 실제 색은 팔레트 표(256칸)에서 찾는다.
- 폭포는 32~47번 16칸에 흰 줄이 섞인 색을, 그림에는 세로로 (y − 열마다 다른 값) mod 16 번호를 칠해 둔다.
- 매 프레임 그 16칸만 (i − 밀림) mod 16 으로 돌려 보여 줄 표를 만든다 — 번호는 그대로인데 흰 줄이 아래로 흐른다.
- 칸 묶음마다 빠르기를 다르게 (폭포 1 · 호수 0.5 · 해 반사 0.7). 화소는 Uint32Array 로 한 번에 써서 putImageData.

## 핵심 코드 — 색 번호 그림 + 팔레트 칸만 돌리기
(발췌: demos/demosLook2.ts makeCycle() draw() 를 정리)
```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 로 정수 배.

## 완성 기준 체크리스트
- [ ] 색 순환을 켜면 폭포 흰 줄이 흐르고 호수 물결 · 해 반사가 반짝인다
- [ ] 끄면 같은 그림이 멈춘 그림이 된다 — 그림 자체는 바뀌지 않음
- [ ] 아래 팔레트 띠의 칸들이 옆으로 도는 것이 보인다
- [ ] 빠르기 0 ~ 4 슬라이더

## 이 기술 정보
- id: `i236` · 분류: 2D · 화면 › 2D 그림 효과 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 192×120 = 2.3만 화소를 표 찾기로 칠하는 정도. 그림을 다시 그리지 않으니 폰도 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i236
- 쓰면 좋을 때: 물 · 불 · 빛이 흐르는 레트로 배경을 가볍게 움직일 때 / 주기 · 나머지를 눈으로 보여 주고 싶을 때
- 쓰지 말 때: 사진 · 그러데이션이 많은 그림 — 색 번호로 줄이기 어렵다 / 물체가 실제로 움직여야 할 때 — 색 순환은 「흐르는 느낌」만. 대신 스프라이트 이동

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeCycle — `src/demos/demosLook2.ts:2962`
```ts
function makeCycle() {
  const W = 192;
  const H = 120;
  const idx = new Uint8Array(W * H);
  const pal = new Uint32Array(256);
  const rgb = (r: number, g: number, b: number) => (255 << 24) | (b << 16) | (g << 8) | r;
  // 하늘 0..15
  for (let i = 0; i < 16; i++) {
    const k = i / 15;
    pal[i] = rgb(Math.round(lerp(40, 255, k ** 1.4)), Math.round(lerp(30, 170, k)), Math.round(lerp(90, 120, k)));
  }
  // 바위 · 숲 16..27
  const rock = [
    [34, 28, 50],
    [52, 40, 70],
    [74, 56, 88],
    [98, 74, 104],
    [20, 50, 40],
    [30, 74, 52],
    [46, 100, 62],
    [70, 130, 70],
  ];
  rock.forEach(([r, g, b], i) => (pal[16 + i] = rgb(r!, g!, b!)));
  // 폭포 32..47 (흰 줄이 흐르게)
  const fall = [255, 220, 170, 120, 90, 70, 60, 70, 90, 120, 150, 110, 80, 70, 100, 180];
  fall.forEach((v, i) => (pal[32 + i] = rgb(Math.round(v * 0.75), Math.round(v * 0.92), Math.min(255, v + 40))));
  // 호수 물결 48..63
  for (let i = 0; i < 16; i++) {
    const v = 0.5 + 0.5 * Math.cos((i / 16) * TAU);
    pal[48 + i] = rgb(Math.round(lerp(20, 120, v ** 3)), Math.round(lerp(50, 150, v ** 2)), Math.round(lerp(110, 210, v)));
  }
  // 해 반사 64..71
  for (let i = 0; i < 8; i++) {
    const v = i < 2 ? 1 : 0.25;
    pal[64 + i] = rgb(Math.round(lerp(60, 255, v)), Math.round(lerp(80, 220, v)), Math.round(lerp(140, 150, v)));
  }
  const r0 = rng(9);
  const bayer = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];
  for (let y = 0; y < H; y++)
    for (let x = 0; x < W; x++) {
      const i = y * W + x;
      // 하늘 (디더 띠)
      const sk = (y / 62) * 15;
      const b = (bayer[(y % 4) * 4 + (x % 4)]! + 0.5) / 16;
      idx[i] = Math.min(15, Math.max(0, Math.floor(sk + b - 0.5)));
    }
  // 해
  for (let y = 0; y < H; y++)
    for (let x = 0; x < W; x++) if ((x - 140) ** 2 + (y - 44) ** 2 < 120) idx[y * W + x] = 15;
  // 먼 산
  for (let x = 0; x < W; x++) {
    const top = 46 + Math.round(8 * Math.sin(x * 0.05) + 5 * Math.sin(x * 0.13 + 1));
    for (let y = top; y < 70; y++) idx[y * W + x] = 17 + (y - top < 2 ? 1 : 0);
  }
  // 호수 (아래)
  for (let y = 70; y < H; y++)
    for (let x = 0; x < W; x++) {
      const d = y - 70;
      const band = Math.floor(d * 0.9 + 3 * Math.sin(x * 0.12 + d * 0.3) + r0() * 1.2);
      idx[y * W + x] = 48 + (((band % 16) + 16) % 16);
      // 해 반사 기둥
      if (Math.abs(x - 140) < 10 - d * 0.08 && (x + y * 3) % 5 < 2) idx[y * W + x] = 64 + ((d + (x % 3)) % 8);
    }
  // 절벽 · 폭포
  for (let y = 22; y < 76; y++)
    for (let x = 0; x < W; x++) {
      const leftEdge = 64 + Math.round(4 * Math.sin(y * 0.3));
      const rightEdge = 92 + Math.round(3 * Math.sin(y * 0.25 + 2));
      const cliffL = x < 30 + y * 0.6 && x >= 0 && y > 30 - x * 0.2;
      if (x >= leftEdge - 18 && x < leftEdge) idx[y * W + x] = 16 + ((x + y) % 7 < 2 ? 2 : 1);
      else if (x >= rightEdge && x < rightEdge + 22) idx[y * W + x] = 16 + ((x * 3 + y) % 9 < 2 ? 3 : 1);
      else if (x >= leftEdge && x < rightEdge) {
        const col = Math.floor(r0() * 3) + ((x * 7) % 5);
        idx[y * W + x] = 32 + (((y - col) % 16) + 16) % 16;
      } else if (cliffL) idx[y * W + x] = 20 + ((x + y) % 5 === 0 ? 1 : 0);
    }
  // 물보라 (호수 위 폭포 발치)
  for (let y = 70; y < 82; y++)
    for (let x = 52; x < 106; x++) {
      const k = 1 - Math.abs(x - 78) / 28 - (y - 70) / 14;
      if (k > 0.2 && r0() < k) idx[y * W + x] = 32 + ((y * 2 + x) % 16);
    }
  // 앞 나무 실루엣
  for (const tx of [12, 30, 170, 184]) {
    const th = 34 + (tx % 7) * 3;
    for (let y = H - th; y < H; y++) {
      const wdt = Math.round(((y - (H - th)) / th) * 10) + 1;
      for (let x = tx - wdt; x <= tx + wdt; x++) if (x >= 0 && x < W) idx[y * W + x] = 20 + ((x + y) % 4 === 0 ? 1 : 0);
    }
  }
  const [cv, cg] = canvas(W, H);
  const img = cg.createImageData(W, H);
  const buf = new Uint32Array(img.data.buffer);
  let on = true;
  let speed = 1;
  let showPal = true;
  let off = 0;
  const shown = new Uint32Array(256);
  return {
    draw(g: CanvasRenderingContext2D, w: number, h: number, _t: number, dt: number) {
      if (on) off += dt * speed * 12;
      const o1 = Math.floor(off);
      const o2 = Math.floor(off * 0.5);
      const o3 = Math.floor(off * 0.7);
      for (let i = 0; i < 256; i++) shown[i] = pal[i]!;
      for (let i = 0; i < 16; i++) shown[32 + i] = pal[32 + ((((i - o1) % 16) + 16) % 16)]!;
      for (let i = 0; i < 16; i++) shown[48 + i] = pal[48 + ((((i - o2) % 16) + 16) % 16)]!;
      for (let i = 0; i < 8; i++) shown[64 + i] = pal[64 + ((((i - o3) % 8) + 8) % 8)]!;
      for (let i = 0; i < W * H; i++) buf[i] = shown[idx[i]!]!;
      cg.putImageData(img, 0, 0);
      g.globalAlpha = 1;
      g.globalCompositeOperation = 'source-over';
      g.fillStyle = '#000';
      g.fillRect(0, 0, w, h);
      const ph = showPal ? Math.max(14, h * 0.12) : 0;
      const sc = Math.min(w / W, (h - ph) / H);
      const dw = W * sc;
      const dh = H * sc;
      g.imageSmoothingEnabled = false;
      g.drawImage(cv, (w - dw) / 2, (h - ph - dh) / 2, dw, dh);
      if (showPal) {
        const y0 = h - ph;
        const n = 40;
        const cw = w / n;
        for (let i = 0; i < n; i++) {
          const p = shown[32 + i]!;
          g.fillStyle = `rgb(${p & 255},${(p >> 8) & 255},${(p >> 16) & 255})`;
          g.fillRect(i * cw, y0 + 3, cw + 0.5, ph - 6);
        }
        g.strokeStyle = '#ffd23f';
        g.lineWidth = 2;
        g.strokeRect(1, y0 + 2, 16 * cw - 2, ph - 4);
        g.strokeStyle = '#7fe0ff';
        g.strokeRect(16 * cw + 1, y0 + 2, 16 * cw - 2, ph - 4);
        g.font = `800 ${Math.max(9, ph * 0.42)}px ${F}`;
        g.textBaseline = 'middle';
        g.fillStyle = '#fff';
        g.shadowColor = 'rgba(0,0,0,0.9)';
        g.shadowBlur = 4;
        g.fillText('폭포 16색 →', 6, y0 + ph / 2);
        g.fillText('호수 16색 →', 16 * cw + 6, y0 + ph / 2);
        g.shadowBlur = 0;
      }
      g.imageSmoothingEnabled = true;
    },
    controls: [
      { type: 'toggle', label: '색 순환 (끄면 그냥 그림)', value: true, on: (v: boolean) => (on = v) },
      { type: 'range', label: '빠르기', min: 0, max: 4, step: 0.1, value: 1, on: (v: number) => (speed = v) },
      { type: 'toggle', label: '팔레트 띠 보기', value: true, on: (v: boolean) => (showPal = v) },
    ] as Control[],
  };
}
```

### i236 견본 항목 — `src/demos/demosLook2.ts:4520`
```ts
  i236: { kind: '2d', caption: '그림(색 번호)은 그대로, 팔레트 칸만 돌려서 폭포 · 호수 물결 · 햇빛 반짝이 흐름 (아래 띠가 도는 색)', make: () => makeCycle() }
```

## 관련 기술
- 먼저 알면 좋은 기술: [색 바꾸기 · 팔레트 교체 (2D)](https://ai-techstudio.web.app/ai/t/i296.md) `i296`
- 다음에 해 볼 기술: [잡음으로 그린 성운 (fbm)](https://ai-techstudio.web.app/ai/t/u54.md) `u54`
- 참고 문서: [Wikipedia — Indexed color](https://en.wikipedia.org/wiki/Indexed_color)
