# AI 꾸러미 — 극적인 확대 (결정적 순간) — Dramatic zoom (slow-motion punch-in)
> 결정적 수가 닿는 순간 게임 시간을 0.14배로 늦추고 카메라를 그 자리로 다가가며 시야를 좁혀서, 체크메이트 · 정답 순간을 영화처럼 짧게 강조했다 돌아온다.  
> 견본: https://ai-techstudio.web.app/#t/i428

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

## 주문서

### 만들어 줘: 극적인 확대 (결정적 순간) — Dramatic zoom (slow-motion punch-in)

#### 1. 목표
체크메이트 한 수에서 결정적 순간에 시간을 느리게 하고 카메라가 그 자리로 다가갔다가 돌아오게 해 줘. 분위기는 밤 보랏빛 판.

#### 2. 핵심 기술 용어
- **Dramatic zoom (slow-motion punch-in)** — 극적인 확대 — 결정적 순간 느리게 + 다가가기
- **Time scale (game time vs real time)** — 게임 시간 배율 — 연출 시계는 실제 시간
- **FOV narrowing** — 시야각 좁히기로 확대감
- **Exponential smoothing in/out** — 들어갈 땐 빠르게, 나올 땐 천천히

#### 3. 환경
- 플랫폼: three.js r186 (ES 모듈 · TypeScript, `import * as THREE from "three"`), WebGL2, 외부 라이브러리 추가 없이
- 화면: 3D · 브라우저 — PC 와 폰(가로 844×390 · 세로 390×844) 모두, 60fps 목표

#### 4. 조건
- 게임 시계(배율 적용)와 연출 시계(실제 시간)를 나눈다
- 느려지기 · 확대는 들어갈 때 빠르게, 나올 때 천천히 (지수 smoothing 빠르기를 다르게)
- 확대 세기 · 느린 화면 켬/끔 · 확대 켬/끔 조절
- 연출은 2초 안에 원래 구도로 돌아온다
- 시야각을 바꾸면 updateProjectionMatrix()

#### 5. 완성 기준 (이게 보이면 성공)
- 말이 왕 자리로 폴짝 뛰어 닿는 순간 화면이 느려지고(「느린 화면 ×0.14」) 카메라가 그 칸으로 다가간다
- 왕이 튕겨 날아가고 별이 터지는 장면이 느리게 보인 뒤, 카메라가 넓은 구도로 천천히 돌아온다
- 「느린 화면」을 끄면 확대만, 「확대」를 끄면 느려지기만 된다
- 폰에서도 끊김 없이

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

## 원리
- 시계를 둘로 나눈다: 게임 시계 gt += dt × ts (말 · 별 입자), 연출 시계는 실제 시간. 그래야 느려져도 카메라 연출은 제 빠르기다.
- 말이 닿기 직전(게임 시간 1.88초)부터 실제 1.7초 동안 ts 목표 0.14. ts 는 들어갈 때 1 − e^(−18dt) 로 확, 나올 때 1 − e^(−5dt) 로 천천히.
- 확대 z 도 같은 때 목표 1 (들어갈 때 7, 나올 때 2.6 의 빠르기). 카메라 = lerp(넓은 자리, 착지점 + (2.2, 1.5, 2.6), ease(z) × 세기 0.85), 시야각 40° → 30°.
- 착지 순간 말이 납작해지고 왕이 튕겨 날아가며 별 90개 · 고리가 터진다. 화면 위에 「결정적 한 수!」 딱지.

