# AI 꾸러미 — 동굴 (셀룰러 오토마타) — Cellular automata cave generation
> 무작위로 벽을 뿌린 뒤 「이웃 8칸 중 벽이 5개 이상이면 벽」 규칙을 몇 번 되풀이해, 자연스러운 동굴 모양을 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i248

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

## 주문서

### 만들어 줘: 동굴 (셀룰러 오토마타) — Cellular automata cave generation

#### 1. 목표
용의 동굴 지도를 셀룰러 오토마타로 만들어 줘 — 무작위 벽 → 규칙 되풀이 → 끊긴 작은 굴 메우기가 한 단계씩 보이게. 분위기는 어두운 보랏빛 동굴.

#### 2. 핵심 기술 용어
- **Cellular automata cave generation** — 이웃 수 규칙으로 동굴 만들기
- **Moore neighborhood (8 neighbors)** — 둘레 8칸 이웃
- **Flood fill (connected components)** — 이어진 굴끼리 묶어 크기 세기

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

#### 4. 조건
- 새 칸 값은 새 배열에 쓰기 — 같은 배열을 고치면서 세면 결과가 한쪽으로 쏠린다
- 판 밖 이웃은 벽(1)으로 세기
- 되풀이가 끝나면 가장 큰 굴만 남기기 (끊긴 작은 굴은 벽으로 메움)
- 처음 벽 비율 · 되풀이 횟수 · 시드를 바꿀 수 있게

#### 5. 완성 기준 (이게 보이면 성공)
- 처음엔 소금 뿌린 듯한 점들이, 되풀이할 때마다 뭉쳐 매끈한 굴이 된다
- 마지막에 떨어진 작은 굴이 사라지고 하나로 이어진 동굴만 남는다
- 벽 비율을 0.38 로 하면 넓게 트인 굴, 0.56 이면 좁은 굴
- 되풀이를 1 로 하면 아직 거칠고, 8 이면 아주 매끈하다

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

## 원리
- 처음엔 칸마다 47% 확률로 벽을 놓는다 (판 테두리는 늘 벽).
- 한 번 되풀이할 때 모든 칸을 동시에 바꾼다: 둘레 8칸 중 벽이 5개 이상이면 벽, 3개 이하면 빈 곳, 4개면 그대로.
- 판 밖은 벽으로 친다 — 그래서 가장자리가 막힌 동굴이 된다.
- 되풀이(견본 5번)가 끝나면 빈 칸을 4방향 flood fill 로 묶어 가장 큰 굴만 남기고 나머지는 벽으로 메운다.

## 핵심 코드 — 4-5 규칙 되풀이 + 가장 큰 굴만 남기기
(발췌: demos/demosMapA.ts i248 start() · step() 을 정리)
```ts
const at = (g: Uint8Array, x: number, y: number) => (x < 0 || y < 0 || x >= GW || y >= GH ? 1 : g[y * GW + x]);
const walls8 = (g: Uint8Array, x: number, y: number) => {
  let n = 0;
  for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) if (dx || dy) n += at(g, x + dx, y + dy);
  return n;
};
// 1) 무작위 벽 (테두리는 벽)
let grid = new Uint8Array(GW * GH);
for (let y = 0; y < GH; y++) for (let x = 0; x < GW; x++)
  grid[y * GW + x] = x === 0 || y === 0 || x === GW - 1 || y === GH - 1 || r() < 0.47 ? 1 : 0;
// 2) 규칙 되풀이 — 새 배열에 써서 모든 칸이 동시에 바뀌게
for (let it = 0; it < 5; it++) {
  const nx = new Uint8Array(GW * GH);
  for (let y = 0; y < GH; y++) for (let x = 0; x < GW; x++) {
    const n = walls8(grid, x, y);
    const edge = x === 0 || y === 0 || x === GW - 1 || y === GH - 1;
    nx[y * GW + x] = edge ? 1 : n >= 5 ? 1 : n <= 3 ? 0 : grid[y * GW + x];
  }
  grid = nx;
}
// 3) 4방향 flood fill 로 굴마다 번호 → 가장 큰 굴이 아니면 벽으로
const lab = new Int32Array(GW * GH).fill(-1);
const sizes: number[] = [];
for (let i = 0; i < GW * GH; i++) {
  if (grid[i] || lab[i] >= 0) continue;
  const id = sizes.length, st = [i];
  let n = 0;
  lab[i] = id;
  while (st.length) {
    const c = st.pop()!; n++;
    const x = c % GW, y = (c / GW) | 0;
    for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
      const ax = x + dx, ay = y + dy, k = ay * GW + ax;
      if (ax >= 0 && ay >= 0 && ax < GW && ay < GH && !grid[k] && lab[k] < 0) { lab[k] = id; st.push(k); }
    }
  }
  sizes.push(n);
}
const best = sizes.indexOf(Math.max(...sizes));
for (let i = 0; i < GW * GH; i++) if (!grid[i] && lab[i] !== best) grid[i] = 1;
```

