# AI 꾸러미 — 슬로모션 · 시간 배율 곡선 — Slow motion (time scale curve)
> 결정적 순간 직전 남은 시간에 따라 게임 시간 배율을 0.15 까지 부드럽게 낮췄다가, 「쾅」 하는 순간 1 로 되돌려 「빨랐다 → 늘어짐 → 쾅」 의 긴장을 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i204

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

## 주문서

### 만들어 줘: 슬로모션 · 시간 배율 곡선 — Slow motion (time scale curve)

#### 1. 목표
마지막 블록이 탑에 내려앉는 순간에 슬로모션을 넣어 줘 — 닿기 직전 시간 배율이 0.15 까지 부드럽게 줄었다가 닿는 순간 원래 속도로, 아래에 시간 배율 그래프. 분위기는 푸른빛 느린 순간 + 쾅.

#### 2. 핵심 기술 용어
- **Slow motion (time scale curve)** — 게임 시간 배율을 곡선으로 바꾸기
- **Bullet time** — 결정적 순간만 느리게
- **Time scale (game time vs real time)** — 게임 시간 += 실제 dt × 배율

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

#### 4. 조건
- 게임 시간과 실제 시간을 나눈다 (gt += dt × 배율) — 그림은 모두 게임 시간으로
- 배율 곡선은 부드럽게 (smoothstep) — 뚝 끊기면 멈칫으로 보인다
- 「쾅」 하는 순간엔 배율 1 — 충격은 원래 속도로
- 가장 느린 배율 조절 (0.05 ~ 0.8, 견본 0.15)
- 아래에 시간 배율 그래프로 곡선 모양을 보여 주기

#### 5. 완성 기준 (이게 보이면 성공)
- 빨간 블록이 떨어지다 탑에 닿기 직전 확 느려지고, 화면이 푸르게 물들며 잔상이 남는다
- 닿는 순간 원래 속도로 돌아오며 화면이 흔들리고 「완성!」 이 톡 나온다
- 아래 그래프에 1 → 0.15 → 1 로 내려갔다 올라오는 곡선이 흐른다
- 「슬로모션 켜기」 를 끄면 그래프가 평평하고 그냥 떨어진다

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

## 원리
- 게임 시간 gt 를 따로 두고 매 프레임 gt += 실제 dt × 배율. 그림 · 움직임은 모두 gt 로 계산한다.
- 배율 = minS + (1 − minS) × smooth(0.06, 0.42, 남은 시간). 닿기 0.42초 전부터 느려지기 시작해 0.06초 전에 가장 느림(0.15).
- 닿는 순간(gt ≥ GC) 배율 1 — 「쾅」 은 원래 속도로 터져야 세다. 흔들림 · 불꽃 · 「완성!」 이 함께.
- 느린 동안은 푸른 막 · 가장자리 어둡게 · 잔상 4장으로 「시간이 늘어났다」 를 보여 준다. 소리는 같은 배율로 음높이를 낮추면 더 좋다 (견본은 「♪ 소리도 낮게」 표시만).

## 핵심 코드 — 남은 시간으로 배율 곡선 · 게임 시간 나누기
(발췌: demos/demosJuice.ts D204 를 정리)
```ts
const smooth = (e0: number, e1: number, x: number) => { const v = Math.max(0, Math.min(1, (x - e0) / (e1 - e0))); return v * v * (3 - 2 * v); };
const GC = 1.0;           // 닿는 게임 시각
let minS = 0.15;          // 가장 느릴 때 배율
let gtime = 0;            // 게임 시간 (실제 시간과 따로)

/** 닿기 0.42초 전부터 느려져 0.06초 전에 가장 느림, 닿으면 1 */
function scaleAt(gt: number): number {
  if (!on || gt >= GC) return 1;
  const d = GC - gt;
  return minS + (1 - minS) * smooth(0.06, 0.42, d);
}

function frame(dt: number) {
  const s = scaleAt(gtime);
  gtime += dt * s;                       // 게임 시간만 느려진다
  const a = gtime - GC;                  // 닿은 뒤 시간
  if (a < 0) {
    const y = lerp(y0, landY, Math.min(1, gtime / GC) ** 2);   // 떨어지기 (게임 시간으로)
    drawBlock(y);
    const slow = 1 - s;
    if (slow > 0.05) for (let j = 1; j <= 4; j++) drawGhost(gtime - j * 0.05, 0.13 * (5 - j) * slow); // 잔상
    if (slow > 0.02) { g.fillStyle = 'rgba(80,140,255,' + 0.16 * slow + ')'; g.fillRect(0, 0, w, top); } // 푸른 막
  } else {
    const k = Math.exp(-a * 9) * 4 * u; // 쾅: 원래 속도로 흔들림
    g.translate(noise1(a * 60, 1) * k, noise1(a * 60, 2) * k);
    drawLanded(a);
  }
  // 소리도 같이: source.playbackRate.value = s (견본은 표시만)
}
```

