# AI 꾸러미 — 아이소메트릭 (등각) 타일 — Isometric projection (2:1 dimetric tiles)
> 네모 격자를 45° 돌리고 세로를 반으로 눌러 비스듬한 화면으로 바꾸고, 뒤(x + y 작은 칸)부터 그려 겹침을 맞춘다.  
> 견본: https://ai-techstudio.web.app/#t/i257

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

## 주문서

### 만들어 줘: 아이소메트릭 (등각) 타일 — Isometric projection (2:1 dimetric tiles)

#### 1. 목표
작은 섬 마을을 아이소메트릭 타일로 그려 줘 — 네모 격자가 45° 돌고 눌리는 과정과, 뒤에서 앞으로 블록을 쌓는 순서가 보이게. 분위기는 밝은 블록 마을.

#### 2. 핵심 기술 용어
- **Isometric projection (2:1 dimetric tiles)** — 등각 타일 — 45° 돌리고 세로 ½
- **Painter's algorithm (depth sort by x + y)** — 뒤에서 앞으로 그리기 순서
- **Grid ↔ screen coordinate transform** — 격자 좌표 ↔ 화면 좌표 바꾸기

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

#### 4. 조건
- 그리기 순서는 깊이 x + y 오름차순, 같으면 칸 번호로 (순서가 프레임마다 흔들리지 않게)
- 면 셋 순서: 왼쪽 면 → 오른쪽 면 → 윗면
- 평면 → 등각으로 바뀌는 과정을 각도 · 세로 배율을 보간해 보여 주기
- 「그리는 순서 틀리게」 켬/끔으로 앞 → 뒤로 그리면 겹침이 틀어지는 모습을 비교

#### 5. 완성 기준 (이게 보이면 성공)
- 위에서 본 네모 격자가 45° 돌며 세로로 눌려 마름모 격자가 된다
- 블록이 뒤에서부터 차례로 떨어져 쌓이고, 앞 블록이 뒤 블록을 바르게 가린다
- 순서를 틀리게 하면 뒤 블록이 앞 블록 위에 그려져 겹침이 깨진다
- 그리는 순서 번호를 켜면 x + y 순서대로 번호가 붙어 있다

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

## 원리
- 격자 (x, y) 를 45° 돌리고 세로를 ½ 로 누르면 등각 화면: 화면 x = (x − y)·칸폭/2, 화면 y = (x + y)·칸높이/2.
- 높이 z 는 화면 y 를 위로 빼서 블록을 세운다 (견본: z·칸·0.62).
- 블록은 윗면 · 왼쪽 면 · 오른쪽 면 세 개의 사각형을 서로 다른 밝기로 칠한다.
- 앞의 블록이 뒤를 가려야 하므로 x + y 가 작은 칸(뒤)부터 큰 칸(앞) 순서로 그린다 (화가 알고리즘).

## 핵심 코드 — 격자 → 등각 화면 + 뒤에서 앞으로 블록 그리기
(발췌: demos/demosMapA.ts i257 draw() 를 정리)
```ts
// k: 0 = 위에서 본 네모, 1 = 등각 (견본은 이 값을 보간해 바뀌는 모습을 보여 준다)
const ang = (k * Math.PI) / 4, sy = 1 - 0.5 * k;
const P = (gx: number, gy: number, z = 0): [number, number] => {
  const a = (gx - N / 2) * S, b = (gy - N / 2) * S;
  const x = a * Math.cos(ang) - b * Math.sin(ang);
  const y = (a * Math.sin(ang) + b * Math.cos(ang)) * sy;   // 세로 ½
  return [cx + x, cy + y - z * S * 0.62 * k];                // 높이는 위로
};
const quad = (p: [number, number][], col: string) => {
  g.beginPath(); p.forEach((q, i) => (i ? g.lineTo(q[0], q[1]) : g.moveTo(q[0], q[1])));
  g.closePath(); g.fillStyle = col; g.fill();
};
// 깊이 = x + y. 작은 것(뒤)부터
const depth = (i: number) => (i % N) + Math.floor(i / N);
const order = [...Array(N * N).keys()].sort((a, b) => depth(a) - depth(b) || a - b);
for (const c of order) {
  const x = c % N, y = Math.floor(c / N), hz = hts[c];
  const [top, left, right] = COLS[kinds[c]];
  const A = P(x, y, hz), B = P(x + 1, y, hz), C = P(x + 1, y + 1, hz), D = P(x, y + 1, hz);
  const Cb = P(x + 1, y + 1, 0), Db = P(x, y + 1, 0), Bb = P(x + 1, y, 0);
  quad([D, C, Cb, Db], left);   // 앞 왼쪽 면
  quad([C, B, Bb, Cb], right);  // 앞 오른쪽 면
  quad([A, B, C, D], top);      // 윗면
}
```

