# AI 꾸러미 — 작은 행성 산책 (구면 중력 · 따라 도는 카메라) — Spherical gravity (planet walking)
> 발밑을 늘 행성 중심 쪽으로 두고 한 걸음마다 접평면으로 옮긴 뒤 구 위로 되돌려, 작은 행성을 어디서나 똑바로 서서 걷게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i536

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

## 주문서

### 만들어 줘: 작은 행성 산책 (구면 중력 · 따라 도는 카메라) — Spherical gravity (planet walking)

#### 1. 목표
작은 풀밭 행성에서 캐릭터가 어디서나 발밑을 행성 중심으로 두고 걷는 구면 중력 산책을 만들어 줘. 분위기는 파스텔 동화 행성.

#### 2. 핵심 기술 용어
- **Spherical gravity (planet walking)** — 아래 = 행성 중심 쪽 — 어디서나 서 있기
- **Tangent-plane step + reprojection** — 접평면으로 한 걸음 → 길이를 맞춰 구 위로 되돌리기
- **Parallel transport of forward** — 앞 방향을 새 위쪽의 접평면으로 실어 나르기 (setFromUnitVectors)
- **Camera up follows player** — 카메라 위쪽(camera.up)도 캐릭터의 위쪽으로 부드럽게

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

#### 4. 조건
- 위치는 up · fwd · hOff 로만 정하고 매 걸음 정규화한다 (수치 오차로 행성에서 떠오르지 않게)
- 앞 방향은 위쪽이 바뀐 만큼 같이 돌리고 접평면으로 다시 눕힌다
- 카메라 위쪽을 캐릭터 위쪽으로 부드럽게 맞추는 켬/끔 (끄면 세상 위쪽 고정 → 행성 아래에서 화면이 뒤집힘을 비교)
- 카메라가 언덕 속으로 들어가지 않게 카메라 아래 땅보다 0.7 위로
- 바다로는 못 들어가게 다음 칸 높이를 먼저 보고 막히면 돌아서기

#### 5. 완성 기준 (이게 보이면 성공)
- 행성 반대편까지 걸어가도 캐릭터가 늘 똑바로 서 있고 화면도 뒤집히지 않는다
- 「카메라 위쪽 맞추기」를 끄면 행성 아래쪽에서 화면이 뒤집혀 차이가 분명하다
- 스페이스로 점프하면 어디서든 행성 중심 쪽으로 떨어진다
- 땅을 누르면 그곳으로 걸어간다

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

## 원리
- 캐릭터 상태는 「위쪽 up(단위 벡터) · 앞쪽 fwd · 땅에서 뜬 높이 hOff」 셋뿐 — 위치는 up × 땅 높이(up) 로 만든다.
- 한 걸음: 지금 위치 + fwd × 속도 × dt 를 다시 정규화하면 새 up 이다 (접평면으로 갔다가 구 위로 되돌림).
- 앞 방향은 「옛 up → 새 up」 회전(setFromUnitVectors)을 똑같이 돌리고 새 up 성분을 빼서 접평면에 다시 눕힌다 — 이렇게 해야 돌지 않았는데 방향이 틀어지지 않는다.
- 점프는 hOff 만 바꾼다: 속도 3.6 으로 올라 중력 9.5 로 떨어짐 — 「아래」는 늘 행성 중심.
- 카메라는 캐릭터 위 2.3 · 뒤 4.2 에서 따라가고, camera.up 을 캐릭터 up 쪽으로 천천히 돌려 행성 밑에서도 화면이 뒤집히지 않는다.