## 흔한 실수 · 확인 목록
- [ ] **같은 배열을 고치면서 이웃을 세면 위 · 왼쪽으로 쏠린 모양이 된다** — 한 번 되풀이할 때는 새 배열에 쓰고 끝나면 바꿔 끼운다.
- [ ] **판 밖을 빈 곳으로 세면 동굴이 가장자리로 뚫린다** — 판 밖 이웃은 벽으로 센다.
- [ ] **끊긴 작은 굴을 그대로 두면 갈 수 없는 곳에 보물이 놓인다** — flood fill 로 가장 큰 굴만 남기고 나머지는 메운다.
- [ ] **칸 그대로 그리면 네모 계단이 거칠다** — 견본은 칸 지도를 흐리게 키운 뒤 문턱(0.44~0.52)으로 잘라 매끈한 바위 테두리를 그린다.

## 완성 기준 체크리스트
- [ ] 처음엔 소금 뿌린 듯한 점들이, 되풀이할 때마다 뭉쳐 매끈한 굴이 된다
- [ ] 마지막에 떨어진 작은 굴이 사라지고 하나로 이어진 동굴만 남는다
- [ ] 벽 비율을 0.38 로 하면 넓게 트인 굴, 0.56 이면 좁은 굴
- [ ] 되풀이를 1 로 하면 아직 거칠고, 8 이면 아주 매끈하다

