# AI 꾸러미 — 파동 함수 붕괴 (WFC) 타일 맞춤 — Wave Function Collapse (WFC)
> 칸마다 「아직 될 수 있는 타일」을 남겨 두고, 가능성이 가장 적은 칸부터 하나씩 확정하며 이웃 규칙을 퍼뜨려 해안 · 숲이 저절로 이어진 지도를 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i249

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

## 주문서

### 만들어 줘: 파동 함수 붕괴 (WFC) 타일 맞춤 — Wave Function Collapse (WFC)

#### 1. 목표
바다 · 모래 · 풀 · 숲 · 산 섬 지도를 파동 함수 붕괴(WFC)로 만들어 줘 — 칸마다 가능한 타일을 두고, 가능성이 가장 적은 칸부터 확정하고 이웃 규칙을 퍼뜨려서. 그림은 칸이 하나씩 확정되는 과정이 보이는 설명.

#### 2. 핵심 기술 용어
- **Wave Function Collapse (WFC)** — 파동 함수 붕괴 — 가능성을 줄여 가며 타일 확정
- **Lowest entropy cell** — 가능한 타일이 가장 적은 칸부터 고르기
- **Constraint propagation** — 확정한 칸의 규칙을 이웃으로 퍼뜨리기
- **Adjacency rules · weighted choice** — 붙을 수 있는 짝 규칙 · 비율 있는 무작위

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

#### 4. 조건
- 타일 규칙은 데이터(순서 · 붙는 짝)로, 알고리즘은 규칙을 몰라도 돌게 나눠서
- 같은 범위 칸이 여럿이면 시드로 만든 작은 흔들림(0.6 × 해시)을 더해 고른다 — 늘 왼쪽 위부터 채우지 않게
- 무작위는 시드 있는 난수(mulberry 등)로 — 같은 시드면 같은 지도
- 모순(가능한 타일이 0개)이 나는 규칙이면 되돌리기나 처음부터 다시를 반드시 넣는다
- 그려 둔 타일은 화면 밖 캔버스에 쌓아 두고 새로 확정된 칸만 덧그린다

#### 5. 완성 기준 (이게 보이면 성공)
- 빈 판에서 시작해 칸이 하나씩 확정되며 바다 옆엔 모래, 모래 옆엔 풀처럼 한 단계씩만 이어진 섬이 생긴다
- 아직 안 정해진 칸에는 남은 가능성 막대가 보이고, 퍼뜨림이 닿은 칸이 잠깐 빛난다
- 「같은 타일끼리 뭉치기」를 1 → 10 으로 올리면 숲 · 바다 덩어리가 확실히 커진다
- 같은 시드로 다시 하면 똑같은 지도가 나온다

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

## 원리
- 타일 6가지를 깊은 바다 → 바다 → 모래 → 풀밭 → 숲 → 산 순서로 줄 세우고, 이웃끼리는 한 단계 차이까지만 붙을 수 있다는 규칙을 둔다.
- 그래서 칸마다 가능한 타일은 「lo ~ hi」 범위 하나로 적을 수 있다. 처음엔 모든 칸이 0 ~ 5.
- 범위가 가장 좁은(가능성이 적은) 칸을 골라, 비율(WT)과 「옆에 같은 타일이 있으면 × 뭉치기」로 하나를 뽑아 확정한다.
- 확정하면 이웃 범위를 lo = max(lo, 내 lo − 1) · hi = min(hi, 내 hi + 1) 로 줄이고, 바뀐 칸은 다시 그 이웃으로 퍼뜨린다 (스택).
- 범위가 한 값으로 좁혀진 칸은 저절로 확정 — 모든 칸이 확정되면 끝, 규칙을 어긴 이음이 하나도 없다.

