# AI 꾸러미 — 동전 빨려 들기 (UI 로 날아가기) — Collect-to-UI animation (coin fly)
> 보상 동전이 하나씩 간격을 두고 곡선으로 날아가 점수 칸에 쏙 들어가고, 들어갈 때마다 칸이 통 튀며 숫자가 오른다.  
> 견본: https://ai-techstudio.web.app/#t/i208

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

## 주문서

### 만들어 줘: 동전 빨려 들기 (UI 로 날아가기) — Collect-to-UI animation (coin fly)

#### 1. 목표
상자에서 나온 동전 10개를 얻으면 하나씩 곡선을 그리며 점수 칸으로 날아가 쏙 들어가게 해 줘 — 들어갈 때마다 칸이 통 튀고 숫자가 1씩 올라. 분위기는 밤하늘 보라 + 금빛.

#### 2. 핵심 기술 용어
- **Collect-to-UI animation (coin fly)** — 보상이 점수 칸으로 날아가는 연출
- **Quadratic Bezier curve** — 시작 · 조절점 · 끝 세 점으로 그리는 곡선
- **Staggered animation** — 하나씩 조금씩 늦게 출발 (간격 두기)
- **Squash and bump feedback** — 받는 칸이 통 커졌다 돌아오기

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

#### 4. 조건
- 동전은 한꺼번에가 아니라 하나씩 간격(0.02~0.15초, 기본 0.055초)을 두고 출발
- 점수 숫자는 동전이 칸에 「도착한 순간」 1씩 오른다 — 날기 전에 미리 다 더하지 않는다
- 받는 칸이 도착마다 통 커졌다 돌아온다 (1 + 0.35·e^(−14t))
- 날아가는 길은 곡선 (2차 베지에), 동전마다 조절점이 달라 겹치지 않게
- 연출 전체 2초 안 — 다음 판 입력을 막지 않는다

#### 5. 완성 기준 (이게 보이면 성공)
- 왼쪽(그냥 숫자가 바뀜) · 오른쪽(동전이 날아감)을 나란히 보면, 오른쪽에서 동전이 하나씩 칸에 들어가며 숫자가 1씩 오른다
- 도착할 때마다 칸이 통 튀고 테두리가 금빛으로 번쩍한다
- 「동전 사이 간격」 슬라이더로 줄줄이 · 띄엄띄엄이 바로 바뀐다
- 폰 가로 화면(844×390)에서도 동전과 점수 칸이 또렷하다

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

## 원리
- 상자가 깨지면 동전이 사방으로 흩어진다 — 거리는 (1 − e^(−5t)) 로 빨리 갔다 느려지고, 중력처럼 조금씩 떨어진다.
- 0.85초 뒤부터 동전 i 번째가 i × 0.055초 늦게 출발한다. 한꺼번에 날면 덩어리로 보이고, 간격을 두면 「하나씩」 세는 맛이 난다.
- 날아가는 길은 2차 베지에 곡선: 시작점 → 옆으로 비킨 조절점 → 점수 칸. 동전마다 조절점을 좌우로 다르게 두면 길이 겹치지 않는다.
- 시간 k 를 e = k²(2.2 − 1.2k) 로 바꿔, 처음엔 느리고 칸 가까이서 빨라지게(빨려 드는 느낌) 한다. 0.5초 안에 도착.
- 도착할 때마다 칸이 1 + 0.35·e^(−14t) 배로 커졌다 돌아오고, 숫자 +1 · 작은 불티가 튄다. 마지막엔 「10개 = 1묶음!」.

## 핵심 코드 — 동전 하나의 비행 — 간격 출발 · 2차 베지에 · 빨려 드는 이징 · 칸 튀기
(발췌: demos/demosJuice.ts D208 (scene) 을 정리)
```ts
const N = 10, BREAK = 0.35;  // 동전 수 · 상자 깨지는 때 (초)
const FLY = 0.85, DUR = 0.5; // 날기 시작 · 한 개 비행 시간 (초)
let gap = 0.055;               // 동전 사이 간격

// 흩어진 자리: 빨리 갔다 느려짐 + 조금씩 떨어짐
function scatter(i: number, t: number, ox: number, oy: number, u: number): [number, number] {
  const an = -Math.PI / 2 + (hash(i, 3) - 0.5) * 2.2;
  const sp = (40 + hash(i, 4) * 40) * u;
  const a = Math.max(0, t - BREAK);
  const d = 1 - Math.exp(-a * 5);
  return [ox + Math.cos(an) * sp * d, oy + Math.sin(an) * sp * d + 30 * u * a * a];
}

function coinPos(i: number, ph: number, ox: number, oy: number, cx: number, cy: number, u: number): [number, number] | null {
  const t0 = FLY + i * gap;               // i 번째는 i × gap 늦게 출발
  if (ph < t0) return scatter(i, ph, ox, oy, u);
  const k = (ph - t0) / DUR;
  if (k >= 1) return null;                // 도착 — 이제 칸이 센다
  const [sx, sy] = scatter(i, t0, ox, oy, u);
  const e = k * k * (2.2 - 1.2 * k);      // 처음 느리고 끝에서 빨려 듦
  const c1x = sx - 40 * u * (i % 2 ? 1 : -0.4); // 조절점을 좌우로 다르게
  const c1y = sy - 10 * u;
  return [
    (1 - e) * (1 - e) * sx + 2 * (1 - e) * e * c1x + e * e * cx,
    (1 - e) * (1 - e) * sy + 2 * (1 - e) * e * c1y + e * e * cy,
  ];
}

// 받는 칸: 도착한 개수만큼 숫자 +, 막 도착했으면 통 튐
let arrived = 0, lastArr = 99;
for (let i = 0; i < N; i++) {
  const ta = FLY + i * gap + DUR;
  if (ph >= ta) { arrived++; lastArr = Math.min(lastArr, ph - ta); }
}
const bump = lastArr < 0.3 ? 1 + 0.35 * Math.exp(-lastArr * 14) : 1;
```

