# AI 꾸러미 — 부서지는 땅 (비트맵 지형) — Destructible terrain (bitmap mask)
> 땅을 한 칸 = 한 픽셀인 칸 지도(비트맵)로 들고 폭발 자리를 동그랗게 지워, 남은 땅 위를 캐릭터가 걷고 떨어지게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i289

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

## 주문서

### 만들어 줘: 부서지는 땅 (비트맵 지형) — Destructible terrain (bitmap mask)

#### 1. 목표
포물선 대포 놀이에 부서지는 땅을 만들어 줘 — 땅을 칸 지도로 들고 폭발 자리를 동그랗게 지우고, 남은 땅 위로 걷고 떨어지게. 분위기는 노을 언덕 만화.

#### 2. 핵심 기술 용어
- **Destructible terrain (bitmap mask)** — 부서지는 땅 — 칸마다 땅 있음/없음
- **Pixel collision** — 그 칸이 땅인지 보고 부딪힘 판단
- **destination-out compositing** — 그림 캔버스에서 동그랗게 지우기
- **Projectile motion** — 포물선으로 나는 포탄

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

#### 4. 조건
- 충돌용 칸 지도와 보이는 그림을 따로 들고, 폭발 때 둘 다 같은 원으로 지우기
- 그림 지우기는 destination-out, 그을음은 source-atop (남은 땅에만 칠해지게)
- 화면 밖 아래는 땅, 옆 · 위는 빈칸으로 보기 (떨어져 사라지지 않게)
- 빠른 포탄은 한 프레임을 여러 번 나눠 검사 — 얇은 땅을 뚫지 않게

#### 5. 완성 기준 (이게 보이면 성공)
- 화면을 누른 자리에 동그란 구멍이 나고, 둘레가 그을린다
- 발밑 땅이 사라지면 캐릭터가 떨어지고, 아래 땅에 내려앉아 다시 걷는다
- 폭발 크기 슬라이더로 구멍 크기가 바뀐다
- 「칸 지도 보기」를 켜면 그림과 충돌 칸이 똑같이 겹친다

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

## 원리
- 땅을 280 × 175 칸의 0/1 배열(mask)로 둔다. 1 = 땅. 그림은 2배 크기 캔버스에 따로 그려 둔다.
- 폭발하면 반지름 r 원 안의 칸을 0 으로 바꾸고, 그림 캔버스에서도 같은 원을 destination-out 으로 지운다. 테두리엔 그을음.
- 걷는 캐릭터는 발밑 칸이 땅인지만 본다. 땅이면 위로 올라가며 빈칸을 찾아 발 높이로(ground), 아니면 중력으로 떨어진다.
- 앞 칸이 4칸보다 높으면 벽으로 보고 돌아선다. 포탄은 한 프레임을 4번 나눠 움직이며 땅에 닿는지 본다.

