# AI 꾸러미 — 스프링 리그
> 머리 · 몸 · 안테나가 용수철처럼 — 살아 있는 마스코트  
> 견본: https://ai-techstudio.web.app/#t/u45

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

## 주문서

### 만들어 줘: 스프링 리그

#### 1. 목표
3D 장면의 물체에 「스프링 리그」 기술을 깔끔한 기본 느낌으로 넣어 줘. — 머리 · 몸 · 안테나가 용수철처럼 — 살아 있는 마스코트

#### 2. 핵심 기술 용어
- 스프링 리그 — 머리 · 몸 · 안테나가 용수철처럼 — 살아 있는 마스코트

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

#### 4. 조건
- 한 프레임 16ms 안 (폰 포함) — 무거운 계산은 처음에 한 번 또는 여러 프레임에 나눠서
- 새 재질 · 셰이더는 첫 화면 전에 미리 컴파일 (renderer.compileAsync)
- 화면 가장자리 흐림 · 비네트로 얼버무리지 않기
- 외부 라이브러리 추가 없이 three.js 와 three/examples 만

#### 5. 완성 기준 (이게 보이면 성공)
- 견본처럼 「머리 · 몸 · 안테나가 용수철처럼 — 살아 있는 마스코트」 이(가) 첫 화면에서 바로 보인다
- 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

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

## 원리
- 머리 · 몸 · 안테나가 용수철처럼 — 살아 있는 마스코트


## 흔한 실수 · 확인 목록
- (아직 정리된 것이 없어요)

## 완성 기준 체크리스트
- [ ] 견본처럼 「머리 · 몸 · 안테나가 용수철처럼 — 살아 있는 마스코트」 이(가) 첫 화면에서 바로 보인다
- [ ] 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- [ ] 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- [ ] 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

## 이 기술 정보
- id: `u45` · 분류: 게임 시스템 · AI › 3D 게임 기본기 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u45

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### splitLabels — `src/demos/demosStructure.ts:179`
```ts
function splitLabels(g: CanvasRenderingContext2D, w: number, h: number, left: string, right: string): void {
  const s = fsz(w, h, 0.9);
  g.fillStyle = 'rgba(255,255,255,0.75)';
  g.fillRect(w / 2 - 1, 0, 2, h);
  pill(g, s * 0.6, s * 0.6, left, s, 'rgba(30,40,80,0.7)');
  pill(g, w / 2 + s * 0.6, s * 0.6, right, s, 'rgba(255,120,60,0.9)');
}
```

