# AI 꾸러미 — 스프링 · 예비 동작 · 찌그러짐 — Squash and stretch
> 뛰기 전에 움츠리고(예비 동작), 공중에서 늘어나고, 착지 때 찌그러졌다 감쇠 스프링으로 통통 돌아오게 해 움직임에 무게와 탄력을 준다.  
> 견본: https://ai-techstudio.web.app/#t/i102

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

## 주문서

### 만들어 줘: 스프링 · 예비 동작 · 찌그러짐 — Squash and stretch

#### 1. 목표
폴짝 뛰는 블록 캐릭터에 스프링 · 예비 동작 · 찌그러짐을 넣어 줘 — 뛰기 전 움츠림, 공중에서 늘어남, 착지 때 감쇠 스프링으로 통통. 분위기는 통통 튀는 만화.

#### 2. 핵심 기술 용어
- **Squash and stretch** — 찌그러짐 · 늘어남 (애니메이션 12원칙)
- **Anticipation** — 예비 동작 — 움직이기 전 반대쪽으로 움츠림
- **Damped spring (e^(−ct)·cos ωt)** — 감쇠 진동 — 출렁이다 멈춤
- **Volume preservation** — 세로로 눌리면 가로로 퍼져 부피 유지

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

#### 4. 조건
- 크기 축은 발바닥(아래쪽 가운데) — 가운데 축이면 공중에 떠서 찌그러진다
- 세로로 눌리면 가로로 퍼지게 (부피 유지 느낌)
- 착지 스프링은 지수 감쇠 × 코사인 — 떨림 빠르기를 조절판으로 (견본 18, 8 ~ 34)
- 왼쪽 「그냥 오르내림」 과 오른쪽 「스프링」 을 나란히 두어 차이를 보이기
- 그림자는 높이에 따라 작고 옅게

#### 5. 완성 기준 (이게 보이면 성공)
- 오른쪽 블록이 뛰기 직전 납작하게 움츠렸다가 길쭉하게 솟는다
- 착지하는 순간 납작하게 퍼졌다 몇 번 출렁이며 제 모양으로 돌아온다 — 왼쪽은 그냥 오르내린다
- 「스프링 떨림」 을 올리면 출렁임이 빨라진다
- 아래 작은 그래프에 세로 크기가 시간에 따라 어떻게 변하는지 보인다

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

## 원리
- 예비 동작(0.05 ~ 0.35초): 세로 sy = 1 − 0.32·sin(k·π/2), 가로 sx = 1 + 0.8·(1 − sy) — 눌린 만큼 퍼진다.
- 공중: 위로 오를 때 · 내려올 때 빠를수록 길쭉하게 (v = |1 − 2·진행|, sy = 1 + 0.28v², sx = 1 − 0.18v²), 꼭대기에선 둥글게.
- 착지: q = e^(−6τ)·cos(떨림 × τ), sy = 1 − 0.35q, sx = 1 + 0.3q — 눌렸다 출렁이며 멈춘다.
- 크기는 발바닥(바닥에 닿는 점)을 축으로 줘야 땅에 붙은 채 찌그러진다.

## 핵심 코드 — 예비 동작 · 공중 늘어남 · 착지 감쇠 스프링
(발췌: demos/demosMotionA.ts i102 의 draw · jumper() 를 정리)
```ts
const seg = (p: number, a: number, b: number) => Math.max(0, Math.min(1, (p - a) / (b - a)));
const T = 2.4, up0 = 0.35, up1 = 1.15;   // 0.35 ~ 1.15초 공중
const p = tt % T;
const air = seg(p, up0, up1);
const yAir = air > 0 && air < 1 ? 4 * air * (1 - air) : 0; // 포물선 높이 0~1
let sx = 1, sy = 1;
if (p < up0) {                            // 예비 동작: 움츠림
  const sq = Math.sin(seg(p, 0.05, up0) * Math.PI * 0.5) * 0.32;
  sy = 1 - sq; sx = 1 + sq * 0.8;
} else if (p < up1) {                     // 공중: 빠를수록 길쭉
  const v = Math.abs(1 - 2 * air);
  sy = 1 + 0.28 * v * v; sx = 1 - 0.18 * v * v;
} else {                                  // 착지: 감쇠 스프링
  const tau = p - up1;
  const q = Math.exp(-tau * 6) * Math.cos(tau * stiff); // stiff = 18
  sy = 1 - 0.35 * q; sx = 1 + 0.3 * q;
}
const y = groundY - yAir * 80;
g.save();
g.translate(x, y);        // 발바닥이 축
g.scale(sx, sy);
g.beginPath(); g.roundRect(-16, -32, 32, 32, 8); // 몸은 축 위쪽으로
g.fillStyle = '#ffcf5c'; g.fill();
g.restore();
```

## 흔한 실수 · 확인 목록
- [ ] **가운데를 축으로 찌그러뜨리면 땅에서 떠 보인다** — 몸을 축 위쪽(−32 ~ 0)에 그리고 발바닥 자리로 translate 한 뒤 scale.
- [ ] **세로만 줄이면 고무가 아니라 종이처럼 보인다** — 세로가 줄어든 만큼 가로를 늘린다 (sx = 1 + 0.8·눌림).
- [ ] **감쇠가 약하면 착지 뒤 계속 떤다** — e^(−6τ) 처럼 0.5초 안에 거의 멈추는 감쇠를 쓴다.

## 완성 기준 체크리스트
- [ ] 오른쪽 블록이 뛰기 직전 납작하게 움츠렸다가 길쭉하게 솟는다
- [ ] 착지하는 순간 납작하게 퍼졌다 몇 번 출렁이며 제 모양으로 돌아온다 — 왼쪽은 그냥 오르내린다
- [ ] 「스프링 떨림」 을 올리면 출렁임이 빨라진다
- [ ] 아래 작은 그래프에 세로 크기가 시간에 따라 어떻게 변하는지 보인다

