# AI 꾸러미 — 2D 물 표면 (스프링 기둥 · 퍼짐) — 2D water surface (spring columns)
> 수면을 점 72개의 스프링 기둥으로 나누고 이웃으로 높이를 퍼뜨려, 물건이 떨어지면 첨벙 출렁이고 물결이 양옆으로 번지게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i288

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

## 주문서

### 만들어 줘: 2D 물 표면 (스프링 기둥 · 퍼짐) — 2D water surface (spring columns)

#### 1. 목표
물통 · 수조에 출렁이는 2D 물 표면을 넣어 줘 — 수면 점마다 스프링, 이웃으로 퍼지며 물체가 떨어지면 첨벙. 분위기는 노을빛 동화.

#### 2. 핵심 기술 용어
- **2D water surface (spring columns)** — 수면 점마다 위아래로 흔들리는 스프링
- **Wave propagation (spread)** — 이웃 기둥으로 높이 차를 나눠 주기
- **Damping** — 잦아들게 하는 감쇠
- **Buoyancy** — 물에 잠긴 만큼 떠오르는 힘

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

#### 4. 조건
- 물리는 고정 간격(1/120초)으로 — 퍼짐 값이 크면 화면 dt 로는 폭주한다
- 퍼짐 계산은 왼쪽 · 오른쪽 차이를 먼저 다 구한 뒤 한꺼번에 더하기 (구하면서 더하면 한쪽으로 쏠린다)
- 첨벙은 수면에 처음 닿는 순간 한 번만 (inW 같은 「물속」 표시로)
- 튄 물방울이 수면에 다시 떨어지면 그 기둥에도 작은 힘을 준다

#### 5. 완성 기준 (이게 보이면 성공)
- 돌이 떨어지면 수면이 움푹 들어갔다가 물결이 양옆으로 번지고, 물방울이 튄다
- 퍼짐 0 이면 제자리에서만 출렁이고, 0.45 면 멀리까지 번진다
- 잦아드는 정도를 키우면 물결이 금방 잔잔해진다
- 가벼운 공은 둥둥 뜨고, 돌은 가라앉는다

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

## 원리
- 수면을 같은 간격 점 72개(기둥)로 나누고, 기둥마다 「쉬는 높이로 돌아가려는 스프링」을 단다 — 가속 = −K × 높이 − 감쇠 × 속도.
- 각 기둥은 이웃과의 높이 차에 퍼짐 정도(0.28)를 곱한 만큼 이웃을 끌어당긴다. 이걸 6번 되풀이하면 물결이 옆으로 번진다.
- 물건이 수면에 처음 닿으면 닿은 곳 근처 기둥들 속도에 떨어지는 속도를 나눠 준다(첨벙) — 가운데가 가장 크게.
- 수면 높이는 두 기둥 사이를 선형 보간해 읽는다. 물건은 잠긴 깊이만큼 위로 뜨고, 수면 기울기로 미끄러진다.

## 핵심 코드 — 스프링 기둥 + 이웃 퍼짐 + 첨벙
(발췌: demos/demos2dMove.ts i288 physics() · splash() 를 정리)
```ts
const N = 72, REST = 104, DX = 280 / (N - 1);
const hgt = new Float64Array(N), vel = new Float64Array(N);
const lD = new Float64Array(N), rD = new Float64Array(N);
let spread = 0.28, damp = 0.04;

function stepWater(h: number) {
  const K = 0.025 * 3600;
  for (let i = 0; i < N; i++) {                        // 기둥마다 스프링
    vel[i] += (-K * hgt[i] - damp * 60 * vel[i]) * h;
    hgt[i] += vel[i] * h;
  }
  for (let pass = 0; pass < 6; pass++) {               // 이웃으로 퍼지기
    for (let i = 0; i < N; i++) {                      // 차이를 먼저 다 구하고
      lD[i] = i > 0 ? spread * (hgt[i] - hgt[i - 1]) : 0;
      rD[i] = i < N - 1 ? spread * (hgt[i] - hgt[i + 1]) : 0;
    }
    for (let i = 0; i < N; i++) {                      // 한꺼번에 더한다
      if (i > 0) { hgt[i - 1] += lD[i] * h * 8; vel[i - 1] += lD[i] * 60 * h * 8; }
      if (i < N - 1) { hgt[i + 1] += rD[i] * h * 8; vel[i + 1] += rD[i] * 60 * h * 8; }
    }
  }
}
/** 그 x 의 수면 높이 (두 기둥 사이 보간) */
function surfAt(x: number) {
  const f = Math.max(0, Math.min(N - 1.001, x / DX)), i = Math.floor(f);
  return REST + hgt[i] + (hgt[i + 1] - hgt[i]) * (f - i);
}
/** 첨벙 — 가운데일수록 세게 */
function splash(x: number, force: number, wide = 2) {
  const c = Math.round(x / DX);
  for (let i = -wide; i <= wide; i++) {
    const j = c + i;
    if (j >= 0 && j < N) vel[j] += force * (1 - Math.abs(i) / (wide + 1));
  }
}
```