## 핵심 코드 — 게임 시계 · 느린 화면 · 확대 카메라 (t = 실제 시간, c = 이번 주기 안 시간)
(발췌: demos/demosCamLight.ts demoDramatic() update() 를 정리)
```ts
const ease = (k: number) => (k <= 0 ? 0 : k >= 1 ? 1 : k * k * (3 - 2 * k));
let gt = 0, ts = 1, z = 0, slowAt = -1;
const st = { zoom: true, slow: true, power: 0.85 };

function update(c: number, t: number, dt0: number, land: THREE.Vector3, cam: THREE.PerspectiveCamera): void {
  const dt = Math.min(dt0, 0.05);
  if (st.slow && slowAt < 0 && gt > 1.88) slowAt = c;            // 닿기 직전부터
  const realSince = slowAt >= 0 ? c - slowAt : 0;
  const slowing = slowAt >= 0 && realSince < 1.7;                // 실제 시간 1.7초
  const tsWant = slowing ? 0.14 : 1;
  ts += (tsWant - ts) * (1 - Math.exp(-dt * (slowing ? 18 : 5))); // 들어갈 땐 확, 나올 땐 천천히
  gt += dt * ts;                                                  // 게임 시계만 느려짐 — 말 · 별은 gt 로
  const zWant = st.zoom && slowing ? 1 : 0;
  z += (zWant - z) * (1 - Math.exp(-dt * (zWant ? 7 : 2.6)));
  // 카메라: 넓게 천천히 돌다가 → 결정적 순간 착지점으로
  const yaw = 0.5 + Math.sin(t * 0.25) * 0.35;
  const wide = new THREE.Vector3(Math.sin(yaw) * 11.5, 9.4, Math.cos(yaw) * 11.5);
  const close = new THREE.Vector3(land.x + 2.2, land.y + 1.5, land.z + 2.6);
  const zz = ease(z) * st.power;
  cam.position.lerpVectors(wide, close, zz);
  cam.fov = 40 + (30 - 40) * zz;
  cam.updateProjectionMatrix();
  cam.lookAt(new THREE.Vector3(0, 0, 0).lerp(land.clone().setY(0.55), zz));
}
```

## 흔한 실수 · 확인 목록
- [ ] **카메라 연출까지 게임 시간으로 돌리면 느려질 때 카메라도 멈춘다** — 느려지는 건 게임 시계(gt)만, 카메라는 실제 dt 로.
- [ ] **느려지기를 확 풀면 툭 끊긴다** — 들어갈 땐 빠르게(18), 나올 땐 천천히(5) 지수 smoothing.
- [ ] **연출이 길면 아이가 기다리다 지친다** — 느린 구간 1.7초 · 전체 2초 안. 이 사이트는 「생각하는 척 0.4 ~ 0.7초까지」를 빠른 반응 기준으로 삼는다.

## 완성 기준 체크리스트
- [ ] 말이 왕 자리로 폴짝 뛰어 닿는 순간 화면이 느려지고(「느린 화면 ×0.14」) 카메라가 그 칸으로 다가간다
- [ ] 왕이 튕겨 날아가고 별이 터지는 장면이 느리게 보인 뒤, 카메라가 넓은 구도로 천천히 돌아온다
- [ ] 「느린 화면」을 끄면 확대만, 「확대」를 끄면 느려지기만 된다
- [ ] 폰에서도 끊김 없이

