# AI 꾸러미 — 스프링 · 감쇠 움직임 (임계 감쇠) — Damped harmonic oscillator
> 목표를 따라가는 스프링에 감쇠비 ζ 를 달리 주어, 덜 감쇠(출렁) · 임계 감쇠(가장 빨리 딱) · 과감쇠(느릿느릿)를 나란히 비교한다.  
> 견본: https://ai-techstudio.web.app/#t/i293

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

## 주문서

### 만들어 줘: 스프링 · 감쇠 움직임 (임계 감쇠) — Damped harmonic oscillator

#### 1. 목표
단추 · 카드 움직임이 스프링으로 목표를 따라가게 해 줘 — 진동수 f 와 감쇠비 ζ 로, 덜 감쇠 · 임계 · 과감쇠를 고를 수 있게. 분위기는 통통 튀는 귀여움.

#### 2. 핵심 기술 용어
- **Damped harmonic oscillator** — 감쇠 진동 — 스프링 + 버티는 힘
- **Damping ratio ζ (critical damping ζ = 1)** — 감쇠비 — 1 이면 넘치지 않고 가장 빨리 멈춤
- **Angular frequency ω = 2πf** — 단단함을 진동수로
- **Spring follow (smooth damp)** — 목표가 바뀌어도 속도를 이어받아 따라가기

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

#### 4. 조건
- 조절 값은 「진동수(Hz) · 감쇠비 ζ」 두 개로 (k · c 를 직접 주면 감이 안 온다)
- 고정 간격(1/240초)으로 적분 — 화면 dt 를 그대로 쓰면 기기마다 출렁임이 다르다
- 목표가 바뀔 때 위치 · 속도를 0 으로 되돌리지 않기 (이어받는 것이 핵심)
- 「멈춤」 판정은 거리 · 속도 둘 다 작을 때 (견본: 1.2px · 8px/초)

#### 5. 완성 기준 (이게 보이면 성공)
- 세 줄이 같은 목표를 따라가는데 덜 감쇠는 출렁, 임계는 가장 빨리 딱, 과감쇠는 천천히 도착한다
- 각 줄 아래에 「○초에 멈춤」이 나오고 임계 감쇠가 가장 짧다
- 첫 줄 ζ 를 0.02 → 0.95 로 바꾸면 출렁임이 줄어 임계에 가까워진다
- 움직이는 중에 목표를 옮겨도 덜컥 끊기지 않는다

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

## 원리
- ω = 2π × 진동수. 가속 = ω² × (목표 − 위치) − 2ζω × 속도. 앞은 끌어당김, 뒤는 버팀.
- ζ < 1 덜 감쇠 — 목표를 넘었다 돌아오며 출렁인다. ζ = 1 임계 감쇠 — 넘치지 않고 가장 빨리 멈춘다. ζ > 1 과감쇠 — 넘치지 않지만 느리다.
- 목표가 갑자기 바뀌어도 지금 속도를 그대로 이어받으므로 끊김 없이 방향을 튼다 (이징 트윈과의 차이).
- 1/240초 고정 간격으로 「속도 먼저, 위치 나중」 — 단단해도 안정적이다.

## 핵심 코드 — 진동수 · 감쇠비 스프링 따라가기
(발췌: demos/demos2dMove.ts i293 step 안쪽 · fixed() 를 정리)
```ts
/** 고정 간격 — 프레임이 들쭉날쭉해도 h 초씩 똑같이 */
function fixed(h: number) {
  let acc = 0;
  return (dt: number, f: (h: number) => void) => {
    acc += Math.min(Math.max(dt, 0), 0.1);
    let n = 0;
    while (acc >= h && n < 40) { f(h); acc -= h; n++; }
    if (n >= 40) acc = 0;
  };
}
const s = { x: 22, v: 0 };
let target = 150, freq = 1.6, zeta = 1;          // ζ = 1 임계 · < 1 출렁 · > 1 느릿
const step = fixed(1 / 240);

function update(dt: number) {
  step(dt, (H) => {
    const om = 2 * Math.PI * freq;
    const a = om * om * (target - s.x) - 2 * zeta * om * s.v; // 끌어당김 − 버팀
    s.v += a * H;                                 // 속도 먼저
    s.x += s.v * H;                               // 위치 나중
  });
}
const settled = () => Math.abs(target - s.x) < 1.2 && Math.abs(s.v) < 8;
```

## 흔한 실수 · 확인 목록
- [ ] **k · c 를 따로 주면 조절할 때마다 출렁임이 엉뚱하게 바뀐다** — 진동수 f 와 감쇠비 ζ 로 바꿔 쓴다 — k = ω², c = 2ζω.
- [ ] **목표가 바뀔 때 속도를 0 으로 만들면 덜컥 멈칫한다** — 위치 · 속도는 그대로 두고 목표만 바꾼다.
- [ ] **탭이 숨었다 돌아오면 큰 dt 로 한꺼번에 튄다** — dt 를 0.1초로 자르고, 한 프레임 최대 40걸음 넘으면 남은 시간을 버린다 (견본 fixed).