## 핵심 코드 — 구 위 한 걸음 — 접평면 이동 · 구 위로 되돌림 · 앞 방향 실어 나르기
(발췌: demos/demosPlanet.ts makeWalk() 의 step() 을 정리)
```ts
const up = spawn.clone();                               // 단위 벡터 = 발밑 반대쪽
const fwd = new THREE.Vector3(-up.z, 0, up.x).normalize();
let hOff = 0, vr = 0;
const tmp = new THREE.Vector3(), tmp2 = new THREE.Vector3();

function step(dt: number, turn: number, go: number, jump: boolean): void {
  if (turn) fwd.applyAxisAngle(up, turn * 2.6 * dt).normalize();
  if (go) {
    const next = tmp.copy(up).multiplyScalar(surface(up)).addScaledVector(fwd, go * 2.1 * dt).normalize();
    if (surface(next) - SEA > 0.1) {                    // 바다면 못 감
      const q = new THREE.Quaternion().setFromUnitVectors(up, next);
      up.copy(next);
      fwd.applyQuaternion(q);                           // 앞 방향도 같은 만큼 돌리고
      fwd.addScaledVector(up, -fwd.dot(up)).normalize(); // 새 접평면에 다시 눕힌다
    }
  }
  if (jump && hOff <= 0.0001) vr = 3.6;
  vr -= 9.5 * dt;                                       // 아래 = 늘 행성 중심 쪽
  hOff = Math.max(0, hOff + vr * dt);
  if (hOff === 0 && vr < 0) vr = 0;
  // 몸 놓기: 위치 = up × (땅 높이 + 뜬 높이), 자세 = (옆, 위, 앞) 기저
  body.position.copy(up).multiplyScalar(surface(up) + hOff - 0.02);
  const m = new THREE.Matrix4().makeBasis(tmp2.crossVectors(up, fwd).normalize(), up, fwd);
  body.quaternion.setFromRotationMatrix(m);
}

// 카메라: 위쪽을 캐릭터 위쪽으로 천천히 → 행성 밑에서도 화면이 안 뒤집힌다
camUp.lerp(up, 1 - Math.exp(-dt * 3)).normalize();
camera.up.copy(camUp);
```

## 흔한 실수 · 확인 목록
- [ ] **앞 방향을 그대로 두면 걸을수록 옆으로 틀어진다** — 위쪽이 바뀐 회전을 앞 방향에도 똑같이 적용하고 접평면 성분만 남긴다 (평행 이동).
- [ ] **camera.up 을 (0,1,0) 에 두면 행성 아래쪽에서 화면이 뒤집힌다** — 카메라 위쪽을 캐릭터 위쪽으로 lerp 한다. 견본에 켬/끔 비교가 있다.
- [ ] **위치를 벡터 더하기로만 쌓으면 조금씩 행성에서 떠오른다** — 위치는 매번 up × 땅 높이 로 새로 만든다 — 오차가 쌓이지 않는다.
- [ ] **흩뿌린 나무가 행성 위에서 비스듬히 선다** — setFromUnitVectors((0,1,0), 방향) 으로 법선에 세운 뒤 그 축으로만 무작위 회전.

## 완성 기준 체크리스트
- [ ] 행성 반대편까지 걸어가도 캐릭터가 늘 똑바로 서 있고 화면도 뒤집히지 않는다
- [ ] 「카메라 위쪽 맞추기」를 끄면 행성 아래쪽에서 화면이 뒤집혀 차이가 분명하다
- [ ] 스페이스로 점프하면 어디서든 행성 중심 쪽으로 떨어진다
- [ ] 땅을 누르면 그곳으로 걸어간다