## 핵심 코드 — 범위(lo ~ hi) 로 줄이는 WFC 한 걸음
(발췌: demos/demosMapA.ts i249 step() 을 정리)
```ts
const GW = 30, GH = 19, NT = 6;               // 깊은 바다 · 바다 · 모래 · 풀 · 숲 · 산
const WT = [1.7, 2.1, 1.4, 3, 2.3, 1.1];        // 타일별 뽑힐 비율
const lo = new Int8Array(GW * GH).fill(0), hi = new Int8Array(GW * GH).fill(NT - 1);
const val = new Int8Array(GW * GH).fill(-1);
const N4 = [[1, 0], [-1, 0], [0, 1], [0, -1]];

function step(rnd: () => number, jitter: (i: number) => number, coherence = 2.5): boolean {
  let best = -1, bs = 1e9;                       // 가능성이 가장 적은 칸
  for (let i = 0; i < GW * GH; i++) {
    if (val[i] >= 0) continue;
    const s = hi[i] - lo[i] + jitter(i) * 0.6;
    if (s < bs) { bs = s; best = i; }
  }
  if (best < 0) return true;                     // 다 확정
  const x0 = best % GW, y0 = (best / GW) | 0;
  const wts: number[] = []; let sum = 0;
  for (let v = lo[best]; v <= hi[best]; v++) {
    let w = WT[v];
    for (const [dx, dy] of N4) {                 // 옆에 같은 타일이 있으면 더 잘 뽑히게
      const nx = x0 + dx, ny = y0 + dy;
      if (nx >= 0 && ny >= 0 && nx < GW && ny < GH && val[ny * GW + nx] === v) w *= coherence;
    }
    wts.push(w); sum += w;
  }
  let pick = rnd() * sum, v = lo[best];
  for (const w of wts) { if (pick < w) break; pick -= w; v++; }
  lo[best] = hi[best] = Math.min(v, hi[best]);
  const st = [best];                             // 퍼뜨리기: 이웃은 한 단계 차이까지만
  while (st.length) {
    const c = st.pop()!, cx = c % GW, cy = (c / GW) | 0;
    for (const [dx, dy] of N4) {
      const nx = cx + dx, ny = cy + dy;
      if (nx < 0 || ny < 0 || nx >= GW || ny >= GH) continue;
      const n = ny * GW + nx;
      const nl = Math.max(lo[n], lo[c] - 1), nh = Math.min(hi[n], hi[c] + 1);
      if (nl !== lo[n] || nh !== hi[n]) { lo[n] = nl; hi[n] = nh; st.push(n); }
    }
  }
  for (let i = 0; i < GW * GH; i++) if (val[i] < 0 && lo[i] === hi[i]) val[i] = lo[i];
  return false;
}
```

## 흔한 실수 · 확인 목록
- [ ] **규칙이 복잡하면 가능한 타일이 0개인 칸(모순)이 생긴다** — 이 견본의 「한 단계씩」 규칙은 범위가 늘 이어져 모순이 없지만, 일반 타일 짝 규칙은 모순이 난다. 되돌리기나 그 시드로 처음부터 다시를 넣는다.
- [ ] **같은 점수 칸을 늘 첫 칸부터 고르면 왼쪽 위부터 줄줄이 채워진다** — 고르기 점수에 시드 해시로 작은 흔들림(0.6)을 더해 여기저기서 확정되게 한다.
- [ ] **뭉치기 없이 비율만 쓰면 얼룩덜룩 잡음 같은 지도가 된다** — 옆 칸과 같은 타일이면 비율을 coherence(2.5)배 — 덩어리가 생겨 섬 · 숲처럼 보인다.
- [ ] **확정할 때마다 판 전체를 다시 그리면 칸이 많을 때 느리다** — 새로 확정된 칸 목록(pending)만 화면 밖 캔버스에 덧그리고, 화면에는 그 캔버스를 한 번 붙인다.

## 완성 기준 체크리스트
- [ ] 빈 판에서 시작해 칸이 하나씩 확정되며 바다 옆엔 모래, 모래 옆엔 풀처럼 한 단계씩만 이어진 섬이 생긴다
- [ ] 아직 안 정해진 칸에는 남은 가능성 막대가 보이고, 퍼뜨림이 닿은 칸이 잠깐 빛난다
- [ ] 「같은 타일끼리 뭉치기」를 1 → 10 으로 올리면 숲 · 바다 덩어리가 확실히 커진다
- [ ] 같은 시드로 다시 하면 똑같은 지도가 나온다

