# AI 꾸러미 — 육각 격자 좌표 (큐브 · 축) — Hexagonal grid — cube coordinates (q, r, s)
> 육각 칸을 (q, r, s) 세 수로 적고 늘 합을 0 으로 두어, 이웃 · 거리 · 고리 · 직선을 덧셈과 반올림만으로 구한다.  
> 견본: https://ai-techstudio.web.app/#t/i256

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

## 주문서

### 만들어 줘: 육각 격자 좌표 (큐브 · 축) — Hexagonal grid — cube coordinates (q, r, s)

#### 1. 목표
헥스 보드게임 판을 육각 격자 큐브 좌표로 만들어 줘 — 칸마다 (q, r, s) 가 보이고 이웃 6칸 · 거리 · 고리 · 직선을 차례로 보여 주게. 분위기는 어두운 바탕에 세 가지 색 좌표.

#### 2. 핵심 기술 용어
- **Hexagonal grid — cube coordinates (q, r, s)** — 육각 격자 큐브 좌표 (q + r + s = 0)
- **Axial coordinates** — 두 수(q, r)만 적는 축 좌표 — s = −q − r
- **Hex distance / ring / line drawing** — 육각 거리 · 고리 · 직선
- **Cube rounding** — 소수 좌표를 가장 가까운 칸으로 반올림

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

#### 4. 조건
- 좌표는 저장할 땐 (q, r) 두 수, 계산할 땐 s = −q − r 을 함께 — 합이 0 인지 늘 지키기
- 화면 위치는 x = 크기·√3·(q + r/2), y = 크기·1.5·r (뾰족한 쪽이 위인 육각)
- 직선 반올림은 세 축을 각각 반올림한 뒤 오차가 가장 큰 축을 다시 계산 (그냥 반올림하면 합이 0 이 아니게 된다)
- 직선 끝점에 1e-6 을 더해 경계에서 반올림이 흔들리지 않게

#### 5. 완성 기준 (이게 보이면 성공)
- 칸마다 q · r · s 세 수가 세 가지 색으로 적혀 있고 합이 늘 0 이다
- 가운데 칸을 고르면 이웃 6칸이 빛나고, 다른 칸에는 거리 숫자가 동심원처럼 적힌다
- 반지름 3 고리가 한 칸씩 이어지며 18칸이 칠해진다
- 두 칸 사이 직선이 빈틈없이 이어진다

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

## 원리
- 육각 칸은 세 축 방향으로 움직일 수 있어서 (q, r, s) 세 수로 적고, 늘 q + r + s = 0 이 되게 둔다.
- 이웃 6칸 = 여섯 방향 (1,0) (1,−1) (0,−1) (−1,0) (−1,1) (0,1) 을 더한 칸.
- 두 칸 거리 = (|Δq| + |Δr| + |Δs|) ÷ 2.
- 반지름 R 고리 = 한 방향으로 R 칸 간 뒤, 여섯 방향을 R 칸씩 걸으면 6R 칸이 나온다 (R = 3 이면 18칸).
- 직선 = 두 칸 사이를 N(거리)등분해 소수 좌표를 반올림하고, 반올림 오차가 가장 큰 축을 나머지 두 축으로 다시 맞춘다.

