# AI 꾸러미 — 2D 물리 엔진 (Box2D · planck) — Box2D (planck.js)
> Box2D 의 JS 판 planck 으로 강체 · 마찰 · 관절 · 센서를 돌리고, 고정 시간 간격(0.02초)으로 나눠 계산해 — 같은 설정값이면 늘 같은 움직임이 나오게 한다.  
> 견본: https://ai-techstudio.web.app/#t/u48

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

## 주문서

### 만들어 줘: 2D 물리 엔진 (Box2D · planck) — Box2D (planck.js)

#### 1. 목표
상자 · 공이 쌓이고 굴러가는 퍼즐을 planck(Box2D) 2D 물리로 만들어 줘 — 중력 -9.81, 0.02초 고정 간격, 반복 8/3, 마찰 0.6. 유니티 2D 와 같은 값으로 똑같이.

#### 2. 핵심 기술 용어
- **Box2D (planck.js)** — 2D 강체 물리 엔진
- **Fixed timestep with accumulator** — 고정 시간 간격 — 남은 시간을 모아 0.02초씩 계산
- **RevoluteJoint · sensor fixture** — 경첩 관절 · 닿기만 알려 주는 센서
- **Velocity / position iterations (8 / 3)** — 한 걸음마다 풀이 반복 횟수

#### 3. 환경
- 플랫폼: TypeScript (브라우저), 라이브러리 없이 — 화면과 떨어진 순수 함수로
- 화면: 2D · 브라우저 — PC · 폰 모두

#### 4. 조건
- 고정 간격(0.02초) + 누적기로 계산, 한 프레임 최대 4걸음
- 물리 단위는 미터 (물체 0.1 ~ 10m) — 픽셀을 그대로 넣으면 엉뚱하게 느리다
- 화면 좌표 변환은 한 곳에서 (y 뒤집기 · 배율 · 원점)
- 안 보이는 물체는 destroyBody 로 지운다 (계속 쌓이지 않게)

#### 5. 완성 기준 (이게 보이면 성공)
- 진자 사슬이 흔들리고, 떨어진 상자 · 공이 경사에 굴러 쌓인다
- 센서 칸에 물체가 들어오면 칸이 초록으로 켜지고 개수가 보인다
- 같은 시작 상태에서 두 번 돌리면 같은 움직임이 나온다
- 탭을 바꿨다 돌아와도 물체가 폭발하듯 튀지 않는다

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

## 원리
- 세계(World)에 중력을 주고, 몸(Body)에 모양(Box · Circle)을 붙인다. 밀도 · 마찰 · 튐(restitution)은 모양마다.
- 화면 프레임 간격은 들쭉날쭉하니, 흐른 시간을 모아 두었다가 0.02초씩 world.step(0.02, 8, 3) 을 부른다 — 같은 입력이면 같은 결과.
- 한 프레임에 최대 4번까지만 계산하고 넘치면 버린다 — 느린 기기에서 계산이 계산을 부르는 「죽음의 나선」을 막는다.
- 관절(RevoluteJoint)은 두 몸을 한 점에 꽂아 돌게 하고, 센서(isSensor)는 부딪히지 않고 닿은 것만 알려 준다.
- 물리 단위는 미터 — 화면에 그릴 때만 배율(픽셀/미터)을 곱하고 y 를 뒤집는다.

