# AI 꾸러미 — FPS 1인칭 무기 연출 — First-person viewmodel (weapon sway / bob)
> 카메라 자식으로 단 무기에 걸음 8자 흔들림 · 숨 · 조준 확대 · 반동 스프링 · 섬광을 겹쳐, 1인칭 사격의 손맛을 낸다.  
> 견본: https://ai-techstudio.web.app/#t/i476

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

## 주문서

### 만들어 줘: FPS 1인칭 무기 연출 — First-person viewmodel (weapon sway / bob)

#### 1. 목표
수학 과녁 쏘기 (문제 과녁 → 맞히면 답)에 1인칭 무기 연출을 넣어 줘 — 화면 오른쪽 아래에 든 무기가 걸음 따라 8자로 흔들리고, 쏘면 반동 스프링 · 섬광, 조준하면 시야가 좁아지게. 분위기는 어두운 훈련장 · 푸른 에너지.

#### 2. 핵심 기술 용어
- **First-person viewmodel (weapon sway / bob)** — 1인칭 손 · 무기 모델과 걸음 흔들림
- **ADS (aim down sights)** — 조준경을 눈앞으로 — 시야각을 좁혀 확대
- **Recoil spring (damped spring)** — 쏠 때 튀었다 돌아오는 감쇠 스프링
- **Muzzle flash** — 총구 섬광 + 순간 점광원

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

#### 4. 조건
- 무기는 카메라 자식으로 — 세계 좌표로 따라다니게 하지 않기
- 반동 · 흔들림은 감쇠 스프링으로 (프레임 수와 상관없이 같게 — 긴 프레임은 8ms 조각으로 나눠 적분)
- 조준 중에는 걸음 흔들림 75% · 숨 75% · 반동 45% 줄이기
- 섬광 점광원은 castShadow 끔, 75ms 뒤 꺼짐
- 시야각은 바뀔 때만 updateProjectionMatrix

#### 5. 완성 기준 (이게 보이면 성공)
- 걸으면 무기가 8자로 흔들리고 머리도 걸음의 2배 박자로 살짝 오르내린다
- 「쏘기」를 누르면 무기가 뒤로 툭 튀었다 통통 돌아오고, 총구 섬광과 둘레 벽이 순간 밝아진다
- 「조준」을 켜면 무기가 화면 가운데로 오며 시야가 좁아지고, 흔들림이 확 줄어든다
- 고개를 돌리면 무기가 살짝 늦게 따라온다

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

## 원리
- 무기를 카메라의 자식(pivot)으로 두면, 카메라가 어디를 보든 화면 같은 자리에 있다.
- 자세 = 엉덩이 자세 ↔ 조준 자세(aimK 로 섞기) + 걸음(사인 8자) + 숨(느린 사인) + 반동 스프링 + 고개 돌릴 때 늦게 따라옴.
- 반동은 「속도에 한 번 툭」 더하고 스프링(단단함 k · 감쇠 c)이 원래 자리로 끌어온다: v += (−k·x − c·v)·dt.
- 조준하면 시야각을 62° → 40° 로 좁히고, 걸음 흔들림 · 숨 · 반동을 줄인다.
- 섬광은 75ms 동안만 — 더하기 섞기 판 두 장 + 순간 점광원으로 둘레 벽까지 번쩍.

