# AI 꾸러미 — 말랑한 몸 (스프링 젤리 · 압력) — Pressure soft body (mass-spring)
> 둘레 점을 스프링으로 잇고 안쪽 넓이를 지키는 압력을 더해, 떨어지면 찌그러졌다가 통 돌아오는 말랑한 슬라임을 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i287

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

## 주문서

### 만들어 줘: 말랑한 몸 (스프링 젤리 · 압력) — Pressure soft body (mass-spring)

#### 1. 목표
슬라임 캐릭터를 압력 말랑 몸으로 만들어 줘 — 둘레 점을 스프링으로 잇고 넓이를 지키는 압력으로 통통 튀게. 분위기는 귀엽고 아기자기.

#### 2. 핵심 기술 용어
- **Pressure soft body (mass-spring)** — 압력 말랑 몸 — 점 · 스프링 · 안쪽 압력
- **Shoelace formula (polygon area)** — 신발끈 공식 — 다각형 넓이
- **Spring-damper (Hooke's law)** — 늘어난 만큼 당기고 속도만큼 버티는 스프링
- **Fixed timestep** — 고정 간격 물리 — 프레임이 흔들려도 같은 결과

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

#### 4. 조건
- 물리는 고정 간격(1/360초)으로 — 화면 dt 를 그대로 쓰면 단단한 스프링이 터진다
- 이웃 스프링 + 한 칸 건너 스프링 두 겹 (건너 스프링이 없으면 몸이 접힌다)
- 넓이는 신발끈 공식, 법선 방향은 넓이 부호로 맞추기 (점 순서가 바뀌어도 바깥쪽)
- 좌표가 NaN 이 되면 처음 모양으로 되살리기
- 그림은 점 사이 중점을 2차 곡선으로 이어 매끈하게

#### 5. 완성 기준 (이게 보이면 성공)
- 슬라임이 바닥에 떨어지면 납작해졌다가 원래 모양으로 통 돌아온다
- 압력 0 → 납작하게 퍼짐, 압력 2 → 단단한 공. 넓이 % 표시가 100% 근처를 지킨다
- 점 하나를 잡아 끌어 던질 수 있고, 놓으면 출렁이며 굴러간다
- 「점 · 스프링 · 압력 보기」를 켜면 건너 스프링과 압력 화살표가 보인다

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

## 원리
- 원 둘레에 점 22개를 놓고, 이웃 점(i ↔ i+1)과 한 칸 건너 점(i ↔ i+2)을 스프링으로 잇는다.
- 스프링 힘 = k × (지금 길이 − 처음 길이) + c × (서로 멀어지는 속도). 건너 잇는 스프링이 몸이 꼬이지 않게 버틴다.
- 신발끈 공식으로 지금 넓이를 재고, 처음 넓이 A0 보다 작으면 각 변을 바깥 법선 쪽으로 민다 — 압력 = 9000 × (A0 − A) / A0.
- 압력을 0 으로 하면 납작하게 퍼지고, 키우면 단단한 공처럼 된다.

## 핵심 코드 — 스프링 두 겹 + 넓이 압력 한 걸음
(발췌: demos/demos2dMove.ts i287 physics() 를 정리)
```ts
const N = 22;
const x = new Float64Array(N), y = new Float64Array(N);
const vx = new Float64Array(N), vy = new Float64Array(N);
const fx = new Float64Array(N), fy = new Float64Array(N);
let A0 = 0, L0 = 0, L2 = 0; // init 에서 처음 넓이 · 이웃 길이 · 건너 길이를 재 둔다

const area = () => { let s = 0; for (let i = 0; i < N; i++) { const j = (i + 1) % N; s += x[i] * y[j] - x[j] * y[i]; } return s / 2; };
function spring(i: number, j: number, rest: number, k: number, c: number) {
  const dx = x[j] - x[i], dy = y[j] - y[i], d = Math.hypot(dx, dy) || 1e-6;
  const nx = dx / d, ny = dy / d;
  const rv = (vx[j] - vx[i]) * nx + (vy[j] - vy[i]) * ny;   // 멀어지는 속도
  const f = k * (d - rest) + c * rv;
  fx[i] += f * nx; fy[i] += f * ny; fx[j] -= f * nx; fy[j] -= f * ny;
}
function physics(h: number, kP = 1, kS = 1) {
  fx.fill(0); fy.fill(0);
  for (let i = 0; i < N; i++) {
    spring(i, (i + 1) % N, L0, 2200 * kS, 10);   // 둘레
    spring(i, (i + 2) % N, L2, 500 * kS, 4);     // 건너 잇기 — 접힘 막기
  }
  const A = area(), sgn = A > 0 ? 1 : -1;
  const P = 9000 * kP * (A0 - Math.abs(A)) / A0; // 줄어든 만큼 밀어낸다
  for (let i = 0; i < N; i++) {
    const j = (i + 1) % N, ex = x[j] - x[i], ey = y[j] - y[i];
    const nx = ey * sgn, ny = -ex * sgn;          // 변의 바깥 법선 (길이 = 변 길이)
    fx[i] += nx * P * 0.25; fy[i] += ny * P * 0.25; fx[j] += nx * P * 0.25; fy[j] += ny * P * 0.25;
  }
  for (let i = 0; i < N; i++) {
    vx[i] = (vx[i] + fx[i] * h) * 0.9995;
    vy[i] = (vy[i] + (fy[i] + 520) * h) * 0.9995; // 520 = 중력
    x[i] += vx[i] * h; y[i] += vy[i] * h;
    if (y[i] > 160) { y[i] = 160; if (vy[i] > 0) vy[i] *= -0.1; vx[i] *= 0.96; } // 바닥 · 마찰
  }
}
```

## 흔한 실수 · 확인 목록
- [ ] **화면 dt 로 바로 적분하면 스프링이 터진다** — 단단한 스프링(2200)은 작은 간격이 필요하다. 1/360초 고정 간격으로 여러 번 돌리고, 한 프레임 최대 횟수를 둔다.
- [ ] **둘레 스프링만 있으면 몸이 안으로 접힌다** — 한 칸 건너 스프링을 더한다 — 견본은 둘레 2200 · 건너 500.
- [ ] **점 순서가 시계 · 반시계로 바뀌면 압력이 안쪽으로 민다** — 넓이의 부호(sgn)를 법선에 곱해 늘 바깥쪽으로.
- [ ] **터진 뒤 NaN 이 퍼지면 화면에서 사라진다** — 중심이 유한한지 매 프레임 보고, 아니면 처음 모양으로 다시 놓는다.

## 완성 기준 체크리스트
- [ ] 슬라임이 바닥에 떨어지면 납작해졌다가 원래 모양으로 통 돌아온다
- [ ] 압력 0 → 납작하게 퍼짐, 압력 2 → 단단한 공. 넓이 % 표시가 100% 근처를 지킨다
- [ ] 점 하나를 잡아 끌어 던질 수 있고, 놓으면 출렁이며 굴러간다
- [ ] 「점 · 스프링 · 압력 보기」를 켜면 건너 스프링과 압력 화살표가 보인다

## 이 기술 정보
- id: `i287` · 분류: 2D · 화면 › 2D 움직임 · 충돌 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 점 22개 · 스프링 44개를 1/360초 간격으로 — 프레임마다 6번 정도. 몸 하나는 폰에서도 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i287
- 쓰면 좋을 때: 슬라임 · 젤리 캐릭터가 땅에 닿을 때 찌그러지는 손맛 / 넓이 보존(모양이 바뀌어도 넓이는 같다)을 보여 줄 때
- 쓰지 말 때: 딱딱한 물체 — 강체 물리(planck 등)가 훨씬 싸고 안정적 / 여러 몸이 서로 부딪는 장면 — 점끼리 충돌이 필요해 무거워진다. 대신 원 하나 강체 + 그림만 찌그러뜨리기

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i287 — `src/demos/demos2dMove.ts:2137`
```ts
const i287: Demo2D = {
  kind: '2d',
  caption: '둘레 점을 스프링으로 잇고 안쪽 넓이를 지키는 압력 — 떨어지면 찌그러졌다 통 돌아오는 슬라임',
  make() {
    const inp = new Input();
    const N = 22;
    const R0 = 23;
    let kP = 1;
    let kS = 1;
    let debug = false;
    const x = new Float64Array(N);
    const y = new Float64Array(N);
    const vx = new Float64Array(N);
    const vy = new Float64Array(N);
    let A0 = 0;
    let L0 = 0;
    let L2 = 0;
    const FLOOR = 160;
    const BUMP = { x: 140, y: 166, r: 15 };
    let jumpT = 1.2;
    let dir = 1;
    let blink = 0;
    let grabI = -1;
    const area = (): number => {
      let s = 0;
      for (let i = 0; i < N; i++) {
        const j = (i + 1) % N;
        s += x[i]! * y[j]! - x[j]! * y[i]!;
      }
      return s / 2;
    };
    const init = (cx: number, cy: number): void => {
      for (let i = 0; i < N; i++) {
        const a = (i / N) * TAU;
        x[i] = cx + Math.cos(a) * R0;
        y[i] = cy + Math.sin(a) * R0;
        vx[i] = 0;
        vy[i] = 0;
      }
      A0 = Math.abs(area());
      L0 = Math.hypot(x[1]! - x[0]!, y[1]! - y[0]!);
      L2 = Math.hypot(x[2]! - x[0]!, y[2]! - y[0]!);
    };
    init(70, 60);
    const fx = new Float64Array(N);
    const fy = new Float64Array(N);
    let lastP = 0;
    const spring = (i: number, j: number, rest: number, k: number, c: number): void => {
      const dx = x[j]! - x[i]!;
      const dy = y[j]! - y[i]!;
      const d = Math.hypot(dx, dy) || 1e-6;
      const nx = dx / d;
      const ny = dy / d;
      const rv = (vx[j]! - vx[i]!) * nx + (vy[j]! - vy[i]!) * ny;
      const f = k * (d - rest) + c * rv;
      fx[i] = fx[i]! + f * nx;
      fy[i] = fy[i]! + f * ny;
      fx[j] = fx[j]! - f * nx;
      fy[j] = fy[j]! - f * ny;
    };
    const physics = (h: number): void => {
      fx.fill(0);
      fy.fill(0);
      for (let i = 0; i < N; i++) {
        spring(i, (i + 1) % N, L0, 2200 * kS, 10);
        spring(i, (i + 2) % N, L2, 500 * kS, 4);
      }
      const A = area();
      const sgn = A > 0 ? 1 : -1;
      const P = 9000 * kP * (A0 - Math.abs(A)) / A0 + (kP > 0 ? 0 : 0);
      lastP = P;
      for (let i = 0; i < N; i++) {
        const j = (i + 1) % N;
        const ex = x[j]! - x[i]!;
        const ey = y[j]! - y[i]!;
        // 바깥 법선 (y 아래 좌표, 넓이 부호로 방향 맞춤)
        const nx = ey * sgn;
        const ny = -ex * sgn;
        const f = P * 0.5;
        fx[i] = fx[i]! + nx * f * 0.5;
        fy[i] = fy[i]! + ny * f * 0.5;
        fx[j] = fx[j]! + nx * f * 0.5;
        fy[j] = fy[j]! + ny * f * 0.5;
      }
      if (grabI >= 0) {
        fx[grabI] = fx[grabI]! + (inp.x - x[grabI]!) * 600 - vx[grabI]! * 20;
        fy[grabI] = fy[grabI]! + (inp.y - y[grabI]!) * 600 - vy[grabI]! * 20;
      }
      for (let i = 0; i < N; i++) {
        vx[i] = (vx[i]! + fx[i]! * h) * 0.9995;
        vy[i] = (vy[i]! + (fy[i]! + 520) * h) * 0.9995;
        x[i] = x[i]! + vx[i]! * h;
        y[i] = y[i]! + vy[i]! * h;
        if (y[i]! > FLOOR) {
          y[i] = FLOOR;
          if (vy[i]! > 0) vy[i] = -vy[i]! * 0.1;
          vx[i] = vx[i]! * 0.96;
        }
        if (x[i]! < 6) {
          x[i] = 6;
          vx[i] = Math.abs(vx[i]!) * 0.3;
        }
        if (x[i]! > 274) {
          x[i] = 274;
          vx[i] = -Math.abs(vx[i]!) * 0.3;
        }
        if (y[i]! < 4) {
          y[i] = 4;
          vy[i] = Math.abs(vy[i]!) * 0.3;
        }
        const bx = x[i]! - BUMP.x;
        const by = y[i]! - BUMP.y;
        const bd = Math.hypot(bx, by);
        if (bd < BUMP.r) {
          const nx = bx / bd;
          const ny = by / bd;
          x[i] = BUMP.x + nx * BUMP.r;
          y[i] = BUMP.y + ny * BUMP.r;
          const vn = vx[i]! * nx + vy[i]! * ny;
          if (vn < 0) {
            vx[i] = vx[i]! - vn * nx * 1.1;
            vy[i] = vy[i]! - vn * ny * 1.1;
          }
        }
      }
    };
    const center = (): [number, number] => {
      let sx = 0;
      let sy = 0;
      for (let i = 0; i < N; i++) {
        sx += x[i]!;
        sy += y[i]!;
      }
      return [sx / N, sy / N];
    };
    const jump = (): void => {
      const [cx] = center();
      dir = cx < 140 ? 1 : -1;
      for (let i = 0; i < N; i++) {
        vy[i] = vy[i]! - 330;
        vx[i] = vx[i]! + dir * 95;
      }
    };
    const step = fixed(1 / 360);
    return {
      controls: [
        { type: 'range', label: '안쪽 압력', min: 0, max: 2, step: 0.05, value: 1, on: (v) => (kP = v) },
        { type: 'range', label: '스프링 단단함', min: 0.2, max: 2.5, step: 0.05, value: 1, on: (v) => (kS = v) },
        { type: 'toggle', label: '점 · 스프링 · 압력 보기', value: false, on: (v) => (debug = v) },
        { type: 'button', label: '점프!', on: () => jump() },
      ] 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') {
            let bi = -1;
            let bd = 30;
            for (let i = 0; i < N; i++) {
              const d = Math.hypot(x[i]! - e.x, y[i]! - e.y);
              if (d < bd) {
                bd = d;
                bi = i;
              }
            }
            grabI = bi;
          } else grabI = -1;
        }
        step(dt, (H) => physics(H));
        jumpT -= dt;
        if (jumpT < 0 && grabI < 0) {
          jump();
          jumpT = 2.3;
        }
        blink -= dt;
        if (blink < -3) blink = 0.12;
        // 터지면 되살리기
        const [cx, cy] = center();
        if (!isFinite(cx) || !isFinite(cy)) init(70, 60);

        fillBg(g, w, h, '#1d2a3f', '#2c4058');
        enter(g, v);
        // 뒤 장식
        g.fillStyle = 'rgba(160,255,200,.05)';
        for (let i = 0; i < 6; i++) {
          g.beginPath();
          g.arc(30 + i * 48, 40 + Math.sin(i * 2.3) * 20, 18 + (i % 3) * 6, 0, TAU);
          g.fill();
        }
        // 바닥
        const fg = g.createLinearGradient(0, FLOOR, 0, 175);
        fg.addColorStop(0, '#6d8aa6');
        fg.addColorStop(1, '#3d546d');
        g.fillStyle = fg;
        g.fillRect(-60, FLOOR, 400, 20);
        g.fillStyle = 'rgba(255,255,255,.25)';
        g.fillRect(-60, FLOOR, 400, 1);
        // 혹 (돌)
        const bg2 = g.createRadialGradient(BUMP.x - 5, BUMP.y - 10, 1, BUMP.x, BUMP.y, BUMP.r);
        bg2.addColorStop(0, '#c9d6e6');
        bg2.addColorStop(1, '#7c8da3');
        g.fillStyle = bg2;
        g.beginPath();
        g.arc(BUMP.x, BUMP.y, BUMP.r - 0.5, Math.PI, TAU);
        g.fill();
        // 그림자
        const hgt = FLOOR - cy;
        g.fillStyle = `rgba(0,0,0,${(0.3 * clamp01(1 - hgt / 140)).toFixed(3)})`;
        g.beginPath();
        g.ellipse(cx, FLOOR + 1, 24 * (1 - hgt / 300), 3, 0, 0, TAU);
        g.fill();
        // 몸 (중점을 잇는 부드러운 곡선)
        const path = (): void => {
          g.beginPath();
          const mx0 = (x[N - 1]! + x[0]!) / 2;
          const my0 = (y[N - 1]! + y[0]!) / 2;
          g.moveTo(mx0, my0);
          for (let i = 0; i < N; i++) {
            const j = (i + 1) % N;
            g.quadraticCurveTo(x[i]!, y[i]!, (x[i]! + x[j]!) / 2, (y[i]! + y[j]!) / 2);
          }
          g.closePath();
        };
        let minX = 1e9;
        let maxX = -1e9;
        let minY = 1e9;
        let maxY = -1e9;
        for (let i = 0; i < N; i++) {
          minX = Math.min(minX, x[i]!);
          maxX = Math.max(maxX, x[i]!);
          minY = Math.min(minY, y[i]!);
          maxY = Math.max(maxY, y[i]!);
        }
        const bw = maxX - minX;
        const bh = maxY - minY;
        path();
        const sg = g.createRadialGradient(cx - bw * 0.2, cy - bh * 0.3, 2, cx, cy, Math.max(bw, bh) * 0.7);
        sg.addColorStop(0, '#c8ffd6');
        sg.addColorStop(0.45, '#5fe08f');
        sg.addColorStop(1, '#1f9e5c');
        g.fillStyle = sg;
        g.globalAlpha = 0.95;
        g.fill();
        g.globalAlpha = 1;
        g.lineWidth = 1.6;
        g.strokeStyle = '#13724a';
        g.stroke();
        // 속 거품
        g.save();
        path();
        g.clip();
        g.fillStyle = 'rgba(255,255,255,.25)';
        for (let i = 0; i < 4; i++) {
          g.beginPath();
          g.arc(cx + Math.sin(t * 0.9 + i * 2) * bw * 0.25, cy + Math.cos(t * 0.7 + i) * bh * 0.2 + 4, 1.2 + (i % 2), 0, TAU);
          g.fill();
        }
        g.fillStyle = 'rgba(255,255,255,.6)';
        g.beginPath();
        g.ellipse(cx - bw * 0.22, cy - bh * 0.28, bw * 0.12, bh * 0.07, -0.6, 0, TAU);
        g.fill();
        g.restore();
        // 얼굴 (가로 · 세로 비율로 찌그러짐)
        const sx = clamp(bw / (R0 * 2), 0.6, 1.8);
        const sy = clamp(bh / (R0 * 2), 0.5, 1.6);
        const vxm = vx.reduce((s, q) => s + q, 0) / N;
        const look = clamp(vxm / 200, -1, 1) * 2;
        for (const s of [-1, 1]) {
          const ex = cx + s * 7 * sx + look;
          const ey = cy - 3 * sy;
          g.fillStyle = '#fff';
          g.beginPath();
          g.ellipse(ex, ey, 3.6 * sx, (blink > 0 ? 0.6 : 4.4) * sy, 0, 0, TAU);
          g.fill();
          if (blink <= 0) {
            g.fillStyle = '#123828';
            g.beginPath();
            g.arc(ex + look * 0.4, ey + 0.6, 2.1 * Math.min(sx, sy), 0, TAU);
            g.fill();
            g.fillStyle = '#fff';
            g.beginPath();
            g.arc(ex + look * 0.4 - 0.7, ey - 0.5, 0.7, 0, TAU);
            g.fill();
          }
        }
        g.strokeStyle = '#123828';
        g.lineWidth = 1;
        g.beginPath();
        g.arc(cx + look, cy + 4 * sy, 3 * sx, 0.15, Math.PI - 0.15);
        g.stroke();
        g.fillStyle = 'rgba(255,140,170,.5)';
        for (const s of [-1, 1]) {
          g.beginPath();
          g.ellipse(cx + s * 12 * sx, cy + 3 * sy, 2.6, 1.4, 0, 0, TAU);
          g.fill();
        }
        if (debug) {
          g.strokeStyle = 'rgba(0,230,255,.4)';
          g.lineWidth = 0.5;
          for (let i = 0; i < N; i++) {
            const j = (i + 2) % N;
            g.beginPath();
            g.moveTo(x[i]!, y[i]!);
            g.lineTo(x[j]!, y[j]!);
            g.stroke();
          }
          const A = area();
          const sgn = A > 0 ? 1 : -1;
          for (let i = 0; i < N; i++) {
            const j = (i + 1) % N;
            const mx = (x[i]! + x[j]!) / 2;
            const my = (y[i]! + y[j]!) / 2;
            const ex = x[j]! - x[i]!;
            const ey = y[j]! - y[i]!;
            const l = Math.hypot(ex, ey) || 1;
            const k = clamp(lastP / 900, -1.2, 1.2) * 8;
            if (Math.abs(k) > 0.6) arrow(g, mx, my, mx + (ey / l) * sgn * k, my + (-ex / l) * sgn * k, k > 0 ? '#ffd166' : '#ff6b8b', 0.8, 2.4);
            g.fillStyle = '#00e5ff';
            g.beginPath();
            g.arc(x[i]!, y[i]!, 1.2, 0, TAU);
            g.fill();
          }
        }
        const ratio = Math.abs(area()) / A0;
        pill(g, `넓이 ${(ratio * 100).toFixed(0)}%`, 272, 10, 7, ratio < 0.85 ? '#e2365b' : 'rgba(10,20,30,.6)', '#fff', 'right');
        if (kP === 0) pill(g, '압력 0 → 납작', 8, 10, 7, '#e2365b', '#fff', 'left');
        else if (v.big) txt(g, '슬라임을 잡아 끌어 던져 보세요', 8, 10, 7, 'rgba(255,255,255,.65)', 'left', 600);
        g.restore();
      },
      dispose() {
        inp.dispose();
      },
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [베를레 밧줄 · 사슬](https://ai-techstudio.web.app/ai/t/i286.md) `i286`
- 다음에 해 볼 기술: [2D 물 표면 (스프링 기둥 · 퍼짐)](https://ai-techstudio.web.app/ai/t/i288.md) `i288` · [스프링 · 감쇠 움직임 (임계 감쇠)](https://ai-techstudio.web.app/ai/t/i293.md) `i293`
- 참고 문서: [Wikipedia — Shoelace formula](https://en.wikipedia.org/wiki/Shoelace_formula)