## 핵심 코드 — 큐브 좌표 — 방향 · 거리 · 화면 위치 · 고리 · 직선
(발췌: demos/demosMapA.ts i256 make() 를 정리)
```ts
type Hex = [number, number]; // (q, r), s = −q − r
const DIRS: Hex[] = [[1, 0], [1, -1], [0, -1], [-1, 0], [-1, 1], [0, 1]];
const dist = (a: Hex, b: Hex) =>
  (Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[0] + a[1] - b[0] - b[1])) / 2;
// 뾰족한 쪽이 위인 육각의 화면 위치
const toScreen = (q: number, r: number, size: number): [number, number] =>
  [size * Math.sqrt(3) * (q + r / 2), size * 1.5 * r];

function ring(c: Hex, R: number): Hex[] {
  const out: Hex[] = [];
  let h: Hex = [c[0] + DIRS[4][0] * R, c[1] + DIRS[4][1] * R]; // 한 방향으로 R 칸
  for (let i = 0; i < 6; i++) for (let j = 0; j < R; j++) { out.push(h); h = [h[0] + DIRS[i][0], h[1] + DIRS[i][1]]; }
  return out; // 6R 칸
}
function hexLine(a: Hex, b: Hex): Hex[] {
  const n = dist(a, b), out: Hex[] = [];
  for (let i = 0; i <= n; i++) {
    const k = n ? i / n : 0;
    const fq = a[0] + 1e-6 + (b[0] - a[0]) * k, fr = a[1] + 1e-6 + (b[1] - a[1]) * k, fs = -fq - fr;
    let q = Math.round(fq), r = Math.round(fr);
    const s = Math.round(fs);
    const dq = Math.abs(q - fq), dr = Math.abs(r - fr), ds = Math.abs(s - fs);
    if (dq > dr && dq > ds) q = -r - s; // 오차가 가장 큰 축을 나머지로 맞춘다
    else if (dr > ds) r = -q - s;
    out.push([q, r]);
  }
  return out;
}
```

## 흔한 실수 · 확인 목록
- [ ] **세 축을 그냥 반올림하면 q + r + s 가 0 이 아닌 칸이 생긴다** — 오차가 가장 큰 축을 나머지 두 축의 합의 반대로 다시 계산한다.
- [ ] **(행, 열) 엇갈림 좌표로 이웃을 구하면 짝수 · 홀수 줄마다 식이 달라 틀리기 쉽다** — 계산은 큐브 좌표로 하고, 저장 · 화면 변환할 때만 바꾼다.
- [ ] **직선이 칸 경계를 정확히 지나면 반올림이 칸마다 흔들린다** — 끝점에 1e-6 을 더해 한쪽으로 기울인다.

## 완성 기준 체크리스트
- [ ] 칸마다 q · r · s 세 수가 세 가지 색으로 적혀 있고 합이 늘 0 이다
- [ ] 가운데 칸을 고르면 이웃 6칸이 빛나고, 다른 칸에는 거리 숫자가 동심원처럼 적힌다
- [ ] 반지름 3 고리가 한 칸씩 이어지며 18칸이 칠해진다
- [ ] 두 칸 사이 직선이 빈틈없이 이어진다