## 이 기술 정보
- id: `i102` · 분류: 이펙트 · 연출 › 모션 그래픽 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 크기 두 값 계산뿐. 아주 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i102
- 쓰면 좋을 때: 캐릭터 · 블록이 뛰거나 떨어질 때 / 단추 · 카드처럼 눌리는 모든 것에 살아 있는 느낌을 줄 때
- 쓰지 말 때: 딱딱해야 하는 물체 (금속 · 유리 · 글자 표) — 대신 살짝 튀기(위치만) / 정확한 위치가 중요한 판 — 찌그러짐으로 칸이 겹쳐 보이면 안 된다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i102 — `src/demos/demosMotionA.ts:1435`
```ts
const i102 = {
  kind: '2d' as const,
  caption: '왼쪽은 그냥 오르내림 · 오른쪽은 움츠렸다(예비 동작) 늘어나며 뛰고, 착지 때 찌그러졌다 통통(감쇠 스프링)',
  make() {
    const o = { s: 1 };
    let tt = 0;
    let stiff = 18;
    return {
      controls: [speedCtl(o), { type: 'range' as const, label: '스프링 떨림', min: 8, max: 34, step: 1, value: 18, on: (v: number) => (stiff = v) }],
      draw(g: G, w: number, h: number, _t: number, dt: number) {
        tt += Math.max(0, dt) * o.s;
        const T = 2.4;
        const p = tt % T;
        bg(g, w, h, '#9fdcff', '#e6f7ff');
        view(g, w, h);
        const GY = 140;
        g.fillStyle = '#7fcf6a';
        g.fillRect(-200, GY, VW + 400, 80);
        g.fillStyle = '#5fb04e';
        g.fillRect(-200, GY, VW + 400, 3);
        g.fillStyle = 'rgba(255,255,255,.7)';
        g.fillRect(VW / 2 - 0.75, 0, 1.5, GY);
        pill(g, '그냥', VW * 0.25, 14, 9, 'rgba(30,50,90,.65)');
        pill(g, '스프링', VW * 0.75, 14, 9, '#ff5c8a');
        // 공통 비행: 0.35 ~ 1.15 공중
        const up0 = 0.35;
        const up1 = 1.15;
        const air = seg(p, up0, up1);
        const yAir = air > 0 && air < 1 ? 4 * air * (1 - air) : 0;
        // 왼쪽: 그냥
        jumper(g, VW * 0.25, GY, GY - yAir * 80, 1, 1, '#b8c2d8', '·_·');
        // 오른쪽: 예비 동작 · 늘어남 · 착지 스프링
        let sx = 1;
        let sy = 1;
        if (p < up0) {
          const k = seg(p, 0.05, up0);
          const sq = Math.sin(k * Math.PI * 0.5) * 0.32;
          sy = 1 - sq;
          sx = 1 + sq * 0.8;
        } else if (p < up1) {
          const v = Math.abs(1 - 2 * air);
          sy = 1 + 0.28 * v * v;
          sx = 1 - 0.18 * v * v;
        } else {
          const tau = p - up1;
          const q = Math.exp(-tau * 6) * Math.cos(tau * stiff);
          sy = 1 - 0.35 * q;
          sx = 1 + 0.3 * q;
        }
        jumper(g, VW * 0.75, GY, GY - yAir * 80, sx, sy, '#ffcf5c', p > up1 && p < up1 + 0.3 ? '>_<' : '^o^');
        // 크기 그래프 (오른쪽 아래)
        const gx = VW * 0.5 + 14;
        const gy = 158;
        const gw = VW * 0.5 - 28;
        g.strokeStyle = 'rgba(255,255,255,.5)';
        g.lineWidth = 1;
        g.beginPath();
        g.moveTo(gx, gy);
        g.lineTo(gx + gw, gy);
        g.stroke();
        g.strokeStyle = '#ff5c8a';
        g.lineWidth = 1.5;
        g.beginPath();
        for (let i = 0; i <= 60; i++) {
          const pp = (i / 60) * T;
          let yy = 1;
          if (pp < up0) yy = 1 - Math.sin(seg(pp, 0.05, up0) * Math.PI * 0.5) * 0.32;
          else if (pp < up1) {
            const a = seg(pp, up0, up1);
            const v = Math.abs(1 - 2 * a);
            yy = 1 + 0.28 * v * v;
          } else yy = 1 - 0.35 * Math.exp(-(pp - up1) * 6) * Math.cos((pp - up1) * stiff);
          const X = gx + (i / 60) * gw;
          const Y = gy - (yy - 1) * 18;
          if (i) g.lineTo(X, Y);
          else g.moveTo(X, Y);
        }
        g.stroke();
        g.fillStyle = '#fff';
        g.beginPath();
        g.arc(gx + (p / T) * gw, gy - (sy - 1) * 18, 2.5, 0, TAU);
        g.fill();
        txt(g, '세로 크기', gx, gy - 12, 7, '#1d5d8f', 'left', F, 700);
        g.restore();
      },
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [스프링 · 감쇠 움직임 (임계 감쇠)](https://ai-techstudio.web.app/ai/t/i293.md) `i293`
- 다음에 해 볼 기술: [젤리 단추 (누름 깊이 · 출렁)](https://ai-techstudio.web.app/ai/t/i206.md) `i206` · [착지 먼지 뭉게](https://ai-techstudio.web.app/ai/t/i214.md) `i214`
- 참고 문서: [Wikipedia — Squash and stretch](https://en.wikipedia.org/wiki/Squash_and_stretch)