## 이 기술 정보
- id: `i249` · 분류: 지도 · 길찾기 › 지도 생성 (절차) · 2D · 난이도 어려움 · 폰 부담 가벼움 (폰 OK) — 30 × 19 = 570칸. 한 걸음마다 모든 칸을 훑어 가장 좁은 칸을 찾는다 — 칸이 수만 개면 우선순위 큐로 바꿔야 한다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i249
- 쓰면 좋을 때: 이어져야 하는 지형 · 무늬 (해안선 · 길 · 강)를 손으로 안 그리고 매번 새로 만들 때 / 「컴퓨터가 규칙만 지키며 그림을 채운다」를 한 단계씩 보여 주는 설명 화면
- 쓰지 말 때: 그냥 울퉁불퉁한 높이 지도만 필요할 때 — 잡음 섬 지도(i245)가 훨씬 빠르고 쉽다 / 방 · 복도처럼 큰 구조가 중요한 던전 — BSP 방 나누기(i247)가 낫다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i249 견본 항목 — `src/demos/demosMapA.ts:1447`
```ts
  i249: {
    kind: '2d',
    caption: '이웃 규칙(바다–모래–풀–숲–산은 한 단계씩만 붙음)을 지키며 가능성이 가장 적은 칸부터 확정 — 해안 · 숲이 저절로 이어져요',
    make() {
      const GW = 30;
      const GH = 19;
      const NT = 6;
      const WT = [1.7, 2.1, 1.4, 3, 2.3, 1.1];
      let lo = new Int8Array(GW * GH);
      let hi = new Int8Array(GW * GH);
      let val = new Int8Array(GW * GH);
      let flash = new Float32Array(GW * GH);
      let left = 0;
      let last = -1;
      let showOpts = true;
      let coherence = 2.5;
      let rnd = mulberry(1);
      let tiles = mkCanvas(1, 1);
      let tw = 0;
      let th = 0;
      let pending: number[] = [];
      let clock = 0;
      const NAMES = ['깊은 바다', '바다', '모래', '풀밭', '숲', '산'];
      const COL = ['#1f4f8c', '#3a88c8', '#ecd59a', '#7cc35a', '#3f8f48', '#9a907e'];
      const run = runner({
        rate: 140,
        hold: 1.8,
        start(seed) {
          rnd = mulberry(seed);
          lo = new Int8Array(GW * GH).fill(0);
          hi = new Int8Array(GW * GH).fill(NT - 1);
          val = new Int8Array(GW * GH).fill(-1);
          flash = new Float32Array(GW * GH).fill(-9);
          left = GW * GH;
          last = -1;
          pending = [];
          tw = 0;
        },
        step() {
          if (left <= 0) return true;
          // 가장 불확실하지 않은(가능성이 적은) 칸
          let best = -1;
          let bs = 1e9;
          for (let i = 0; i < GW * GH; i++) {
            if (val[i]! >= 0) continue;
            const s = hi[i]! - lo[i]! + hash2(i, run.seed, 11) * 0.6;
            if (s < bs) {
              bs = s;
              best = i;
            }
          }
          if (best < 0) return true;
          const x0 = best % GW;
          const y0 = (best / GW) | 0;
          const wts: number[] = [];
          let sum = 0;
          for (let v = lo[best]!; v <= hi[best]!; v++) {
            let w = WT[v]!;
            for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as P2[]) {
              const nx = x0 + dx;
              const ny = y0 + dy;
              if (nx < 0 || ny < 0 || nx >= GW || ny >= GH) continue;
              if (val[ny * GW + nx] === v) w *= coherence;
            }
            wts.push(w);
            sum += w;
          }
          let pick = rnd() * sum;
          let v = lo[best]!;
          for (const w of wts) {
            if (pick < w) break;
            pick -= w;
            v++;
          }
          v = Math.min(v, hi[best]!);
          const st = [best];
          lo[best] = v;
          hi[best] = v;
          // 퍼뜨리기
          while (st.length) {
            const c = st.pop()!;
            const cx = c % GW;
            const cy = (c / GW) | 0;
            for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as P2[]) {
              const nx = cx + dx;
              const ny = cy + dy;
              if (nx < 0 || ny < 0 || nx >= GW || ny >= GH) continue;
              const n = ny * GW + nx;
              const nl = Math.max(lo[n]!, lo[c]! - 1);
              const nh = Math.min(hi[n]!, hi[c]! + 1);
              if (nl !== lo[n] || nh !== hi[n]) {
                lo[n] = nl;
                hi[n] = nh;
                flash[n] = clock;
                st.push(n);
              }
            }
          }
          // 확정된 칸
          for (let i = 0; i < GW * GH; i++)
            if (val[i]! < 0 && lo[i] === hi[i]) {
              val[i] = lo[i]!;
              left--;
              pending.push(i);
            }
          last = best;
          return false;
        },
      });
      run.restart();
      const drawTile = (g: G, v: number, X: number, Y: number, s: number, i: number): void => {
        const n = hash2(i, 7, 3);
        g.fillStyle = COL[v]!;
        g.fillRect(X, Y, s + 0.6, s + 0.6);
        if (v <= 1) {
          g.strokeStyle = v === 0 ? 'rgba(120,180,240,0.45)' : 'rgba(200,235,255,0.55)';
          g.lineWidth = Math.max(1, s * 0.08);
          g.beginPath();
          const yy = Y + s * (0.35 + n * 0.3);
          g.moveTo(X + s * 0.2, yy);
          g.quadraticCurveTo(X + s * 0.35, yy - s * 0.15, X + s * 0.5, yy);
          g.quadraticCurveTo(X + s * 0.65, yy + s * 0.15, X + s * 0.8, yy);
          g.stroke();
        } else if (v === 2) {
          g.fillStyle = 'rgba(170,130,70,0.45)';
          for (let k = 0; k < 3; k++) g.fillRect(X + s * hash2(i, k, 1) * 0.8, Y + s * hash2(i, k, 2) * 0.8, s * 0.1, s * 0.1);
        } else if (v === 3) {
          g.fillStyle = 'rgba(60,130,50,0.7)';
          const tx = X + s * (0.2 + n * 0.5);
          const ty = Y + s * 0.6;
          g.beginPath();
          g.moveTo(tx, ty);
          g.lineTo(tx + s * 0.08, ty - s * 0.25);
          g.lineTo(tx + s * 0.16, ty);
          g.lineTo(tx + s * 0.24, ty - s * 0.2);
          g.lineTo(tx + s * 0.3, ty);
          g.fill();
        } else if (v === 4) {
          g.fillStyle = 'rgba(20,50,20,0.35)';
          g.beginPath();
          g.ellipse(X + s * 0.55, Y + s * 0.78, s * 0.36, s * 0.14, 0, 0, TAU);
          g.fill();
          g.fillStyle = '#2a6a33';
          g.beginPath();
          g.arc(X + s * 0.5, Y + s * 0.48, s * 0.36, 0, TAU);
          g.fill();
          g.fillStyle = '#58a65a';
          g.beginPath();
          g.arc(X + s * 0.42, Y + s * 0.4, s * 0.17, 0, TAU);
          g.fill();
        } else {
          g.fillStyle = '#7a705f';
          g.beginPath();
          g.moveTo(X + s * 0.05, Y + s * 0.92);
          g.lineTo(X + s * 0.5, Y + s * 0.1);
          g.lineTo(X + s * 0.95, Y + s * 0.92);
          g.fill();
          g.fillStyle = '#b6ab95';
          g.beginPath();
          g.moveTo(X + s * 0.5, Y + s * 0.1);
          g.lineTo(X + s * 0.95, Y + s * 0.92);
          g.lineTo(X + s * 0.6, Y + s * 0.92);
          g.fill();
          g.fillStyle = '#fafafa';
          g.beginPath();
          g.moveTo(X + s * 0.5, Y + s * 0.1);
          g.lineTo(X + s * 0.66, Y + s * 0.38);
          g.lineTo(X + s * 0.5, Y + s * 0.32);
          g.lineTo(X + s * 0.36, Y + s * 0.36);
          g.fill();
        }
      };
      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 - 4) / GW, (h - 4) / GH);
          const ox = (w - GW * s) / 2;
          const oy = (h - GH * s) / 2;
          const W = Math.round(w);
          const H = Math.round(h);
          if (tw !== W || th !== H) {
            tw = W;
            th = H;
            tiles = mkCanvas(W, H);
            pending = [];
            for (let i = 0; i < GW * GH; i++) if (val[i]! >= 0) pending.push(i);
          }
          const tg = c2(tiles);
          if (left === GW * GH && pending.length === 0) tg.clearRect(0, 0, W, H);
          for (const i of pending) drawTile(tg, val[i]!, ox + (i % GW) * s, oy + ((i / GW) | 0) * s, s, i);
          pending = [];
          g.fillStyle = '#0d1220';
          g.fillRect(0, 0, w, h);
          g.drawImage(tiles, 0, 0, w, h);
          // 아직 안 정해진 칸
          for (let i = 0; i < GW * GH; i++) {
            if (val[i]! >= 0) continue;
            const X = ox + (i % GW) * s;
            const Y = oy + ((i / GW) | 0) * s;
            g.fillStyle = '#18203a';
            g.fillRect(X + 0.5, Y + 0.5, s - 1, s - 1);
            if (showOpts) {
              const nOpt = hi[i]! - lo[i]! + 1;
              const bw = (s - 3) / NT;
              for (let v = lo[i]!; v <= hi[i]!; v++) {
                g.fillStyle = COL[v]!;
                g.globalAlpha = nOpt === NT ? 0.28 : 0.85;
                g.fillRect(X + 1.5 + v * bw, Y + s * 0.3, Math.max(1, bw - 0.4), s * 0.4);
              }
              g.globalAlpha = 1;
            }
            const fk = 1 - (clock - flash[i]!) / 0.35;
            if (fk > 0) {
              g.strokeStyle = `rgba(120,230,255,${fk * 0.9})`;
              g.lineWidth = 1;
              g.strokeRect(X + 1, Y + 1, s - 2, s - 2);
            }
          }
          if (last >= 0 && !run.holding) {
            g.strokeStyle = '#fff';
            g.lineWidth = 2 * u;
            g.strokeRect(ox + (last % GW) * s, oy + ((last / GW) | 0) * s, s, s);
          }
          const done = GW * GH - left;
          pill(g, run.holding ? '모든 칸 확정 — 규칙을 어긴 이음 없음' : `확정 ${done} / ${GW * GH} 칸`, 8 * ui(u), 13 * ui(u), 9 * ui(u), 'rgba(10,14,30,0.85)');
          if (w > 420) {
            let x = 10 * u;
            const y = h - 13 * u;
            for (let v = 0; v < NT; v++) {
              g.font = `700 ${8 * u}px ${F}`;
              const tw2 = g.measureText(NAMES[v]!).width;
              rr(g, x, y - 7 * u, tw2 + 22 * u, 14 * u, 7 * u);
              g.fillStyle = 'rgba(10,14,30,0.75)';
              g.fill();
              g.fillStyle = COL[v]!;
              g.fillRect(x + 4 * u, y - 4 * u, 8 * u, 8 * u);
              txt(g, NAMES[v]!, x + 15 * u, y, 8 * u, '#fff', 'left');
              x += tw2 + 20 * u;
              if (v < NT - 1) txt(g, '↔', x + 2 * u, y, 8 * u, '#9fb4d8');
              x += 8 * u;
            }
          }
        },
        controls: [
          seedCtl(run),
          speedCtl(run),
          { type: 'toggle', label: '남은 가능성 보기', value: true, on: (v) => { showOpts = v; } },
          { type: 'range', label: '같은 타일 끼리 뭉치기', min: 1, max: 10, step: 0.5, value: 2.5, on: (v) => { coherence = v; run.restart(run.seed); } },
        ],
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [잡음 섬 지도 (높이 → 생물군)](https://ai-techstudio.web.app/ai/t/i245.md) `i245` · [동굴 (셀룰러 오토마타)](https://ai-techstudio.web.app/ai/t/i248.md) `i248`
- 다음에 해 볼 기술: [포아송 원판 흩뿌리기](https://ai-techstudio.web.app/ai/t/i254.md) `i254` · [시드 지도 (같은 수 = 같은 지도)](https://ai-techstudio.web.app/ai/t/i255.md) `i255`
- 참고 문서: [GitHub — mxgmn/WaveFunctionCollapse (원조 구현)](https://github.com/mxgmn/WaveFunctionCollapse)