## 흔한 실수 · 확인 목록
- [ ] **숫자를 미리 다 올려 두면 동전이 헛날아 보인다** — 점수는 동전이 칸에 닿는 순간마다 1씩 올린다. 그래야 동전 = 점수 1 이 이어져 보인다.
- [ ] **모든 동전이 같은 길로 날면 한 줄로 겹친다** — 베지에 조절점을 동전마다 좌우로 다르게(홀짝으로 1 · −0.4) 두고 크기도 날며 30% 줄인다.
- [ ] **같은 빠르기로 날면 「빨려 드는」 맛이 없다** — k → k²(2.2 − 1.2k) 처럼 끝에서 빨라지는 이징을 쓴다. 등속은 둥둥 떠다니는 느낌.
- [ ] **점수 칸 위치를 하드코딩하면 화면 크기마다 빗나간다** — 칸 위치는 그릴 때마다 화면 폭 · 높이로 다시 계산한다 (견본은 오른쪽 위 w − 36u, 34u).

## 완성 기준 체크리스트
- [ ] 왼쪽(그냥 숫자가 바뀜) · 오른쪽(동전이 날아감)을 나란히 보면, 오른쪽에서 동전이 하나씩 칸에 들어가며 숫자가 1씩 오른다
- [ ] 도착할 때마다 칸이 통 튀고 테두리가 금빛으로 번쩍한다
- [ ] 「동전 사이 간격」 슬라이더로 줄줄이 · 띄엄띄엄이 바로 바뀐다
- [ ] 폰 가로 화면(844×390)에서도 동전과 점수 칸이 또렷하다