## 핵심 코드 — 칸 지도로 땅 들기 · 동그랗게 지우기 · 발 놓을 곳 찾기
(발췌: demos/demos2dMove.ts i289 solid() · explode() · ground() 를 정리)
```ts
const W = 280, H = 175, S = 2;                 // 칸 지도 크기 · 그림 배율
const mask = new Uint8Array(W * H);              // 1 = 땅
const art = document.createElement('canvas');
art.width = W * S; art.height = H * S;           // 땅 그림은 여기에 미리 그려 둔다

function solid(x: number, y: number) {
  const xi = Math.floor(x), yi = Math.floor(y);
  if (xi < 0 || xi >= W) return false;
  if (yi >= H) return true;                      // 화면 아래는 늘 땅
  if (yi < 0) return false;
  return mask[yi * W + xi] === 1;
}
function explode(ex: number, ey: number, r: number) {
  for (let y = Math.max(0, Math.floor(ey - r)); y < Math.min(H, ey + r + 1); y++)
    for (let x = Math.max(0, Math.floor(ex - r)); x < Math.min(W, ex + r + 1); x++)
      if ((x - ex) ** 2 + (y - ey) ** 2 <= r * r) mask[y * W + x] = 0;
  const g = art.getContext('2d')!;
  g.setTransform(S, 0, 0, S, 0, 0);
  g.globalCompositeOperation = 'source-atop';    // 그을음은 남은 땅에만
  const sg = g.createRadialGradient(ex, ey, r * 0.9, ex, ey, r + 4);
  sg.addColorStop(0, 'rgba(30,12,5,.85)'); sg.addColorStop(1, 'rgba(60,25,10,0)');
  g.fillStyle = sg; g.beginPath(); g.arc(ex, ey, r + 4, 0, Math.PI * 2); g.fill();
  g.globalCompositeOperation = 'destination-out'; // 구멍 뚫기
  g.beginPath(); g.arc(ex, ey, r, 0, Math.PI * 2); g.fill();
  g.globalCompositeOperation = 'source-over';
}
/** y 근처에서 위로 올라가며 발 놓을 높이 */
function ground(x: number, y: number) {
  let yy = Math.floor(y);
  while (yy > 0 && solid(x, yy - 1)) yy--;
  return yy;
}
```

## 흔한 실수 · 확인 목록
- [ ] **그림만 지우고 칸 지도를 안 고치면 허공을 걷는다** — 폭발 한 번에 mask 와 그림 캔버스를 같은 원 · 같은 반지름으로 함께 지운다.
- [ ] **빠른 포탄이 얇은 땅을 뚫고 지나간다** — 견본은 한 프레임을 4번 나눠 움직이며 매번 solid 를 본다.
- [ ] **그을음을 source-over 로 칠하면 하늘까지 검게 번진다** — source-atop 으로 칠해 남은 땅 픽셀에만 묻게 한다.

## 완성 기준 체크리스트
- [ ] 화면을 누른 자리에 동그란 구멍이 나고, 둘레가 그을린다
- [ ] 발밑 땅이 사라지면 캐릭터가 떨어지고, 아래 땅에 내려앉아 다시 걷는다
- [ ] 폭발 크기 슬라이더로 구멍 크기가 바뀐다
- [ ] 「칸 지도 보기」를 켜면 그림과 충돌 칸이 똑같이 겹친다