## 핵심 코드 — planck 세계 · 관절 · 센서 · 고정 간격 계산
(발췌: demos/demosSim.ts u48 build() · draw() 를 정리)
```ts
import { Box, Circle, RevoluteJoint, World } from 'planck';

const world = new World({ gravity: { x: 0, y: -9.81 } });
const ground = world.createBody();
ground.createFixture({ shape: new Box(8.4, 0.3, { x: 0, y: -0.3 }, 0), friction: 0.6 });

// 관절로 이은 사슬 6마디 + 끝 공
let prev = ground;
const ax = 2.2, ay = 9.6;
for (let i = 0; i < 6; i++) {
  const y = ay - 0.25 - i * 0.5;
  const b = world.createDynamicBody({ position: { x: ax, y } });
  b.createFixture({ shape: new Box(0.08, 0.25), density: 2, friction: 0.6 });
  world.createJoint(new RevoluteJoint({}, prev, b, { x: ax, y: y + 0.25 }));
  prev = b;
}
const bob = world.createDynamicBody({ position: { x: ax, y: ay - 3 - 0.45 } });
bob.createFixture({ shape: new Circle(0.45), density: 4, friction: 0.6 });
world.createJoint(new RevoluteJoint({}, prev, bob, { x: ax, y: ay - 3 }));

// 센서: 부딪히지 않고 닿은 것만 센다
const sensor = world.createBody({ position: { x: 6.3, y: 1.1 } });
sensor.createFixture({ shape: new Box(1.6, 1.1), isSensor: true });
const countInside = () => { let n = 0; for (let ce = sensor.getContactList(); ce; ce = ce.next ?? null) if (ce.contact.isTouching()) n++; return n; };

// 고정 간격 0.02초 — 한 프레임 최대 4걸음
let acc = 0;
function tick(dt: number) {
  acc += Math.min(dt, 0.1);
  let n = 0;
  while (acc >= 0.02 && n < 4) { world.step(0.02, 8, 3); acc -= 0.02; n++; }
  if (n === 4) acc = 0;                     // 밀린 시간은 버린다 (죽음의 나선 막기)
}
```

## 흔한 실수 · 확인 목록
- [ ] **프레임 간격(dt)을 그대로 step 에 넣으면 기기마다 결과가 다르다** — 0.02초 고정 간격 + 누적기로 나눠 계산한다 — 유니티 원본과 같은 값이어야 같은 결과.
- [ ] **픽셀 단위로 몸을 만들면 물체가 달 위처럼 느리게 떨어진다** — Box2D 는 미터 단위(0.1 ~ 10m)에 맞춰져 있다. 그릴 때만 픽셀 배율을 곱한다.
- [ ] **위치 고정 같은 유니티 설정이 planck 에 없다** — 관절 · 계산 뒤 위치 되돌리기로 흉내 낸다 (SEVEN 의 applyLock).
- [ ] **탭이 숨었다 돌아오면 밀린 시간 때문에 수십 걸음을 한꺼번에 돌린다** — dt 를 0.1초로 자르고 한 프레임 최대 4걸음, 넘치면 버린다.

## 완성 기준 체크리스트
- [ ] 진자 사슬이 흔들리고, 떨어진 상자 · 공이 경사에 굴러 쌓인다
- [ ] 센서 칸에 물체가 들어오면 칸이 초록으로 켜지고 개수가 보인다
- [ ] 같은 시작 상태에서 두 번 돌리면 같은 움직임이 나온다
- [ ] 탭을 바꿨다 돌아와도 물체가 폭발하듯 튀지 않는다