## 흔한 실수 · 확인 목록
- [ ] **requestAnimationFrame 시간을 그대로 쓰면 느리게 할 수 없다** — 게임 시간 변수를 따로 두고 dt × 배율로만 늘린다. 모든 움직임은 그 시간으로.
- [ ] **충격 순간까지 느리면 「쾅」 이 맥빠진다** — 닿는 순간 배율을 1 로 — 느림은 그 직전까지만.
- [ ] **프레임 시간을 0.05 로 잘라 쓰면 느린 폰에서 의도치 않은 슬로모션이 생긴다** — 사이트에서 실제로 겪었다. 연출용 배율과 별개로, 기본 dt 는 실제 시간(0.25초까지)을 쓰고 작은 조각으로 나눠 적분한다.

## 완성 기준 체크리스트
- [ ] 빨간 블록이 떨어지다 탑에 닿기 직전 확 느려지고, 화면이 푸르게 물들며 잔상이 남는다
- [ ] 닿는 순간 원래 속도로 돌아오며 화면이 흔들리고 「완성!」 이 톡 나온다
- [ ] 아래 그래프에 1 → 0.15 → 1 로 내려갔다 올라오는 곡선이 흐른다
- [ ] 「슬로모션 켜기」 를 끄면 그래프가 평평하고 그냥 떨어진다