### u45 견본 항목 — `src/demos/demosStructure.ts:2176`
```ts
  u45: {
    kind: '3d',
    caption: '같은 몸 움직임 — 왼쪽은 딱딱하게 붙어 있고, 오른쪽은 머리 · 안테나가 용수철처럼 늦게 따라와 출렁여요',
    make() {
      const mkBot = (): { scene: THREE.Scene; body: THREE.Group; neck: THREE.Group; ant: THREE.Group } => {
        const scene = baseScene('#d7f0ff', '#f8fdff');
        const ground = new THREE.Mesh(new THREE.CircleGeometry(3, 40), std(0xcfe8d0, 0.9));
        ground.rotation.x = -Math.PI / 2;
        scene.add(ground);
        const body = new THREE.Group();
        const torso = new THREE.Mesh(new RoundedBoxGeometry(0.9, 0.8, 0.7, 3, 0.2), std(0x6aa8ff, 0.35, 0.2));
        torso.position.y = 0.55;
        body.add(torso);
        for (const s of [-1, 1]) {
          const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.12, 0.3, 12), std(0x3a4a7a, 0.4));
          leg.position.set(s * 0.22, 0.12, 0);
          body.add(leg);
        }
        const neck = new THREE.Group();
        neck.position.y = 0.95;
        body.add(neck);
        const head = new THREE.Mesh(new RoundedBoxGeometry(1.05, 0.8, 0.8, 3, 0.25), std(0xf2f5ff, 0.3, 0.1));
        head.position.y = 0.42;
        neck.add(head);
        const screen = new THREE.Mesh(new RoundedBoxGeometry(0.8, 0.5, 0.05, 2, 0.1), std(0x1b2450, 0.3));
        screen.position.set(0, 0.42, 0.39);
        neck.add(screen);
        for (const s of [-1, 1]) {
          const eye = new THREE.Mesh(new THREE.SphereGeometry(0.08, 14, 10), new THREE.MeshBasicMaterial({ color: 0x7cf0ff }));
          eye.position.set(s * 0.17, 0.45, 0.43);
          eye.scale.set(1, 1.3, 0.4);
          neck.add(eye);
        }
        const ant = new THREE.Group();
        ant.position.y = 0.82;
        neck.add(ant);
        const stick = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.025, 0.4, 8), std(0x8a96ad, 0.4, 0.6));
        stick.position.y = 0.2;
        const bulb = new THREE.Mesh(new THREE.SphereGeometry(0.08, 14, 10), new THREE.MeshStandardMaterial({ color: 0xff5a7a, emissive: 0xff2050, emissiveIntensity: 0.6 }));
        bulb.position.y = 0.42;
        ant.add(stick, bulb);
        scene.add(body);
        return { scene, body, neck, ant };
      };
      const A = mkBot();
      const B = mkBot();
      const cam = new THREE.PerspectiveCamera(34, 1, 0.1, 50);
      const hud = new Hud();
      let stiff = 60;
      // 용수철 상태: 머리 늦음(x, y) · 안테나 각
      const sp = { hx: 0, hvx: 0, hy: 0, hvy: 0, a: 0, va: 0 };
      let prevX = 0;
      let prevY = 0;
      let pvx = 0;
      let pvy = 0;
      const bodyAt = (t: number): [number, number] => {
        const k = Math.floor(t / 1.1);
        const f = (t % 1.1) / 1.1;
        const xs = [-0.55, 0.55, 0, 0.55, -0.55, 0];
        const x0 = xs[k % xs.length]!;
        const x1 = xs[(k + 1) % xs.length]!;
        const m = smooth(clamp(f / 0.35, 0, 1));
        const hop = f < 0.35 ? Math.sin((f / 0.35) * Math.PI) * 0.35 : 0;
        return [lerp(x0, x1, m), hop];
      };
      return {
        scene: A.scene,
        camera: cam,
        update(t, dt) {
          const [x, y] = bodyAt(t);
          for (const S of [A, B]) {
            S.body.position.set(x, y, 0);
          }
          // 몸의 가속도로 용수철을 흔든다 (작은 걸음으로 나눠 안정하게)
          const n = Math.max(1, Math.ceil(dt / (1 / 240)));
          const h = dt / n;
          const vx = (x - prevX) / Math.max(dt, 1e-4);
          const vy = (y - prevY) / Math.max(dt, 1e-4);
          const ax = (vx - pvx) / Math.max(dt, 1e-4);
          const ay = (vy - pvy) / Math.max(dt, 1e-4);
          prevX = x;
          prevY = y;
          pvx = vx;
          pvy = vy;
          const k = stiff;
          const c = 2 * Math.sqrt(k) * 0.18;
          for (let i = 0; i < n; i++) {
            sp.hvx += (-k * sp.hx - c * sp.hvx - clamp(ax, -60, 60)) * h;
            sp.hx += sp.hvx * h;
            sp.hvy += (-k * sp.hy - c * sp.hvy - clamp(ay, -60, 60)) * h;
            sp.hy += sp.hvy * h;
            const ka = k * 0.6;
            sp.va += (-ka * sp.a - 2 * Math.sqrt(ka) * 0.1 * sp.va + sp.hvx * 8) * h;
            sp.a += sp.va * h;
          }
          B.neck.position.x = clamp(sp.hx * 0.6, -0.25, 0.25);
          B.neck.position.y = 0.95 + clamp(sp.hy * 0.5, -0.15, 0.15);
          B.neck.rotation.z = clamp(-sp.hx * 1.2, -0.5, 0.5);
          B.ant.rotation.z = clamp(sp.a * 0.5, -1.1, 1.1);
        },
        render(r, w, h) {
          const tn = Math.tan((cam.fov * Math.PI) / 360);
          const d = Math.max(1.6 / tn, 1.45 / (tn * (w / 2 / h)));
          cam.position.set(0, 1.4, d);
          cam.lookAt(0, 0.9, 0);
          splitRender(r, w, h, A.scene, B.scene, cam);
          hud.draw(r, w, h, `${w}x${h}`, (g) => splitLabels(g, w, h, '딱딱하게', '용수철 리그'));
        },
        controls: [{ type: 'range', label: '용수철 세기', min: 15, max: 200, step: 5, value: 60, on: (v) => (stiff = v) }],
        dispose() {
          disposeScene(A.scene);
          disposeScene(B.scene);
          hud.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 이 효과를 만든 기술: [흔들리는 뼈 (귀 · 머리카락 · 꼬리 2차 움직임)](https://ai-techstudio.web.app/ai/t/i462.md) `i462`