## 이 기술 정보
- id: `i256` · 분류: 지도 · 길찾기 › 격자 · 좌표 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 모든 계산이 덧셈 · 절댓값 · 반올림이라 아주 가볍다. 반지름 4 판 = 61칸.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i256
- 쓰면 좋을 때: 헥스 보드게임 · 전략 지도 / 이웃이 모두 같은 거리여야 하는 판 (네모 격자의 대각선 문제를 피함) / 거리 · 사거리 · 시야 계산이 많은 게임
- 쓰지 말 때: 칸이 네모인 판 — 대신 보통 (x, y) 격자 / 구 위 칸 — 대신 육각 타일 행성(i539)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i256 견본 항목 — `src/demos/demosMapA.ts:2991`
```ts
  i256: {
    kind: '2d',
    caption: '육각 칸 = (q, r, s) 세 수, 늘 q + r + s = 0 — 이웃 6칸 · 거리 · 고리 · 직선을 덧셈으로',
    make() {
      const RAD = 4;
      const cells: [number, number][] = [];
      for (let q = -RAD; q <= RAD; q++) for (let r = Math.max(-RAD, -q - RAD); r <= Math.min(RAD, -q + RAD); r++) cells.push([q, r]);
      const DIRS: [number, number][] = [
        [1, 0],
        [1, -1],
        [0, -1],
        [-1, 0],
        [-1, 1],
        [0, 1],
      ];
      const dist = (a: [number, number], b: [number, number]): number => (Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[0] + a[1] - b[0] - b[1])) / 2;
      const inside = (c: [number, number]): boolean => dist(c, [0, 0]) <= RAD;
      const QC = '#ff6b8a';
      const RC = '#5fd68f';
      const SC = '#5ab4ff';
      let auto = true;
      let mode = 0;
      let mt = 0;
      let labels = true;
      let cur: [number, number] = [0, 0];
      let walkT = 0;
      let A: [number, number] = [-3, 1];
      let B: [number, number] = [3, -2];
      const MODES = ['좌표 (q, r, s)', '이웃 6칸', '거리', '고리', '직선'];
      const DUR = 4;
      const pickLine = (): void => {
        const r = Math.random;
        for (;;) {
          A = cells[Math.floor(r() * cells.length)]!;
          B = cells[Math.floor(r() * cells.length)]!;
          if (dist(A, B) >= 5) break;
        }
      };
      const enter = (m: number): void => {
        mode = m;
        mt = 0;
        if (m === 1 || m === 3) cur = [0, 0];
        if (m === 2) cur = cells[Math.floor(Math.random() * cells.length)]!;
        if (m === 4) pickLine();
      };
      const hexLine = (a: [number, number], b: [number, number]): { c: [number, number]; f: [number, number] }[] => {
        const n = dist(a, b);
        const out: { c: [number, number]; f: [number, number] }[] = [];
        for (let i = 0; i <= n; i++) {
          const k = n ? i / n : 0;
          const fq = lerp(a[0] + 1e-6, b[0] + 1e-6, k);
          const fr = lerp(a[1] + 1e-6, b[1] + 1e-6, k);
          const fs = -fq - fr;
          let q = Math.round(fq);
          let r = Math.round(fr);
          const s = Math.round(fs);
          const dq = Math.abs(q - fq);
          const dr = Math.abs(r - fr);
          const ds = Math.abs(s - fs);
          if (dq > dr && dq > ds) q = -r - s;
          else if (dr > ds) r = -q - s;
          out.push({ c: [q, r], f: [fq, fr] });
        }
        return out;
      };
      return {
        draw(g, w, h, _t, dt) {
          reset(g);
          mt += Math.min(dt, 0.1);
          if (auto && mt > DUR) enter((mode + 1) % 5);
          const u = ui(scaleOf(w, h));
          const bg = g.createRadialGradient(w / 2, h / 2, 0, w / 2, h / 2, Math.hypot(w, h) / 2);
          bg.addColorStop(0, '#1d2742');
          bg.addColorStop(1, '#0c1120');
          g.fillStyle = bg;
          g.fillRect(0, 0, w, h);
          const top = 22 * u;
          const size = Math.min((w * 0.94) / (Math.sqrt(3) * (2 * RAD + 1)), (h - top - 4 * u) / (1.5 * 2 * RAD + 2));
          const cx = w / 2;
          const cy = top + (h - top) / 2;
          const P = (q: number, r: number): P2 => [cx + size * Math.sqrt(3) * (q + r / 2), cy + size * 1.5 * r];
          const hexPath = (q: number, r: number, k = 0.94): void => {
            const [x, y] = P(q, r);
            g.beginPath();
            for (let i = 0; i < 6; i++) {
              const a = (Math.PI / 3) * i - Math.PI / 6;
              const px = x + Math.cos(a) * size * k;
              const py = y + Math.sin(a) * size * k;
              if (i) g.lineTo(px, py);
              else g.moveTo(px, py);
            }
            g.closePath();
          };
          // 좌표 모드: 커서가 이웃으로 걸어 다님
          if (mode === 0) {
            walkT -= dt;
            if (walkT <= 0) {
              walkT = 0.7;
              const opts = DIRS.map((d) => [cur[0] + d[0], cur[1] + d[1]] as [number, number]).filter(inside);
              cur = opts[Math.floor(Math.random() * opts.length)]!;
            }
          }
          const fill = new Map<string, string>();
          const key = (c: [number, number]): string => c[0] + ',' + c[1];
          const extra: (() => void)[] = [];
          if (mode === 0) {
            for (const c of cells) {
              const s = -c[0] - c[1];
              const cs = -cur[0] - cur[1];
              if (c[0] === cur[0]) fill.set(key(c), 'rgba(255,107,138,0.28)');
              if (c[1] === cur[1]) fill.set(key(c), 'rgba(95,214,143,0.28)');
              if (s === cs) fill.set(key(c), 'rgba(90,180,255,0.28)');
            }
            fill.set(key(cur), '#ffd54a');
          } else if (mode === 1) {
            fill.set(key(cur), '#ffd54a');
            const n = Math.min(6, Math.floor(mt / 0.35));
            for (let i = 0; i < n; i++) {
              const d = DIRS[i]!;
              const c: [number, number] = [cur[0] + d[0], cur[1] + d[1]];
              fill.set(key(c), '#ffb347');
              extra.push(() => {
                const [x0, y0] = P(cur[0], cur[1]);
                const [x1, y1] = P(c[0], c[1]);
                g.strokeStyle = '#fff';
                g.fillStyle = '#fff';
                g.lineWidth = 1.4 * u;
                arrow(g, x0 + (x1 - x0) * 0.32, y0 + (y1 - y0) * 0.32, x0 + (x1 - x0) * 0.62, y0 + (y1 - y0) * 0.62, 3.5 * u);
                if (!labels || size < 16) return;
                const sg = (v: number): string => (v > 0 ? '+' + v : v < 0 ? '−' + -v : '0');
                txt(g, `${sg(d[0])},${sg(d[1])},${sg(-d[0] - d[1])}`, x1, y1 + size * 0.55, Math.max(7, size * 0.24), '#3a2a00', 'center', 800);
              });
            }
          } else if (mode === 2) {
            const R = mt / 0.4;
            for (const c of cells) {
              const d = dist(c, cur);
              if (d <= R) fill.set(key(c), rgb(rampAt([[0, [255, 214, 74]], [0.5, [255, 140, 70]], [1, [190, 70, 140]]], d / 8)));
            }
          } else if (mode === 3) {
            fill.set(key(cur), '#ffd54a');
            const RR = 3;
            const ring: [number, number][] = [];
            let c: [number, number] = [cur[0] + DIRS[4]![0] * RR, cur[1] + DIRS[4]![1] * RR];
            for (let i = 0; i < 6; i++)
              for (let j = 0; j < RR; j++) {
                ring.push(c);
                c = [c[0] + DIRS[i]![0], c[1] + DIRS[i]![1]];
              }
            const n = Math.min(ring.length, Math.floor(mt / 0.15));
            for (let i = 0; i < n; i++) fill.set(key(ring[i]!), i === n - 1 ? '#fff' : '#ff9f43');
            extra.push(() => {
              g.strokeStyle = 'rgba(255,255,255,0.8)';
              g.lineWidth = 1.5 * u;
              g.beginPath();
              for (let i = 0; i < n; i++) {
                const [x, y] = P(ring[i]![0], ring[i]![1]);
                if (i) g.lineTo(x, y);
                else g.moveTo(x, y);
              }
              g.stroke();
            });
          } else {
            const line = hexLine(A, B);
            const n = Math.min(line.length, Math.floor(mt / 0.22) + 1);
            for (let i = 0; i < n; i++) fill.set(key(line[i]!.c), '#7ee0c3');
            fill.set(key(A), '#ffd54a');
            fill.set(key(B), '#ff6b8a');
            extra.push(() => {
              const [x0, y0] = P(A[0], A[1]);
              const [x1, y1] = P(B[0], B[1]);
              g.strokeStyle = 'rgba(255,255,255,0.85)';
              g.lineWidth = 1.4 * u;
              g.setLineDash([4 * u, 3 * u]);
              g.beginPath();
              g.moveTo(x0, y0);
              g.lineTo(x1, y1);
              g.stroke();
              g.setLineDash([]);
              for (let i = 0; i < n; i++) {
                const [x, y] = P(line[i]!.f[0], line[i]!.f[1]);
                g.beginPath();
                g.arc(x, y, 2.4 * u, 0, TAU);
                g.fillStyle = '#fff';
                g.fill();
              }
            });
          }
          for (const c of cells) {
            hexPath(c[0], c[1]);
            const f = fill.get(key(c));
            g.fillStyle = '#26324f';
            g.fill();
            if (f) {
              g.fillStyle = f;
              g.fill();
            }
            g.strokeStyle = '#3d4d78';
            g.lineWidth = 1;
            g.stroke();
          }
          for (const e of extra) e();
          // 칸 글씨
          if (labels && size >= 17) {
            const fs = Math.max(7, size * 0.31);
            for (const c of cells) {
              const [x, y] = P(c[0], c[1]);
              const s = -c[0] - c[1];
              const f = fill.get(key(c));
              const dark = f !== undefined && f.startsWith('#');
              if (mode === 2 && f) {
                txt(g, String(dist(c, cur)), x, y, size * 0.5, '#2a1400', 'center', 900);
                continue;
              }
              g.globalAlpha = dark ? 1 : 0.92;
              txt(g, String(c[0]), x - size * 0.34, y - size * 0.22, fs, dark ? '#a01838' : QC, 'center', 800);
              txt(g, String(c[1]), x + size * 0.34, y - size * 0.22, fs, dark ? '#156a3a' : RC, 'center', 800);
              txt(g, String(s), x, y + size * 0.36, fs, dark ? '#0f4a8a' : SC, 'center', 800);
              g.globalAlpha = 1;
            }
          }
          // 정보 띠
          const cs = -cur[0] - cur[1];
          let info = '';
          if (mode === 0) info = `(q, r, s) = (${cur[0]}, ${cur[1]}, ${cs}) · 합 0`;
          else if (mode === 1) info = '이웃 = 방향 6개를 더하기';
          else if (mode === 2) info = '거리 = (|Δq| + |Δr| + |Δs|) ÷ 2';
          else if (mode === 3) info = '고리 반지름 3 = 18칸';
          else info = `직선 = 거리 ${dist(A, B)}칸을 고르게 나눠 반올림`;
          pill(g, MODES[mode]!, 8 * u, 12 * u, 8.5 * u, 'rgba(255,213,74,0.95)', '#2a1d00');
          pill(g, info, w - 8 * u, 12 * u, 8 * u, 'rgba(10,14,30,0.85)', '#fff', 'right');
          if (mode === 0 && size < 17) {
            const [x, y] = P(cur[0], cur[1]);
            txt(g, String(cur[0]), x - size * 0.95, y - size * 1.2, 8 * u, QC, 'center', 900);
            txt(g, String(cur[1]), x + size * 0.95, y - size * 1.2, 8 * u, RC, 'center', 900);
            txt(g, String(cs), x, y + size * 1.55, 8 * u, SC, 'center', 900);
          }
        },
        controls: [
          { type: 'toggle', label: '자동 순환', value: true, on: (v) => { auto = v; } },
          { type: 'range', label: '보기 (좌표 · 이웃 · 거리 · 고리 · 직선)', min: 0, max: 4, step: 1, value: 0, on: (v) => { enter(v); } },
          { type: 'toggle', label: '칸 좌표 글씨', value: true, on: (v) => { labels = v; } },
          { type: 'button', label: '다시', on: () => enter(mode) },
        ],
      };
    },
  }
```

## 관련 기술
- 다음에 해 볼 기술: [육각 타일 행성 (골드버그 다면체)](https://ai-techstudio.web.app/ai/t/i539.md) `i539` · [A* 길찾기 (열린 · 닫힌 칸 보기)](https://ai-techstudio.web.app/ai/t/i264.md) `i264` · [시야 · 가림 (그림자 던지기 FOV)](https://ai-techstudio.web.app/ai/t/i268.md) `i268`
- 참고 문서: [Red Blob Games — Hexagonal Grids](https://www.redblobgames.com/grids/hexagons/)