## 핵심 코드 — 반동 스프링 + 자세 섞기 (엉덩이 ↔ 조준 · 걸음 · 숨 · 반동)
(발췌: demos/demosHard.ts springStep() · demo476() 의 fire · update 를 정리 (단위는 견본 장면 크기 그대로))
```ts
interface Spring { x: number; v: number }
// 단단함 k · 감쇠 c — 8ms 조각으로 나눠 적분해 프레임이 길어도 튀지 않게
function springStep(s: Spring, k: number, c: number, dt: number): void {
  const n = Math.max(1, Math.ceil(dt / 0.008));
  const h = dt / n;
  for (let i = 0; i < n; i++) { s.v += (-k * s.x - c * s.v) * h; s.x += s.v * h; }
}
const rz = { x: 0, v: 0 }, rp = { x: 0, v: 0 }, ry = { x: 0, v: 0 }; // 뒤로 · 들림 · 옆

function fire(): void {
  rz.v += 75 * (1 - 0.45 * aimK);     // 조준 중엔 반동이 작다
  rp.v += 2.6 * (1 - 0.3 * aimK);
  ry.v += (Math.random() - 0.5) * 1.2;
  flashT = now;
}

// 매 프레임
walkK += (walkWant - walkK) * Math.min(1, dt * 5);
aimK += (aimWant - aimK) * Math.min(1, dt * 8);
springStep(rz, 260, 21, dt); springStep(rp, 200, 17, dt); springStep(ry, 180, 15, dt);
const ph = t * 9.2, a = ease(aimK);
const W = walkK * (1 - 0.75 * aimK), br = 1 - 0.75 * a; // 조준하면 걸음 · 숨이 줄어든다
camera.position.y = EYE + Math.sin(ph * 2) * 1.5 * walkK; // 머리는 걸음의 2배 박자
const fov = THREE.MathUtils.lerp(62, 40, a);
if (Math.abs(camera.fov - fov) > 0.01) { camera.fov = fov; camera.updateProjectionMatrix(); }
pivot.position.set( // pivot = 카메라 자식
  lerp(12.5, 0, a) + Math.sin(ph) * 1.1 * W + Math.sin(t * 0.85) * 0.12 * br + yawV * 2.2,
  lerp(-13.5, -14.15, a) - Math.abs(Math.cos(ph)) * 0.9 * W + Math.sin(t * 1.7) * 0.22 * br,
  lerp(-24, -9.5, a) + rz.x * 0.06);
pivot.rotation.set(lerp(0.02, 0, a) + rp.x * 0.05, lerp(0.1, 0, a) + ry.x * 0.03 + yawV * 0.05,
  lerp(0.03, 0, a) + Math.sin(ph) * 0.025 * W, 'YXZ');
const fl = (now - flashT) / 0.075;           // 섬광 75ms
flash.visible = fl < 1;
muzzleLight.intensity = fl < 1 ? 7000 * (1 - fl) : 0;
```

## 흔한 실수 · 확인 목록
- [ ] **반동을 위치에 바로 더하면 딱딱하게 튄다** — 속도(v)에 더하고 스프링이 끌어오게 해야 「툭 → 통통」 이 된다. 뒤로 · 들림 · 옆 세 축을 다른 k · c 로.
- [ ] **조준 중에도 걸음 흔들림이 그대로면 조준점이 출렁인다** — 흔들림 · 숨에 (1 − 0.75·조준)을 곱해 줄인다.
- [ ] **섬광 점광원에 그림자를 켜면 쏠 때마다 무겁다** — 점광원 그림자 = 그림자 지도 6장. 섬광 빛은 그림자 없이 75ms 만 켠다.
- [ ] **시야각을 매 프레임 바꾸면 투영 행렬을 계속 다시 만든다** — 값이 실제로 바뀔 때만 updateProjectionMatrix 를 부른다.

## 완성 기준 체크리스트
- [ ] 걸으면 무기가 8자로 흔들리고 머리도 걸음의 2배 박자로 살짝 오르내린다
- [ ] 「쏘기」를 누르면 무기가 뒤로 툭 튀었다 통통 돌아오고, 총구 섬광과 둘레 벽이 순간 밝아진다
- [ ] 「조준」을 켜면 무기가 화면 가운데로 오며 시야가 좁아지고, 흔들림이 확 줄어든다
- [ ] 고개를 돌리면 무기가 살짝 늦게 따라온다

