# AI 꾸러미 — 가짜 커서 UI 시연 (커서 대본 · 클릭 물결 · 따라가는 확대) — Scripted cursor / product demo animation
> 대본(시각 · 위치 · 동작)대로 가짜 커서가 곡선을 그리며 가서 멈칫 · 딸깍 — 단추가 눌리고 물결이 퍼지며, 화면은 누르는 곳으로 다가갔다 물러나 결과를 보여 준다.  
> 견본: https://ai-techstudio.web.app/#t/i549

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

## 주문서

### 만들어 줘: 가짜 커서 UI 시연 (커서 대본 · 클릭 물결 · 따라가는 확대) — Scripted cursor / product demo animation

#### 1. 목표
게임 아이템 상점 (담기 → 구매) 화면 위에서 가짜 커서가 대본대로 움직이며 단추를 누르는 시연 애니메이션을 만들어 줘 — 곡선 이동 · 멈칫 · 딸깍 물결 · 커서 따라 확대. 분위기는 어두운 바탕 위 밝은 앱 창.

#### 2. 핵심 기술 용어
- **Scripted cursor / product demo animation** — 사람 손 대신 시간표로 움직이는 커서 — 제품 소개 영상의 기본
- **Click ripple · press state** — 누른 자리에서 퍼지는 고리 · 단추가 살짝 눌리는 상태
- **Auto zoom / camera follow (screen studio style)** — 누르기 직전 커서 쪽으로 확대, 누른 뒤 물러남

#### 3. 환경
- 플랫폼: TypeScript + HTML · CSS · SVG (DOM), 라이브러리 없이 — 움직임은 transform · opacity 로
- 화면: 브라우저 — PC 와 폰(390px 폭) 모두, 60fps 목표
- 커서 · 물결 · 카메라 상자는 transform 으로. 대본은 [시각, 위치, 동작] 배열로 두고 지금 시각에서 바로 계산하면 되감기 · 영상 굽기(i82)와 맞는다.

#### 4. 조건
- 커서 움직임 · 클릭 · 결과를 모두 「대본의 시각」에서 계산할 것 (setTimeout 을 줄줄이 걸지 않는다 — 되감기 · 속도 조절 · 영상 굽기가 깨진다)
- 이동은 곧은 선 대신 살짝 휜 호 + inOut 이징, 누르기 전 짧게 멈춤
- 누름 표시 3가지: 커서 작아짐 · 단추 눌림 · 퍼지는 고리. 결과는 0.3~0.5초 뒤
- 확대는 누르는 순간 근처에만, 누른 뒤엔 물러나 결과가 화면에 다 들어오게. 화면 밖 빈 곳이 보이지 않게 이동 범위 가두기

#### 5. 완성 기준 (이게 보이면 성공)
- 커서가 곡선으로 가서 멈칫한 뒤 누르고, 단추가 눌리며 노란 고리가 퍼진다
- 「담기」를 누르면 점이 장바구니로 날아가고 배지 숫자가 통 튀며 올라간다
- 확대를 켜면 누르는 곳으로 다가갔다가 물러나고, 끄면 화면이 그대로다 — 어느 쪽이든 창 밖 빈 곳이 드러나지 않는다
- 속도를 바꿔도 순서(누름 → 결과)가 어긋나지 않는다

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

## 원리
- 대본 = [언제, 어디서, 어디로] 이동 목록 + [언제, 무엇을] 클릭 목록. 매 프레임 「지금 몇 초째인가」로 커서 자리 · 단추 상태 · 배지 숫자를 바로 계산한다.
- 사람 손은 곧게 가지 않는다 — 출발 · 도착을 천천히(inOut), 길은 옆으로 살짝 휜 호(sin(πk) × 10px), 누르기 전 0.1~0.2초 멈칫.
- 딸깍 = 커서가 0.14초 작아짐 + 단추가 1px 눌림 + 누른 자리에서 커지며 사라지는 고리. 결과(배지 +1 · 알림)는 조금 늦게 따라와야 원인과 결과가 읽힌다.
- 확대: 목표 배율을 누르기 0.45초 전에 1.35, 누른 뒤 1 로. 실제 배율 · 초점은 지수 감쇠로 따라가고, 화면 밖 빈 곳이 보이지 않게 가둔다.