## 흔한 실수 · 확인 목록
- [ ] **퍼짐을 구하면서 바로 더하면 물결이 한쪽으로 쏠린다** — 왼쪽 · 오른쪽 차이 배열(lD · rD)을 먼저 다 채운 뒤 따로 더한다.
- [ ] **물속에 있는 동안 매 프레임 첨벙하면 수면이 끝없이 끓는다** — 처음 닿는 순간 한 번만 splash, 물속에서는 속도가 클 때만 작은 힘(0.02배)을 준다.
- [ ] **퍼짐 값을 0.5 넘게 주면 수면이 폭주한다** — 견본은 0 ~ 0.45 로 막아 둔다. 고정 간격 1/120초도 함께.

## 완성 기준 체크리스트
- [ ] 돌이 떨어지면 수면이 움푹 들어갔다가 물결이 양옆으로 번지고, 물방울이 튄다
- [ ] 퍼짐 0 이면 제자리에서만 출렁이고, 0.45 면 멀리까지 번진다
- [ ] 잦아드는 정도를 키우면 물결이 금방 잔잔해진다
- [ ] 가벼운 공은 둥둥 뜨고, 돌은 가라앉는다

## 이 기술 정보
- id: `i288` · 분류: 2D · 화면 › 2D 움직임 · 충돌 · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 기둥 72개 × 퍼짐 6번 = 프레임마다 몇 백 번 덧셈. 물방울 알갱이까지 폰에서 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i288
- 쓰면 좋을 때: 물통 · 물 위 놀이처럼 수면이 화면의 주인공일 때 / 물건이 떨어지는 순간의 손맛(첨벙 · 물방울)이 필요할 때
- 쓰지 말 때: 물이 쏟아지고 흐르는 장면 — 기둥 방식은 수면 높이만 안다. 대신 입자 유체(SPH) / 3D 물 — 셰이더 수면(높이 지도 · 법선)이 맞다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i288 — `src/demos/demos2dMove.ts:2475`
```ts
const i288: Demo2D = {
  kind: '2d',
  caption: '수면 점마다 스프링 하나 + 이웃으로 퍼지기 — 떨어진 물건이 첨벙, 물결이 양옆으로 번진다',
  make() {
    const inp = new Input();
    const N = 72;
    const REST = 104;
    const DX = 280 / (N - 1);
    const hgt = new Float64Array(N);
    const vel = new Float64Array(N);
    const lD = new Float64Array(N);
    const rD = new Float64Array(N);
    let spread = 0.28;
    let damp = 0.04;
    let debug = false;
    interface Obj {
      kind: 0 | 1 | 2;
      x: number;
      y: number;
      vx: number;
      vy: number;
      r: number;
      ang: number;
      inW: boolean;
      life: number;
    }
    const objs: Obj[] = [{ kind: 2, x: 200, y: REST - 4, vx: 0, vy: 0, r: 8, ang: 0, inW: true, life: 1e9 }];
    const drops: Part[] = [];
    const bubbles: Part[] = [];
    const R = rng(5);
    let spawnT = 0.6;
    let k = 0;
    const surfAt = (x: number): number => {
      const f = clamp(x / DX, 0, N - 1.001);
      const i = Math.floor(f);
      return REST + lerp(hgt[i]!, hgt[i + 1]!, f - i);
    };
    const splash = (x: number, force: number, wide: number): void => {
      const c = Math.round(x / DX);
      for (let i = -wide; i <= wide; i++) {
        const j = c + i;
        if (j < 0 || j >= N) continue;
        vel[j] = vel[j]! + force * (1 - Math.abs(i) / (wide + 1));
      }
      const n = Math.min(26, Math.round(Math.abs(force) / 8));
      for (let i = 0; i < n; i++) {
        const a = -Math.PI / 2 + (R() - 0.5) * 1.6;
        const sp = Math.abs(force) * (0.25 + R() * 0.5);
        drops.push({ x: x + (R() - 0.5) * 8, y: surfAt(x) - 1, vx: Math.cos(a) * sp * 0.6, vy: Math.sin(a) * sp, life: 1.2, max: 1.2, size: 1 + R() * 1.4, color: R() < 0.5 ? '#e8f8ff' : '#9edcff', grav: 420, drag: 0.995 });
      }
    };
    const drop = (x: number, kind: 0 | 1): void => {
      objs.push({ kind, x, y: -12, vx: (R() - 0.5) * 20, vy: 40, r: kind === 0 ? 6 : 7, ang: R() * TAU, inW: false, life: 6 });
    };
    const physics = (h: number): void => {
      const K = 0.025 * 3600;
      for (let i = 0; i < N; i++) {
        const a = -K * hgt[i]! - damp * 60 * vel[i]!;
        vel[i] = vel[i]! + a * h;
        hgt[i] = hgt[i]! + vel[i]! * h;
      }
      for (let pass = 0; pass < 6; pass++) {
        for (let i = 0; i < N; i++) {
          lD[i] = i > 0 ? spread * (hgt[i]! - hgt[i - 1]!) : 0;
          rD[i] = i < N - 1 ? spread * (hgt[i]! - hgt[i + 1]!) : 0;
        }
        for (let i = 0; i < N; i++) {
          if (i > 0) {
            hgt[i - 1] = hgt[i - 1]! + lD[i]! * h * 8;
            vel[i - 1] = vel[i - 1]! + lD[i]! * 60 * h * 8;
          }
          if (i < N - 1) {
            hgt[i + 1] = hgt[i + 1]! + rD[i]! * h * 8;
            vel[i + 1] = vel[i + 1]! + rD[i]! * 60 * h * 8;
          }
        }
      }
      for (const o of objs) {
        const s = surfAt(o.x);
        const depth = o.y + o.r - s;
        if (!o.inW && depth > 0) {
          o.inW = true;
          splash(o.x, o.vy * (o.kind === 0 ? 0.5 : 0.38), 2);
        }
        if (o.inW && depth < -o.r * 2) o.inW = false;
        let ay = 300;
        if (depth > 0) {
          const sub = clamp01(depth / (o.r * 2));
          const dens = o.kind === 0 ? 0.55 : o.kind === 1 ? 2.2 : 2.6;
          ay -= sub * dens * 300;
          o.vy *= Math.pow(o.kind === 0 ? 0.7 : 0.08, h);
          o.vx *= Math.pow(0.3, h);
          const slope = (surfAt(o.x + 4) - surfAt(o.x - 4)) / 8;
          o.vx -= slope * 120 * h;
          if (o.kind !== 0) o.ang = lerp(o.ang, Math.atan(slope), 1 - Math.pow(0.02, h));
          const ci = Math.round(o.x / DX);
          if (ci >= 0 && ci < N && Math.abs(o.vy) > 15) vel[ci] = vel[ci]! + o.vy * 0.02;
          if (o.kind === 0 && R() < 0.15) bubbles.push({ x: o.x + (R() - 0.5) * 6, y: o.y, vx: 0, vy: -30, life: 1, max: 1, size: 0.8 + R(), color: 'rgba(220,245,255,.7)', grav: -10 });
        }
        o.vy += ay * h;
        o.x += o.vx * h;
        o.y += o.vy * h;
        if (o.kind === 0) o.ang += o.vx * h * 0.1;
        if (o.kind === 2) {
          o.vx += (200 - o.x) * 0.3 * h;
          o.ang = clamp(o.ang, -0.6, 0.6);
        }
        if (o.x < 8) o.vx = Math.abs(o.vx);
        if (o.x > 272) o.vx = -Math.abs(o.vx);
        o.life -= h;
      }
      for (let i = objs.length - 1; i >= 0; i--) if (objs[i]!.y > 190 || objs[i]!.life < 0) objs.splice(i, 1);
      for (let i = drops.length - 1; i >= 0; i--) {
        const d = drops[i]!;
        if (d.vy > 0 && d.y > surfAt(d.x)) {
          const ci = Math.round(d.x / DX);
          if (ci >= 0 && ci < N) vel[ci] = vel[ci]! + d.vy * 0.04 * d.size;
          drops.splice(i, 1);
        }
      }
      stepParts(drops, h);
      stepParts(bubbles, h);
      for (let i = bubbles.length - 1; i >= 0; i--) if (bubbles[i]!.y < surfAt(bubbles[i]!.x)) bubbles.splice(i, 1);
    };
    const step = fixed(1 / 120);
    return {
      controls: [
        { type: 'range', label: '퍼지는 정도', min: 0, max: 0.45, step: 0.01, value: 0.28, on: (v) => (spread = v) },
        { type: 'range', label: '잦아드는 정도', min: 0.005, max: 0.15, step: 0.005, value: 0.04, on: (v) => (damp = v) },
        { type: 'toggle', label: '스프링 기둥 보기', value: false, on: (v) => (debug = v) },
        { type: 'button', label: '돌 던지기', on: () => drop(40 + R() * 200, 1) },
      ] 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') objs.push({ kind: 1, x: clamp(e.x, 10, 270), y: Math.min(e.y, surfAt(e.x) - 14), vx: 0, vy: 160, r: 6, ang: 0, inW: false, life: 6 });
        spawnT -= dt;
        if (spawnT < 0) {
          drop(30 + R() * 140, k % 3 === 2 ? 0 : 1);
          k++;
          spawnT = 1.5 + R() * 0.6;
        }
        step(dt, (H) => physics(H));

        fillBg(g, w, h, '#ff9a7a', '#ffd9a8');
        enter(g, v);
        // 해 · 산
        const sun = g.createRadialGradient(70, 70, 2, 70, 70, 40);
        sun.addColorStop(0, 'rgba(255,250,220,1)');
        sun.addColorStop(0.3, 'rgba(255,230,170,.8)');
        sun.addColorStop(1, 'rgba(255,200,150,0)');
        g.fillStyle = sun;
        g.fillRect(20, 20, 100, 100);
        g.fillStyle = '#c87a8a';
        g.beginPath();
        g.moveTo(-20, REST);
        for (let x = -20; x <= 300; x += 10) g.lineTo(x, REST - 18 - Math.abs(Math.sin(x * 0.018 + 1)) * 26);
        g.lineTo(300, REST);
        g.fill();
        g.fillStyle = '#a8607a';
        g.beginPath();
        g.moveTo(-20, REST);
        for (let x = -20; x <= 300; x += 10) g.lineTo(x, REST - 6 - Math.abs(Math.sin(x * 0.03 + 3)) * 12);
        g.lineTo(300, REST);
        g.fill();
        // 물 몸
        const wpath = (): void => {
          g.beginPath();
          g.moveTo(-40, 200);
          g.lineTo(-40, REST + hgt[0]!);
          for (let i = 0; i < N; i++) g.lineTo(i * DX, REST + hgt[i]!);
          g.lineTo(320, REST + hgt[N - 1]!);
          g.lineTo(320, 200);
          g.closePath();
        };
        // 물 아래 물건 먼저(돌) — 물 위에 반투명 물을 덮는다
        const drawObj = (o: Obj): void => {
          g.save();
          g.translate(o.x, o.y);
          g.rotate(o.ang);
          if (o.kind === 1) {
            const rg = g.createRadialGradient(-2, -2, 1, 0, 0, o.r);
            rg.addColorStop(0, '#c8c3d0');
            rg.addColorStop(1, '#6a6478');
            g.fillStyle = rg;
            g.beginPath();
            g.ellipse(0, 0, o.r * 1.1, o.r * 0.85, 0, 0, TAU);
            g.fill();
            g.strokeStyle = 'rgba(40,30,50,.6)';
            g.lineWidth = 0.7;
            g.stroke();
          } else if (o.kind === 0) {
            const cols = ['#ff5a5a', '#fff', '#3fa9ff', '#ffd23f'];
            for (let s = 0; s < 4; s++) {
              g.fillStyle = cols[s]!;
              g.beginPath();
              g.moveTo(0, 0);
              g.arc(0, 0, o.r, (s / 4) * TAU, ((s + 1) / 4) * TAU);
              g.fill();
            }
            g.fillStyle = 'rgba(255,255,255,.55)';
            g.beginPath();
            g.arc(-2, -2.5, 2, 0, TAU);
            g.fill();
            g.strokeStyle = 'rgba(40,30,50,.5)';
            g.lineWidth = 0.6;
            g.beginPath();
            g.arc(0, 0, o.r, 0, TAU);
            g.stroke();
          } else {
            // 오리
            g.fillStyle = '#ffd23f';
            g.beginPath();
            g.ellipse(0, 1, 10, 6, 0, 0, TAU);
            g.fill();
            g.beginPath();
            g.moveTo(-10, 0);
            g.lineTo(-14, -4);
            g.lineTo(-8, -2);
            g.fill();
            g.beginPath();
            g.arc(5, -6, 5, 0, TAU);
            g.fill();
            g.fillStyle = '#ff8a2a';
            g.beginPath();
            g.ellipse(10.5, -5, 3, 1.5, 0, 0, TAU);
            g.fill();
            g.fillStyle = '#2a1830';
            g.beginPath();
            g.arc(6.5, -7, 1, 0, TAU);
            g.fill();
            g.fillStyle = '#f0b020';
            g.beginPath();
            g.ellipse(-2, 0, 5, 3, 0.3, 0, TAU);
            g.fill();
          }
          g.restore();
        };
        for (const o of objs) if (o.kind !== 2) drawObj(o);
        wpath();
        const wg = g.createLinearGradient(0, REST - 10, 0, 175);
        wg.addColorStop(0, 'rgba(70,170,230,.82)');
        wg.addColorStop(1, 'rgba(25,70,150,.95)');
        g.fillStyle = wg;
        g.fill();
        // 빛 줄
        g.save();
        wpath();
        g.clip();
        g.globalCompositeOperation = 'lighter';
        g.strokeStyle = 'rgba(140,220,255,.1)';
        g.lineWidth = 6;
        for (let i = 0; i < 7; i++) {
          const x0 = ((i * 53 + t * 8) % 340) - 30;
          g.beginPath();
          g.moveTo(x0, REST);
          g.lineTo(x0 - 30, 180);
          g.stroke();
        }
        g.globalCompositeOperation = 'source-over';
        // 반사된 해
        g.fillStyle = 'rgba(255,240,200,.25)';
        for (let i = 0; i < 6; i++) g.fillRect(70 - 12 + Math.sin(t * 2 + i) * 4 - i, REST + 4 + i * 4, 24 - i * 2, 1.2);
        g.restore();
        // 수면 선
        g.strokeStyle = 'rgba(230,250,255,.95)';
        g.lineWidth = 1.4;
        g.beginPath();
        for (let i = 0; i < N; i++) (i ? g.lineTo(i * DX, REST + hgt[i]!) : g.moveTo(0, REST + hgt[i]!));
        g.stroke();
        // 거품
        g.fillStyle = 'rgba(255,255,255,.8)';
        for (let i = 1; i < N - 1; i++) {
          const steep = Math.abs(hgt[i + 1]! - hgt[i - 1]!);
          if (steep > 1.6) {
            g.beginPath();
            g.arc(i * DX, REST + hgt[i]! + 0.5, Math.min(1.8, steep * 0.35), 0, TAU);
            g.fill();
          }
        }
        drawParts(g, bubbles);
        for (const o of objs) if (o.kind === 2) drawObj(o);
        drawParts(g, drops);
        if (debug) {
          for (let i = 0; i < N; i += 1) {
            const yy = REST + hgt[i]!;
            g.strokeStyle = hgt[i]! < 0 ? 'rgba(255,230,90,.8)' : 'rgba(255,120,160,.8)';
            g.lineWidth = 0.7;
            g.beginPath();
            g.moveTo(i * DX, REST);
            g.lineTo(i * DX, yy);
            g.stroke();
            g.fillStyle = '#fff';
            g.beginPath();
            g.arc(i * DX, yy, 0.9, 0, TAU);
            g.fill();
          }
          g.setLineDash([2, 2]);
          g.strokeStyle = 'rgba(255,255,255,.5)';
          g.beginPath();
          g.moveTo(0, REST);
          g.lineTo(280, REST);
          g.stroke();
          g.setLineDash([]);
        }
        if (v.big) txt(g, '물 위를 눌러 돌을 떨어뜨리세요', 140, 10, 7, 'rgba(80,30,40,.7)', 'center', 700);
        g.restore();
      },
      dispose() {
        inp.dispose();
      },
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [말랑한 몸 (스프링 젤리 · 압력)](https://ai-techstudio.web.app/ai/t/i287.md) `i287`
- 다음에 해 볼 기술: [부서지는 땅 (비트맵 지형)](https://ai-techstudio.web.app/ai/t/i289.md) `i289`