## 흔한 실수 · 확인 목록
- [ ] **앞에서 뒤로 그리면 뒤 블록이 앞 블록을 덮는다** — 깊이 x + y 오름차순으로 그린다. 견본의 「그리는 순서 틀리게」 토글로 차이를 볼 수 있다.
- [ ] **같은 깊이끼리 순서가 정해지지 않으면 프레임마다 깜박인다** — 정렬에 칸 번호를 두 번째 열쇠로 넣는다.
- [ ] **면 세 개 밝기가 같으면 블록이 납작해 보인다** — 윗면 가장 밝게, 왼쪽 면 중간, 오른쪽 면 가장 어둡게 (견본 COLS 세 색).

## 완성 기준 체크리스트
- [ ] 위에서 본 네모 격자가 45° 돌며 세로로 눌려 마름모 격자가 된다
- [ ] 블록이 뒤에서부터 차례로 떨어져 쌓이고, 앞 블록이 뒤 블록을 바르게 가린다
- [ ] 순서를 틀리게 하면 뒤 블록이 앞 블록 위에 그려져 겹침이 깨진다
- [ ] 그리는 순서 번호를 켜면 x + y 순서대로 번호가 붙어 있다

## 이 기술 정보
- id: `i257` · 분류: 지도 · 길찾기 › 격자 · 좌표 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 7×7 = 49 블록 × 사각형 3개. 칸이 수천이면 정적인 바닥은 화면 밖 캔버스에 굽는다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i257
- 쓰면 좋을 때: 3D 없이 입체감 있는 판을 그리고 싶을 때 / 도시 · 농장 짓기처럼 칸이 많은 판 / 도트 그림 느낌의 블록 세계
- 쓰지 말 때: 카메라를 자유롭게 돌려야 할 때 — 대신 three.js 정사영 카메라 / 블록이 칸보다 크거나 여러 칸에 걸칠 때 — 단순 x + y 정렬로는 겹침이 틀어진다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i257 견본 항목 — `src/demos/demosMapA.ts:3240`
```ts
  i257: {
    kind: '2d',
    caption: '네모 격자를 45° 돌리고 세로를 반으로 눌러 = 등각 화면 — 블록은 뒤(x+y 작은 쪽)부터 앞으로 그려야 겹침이 맞아요',
    make() {
      const N = 7;
      type Kind = 0 | 1 | 2 | 3 | 4 | 5; // 물 · 모래 · 풀 · 돌 · 집 · 나무
      let kinds: Kind[] = [];
      let hts: number[] = [];
      let ct = 0;
      let speed = 1;
      let wrong = false;
      let showOrder = true;
      const gen = (): void => {
        const seed = Math.floor(Math.random() * 9999);
        kinds = [];
        hts = [];
        for (let y = 0; y < N; y++)
          for (let x = 0; x < N; x++) {
            const e = fbm(x * 0.32 + seed, y * 0.32, seed, 3) + (1 - Math.hypot(x - 3, y - 3) / 4.5) * 0.25;
            let k: Kind = e < 0.42 ? 0 : e < 0.48 ? 1 : e < 0.66 ? 2 : 3;
            const r = hash2(x, y, seed);
            if (k === 2 && r < 0.18) k = 4;
            else if (k === 2 && r < 0.42) k = 5;
            kinds.push(k);
            hts.push(k === 0 ? 0.35 : k === 1 ? 0.6 : k === 3 ? Math.min(2.3, 1.6 + (e - 0.66) * 4) : 1);
          }
      };
      gen();
      const TOP = 0.8;
      const MORPH = 1.2;
      const DROP = 0.055;
      const HOLD = 2.2;
      const total = (): number => TOP + MORPH + N * N * DROP + 0.4 + HOLD;
      const COLS: Record<number, [string, string, string]> = {
        0: ['#58b4ea', '#3a88c0', '#2f72a6'],
        1: ['#f0dba2', '#d1b97c', '#b99f62'],
        2: ['#93d36c', '#62a347', '#4f8a39'],
        3: ['#b2b0bc', '#86848f', '#6e6c78'],
        4: ['#93d36c', '#62a347', '#4f8a39'],
        5: ['#93d36c', '#62a347', '#4f8a39'],
      };
      return {
        draw(g, w, h, t, dt) {
          reset(g);
          ct += Math.min(dt, 0.1) * speed;
          if (ct > total()) {
            ct = 0;
            gen();
          }
          const u = ui(scaleOf(w, h));
          const bg = g.createLinearGradient(0, 0, 0, h);
          bg.addColorStop(0, '#24345a');
          bg.addColorStop(1, '#121a30');
          g.fillStyle = bg;
          g.fillRect(0, 0, w, h);
          const k = ease((ct - TOP) / MORPH);
          const S = Math.min(w / (N * 1.55), (h * 0.9) / (N * (1 - 0.3 * k) + 1.6 * k)) * 0.98;
          const cx = w / 2;
          const cy = h * (0.5 + 0.06 * k);
          const ang = (k * Math.PI) / 4;
          const sy = 1 - 0.5 * k;
          const P = (gx: number, gy: number, z = 0): P2 => {
            const a = (gx - N / 2) * S;
            const b = (gy - N / 2) * S;
            const x = a * Math.cos(ang) - b * Math.sin(ang);
            const y = (a * Math.sin(ang) + b * Math.cos(ang)) * sy;
            return [cx + x, cy + y - z * S * 0.62 * k];
          };
          const quad = (p: P2[], col: string): void => {
            g.beginPath();
            p.forEach((q, i) => (i ? g.lineTo(q[0], q[1]) : g.moveTo(q[0], q[1])));
            g.closePath();
            g.fillStyle = col;
            g.fill();
          };
          // 바닥 격자 (돌아가는 판)
          quad([P(0, 0), P(N, 0), P(N, N), P(0, N)], 'rgba(255,255,255,0.05)');
          g.strokeStyle = 'rgba(160,190,255,0.35)';
          g.lineWidth = 1;
          for (let i = 0; i <= N; i++) {
            const a = P(i, 0);
            const b = P(i, N);
            const c = P(0, i);
            const d = P(N, i);
            g.beginPath();
            g.moveTo(a[0], a[1]);
            g.lineTo(b[0], b[1]);
            g.moveTo(c[0], c[1]);
            g.lineTo(d[0], d[1]);
            g.stroke();
          }
          // 블록
          const dropT = ct - TOP - MORPH;
          const order: number[] = [];
          for (let i = 0; i < N * N; i++) order.push(i);
          const depth = (i: number): number => (i % N) + Math.floor(i / N);
          order.sort((a, b) => (wrong ? depth(b) - depth(a) : depth(a) - depth(b)) || a - b);
          const appearOrder = [...order].sort((a, b) => depth(a) - depth(b) || a - b);
          const rank = new Map<number, number>();
          appearOrder.forEach((c, i) => rank.set(c, i));
          for (const c of order) {
            const r = rank.get(c)!;
            const bt = dropT - r * DROP;
            if (bt <= 0) continue;
            const x = c % N;
            const y = Math.floor(c / N);
            const kd = kinds[c]!;
            const fall = Math.max(0, 1 - bt / 0.28);
            const hz = hts[c]!;
            const z0 = fall * fall * 4;
            const [top, left, right] = COLS[kd]!;
            const A = P(x, y, hz + z0);
            const B = P(x + 1, y, hz + z0);
            const C = P(x + 1, y + 1, hz + z0);
            const D = P(x, y + 1, hz + z0);
            const Cb = P(x + 1, y + 1, z0);
            const Db = P(x, y + 1, z0);
            const Bb = P(x + 1, y, z0);
            g.globalAlpha = 1 - fall * 0.6;
            quad([D, C, Cb, Db], left);
            quad([C, B, Bb, Cb], right);
            quad([A, B, C, D], top);
            g.strokeStyle = 'rgba(0,0,0,0.18)';
            g.lineWidth = 0.8;
            g.beginPath();
            g.moveTo(A[0], A[1]);
            g.lineTo(B[0], B[1]);
            g.lineTo(C[0], C[1]);
            g.lineTo(D[0], D[1]);
            g.closePath();
            g.stroke();
            if (kd === 0) {
              const m = P(x + 0.5, y + 0.5, hz + z0);
              g.strokeStyle = 'rgba(255,255,255,0.5)';
              g.lineWidth = 1;
              g.beginPath();
              g.ellipse(m[0], m[1], S * 0.22, S * 0.08, 0, 0, Math.PI);
              g.stroke();
            }
            if (kd === 5) {
              const m = P(x + 0.5, y + 0.5, hz + z0);
              g.fillStyle = '#7a4a28';
              g.fillRect(m[0] - S * 0.05, m[1] - S * 0.35, S * 0.1, S * 0.35);
              g.beginPath();
              g.arc(m[0], m[1] - S * 0.55, S * 0.3, 0, TAU);
              g.fillStyle = '#3f8f3c';
              g.fill();
              g.beginPath();
              g.arc(m[0] - S * 0.09, m[1] - S * 0.64, S * 0.13, 0, TAU);
              g.fillStyle = '#6cc35a';
              g.fill();
            }
            if (kd === 4) {
              const z1 = hz + z0;
              const b2 = P(x + 0.8, y + 0.2, z1 + 0.8);
              const c2b = P(x + 0.8, y + 0.8, z1 + 0.8);
              const d2 = P(x + 0.2, y + 0.8, z1 + 0.8);
              const cbb = P(x + 0.8, y + 0.8, z1);
              const dbb = P(x + 0.2, y + 0.8, z1);
              const bbb = P(x + 0.8, y + 0.2, z1);
              quad([d2, c2b, cbb, dbb], '#efe4cf');
              quad([c2b, b2, bbb, cbb], '#d6c8ad');
              const apex = P(x + 0.5, y + 0.5, z1 + 1.35);
              quad([d2, c2b, apex], '#e0503f');
              quad([c2b, b2, apex], '#b83a2e');
              const door = P(x + 0.5, y + 0.8, z1 + 0.3);
              g.fillStyle = '#7a4a28';
              g.fillRect(door[0] - S * 0.06, door[1] - S * 0.08, S * 0.12, S * 0.2);
            }
            g.globalAlpha = 1;
            if (showOrder && bt < 0.7 && k >= 1) {
              const m = P(x + 0.5, y + 0.5, hz + z0);
              g.globalAlpha = 1 - bt / 0.7;
              txt(g, String(r + 1), m[0], m[1] - S * 0.5, Math.max(8, S * 0.38), '#fff', 'center', 900);
              g.globalAlpha = 1;
            }
          }
          // 커서 칸 · 좌표
          if (dropT < 0) {
            const ci = Math.floor(t * 1.2) % (N * N);
            const x = (ci * 3) % N;
            const y = Math.floor((ci * 5) / N) % N;
            const A = P(x, y);
            const B = P(x + 1, y);
            const C = P(x + 1, y + 1);
            const D = P(x, y + 1);
            g.beginPath();
            g.moveTo(A[0], A[1]);
            g.lineTo(B[0], B[1]);
            g.lineTo(C[0], C[1]);
            g.lineTo(D[0], D[1]);
            g.closePath();
            g.fillStyle = 'rgba(255,213,74,0.55)';
            g.fill();
            g.strokeStyle = '#ffd54a';
            g.lineWidth = 2 * u;
            g.stroke();
            const m = P(x + 0.5, y + 0.5);
            txt(g, `(${x}, ${y})`, m[0], m[1], Math.max(8, S * 0.3), '#fff', 'center', 900);
          }
          const label = ct < TOP ? '① 위에서 본 네모 격자' : ct < TOP + MORPH ? '② 45° 돌리고 세로 ½' : wrong ? '③ 앞에서 뒤로 그리면 — 겹침이 틀려요' : '③ 뒤에서 앞으로 블록 쌓기';
          pill(g, label, 8 * u, 13 * u, 9 * u, 'rgba(10,14,30,0.82)');
          if (w > 420 || k >= 1) {
            g.font = `700 ${7.5 * u}px ${F}`;
            const lines = ['화면 x = (x − y) × 칸폭 ÷ 2', '화면 y = (x + y) × 칸높이 ÷ 2'];
            const bw = 120 * u;
            rr(g, w - bw - 8 * u, h - 30 * u, bw, 24 * u, 5 * u);
            g.fillStyle = 'rgba(10,14,30,0.75)';
            g.fill();
            lines.forEach((s, i) => txt(g, s, w - bw - 2 * u, h - 23.5 * u + i * 11 * u, 7.5 * u, '#cfe0ff', 'left', 700));
          }
        },
        controls: [
          { type: 'button', label: '새 마을', on: () => { ct = 0; gen(); } },
          { type: 'range', label: '속도', min: 0.25, max: 3, step: 0.25, value: 1, on: (v) => { speed = v; } },
          { type: 'toggle', label: '그리는 순서 틀리게 (앞 → 뒤)', value: false, on: (v) => { wrong = v; } },
          { type: 'toggle', label: '그리는 순서 번호', value: true, on: (v) => { showOrder = v; } },
        ],
      };
    },
  }
```

## 관련 기술
- 다음에 해 볼 기술: [디오라마 지도 (3D 섬 판)](https://ai-techstudio.web.app/ai/t/i275.md) `i275` · [자동 타일 (비트마스크 이음)](https://ai-techstudio.web.app/ai/t/i258.md) `i258`
- 참고 문서: [Clint Bellanger — Isometric Tiles Math](https://clintbellanger.net/articles/isometric_math/)
