# AI 꾸러미 — 자동 타일 (비트마스크 이음) — Autotiling (bitmask tiling)
> 땅 칸마다 이웃 4칸이 땅인지를 비트(위1 · 오른쪽2 · 아래4 · 왼쪽8)로 더해, 그 수에 맞는 가장자리 모양을 저절로 고른다.  
> 견본: https://ai-techstudio.web.app/#t/i258

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

## 주문서

### 만들어 줘: 자동 타일 (비트마스크 이음) — Autotiling (bitmask tiling)

#### 1. 목표
섬 · 물 경계에 자동 타일을 넣어 줘 — 땅을 칠하면 이웃 비트 수에 맞게 해안선 · 모래 띠 · 둥근 모서리가 저절로 생기고, 칸마다 비트 수가 보이게. 분위기는 밝은 지도 색.

#### 2. 핵심 기술 용어
- **Autotiling (bitmask tiling)** — 이웃 비트로 가장자리 조각 고르기
- **4-bit (16-tile) / 8-bit (47-tile blob) bitmask** — 이웃 4칸 = 16조각 · 8칸 = 47조각
- **Concave corner fill** — 양옆은 땅 · 대각선은 물인 오목 모서리 메우기

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

#### 4. 조건
- 비트 값은 위 1 · 오른쪽 2 · 아래 4 · 왼쪽 8 로 고정 (조각 번호와 맞게)
- 판 밖은 땅이 아닌 것으로 세기
- 바깥 모서리(두 쪽이 드러남)만 둥글게, 오목 모서리는 따로 메우기
- 「자동 타일」 켬/끔으로 네모 칸과 비교 · 칸마다 비트 수 보기

#### 5. 완성 기준 (이게 보이면 성공)
- 땅을 칠하면 그 칸과 이웃 칸의 가장자리가 바로 바뀐다
- 해안에 흰 물거품 · 모래 띠 · 풀이 겹으로 보이고 바깥 모서리가 둥글다
- 자동 타일을 끄면 네모 칸 그대로라 계단처럼 보인다
- 칸마다 적힌 비트 수(0~15)가 실제 이웃 모양과 맞다

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

## 원리
- 땅 칸마다 위 · 오른쪽 · 아래 · 왼쪽 이웃이 땅이면 1 · 2 · 4 · 8 을 더한다 → 0~15, 16가지.
- 그 수가 어느 쪽이 물에 드러났는지 알려 준다 (15 = 사방이 땅, 0 = 외딴섬).
- 견본은 그림 조각 대신 코드로 그린다: 드러난 쪽만 e 만큼 넓히고, 두 쪽이 드러난 바깥 모서리는 둥글게.
- 물거품(넓게) → 모래(조금 넓게) → 풀(안쪽으로 좁게) 세 겹을 같은 규칙으로 쌓으면 해안 띠가 생긴다.
- 양옆은 땅인데 대각선만 물인 오목 모서리는 따로 메운다 — 8칸 비트(47조각)가 필요한 까닭.