## 이 기술 정보
- id: `i204` · 분류: 이펙트 · 연출 › 손맛 · 주스 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 배율 곡선 하나. 비용 없음.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i204
- 쓰면 좋을 때: 결과가 갈리는 마지막 순간에 긴장을 줄 때 / 빠른 움직임이 무엇이었는지 보여 줄 때
- 쓰지 말 때: 자주 일어나는 동작 — 매번 느려지면 답답하다 / 사용자가 조작 중인 순간 — 입력까지 느려지면 굼뜨다. 연출 구간에만

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### D204 — `src/demos/demosJuice.ts:897`
```ts
const D204: Demo = {
  kind: '2d',
  caption: '마지막 블록이 닿기 직전 시간이 0.15배로 늘어졌다가 「쾅」 하는 순간 원래 속도 — 아래는 시간 배율 그래프',
  make() {
    let on = true;
    let minS = 0.15;
    let gtime = 0;
    let wait = 0;
    const N = 200;
    const hist = new Float32Array(N).fill(1);
    let hi = 0;
    let acc = 0;
    let curS = 1;
    const GC = 1.0;
    const sAt = (gt: number): number => {
      if (!on) return 1;
      if (gt >= GC) return 1;
      const d = GC - gt;
      return minS + (1 - minS) * smooth(0.06, 0.42, d);
    };
    return {
      draw(g, w, h, _t, dt0) {
        reset(g);
        const dt = Math.min(dt0, 0.05);
        const u = Math.min(w / 280, h / 175);
        if (wait > 0) {
          wait -= dt;
          curS = 1;
        } else {
          curS = sAt(gtime);
          gtime += dt * curS;
          if (gtime > GC + 1.3) {
            gtime = 0;
            wait = 0.25;
          }
        }
        acc += dt;
        while (acc > 1 / 50) {
          acc -= 1 / 50;
          hist[hi] = curS;
          hi = (hi + 1) % N;
        }
        const top = h * 0.74;
        const a = gtime - GC;
        // 흔들기
        g.save();
        if (a >= 0 && a < 0.4) {
          const k = Math.exp(-a * 9) * 4 * u;
          g.translate(noise1(a * 60, 1) * k, noise1(a * 60, 2) * k);
        }
        bg(g, w, top, '#1f3b73', '#0d1a3a');
        ground(g, w, top, top * 0.86, u);
        const gy = top * 0.86;
        const s = 26 * u;
        const cx = w * 0.5;
        const stack = ['1', '2', '3'];
        stack.forEach((n, i) => block(g, cx, gy - i * s * 0.94, s, BLK[i + 2]!, n));
        const landY = gy - 3 * s * 0.94;
        const y0 = -s * 0.3;
        const fy = (gt: number): number => lerp(y0, landY, clamp01(gt / GC) ** 2);
        const fx = (gt: number): number => cx + Math.sin(gt * 4) * 6 * u * (1 - clamp01(gt / GC));
        if (a < 0) {
          const slow = 1 - curS;
          if (slow > 0.05) {
            for (let j = 1; j <= 4; j++) {
              const gg = Math.max(0, gtime - j * 0.05);
              block(g, fx(gg), fy(gg), s, '#ff6b8a', '', { alpha: 0.13 * (5 - j) * slow, rot: Math.sin(gg * 3) * 0.25 * (1 - gg / GC) });
            }
          }
          block(g, fx(gtime), fy(gtime), s, '#ff6b8a', '4', { rot: Math.sin(gtime * 3) * 0.25 * (1 - gtime / GC) });
        } else {
          const sq = 1 - 0.2 * Math.exp(-a * 10) * Math.cos(a * 30);
          block(g, cx, landY, s, '#ff6b8a', '4', { sx: 2 - sq, sy: sq, flash: clamp01(1 - a / 0.1) * 0.7 });
          sparks(g, cx, landY - s, a, 18, 3, 60 * u, ['#fff', '#ffe066', '#ffa94d'], 0.6, 0, 2.6 * u);
          ringFx(g, cx, landY - s / 2, a, 0.4, 10 * u, 70 * u, '#ffffff', 3 * u);
          if (a < 1.2) {
            g.save();
            g.globalAlpha = 1 - smooth(0.9, 1.2, a);
            const k = backOut(a / 0.35);
            g.translate(cx + 62 * u, top * 0.32);
            g.scale(k, k);
            otext(g, '완성!', 0, 0, 20 * u, '#ffe066', '#6b2c00', 4 * u);
            g.restore();
          }
        }
        // 느려진 느낌: 푸른 막 + 가장자리
        const slow = a < 0 ? 1 - curS : 0;
        if (slow > 0.02) {
          g.fillStyle = `rgba(80,140,255,${0.16 * slow})`;
          g.fillRect(0, 0, w, top);
          const rg = g.createRadialGradient(w / 2, top / 2, top * 0.3, w / 2, top / 2, w * 0.7);
          rg.addColorStop(0, 'rgba(0,10,40,0)');
          rg.addColorStop(1, `rgba(0,10,40,${0.6 * slow})`);
          g.fillStyle = rg;
          g.fillRect(0, 0, w, top);
        }
        g.restore();
        pill(g, `시간 ×${curS.toFixed(2)}`, 38 * u, 13 * u, 8 * u, curS < 0.9 ? '#4c6ef5' : 'rgba(255,255,255,.18)', '#fff');
        if (curS < 0.6) pill(g, '♪ 소리도 낮게', 104 * u, 13 * u, 7 * u, 'rgba(76,110,245,.55)', '#dbe4ff');
        // 그래프
        const gx = 8 * u;
        const gyy = top + 6 * u;
        const gw = w - 16 * u;
        const gh = h - gyy - 6 * u;
        rr(g, gx, gyy, gw, gh, 6 * u);
        g.fillStyle = '#0a0f26';
        g.fill();
        txt(g, '시간 배율', gx + 6 * u, gyy + 7 * u, 6.5 * u, '#8f9bd6', 'left', 700);
        g.strokeStyle = 'rgba(255,255,255,.12)';
        g.setLineDash([3 * u, 3 * u]);
        g.beginPath();
        g.moveTo(gx + 40 * u, gyy + 6 * u);
        g.lineTo(gx + gw - 4 * u, gyy + 6 * u);
        g.stroke();
        g.setLineDash([]);
        g.beginPath();
        for (let i = 0; i < N; i++) {
          const v = hist[(hi + i) % N]!;
          const px = gx + 40 * u + ((gw - 46 * u) * i) / (N - 1);
          const py = gyy + 6 * u + (1 - v) * (gh - 12 * u);
          if (i) g.lineTo(px, py);
          else g.moveTo(px, py);
        }
        g.strokeStyle = '#74c0fc';
        g.lineWidth = 2 * u;
        g.lineJoin = 'round';
        g.stroke();
        txt(g, '1', gx + 36 * u, gyy + 6 * u, 6 * u, '#8f9bd6', 'right', 700);
        txt(g, '0', gx + 36 * u, gyy + gh - 6 * u, 6 * u, '#8f9bd6', 'right', 700);
        reset(g);
      },
      controls: [
        tgl('슬로모션 켜기', on, (v) => (on = v)),
        rng('가장 느릴 때 배율', 0.05, 0.8, 0.01, minS, (v) => (minS = v)),
        { type: 'button', label: '처음부터', on: () => ((gtime = 0), (wait = 0)) },
      ],
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [카메라 킥 · 펀치 줌](https://ai-techstudio.web.app/ai/t/i199.md) `i199` · [임팩트 프레임 (반전 번쩍)](https://ai-techstudio.web.app/ai/t/i200.md) `i200`
- 다음에 해 볼 기술: [전투 판정 (휘두르기 · 히트스톱 · 넉백)](https://ai-techstudio.web.app/ai/t/i484.md) `i484`
- 참고 문서: [MDN — AudioBufferSourceNode.playbackRate](https://developer.mozilla.org/en-US/docs/Web/API/AudioBufferSourceNode/playbackRate)