## 이 기술 정보
- id: `i248` · 분류: 지도 · 길찾기 › 지도 생성 (절차) · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 72×45 = 3,240칸 × 이웃 8칸 × 5번 ≈ 13만 번 더하기. 한순간에 끝난다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i248
- 쓰면 좋을 때: 동굴 · 숲 덤불처럼 울퉁불퉁한 모양이 필요할 때 / 「규칙 하나로 모양이 생긴다」를 보여 줄 때 / 방이 없는 자연 지형 층
- 쓰지 말 때: 방과 복도가 또렷한 건물 — 대신 BSP 던전(i247) / 길이 반드시 하나로 이어진 미로 — 대신 미로 생성(i250)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i248 견본 항목 — `src/demos/demosMapA.ts:1156`
```ts
  i248: {
    kind: '2d',
    caption: '무작위 벽 → 「이웃 8칸 중 벽이 5개 이상이면 벽」을 몇 번 되풀이 → 매끈한 동굴 · 끊긴 굴은 메우기',
    make() {
      const GW = 72;
      const GH = 45;
      let grid = new Uint8Array(GW * GH);
      let removed = new Uint8Array(GW * GH);
      let iter = 0;
      let stage: 'iter' | 'fill' | 'done' = 'iter';
      let fillP = 0.47;
      let iters = 5;
      let tk = 0;
      let fadeK = 1;
      let cvA = mkCanvas(1, 1);
      let cvB = mkCanvas(1, 1);
      let floor = mkCanvas(1, 1);
      let cw = 0;
      let ch = 0;
      let dirty = true;
      let cursor = 0;
      let cursorT = 0;
      let crystals: number[] = [];
      const ST = 40;
      const at = (g: Uint8Array, x: number, y: number): number => (x < 0 || y < 0 || x >= GW || y >= GH ? 1 : g[y * GW + x]!);
      const walls8 = (g: Uint8Array, x: number, y: number): number => {
        let n = 0;
        for (const [dx, dy] of N8) n += at(g, x + dx, y + dy);
        return n;
      };
      const run = runner({
        rate: 60,
        hold: 1.8,
        start(seed) {
          const r = mulberry(seed);
          grid = new Uint8Array(GW * GH);
          for (let y = 0; y < GH; y++) for (let x = 0; x < GW; x++) grid[y * GW + x] = x === 0 || y === 0 || x === GW - 1 || y === GH - 1 || r() < fillP ? 1 : 0;
          removed = new Uint8Array(GW * GH);
          iter = 0;
          stage = 'iter';
          tk = 0;
          fadeK = 1;
          dirty = true;
          crystals = [];
        },
        step() {
          tk++;
          fadeK = Math.min(1, fadeK + 1 / 14);
          if (tk % ST !== 0) return false;
          if (stage === 'iter') {
            if (iter >= iters) {
              // 가장 큰 굴만 남기기
              const lab = new Int32Array(GW * GH).fill(-1);
              let best = -1;
              let bestN = 0;
              const sizes: number[] = [];
              for (let i = 0; i < GW * GH; i++) {
                if (grid[i] || lab[i]! >= 0) continue;
                const id = sizes.length;
                let n = 0;
                const st = [i];
                lab[i] = id;
                while (st.length) {
                  const c = st.pop()!;
                  n++;
                  const x = c % GW;
                  const y = (c / GW) | 0;
                  for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as P2[]) {
                    const nx = x + dx;
                    const ny = y + dy;
                    if (nx < 0 || ny < 0 || nx >= GW || ny >= GH) continue;
                    const k = ny * GW + nx;
                    if (!grid[k] && lab[k]! < 0) {
                      lab[k] = id;
                      st.push(k);
                    }
                  }
                }
                sizes.push(n);
                if (n > bestN) {
                  bestN = n;
                  best = id;
                }
              }
              for (let i = 0; i < GW * GH; i++) if (!grid[i] && lab[i] !== best) removed[i] = 1;
              stage = 'fill';
              return false;
            }
            const nx = new Uint8Array(GW * GH);
            for (let y = 0; y < GH; y++)
              for (let x = 0; x < GW; x++) {
                const n = walls8(grid, x, y);
                nx[y * GW + x] = x === 0 || y === 0 || x === GW - 1 || y === GH - 1 ? 1 : n >= 5 ? 1 : n <= 3 ? 0 : grid[y * GW + x]!;
              }
            grid = nx;
            iter++;
            fadeK = 0;
            dirty = true;
            return false;
          }
          if (stage === 'fill') {
            for (let i = 0; i < GW * GH; i++) if (removed[i]) grid[i] = 1;
            const r = mulberry(run.seed + 3);
            for (let k = 0; k < 400 && crystals.length < 14; k++) {
              const i = Math.floor(r() * GW * GH);
              const x = i % GW;
              const y = (i / GW) | 0;
              if (!grid[i] && at(grid, x, y - 1)) crystals.push(i);
            }
            removed = new Uint8Array(GW * GH);
            stage = 'done';
            fadeK = 0;
            dirty = true;
            return false;
          }
          return true;
        },
      });
      run.restart();
      const renderCave = (cv: HTMLCanvasElement, w: number, h: number): void => {
        const g = c2(cv);
        g.clearRect(0, 0, w, h);
        g.drawImage(floor, 0, 0);
        const ts = Math.max(w / GW, h / GH);
        const ox = (w - GW * ts) / 2;
        const oy = (h - GH * ts) / 2;
        // 칸 지도를 부드럽게 키우고 문턱으로 잘라 매끈한 바위 모양(마스크)을 만든다
        const small = mkCanvas(GW, GH);
        const sg = c2(small);
        const sd = sg.createImageData(GW, GH);
        for (let i = 0; i < GW * GH; i++) {
          const v = grid[i] ? 255 : 0;
          sd.data[i * 4] = v;
          sd.data[i * 4 + 1] = v;
          sd.data[i * 4 + 2] = v;
          sd.data[i * 4 + 3] = 255;
        }
        sg.putImageData(sd, 0, 0);
        const mask = mkCanvas(w, h);
        const mg = c2(mask);
        mg.filter = `blur(${Math.max(1, ts * 0.45)}px)`;
        mg.drawImage(small, ox, oy, GW * ts, GH * ts);
        mg.filter = 'none';
        const md = mg.getImageData(0, 0, w, h);
        const a = md.data;
        for (let o = 0; o < a.length; o += 4) {
          const v = a[o]! / 255;
          const k = clamp01((v - 0.44) / 0.08);
          a[o] = 255;
          a[o + 1] = 255;
          a[o + 2] = 255;
          a[o + 3] = k * 255;
        }
        mg.putImageData(md, 0, 0);
        const layer = (col: string, dx: number, dy: number, tex = false): void => {
          const L = mkCanvas(w, h);
          const lg = c2(L);
          lg.drawImage(mask, 0, 0);
          lg.globalCompositeOperation = 'source-in';
          lg.fillStyle = col;
          lg.fillRect(0, 0, w, h);
          if (tex) {
            lg.globalCompositeOperation = 'source-atop';
            const r = mulberry(11);
            for (let k = 0; k < (w * h) / 90; k++) {
              lg.fillStyle = r() < 0.5 ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.12)';
              const s = 1 + r() * ts * 0.5;
              lg.fillRect(r() * w, r() * h, s, s);
            }
          }
          g.drawImage(L, dx, dy);
        };
        layer('rgba(30,14,10,0.6)', ts * 0.12, ts * 0.38);
        layer('#8a7184', 0, -ts * 0.16);
        layer('#2e2436', 0, ts * 0.05, true);
      };
      return {
        draw(g, w, h, t, dt) {
          reset(g);
          run.tick(dt);
          const W = Math.round(w);
          const H = Math.round(h);
          if (W !== cw || H !== ch) {
            cw = W;
            ch = H;
            cvA = mkCanvas(W, H);
            cvB = mkCanvas(W, H);
            floor = mkCanvas(W, H);
            const fg = c2(floor);
            const gr = fg.createRadialGradient(W * 0.5, H * 0.45, 0, W * 0.5, H * 0.5, Math.hypot(W, H) * 0.6);
            gr.addColorStop(0, '#d2a674');
            gr.addColorStop(0.6, '#9a6f4c');
            gr.addColorStop(1, '#5a3d2e');
            fg.fillStyle = gr;
            fg.fillRect(0, 0, W, H);
            const r = mulberry(5);
            for (let k = 0; k < (W * H) / 60; k++) {
              fg.fillStyle = r() < 0.5 ? 'rgba(255,230,190,0.12)' : 'rgba(60,30,20,0.14)';
              const s = 0.6 + r() * 2.2;
              fg.fillRect(r() * W, r() * H, s, s);
            }
            dirty = true;
          }
          if (dirty) {
            dirty = false;
            const tmp = cvA;
            cvA = cvB;
            cvB = tmp;
            renderCave(cvB, W, H);
          }
          g.drawImage(cvA, 0, 0, w, h);
          g.globalAlpha = ease(fadeK);
          g.drawImage(cvB, 0, 0, w, h);
          g.globalAlpha = 1;
          const u = ui(scaleOf(w, h));
          const ts = Math.max(w / GW, h / GH);
          const ox = (w - GW * ts) / 2;
          const oy = (h - GH * ts) / 2;
          // 메울 작은 굴
          if (stage === 'fill') {
            g.fillStyle = `rgba(255,70,60,${0.45 + Math.sin(t * 12) * 0.25})`;
            for (let i = 0; i < GW * GH; i++) if (removed[i]) g.fillRect(ox + (i % GW) * ts, oy + ((i / GW) | 0) * ts, ts + 0.5, ts + 0.5);
          }
          // 수정
          if (stage === 'done') {
            g.globalCompositeOperation = 'lighter';
            for (const i of crystals) {
              const x = ox + ((i % GW) + 0.5) * ts;
              const y = oy + (((i / GW) | 0) + 0.3) * ts;
              const R = ts * (2.2 + Math.sin(t * 3 + i) * 0.5);
              const gr = g.createRadialGradient(x, y, 0, x, y, R);
              gr.addColorStop(0, 'rgba(140,240,255,0.9)');
              gr.addColorStop(1, 'rgba(60,120,255,0)');
              g.fillStyle = gr;
              g.fillRect(x - R, y - R, R * 2, R * 2);
            }
            g.globalCompositeOperation = 'source-over';
          }
          vignette(g, w, h, 0.45);
          // 규칙 카드: 칸 하나의 이웃 세기
          cursorT -= dt;
          if (cursorT <= 0) {
            cursorT = 0.45;
            const r = Math.random;
            for (let k = 0; k < 40; k++) {
              const x = 2 + Math.floor(r() * (GW - 4));
              const y = 2 + Math.floor(r() * (GH - 4));
              const n = walls8(grid, x, y);
              if (n >= 2 && n <= 6) {
                cursor = y * GW + x;
                break;
              }
            }
          }
          if (stage === 'iter') {
            const cx = cursor % GW;
            const cy = (cursor / GW) | 0;
            g.strokeStyle = '#ffe14d';
            g.lineWidth = 1.5 * u;
            g.strokeRect(ox + (cx - 1) * ts, oy + (cy - 1) * ts, ts * 3, ts * 3);
            const cs = 7 * u;
            const bx = w - cs * 3 - 44 * u;
            const by = 8 * u;
            rr(g, bx - 5 * u, by - 4 * u, cs * 3 + 52 * u, cs * 3 + 8 * u, 5 * u);
            g.fillStyle = 'rgba(20,12,24,0.82)';
            g.fill();
            for (let j = -1; j <= 1; j++)
              for (let i = -1; i <= 1; i++) {
                const v = at(grid, cx + i, cy + j);
                g.fillStyle = i === 0 && j === 0 ? '#ffe14d' : v ? '#6b5672' : '#d8b080';
                g.fillRect(bx + (i + 1) * cs + 0.5, by + (j + 1) * cs + 0.5, cs - 1, cs - 1);
              }
            const n = walls8(grid, cx, cy);
            const res = n >= 5 ? '벽' : n <= 3 ? '길' : '그대로';
            txt(g, `벽 ${n}`, bx + cs * 3 + 6 * u, by + cs * 0.9, 8 * u, '#fff', 'left', 800);
            txt(g, `→ ${res}`, bx + cs * 3 + 6 * u, by + cs * 2.2, 8 * u, n >= 5 ? '#c9a8ff' : '#ffd08a', 'left', 800);
          }
          const label = stage === 'iter' ? (iter === 0 ? `무작위 벽 ${Math.round(fillP * 100)}%` : `규칙 ${iter}번 적용`) : stage === 'fill' ? '끊긴 작은 굴 메우기' : '동굴 완성';
          pill(g, label, 8 * ui(u), 13 * ui(u), 9 * ui(u), 'rgba(20,12,24,0.82)', '#ffe6c0');
        },
        controls: [
          seedCtl(run),
          speedCtl(run),
          { type: 'range', label: '처음 벽 비율', min: 0.38, max: 0.56, step: 0.01, value: 0.47, on: (v) => { fillP = v; run.restart(run.seed); } },
          { type: 'range', label: '규칙 되풀이', min: 1, max: 8, step: 1, value: 5, on: (v) => { iters = v; run.restart(run.seed); } },
        ],
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [시드 지도 (같은 수 = 같은 지도)](https://ai-techstudio.web.app/ai/t/i255.md) `i255` · [방 + 복도 던전 (BSP)](https://ai-techstudio.web.app/ai/t/i247.md) `i247`
- 다음에 해 볼 기술: [자동 타일 (비트마스크 이음)](https://ai-techstudio.web.app/ai/t/i258.md) `i258` · [마칭 스퀘어 (등고선 · 경계)](https://ai-techstudio.web.app/ai/t/i259.md) `i259` · [시야 · 가림 (그림자 던지기 FOV)](https://ai-techstudio.web.app/ai/t/i268.md) `i268`
- 참고 문서: [RogueBasin — Cellular Automata Method for Generating Random Cave-Like Levels](https://www.roguebasin.com/index.php/Cellular_Automata_Method_for_Generating_Random_Cave-Like_Levels)