## 이 기술 정보
- id: `i428` · 분류: 이펙트 · 연출 › 카메라 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 카메라 · 시간 배율 계산뿐. 비용 없음.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i428
- 쓰면 좋을 때: 게임이 끝나는 한 수 · 정답처럼 한 판에 한 번 있는 순간 / 무엇이 일어났는지 아이가 놓치기 쉬울 만큼 빠른 순간
- 쓰지 말 때: 자주 일어나는 일 — 매번 느려지면 답답하다. 한 판에 한두 번만 / 빠른 반응이 중요한 연속 입력 — 연출 중 입력을 막지 말거나 아주 짧게 (생각하는 척 0.4 ~ 0.7초 기준)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### demoDramatic — `src/demos/demosCamLight.ts:1204`
```ts
function demoDramatic(): Scene3D {
  const scene = new THREE.Scene();
  const sky = makeSky(0x141a3c, 0x3d3470, 0x221c40);
  scene.add(sky.mesh);
  const { sun } = addLights(scene);
  sun.position.set(-5, 11, 6);
  const board = buildBoard(7, 7);
  scene.add(board.group);
  const hero = makePiece(0xe8574a, false);
  const king = makePiece(0x4a7fe8, true);
  scene.add(hero, king);
  const others: THREE.Group[] = [];
  const ot: [number, number, number][] = [
    [0, 1, 0x4a7fe8],
    [5, 1, 0x4a7fe8],
    [6, 4, 0x4a7fe8],
    [1, 6, 0xe8574a],
    [4, 6, 0xe8574a],
  ];
  for (const [i, j, c] of ot) {
    const p = makePiece(c, false);
    p.position.copy(board.tile(i, j));
    scene.add(p);
    others.push(p);
  }
  const route = [board.tile(1, 5), board.tile(2, 4), board.tile(3, 3), board.tile(4, 2)];
  const land = route[3]!;
  /* 별 터짐 */
  const N = 90;
  const pg = new THREE.BufferGeometry();
  const pp = new Float32Array(N * 3);
  const pc = new Float32Array(N * 3);
  const pvv: THREE.Vector3[] = [];
  const r0 = rng(5);
  const pal = [new THREE.Color(0xffe066), new THREE.Color(0xffffff), new THREE.Color(0xff8fb1), new THREE.Color(0x8fd8ff)];
  for (let i = 0; i < N; i++) {
    const a = r0() * TAU;
    const sp = 1.5 + r0() * 3.5;
    pvv.push(new THREE.Vector3(Math.cos(a) * sp, 2 + r0() * 4, Math.sin(a) * sp));
    const c = pal[i % 4]!;
    pc.set([c.r, c.g, c.b], i * 3);
  }
  pg.setAttribute('position', new THREE.BufferAttribute(pp, 3));
  pg.setAttribute('color', new THREE.BufferAttribute(pc, 3));
  const glow = glowTex();
  const pts = new THREE.Points(pg, new THREE.PointsMaterial({ size: 0.32, map: glow, vertexColors: true, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false }));
  pts.frustumCulled = false;
  scene.add(pts);
  const ring = new THREE.Mesh(
    new THREE.RingGeometry(0.85, 1, 48).rotateX(-Math.PI / 2),
    new THREE.MeshBasicMaterial({ color: 0xffd860, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide }),
  );
  ring.position.copy(land).setY(0.2);
  scene.add(ring);
  const cam = new THREE.PerspectiveCamera(40, 1.6, 0.1, 200);
  const mm = new MiniMap(9);
  mm.rectItem(board.w, board.d, 0xa8693c);
  mm.rectItem(7, 7, 0x8fd0a6);
  const landDot = mm.dot(0xffd23f, 0.25);
  landDot.position.set(land.x, 0, land.z);
  const heroDot = mm.dot(0xe8574a, 0.3);
  mm.camera();
  const hud = new Hud();
  const st = { zoom: true, slow: true, power: 0.85 };
  let gt = 0;
  let lastC = 0;
  let slowAt = -1;
  let ts = 1;
  let z = 0;
  let realSince = 0;
  const kingV = new THREE.Vector3();
  const look = new THREE.Vector3();
  const pos = new THREE.Vector3();
  const zp = new THREE.Vector3();
  const LAND = 2.0;
  return {
    scene,
    camera: cam,
    update(t, dt) {
      dt = Math.min(dt, 0.05);
      const c = t % 6.2;
      if (c < lastC) {
        gt = 0;
        slowAt = -1;
        realSince = 0;
      }
      lastC = c;
      if (st.slow && slowAt < 0 && gt > 1.88) slowAt = c;
      if (slowAt >= 0) realSince = c - slowAt;
      const slowing = slowAt >= 0 && realSince < 1.7;
      const tsWant = slowing ? 0.14 : 1;
      ts += (tsWant - ts) * (1 - Math.exp(-dt * (slowing ? 18 : 5)));
      gt += dt * ts;
      const zWant = st.zoom && (slowing || (!st.slow && gt > 1.75 && gt < 2.9)) ? 1 : 0;
      z += (zWant - z) * (1 - Math.exp(-dt * (zWant ? 7 : 2.6)));
      /* 말 폴짝 */
      const hops: [number, number, number][] = [
        [0.5, 0.9, 0.5],
        [1.05, 1.45, 0.5],
        [1.5, LAND, 1.2],
      ];
      pos.copy(route[0]!);
      let squash = 1;
      for (let i = 0; i < 3; i++) {
        const [a, b, hgt] = hops[i]!;
        if (gt >= b) pos.copy(route[i + 1]!);
        else if (gt > a) {
          const k = (gt - a) / (b - a);
          pos.lerpVectors(route[i]!, route[i + 1]!, ease(k));
          pos.y += Math.sin(Math.PI * k) * hgt;
        }
      }
      const since = gt - LAND;
      if (since > 0 && since < 0.35) squash = 1 - Math.sin((since / 0.35) * Math.PI) * 0.3;
      hero.position.copy(pos);
      hero.scale.set(2 - squash, squash, 2 - squash);
      hero.rotation.y = Math.PI * 0.75;
      /* 왕 날아가기 */
      if (since <= 0) {
        king.position.copy(land);
        king.rotation.set(0, -Math.PI * 0.25, 0);
        king.visible = true;
        kingV.set(1.6, 4.2, -2.0);
      } else {
        king.position.set(land.x + kingV.x * since, land.y + kingV.y * since - 4.9 * since * since, land.z + kingV.z * since);
        king.rotation.set(-since * 5, -Math.PI * 0.25, since * 3);
        king.visible = king.position.y > -6;
      }
      /* 별 · 고리 */
      const show = since > 0 && since < 1.6;
      pts.visible = show;
      ring.visible = show;
      if (show) {
        for (let i = 0; i < N; i++) {
          const v = pvv[i]!;
          pp[i * 3] = land.x + v.x * since;
          pp[i * 3 + 1] = 0.4 + v.y * since - 4 * since * since;
          pp[i * 3 + 2] = land.z + v.z * since;
        }
        pg.attributes.position!.needsUpdate = true;
        (pts.material as THREE.PointsMaterial).opacity = 1 - since / 1.6;
        ring.scale.setScalar(0.3 + since * 4);
        (ring.material as THREE.MeshBasicMaterial).opacity = Math.max(0, 1 - since / 0.9);
      }
      others.forEach((o, i) => (o.position.y = 0.16 + Math.abs(Math.sin(gt * 2.2 + i)) * 0.03));
      /* 카메라: 넓게 천천히 → 결정적 순간에 다가감 */
      const yaw = 0.5 + Math.sin(t * 0.25) * 0.35;
      const wide = new THREE.Vector3(Math.sin(yaw) * 11.5, 9.4, Math.cos(yaw) * 11.5);
      zp.set(land.x + 2.2, land.y + 1.5, land.z + 2.6);
      const zz = ease(z) * st.power;
      cam.position.lerpVectors(wide, zp, zz);
      look.set(0, 0, 0).lerp(land.clone().setY(0.55), zz);
      cam.fov = lerp(40, 30, zz);
      cam.updateProjectionMatrix();
      cam.lookAt(look);
      heroDot.position.set(pos.x, 0, pos.z);
      mm.setCamera(cam);
    },
    render(r, w, h) {
      withState(r, () => {
        r.render(scene, cam);
        mm.draw(r, w, h);
        const u = Math.min(1.9, w / 280, h / 175);
        hud.label('s', `느린 화면 ×${ts.toFixed(2)}`, 10 * u, 10 * u, 16 * u, { bg: 'rgba(14,20,44,0.78)', visible: ts < 0.9 });
        const since = gt - LAND;
        hud.label('p', '결정적 한 수!', w * 0.36, h * 0.2, 26 * u * (1 + Math.max(0, 0.25 - since) * 1.2), { align: 'c', bg: 'rgba(255,90,122,0.92)', visible: since > 0 && since < 1.4 });
        hud.draw(r, w, h);
      });
    },
    controls: [
      { type: 'toggle', label: '확대 (결정적 순간에 다가가기)', value: true, on: (v) => (st.zoom = v) },
      { type: 'toggle', label: '느린 화면', value: true, on: (v) => (st.slow = v) },
      { type: 'range', label: '확대 세기', min: 0.3, max: 1, step: 0.05, value: 0.85, on: (v) => (st.power = v) },
    ] as Control[],
    dispose() {
      disposeTree(scene);
      mm.dispose();
      hud.dispose();
    },
  };
}
```

### i428 견본 항목 — `src/demos/demosCamLight.ts:2478`
```ts
  i428: { kind: '3d', caption: '결정적 한 수에서 느린 화면 + 확대로 다가갔다 돌아옴', make: () => demoDramatic() }
```

## 관련 기술
- 먼저 알면 좋은 기술: [따라가는 카메라 (부드러운 추적)](https://ai-techstudio.web.app/ai/t/i426.md) `i426`
- 다음에 해 볼 기술: [슬로모션 · 시간 배율 곡선](https://ai-techstudio.web.app/ai/t/i204.md) `i204` · [카메라 흔들림 (잡음 기반 · 감쇠)](https://ai-techstudio.web.app/ai/t/i429.md) `i429`