## 완성 기준 체크리스트
- [ ] 세 줄이 같은 목표를 따라가는데 덜 감쇠는 출렁, 임계는 가장 빨리 딱, 과감쇠는 천천히 도착한다
- [ ] 각 줄 아래에 「○초에 멈춤」이 나오고 임계 감쇠가 가장 짧다
- [ ] 첫 줄 ζ 를 0.02 → 0.95 로 바꾸면 출렁임이 줄어 임계에 가까워진다
- [ ] 움직이는 중에 목표를 옮겨도 덜컥 끊기지 않는다

## 이 기술 정보
- id: `i293` · 분류: 2D · 화면 › 2D 움직임 · 충돌 · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 값 하나에 곱셈 몇 번. 1/240초 간격이라 프레임마다 4번쯤 — 비용 없음.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i293
- 쓰면 좋을 때: 목표가 계속 바뀌는 따라가기 (마우스 · 카메라 · 끌기) / UI 가 「살아 있는」 느낌으로 통 멈추게 할 때
- 쓰지 말 때: 정확히 몇 초에 끝나야 하는 연출 — 스프링은 끝나는 시간이 정해지지 않는다. 대신 이징(i292) / 아주 단단한 스프링을 큰 dt 로 — 폭주한다. 고정 간격을 쓴다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i293 — `src/demos/demos2dMove.ts:3977`
```ts
const i293: Demo2D = {
  kind: '2d',
  caption: '목표를 따라가는 스프링 셋 — 덜 감쇠는 출렁, 임계 감쇠는 가장 빨리 딱, 과감쇠는 느릿느릿',
  make() {
    const inp = new Input();
    let freq = 1.6;
    let z0 = 0.15;
    let graph = true;
    const rows = [
      { z: 0.15, name: '덜 감쇠', tag: '출렁출렁', c: '#ff8a5c' },
      { z: 1, name: '임계 감쇠', tag: '딱 맞게', c: '#5cf0a8' },
      { z: 2.6, name: '과감쇠', tag: '느릿느릿', c: '#7ad7ff' },
    ];
    const X0 = 22;
    const X1 = 150;
    let target = X0;
    const st = rows.map(() => ({ x: X0, v: 0, hist: [] as number[], tHist: [] as number[] }));
    let tt = 1.4;
    let side = 0;
    let acc = 0;
    let settle = [0, 0, 0];
    let lastJump = 0;
    let time = 0;
    const step = fixed(1 / 240);
    return {
      controls: [
        { type: 'range', label: '단단함 (진동수 Hz)', min: 0.4, max: 3, step: 0.1, value: 1.6, on: (v) => (freq = v) },
        { type: 'range', label: '첫 줄 감쇠비 ζ', min: 0.02, max: 0.95, step: 0.01, value: 0.15, on: (v) => (z0 = v) },
        { type: 'toggle', label: '시간 그래프', value: true, on: (v) => (graph = v) },
      ] as Control[],
      draw(g, w, h, _t, dt) {
        const v = viewOf(w, h);
        inp.sync(g, v);
        rows[0]!.z = z0;
        for (const e of inp.take())
          if (e.k === 'down') {
            target = clamp(e.x, X0, X1);
            tt = 4;
            lastJump = time;
            settle = [0, 0, 0];
          }
        tt -= dt;
        if (tt < 0) {
          side = 1 - side;
          target = side ? X1 : X0;
          tt = 2.4;
          lastJump = time;
          settle = [0, 0, 0];
        }
        step(dt, (H) => {
          time += H;
          const om = TAU * freq;
          st.forEach((s, i) => {
            const a = om * om * (target - s.x) - 2 * rows[i]!.z * om * s.v;
            s.v += a * H;
            s.x += s.v * H;
            if (!settle[i] && Math.abs(target - s.x) < 1.2 && Math.abs(s.v) < 8 && time - lastJump > 0.02) settle[i] = time - lastJump;
            if (settle[i] && (Math.abs(target - s.x) > 1.5 || Math.abs(s.v) > 10)) settle[i] = 0;
          });
          acc += H;
          if (acc >= 1 / 60) {
            acc -= 1 / 60;
            st.forEach((s) => {
              s.hist.push(s.x);
              s.tHist.push(target);
              if (s.hist.length > 150) {
                s.hist.shift();
                s.tHist.shift();
              }
            });
          }
        });
        fillBg(g, w, h, '#131a30', '#1e2a4a');
        enter(g, v);
        rows.forEach((r, i) => {
          const s = st[i]!;
          const top = 4 + i * 57;
          const cy = top + 31;
          rr(g, 4, top, 272, 53, 7);
          g.fillStyle = 'rgba(255,255,255,.045)';
          g.fill();
          txt(g, `${r.name}  ζ = ${r.z.toFixed(2)}`, 10, top + 8, 7, r.c, 'left', 800);
          txt(g, r.tag, 150, top + 8, 6, 'rgba(255,255,255,.55)', 'right', 700);
          // 길
          g.fillStyle = 'rgba(255,255,255,.08)';
          rr(g, X0 - 10, cy - 1, X1 - X0 + 20, 2, 1);
          g.fill();
          // 목표 깃발
          g.strokeStyle = 'rgba(255,255,255,.6)';
          g.lineWidth = 0.8;
          g.setLineDash([2, 2]);
          g.beginPath();
          g.moveTo(target, cy - 16);
          g.lineTo(target, cy + 15);
          g.stroke();
          g.setLineDash([]);
          g.fillStyle = '#ffe066';
          g.beginPath();
          g.moveTo(target, cy - 17);
          g.lineTo(target + 7, cy - 14.5);
          g.lineTo(target, cy - 12);
          g.fill();
          // 따라가는 카드 (속도로 늘어남)
          const sq = clamp(Math.abs(s.v) / 500, 0, 0.35);
          const cw = 18 * (1 + sq);
          const ch = 16 * (1 - sq * 0.5);
          g.save();
          g.translate(s.x, cy);
          g.fillStyle = 'rgba(0,0,0,.25)';
          rr(g, -cw / 2 + 1, -ch / 2 + 2, cw, ch, 4);
          g.fill();
          const cg = g.createLinearGradient(0, -ch / 2, 0, ch / 2);
          cg.addColorStop(0, '#ffffff');
          cg.addColorStop(1, r.c);
          g.fillStyle = cg;
          rr(g, -cw / 2, -ch / 2, cw, ch, 4);
          g.fill();
          g.strokeStyle = 'rgba(20,20,40,.5)';
          g.lineWidth = 0.7;
          g.stroke();
          const look = clamp(s.v / 200, -1.5, 1.5);
          g.fillStyle = '#1a1433';
          g.beginPath();
          g.arc(-3 + look, -1, 1.1, 0, TAU);
          g.arc(3 + look, -1, 1.1, 0, TAU);
          g.fill();
          g.strokeStyle = '#1a1433';
          g.beginPath();
          g.arc(look, 2, 1.8, 0.2, Math.PI - 0.2);
          g.stroke();
          g.restore();
          if (settle[i]) pill(g, `${settle[i]!.toFixed(2)}초에 멈춤`, s.x, cy + 16, 5.5, 'rgba(10,14,30,.8)', r.c);
          // 그래프
          if (graph) {
            const gx = 160;
            const gw = 110;
            const gy = top + 6;
            const gh = 42;
            rr(g, gx, gy, gw, gh, 4);
            g.fillStyle = 'rgba(0,0,0,.25)';
            g.fill();
            const Y = (x: number): number => gy + gh - 6 - ((x - X0) / (X1 - X0)) * (gh - 12);
            g.strokeStyle = 'rgba(255,224,102,.6)';
            g.lineWidth = 0.7;
            g.setLineDash([2, 1.5]);
            g.beginPath();
            s.tHist.forEach((q, k) => (k ? g.lineTo(gx + (k / 150) * gw, Y(q)) : g.moveTo(gx, Y(q))));
            g.stroke();
            g.setLineDash([]);
            g.save();
            rr(g, gx, gy, gw, gh, 4);
            g.clip();
            g.strokeStyle = r.c;
            g.lineWidth = 1.3;
            g.beginPath();
            s.hist.forEach((q, k) => (k ? g.lineTo(gx + (k / 150) * gw, Y(q)) : g.moveTo(gx, Y(q))));
            g.stroke();
            g.restore();
            txt(g, '시간 →', gx + gw - 3, gy + gh - 3.5, 4.5, 'rgba(255,255,255,.4)', 'right', 600);
          }
        });
        if (v.big) txt(g, '길 위를 눌러 목표 옮기기', 140, 172, 6, 'rgba(255,255,255,.55)', 'center', 600);
        g.restore();
      },
      dispose() {
        inp.dispose();
      },
    };
  },
};
```

## 관련 기술
- 같은 방법으로 만든 효과 예시: [계기 바늘 물리](https://ai-techstudio.web.app/ai/t/i218.md) `i218`
- 먼저 알면 좋은 기술: [이징 곡선 모음 (트윈 비교)](https://ai-techstudio.web.app/ai/t/i292.md) `i292`
- 다음에 해 볼 기술: [말랑한 몸 (스프링 젤리 · 압력)](https://ai-techstudio.web.app/ai/t/i287.md) `i287`