## 핵심 코드 — 이웃 비트 수 + 드러난 쪽만 넓히는 칸 모양
(발췌: demos/demosMapA.ts i258 maskOf · shape 를 정리)
```ts
const L = (x: number, y: number) => x >= 0 && y >= 0 && x < GW && y < GH && land[y * GW + x] === 1;
// 위 1 · 오른쪽 2 · 아래 4 · 왼쪽 8
const maskOf = (x: number, y: number) =>
  (L(x, y - 1) ? 1 : 0) | (L(x + 1, y) ? 2 : 0) | (L(x, y + 1) ? 4 : 0) | (L(x - 1, y) ? 8 : 0);

/** 드러난 쪽만 e 만큼 늘이고(음수면 줄이고), 두 쪽이 드러난 모서리는 반지름 r 로 둥글게 */
function shape(g: CanvasRenderingContext2D, x: number, y: number, X: number, Y: number, s: number, e: number, r: number) {
  const m = maskOf(x, y);
  const n = !(m & 1), ea = !(m & 2), so = !(m & 4), we = !(m & 8);
  const x0 = X - (we ? e : 0), x1 = X + s + (ea ? e : 0);
  const y0 = Y - (n ? e : 0), y1 = Y + s + (so ? e : 0);
  g.beginPath();
  g.roundRect(x0, y0, x1 - x0, y1 - y0, [n && we ? r : 0, n && ea ? r : 0, so && ea ? r : 0, so && we ? r : 0]);
  g.fill();
}
// 세 겹: [색, 넓힘 e, 둥근 반지름 r] — 칸 크기 s 기준
const layers: [string, number, number][] = [
  ['rgba(255,255,255,0.55)', s * 0.24, s * 0.5], // 물거품
  ['#e9d39a', s * 0.1, s * 0.38],                 // 모래
  ['#7fc45c', -s * 0.14, s * 0.26],               // 풀 (안쪽으로)
];
for (const [col, e, r] of layers) {
  g.fillStyle = col;
  for (const i of landCells) shape(g, i % GW, (i / GW) | 0, ox + (i % GW) * s, oy + ((i / GW) | 0) * s, s, e, r);
}
```

## 흔한 실수 · 확인 목록
- [ ] **이웃 4칸만 보면 오목한 모서리에 물 구멍이 남는다** — 양옆은 땅이고 대각선만 물인 모서리를 따로 메운다 (8칸 비트 = 47조각 방식).
- [ ] **비트 순서를 조각 그림 순서와 다르게 정하면 엉뚱한 조각이 붙는다** — 위 1 · 오른쪽 2 · 아래 4 · 왼쪽 8 처럼 한 번 정하고 조각 번호를 그 수로 맞춘다.
- [ ] **칸 하나를 바꾸고 그 칸만 다시 그리면 이웃 가장자리가 그대로 남는다** — 바꾼 칸과 둘레 8칸을 함께 다시 고른다 (견본은 매 프레임 전체를 다시 그림).

## 완성 기준 체크리스트
- [ ] 땅을 칠하면 그 칸과 이웃 칸의 가장자리가 바로 바뀐다
- [ ] 해안에 흰 물거품 · 모래 띠 · 풀이 겹으로 보이고 바깥 모서리가 둥글다
- [ ] 자동 타일을 끄면 네모 칸 그대로라 계단처럼 보인다
- [ ] 칸마다 적힌 비트 수(0~15)가 실제 이웃 모양과 맞다

