# AI 꾸러미 — 지형 조각 캐시 (청크 · LRU) — Chunked tile cache
> 큰 지도를 512×256 조각 캔버스로 미리 구워 두고 카메라에 걸친 조각만 붙여 그리며, 오래 안 쓴 조각부터 버려 메모리를 지킨다.  
> 견본: https://ai-techstudio.web.app/#t/i78

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

## 주문서

### 만들어 줘: 지형 조각 캐시 (청크 · LRU) — Chunked tile cache

#### 1. 목표
넓은 등각(아이소) 타일 지도를 조각 캐시로 그려 줘 — 지도를 512×256 조각으로 구워 두고, 화면에 걸친 조각만 붙이고, 넘치면 가장 오래 안 쓴 조각부터 버린다. 지도는 잔디 · 모래 · 물이 있는 섬.

#### 2. 핵심 기술 용어
- **Chunked tile cache** — 지도를 조각으로 나눠 구워 두는 캐시
- **LRU eviction (least recently used)** — 가장 오래 안 쓴 것부터 버리기
- **Prefetch (idle baking)** — 쉴 때 둘레 조각을 미리 굽기
- **Frame cycling (water 4 frames)** — 물 조각만 4장 구워 돌려 그리기

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

#### 4. 조건
- 조각 크기는 고정(예: 512×256), 열쇠는 조각 칸 번호(cy·GX + cx)
- 화면에 보이는 조각은 버리지 않기 — LRU 는 보이지 않는 것 중에서 가장 오래된 것
- 쉴 때 미리 굽기는 한 번에 한 조각씩 (프레임이 튀지 않게)
- 굽기 · 버리기 · 캐시 크기 수를 화면에 보여 주기

#### 5. 완성 기준 (이게 보이면 성공)
- 카메라를 움직이면 새로 걸친 조각만 구워지고(흰 번쩍), 나머지는 붙이기만 한다
- 「캐시 크기」 슬라이더를 줄이면 버림(LRU) 수가 늘고, 다시 돌아간 곳은 다시 굽는다
- 멈춰 있으면 둘레 조각이 미리 구워져, 그쪽으로 움직일 때 굽기가 없다
- 폰에서 스크롤 중에도 60fps

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

## 원리
- 타일 수천 개를 프레임마다 하나씩 그리면 느리다. 지도를 일정한 조각(견본 512×256)으로 나눠 조각마다 캔버스 한 장에 미리 굽는다.
- 화면에는 카메라 네모에 걸친 조각 몇 장만 drawImage 로 붙인다 — 프레임마다 그리는 일은 조각 수만큼.
- 조각 캔버스가 쌓이면 메모리가 넘치니, 조각마다 마지막으로 쓴 때(used)를 적어 두고 상한(견본 12장)을 넘으면 가장 오래된 것부터 버린다(LRU).
- 카메라가 멈춰 있을 때는 0.2초마다 둘레 조각을 하나씩 미리 굽는다. 물이 있는 조각만 4장을 구워 돌려 그려 물결이 흐르게 한다.

