# AI 꾸러미 — 쓸고 지나는 충돌 (빠른 물체 뚫림 막기) — Continuous collision detection (swept test)
> 한 걸음에 벽을 건너뛰는 빠른 총알을, 그 순간 자리만 보지 않고 지나온 선분을 반지름만큼 넓힌 벽과 맞대어 닿는 순간을 찾아 뚫림을 막는다.  
> 견본: https://ai-techstudio.web.app/#t/i285

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

## 주문서

### 만들어 줘: 쓸고 지나는 충돌 (빠른 물체 뚫림 막기) — Continuous collision detection (swept test)

#### 1. 목표
빠른 총알 · 공이 벽을 뚫지 않게 쓸고 지나는 충돌을 넣어 줘 — 이번 걸음 시작점 → 끝점 선분이 반지름만큼 넓힌 벽과 만나는 순간을 찾아 거기서 멈춘다. 화면은 순간 검사 · 쓸기 검사 두 줄 비교.

#### 2. 핵심 기술 용어
- **Continuous collision detection (swept test)** — 쓸고 지나는 충돌 — 지나온 길 전체 검사
- **Tunneling** — 빠른 물체가 벽을 뚫고 지나가는 것
- **Minkowski sum (inflated wall)** — 벽을 공 반지름만큼 넓혀 점 하나로 검사
- **Time of impact (TOI)** — 닿는 순간 — 이번 걸음 안 비율 t

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

#### 4. 조건
- 공 반지름만큼 벽을 넓혀(민코프스키) 점 · 선분 검사로 바꾸기
- 이번 걸음의 시작점 · 끝점으로 검사 (끝점만 보지 않기)
- 닿는 순간 자리에 멈추고, 남은 걸음은 튕김으로
- 같은 입력의 「순간 검사」와 나란히 비교, 빠르기 · 검사 횟수 조절

#### 5. 완성 기준 (이게 보이면 성공)
- 빠르기를 올리거나 검사 횟수를 내리면 위 줄(순간 검사)은 벽을 뚫고, 아래 줄(쓸기)은 늘 벽에서 튕긴다
- 뚫림 · 막음 횟수가 줄마다 쌓여 보인다
- 「넓힌 벽」 보기를 켜면 반지름만큼 넓힌 벽이 보인다
- 닿는 자리가 벽 면에 딱 맞는다

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

## 원리
- 1초에 20번 보는데 총알이 초속 560 이면 한 번에 28씩 간다. 벽 두께가 4 면 「그 순간」 자리만 보면 벽 앞 → 벽 뒤로 건너뛰어 놓친다 (견본 위 줄).
- 공(반지름 3)과 벽 대신, 벽을 양쪽으로 3씩 넓히고 공을 점으로 본다 (민코프스키 합). 넓힌 벽 왼쪽 면 L = 벽 x − 3.
- 이번 걸음 x0 → x1 에서 x0 ≤ L ≤ x1 이면 지나온 길에 벽이 있다. 닿는 비율 t = (L − x0) / (x1 − x0), 그 자리 L 에 멈춘다.
- 닿은 뒤엔 튕김: vx × −0.25 · vy = −120 위로 튀며 불꽃.

## 핵심 코드 — 이번 걸음 선분 × 넓힌 벽 → 닿는 순간에 멈추기
(발췌: demos/demos2dMove.ts i285 physTick() 을 정리)
```ts
const WX = 168, WW = 4, RAD = 3; // 벽 x · 두께 · 총알 반지름
interface B { x: number; vx: number; y: number; vy: number; hit: boolean }

// 위 줄 — 순간 검사: 끝점만 본다 (빠르면 뚫린다)
function stepPoint(b: B, h: number) {
  b.x += b.vx * h;
  if (b.x > WX - RAD && b.x < WX + WW + RAD) b.hit = true;
}

// 아래 줄 — 쓸기 검사: 지나온 길 x0 → x1 이 넓힌 벽 면 L 을 건너면 닿음
function stepSwept(b: B, h: number) {
  const x0 = b.x;
  const x1 = b.x + b.vx * h;
  const L = WX - RAD; // 벽을 반지름만큼 넓힌 왼쪽 면 (민코프스키)
  if (x0 <= L && x1 >= L) {
    const t = (L - x0) / (x1 - x0); // 이번 걸음 안 닿는 비율 (0~1)
    void t;                          // 남은 (1 - t) 만큼은 튕긴 뒤 쓸 수 있다
    b.x = L;
    b.hit = true;
  } else b.x = x1;
  if (b.hit) {
    b.vx = -b.vx * 0.25; // 튕김
    b.vy = -120;
  }
}

// 1초에 hz 번 고정 간격
let acc = 0;
function frame(dt: number, hz = 20) {
  acc += Math.min(dt, 0.1);
  const H = 1 / hz;
  while (acc >= H) { acc -= H; stepPoint(top, H); stepSwept(bottom, H); }
}
```

