# AI 꾸러미 — 착 붙기 · 관성 (스냅과 던지기) — Inertial fling with snap points
> 카드를 놓을 때의 속도로 미끄러질 도착점을 미리 계산해 가장 가까운 칸을 고르고, 미끄러지다 스프링으로 그 칸에 착 붙게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i420

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

## 주문서

### 만들어 줘: 착 붙기 · 관성 (스냅과 던지기) — Inertial fling with snap points

#### 1. 목표
카드 · 조각 퍼즐에 관성 던지기와 칸 붙기를 넣어 줘 — 놓을 때 최근 0.09초 손 자리로 속도를 재고, 도착 예상점(자리 + 속도 ÷ 마찰)에 가장 가까운 칸을 골라, 미끄러지다 스프링으로 착. 손맛은 미끄럽고 멀리 (마찰 작게).

#### 2. 핵심 기술 용어
- **Inertial fling with snap points** — 관성 던지기 + 착 붙는 자리
- **Release velocity (from recent samples)** — 놓을 때 속도 — 최근 0.09초 손 자리로
- **Exponential friction (v·e^(−f·dt))** — 지수 마찰 — 미끄러질 거리 ≈ 속도 ÷ 마찰
- **Critically damped spring** — 감쇠 스프링 — 칸에 끌려가 멈춤

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

#### 4. 조건
- 놓을 때 속도는 최근 0.09초 손 자리로 — 놓기 직전 0.08초 넘게 멈췄으면 0
- 목표 칸은 놓는 순간 한 번 정한다: 가장 가까운 칸(자리 + 속도 ÷ 마찰)
- 미끄러짐(지수 마찰) → 속도 140 아래면 스프링으로 — 두 단계
- 속도 상한 1,300px/초, 판 벽에 닿으면 −0.4 로 살짝 튕김
- 마찰 · 스프링 단단함 조절판

#### 5. 완성 기준 (이게 보이면 성공)
- 끔: 놓은 자리에 그냥 멈춰 「칸 사이에 멈춤」
- 켬: 툭 던지면 미끄러지다 점선으로 미리 표시된 칸에 스프링으로 착 붙는다
- 「놓을 때 속도 …px/초 · 미끄러질 거리 ≈ 속도 ÷ 마찰 = …px」 숫자가 보인다
- 마찰을 작게 하면 더 멀리 간다

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

## 원리
- 놓는 순간 속도 = (마지막 손 자리 − 0.09초 전 손 자리) ÷ 그 시간. 놓기 전에 0.08초 넘게 멈춰 있었으면 0.
- 마찰은 지수로: 속도 × e^(−마찰 × dt). 그러면 미끄러질 거리 ≈ 속도 ÷ 마찰 (견본 마찰 4).
- 놓을 때 도착 예상점 = 지금 자리 + 속도 ÷ 마찰 → 가장 가까운 칸이 목표.
- 속도가 140px/초 아래로 떨어지면 미끄러짐을 멈추고, 스프링(k = 170, 감쇠비 0.55)으로 목표 칸에 끌려간다.
- 너무 빠른 던지기는 1,300px/초로 자른다.

## 핵심 코드 — 놓을 때 속도 → 도착 칸 고르기 → 미끄러짐 → 스프링
(발췌: demos/demosInput.ts i420 move() · up() · step())
```ts
let fr = 4;    // 마찰
let k = 170;   // 스프링 단단함
let samples: { x: number; y: number; t: number }[] = [];
let phase: 'rest' | 'drag' | 'glide' | 'spring' = 'rest';

function onMove(x: number, y: number, t: number): void {
  pos = { x: x + grab.ox, y: y + grab.oy };
  samples.push({ x, y, t });
  while (samples.length > 2 && t - samples[0].t > 0.09) samples.shift(); // 최근 0.09초만
}

function onUp(now: number): void {
  let vx = 0, vy = 0;
  const a = samples[0], b = samples[samples.length - 1];
  if (a && b && b.t - a.t > 0.005 && now - b.t < 0.08) { // 놓기 직전 멈췄으면 0
    vx = (b.x - a.x) / (b.t - a.t);
    vy = (b.y - a.y) / (b.t - a.t);
  }
  const raw = Math.hypot(vx, vy);
  if (raw > 1300) { vx *= 1300 / raw; vy *= 1300 / raw; }
  target = nearest({ x: pos.x + vx / fr, y: pos.y + vy / fr }); // 미끄러질 거리 ≈ 속도 ÷ 마찰
  vel = { x: vx, y: vy };
  phase = Math.hypot(vx, vy) > 140 ? 'glide' : 'spring';
}

function step(dt: number): void {
  if (phase === 'glide') {
    const f = Math.exp(-fr * dt);
    vel = { x: vel.x * f, y: vel.y * f };
    pos = { x: pos.x + vel.x * dt, y: pos.y + vel.y * dt };
    if (Math.hypot(vel.x, vel.y) < 140) phase = 'spring';
  } else if (phase === 'spring' && target) {
    const c = 2 * Math.sqrt(k) * 0.55;
    vel.x += (k * (target.x - pos.x) - c * vel.x) * dt;
    vel.y += (k * (target.y - pos.y) - c * vel.y) * dt;
    pos = { x: pos.x + vel.x * dt, y: pos.y + vel.y * dt };
    if (Math.hypot(target.x - pos.x, target.y - pos.y) < 0.3 && Math.hypot(vel.x, vel.y) < 3) { pos = { ...target }; phase = 'rest'; }
  }
}
```