## 핵심 코드 — 대본에서 커서 자리 계산 (휜 호 + inOut)
(발췌: demos/demosMotionC.ts i549 의 cursorAt() 을 정리)
```ts
type P2 = [number, number];
// [시작 초, 끝 초, 출발, 도착]
const MOVES: [number, number, P2, P2][] = [
  [0.25, 1.0, [262, 166], [228, 56]],
  [1.35, 1.95, [228, 56], [228, 112]],
];
const CLICKS = [1.15, 2.1];

const seg = (p: number, a: number, b: number) => Math.min(1, Math.max(0, (p - a) / (b - a)));
const inOut = (x: number) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2);

function cursorAt(p: number): P2 {
  let pos: P2 = MOVES[0][2];
  for (const [t0, t1, a, b] of MOVES) {
    if (p < t0) break;                       // 아직 시작 안 한 이동
    const k = inOut(seg(p, t0, t1));
    const nx = -(b[1] - a[1]), ny = b[0] - a[0];
    const nl = Math.hypot(nx, ny) || 1;
    const arc = Math.sin(Math.PI * k) * 10;  // 사람 손처럼 옆으로 살짝 휜 길
    pos = [a[0] + (b[0] - a[0]) * k + (nx / nl) * arc, a[1] + (b[1] - a[1]) * k + (ny / nl) * arc];
  }
  return pos;
}

// 확대: 누르기 직전에만 목표 1.35, 실제 값은 부드럽게 따라감
const zt = CLICKS.some((c) => p > c - 0.45 && p < c + 0.2) ? 1.35 : 1;
z += (zt - z) * (1 - Math.exp(-dt * 6));
```

## 흔한 실수 · 확인 목록
- [ ] **계속 확대해 두면 결과가 화면 밖에 있다** — 처음엔 줄곧 1.5 배로 따라갔더니 장바구니 배지가 잘려 보이지 않았다. 누르는 순간 근처에만 확대하고 물러나 결과를 보여 준다.
- [ ] **setTimeout 으로 이어 붙이면 속도 · 되감기가 깨진다** — 탭을 숨겼다 오면 타이머가 몰려 터진다. 대본을 시각 목록으로 두고 매 프레임 지금 시각으로 계산한다.
- [ ] **결과가 누름과 같은 순간에 나오면 원인이 안 읽힌다** — 날아가는 점 · 배지 변화를 0.3~0.5초 늦게 — 눈이 커서에서 결과로 옮겨 갈 시간.
- [ ] **확대 초점이 커서를 딱 붙어 따라가면 어지럽다** — 초점 · 배율 모두 지수 감쇠(1 − e^(−dt·k))로 늦게 따라가게 하고, 이동 범위를 화면 안으로 가둔다.

## 완성 기준 체크리스트
- [ ] 커서가 곡선으로 가서 멈칫한 뒤 누르고, 단추가 눌리며 노란 고리가 퍼진다
- [ ] 「담기」를 누르면 점이 장바구니로 날아가고 배지 숫자가 통 튀며 올라간다
- [ ] 확대를 켜면 누르는 곳으로 다가갔다가 물러나고, 끄면 화면이 그대로다 — 어느 쪽이든 창 밖 빈 곳이 드러나지 않는다
- [ ] 속도를 바꿔도 순서(누름 → 결과)가 어긋나지 않는다