## 흔한 실수 · 확인 목록
- [ ] **끝점만 검사하면 빠른 총알이 얇은 벽을 뚫는다** — 이번 걸음 시작점 → 끝점 선분이 벽을 건너는지 본다.
- [ ] **공을 점으로 보면서 벽을 안 넓히면 공이 벽에 반쯤 박힌다** — 벽을 반지름만큼 넓혀(민코프스키) 점 검사로 바꾼다.
- [ ] **닿은 뒤 끝점으로 옮기면 벽 안에 들어가 다음 걸음에 갇힌다** — 닿는 자리(L)에 멈추고 속도를 바꾼다.
- [ ] **검사 횟수만 올려 막으면 폰에서 무겁다** — 쓸기 검사는 검사 횟수가 적어도 막는다 — 빈도를 올리는 대신 쓸기를.

## 완성 기준 체크리스트
- [ ] 빠르기를 올리거나 검사 횟수를 내리면 위 줄(순간 검사)은 벽을 뚫고, 아래 줄(쓸기)은 늘 벽에서 튕긴다
- [ ] 뚫림 · 막음 횟수가 줄마다 쌓여 보인다
- [ ] 「넓힌 벽」 보기를 켜면 반지름만큼 넓힌 벽이 보인다
- [ ] 닿는 자리가 벽 면에 딱 맞는다