## 흔한 실수 · 확인 목록
- [ ] **놓기 직전 손을 멈췄는데 오래된 속도가 남아 카드가 날아간다** — 마지막 손 자리가 0.08초보다 오래됐으면 속도를 0 으로.
- [ ] **처음 두 점만으로 속도를 재면 끄는 내내의 평균이 돼 던진 느낌이 없다** — 최근 0.09초 손 자리만 남기고 잰다.
- [ ] **도착 칸을 멈춘 뒤에 고르면 스프링이 엉뚱한 쪽으로 되돌아간다** — 놓는 순간 예상점으로 칸을 정하고, 미끄러지는 동안 그 칸을 미리 보여 준다.

## 완성 기준 체크리스트
- [ ] 끔: 놓은 자리에 그냥 멈춰 「칸 사이에 멈춤」
- [ ] 켬: 툭 던지면 미끄러지다 점선으로 미리 표시된 칸에 스프링으로 착 붙는다
- [ ] 「놓을 때 속도 …px/초 · 미끄러질 거리 ≈ 속도 ÷ 마찰 = …px」 숫자가 보인다
- [ ] 마찰을 작게 하면 더 멀리 간다

## 이 기술 정보
- id: `i420` · 분류: 게임 시스템 · AI › 입력 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 프레임마다 곱셈 몇 번. 비용 없음.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i420
- 쓰면 좋을 때: 카드 · 조각을 툭 던져 놓는 손맛이 필요할 때 / 칸이 정해진 판에서 끌기가 「칸 사이에 멈춤」으로 끝날 때
- 쓰지 말 때: 정확한 자리가 중요한 퍼즐 — 던지기가 실수를 부른다 (대신 끈 자리에서 가장 가까운 칸) / 칸이 아주 촘촘할 때 — 예상점이 조금만 틀려도 엉뚱한 칸

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i420 — `src/demos/demosInput.ts:1045`
```ts
const i420 = demo<P420>({
  caption: '카드를 툭 던지면 — 끔: 놓은 자리에 그냥 멈춤 · 켬: 속도대로 미끄러지다 가까운 칸에 스프링으로 착',
  howto: '카드를 잡고 빠르게 던지듯 놓아 보세요. 마찰이 작을수록 멀리 미끄러지고, 점선 칸은 미리 계산한 도착 자리입니다.',
  onName: '관성 + 칸에 착',
  cycle: 3.2,
  prm: () => ({ fr: 4, k: 170 }),
  script: (u, loop) => {
    const f = path(u, [
      [0, 200, 318, 0],
      [0.35, 70, 92, 0],
      [0.5, 70, 92, 1],
      [0.62, 78, 100, 1, 1],
      [0.74, 124, 150, 0],
      [1.5, 190, 318, 0],
      [3.2, 200, 318, 0],
    ]);
    if (loop % 2) {
      f.x = 300 - f.x;
      f.y = 340 - f.y;
    }
    return [f];
  },
  create: (env) => {
    let pos: V2 = { x: SX[0]!, y: SY[0]! };
    let vel: V2 = { x: 0, y: 0 };
    let phase: 'rest' | 'drag' | 'glide' | 'spring' = 'rest';
    let target: V2 | null = null;
    let grab: { id: number; ox: number; oy: number } | null = null;
    let samples: { x: number; y: number; t: number }[] = [];
    let trail: V2[] = [];
    let rel: { x: number; y: number; vx: number; vy: number; a: number } | null = null;
    let v: Verdict = { text: '카드를 던져 보세요', color: COL.dim };
    let ex: string[] = [];
    const nearest = (p: V2): V2 => {
      let best: V2 = { x: SX[0]!, y: SY[0]! };
      let bd = 1e9;
      for (const x of SX)
        for (const y of SY) {
          const d = dist(p.x, p.y, x, y);
          if (d < bd) {
            bd = d;
            best = { x, y };
          }
        }
      return best;
    };
    const slotName = (p: V2): string => `${SY.indexOf(p.y) + 1}줄 ${SX.indexOf(p.x) + 1}칸`;
    return {
      reset(loop) {
        pos = loop % 2 ? { x: SX[2]!, y: SY[2]! } : { x: SX[0]!, y: SY[0]! };
        vel = { x: 0, y: 0 };
        phase = 'rest';
        target = null;
        grab = null;
        samples = [];
        trail = [];
        rel = null;
        v = { text: '카드를 던져 보세요', color: COL.dim };
        ex = [];
      },
      down(id, x, y) {
        if (grab) return;
        if (Math.abs(x - pos.x) < 36 && Math.abs(y - pos.y) < 42) {
          grab = { id, ox: pos.x - x, oy: pos.y - y };
          phase = 'drag';
          target = null;
          vel = { x: 0, y: 0 };
          samples = [{ x, y, t: env.now() }];
          v = { text: '잡음', color: COL.blue };
        }
      },
      move(id, x, y) {
        if (!grab || grab.id !== id) return;
        pos = { x: x + grab.ox, y: y + grab.oy };
        const t = env.now();
        samples.push({ x, y, t });
        while (samples.length > 2 && t - samples[0]!.t > 0.09) samples.shift();
      },
      up(id) {
        if (!grab || grab.id !== id) return;
        grab = null;
        let vx = 0;
        let vy = 0;
        const a = samples[0];
        const b = samples[samples.length - 1];
        if (a && b && b.t - a.t > 0.005 && env.now() - b.t < 0.08) {
          vx = (b.x - a.x) / (b.t - a.t);
          vy = (b.y - a.y) / (b.t - a.t);
        }
        const raw = Math.hypot(vx, vy);
        if (raw > 1300) {
          vx *= 1300 / raw;
          vy *= 1300 / raw;
        }
        const sp = Math.hypot(vx, vy);
        rel = { x: pos.x, y: pos.y, vx, vy, a: 0 };
        if (env.on()) {
          const fr = env.prm.fr;
          const proj = { x: pos.x + vx / fr, y: pos.y + vy / fr };
          target = nearest(proj);
          vel = { x: vx, y: vy };
          phase = sp > 140 ? 'glide' : 'spring';
          v = { text: `${slotName(target)}에 착`, color: COL.on };
          ex = [`놓을 때 속도 ${Math.round(sp)}px/초`, `미끄러질 거리 ≈ 속도 ÷ 마찰 = ${Math.round(sp / fr)}px`, '예상 도착점에서 가장 가까운 칸으로'];
        } else {
          phase = 'rest';
          vel = { x: 0, y: 0 };
          const n = nearest(pos);
          const d = dist(pos.x, pos.y, n.x, n.y);
          v = d < 8 ? { text: '칸에 놓임', color: COL.on } : { text: '칸 사이에 멈춤', color: COL.off };
          ex = [`놓을 때 속도 ${Math.round(sp)}px/초 — 버림`, `가장 가까운 칸에서 ${Math.round(d)}px 어긋남`];
        }
      },
      cancel(id) {
        if (grab && grab.id === id) {
          grab = null;
          target = nearest(pos);
          phase = 'spring';
        }
      },
      step(dt) {
        if (rel) {
          rel.a += dt;
          if (rel.a > 1.1) rel = null;
        }
        if (phase === 'glide') {
          const k = Math.exp(-env.prm.fr * dt);
          vel = { x: vel.x * k, y: vel.y * k };
          pos = { x: pos.x + vel.x * dt, y: pos.y + vel.y * dt };
          // 판 벽에 닿으면 살짝 튕김
          if (pos.x < 44 || pos.x > 256) {
            pos.x = clamp(pos.x, 44, 256);
            vel.x *= -0.4;
          }
          if (pos.y < 72 || pos.y > 268) {
            pos.y = clamp(pos.y, 72, 268);
            vel.y *= -0.4;
          }
          if (Math.hypot(vel.x, vel.y) < 140) phase = 'spring';
        } else if (phase === 'spring' && target) {
          const k = env.prm.k;
          const c = 2 * Math.sqrt(k) * 0.55;
          vel.x += (k * (target.x - pos.x) - c * vel.x) * dt;
          vel.y += (k * (target.y - pos.y) - c * vel.y) * dt;
          pos = { x: pos.x + vel.x * dt, y: pos.y + vel.y * dt };
          if (dist(pos.x, pos.y, target.x, target.y) < 0.3 && Math.hypot(vel.x, vel.y) < 3) {
            pos = { ...target };
            phase = 'rest';
          }
        }
        if (phase !== 'rest') trail.push({ ...pos });
        else if (trail.length) trail.shift();
        while (trail.length > 16) trail.shift();
      },
      draw(g) {
        board(g, 22, 40, 256, 262);
        SY.forEach((y) =>
          SX.forEach((x) => {
            rr(g, x - 30, y - 36, 60, 72, 9);
            g.fillStyle = 'rgba(120,140,220,0.07)';
            g.fill();
            g.setLineDash([4, 4]);
            g.lineWidth = 1;
            g.strokeStyle = 'rgba(160,180,255,0.28)';
            g.stroke();
            g.setLineDash([]);
          }),
        );
        if (target && env.on() && phase !== 'rest') {
          g.save();
          g.shadowColor = rgba(COL.on, 0.9);
          g.shadowBlur = 12;
          rr(g, target.x - 30, target.y - 36, 60, 72, 9);
          g.lineWidth = 2;
          g.strokeStyle = COL.on;
          g.setLineDash([6, 4]);
          g.stroke();
          g.restore();
        }
        // 잔상
        trail.forEach((p, i) => {
          const al = (i / trail.length) * 0.22;
          rr(g, p.x - 26, p.y - 32, 52, 64, 8);
          g.fillStyle = `rgba(255,236,200,${al})`;
          g.fill();
        });
        if (rel) {
          const al = 1 - rel.a / 1.1;
          arrow(g, rel.x, rel.y, rel.x + rel.vx * 0.16, rel.y + rel.vy * 0.16, rgba(COL.warn, al), 2.4);
        }
        // 카드
        const lift = phase === 'drag' ? 1.07 : 1;
        g.save();
        g.translate(pos.x, pos.y);
        g.rotate(clamp(vel.x * 0.00025, -0.12, 0.12));
        g.scale(lift, lift);
        g.shadowColor = 'rgba(0,0,0,0.55)';
        g.shadowBlur = phase === 'drag' ? 18 : 8;
        g.shadowOffsetY = phase === 'drag' ? 10 : 3;
        rr(g, -26, -32, 52, 64, 8);
        const gr = g.createLinearGradient(0, -32, 0, 32);
        gr.addColorStop(0, '#fffaf0');
        gr.addColorStop(1, '#ecdcc0');
        g.fillStyle = gr;
        g.fill();
        g.shadowColor = 'transparent';
        rr(g, -22, -28, 44, 56, 6);
        g.lineWidth = 1;
        g.strokeStyle = 'rgba(200,150,60,0.5)';
        g.stroke();
        // 별
        g.beginPath();
        for (let i = 0; i < 10; i++) {
          const r = i % 2 ? 5 : 12;
          const a = -Math.PI / 2 + (i * Math.PI) / 5;
          const px = Math.cos(a) * r;
          const py = Math.sin(a) * r + 2;
          if (i) g.lineTo(px, py);
          else g.moveTo(px, py);
        }
        g.closePath();
        g.fillStyle = '#f2a516';
        g.fill();
        txt(g, '7', -14, -18, 11, '#b8540f', 'center', 800);
        g.restore();
      },
      verdict: () => v,
      extra: () => ex,
    };
  },
  controls: (p) => [
    { type: 'range', label: '마찰 (클수록 빨리 멈춤)', min: 1, max: 12, step: 0.5, value: p.fr, on: (v) => (p.fr = v) },
    { type: 'range', label: '스프링 단단함', min: 40, max: 400, step: 10, value: p.k, on: (v) => (p.k = v) },
  ],
});
```

## 관련 기술
- 먼저 알면 좋은 기술: [끌기 축 잠금](https://ai-techstudio.web.app/ai/t/i419.md) `i419`
- 다음에 해 볼 기술: [끌어다 놓기 미리 보기](https://ai-techstudio.web.app/ai/t/i424.md) `i424`