## 이 기술 정보
- id: `i289` · 분류: 2D · 화면 › 2D 움직임 · 충돌 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 칸 지도 4.9만 칸(280×175). 폭발 한 번은 반지름² 칸만 고친다. 그림은 drawImage 한 번.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i289
- 쓰면 좋을 때: 웜즈 꼴 포탄 놀이처럼 땅 모양이 계속 바뀔 때 / 파 내려가는 굴 · 모래 놀이
- 쓰지 말 때: 땅이 바뀌지 않는 판 — 타일 충돌이 더 싸고 단순하다 / 아주 큰 세상 — 칸 지도가 커지면 메모리 · 지우기 비용이 커진다. 대신 조각(청크)으로 나눈다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i289 — `src/demos/demos2dMove.ts:2792`
```ts
const i289: Demo2D = {
  kind: '2d',
  caption: '땅을 칸 지도(비트맵)로 들고 폭발 자리를 동그랗게 지운다 — 남은 땅 위를 꼬마가 걷고 떨어진다',
  make() {
    const inp = new Input();
    const W = 280;
    const H = 175;
    const S = 2;
    const mask = new Uint8Array(W * H);
    let art: HTMLCanvasElement | null = null;
    let boomR = 15;
    let debug = false;
    let seed = 1;
    let shots = 0;
    const solid = (x: number, y: number): boolean => {
      const xi = Math.floor(x);
      const yi = Math.floor(y);
      if (xi < 0 || xi >= W) return false;
      if (yi >= H) return true;
      if (yi < 0) return false;
      return mask[yi * W + xi] === 1;
    };
    const build = (): void => {
      seed++;
      const Rn = rng(seed * 13);
      const p1 = Rn() * TAU;
      const p2 = Rn() * TAU;
      const hs: number[] = [];
      for (let x = 0; x < W; x++) hs.push(108 - Math.sin(x * 0.022 + p1) * 18 - Math.sin(x * 0.061 + p2) * 7 - (x < 50 ? (50 - x) * 0.3 : 0));
      mask.fill(0);
      for (let x = 0; x < W; x++) for (let y = Math.floor(hs[x]!); y < H; y++) mask[y * W + x] = 1;
      // 떠 있는 섬
      const ix = 150 + Rn() * 60;
      for (let y = 46; y < 62; y++) for (let x = Math.floor(ix - 26); x < ix + 26; x++) if (((x - ix) / 26) ** 2 + ((y - 50) / 13) ** 2 < 1 && y > 44) mask[y * W + x] = 1;
      if (!art) art = mkCanvas(W * S, H * S);
      const g = art.getContext('2d')!;
      g.setTransform(S, 0, 0, S, 0, 0);
      g.globalCompositeOperation = 'source-over';
      g.clearRect(0, 0, W, H);
      // 흙 층
      const shape = (): void => {
        g.beginPath();
        g.moveTo(0, H);
        for (let x = 0; x < W; x++) g.lineTo(x, hs[x]!);
        g.lineTo(W, H);
        g.closePath();
        g.moveTo(ix + 26, 50);
        g.ellipse(ix, 50, 26, 13, 0, 0, Math.PI);
        g.lineTo(ix - 26, 50);
        g.closePath();
      };
      shape();
      const dg = g.createLinearGradient(0, 40, 0, H);
      dg.addColorStop(0, '#b97a4a');
      dg.addColorStop(0.5, '#8f5432');
      dg.addColorStop(1, '#5e3420');
      g.fillStyle = dg;
      g.fill();
      g.save();
      shape();
      g.clip();
      for (let i = 0; i < 6; i++) {
        g.strokeStyle = i % 2 ? 'rgba(255,220,170,.08)' : 'rgba(50,20,10,.15)';
        g.lineWidth = 3;
        g.beginPath();
        for (let x = 0; x <= W; x += 6) g.lineTo(x, 118 + i * 10 + Math.sin(x * 0.04 + i) * 3);
        g.stroke();
      }
      for (let i = 0; i < 140; i++) {
        g.fillStyle = Rn() < 0.5 ? 'rgba(60,30,15,.35)' : 'rgba(230,190,140,.25)';
        g.beginPath();
        g.ellipse(Rn() * W, 50 + Rn() * 125, 1 + Rn() * 2, 0.7 + Rn(), Rn() * 3, 0, TAU);
        g.fill();
      }
      g.restore();
      // 풀 윗면
      g.strokeStyle = '#59c94f';
      g.lineWidth = 4;
      g.lineJoin = 'round';
      g.beginPath();
      for (let x = 0; x < W; x++) (x ? g.lineTo(x, hs[x]! + 1.2) : g.moveTo(x, hs[x]! + 1.2));
      g.stroke();
      g.beginPath();
      g.ellipse(ix, 50.5, 25, 1, 0, Math.PI, TAU);
      g.stroke();
      g.strokeStyle = '#a6ef8c';
      g.lineWidth = 1;
      g.beginPath();
      for (let x = 0; x < W; x++) (x ? g.lineTo(x, hs[x]!) : g.moveTo(x, hs[x]!));
      g.stroke();
      g.globalCompositeOperation = 'source-atop';
      g.fillStyle = 'rgba(255,255,255,.04)';
      g.fillRect(0, 0, W, H);
      g.globalCompositeOperation = 'source-over';
      shots = 0;
      tank.y = 0;
      walker.y = 0;
      walker.x = 230;
    };
    const tank = { x: 26, y: 0, vy: 0, ang: -0.9 };
    const walker = { x: 230, y: 0, vy: 0, dir: -1, t: 0, hurt: 0 };
    const debris: Part[] = [];
    const smoke: Part[] = [];
    const rings: { x: number; y: number; r: number; t: number }[] = [];
    let shake = 0;
    let shell: { x: number; y: number; vx: number; vy: number; tr: { x: number; y: number }[] } | null = null;
    let cool = 0.6;
    const R = rng(77);
    const explode = (ex: number, ey: number, r: number): void => {
      for (let y = Math.max(0, Math.floor(ey - r)); y < Math.min(H, ey + r + 1); y++)
        for (let x = Math.max(0, Math.floor(ex - r)); x < Math.min(W, ex + r + 1); x++) if ((x - ex) ** 2 + (y - ey) ** 2 <= r * r) mask[y * W + x] = 0;
      const g = art!.getContext('2d')!;
      g.setTransform(S, 0, 0, S, 0, 0);
      // 그을음 테두리
      g.globalCompositeOperation = 'source-atop';
      const sg = g.createRadialGradient(ex, ey, r * 0.9, ex, ey, r + 4);
      sg.addColorStop(0, 'rgba(30,12,5,.85)');
      sg.addColorStop(0.5, 'rgba(60,25,10,.45)');
      sg.addColorStop(1, 'rgba(60,25,10,0)');
      g.fillStyle = sg;
      g.beginPath();
      g.arc(ex, ey, r + 4, 0, TAU);
      g.fill();
      g.globalCompositeOperation = 'destination-out';
      g.beginPath();
      g.arc(ex, ey, r, 0, TAU);
      g.fill();
      g.globalCompositeOperation = 'source-over';
      for (let i = 0; i < 22; i++) {
        const a = R() * TAU;
        const sp = 40 + R() * 110;
        debris.push({ x: ex + Math.cos(a) * r * 0.6, y: ey + Math.sin(a) * r * 0.6, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 60, life: 1 + R() * 0.6, max: 1.6, size: 1 + R() * 1.6, color: R() < 0.3 ? '#59c94f' : R() < 0.6 ? '#9b603a' : '#6e4127', grav: 380, drag: 0.99 });
      }
      for (let i = 0; i < 10; i++) {
        const a = R() * TAU;
        smoke.push({ x: ex, y: ey, vx: Math.cos(a) * 25, vy: Math.sin(a) * 25 - 15, life: 1.1, max: 1.1, size: 5 + R() * 5, color: 'rgba(80,70,90,.4)', grav: -20, drag: 0.95 });
      }
      rings.push({ x: ex, y: ey, r, t: 0 });
      shake = 0.25;
      if (Math.hypot(walker.x - ex, walker.y - 6 - ey) < r + 10) {
        walker.vy = -140;
        walker.hurt = 0.8;
      }
    };
    const ground = (x: number, y: number): number => {
      // y 부근에서 위로 올라갈 땅 높이 (발 놓을 곳)
      let yy = Math.floor(y);
      while (yy > 0 && solid(x, yy - 1)) yy--;
      return yy;
    };
    const physics = (h: number): void => {
      // 대포
      if (!solid(tank.x, tank.y + 1)) {
        tank.vy += 400 * h;
        tank.y += tank.vy * h;
        if (solid(tank.x, tank.y)) {
          tank.y = ground(tank.x, tank.y);
          tank.vy = 0;
        }
        if (tank.y > H + 20) tank.y = 0;
      } else tank.y = ground(tank.x, tank.y);
      // 꼬마
      walker.t += h;
      walker.hurt -= h;
      if (solid(walker.x, walker.y + 1) && walker.vy >= 0) {
        walker.vy = 0;
        const nx = walker.x + walker.dir * 18 * h;
        const gy = ground(nx, walker.y);
        if (walker.y - gy > 4 || nx < 150 || nx > 272) walker.dir *= -1;
        else {
          walker.x = nx;
          walker.y = solid(nx, walker.y + 1) ? gy : walker.y;
        }
      } else {
        walker.vy += 400 * h;
        walker.y += walker.vy * h;
        if (walker.vy > 0 && solid(walker.x, walker.y)) {
          walker.y = ground(walker.x, walker.y);
          walker.vy = 0;
        }
        if (walker.y > H + 10) {
          walker.y = 0;
          walker.x = 200 + R() * 60;
        }
      }
      // 포탄
      if (shell) {
        const s = shell;
        const n = 4;
        for (let i = 0; i < n && shell; i++) {
          s.vy += (280 * h) / n;
          s.x += (s.vx * h) / n;
          s.y += (s.vy * h) / n;
          if (solid(s.x, s.y) || Math.hypot(walker.x - s.x, walker.y - 6 - s.y) < 6) {
            explode(s.x, s.y, boomR);
            shell = null;
            shots++;
          } else if (s.x > W + 10 || s.y > H + 10) shell = null;
        }
        if (shell) {
          s.tr.push({ x: s.x, y: s.y });
          if (s.tr.length > 14) s.tr.shift();
        }
      } else {
        cool -= h;
        if (cool < 0) {
          if (shots >= 7) build();
          const tx = 120 + R() * 140;
          const vx = 95 + R() * 25;
          const tFly = (tx - tank.x) / vx;
          const ty = 90 + R() * 30;
          const vy = (ty - (tank.y - 8) - 0.5 * 280 * tFly * tFly) / tFly;
          tank.ang = Math.atan2(vy, vx);
          shell = { x: tank.x + Math.cos(tank.ang) * 10, y: tank.y - 8 + Math.sin(tank.ang) * 10, vx, vy, tr: [] };
          cool = 1.1;
        }
      }
      stepParts(debris, h);
      stepParts(smoke, h);
      for (const r of rings) r.t += h;
      while (rings.length && rings[0]!.t > 0.4) rings.shift();
      shake = Math.max(0, shake - h);
    };
    build();
    const step = fixed(1 / 120);
    return {
      controls: [
        { type: 'range', label: '폭발 크기', min: 6, max: 30, step: 1, value: 15, on: (v) => (boomR = v) },
        { type: 'toggle', label: '칸 지도 보기', value: false, on: (v) => (debug = v) },
        { type: 'button', label: '땅 다시 만들기', on: () => build() },
      ] as Control[],
      draw(g, w, h, t, dt) {
        const v = viewOf(w, h);
        inp.sync(g, v);
        for (const e of inp.take()) if (e.k === 'down') explode(e.x, e.y, boomR);
        step(dt, (H2) => physics(H2));
        fillBg(g, w, h, '#3b2f6b', '#f39a7a');
        enter(g, v);
        if (shake > 0) g.translate((R() - 0.5) * shake * 12, (R() - 0.5) * shake * 12);
        // 먼 산 · 구름
        g.fillStyle = 'rgba(120,80,140,.55)';
        g.beginPath();
        g.moveTo(0, 175);
        for (let x = 0; x <= 280; x += 8) g.lineTo(x, 84 - Math.abs(Math.sin(x * 0.025 + 2)) * 34);
        g.lineTo(280, 175);
        g.fill();
        g.fillStyle = 'rgba(255,230,240,.35)';
        for (let i = 0; i < 3; i++) {
          const cx = ((i * 110 + t * 5) % 340) - 30;
          g.beginPath();
          g.ellipse(cx, 22 + i * 9, 20, 5, 0, 0, TAU);
          g.fill();
        }
        g.drawImage(art!, 0, 0, W, H);
        if (debug) {
          const c = 4;
          for (let y = 0; y < H; y += c)
            for (let x = 0; x < W; x += c) {
              const s = mask[(y + 2) * W + x + 2] === 1;
              g.fillStyle = s ? 'rgba(0,229,255,.18)' : 'rgba(0,0,0,0)';
              if (s) g.fillRect(x + 0.3, y + 0.3, c - 0.6, c - 0.6);
            }
          for (const r of rings) {
            g.strokeStyle = '#ff4d6d';
            g.setLineDash([2, 1.5]);
            g.beginPath();
            g.arc(r.x, r.y, r.r, 0, TAU);
            g.stroke();
            g.setLineDash([]);
          }
        }
        // 대포 (탱크)
        g.save();
        g.translate(tank.x, tank.y);
        g.fillStyle = '#4a5a8a';
        g.save();
        g.translate(0, -8);
        g.rotate(tank.ang);
        rr(g, 0, -2, 13, 4, 2);
        g.fill();
        g.restore();
        const tg = g.createLinearGradient(0, -12, 0, 0);
        tg.addColorStop(0, '#9fb4ff');
        tg.addColorStop(1, '#5469b8');
        g.fillStyle = tg;
        rr(g, -9, -9, 18, 8, 4);
        g.fill();
        g.fillStyle = '#2d3558';
        rr(g, -10, -3, 20, 4, 2);
        g.fill();
        g.fillStyle = '#fff';
        g.beginPath();
        g.arc(2, -6, 1.6, 0, TAU);
        g.fill();
        g.fillStyle = '#1a1a33';
        g.beginPath();
        g.arc(2.5, -6, 0.9, 0, TAU);
        g.fill();
        g.restore();
        // 꼬마
        blob(g, walker.x, walker.y, 9, 11, walker.dir, '#fff2a8', '#ffb547', walker.hurt > 0 ? 'hurt' : 'ok');
        // 포탄
        if (shell) {
          shell.tr.forEach((p, i) => {
            g.fillStyle = `rgba(255,220,150,${((i / shell!.tr.length) * 0.5).toFixed(3)})`;
            g.beginPath();
            g.arc(p.x, p.y, 1 + i * 0.08, 0, TAU);
            g.fill();
          });
          g.fillStyle = '#2b2b3a';
          g.beginPath();
          g.arc(shell.x, shell.y, 2.4, 0, TAU);
          g.fill();
          g.fillStyle = '#ffcf6a';
          g.beginPath();
          g.arc(shell.x - 0.7, shell.y - 0.7, 0.8, 0, TAU);
          g.fill();
        }
        // 폭발 빛
        for (const r of rings) {
          const k = r.t / 0.4;
          g.globalCompositeOperation = 'lighter';
          const fg = g.createRadialGradient(r.x, r.y, 0, r.x, r.y, r.r * (1 + k));
          fg.addColorStop(0, `rgba(255,240,180,${(0.9 * (1 - k)).toFixed(3)})`);
          fg.addColorStop(0.5, `rgba(255,140,60,${(0.6 * (1 - k)).toFixed(3)})`);
          fg.addColorStop(1, 'rgba(255,80,40,0)');
          g.fillStyle = fg;
          g.beginPath();
          g.arc(r.x, r.y, r.r * (1 + k), 0, TAU);
          g.fill();
          g.globalCompositeOperation = 'source-over';
          g.strokeStyle = `rgba(255,255,255,${(0.8 * (1 - k)).toFixed(3)})`;
          g.lineWidth = 1.2;
          g.beginPath();
          g.arc(r.x, r.y, r.r * (0.6 + k * 1.2), 0, TAU);
          g.stroke();
        }
        drawParts(g, smoke);
        drawParts(g, debris);
        if (v.big) txt(g, '아무 곳이나 눌러 터뜨리기', 272, 9, 7, 'rgba(255,255,255,.8)', 'right', 700);
        g.restore();
      },
      dispose() {
        inp.dispose();
      },
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [타일 충돌 (축마다 따로 밀어내기)](https://ai-techstudio.web.app/ai/t/i283.md) `i283`
- 다음에 해 볼 기술: [궤도 · 중력 우물](https://ai-techstudio.web.app/ai/t/i290.md) `i290`