## 이 기술 정보
- id: `i476` · 분류: 3D 모델 · 캐릭터 › 하드서피스 · 실사 렌더링 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 스프링 셋 · 사인 몇 개라 계산은 거의 없다. 섬광 점광원은 그림자를 끄고 쓴다 (켜면 그림자 지도 6장).
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i476
- 쓰면 좋을 때: 1인칭 시점 게임에서 「들고 있다」는 느낌이 필요할 때 / 쏘기 · 던지기 같은 한 번의 동작에 손맛을 줄 때
- 쓰지 말 때: 3인칭 · 위에서 보는 게임 — 대신 캐릭터 몸짓 · 카메라 킥 / 어린 아이용 화면에서 실제 총 모양 — 물총 · 광선총 같은 장난감으로 바꾼다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### demo476 — `src/demos/demosHard.ts:1271`
```ts
function demo476(): Scene3D {
  let auto = true;
  let walkOn = true;
  let aimOn = false;
  let now = 0;
  let lastT = 0;
  const EYE = 0;
  const FLOOR = -165;
  const QA: [string, string][] = [
    ['7×8', '56'],
    ['√81', '9'],
    ['12÷4', '3'],
  ];
  const disposables: { dispose(): void }[] = [];
  const keep = <X extends { dispose(): void }>(x: X): X => {
    disposables.push(x);
    return x;
  };
  const room = new THREE.Group();
  // 바닥 · 벽 · 낮은 벽 (훈련장)
  const floorTex = keep(gridTexture('#0d1322', 'rgba(90,200,255,0.55)', 4));
  floorTex.repeat.set(60, 60);
  const floor = new THREE.Mesh(keep(new THREE.PlaneGeometry(8000, 8000)), keep(new THREE.MeshStandardMaterial({ map: floorTex, roughness: 0.62, metalness: 0.3 })));
  floor.rotation.x = -Math.PI / 2;
  floor.position.y = FLOOR;
  const wallTex = keep(gridTexture('#151c2e', 'rgba(120,170,230,0.35)', 2, 'rgba(255,122,42,0.55)'));
  wallTex.repeat.set(14, 3);
  const wallMat = keep(new THREE.MeshStandardMaterial({ map: wallTex, roughness: 0.7, metalness: 0.1 }));
  const back = new THREE.Mesh(keep(new THREE.PlaneGeometry(4000, 900)), wallMat);
  back.position.set(0, FLOOR + 450, -1500);
  const sideG = keep(new THREE.PlaneGeometry(3200, 900));
  const left = new THREE.Mesh(sideG, wallMat);
  left.rotation.y = Math.PI / 2;
  left.position.set(-900, FLOOR + 450, 100);
  const right = new THREE.Mesh(sideG, wallMat);
  right.rotation.y = -Math.PI / 2;
  right.position.set(900, FLOOR + 450, 100);
  const blockG = keep(new THREE.BoxGeometry(160, 90, 60));
  const blockM = keep(new THREE.MeshStandardMaterial({ color: 0x3a4152, roughness: 0.55, metalness: 0.4 }));
  const blocks = new THREE.InstancedMesh(blockG, blockM, 6);
  [-520, 520].forEach((x, j) => [-260, -620, -960].forEach((z, i) => blocks.setMatrixAt(j * 3 + i, new THREE.Matrix4().makeTranslation(x, FLOOR + 45, z))));
  room.add(floor, back, left, right, blocks);
  // 과녁 (앞 = 문제, 뒤 = 답)
  const boardG = keep(new THREE.BoxGeometry(150, 150, 8));
  const boardM = keep(new THREE.MeshStandardMaterial({ color: 0x252a33, roughness: 0.45, metalness: 0.7 }));
  const faceG = keep(new THREE.CircleGeometry(68, 48));
  const poleG = keep(new THREE.CylinderGeometry(4, 4, 200, 12));
  const targets = QA.map(([q, a], i) => {
    const g = new THREE.Group();
    g.position.set((i - 1) * 300, -20, -1000);
    const spin = new THREE.Group();
    spin.add(new THREE.Mesh(boardG, boardM));
    const ft = keep(targetTexture(q, false));
    const bt = keep(targetTexture(a, true));
    const fm = keep(new THREE.MeshStandardMaterial({ map: ft, emissiveMap: ft, emissive: 0xffffff, emissiveIntensity: 0.55, roughness: 0.6 }));
    const bm = keep(new THREE.MeshStandardMaterial({ map: bt, emissiveMap: bt, emissive: 0xffffff, emissiveIntensity: 0.55, roughness: 0.6 }));
    const f = new THREE.Mesh(faceG, fm);
    f.position.z = 4.3;
    const b = new THREE.Mesh(faceG, bm);
    b.position.z = -4.3;
    b.rotation.y = Math.PI;
    spin.add(f, b);
    const pole = new THREE.Mesh(poleG, boardM);
    pole.position.y = -175;
    g.add(spin, pole);
    room.add(g);
    return { g, spin, hitAt: -1 };
  });
  // 섬광 · 빛 · 광선 · 입자
  const starTex = keep(starTexture());
  const flashMat = keep(new THREE.MeshBasicMaterial({ map: starTex, color: new THREE.Color(1.6, 1.9, 2.2), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false, side: THREE.DoubleSide }));
  const flashG = keep(new THREE.PlaneGeometry(1, 1));
  const flashA = new THREE.Mesh(flashG, flashMat);
  flashA.rotation.y = -Math.PI / 2;
  const flashB = new THREE.Mesh(flashG, flashMat);
  const flash = new THREE.Group();
  flash.add(flashA, flashB);
  flash.position.set(61, 4.6, 0);
  const light = new THREE.PointLight(0x6fdcff, 0, 0, 2);
  light.position.set(64, 6, 0);
  const boltMat = keep(new THREE.MeshBasicMaterial({ color: new THREE.Color(0.9, 2.4, 3.2), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false }));
  const boltCore = keep(new THREE.MeshBasicMaterial({ color: new THREE.Color(3, 3, 3), toneMapped: false }));
  const boltG = keep(new THREE.CylinderGeometry(2.2, 2.2, 160, 10, 1, true).rotateX(Math.PI / 2));
  const boltCG = keep(new THREE.CylinderGeometry(0.7, 0.7, 150, 8, 1, true).rotateX(Math.PI / 2));
  const bolt = new THREE.Group();
  bolt.add(new THREE.Mesh(boltG, boltMat), new THREE.Mesh(boltCG, boltCore));
  bolt.visible = false;
  room.add(bolt);
  const sparks = new Particles(220, new THREE.Color(0.55, 0.9, 1.0), true, -900, 1.6);
  const steam = new Particles(140, new THREE.Color(0.86, 0.9, 0.95), false, 18, 0.9);
  disposables.push(sparks, steam);
  room.add(sparks.points, steam.points);

  const pivot = new THREE.Group();
  const s = new Show({ fov: 62, near: 0.5, far: 6000, bg: ['#0b1222', '#0b1120', '#070a12'], env: 0.75, stage: 7 }, (_k, bl) => {
    bl.root.rotation.y = Math.PI / 2;
    pivot.add(bl.root);
    bl.root.add(flash, light);
  });
  s.scene.add(room, s.cam);
  s.cam.add(pivot);
  s.hide.push(pivot);
  s.scene.fog = new THREE.Fog(0x0a1020, 1400, 4200);
  s.key.position.set(-120, 300, 200);
  s.key.intensity = 2.0;

  // 상태
  let aimK = 0;
  let walkK = 0;
  let phase = 0;
  let camZ = 250;
  let yaw = 0;
  let pitch = 0;
  let yawV = 0;
  let wantYaw = 0;
  let wantPitch = 0;
  let heat = 0;
  let energy = 40;
  let flashT = -1;
  let hitT = -1;
  let shots = 0;
  let fade = 0;
  let inspect = 0;
  const rz: Spring = { x: 0, v: 0 };
  const rp: Spring = { x: 0, v: 0 };
  const ry: Spring = { x: 0, v: 0 };
  const boltS = { on: false, t0: 0, from: new THREE.Vector3(), to: new THREE.Vector3(), target: 0 };
  const v1 = new THREE.Vector3();
  const v2 = new THREE.Vector3();
  const label = { text: '' };
  const aimAt = (i: number): void => {
    const tg = targets[i]!.g.position;
    const dx = tg.x - 0;
    const dz = tg.z - camZ;
    wantYaw = Math.atan2(-dx, -dz);
    wantPitch = Math.atan2(tg.y - EYE, Math.hypot(dx, dz));
  };
  const fire = (i: number): void => {
    const bl = s.bl;
    if (!bl) return;
    bl.root.updateWorldMatrix(true, false);
    boltS.from.copy(bl.root.localToWorld(v1.set(62, 4.6, 0)));
    const tg = targets[i]!.g.position;
    boltS.to.set(tg.x + (Math.random() - 0.5) * 30, tg.y + (Math.random() - 0.5) * 30, tg.z + 6);
    boltS.on = true;
    boltS.t0 = now;
    boltS.target = i;
    targets[i]!.hitAt = -1;
    flashT = now;
    rz.v += 75 * (1 - 0.45 * aimK);
    rp.v += 2.6 * (1 - 0.3 * aimK);
    ry.v += (Math.random() - 0.5) * 1.2;
    heat = Math.min(1.2, heat + 0.38);
    energy = Math.max(0, energy - 1);
    shots++;
    flashA.rotation.x = Math.random() * TAU;
    const sc = 13 + Math.random() * 7;
    flashA.scale.set(sc, sc, 1);
    flashB.scale.set(sc * 1.6, sc * 0.55, 1);
  };
  const reset = (): void => {
    for (const tg of targets) {
      tg.hitAt = -1;
      tg.spin.rotation.y = 0;
    }
    energy = 40;
    camZ = 650;
  };

  return {
    scene: s.scene,
    camera: s.cam,
    update(t, dt) {
      now = t;
      if (!s.tick(t)) return;
      const bl = s.bl!;
      dt = Math.min(dt, 0.05);
      let walkWant = walkOn ? 1 : 0;
      let aimWant = aimOn ? 1 : 0;
      inspect = 0;
      if (auto) {
        const L = 9.6;
        const T = t % L;
        const P = lastT % L;
        const crossed = (x: number): boolean => (P < x && T >= x) || (P > T && (x <= T || x > P));
        if (crossed(0.01)) reset();
        fade = T < 0.4 ? 1 - T / 0.4 : T > 9.0 ? (T - 9.0) / 0.6 : 0;
        walkWant = T < 3.0 ? 1 : 0;
        aimWant = T > 3.4 && T < 6.1 ? 1 : 0;
        if (T < 3.0) {
          camZ = lerp(650, 250, T / 3.0);
          wantYaw = 0;
          wantPitch = -0.02;
        } else if (T < 4.25) aimAt(0);
        else if (T < 5.1) aimAt(1);
        else if (T < 6.3) aimAt(2);
        else {
          wantYaw = 0;
          wantPitch = -0.04;
        }
        if (crossed(3.9)) fire(0);
        if (crossed(4.75)) fire(1);
        if (crossed(5.6)) fire(2);
        inspect = T > 6.5 && T < 8.7 ? ease(Math.min((T - 6.5) / 0.6, (8.7 - T) / 0.6, 1)) : 0;
        phase = T < 3.0 ? 0 : T < 3.8 ? 1 : T < 6.2 ? 2 : 3;
      } else {
        fade = 0;
        camZ = 250;
        phase = aimOn ? (t - Math.max(0, boltS.t0) < 1 ? 2 : 1) : heat > 0.15 ? 3 : walkOn ? 0 : 1;
      }
      lastT = t;
      // 걷기 · 조준 부드럽게
      walkK += (walkWant - walkK) * Math.min(1, dt * 5);
      aimK += (aimWant - aimK) * Math.min(1, dt * 8);
      const prevYaw = yaw;
      yaw += (wantYaw - yaw) * Math.min(1, dt * 6);
      pitch += (wantPitch - pitch) * Math.min(1, dt * 6);
      yawV = lerp(yawV, (yaw - prevYaw) / Math.max(dt, 1e-3), Math.min(1, dt * 10));
      const ph = t * 9.2;
      const W = walkK * (1 - 0.75 * aimK);
      s.cam.position.set(0, EYE + Math.sin(ph * 2) * 1.5 * walkK, camZ);
      s.cam.rotation.set(pitch, yaw, Math.sin(ph) * 0.006 * walkK, 'YXZ');
      const fov = lerp(62, 40, ease(aimK));
      if (Math.abs(s.cam.fov - fov) > 0.01) {
        s.cam.fov = fov;
        s.cam.updateProjectionMatrix();
      }
      if (!auto && walkOn) floorTex.offset.y -= dt * 1.1;
      // 반동 스프링
      springStep(rz, 260, 21, dt);
      springStep(rp, 200, 17, dt);
      springStep(ry, 180, 15, dt);
      // 총 자세 = 엉덩이 ↔ 조준 + 걸음 8자 + 숨 + 반동 + 돌릴 때 늦게 따라옴
      const a = ease(aimK);
      const br = 1 - 0.75 * a;
      pivot.position.set(
        lerp(12.5, 0, a) + Math.sin(ph) * 1.1 * W + Math.sin(t * 0.85) * 0.12 * br + yawV * 2.2,
        lerp(-13.5, -14.15, a) - Math.abs(Math.cos(ph)) * 0.9 * W + Math.sin(t * 1.7) * 0.22 * br - inspect * 2,
        lerp(-24, -9.5, a) + rz.x * 0.06 - inspect * 4,
      );
      pivot.rotation.set(lerp(0.02, 0, a) + rp.x * 0.05 + Math.sin(t * 1.7) * 0.004 * br + inspect * 0.12, lerp(0.1, 0, a) + ry.x * 0.03 + yawV * 0.05 + inspect * 0.5, lerp(0.03, 0, a) + Math.sin(ph) * 0.025 * W - inspect * 0.45, 'YXZ');
      // 열 · 섬광 · 빛
      const glow = bl.mats.full.glow as THREE.MeshStandardMaterial;
      glow.emissiveIntensity = 2.6 + heat * 5;
      const fl = flashT < 0 ? 1 : (now - flashT) / 0.075;
      flash.visible = fl < 1;
      light.intensity = fl < 1 ? 7000 * (1 - fl) : 0;
      if (phase === 3 || (!auto && !aimOn)) {
        if (heat > 0.05) {
          heat = Math.max(0, heat - dt * 0.45);
          bl.root.updateWorldMatrix(true, false);
          const nE = Math.random() < heat * dt * 60 ? 2 : 0;
          for (let k = 0; k < nE; k++) {
            const x = [25.3, 28.5, 31.7, 34.9, 38.1][Math.floor(Math.random() * 5)]!;
            const sz = Math.random() < 0.5 ? 3.2 : -3.2;
            bl.root.localToWorld(v1.set(x, 4.6 + (Math.random() - 0.5) * 2, sz));
            v2.set((Math.random() - 0.5) * 8, 14 + Math.random() * 16, (Math.random() - 0.5) * 8);
            steam.emit(v1, v2, 1.1 + Math.random() * 0.9, 2.5, 13);
          }
        }
      } else heat = Math.max(0, heat - dt * 0.05);
      // 광선이 날아가 과녁에
      if (boltS.on) {
        const k = (now - boltS.t0) / 0.13;
        if (k >= 1) {
          boltS.on = false;
          bolt.visible = false;
          hitT = now;
          const tg = targets[boltS.target]!;
          tg.hitAt = now;
          for (let i = 0; i < 34; i++) {
            v2.set((Math.random() - 0.5) * 520, Math.random() * 420, 120 + Math.random() * 380);
            sparks.emit(boltS.to, v2, 0.35 + Math.random() * 0.5, 10 + Math.random() * 12);
          }
          for (let i = 0; i < 3; i++) sparks.emit(boltS.to, v2.set(0, 0, 0), 0.16, 140 - i * 30);
        } else {
          bolt.visible = true;
          bolt.position.lerpVectors(boltS.from, boltS.to, k);
          bolt.lookAt(boltS.to);
        }
      }
      for (const tg of targets) {
        const k = tg.hitAt < 0 ? 0 : (now - tg.hitAt) / 0.55;
        const e = k <= 0 ? 0 : k >= 1 ? 1 : 1 + 2.2 * Math.pow(k - 1, 3) + 1.2 * Math.pow(k - 1, 2);
        tg.spin.rotation.y = Math.PI * e;
      }
      sparks.step(dt);
      steam.step(dt);
      label.text = ['걷기 — 걸음 따라 8자로 흔들림 · 숨쉬기', '조준(ADS) — 조준경이 눈앞으로 · 시야 62° → 40°', '반동 스프링 · 에너지 섬광 · 순간 빛', '열 배출 — 코일이 식으며 김'][phase]!;
    },
    render(r, w, h) {
      const sc = (h * 0.5) / Math.tan(THREE.MathUtils.degToRad(s.cam.fov * 0.5));
      sparks.mat.uniforms['uScale']!.value = sc;
      steam.mat.uniforms['uScale']!.value = sc;
      s.render(r, w, h, undefined, () => {
        const H = s.hud;
        const px = H.px;
        H.text('ph', label.text, px * 0.7, px * 0.7, 0, 0, 0.9);
        const ca = clamp(1 - aimK * 2.5, 0, 1);
        if (ca > 0.02) {
          const g = px * 0.45;
          const L = px * 0.55;
          H.rect('c1', w / 2 - g - L, h / 2 - 1, L, 2, 0xffffff, ca * 0.9);
          H.rect('c2', w / 2 + g, h / 2 - 1, L, 2, 0xffffff, ca * 0.9);
          H.rect('c3', w / 2 - 1, h / 2 - g - L, 2, L, 0xffffff, ca * 0.9);
          H.rect('c4', w / 2 - 1, h / 2 + g, 2, L, 0xffffff, ca * 0.9);
        }
        if (hitT > 0 && now - hitT < 0.18) {
          const q = px * 0.5;
          H.line(w / 2 - q * 2, h / 2 - q * 2, w / 2 - q, h / 2 - q);
          H.line(w / 2 + q * 2, h / 2 - q * 2, w / 2 + q, h / 2 - q);
          H.line(w / 2 - q * 2, h / 2 + q * 2, w / 2 - q, h / 2 + q);
          H.line(w / 2 + q * 2, h / 2 + q * 2, w / 2 + q, h / 2 + q);
        }
        H.text('en', `에너지 ${energy}  ${'▮'.repeat(Math.ceil(energy / 8))}${'▯'.repeat(5 - Math.ceil(energy / 8))}`, w - px * 0.7, h - px * 0.7, 1, 1, 0.85, '#7fe3ff');
        if (fade > 0.01) H.rect('fade', 0, 0, w, h, 0x000000, clamp(fade, 0, 1));
        s.info(`맞힌 과녁 ${targets.filter((x) => x.hitAt >= 0).length} / 3 · 열 ${Math.round(heat * 100)}%`);
        void shots;
      });
    },
    controls: [
      { type: 'toggle', label: '자동 연출', value: true, on: (v) => (auto = v) },
      {
        type: 'button',
        label: '쏘기',
        on: () => {
          auto = false;
          aimAt(shots % 3);
          fire(shots % 3);
        },
      },
      {
        type: 'toggle',
        label: '조준 (ADS)',
        value: false,
        on: (v) => {
          auto = false;
          aimOn = v;
        },
      },
      {
        type: 'toggle',
        label: '걷기',
        value: true,
        on: (v) => {
          auto = false;
          walkOn = v;
        },
      },
      wireCtl(s),
    ],
    dispose: () => {
      s.dispose();
      for (const d of disposables) d.dispose();
    },
  };
}
```

### springStep — `src/demos/demosHard.ts:1262`
```ts
const springStep = (s: Spring, k: number, c: number, dt: number): void => {
  const n = Math.max(1, Math.ceil(dt / 0.008));
  const h = dt / n;
  for (let i = 0; i < n; i++) {
    s.v += (-k * s.x - c * s.v) * h;
    s.x += s.v * h;
  }
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [전투 판정 (휘두르기 · 히트스톱 · 넉백)](https://ai-techstudio.web.app/ai/t/i484.md) `i484` · [카메라 킥 · 펀치 줌](https://ai-techstudio.web.app/ai/t/i199.md) `i199`
- 다음에 해 볼 기술: [카메라가 벽을 뚫지 않게](https://ai-techstudio.web.app/ai/t/i481.md) `i481` · [캐릭터 조작기 (경사 · 계단 · 점프 · 벽 미끄러지기)](https://ai-techstudio.web.app/ai/t/i480.md) `i480`