## 핵심 코드 — 보이는 조각 굽기 · 쉴 때 미리 굽기 · LRU 버리기
(발췌: demos/demosEpic.ts makeI78() 의 ensure · 버리기를 정리)
```ts
const CW = 512, CH = 256, CAP = 12;
interface Chunk { key: number; cv: HTMLCanvasElement[]; used: number }
const cache = new Map<number, Chunk>();

function ensure(cx: number, cy: number, t: number, pre: boolean) {
  const key = cy * GX + cx;
  let ch = cache.get(key);
  if (!ch) {
    const frames = hasWater[key] ? 4 : 1; // 물 조각만 4장
    ch = { key, cv: Array.from({ length: frames }, (_, f) => bakeChunk(cx, cy, f)), used: t };
    cache.set(key, ch);
  }
  if (!pre) ch.used = t; // 미리 굽기는 「썼다」로 치지 않는다
}

function frame(t: number, cam: { x: number; y: number; w: number; h: number }, resting: boolean) {
  const vis = new Set<number>();
  const x0 = Math.floor((cam.x - cam.w / 2) / CW), x1 = Math.floor((cam.x + cam.w / 2) / CW);
  const y0 = Math.floor((cam.y - cam.h / 2) / CH), y1 = Math.floor((cam.y + cam.h / 2) / CH);
  for (let cy = y0; cy <= y1; cy++)
    for (let cx = x0; cx <= x1; cx++) { vis.add(cy * GX + cx); ensure(cx, cy, t, false); }
  // 쉴 때: 둘레 한 칸 중 아직 없는 조각 하나만 (0.2초마다)
  if (resting && t - lastPre > 0.2) prefetchOne(x0 - 1, x1 + 1, y0 - 1, y1 + 1, t);
  // 넘치면 보이지 않는 것 중 가장 오래 안 쓴 것부터
  while (cache.size > CAP) {
    let old: Chunk | null = null;
    for (const c of cache.values()) if (!vis.has(c.key) && (!old || c.used < old.used)) old = c;
    if (!old) break;
    cache.delete(old.key);
  }
  const wf = Math.floor(t * 4) % 4; // 물 4장 돌리기
  for (const key of vis) {
    const ch = cache.get(key)!;
    const cx = key % GX, cy = Math.floor(key / GX);
    g.drawImage(ch.cv[ch.cv.length > 1 ? wf : 0]!, cx * CW - (cam.x - cam.w / 2), cy * CH - (cam.y - cam.h / 2), CW, CH);
  }
}
```

## 흔한 실수 · 확인 목록
- [ ] **보이는 조각까지 LRU 로 버리면 깜빡이며 매 프레임 다시 굽는다** — 버릴 후보에서 지금 보이는 조각(vis)을 뺀다. 상한은 「보이는 조각 수 + 둘레」보다 크게.
- [ ] **미리 굽기를 한 번에 여러 장 하면 멈춘 순간 프레임이 튄다** — 한 번에 한 조각, 0.2초 간격으로.
- [ ] **미리 구운 조각에 used 를 적으면 진짜 쓰는 조각이 먼저 버려진다** — 미리 굽기는 used 를 갱신하지 않는다 (견본 ensure 의 pre).
- [ ] **조각을 붙일 때 이음새에 실선이 보인다** — 조각 경계에 걸친 타일도 양쪽 조각에 모두 그리고(경계 상자 검사), 놓을 자리는 정수로 맞춘다.

## 완성 기준 체크리스트
- [ ] 카메라를 움직이면 새로 걸친 조각만 구워지고(흰 번쩍), 나머지는 붙이기만 한다
- [ ] 「캐시 크기」 슬라이더를 줄이면 버림(LRU) 수가 늘고, 다시 돌아간 곳은 다시 굽는다
- [ ] 멈춰 있으면 둘레 조각이 미리 구워져, 그쪽으로 움직일 때 굽기가 없다
- [ ] 폰에서 스크롤 중에도 60fps