## 이 기술 정보
- id: `i208` · 분류: 이펙트 · 연출 › 손맛 · 주스 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 동전 10개를 원 · 빛무리로 그리는 2D 계산뿐이라 폰에서도 가볍다. 빛무리는 lighter 섞기 한 번.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i208
- 쓰면 좋을 때: 동전 · 별 · 점수 보상을 줄 때 — 얼마나 받았는지 몸으로 느끼게 / 「10개면 1묶음」처럼 하나씩 세는 것 자체가 배움일 때
- 쓰지 말 때: 보상이 수백 개일 때 — 하나씩 날리면 너무 길다. 10~20개만 날리고 나머지는 숫자로 한 번에 더한다 / 빨리 이어서 눌러야 하는 판 — 연출이 다음 입력을 막지 않게 0.5초 안에 끝낸다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### D208 — `src/demos/demosJuice.ts:1239`
```ts
const D208: Demo = {
  kind: '2d',
  caption: '보상 동전 10개가 곡선을 그리며 점수 칸으로 쏙쏙 — 들어갈 때마다 칸이 통 튀고 숫자가 하나씩',
  make() {
    let compare = true;
    let gap = 0.055;
    const P = 3.4;
    const N = 10;
    const BREAK = 0.35;
    const FLY = 0.85;
    const DUR = 0.5;
    let T = 0;
    const scene = (g: G, w: number, h: number, on: boolean): void => {
      const u = uh(w, h);
      bg(g, w, h, '#2b1d5e', '#0e0a26');
      const ph = T % P;
      const gy = h * 0.82;
      ground(g, w, h, gy, u);
      const ox = w * 0.4;
      const oy = gy - 18 * u;
      const cx = w - 36 * u;
      const cy = 34 * u;
      // 상자
      if (ph < BREAK) {
        const shake = ph > BREAK - 0.2 ? Math.sin(ph * 90) * 2 * u : 0;
        block(g, ox + shake, gy, 34 * u, '#ffd43b', '10', { sy: 1 - 0.05 * Math.sin(ph * 40) * (ph > 0.15 ? 1 : 0) });
      } else {
        const a = ph - BREAK;
        if (a < 0.25) block(g, ox, gy, 34 * u, '#ffd43b', '10', { sx: 1 + a * 2, sy: 1 + a * 2, alpha: 1 - a / 0.25, flash: 0.6 });
        ringFx(g, ox, oy, a, 0.4, 8 * u, 50 * u, '#ffe066', 3 * u);
        const back = ph - (P - 0.55);
        if (back > 0) {
          const k = backOut(back / 0.35);
          block(g, ox, gy, 34 * u, '#ffd43b', '10', { sx: k, sy: k });
        }
      }
      // 칸
      let arrived = 0;
      let lastArr = 99;
      for (let i = 0; i < N; i++) {
        const ta = FLY + i * gap + DUR;
        if (ph >= ta) {
          arrived++;
          lastArr = Math.min(lastArr, ph - ta);
        }
      }
      let count = 20 + 10 * (Math.floor(T / P) % 7);
      if (on) count += arrived;
      else if (ph >= FLY) count += N;
      const bump = on && lastArr < 0.3 ? 1 + 0.35 * Math.exp(-lastArr * 14) : 1;
      g.save();
      g.translate(cx, cy);
      g.scale(bump, bump);
      rr(g, -24 * u, -11 * u, 50 * u, 22 * u, 11 * u);
      g.fillStyle = '#0a0620';
      g.fill();
      g.strokeStyle = on && lastArr < 0.15 ? '#ffe066' : 'rgba(255,255,255,.3)';
      g.lineWidth = 1.5 * u;
      g.stroke();
      coin(g, -13 * u, 0, 8 * u);
      txt(g, String(count), 9 * u, 0.5 * u, 11 * u, '#fff', 'center', 900);
      g.restore();
      if (on && lastArr < 0.3) sparks(g, cx - 13 * u, cy, lastArr, 8, arrived, 18 * u, ['#fff3a0', '#ffd43b'], 0.3, 0, 2 * u);
      if (on && arrived === N && ph < FLY + N * gap + DUR + 0.9) {
        const a = ph - (FLY + (N - 1) * gap + DUR);
        g.save();
        g.globalAlpha = 1 - smooth(0.6, 0.9, a);
        const k = backOut(a / 0.3);
        g.translate(cx, cy + 22 * u);
        g.scale(k, k);
        pill(g, '10개 = 1묶음!', 0, 0, 7.5 * u, '#40c057', '#fff');
        g.restore();
      }
      // 동전
      if (ph >= BREAK) {
        for (let i = 0; i < N; i++) {
          const an = -Math.PI / 2 + (hash(i, 3) - 0.5) * 2.2;
          const sp = (40 + hash(i, 4) * 40) * u;
          const pos = (tt: number): [number, number] => {
            const a = Math.max(0, tt - BREAK);
            const d = 1 - Math.exp(-a * 5);
            return [ox + Math.cos(an) * sp * d, oy + Math.sin(an) * sp * d + 30 * u * a * a];
          };
          const t0 = FLY + i * gap;
          if (ph < t0) {
            const [x, y] = pos(ph);
            coin(g, x, y, 7 * u, Math.cos(ph * 9 + i));
          } else if (on) {
            const k = (ph - t0) / DUR;
            if (k >= 1) continue;
            const [sx, sy] = pos(t0);
            const e = k * k * (2.2 - 1.2 * k);
            const c1x = sx - 40 * u * (i % 2 ? 1 : -0.4);
            const c1y = sy - 10 * u;
            const x = (1 - e) * (1 - e) * sx + 2 * (1 - e) * e * c1x + e * e * (cx - 13 * u);
            const y = (1 - e) * (1 - e) * sy + 2 * (1 - e) * e * c1y + e * e * cy;
            g.save();
            g.globalCompositeOperation = 'lighter';
            glow(g, x, y, 12 * u, '#ffd43b', 0.35);
            g.restore();
            coin(g, x, y, 7 * u * (1 - 0.3 * e), Math.cos(ph * 14 + i));
          } else {
            const a = ph - t0;
            if (a < 0.15) {
              const [x, y] = pos(t0);
              g.globalAlpha = 1 - a / 0.15;
              coin(g, x, y, 7 * u);
              g.globalAlpha = 1;
            }
          }
        }
      }
    };
    return {
      draw(g, w, h, t) {
        T = t;
        split(g, w, h, compare, scene);
      },
      controls: [tgl('나란히 비교 (끔 | 켬)', compare, (v) => (compare = v)), rng('동전 사이 간격 (초)', 0.02, 0.15, 0.005, gap, (v) => (gap = v))],
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [튀어 오르는 숫자](https://ai-techstudio.web.app/ai/t/i202.md) `i202`
- 다음에 해 볼 기술: [별점 터짐](https://ai-techstudio.web.app/ai/t/i211.md) `i211` · [액체 진행 막대](https://ai-techstudio.web.app/ai/t/i217.md) `i217`