## 이 기술 정보
- id: `i285` · 분류: 2D · 화면 › 2D 움직임 · 충돌 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 걸음마다 선분 × 벽 검사 하나. 벽이 많으면 지나는 길 상자 안 벽만 고른다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i285
- 쓰면 좋을 때: 한 걸음 이동이 벽 두께보다 클 수 있는 빠른 물체 / 검사 횟수(물리 빈도)를 낮춰야 하는 폰 게임
- 쓰지 말 때: 느린 물체 — 순간 검사로 충분하다 / 서로 빠르게 움직이는 두 물체 — 한쪽 기준으로 상대 속도를 써야 한다. 대신 물리 엔진의 연속 충돌

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i285 — `src/demos/demos2dMove.ts:1509`
```ts
const i285: Demo2D = {
  kind: '2d',
  caption: '1/20초마다 한 번 보는 총알 — 위는 그 순간만 검사해 벽을 뚫고, 아래는 지나온 선을 쓸어 닿는 순간을 찾는다',
  make() {
    let speed = 560;
    let hz = 20;
    let showBox = false;
    const WX = 168;
    const WW = 4;
    const WH = 44;
    const RAD = 3;
    const PH = [0.15, 0.6, 0.85, 0.35, 0.05, 0.72, 0.5, 0.95];
    let shot = 0;
    let shotT = 0;
    let acc = 0;
    let tick = 0;
    interface B {
      x: number;
      vx: number;
      prev: number[];
      hit: boolean;
      hitX: number;
      through: boolean;
      pop: number;
      vy: number;
      y: number;
    }
    const mk = (x0: number): B => ({ x: x0, vx: speed, prev: [x0], hit: false, hitX: 0, through: false, pop: -1, vy: 0, y: 0 });
    let lanes: [B, B] = [mk(30), mk(30)];
    const tally = [
      [0, 0],
      [0, 0],
    ];
    const sparks: Part[] = [];
    const fire = (): void => {
      shot++;
      shotT = 0;
      acc = 0;
      tick = 0;
      const step = speed / hz;
      const x0 = 30 + PH[shot % PH.length]! * step;
      lanes = [mk(x0), mk(x0)];
    };
    fire();
    const LY = [46, 128];
    const physTick = (h: number): void => {
      tick++;
      lanes.forEach((b, li) => {
        if (b.pop >= 0) return;
        if (b.hit) {
          b.prev.push(b.x);
          b.vy += 600 * h;
          b.x += b.vx * h;
          b.y += b.vy * h;
          return;
        }
        const x0 = b.x;
        const x1 = b.x + b.vx * h;
        const L = WX - RAD;
        const Rr = WX + WW + RAD;
        if (li === 0) {
          b.x = x1;
          if (b.x > L && b.x < Rr) {
            b.hit = true;
            b.hitX = b.x;
          }
        } else if (x0 <= L && x1 >= L) {
          const tt = (L - x0) / (x1 - x0);
          b.x = L;
          b.hit = true;
          b.hitX = b.x;
          b.prev.push(x0 + (x1 - x0) * tt);
          void tt;
        } else b.x = x1;
        if (b.hit) {
          b.vx = -b.vx * 0.25;
          b.vy = -120;
          tally[li]![1]!++;
          for (let i = 0; i < 12; i++) {
            const a = Math.PI + (Math.random() - 0.5) * 2.2;
            const sp = 40 + Math.random() * 90;
            sparks.push({ x: WX, y: LY[li]!, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, life: 0.4, max: 0.4, size: 1.3, color: i % 2 ? '#ffe27a' : '#ff9a3d', grav: 160 });
          }
        }
        if (!b.hit && x0 < Rr && b.x >= Rr) b.through = true;
        if (!b.hit && b.x > 232) {
          b.pop = 0;
          tally[li]![0]!++;
          for (let i = 0; i < 14; i++) {
            const a = (i / 14) * TAU;
            sparks.push({ x: 238, y: LY[li]!, vx: Math.cos(a) * 70, vy: Math.sin(a) * 70, life: 0.45, max: 0.45, size: 1.6, color: '#ff6f9a', grav: 80 });
          }
        }
        b.prev.push(b.x);
      });
    };
    return {
      controls: [
        { type: 'range', label: '총알 빠르기', min: 60, max: 1200, step: 20, value: 560, on: (v) => (speed = v) },
        { type: 'range', label: '1초에 검사 횟수', min: 8, max: 60, step: 1, value: 20, on: (v) => (hz = v) },
        { type: 'toggle', label: '넓힌 벽(민코프스키) 보기', value: false, on: (v) => (showBox = v) },
        { type: 'button', label: '쏘기', on: () => fire() },
      ] as Control[],
      draw(g, w, h, t, dt) {
        const v = viewOf(w, h);
        shotT += dt;
        acc += Math.min(dt, 0.1);
        const H = 1 / hz;
        while (acc >= H) {
          acc -= H;
          physTick(H);
        }
        stepParts(sparks, dt);
        for (const b of lanes) if (b.pop >= 0) b.pop += dt;
        if (shotT > 2.1) fire();

        fillBg(g, w, h, '#0f1630', '#1b2447');
        enter(g, v);
        const name = ['순간만 검사', '쓸고 지나는 검사'];
        const col = ['#ff6b8b', '#4be3a0'];
        lanes.forEach((b, li) => {
          const cy = LY[li]!;
          const top = cy - 34;
          rr(g, 8, top, 264, 72, 8);
          const lg = g.createLinearGradient(0, top, 0, top + 72);
          lg.addColorStop(0, li ? '#16324a' : '#2f1c3f');
          lg.addColorStop(1, li ? '#0e2034' : '#1d1230');
          g.fillStyle = lg;
          g.fill();
          g.strokeStyle = 'rgba(255,255,255,.12)';
          g.lineWidth = 1;
          g.stroke();
          pill(g, name[li]!, 14, top + 9, 7, col[li]!, '#14102a', 'left');
          txt(g, `뚫림 ${tally[li]![0]} · 막힘 ${tally[li]![1]}`, 266, top + 9, 6.5, 'rgba(255,255,255,.75)', 'right', 700);
          // 대포
          g.fillStyle = '#5d6b9a';
          rr(g, 14, cy - 5, 18, 10, 4);
          g.fill();
          g.fillStyle = '#8796c8';
          rr(g, 16, cy - 3.5, 14, 3, 1.5);
          g.fill();
          g.fillStyle = '#3c466e';
          g.beginPath();
          g.arc(18, cy + 6, 5, 0, TAU);
          g.fill();
          // 벽
          if (showBox) {
            g.setLineDash([2, 2]);
            g.strokeStyle = '#ffe066';
            g.lineWidth = 0.8;
            g.strokeRect(WX - RAD, cy - WH / 2 - RAD, WW + RAD * 2, WH + RAD * 2);
            g.setLineDash([]);
          }
          const flash = b.hit && b.prev.length < 4 ? 1 : 0;
          const wg = g.createLinearGradient(WX, 0, WX + WW, 0);
          wg.addColorStop(0, flash ? '#fff4c0' : '#bfe7ff');
          wg.addColorStop(1, flash ? '#ffd24a' : '#6aa8e0');
          g.fillStyle = wg;
          rr(g, WX, cy - WH / 2, WW, WH, 1.5);
          g.fill();
          g.fillStyle = 'rgba(255,255,255,.6)';
          g.fillRect(WX + 0.6, cy - WH / 2 + 2, 0.8, WH - 4);
          // 풍선 (과녁)
          if (b.pop < 0) {
            const by = cy + Math.sin(t * 2 + li) * 2;
            g.strokeStyle = 'rgba(255,255,255,.5)';
            g.lineWidth = 0.5;
            g.beginPath();
            g.moveTo(240, by + 9);
            g.quadraticCurveTo(243, by + 18, 238, by + 26);
            g.stroke();
            const bg2 = g.createRadialGradient(236, by - 3, 1, 240, by, 10);
            bg2.addColorStop(0, '#ffc1d6');
            bg2.addColorStop(1, '#ff4d7e');
            g.fillStyle = bg2;
            g.beginPath();
            g.ellipse(240, by, 7.5, 9, 0, 0, TAU);
            g.fill();
          } else if (b.pop < 1.2) {
            txt(g, '펑! 뚫렸다', 240, cy, 8, '#ff7a9c', 'center', 800);
          }
          // 프레임 자국
          const stepPx = speed / hz;
          b.prev.forEach((x, i) => {
            const yy = cy + (b.hit ? 0 : 0);
            if (li === 1 && i > 0 && !b.hit) {
              const x0 = b.prev[i - 1]!;
              g.fillStyle = 'rgba(75,227,160,.16)';
              rr(g, Math.min(x0, x) - RAD, yy - RAD, Math.abs(x - x0) + RAD * 2, RAD * 2, RAD);
              g.fill();
            }
            if (i < b.prev.length - 1 && !(b.hit && x < WX - 10 && i > 0 && b.prev[i - 1]! > x)) {
              g.strokeStyle = li ? 'rgba(75,227,160,.55)' : 'rgba(255,107,139,.55)';
              g.lineWidth = 0.8;
              g.beginPath();
              g.arc(x, yy, RAD, 0, TAU);
              g.stroke();
              if (stepPx > 12) txt(g, String(i + 1), x, yy - 7, 4.5, 'rgba(255,255,255,.45)', 'center', 700);
            }
          });
          if (b.pop < 0) {
            const yy = cy + b.y;
            g.globalCompositeOperation = 'lighter';
            const gl = g.createRadialGradient(b.x, yy, 0, b.x, yy, 8);
            gl.addColorStop(0, li ? 'rgba(120,255,200,.7)' : 'rgba(255,150,170,.7)');
            gl.addColorStop(1, 'rgba(0,0,0,0)');
            g.fillStyle = gl;
            g.beginPath();
            g.arc(b.x, yy, 8, 0, TAU);
            g.fill();
            g.globalCompositeOperation = 'source-over';
            g.fillStyle = '#fff';
            g.beginPath();
            g.arc(b.x, yy, RAD, 0, TAU);
            g.fill();
          }
          if (b.through && li === 0) txt(g, '한 걸음에 벽을 건너뜀!', WX, cy + 28, 6.5, '#ff9ab0', 'center', 800);
          if (b.hit && li === 1) txt(g, '닿는 순간에서 멈춤', WX - 30, cy + 28, 6.5, '#8ff5c8', 'center', 800);
        });
        drawParts(g, sparks, true);
        txt(g, `한 번에 ${(speed / hz).toFixed(0)}칸 이동 · 벽 두께 ${WW}`, 140, 86.5, 6.5, 'rgba(255,255,255,.55)', 'center', 600);
        g.restore();
      },
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [분리축 충돌 (SAT · 다각형)](https://ai-techstudio.web.app/ai/t/i284.md) `i284` · [타일 충돌 (축마다 따로 밀어내기)](https://ai-techstudio.web.app/ai/t/i283.md) `i283`
- 다음에 해 볼 기술: [공간 나누기 (쿼드트리)](https://ai-techstudio.web.app/ai/t/i263.md) `i263`
- 참고 문서: [Wikipedia — Minkowski addition](https://en.wikipedia.org/wiki/Minkowski_addition) · [Wikipedia — Collision detection](https://en.wikipedia.org/wiki/Collision_detection)