## 이 기술 정보
- id: `i78` · 분류: 게임 시스템 · AI › 플랫폼 · 성능 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 굽기는 조각이 처음 보일 때만. 메모리는 조각 크기 × 상한 (견본은 해상도 1/4 로 128×64 × 최대 24장). 물 조각은 4배.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i78
- 쓰면 좋을 때: 지도가 화면보다 몇 배 넓고 타일 그리기가 무거울 때 / 폰에서 스크롤할 때 프레임이 떨어질 때
- 쓰지 말 때: 한 화면에 다 들어가는 작은 판 — 통째로 한 장 굽기면 충분 / 타일이 매 프레임 바뀌는 지도 — 조각을 계속 다시 구워야 해서 이득이 없다. 바뀌는 것만 위에 따로 그린다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeI78 — `src/demos/demosEpic.ts:1788`
```ts
function makeI78() {
  const NT = 48;
  const HW = 32;
  const HH = 16;
  const CW = 512;
  const CH = 256;
  const WW = NT * HW * 2; // 3072
  const WH = NT * HH * 2; // 1536
  const GX = WW / CW;
  const GY = WH / CH;
  const RES = 0.25; // 조각 캔버스 해상도 (세상 화소의 1/4)
  const CAP = 12;
  const ter = new Uint8Array(NT * NT);
  for (let j = 0; j < NT; j++)
    for (let i = 0; i < NT; i++) {
      const v = fbm(i * 0.09, j * 0.09, 17);
      ter[j * NT + i] = v < 0.4 ? 0 : v < 0.45 ? 1 : v < 0.66 ? 2 : 3;
    }
  // 세상 좌표: 칸 (i,j) 의 위 꼭짓점 = (WW/2 + (i-j)HW, (i+j)HH)
  const hasWater = new Uint8Array(GX * GY);
  const inMap = new Uint8Array(GX * GY);
  for (let cy = 0; cy < GY; cy++)
    for (let cx = 0; cx < GX; cx++) {
      // 조각 사각형에 마름모 지도가 걸치나 (네 꼭짓점 + 가운데 검사로 충분)
      let any = false;
      let water = false;
      for (let sy = 0; sy <= 4; sy++)
        for (let sx = 0; sx <= 4; sx++) {
          const X = cx * CW + (sx / 4) * CW - WW / 2;
          const Y = cy * CH + (sy / 4) * CH;
          const i = Math.floor((Y / HH + X / HW) / 2);
          const j = Math.floor((Y / HH - X / HW) / 2);
          if (i < 0 || j < 0 || i >= NT || j >= NT) continue;
          any = true;
          if (ter[j * NT + i] === 0) water = true;
        }
      inMap[cy * GX + cx] = any ? 1 : 0;
      hasWater[cy * GX + cx] = water ? 1 : 0;
    }
  const TC = ['#4ea8dc', '#e6d394', '#82c65a', '#5aa548'];
  const bakeChunk = (cx: number, cy: number, frame: number): HTMLCanvasElement => {
    const c = mkCanvas(CW * RES, CH * RES);
    const q = c2(c);
    q.setTransform(RES, 0, 0, RES, -cx * CW * RES, -cy * CH * RES);
    const i0 = 0;
    for (let j = 0; j < NT; j++)
      for (let i = i0; i < NT; i++) {
        const x = WW / 2 + (i - j) * HW;
        const y = (i + j) * HH;
        if (x + HW < cx * CW || x - HW > (cx + 1) * CW || y + HH * 2 < cy * CH || y > (cy + 1) * CH) continue;
        const tt = ter[j * NT + i]!;
        q.beginPath();
        q.moveTo(x, y);
        q.lineTo(x + HW, y + HH);
        q.lineTo(x, y + HH * 2);
        q.lineTo(x - HW, y + HH);
        q.closePath();
        q.fillStyle = tt === 0 && (i + j + frame) % 4 === 0 ? '#6cc0ec' : TC[tt]!;
        q.fill();
        if (tt === 0) {
          q.strokeStyle = 'rgba(255,255,255,.7)';
          q.lineWidth = 4;
          const o = (frame / 4) * HW;
          q.beginPath();
          q.moveTo(x - HW * 0.4 + o * 0.5, y + HH);
          q.lineTo(x + o * 0.5, y + HH);
          q.stroke();
        } else if (tt === 3 && hash2(i, j, 2) < 0.35) {
          q.fillStyle = '#3f7f3a';
          q.beginPath();
          q.arc(x, y + HH * 0.6, HW * 0.35, 0, Math.PI * 2);
          q.fill();
        }
      }
    return c;
  };
  // 전체 지도 흐린 밑그림 (설명용)
  const whole = mkCanvas(WW / 16, WH / 16);
  {
    const q = c2(whole);
    q.setTransform(1 / 16, 0, 0, 1 / 16, 0, 0);
    for (let j = 0; j < NT; j++)
      for (let i = 0; i < NT; i++) {
        const x = WW / 2 + (i - j) * HW;
        const y = (i + j) * HH;
        q.beginPath();
        q.moveTo(x, y);
        q.lineTo(x + HW + 8, y + HH);
        q.lineTo(x, y + HH * 2 + 8);
        q.lineTo(x - HW - 8, y + HH);
        q.closePath();
        q.fillStyle = TC[ter[j * NT + i]!]!;
        q.fill();
      }
  }
  interface Ch {
    key: number;
    cv: HTMLCanvasElement[];
    used: number;
    baked: number;
    pre: boolean;
  }
  const cache = new Map<number, Ch>();
  const fading: { cx: number; cy: number; at: number }[] = [];
  let builds = 0;
  let evicts = 0;
  let prefetches = 0;
  // 카메라: 목표로 미끄러져 갔다가 잠깐 쉼 (쉴 때 둘레를 미리 굽기)
  const cam = { x: WW / 2, y: WH / 2, fx: WW / 2, fy: WH / 2, tx: WW / 2, ty: WH / 2, t0: 0, rest: 0 };
  const CAMW = 900;
  const CAMH = 560;
  const r = rng(5);
  const pickTarget = (): void => {
    for (let k = 0; k < 20; k++) {
      const i = 6 + r() * (NT - 12);
      const j = 6 + r() * (NT - 12);
      const x = WW / 2 + (i - j) * HW;
      const y = (i + j) * HH;
      if (Math.hypot(x - cam.x, y - cam.y) > 700) {
        cam.tx = x;
        cam.ty = y;
        return;
      }
    }
  };
  let cap = CAP;
  let lastPre = 0;
  let lastT = 0;
  const ensure = (cx: number, cy: number, t: number, pre: boolean): void => {
    const key = cy * GX + cx;
    let ch = cache.get(key);
    if (!ch) {
      const nf = hasWater[key] ? 4 : 1;
      ch = { key, cv: Array.from({ length: nf }, (_, f) => bakeChunk(cx, cy, f)), used: t, baked: t, pre };
      cache.set(key, ch);
      builds++;
      if (pre) prefetches++;
    }
    if (!pre) ch.used = t;
  };
  const controls: Control[] = [{ type: 'range', label: '캐시 크기 (조각 수)', min: 6, max: 24, step: 1, value: cap, on: (v) => (cap = v) }];
  return {
    controls,
    draw(g: G, w: number, h: number, t: number) {
      reset(g);
      const u = scaleOf(w, h);
      if (t < lastT) cam.t0 = t;
      lastT = t;
      // 카메라 움직임
      const MOVE = 2.6;
      const ph = t - cam.t0;
      let moving = true;
      if (ph < MOVE) {
        const k = smooth(0, 1, ph / MOVE);
        cam.x = lerp(cam.fx, cam.tx, k);
        cam.y = lerp(cam.fy, cam.ty, k);
      } else if (ph < MOVE + 1.6) moving = false;
      else {
        cam.fx = cam.x;
        cam.fy = cam.y;
        cam.t0 = t;
        pickTarget();
      }
      // 화면에 걸친 조각 = 쓰는 중
      const vx0 = Math.floor((cam.x - CAMW / 2) / CW);
      const vx1 = Math.floor((cam.x + CAMW / 2) / CW);
      const vy0 = Math.floor((cam.y - CAMH / 2) / CH);
      const vy1 = Math.floor((cam.y + CAMH / 2) / CH);
      const vis = new Set<number>();
      for (let cy = vy0; cy <= vy1; cy++)
        for (let cx = vx0; cx <= vx1; cx++) {
          if (cx < 0 || cy < 0 || cx >= GX || cy >= GY || !inMap[cy * GX + cx]) continue;
          vis.add(cy * GX + cx);
          ensure(cx, cy, t, false);
        }
      // 쉴 때: 한 칸 둘레를 하나씩 미리 굽기
      if (!moving && t - lastPre > 0.2) {
        for (let cy = vy0 - 1; cy <= vy1 + 1; cy++) {
          let done = false;
          for (let cx = vx0 - 1; cx <= vx1 + 1; cx++) {
            if (cx < 0 || cy < 0 || cx >= GX || cy >= GY || !inMap[cy * GX + cx] || cache.has(cy * GX + cx)) continue;
            ensure(cx, cy, t, true);
            lastPre = t;
            done = true;
            break;
          }
          if (done) break;
        }
      }
      // 넘치면 가장 오래 안 쓴 것부터 버림 (LRU)
      while (cache.size > cap) {
        let old: Ch | null = null;
        for (const c of cache.values()) if (!vis.has(c.key) && (!old || c.used < old.used)) old = c;
        if (!old) break;
        cache.delete(old.key);
        fading.push({ cx: old.key % GX, cy: Math.floor(old.key / GX), at: t });
        evicts++;
      }
      bg(g, w, h, '#1e2756', '#0b1030');
      // ── 왼쪽: 전체 지도 (조각 격자)
      const ovW = w * 0.6;
      const s = Math.min((ovW - 16 * u) / WW, (h - 44 * u) / WH);
      const ox = 8 * u;
      const oy = 26 * u + (h - 44 * u - WH * s) / 2;
      const wf = Math.floor(t * 4) % 4;
      g.globalAlpha = 0.22;
      g.drawImage(whole, ox, oy, WW * s, WH * s);
      g.globalAlpha = 1;
      for (let cy = 0; cy < GY; cy++)
        for (let cx = 0; cx < GX; cx++) {
          const key = cy * GX + cx;
          const x = ox + cx * CW * s;
          const y = oy + cy * CH * s;
          const cw = CW * s;
          const chh = CH * s;
          const ch = cache.get(key);
          if (ch) {
            g.drawImage(ch.cv[ch.cv.length > 1 ? wf : 0]!, x, y, cw, chh);
            const age = t - ch.baked;
            if (age < 0.5) {
              g.fillStyle = `rgba(255,255,255,${(0.8 * (1 - age / 0.5)).toFixed(2)})`;
              g.fillRect(x, y, cw, chh);
            }
            g.strokeStyle = vis.has(key) ? '#7dff9a' : ch.pre ? '#6fc8ff' : 'rgba(125,255,154,.55)';
            g.lineWidth = (vis.has(key) ? 2 : 1.2) * u;
            g.strokeRect(x + 1, y + 1, cw - 2, chh - 2);
            if (ch.cv.length > 1) txt(g, '×4', x + cw - 2.5 * u, y + 5 * u, 5.5 * u, '#ffffff', 'right', F, 900);
          } else {
            g.strokeStyle = 'rgba(255,255,255,.12)';
            g.lineWidth = 1;
            g.strokeRect(x + 0.5, y + 0.5, cw - 1, chh - 1);
          }
        }
      for (let k = fading.length - 1; k >= 0; k--) {
        const f = fading[k]!;
        const a = 1 - (t - f.at) / 0.8;
        if (a <= 0) {
          fading.splice(k, 1);
          continue;
        }
        g.fillStyle = `rgba(255,90,110,${(a * 0.55).toFixed(2)})`;
        g.fillRect(ox + f.cx * CW * s, oy + f.cy * CH * s, CW * s, CH * s);
      }
      // 카메라 네모
      g.strokeStyle = '#ffd23f';
      g.lineWidth = 2 * u;
      g.setLineDash([4 * u, 3 * u]);
      g.strokeRect(ox + (cam.x - CAMW / 2) * s, oy + (cam.y - CAMH / 2) * s, CAMW * s, CAMH * s);
      g.setLineDash([]);
      txt(g, moving ? '카메라 이동' : '쉬는 중 → 둘레 미리 굽기', ox, 13 * u, 8 * u, moving ? '#ffd23f' : '#6fc8ff', 'left', F, 800);
      // ── 오른쪽: 카메라 화면 = 구워 둔 조각 몇 장을 붙여 그리기만
      const vx = ovW + 4 * u;
      const vw = w - vx - 8 * u;
      const vh = vw * (CAMH / CAMW);
      const vy = 22 * u;
      const vs = vw / CAMW;
      g.save();
      rr(g, vx, vy, vw, vh, 5 * u);
      g.clip();
      g.fillStyle = '#0b1030';
      g.fillRect(vx, vy, vw, vh);
      for (const key of vis) {
        const ch = cache.get(key);
        if (!ch) continue;
        const cx = key % GX;
        const cy = Math.floor(key / GX);
        g.drawImage(ch.cv[ch.cv.length > 1 ? wf : 0]!, vx + (cx * CW - (cam.x - CAMW / 2)) * vs, vy + (cy * CH - (cam.y - CAMH / 2)) * vs, CW * vs + 0.5, CH * vs + 0.5);
        g.strokeStyle = 'rgba(125,255,154,.6)';
        g.lineWidth = 1;
        g.strokeRect(vx + (cx * CW - (cam.x - CAMW / 2)) * vs, vy + (cy * CH - (cam.y - CAMH / 2)) * vs, CW * vs, CH * vs);
      }
      g.restore();
      g.strokeStyle = '#ffd23f';
      g.lineWidth = 1.5 * u;
      rr(g, vx, vy, vw, vh, 5 * u);
      g.stroke();
      txt(g, `화면 = 조각 ${vis.size}장 붙이기`, vx + vw / 2, 12 * u, 7.5 * u, '#fff', 'center', F, 800);
      // 계수 + LRU 줄
      let y = vy + vh + 12 * u;
      const line = (label: string, v: string, c: string): void => {
        txt(g, label, vx, y, 7.5 * u, '#aab4e8', 'left', F, 700);
        txt(g, v, vx + vw, y, 8.5 * u, c, 'right', F, 800);
        y += 12 * u;
      };
      line('구움', String(builds), '#ffffff');
      line('캐시', `${cache.size} / ${cap}`, '#7dff9a');
      line('버림 (LRU)', String(evicts), '#ff7a8a');
      if (h > 200) line('미리 굽기', String(prefetches), '#6fc8ff');
      // 오래된 순서 띠
      const list = [...cache.values()].sort((a, b) => a.used - b.used);
      const bw = vw / Math.max(cap, 1);
      for (let k = 0; k < list.length && y + 8 * u < h; k++) {
        const c = list[k]!;
        g.fillStyle = vis.has(c.key) ? '#7dff9a' : `hsl(${lerp(0, 120, clamp01(1 - (t - c.used) / 12))},70%,55%)`;
        rr(g, vx + k * bw + 0.5, y - 3 * u, bw - 1, 7 * u, 2 * u);
        g.fill();
      }
      if (y + 8 * u < h) txt(g, '오래됨 ← → 최근', vx + vw / 2, y + 10 * u, 6.5 * u, '#8a94c8');
    },
  };
}
```

### i78 견본 항목 — `src/demos/demosEpic.ts:2116`
```ts
  i78: {
    kind: '2d',
    caption: '큰 지도를 512×256 조각으로 구워 두고 카메라에 걸친 것만 붙여 그림 — 흰 번쩍 = 굽기, 빨강 = 오래된 것 버림, ×4 = 물 4장',
    make: makeI78,
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [화면 밖 캔버스 (OffscreenCanvas)](https://ai-techstudio.web.app/ai/t/i32.md) `i32`
- 다음에 해 볼 기술: [공간 나누기 (쿼드트리)](https://ai-techstudio.web.app/ai/t/i263.md) `i263`
- 참고 문서: [Wikipedia — Cache replacement policies (LRU)](https://en.wikipedia.org/wiki/Cache_replacement_policies)