## 이 기술 정보
- id: `u48` · 분류: 물리 · 수학 원리 › 물리 · 시뮬레이션 · 2D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 몸 수십 개는 폰도 가볍다. 수백 개가 서로 닿으면 무거워지니, 오래된 것은 지운다 (견본은 떨어진 물체 26개까지).
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u48
- 쓰면 좋을 때: 쌓기 · 굴리기 · 매달기처럼 진짜 같은 2D 움직임 / 유니티 2D 게임을 웹으로 옮길 때 (같은 Box2D 값)
- 쓰지 말 때: 3D 로 굴러야 하는 주사위 — 3D 물리 엔진(i24) / 정해진 칸 위로만 움직이는 퍼즐 — 물리 없이 규칙으로 움직이는 편이 정확하다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### u48 견본 항목 — `src/demos/demosSim.ts:2625`
```ts
  u48: {
    kind: '2d',
    caption: '진짜 planck(Box2D) — 중력 -9.81 · 0.02초 · 반복 8/3. 관절로 이은 진자 사슬, 떨어지는 상자 · 공, 들어오면 켜지는 센서',
    make() {
      interface Item {
        b: PBody;
        kind: 'box' | 'ball' | 'link';
        hx: number;
        hy: number;
        col: string;
      }
      let world!: World;
      let items: Item[] = [];
      let sensor!: PBody;
      let bob!: PBody;
      let grav = -9.81;
      const COLS = ['#ff6a7a', '#ffd23a', '#5ab8ff', '#7ae08a', '#c08aff', '#ff9a4a'];
      const r = rng(11);
      const build = (): void => {
        world = new World({ gravity: { x: 0, y: grav } });
        items = [];
        const ground = world.createBody();
        ground.createFixture({ shape: new Box(8.4, 0.3, { x: 0, y: -0.3 }, 0), friction: 0.6 });
        ground.createFixture({ shape: new Box(0.2, 6, { x: -8.2, y: 5.5 }, 0), friction: 0.6 });
        ground.createFixture({ shape: new Box(0.2, 6, { x: 8.2, y: 5.5 }, 0), friction: 0.6 });
        ground.createFixture({ shape: new Box(2.4, 0.12, { x: -4, y: 4 }, -0.32), friction: 0.3 });
        let prev: PBody = ground;
        const ax = 2.2;
        const ay = 9.6;
        for (let i = 0; i < 6; i++) {
          const y = ay - 0.25 - i * 0.5;
          const b = world.createDynamicBody({ position: { x: ax, y } });
          b.createFixture({ shape: new Box(0.08, 0.25), density: 2, friction: 0.6 });
          world.createJoint(new RevoluteJoint({}, prev, b, { x: ax, y: y + 0.25 }));
          items.push({ b, kind: 'link', hx: 0.08, hy: 0.25, col: '#e8e4f8' });
          prev = b;
        }
        bob = world.createDynamicBody({ position: { x: ax, y: ay - 3 - 0.45 } });
        bob.createFixture({ shape: new Circle(0.45), density: 4, friction: 0.6 });
        world.createJoint(new RevoluteJoint({}, prev, bob, { x: ax, y: ay - 3 }));
        items.push({ b: bob, kind: 'ball', hx: 0.45, hy: 0.45, col: '#ff8a3a' });
        bob.setLinearVelocity({ x: 9, y: 0 });
        sensor = world.createBody({ position: { x: 6.3, y: 1.1 } });
        sensor.createFixture({ shape: new Box(1.6, 1.1), isSensor: true });
      };
      build();
      let acc = 0;
      let spawnT = 0;
      let kickT = 0;
      const drops: Item[] = [];
      return {
        draw(g, w, h, _t, dt) {
          dt = Math.min(dt, 0.1);
          acc += dt;
          let n = 0;
          while (acc >= 0.02 && n < 4) {
            world.step(0.02, 8, 3);
            acc -= 0.02;
            n++;
          }
          if (n === 4) acc = 0;
          spawnT += dt;
          kickT += dt;
          if (spawnT > 0.5) {
            spawnT = 0;
            const ball = r() < 0.45;
            const hx = 0.25 + r() * 0.3;
            const hy = ball ? hx : 0.2 + r() * 0.3;
            const b = world.createDynamicBody({ position: { x: -6.5 + r() * 13.5, y: 11 }, angle: r() * 3 });
            b.createFixture({ shape: ball ? new Circle(hx) : new Box(hx, hy), density: 1, friction: 0.6, restitution: ball ? 0.35 : 0.05 });
            const it: Item = { b, kind: ball ? 'ball' : 'box', hx, hy, col: COLS[Math.floor(r() * COLS.length)]! };
            items.push(it);
            drops.push(it);
            if (drops.length > 26) {
              const old = drops.shift()!;
              world.destroyBody(old.b);
              items.splice(items.indexOf(old), 1);
            }
          }
          if (kickT > 5) {
            kickT = 0;
            const v = bob.getLinearVelocity();
            bob.setLinearVelocity({ x: v.x + (v.x >= 0 ? 6 : -6), y: v.y });
          }
          // 그리기
          bg(g, w, h, '#18245a', '#3a5aa0');
          const s = Math.min(w / 17.6, h / 11.8);
          const ox = w / 2;
          const oy = h / 2 + 5.2 * s;
          const X = (x: number): number => ox + x * s;
          const Y = (y: number): number => oy - y * s;
          g.lineWidth = Math.max(1, s * 0.05);
          g.strokeStyle = '#10142e';
          // 바닥 · 벽 · 경사
          g.fillStyle = '#6a7ab8';
          g.fillRect(X(-8.4), Y(0), 16.8 * s, 0.6 * s);
          g.fillRect(X(-8.4), Y(11.5), 0.4 * s, 11.5 * s);
          g.fillRect(X(7.99), Y(11.5), 0.4 * s, 11.5 * s);
          g.save();
          g.translate(X(-4), Y(4));
          g.rotate(0.32);
          g.fillRect(-2.4 * s, -0.12 * s, 4.8 * s, 0.24 * s);
          g.restore();
          // 센서
          let inside = 0;
          for (let ce = sensor.getContactList(); ce; ce = ce.next ?? null) if (ce.contact.isTouching()) inside++;
          g.save();
          g.setLineDash([s * 0.2, s * 0.15]);
          g.strokeStyle = inside ? '#7affa0' : 'rgba(255,255,255,0.6)';
          g.fillStyle = inside ? 'rgba(122,255,160,0.28)' : 'rgba(255,255,255,0.06)';
          g.fillRect(X(4.7), Y(2.2), 3.2 * s, 2.2 * s);
          g.strokeRect(X(4.7), Y(2.2), 3.2 * s, 2.2 * s);
          g.restore();
          text(g, `센서 ${inside}`, X(6.3), Y(2.6), clamp(s * 0.42, 9, 22), inside ? '#7affa0' : '#dfe6ff', 'center', FONT, '800');
          // 물체
          for (const it of items) {
            const p = it.b.getPosition();
            g.save();
            g.translate(X(p.x), Y(p.y));
            g.rotate(-it.b.getAngle());
            g.fillStyle = it.col;
            g.strokeStyle = '#10142e';
            g.lineWidth = Math.max(1, s * 0.05);
            if (it.kind === 'ball') {
              g.beginPath();
              g.arc(0, 0, it.hx * s, 0, TAU);
              g.fill();
              g.stroke();
              g.beginPath();
              g.moveTo(0, 0);
              g.lineTo(it.hx * s, 0);
              g.stroke();
            } else {
              g.beginPath();
              g.roundRect(-it.hx * s, -it.hy * s, it.hx * 2 * s, it.hy * 2 * s, Math.min(it.hx, it.hy) * s * 0.3);
              g.fill();
              g.stroke();
            }
            g.restore();
          }
          // 관절 점
          g.fillStyle = '#ffd23a';
          for (let j = world.getJointList(); j; j = j.getNext()) {
            const a = j.getAnchorA();
            g.beginPath();
            g.arc(X(a.x), Y(a.y), Math.max(2, s * 0.09), 0, TAU);
            g.fill();
          }
          text(g, '관절 (RevoluteJoint)', X(2.2) + s * 0.4, Y(9.75), clamp(s * 0.36, 8, 18), '#ffe58a', 'left', FONT, '800');
        },
        controls: [
          {
            type: 'range',
            label: '중력 (m/s²)',
            min: -20,
            max: 0,
            step: 0.5,
            value: -9.81,
            on: (v) => {
              grav = v;
              world.setGravity({ x: 0, y: v });
            },
          },
          {
            type: 'button',
            label: '처음부터',
            on: () => {
              drops.length = 0;
              build();
            },
          },
        ],
      };
    },
  }
```

## 관련 기술
- 다음에 해 볼 기술: [3D 강체 물리 (충돌 · 마찰 · 쌓기 — 직접 만든 엔진)](https://ai-techstudio.web.app/ai/t/i24.md) `i24` · [SPH · 입자 물](https://ai-techstudio.web.app/ai/t/i47.md) `i47`
- 참고 문서: [planck.js (GitHub)](https://github.com/piqnt/planck.js) · [Box2D 문서](https://box2d.org/documentation/) · [Gaffer On Games — Fix Your Timestep!](https://gafferongames.com/post/fix_your_timestep/)