## 이 기술 정보
- id: `i258` · 분류: 지도 · 길찾기 › 격자 · 좌표 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 24×15 = 360칸 × 겹 3개. 칸을 바꿀 때만 다시 그리면 더 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i258
- 쓰면 좋을 때: 칠하기만 하면 경계가 매끈해져야 하는 타일맵 / 지도 편집기 · 그리기 놀이 / 물 · 땅 · 길 경계가 많은 판
- 쓰지 말 때: 경계가 칸과 상관없이 매끈해야 할 때 — 대신 마칭 스퀘어(i259) / 칸이 몇 개 없는 고정된 판 — 손으로 조각을 놓는 편이 쉽다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i258 견본 항목 — `src/demos/demosMapA.ts:3463`
```ts
  i258: {
    kind: '2d',
    caption: '땅을 칠하면 이웃 4칸(위1 · 오른쪽2 · 아래4 · 왼쪽8)을 더한 수로 가장자리 조각이 저절로 — 끄면 네모 칸 그대로',
    make() {
      const GW = 24;
      const GH = 15;
      let land = new Uint8Array(GW * GH);
      let bx = GW / 2;
      let by = GH / 2;
      let dir = 0;
      let stepN = 0;
      let auto = true;
      let nums = true;
      let rnd = mulberry(1);
      let curCell = -1;
      let painted = new Float32Array(GW * GH);
      let clock = 0;
      const STEPS = 190;
      const run = runner({
        rate: 38,
        hold: 1.8,
        start(seed) {
          rnd = mulberry(seed);
          land = new Uint8Array(GW * GH);
          painted = new Float32Array(GW * GH).fill(-9);
          bx = GW * (0.35 + rnd() * 0.3);
          by = GH * (0.35 + rnd() * 0.3);
          dir = rnd() * TAU;
          stepN = 0;
        },
        step() {
          stepN++;
          if (stepN > STEPS) return true;
          const erase = stepN > STEPS * 0.82;
          dir += (rnd() - 0.5) * 1.3;
          const cxm = GW / 2 - bx;
          const cym = GH / 2 - by;
          dir += Math.sin(Math.atan2(cym, cxm) - dir) * 0.15 * Math.min(1, Math.hypot(cxm, cym) / 6);
          bx = clamp(bx + Math.cos(dir) * 0.8, 1.5, GW - 2.5);
          by = clamp(by + Math.sin(dir) * 0.8, 1.5, GH - 2.5);
          const x = Math.floor(bx);
          const y = Math.floor(by);
          curCell = y * GW + x;
          const set = (i: number): void => {
            if (erase) {
              if (land[i]) {
                land[i] = 0;
                painted[i] = clock;
              }
            } else if (!land[i]) {
              land[i] = 1;
              painted[i] = clock;
            }
          };
          set(curCell);
          if (!erase && rnd() < 0.7) {
            const d = [1, -1, GW, -GW][Math.floor(rnd() * 4)]!;
            const j = curCell + d;
            if (j >= 0 && j < GW * GH) set(j);
          }
          return false;
        },
      });
      run.restart();
      const L = (x: number, y: number): boolean => x >= 0 && y >= 0 && x < GW && y < GH && land[y * GW + x] === 1;
      const maskOf = (x: number, y: number): number => (L(x, y - 1) ? 1 : 0) | (L(x + 1, y) ? 2 : 0) | (L(x, y + 1) ? 4 : 0) | (L(x - 1, y) ? 8 : 0);
      /** 땅 한 칸 모양: 드러난 쪽만 e 만큼 늘이고(음수면 줄이고), 두 쪽이 드러난 모서리는 둥글게 */
      const shape = (g: G, x: number, y: number, X: number, Y: number, s: number, e: number, r: number): void => {
        const m = maskOf(x, y);
        const n = !(m & 1);
        const ea = !(m & 2);
        const so = !(m & 4);
        const we = !(m & 8);
        const x0 = X - (we ? e : 0);
        const x1 = X + s + (ea ? e : 0);
        const y0 = Y - (n ? e : 0);
        const y1 = Y + s + (so ? e : 0);
        g.beginPath();
        g.roundRect(x0, y0, x1 - x0, y1 - y0, [n && we ? r : 0, n && ea ? r : 0, so && ea ? r : 0, so && we ? r : 0]);
        g.fill();
      };
      /** 오목한 모서리(양옆은 땅, 대각선은 물) 메우기 · 깎기 */
      const concave = (g: G, x: number, y: number, X: number, Y: number, s: number, e: number): void => {
        const corners: [number, number][] = [
          [1, -1],
          [1, 1],
          [-1, 1],
          [-1, -1],
        ];
        for (const [dx, dy] of corners) {
          if (!(L(x + dx, y) && L(x, y + dy) && !L(x + dx, y + dy))) continue;
          const cx = dx > 0 ? X + s : X;
          const cy = dy > 0 ? Y + s : Y;
          if (e > 0) g.fillRect(dx > 0 ? cx : cx - e, dy > 0 ? cy : cy - e, e, e);
          else {
            const i = -e;
            g.fillRect(dx > 0 ? cx - i : cx, dy > 0 ? cy - i : cy, i, i);
          }
        }
      };
      let water = mkCanvas(1, 1);
      let ww = 0;
      let wh = 0;
      return {
        draw(g, w, h, t, dt) {
          reset(g);
          clock += dt;
          run.tick(dt);
          const u = ui(scaleOf(w, h));
          const s = Math.min(w / GW, h / GH);
          const ox = (w - GW * s) / 2;
          const oy = (h - GH * s) / 2;
          if (ww !== Math.round(w) || wh !== Math.round(h)) {
            ww = Math.round(w);
            wh = Math.round(h);
            water = mkCanvas(ww, wh);
            const wg = c2(water);
            const gr = wg.createLinearGradient(0, 0, 0, wh);
            gr.addColorStop(0, '#3fa6d8');
            gr.addColorStop(1, '#2a7fb8');
            wg.fillStyle = gr;
            wg.fillRect(0, 0, ww, wh);
            wg.strokeStyle = 'rgba(255,255,255,0.16)';
            wg.lineWidth = 1.2;
            const r = mulberry(4);
            for (let k = 0; k < 90; k++) {
              const x = r() * ww;
              const y = r() * wh;
              const L2 = 6 + r() * 10;
              wg.beginPath();
              wg.moveTo(x, y);
              wg.quadraticCurveTo(x + L2 / 2, y - 3, x + L2, y);
              wg.stroke();
            }
          }
          g.drawImage(water, 0, 0, w, h);
          const cellXY = (i: number): [number, number, number, number] => {
            const x = i % GW;
            const y = (i / GW) | 0;
            return [x, y, ox + x * s, oy + y * s];
          };
          const list: number[] = [];
          for (let i = 0; i < GW * GH; i++) if (land[i]) list.push(i);
          if (auto) {
            const layers: [string, number, number][] = [
              ['rgba(255,255,255,0.55)', s * 0.24, s * 0.5],
              ['#e9d39a', s * 0.1, s * 0.38],
              ['#7fc45c', -s * 0.14, s * 0.26],
            ];
            for (const [col, e, r] of layers) {
              g.fillStyle = col;
              for (const i of list) {
                const [x, y, X, Y] = cellXY(i);
                shape(g, x, y, X, Y, s, e, r);
              }
              for (const i of list) {
                const [x, y, X, Y] = cellXY(i);
                if (e < 0) g.fillStyle = '#e9d39a';
                concave(g, x, y, X, Y, s, e);
                g.fillStyle = col;
              }
            }
            // 풀 무늬
            for (const i of list) {
              const [x, y, X, Y] = cellXY(i);
              if (maskOf(x, y) !== 15) continue;
              const n = hash2(x, y, 5);
              if (n < 0.35) {
                g.fillStyle = '#5ea844';
                g.beginPath();
                g.arc(X + s * (0.3 + n), Y + s * 0.5, s * 0.12, 0, TAU);
                g.fill();
              } else if (n > 0.85) {
                g.fillStyle = '#fff6a8';
                g.beginPath();
                g.arc(X + s * 0.5, Y + s * 0.5, s * 0.07, 0, TAU);
                g.fill();
              }
            }
          } else {
            g.fillStyle = '#7fc45c';
            for (const i of list) {
              const [, , X, Y] = cellXY(i);
              g.fillRect(X, Y, s, s);
            }
            g.strokeStyle = 'rgba(0,0,0,0.12)';
            for (const i of list) {
              const [, , X, Y] = cellXY(i);
              g.strokeRect(X + 0.5, Y + 0.5, s - 1, s - 1);
            }
          }
          // 막 칠한 칸 반짝
          for (const i of list) {
            const k = 1 - (clock - painted[i]!) / 0.4;
            if (k <= 0) continue;
            const [, , X, Y] = cellXY(i);
            g.fillStyle = `rgba(255,255,255,${k * 0.6})`;
            g.fillRect(X, Y, s, s);
          }
          if (nums && s >= 9) {
            for (const i of list) {
              const [x, y, X, Y] = cellXY(i);
              txt(g, String(maskOf(x, y)), X + s / 2, Y + s / 2 + 0.5, Math.max(6, s * 0.36), 'rgba(30,60,20,0.75)', 'center', 800);
            }
          }
          // 붓
          if (!run.holding && curCell >= 0) {
            const erase = stepN > STEPS * 0.82;
            const [, , X, Y] = cellXY(curCell);
            g.strokeStyle = erase ? '#ff6b6b' : '#ffe14d';
            g.lineWidth = 2 * u;
            g.strokeRect(X - 1, Y - 1, s + 2, s + 2);
            const px = X + s * 0.9;
            const py = Y - s * 0.2;
            g.save();
            g.translate(px, py);
            g.rotate(0.7 + Math.sin(t * 10) * 0.08);
            g.fillStyle = erase ? '#ff9aa0' : '#8a5a34';
            g.fillRect(-s * 0.12, -s * 1.3, s * 0.24, s * 1.1);
            g.fillStyle = erase ? '#fff' : '#7fc45c';
            g.beginPath();
            g.ellipse(0, -s * 0.12, s * 0.16, s * 0.24, 0, 0, TAU);
            g.fill();
            g.restore();
          }
          // 계산 창: 지금 칸의 이웃 → 수
          if (curCell >= 0 && land[curCell]) {
            const x = curCell % GW;
            const y = (curCell / GW) | 0;
            const m = maskOf(x, y);
            const cs = 9 * u;
            const bw = cs * 3 + 64 * u;
            const bx0 = w - bw - 6 * u;
            const by0 = h - cs * 3 - 14 * u;
            rr(g, bx0, by0, bw, cs * 3 + 8 * u, 5 * u);
            g.fillStyle = 'rgba(10,20,40,0.85)';
            g.fill();
            const cells: [number, number, number][] = [
              [0, -1, 1],
              [1, 0, 2],
              [0, 1, 4],
              [-1, 0, 8],
            ];
            g.fillStyle = '#ffe14d';
            g.fillRect(bx0 + 4 * u + cs, by0 + 4 * u + cs, cs - 1, cs - 1);
            const parts: string[] = [];
            for (const [dx, dy, b] of cells) {
              const on = L(x + dx, y + dy);
              g.fillStyle = on ? '#7fc45c' : '#3a88c8';
              g.fillRect(bx0 + 4 * u + (dx + 1) * cs, by0 + 4 * u + (dy + 1) * cs, cs - 1, cs - 1);
              txt(g, String(b), bx0 + 4 * u + (dx + 1.5) * cs, by0 + 4 * u + (dy + 1.5) * cs, 6 * u, on ? '#183008' : '#cfe8ff', 'center', 800);
              if (on) parts.push(String(b));
            }
            txt(g, parts.length ? parts.join('+') : '0', bx0 + cs * 3 + 10 * u, by0 + 4 * u + cs * 0.9, 8 * u, '#cfe0ff', 'left', 700);
            txt(g, `= ${m}번 조각`, bx0 + cs * 3 + 10 * u, by0 + 4 * u + cs * 2.1, 8.5 * u, '#ffe14d', 'left', 900);
          }
          pill(g, auto ? '자동 타일 켜짐 — 수에 맞는 가장자리 조각' : '자동 타일 꺼짐 — 네모 칸만', 8 * u, 12 * u, 8.5 * u, 'rgba(10,20,40,0.82)');
        },
        controls: [
          seedCtl(run),
          speedCtl(run),
          { type: 'toggle', label: '자동 타일', value: true, on: (v) => { auto = v; } },
          { type: 'toggle', label: '칸마다 비트 수', value: true, on: (v) => { nums = v; } },
        ],
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [동굴 (셀룰러 오토마타)](https://ai-techstudio.web.app/ai/t/i248.md) `i248`
- 다음에 해 볼 기술: [마칭 스퀘어 (등고선 · 경계)](https://ai-techstudio.web.app/ai/t/i259.md) `i259`