## 이 기술 정보
- id: `i536` · 분류: 게임 시스템 · AI › 3D 게임 기본기 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 걸음 계산은 벡터 몇 개. 견본의 무거운 부분은 행성 정점(구 6번 쪼갬)과 나무 · 바위 · 꽃 흩뿌리기 5200 후보 — 인스턴싱 4묶음으로 그린다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i536
- 쓰면 좋을 때: 작은 행성 · 지구본 위를 걸어 다니는 탐험 / 구면 좌표 · 위도 경도를 몸으로 느끼는 수학 체험
- 쓰지 말 때: 넓고 평평한 땅 — 보통 캐릭터 조작기(i480) / 물리 엔진 강체와 부딪힘이 많을 때 — 엔진의 사용자 중력(Unity AddForce · Godot up_direction)으로

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeWalk — `src/demos/demosPlanet.ts:379`
```ts
function makeWalk(): Scene3D {
  const R = 10;
  const SEA = 10.12;
  const scene = new THREE.Scene();
  const bg = pastelSky('#8fd3ff', '#cdeeff', '#ffe6d2');
  scene.background = bg;
  const cam = new THREE.PerspectiveCamera(46, 1.6, 0.1, 200);
  const hemi = new THREE.HemisphereLight(0xe8f6ff, 0x9a8a78, 1.25);
  scene.add(hemi);
  const sun = new THREE.DirectionalLight(0xfff1dc, 2.4);
  sun.castShadow = true;
  sun.shadow.mapSize.set(1024, 1024);
  const sc = sun.shadow.camera;
  sc.left = sc.bottom = -7;
  sc.right = sc.top = 7;
  sc.near = 1;
  sc.far = 60;
  sun.shadow.bias = -0.0006;
  sun.shadow.normalBias = 0.03;
  scene.add(sun, sun.target);
  const sunDir = new THREE.Vector3(0.55, 0.75, 0.4).normalize();

  // ── 땅 높이: 3D 잡음 대륙 + 언덕, 바다는 따로 구 ──
  const seed = 7;
  const heightAt = (x: number, y: number, z: number): number => {
    const c = fbm3(x * 1.35 + 3, y * 1.35, z * 1.35, seed, 4);
    const hills = fbm3(x * 4.2, y * 4.2 + 9, z * 4.2, seed + 5, 3);
    const e = (c - 0.47) * 3.2;
    if (e < 0) return R + 0.12 + e * 0.9;
    return R + 0.12 + e * 0.55 + Math.pow(smooth(0.05, 0.6, e), 1.4) * (hills - 0.35) * 1.4;
  };
  const { pos, idx } = icosphere(6);
  const nV = pos.length / 3;
  const P = new Float32Array(nV * 3);
  const C = new Float32Array(nV * 3);
  const sand = new THREE.Color(0xf2dca2);
  const g1 = new THREE.Color(0x8fd36a);
  const g2 = new THREE.Color(0x5fb35a);
  const rock = new THREE.Color(0xb7a99a);
  const snow = new THREE.Color(0xffffff);
  const deep = new THREE.Color(0x3d8fb0);
  const col = new THREE.Color();
  for (let i = 0; i < nV; i++) {
    const x = pos[i * 3]!;
    const y = pos[i * 3 + 1]!;
    const z = pos[i * 3 + 2]!;
    const r = heightAt(x, y, z);
    P.set([x * r, y * r, z * r], i * 3);
    const a = r - SEA;
    const tint = fbm3(x * 6, y * 6, z * 6, 3, 2);
    if (a < -0.02) col.copy(deep).lerp(sand, smooth(-0.6, -0.02, a));
    else if (a < 0.1) col.copy(sand);
    else col.copy(g1).lerp(g2, smooth(0.35, 0.65, tint)).lerp(sand, 1 - smooth(0.1, 0.16, a));
    col.lerp(rock, smooth(0.55, 0.75, a)).lerp(snow, smooth(0.82, 0.9, a));
    C.set([col.r, col.g, col.b], i * 3);
  }
  const pg = new THREE.BufferGeometry();
  pg.setAttribute('position', new THREE.BufferAttribute(P, 3));
  pg.setAttribute('color', new THREE.BufferAttribute(C, 3));
  pg.setIndex(idx);
  pg.computeVertexNormals();
  const uTime = { value: 0 };
  const landM = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.92 });
  // 기슭 물거품 — 바다 높이 둘레에 출렁이는 흰 띠 (땅 쪽에 그린다)
  landM.onBeforeCompile = (sh) => {
    sh.uniforms['uTime'] = uTime;
    sh.uniforms['uSea'] = { value: SEA };
    sh.vertexShader = 'varying vec3 vObj;\n' + sh.vertexShader.replace('#include <begin_vertex>', '#include <begin_vertex>\nvObj = position;');
    sh.fragmentShader =
      'uniform float uTime; uniform float uSea; varying vec3 vObj;\n' +
      sh.fragmentShader.replace(
        '#include <color_fragment>',
        `#include <color_fragment>
        float rr = length(vObj);
        float wob = 0.022 * sin(uTime * 1.8 + vObj.x * 2.3 + vObj.z * 1.7) + 0.012 * sin(uTime * 3.1 + vObj.y * 4.0);
        float d = rr - uSea - 0.03 - wob;
        float foam = 1.0 - smoothstep(0.0, 0.035, abs(d));
        float foam2 = (1.0 - smoothstep(0.0, 0.02, abs(d - 0.07 - wob))) * 0.5;
        diffuseColor.rgb = mix(diffuseColor.rgb, vec3(1.0), clamp(foam + foam2, 0.0, 1.0) * step(-0.08, rr - uSea) * 0.9);`,
      );
  };
  landM.customProgramCacheKey = () => 'planet-foam';
  const planet = new THREE.Mesh(pg, landM);
  planet.receiveShadow = true;
  scene.add(planet);
  const seaG = new THREE.SphereGeometry(SEA, 96, 64);
  const seaM = new THREE.MeshStandardMaterial({ color: 0x5cc6ee, roughness: 0.18, metalness: 0, transparent: true, opacity: 0.78 });
  const sea = new THREE.Mesh(seaG, seaM);
  sea.receiveShadow = true;
  scene.add(sea);

  // ── 흩뿌리기: 피보나치 구 + 각거리 간격(구면 포아송) · 높이 · 경사 규칙 · 법선 정렬 ──
  const dirOK = (d: THREE.Vector3, minA: number, maxA: number, maxSlope: number): number => {
    const r = heightAt(d.x, d.y, d.z);
    const a = r - SEA;
    if (a < minA || a > maxA) return -1;
    const t1 = new THREE.Vector3(-d.z, 0, d.x).normalize();
    if (t1.lengthSq() < 0.1) t1.set(1, 0, 0);
    const t2 = new THREE.Vector3().crossVectors(d, t1);
    const e = 0.02;
    const s1 = Math.abs(heightAt(d.x + t1.x * e, d.y + t1.y * e, d.z + t1.z * e) - r) / (e * R);
    const s2 = Math.abs(heightAt(d.x + t2.x * e, d.y + t2.y * e, d.z + t2.z * e) - r) / (e * R);
    return Math.max(s1, s2) > maxSlope ? -1 : r;
  };
  const geos: THREE.BufferGeometry[] = [pg, seaG];
  const mats: THREE.Material[] = [landM, seaM];
  const roundTree = mergeColored([
    [new THREE.CylinderGeometry(0.06, 0.09, 0.5, 7).translate(0, 0.25, 0), 0x9a6a45],
    [new THREE.IcosahedronGeometry(0.34, 2).translate(0, 0.68, 0), 0x6cc35a],
    [new THREE.IcosahedronGeometry(0.24, 2).translate(0.16, 0.55, 0.08), 0x5fb35a],
    [new THREE.IcosahedronGeometry(0.22, 2).translate(-0.15, 0.58, -0.06), 0x7fcf62],
  ]);
  const pineTree = mergeColored([
    [new THREE.CylinderGeometry(0.05, 0.07, 0.3, 6).translate(0, 0.15, 0), 0x8a5e3e],
    [new THREE.ConeGeometry(0.32, 0.5, 9).translate(0, 0.48, 0), 0x3f9a63],
    [new THREE.ConeGeometry(0.25, 0.42, 9).translate(0, 0.76, 0), 0x4cae6c],
    [new THREE.ConeGeometry(0.17, 0.34, 9).translate(0, 1.0, 0), 0x5bbd78],
  ]);
  const rockG = new THREE.IcosahedronGeometry(0.22, 1);
  {
    const p = rockG.attributes.position as THREE.BufferAttribute;
    const rr = rng(3);
    const seen = new Map<string, number>();
    for (let i = 0; i < p.count; i++) {
      const k = `${p.getX(i).toFixed(3)},${p.getY(i).toFixed(3)},${p.getZ(i).toFixed(3)}`;
      let s = seen.get(k);
      if (s === undefined) {
        s = 0.8 + rr() * 0.35;
        seen.set(k, s);
      }
      p.setXYZ(i, p.getX(i) * s, p.getY(i) * s * 0.7, p.getZ(i) * s);
    }
    rockG.computeVertexNormals();
  }
  const flowerG = mergeColored([
    [new THREE.CylinderGeometry(0.012, 0.012, 0.14, 4).translate(0, 0.07, 0), 0x4f9a3f],
    [new THREE.SphereGeometry(0.05, 8, 6).translate(0, 0.16, 0), 0xffffff],
  ]);
  geos.push(roundTree, pineTree, rockG, flowerG);
  const vcM = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.8 });
  const rockM = new THREE.MeshStandardMaterial({ color: 0xc9bdb0, roughness: 0.95, flatShading: true });
  mats.push(vcM, rockM);
  const placed: THREE.Vector3[] = [];
  const spawn = new THREE.Vector3();
  const N = 5200;
  const rnd = rng(19);
  const kinds: { g: THREE.BufferGeometry; m: THREE.Material; list: THREE.Matrix4[]; colors: THREE.Color[] }[] = [
    { g: roundTree, m: vcM, list: [], colors: [] },
    { g: pineTree, m: vcM, list: [], colors: [] },
    { g: rockG, m: rockM, list: [], colors: [] },
    { g: flowerG, m: vcM, list: [], colors: [] },
  ];
  const FLOWER = [0xff7aa8, 0xffd04d, 0xb48cff, 0xffffff, 0xff9f5a];
  // 시작 자리: 넓은 풀밭 한가운데
  {
    let best = -1;
    for (let i = 0; i < 400; i++) {
      const d = new THREE.Vector3().setFromSphericalCoords(1, Math.acos(1 - 2 * ((i + 0.5) / 400)), i * 2.39996);
      const r = dirOK(d, 0.18, 0.4, 0.35);
      if (r < 0) continue;
      let ok = 0;
      for (let k = 0; k < 8; k++) {
        const q = d.clone().add(new THREE.Vector3(Math.cos(k), Math.sin(k * 1.7), Math.cos(k * 2.3)).multiplyScalar(0.09)).normalize();
        if (heightAt(q.x, q.y, q.z) - SEA > 0.12) ok++;
      }
      if (ok > best) {
        best = ok;
        spawn.copy(d);
      }
    }
  }
  const m4 = new THREE.Matrix4();
  const q = new THREE.Quaternion();
  const qy = new THREE.Quaternion();
  const up0 = new THREE.Vector3(0, 1, 0);
  for (let i = 0; i < N; i++) {
    const y = 1 - 2 * ((i + 0.5) / N);
    const d = new THREE.Vector3().setFromSphericalCoords(1, Math.acos(y), i * 2.39996 + rnd() * 0.3).normalize();
    if (d.angleTo(spawn) < 0.08) continue;
    const roll = rnd();
    const kind = roll < 0.3 ? 0 : roll < 0.48 ? 1 : roll < 0.6 ? 2 : 3;
    const minGap = kind === 3 ? 0.035 : 0.075;
    // 무리 지어 자라게 — 잡음이 높은 곳에 나무, 낮은 곳에 꽃
    const clump = fbm3(d.x * 3, d.y * 3, d.z * 3, 41, 2);
    if (kind < 2 && clump < 0.5) continue;
    if (kind === 3 && clump > 0.55) continue;
    const r = kind === 2 ? dirOK(d, 0.04, 0.9, 1.4) : kind === 1 ? dirOK(d, 0.2, 0.75, 0.9) : dirOK(d, 0.12, 0.6, 0.7);
    if (r < 0) continue;
    if (placed.some((p) => p.angleTo(d) < minGap)) continue;
    placed.push(d);
    const s = kind === 3 ? 0.8 + rnd() * 0.5 : 0.75 + rnd() * 0.55;
    q.setFromUnitVectors(up0, d);
    qy.setFromAxisAngle(up0, rnd() * TAU);
    q.multiply(qy);
    m4.compose(d.clone().multiplyScalar(r - 0.02), q, new THREE.Vector3(s, s, s));
    kinds[kind]!.list.push(m4.clone());
    kinds[kind]!.colors.push(kind === 3 ? new THREE.Color(FLOWER[Math.floor(rnd() * FLOWER.length)]!) : new THREE.Color().setHSL(0, 0, 0.88 + rnd() * 0.12));
  }
  const counts: number[] = [];
  for (const k of kinds) {
    const im = new THREE.InstancedMesh(k.g, k.m, Math.max(1, k.list.length));
    k.list.forEach((mm, i) => {
      im.setMatrixAt(i, mm);
      im.setColorAt(i, k.colors[i]!);
    });
    im.count = k.list.length;
    im.castShadow = k.g !== flowerG;
    im.receiveShadow = true;
    scene.add(im);
    counts.push(k.list.length);
  }
  const scattered = counts.reduce((a, b) => a + b, 0);

  // ── 행성 둘레를 도는 뭉게구름 ──
  const cloudG = mergeColored([
    [new THREE.IcosahedronGeometry(0.55, 2), 0xffffff],
    [new THREE.IcosahedronGeometry(0.42, 2).translate(0.55, -0.08, 0.05), 0xffffff],
    [new THREE.IcosahedronGeometry(0.38, 2).translate(-0.52, -0.1, -0.05), 0xffffff],
    [new THREE.IcosahedronGeometry(0.3, 2).translate(0.15, 0.3, 0.12), 0xffffff],
  ]);
  geos.push(cloudG);
  const cloudM = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 1, emissive: 0x9ab4c8, emissiveIntensity: 0.35 });
  mats.push(cloudM);
  const clouds = new THREE.Group();
  {
    const cr = rng(5);
    for (let i = 0; i < 11; i++) {
      const c = new THREE.Mesh(cloudG, cloudM);
      const d = new THREE.Vector3(cr() * 2 - 1, cr() * 2 - 1, cr() * 2 - 1).normalize();
      c.position.copy(d).multiplyScalar(13.2 + cr() * 1.2);
      c.quaternion.setFromUnitVectors(up0, d);
      c.scale.setScalar(0.8 + cr() * 0.7);
      c.castShadow = true;
      clouds.add(c);
    }
  }
  scene.add(clouds);

  // ── 꼬마 ──
  const buddy = makeBuddy();
  scene.add(buddy.root);
  const up = spawn.clone();
  const fwd = new THREE.Vector3(-up.z, 0, up.x).normalize();
  let hOff = 0;
  let vr = 0;
  let moving = 0;
  let walkPh = 0;
  let target: THREE.Vector3 | null = null;
  let wanderT = 0;
  let idle = 0;
  const input = new Input(true);
  const rayc = new THREE.Raycaster();
  let sphereCam = true;
  let wide = false;
  let wideK = 0;
  const camUp = up.clone();
  const camPos = new THREE.Vector3();
  let camInit = false;
  let orbitYaw = 0;
  const overlay = new Overlay();
  const tmp = new THREE.Vector3();
  const tmp2 = new THREE.Vector3();
  const look = new THREE.Vector3();
  let isBig = false;
  const surface = (d: THREE.Vector3): number => heightAt(d.x, d.y, d.z);
  let blocked = 0;

  const step = (dt: number, t: number): void => {
    // 입력 → 돌기 · 앞으로
    let turn = 0;
    let go = 0;
    const k = input.keys;
    if (k.has('arrowleft') || k.has('a')) turn += 1;
    if (k.has('arrowright') || k.has('d')) turn -= 1;
    if (k.has('arrowup') || k.has('w')) go += 1;
    if (k.has('arrowdown') || k.has('s')) go -= 0.6;
    if (turn || go) {
      target = null;
      idle = 0;
    }
    if (k.has(' ') && hOff <= 0.0001) {
      vr = 3.6;
      k.delete(' ');
      idle = 0;
    }
    idle += dt;
    // 누른 곳으로 걸어가기 · 가만히 있으면 저절로 산책
    if (!turn && !go && (target || idle > (isBig ? 6 : 0))) {
      if (!target || (wanderT -= dt) < 0) {
        if (!target || idle > 6 || !isBig) {
          for (let i = 0; i < 30; i++) {
            const cand = up.clone().addScaledVector(new THREE.Vector3(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5), 0.9).normalize();
            if (surface(cand) - SEA > 0.12) {
              target = cand;
              break;
            }
          }
          wanderT = 7;
        }
      }
      if (target) {
        tmp.copy(target).addScaledVector(up, -target.dot(up));
        if (tmp.lengthSq() > 1e-6 && up.angleTo(target) > 0.02) {
          tmp.normalize();
          const side = tmp2.crossVectors(up, fwd).dot(tmp);
          const ahead = fwd.dot(tmp);
          turn = clamp(Math.atan2(side, ahead) * 2.2, -1, 1);
          go = ahead > 0.2 ? 1 : 0.25;
        } else target = null;
      }
    }
    if (turn) fwd.applyAxisAngle(up, turn * 2.6 * dt).normalize();
    const speed = 2.1;
    moving = lerp(moving, Math.abs(go), 1 - Math.exp(-dt * 10));
    if (go) {
      // 접평면으로 한 걸음 → 다시 구 위로 (발밑 = 중심 쪽)
      const rNow = surface(up);
      const next = tmp.copy(up).multiplyScalar(rNow).addScaledVector(fwd, go * speed * dt).normalize();
      if (surface(next) - SEA > 0.1) {
        // 앞 방향을 새 위쪽의 접평면으로 옮겨 실어 나르기 (평행 이동)
        const qq = new THREE.Quaternion().setFromUnitVectors(up, next);
        up.copy(next);
        fwd.applyQuaternion(qq);
        fwd.addScaledVector(up, -fwd.dot(up)).normalize();
        blocked = Math.max(0, blocked - dt);
      } else {
        blocked = 0.6;
        target = null;
        fwd.applyAxisAngle(up, 2.4 * dt * 3);
      }
    }
    // 점프 — 중력은 늘 행성 중심 쪽
    vr -= 9.5 * dt;
    hOff = Math.max(0, hOff + vr * dt);
    if (hOff === 0 && vr < 0) vr = 0;
    walkPh += dt * moving * 11;
    // 몸 놓기
    const rr = surface(up);
    buddy.root.position.copy(up).multiplyScalar(rr + hOff - 0.02);
    const m = new THREE.Matrix4().makeBasis(tmp2.crossVectors(up, fwd).normalize(), up, fwd);
    buddy.root.quaternion.setFromRotationMatrix(m);
    const bob = Math.abs(Math.sin(walkPh)) * 0.07 * moving;
    buddy.body.position.y = bob;
    const sq = hOff > 0 ? 1 + Math.min(0.12, vr * 0.03) : 1 - bob * 0.6;
    buddy.body.scale.set(1 / Math.sqrt(sq), sq, 1 / Math.sqrt(sq));
    buddy.body.rotation.z = Math.sin(walkPh * 0.5) * 0.08 * moving;
    buddy.feet.forEach((f, i) => {
      const ph = walkPh * 0.5 + i * Math.PI;
      f.position.z = 0.03 + Math.sin(ph) * 0.12 * moving;
      f.position.y = 0.05 + Math.max(0, Math.cos(ph)) * 0.07 * moving;
    });
    clouds.rotation.y = t * 0.025;
    clouds.rotation.x = t * 0.011;
  };

  return {
    scene,
    camera: cam,
    update(t, dt) {
      uTime.value = t;
      step(Math.min(dt, 0.05), t);
      if (input.drag.dx) {
        orbitYaw = clamp(orbitYaw + input.drag.dx * 0.006, -2.6, 2.6);
        input.drag.dx = 0;
        input.drag.dy = 0;
      } else orbitYaw *= Math.exp(-dt * 0.6);
      // 카메라: 꼬마의 위 · 뒤에서 — 위쪽을 꼬마의 위쪽으로 부드럽게 돌린다 (끄면 세상 위쪽 고정)
      wideK = lerp(wideK, wide ? 1 : 0, 1 - Math.exp(-dt * 2.5));
      const k = 1 - Math.exp(-dt * 4);
      camUp.lerp(up, 1 - Math.exp(-dt * 3)).normalize();
      const back = tmp.copy(fwd).applyAxisAngle(up, orbitYaw).multiplyScalar(-1);
      const p = buddy.root.position;
      const want = tmp2.copy(p).addScaledVector(up, lerp(2.3, 16, wideK)).addScaledVector(back, lerp(4.2, 9, wideK));
      if (!camInit) {
        camPos.copy(want);
        camInit = true;
      } else camPos.lerp(want, k);
      // 언덕 속으로 들어가지 않게 — 카메라 아래 땅보다 늘 0.7 위
      {
        const cd = tmp.copy(camPos).normalize();
        const floor = Math.max(surface(cd), SEA) + 0.7;
        if (camPos.length() < floor) camPos.setLength(floor);
      }
      cam.position.copy(camPos);
      if (sphereCam) cam.up.copy(camUp);
      else cam.up.set(0, 1, 0);
      look.copy(p).addScaledVector(up, lerp(0.5, -6, wideK)).addScaledVector(fwd, lerp(1.3, 0, wideK));
      cam.lookAt(look);
      // 그림자는 꼬마 둘레만
      sun.position.copy(p).addScaledVector(sunDir, 25);
      sun.target.position.copy(p);
    },
    render(r, w, h) {
      isBig = w >= 700;
      if (isBig) input.attach(w, h);
      r.shadowMap.enabled = true;
      r.shadowMap.type = THREE.PCFShadowMap;
      while (input.taps.length) {
        const tp = input.taps.shift()!;
        rayc.setFromCamera(new THREE.Vector2(tp.x, tp.y), cam);
// … (39줄 더 — 전체는 라이브 견본 페이지의 「코드」 탭)
```

## 관련 기술
- 먼저 알면 좋은 기술: [캐릭터 조작기 (경사 · 계단 · 점프 · 벽 미끄러지기)](https://ai-techstudio.web.app/ai/t/i480.md) `i480`
- 다음에 해 볼 기술: [휘는 지평선 (굴러가는 세계)](https://ai-techstudio.web.app/ai/t/i537.md) `i537` · [육각 타일 행성 (골드버그 다면체)](https://ai-techstudio.web.app/ai/t/i539.md) `i539`