## 이 기술 정보
- id: `i549` · 분류: 이펙트 · 연출 › 모션 그래픽 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — DOM 몇 개의 transform 만 바뀐다. 영상으로 구울 때(i82 · i89)는 시간을 1/30초씩 옮겨 찍으면 된다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i549
- 쓰면 좋을 때: 게임 · 앱 소개 영상, 사용법 안내, 튜토리얼 첫 화면 / 「여기를 누르세요」를 말 대신 보여 줄 때
- 쓰지 말 때: 실제 사용자가 조작하는 화면 위 — 진짜 커서와 헷갈린다. 대신 손가락 아이콘과 반투명 안내층 / 긴 과정 전체 — 핵심 2~3번 누르기만 보여 주고 나머지는 컷

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i549 — `src/demos/demosMotionC.ts:168`
```ts
const i549 = {
  kind: 'dom' as const,
  caption: '대본대로 움직이는 가짜 커서 — 곡선으로 가서 잠깐 멈추고 딸깍(눌림 · 물결), 화면은 커서를 따라 확대돼요',
  make(box: HTMLElement) {
    const o = { s: 1, zoom: true };
    const ITEMS = [['빛나는 검', '120', '#ffb547'], ['나무 방패', '80', '#b07a4a'], ['회복 물약', '30', '#ff5c8a']];
    let rows = '';
    ITEMS.forEach((it, k) => {
      const y = 32 + k * 28;
      rows += `<div class="mc49-row" style="top:${y}px"><i style="background:${it[2]}"></i><span>${it[0]}</span><em>${it[1]} G</em></div><div class="mc49-btn" style="top:${y + 3}px">담기</div>`;
    });
    const S = stage(
      box,
      `.mc49-cam{position:absolute;left:0;top:0;width:280px;height:175px;transform-origin:0 0}
      .mc49-w{position:absolute;left:20px;top:12px;width:240px;height:150px;border-radius:10px;background:#fff;box-shadow:0 6px 20px rgba(0,0,0,.35);overflow:hidden;font-family:${F}}
      .mc49-hd{position:absolute;left:0;top:0;right:0;height:24px;background:#f2f4fa;border-bottom:1px solid #e3e7f2;font:800 9.5px/24px ${F};color:#1d2b4f;padding-left:12px}
      .mc49-cart{position:absolute;left:212px;top:6px;width:18px;height:13px}
      .mc49-bd{position:absolute;left:224px;top:2px;min-width:12px;height:12px;border-radius:6px;background:#ff3d5a;color:#fff;font:800 8px/12px ${F};text-align:center;opacity:0}
      .mc49-row{position:absolute;left:10px;width:220px;height:24px;font-size:9px;color:#1d2b4f}
      .mc49-row i{position:absolute;left:0;top:3px;width:18px;height:18px;border-radius:5px}
      .mc49-row span{position:absolute;left:26px;top:5px;font-weight:800}
      .mc49-row em{position:absolute;left:120px;top:5px;font-style:normal;color:#7a86a8;font-weight:700}
      .mc49-btn{position:absolute;left:186px;width:44px;height:18px;border-radius:9px;background:#e8eeff;color:#3a6bff;font:800 8.5px/18px ${F};text-align:center}
      .mc49-btn.hv,.mc49-buy.hv{filter:brightness(.93)}
      .mc49-buy{position:absolute;left:10px;top:120px;width:220px;height:22px;border-radius:11px;background:#3a6bff;color:#fff;font:800 9.5px/22px ${F};text-align:center}
      .mc49-fly{position:absolute;left:0;top:0;width:7px;height:7px;margin:-3.5px;border-radius:50%;background:#ff3d5a;opacity:0}
      .mc49-toast{position:absolute;left:90px;top:96px;width:100px;height:20px;border-radius:10px;background:#1d2b4f;color:#fff;font:800 9px/20px ${F};text-align:center;opacity:0}
      .mc49-rp{position:absolute;left:0;top:0;width:30px;height:30px;margin:-15px;border-radius:50%;border:2px solid #ffd23f;opacity:0;box-sizing:border-box}
      .mc49-cu{position:absolute;left:0;top:0;width:12px;height:17px;transform-origin:0 0;filter:drop-shadow(0 1px 1px rgba(0,0,0,.4))}`,
      `<div class="mc49-cam"><div class="mc49-w"><div class="mc49-hd">아이템 상점</div>
        <svg class="mc49-cart" viewBox="0 0 18 13"><path d="M0 1h3l2 8h10l2-6H4.5" fill="none" stroke="#1d2b4f" stroke-width="1.6" stroke-linejoin="round"/><circle cx="6.5" cy="11.5" r="1.3" fill="#1d2b4f"/><circle cx="13.5" cy="11.5" r="1.3" fill="#1d2b4f"/></svg>
        <div class="mc49-bd">0</div>${rows}<div class="mc49-buy">구매하기</div><div class="mc49-toast">구매 완료!</div></div>
        <div class="mc49-fly"></div><div class="mc49-rp"></div>
        <svg class="mc49-cu" viewBox="-1 -1 12 17"><path d="M0 0 L0 13 L3.5 10 L6 15.5 L8 14.6 L5.6 9.4 L10 9.4 Z" fill="#fff" stroke="#111" stroke-width="1" stroke-linejoin="round"/></svg></div>`,
      'linear-gradient(#20305f,#0f1733)',
    );
    const $ = (s: string): HTMLElement => S.st.querySelector<HTMLElement>(s)!;
    const cam = $('.mc49-cam');
    const cu = $('.mc49-cu');
    const rp = $('.mc49-rp');
    const fly = $('.mc49-fly');
    const bd = $('.mc49-bd');
    const toast = $('.mc49-toast');
    const btns = [...S.st.querySelectorAll<HTMLElement>('.mc49-btn')];
    const buy = $('.mc49-buy');

    // 대본 — 무대 좌표 (창 안 단추 가운데)
    type P2 = [number, number];
    const REST: P2 = [262, 166];
    const B = (k: number): P2 => [228, 56 + 28 * k];
    const BUY: P2 = [140, 143];
    const CART: P2 = [241, 25];
    const MOVES: [number, number, P2, P2][] = [
      [0.25, 1.0, REST, B(0)],
      [1.35, 1.95, B(0), B(2)],
      [2.3, 2.95, B(2), BUY],
      [4.2, 4.9, BUY, REST],
    ];
    const ADDS: [number, number][] = [[1.15, 0], [2.1, 2]];
    const BUY_T = 3.1;
    const CLICKS = [1.15, 2.1, BUY_T];
    const T = 5.6;
    let tt = 0;
    let z = 1;
    let fx = 140;
    let fy = 87;

    function cursorAt(p: number): P2 {
      let pos: P2 = REST;
      for (const [t0, t1, a, b] of MOVES) {
        if (p < t0) break;
        const k = inOut(seg(p, t0, t1));
        // 곧은 줄 대신 살짝 휜 길 — 사람 손은 호를 그린다
        const nx = -(b[1] - a[1]);
        const ny = b[0] - a[0];
        const nl = Math.hypot(nx, ny) || 1;
        const arc = Math.sin(Math.PI * k) * 10;
        pos = [lerp(a[0], b[0], k) + (nx / nl) * arc, lerp(a[1], b[1], k) + (ny / nl) * arc];
      }
      return pos;
    }

    return {
      controls: [speedCtl(o), { type: 'toggle', label: '커서 따라 확대', value: true, on: (v: boolean) => (o.zoom = v) }] as Control[],
      update(_t: number, dt: number) {
        const d = Math.max(0, Math.min(dt, 0.1)) * o.s;
        tt += d;
        const p = tt % T;
        const [x, y] = cursorAt(p);
        // 딸깍 — 가장 최근 클릭
        let lc = -1;
        for (const c of CLICKS) if (p >= c) lc = c;
        const since = lc < 0 ? 9 : p - lc;
        const press = since < 0.14;
        cu.style.transform = `translate(${x.toFixed(2)}px,${y.toFixed(2)}px) scale(${press ? 0.82 : 1})`;
        const rk = clamp01(since / 0.5);
        rp.style.opacity = since < 0.5 ? (1 - rk).toFixed(3) : '0';
        if (lc >= 0) rp.style.transform = `translate(${cursorAt(lc)[0].toFixed(1)}px,${cursorAt(lc)[1].toFixed(1)}px) scale(${(0.3 + rk * 1.2).toFixed(3)})`;
        // 단추: 커서가 올라가면 진하게, 누르면 꾹
        btns.forEach((b, k) => {
          const [bx, by] = B(k);
          b.classList.toggle('hv', Math.abs(x - bx) < 22 && Math.abs(y - by) < 9);
          b.style.transform = press && lc !== BUY_T && Math.abs(cursorAt(lc)[1] - by) < 2 ? 'translateY(1px) scale(.94)' : '';
        });
        buy.classList.toggle('hv', Math.abs(x - BUY[0]) < 110 && Math.abs(y - BUY[1]) < 11);
        buy.style.transform = press && lc === BUY_T ? 'translateY(1px) scale(.97)' : '';
        // 담기 → 점이 곡선으로 장바구니에 들어가고 배지가 통
        let count = 0;
        let arr = -9;
        fly.style.opacity = '0';
        for (const [c, k] of ADDS) {
          const f = seg(p, c, c + 0.5);
          if (f > 0 && f < 1) {
            const [bx, by] = B(k);
            const e = inOut(f);
            fly.style.opacity = '1';
            fly.style.transform = `translate(${lerp(bx, CART[0], e).toFixed(1)}px,${(lerp(by, CART[1], e) - Math.sin(Math.PI * e) * 22).toFixed(1)}px)`;
          }
          if (p >= c + 0.5) {
            count++;
            arr = c + 0.5;
          }
        }
        if (p >= BUY_T + 0.15) count = 0;
        const bump = 1 + 0.6 * Math.pow(Math.max(0, 1 - (p - arr) / 0.3), 2);
        bd.textContent = String(count);
        bd.style.opacity = count > 0 ? '1' : '0';
        bd.style.transform = `scale(${bump.toFixed(3)})`;
        const ta = outBack(seg(p, BUY_T + 0.15, BUY_T + 0.5)) * (1 - inOut(seg(p, 4.4, 4.7)));
        toast.style.opacity = clamp01(ta).toFixed(3);
        toast.style.transform = `translateY(${((1 - ta) * 14).toFixed(2)}px)`;
        // 따라가는 확대 — 누르기 직전에 다가가고 누른 뒤엔 물러나 결과(배지 · 알림)를 보여 줌.
        // 목표 배율 · 초점을 부드럽게 따라가고, 화면 밖이 보이지 않게 가두기
        const zt = o.zoom && CLICKS.some((c) => p > c - 0.45 && p < c + 0.2) ? 1.35 : 1;
        const kz = 1 - Math.exp(-d * 6);
        z += (zt - z) * kz;
        fx += (x - fx) * (1 - Math.exp(-d * 5));
        fy += (y - fy) * (1 - Math.exp(-d * 5));
        const tx = Math.min(0, Math.max(280 - 280 * z, 140 - fx * z));
        const ty = Math.min(0, Math.max(175 - 175 * z, 87.5 - fy * z));
        cam.style.transform = `translate(${tx.toFixed(2)}px,${ty.toFixed(2)}px) scale(${z.toFixed(4)})`;
      },
      dispose() {
        S.dispose();
      },
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [이징 곡선 모음 (트윈 비교)](https://ai-techstudio.web.app/ai/t/i292.md) `i292` · [감독 투어 + 프레임 단위 녹화](https://ai-techstudio.web.app/ai/t/i89.md) `i89`
- 다음에 해 볼 기술: [영상으로 굽기 (헤드리스 브라우저 → mp4)](https://ai-techstudio.web.app/ai/t/i82.md) `i82` · [타임라인 연출 + 프레임 효과음](https://ai-techstudio.web.app/ai/t/i127.md) `i127`
- 참고 문서: [MDN — Using the Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API/Using_the_Web_Animations_API)
