# AI 꾸러미 — 캐릭터 조작기 (경사 · 계단 · 점프 · 벽 미끄러지기) — Kinematic character controller (capsule)
> 캡슐을 바닥 · 벽 삼각형과 맞대어 겹친 만큼 밀어내고, 닿은 면 기울기로 바닥 · 가파른 경사 · 벽을 나눠 계단 오르기 · 땅 붙기 · 코요테 점프까지 처리한다.  
> 견본: https://ai-techstudio.web.app/#t/i480

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

## 주문서

### 만들어 줘: 캐릭터 조작기 (경사 · 계단 · 점프 · 벽 미끄러지기) — Kinematic character controller (capsule)

#### 1. 목표
쿼터뷰 던전의 기사에 캡슐 캐릭터 조작기를 만들어 줘 — 경사 한계 · 계단 오르기 · 땅 붙기 · 벽 미끄러지기 · 코요테 점프 포함. 분위기는 돌 던전 · 횃불.

#### 2. 핵심 기술 용어
- **Kinematic character controller (capsule)** — 물리 강체 없이 직접 움직이고 밀어내는 캡슐
- **Depenetration by contact normal** — 캡슐 뼈대 선분 ↔ 삼각형 가장 가까운 점으로 겹친 깊이만큼 밀기
- **Slope limit · step offset · ground snap** — 오를 수 있는 경사 · 계단 높이 · 내리막 붙기
- **Coyote time · jump buffer** — 떨어진 직후에도 점프 · 땅 닿기 전에 누른 점프 기억

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

#### 4. 조건
- 고정 시간 걸음(1/60초)으로 돌리고 한 장면에 쌓이는 시간은 최대 0.07초로 자른다
- 충돌 삼각형은 격자 칸에 미리 나눠 담고 캡슐 둘레 칸만 본다
- 밀어내기는 한 번이 아니라 여러 번(최대 4) — 구석에서 두 벽에 동시에 닿을 때
- 경사 한계 · 계단 높이 · 코요테 시간 · 점프 버퍼 4개를 슬라이더로
- 설명 화면이면 닿은 면 법선을 화살표(바닥 초록 · 경사 주황 · 벽 하늘 · 천장 분홍)로

#### 5. 완성 기준 (이게 보이면 성공)
- 45° 보다 완만한 경사는 걸어 오르고, 가파른 경사에선 미끄러져 내려온다
- 0.3 이하 계단은 점프 없이 올라서고, 내리막 · 계단 내려갈 때 붕 뜨지 않는다
- 벽에 비스듬히 부딪히면 멈추지 않고 벽을 따라 미끄러진다
- 모서리를 지나 0.12초 안에 누른 점프가 된다 (코요테)

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

## 원리
- 고정 1/60초로 나눠 돈다: ① 입력 → 수평 속도(땅 가속 28, 공중 9) ② 점프 ③ 중력 18 ④ 위치 이동 ⑤ 밀어내기 ⑥ 계단 ⑦ 땅 붙기.
- 밀어내기: 캡슐 뼈대 선분과 근처 삼각형의 가장 가까운 두 점 거리 d 가 반지름 R(0.32) 보다 작으면 겹친 깊이 R − d 만큼 그 방향(법선)으로 민다 — 최대 4번 되풀이.
- 닿은 법선의 y 로 나눈다: y ≥ cos(경사 한계 45°) 면 바닥(위로만 밀기 · 착지), 0.08 보다 크면 가파른 경사(옆으로만 밀어 미끄러짐), −0.3 보다 크면 벽(벽 쪽 속도만 지우기 = 벽 따라 미끄러지기), 그 아래는 천장.
- 계단: 바닥에 있는데 낮은 턱(0.3 이하)에 막히면 턱 높이만큼 올려 앞으로 옮겨 보고 겹치지 않으면 그 위로 올라선다.
- 땅 붙기: 방금까지 땅이었는데 떠 있으면 아래로 광선을 쏴 계단 높이 + 0.12 안에 바닥이 있으면 붙인다. 점프는 버퍼 0.15초 · 코요테 0.12초 안이면 허락.

## 핵심 코드 — 밀어내기 — 닿은 면 기울기로 바닥 · 경사 · 벽 · 천장 나누기
(발췌: demos/demosGame3dA.ts CtrlDemo.stepSim() 의 4) 밀어내기를 정리)
```ts
const cosMax = Math.cos((maxSlope * Math.PI) / 180);   // 경사 한계 45°
let ground = false;
for (let it = 0; it < 4; it++) {                         // 구석에선 여러 번
  setSegment(pos);                                       // 캡슐 뼈대 A(아래 구 중심) · B(위 구 중심)
  col.near(pos.x - R, pos.y - 0.05, pos.z - R, pos.x + R, pos.y + H, pos.z + R, near);
  let moved = false;
  for (const i of near) {
    setSegment(pos);
    const d2 = col.segTri(i, A, B, cs, ct);               // 선분 ↔ 삼각형 가장 가까운 두 점
    if (d2 >= R * R - 1e-6) continue;
    const d = Math.sqrt(d2);
    const nx = (cs[0] - ct[0]) / d, ny = (cs[1] - ct[1]) / d, nz = (cs[2] - ct[2]) / d;
    const depth = R - d;
    if (ny >= cosMax) {                                   // 바닥: 위로만 올리고 착지
      pos.y += depth / ny;
      if (vel.y < 0) vel.y = 0;
      ground = true;
    } else if (ny > 0.08) {                               // 가파른 경사: 옆으로만 밀어 미끄러짐
      const hl = Math.hypot(nx, nz) || 1;
      pos.x += (nx / hl) * depth * 1.02;
      pos.z += (nz / hl) * depth * 1.02;
      const vn = vel.x * (nx / hl) + vel.z * (nz / hl);
      if (vn < 0) { vel.x -= (nx / hl) * vn; vel.z -= (nz / hl) * vn; }
    } else {                                              // 벽 · 천장: 법선 쪽으로 밀고 그쪽 속도만 지움
      pos.x += nx * depth; pos.y += ny * depth; pos.z += nz * depth;
      if (ny > -0.3) { const vn = vel.x * nx + vel.z * nz; if (vn < 0) { vel.x -= nx * vn; vel.z -= nz * vn; } }
      else if (vel.y > 0) vel.y = 0;
    }
    moved = true;
  }
  if (!moved) break;
}
// 점프 허락: 버퍼(누른 뒤 0.15초) 안 + (땅이거나 떨어진 지 0.12초 안)
if (bufT > 0 && !jumped && (wasGrounded || sinceGround <= coyote)) { vel.y = 6.8; jumped = true; bufT = 0; }
```

## 흔한 실수 · 확인 목록
- [ ] **경사도 벽처럼 법선 방향으로 밀면 오르막에서 뒤로 밀려난다** — 바닥 판정(ny ≥ cos 한계)이면 위로만 depth / ny 만큼 올린다.
- [ ] **가파른 경사를 바닥처럼 다루면 벽을 타고 오른다** — 한계를 넘는 면은 수평으로만 밀고 그쪽 속도를 지워 미끄러지게 한다.
- [ ] **내리막 · 계단을 내려갈 때 매번 공중 판정이 나 점프가 막힌다** — 방금까지 땅이었으면 아래로 광선을 쏴 계단 높이 + 0.12 안의 바닥에 붙인다.
- [ ] **장면 시간으로 바로 돌리면 느린 기기에서 벽을 뚫는다** — 1/60초 고정 걸음으로 나눠 여러 번 돌리고, 쌓이는 시간은 0.07초로 자른다.

## 완성 기준 체크리스트
- [ ] 45° 보다 완만한 경사는 걸어 오르고, 가파른 경사에선 미끄러져 내려온다
- [ ] 0.3 이하 계단은 점프 없이 올라서고, 내리막 · 계단 내려갈 때 붕 뜨지 않는다
- [ ] 벽에 비스듬히 부딪히면 멈추지 않고 벽을 따라 미끄러진다
- [ ] 모서리를 지나 0.12초 안에 누른 점프가 된다 (코요테)

## 이 기술 정보
- id: `i480` · 분류: 게임 시스템 · AI › 3D 게임 기본기 · 3D · 난이도 어려움 · 폰 부담 보통 (폰 주의) — 한 걸음마다 근처 삼각형만(격자 칸으로 걸러) 선분 ↔ 삼각형 검사 × 최대 4번. 삼각형 수천 개 던전도 1ms 안. 고정 1/60초라 느린 기기에선 한 장면에 몇 걸음 돈다(0.07초까지).
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i480
- 쓰면 좋을 때: 걸어 다니는 3D 게임의 주인공 (물리 엔진 강체보다 조작감이 정확함) / 경사 · 계단 · 점프가 섞인 지형
- 쓰지 말 때: 밀리고 굴러야 하는 물체 — 물리 엔진 강체로 / 격자 위를 한 칸씩 움직이는 퍼즐 — 칸 이동이면 충분

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### CtrlDemo — `src/demos/demosGame3dA.ts:1262`
```ts
class CtrlDemo extends Stage {
  private col: Col | null = null;
  private rig!: Rig;
  private readonly R = 0.32;
  private readonly H = 1.25;
  private pos = new THREE.Vector3(0.5, 0, 4);
  private vel = new THREE.Vector3();
  private yaw = 0;
  private maxSlope = 45;
  private stepH = 0.3;
  private coyote = 0.12;
  private buffer = 0.15;
  private grounded = false;
  private groundNy = 1;
  private sinceGround = 0;
  private bufT = 0;
  private bufAir = false;
  private jumped = false;
  private kindNow: 'ground' | 'steep' | 'wall' | 'air' = 'air';
  private stepFlash = 0;
  private contacts: Contact[] = [];
  private toasts: { text: string; col: string; t0: number; p: THREE.Vector3 }[] = [];
  private wp = 0;
  private wpT = 0;
  private pendingBuffer = false;
  private pitT = 0;
  private acc = 0;
  private time = 0;
  private input = new BigInput(true);
  private prevSpace = false;
  private capLines!: THREE.LineSegments;
  private arrows: THREE.ArrowHelper[] = [];
  private near: number[] = [];
  private A = v3();
  private B = v3();
  private cs = v3();
  private ct = v3();
  private hit = { y: 0, ny: 1 };
  constructor() {
    super();
    this.camera.fov = 30;
    this.followCam(1);
    this.buildArena(
      (b) => {
        slab(b, -8, -6, 2, 6);
        slab(b, 5, -6, 8, 6);
        slab(b, 2, -6, 5, -3);
        slab(b, 2, 3, 5, 6);
        pitFloor(b, 2, -3, 5, 3);
        wall(b, -8.6, -6.6, 8.6, -6, 2.4);
        wall(b, -8.6, -6, -8, 6.4, 2.4);
        wall(b, -8, 6, 8.4, 6.4, 0.35);
        wall(b, 8, -6, 8.4, 6, 0.35);
        b.box(-7.6, 0, -5.6, -2.4, 1.5, -2.5, WALL, { topColor: STONE });
        b.wedge(-7.6, -2.5, -5.6, 0.9, 0, 1.5, '-z', 0xb09878);
        b.wedge(-5.0, -2.5, -3.6, -1.3, 0, 1.5, '-z', 0xc08a62);
        for (let i = 0; i < 6; i++) b.box(-2.4 + i * 0.5, 0, -5.6, -1.9 + i * 0.5, 1.25 - i * 0.25, -3.6, WALL, { topColor: WALL_TOP });
        wall(b, -6, 3.3, -1, 3.7, 1.1);
        pillar(b, 6.7, -4.5, 0.8, 2.2);
        pillar(b, 7.0, 4.7, 0.7, 0.95);
        bridge(b, 1.9, 5.1, -0.55, 0.55);
        return () => b.tiles(-8, -6, 8, 6, STONE, (x, z) => x > 2 && x < 5 && z > -3 && z < 3, 1);
      },
      (b) => {
        this.col = new Col(b.tris);
      },
    );
    this.phases.push(() => {
      this.addTorch(-1.2, 1.75, -5.88);
      this.addTorch(4.4, 1.75, -5.88);
      this.rig = makeKnight('knight', this.ink);
      this.root.add(this.rig.root);
      this.capLines = this.makeCapsule();
      this.root.add(this.capLines);
      for (let i = 0; i < 4; i++) {
        const a = new THREE.ArrowHelper(new THREE.Vector3(0, 1, 0), new THREE.Vector3(), 0.7, 0xffffff, 0.2, 0.12);
        for (const m of [a.line.material, a.cone.material] as THREE.Material[]) {
          m.depthTest = false;
          m.transparent = true;
        }
        a.renderOrder = 10;
        a.visible = false;
        this.root.add(a);
        this.arrows.push(a);
      }
    });
    this.controls = [
      { type: 'range', label: '오를 수 있는 경사 한계 (°)', min: 20, max: 70, step: 1, value: 45, on: (v) => (this.maxSlope = v) },
      { type: 'range', label: '계단 오르기 높이', min: 0, max: 0.5, step: 0.01, value: 0.3, on: (v) => (this.stepH = v) },
      { type: 'range', label: '코요테 시간 (초)', min: 0, max: 0.25, step: 0.01, value: 0.12, on: (v) => (this.coyote = v) },
      { type: 'range', label: '점프 버퍼 (초)', min: 0, max: 0.25, step: 0.01, value: 0.15, on: (v) => (this.buffer = v) },
    ];
  }
  private camTgt = new THREE.Vector3(0.5, 0, 3);
  private vis = new THREE.Vector3(0.5, 0, 4);
  /** 쿼터뷰 — 주인공을 부드럽게 따라감 (판 밖은 덜 보이게 가둠) */
  private followCam(k: number): void {
    this.camTgt.x = lerp(this.camTgt.x, clamp(this.pos.x, -4.2, 4.6), k);
    this.camTgt.z = lerp(this.camTgt.z, clamp(this.pos.z, -3.2, 2.8), k);
    this.camTgt.y = lerp(this.camTgt.y, clamp(this.pos.y, 0, 1.5) * 0.5, k);
    const z = this.big ? 1 : 0.8;
    this.camera.position.set(this.camTgt.x + 5.6 * z, this.camTgt.y + 10.8 * z, this.camTgt.z + 9.6 * z);
    this.camera.lookAt(this.camTgt);
  }
  private makeCapsule(): THREE.LineSegments {
    const R = this.R;
    const H = this.H;
    const p: number[] = [];
    const N = 28;
    for (const y of [R, H - R])
      for (let i = 0; i < N; i++) {
        const a0 = (i / N) * TAU;
        const a1 = ((i + 1) / N) * TAU;
        p.push(Math.cos(a0) * R, y, Math.sin(a0) * R, Math.cos(a1) * R, y, Math.sin(a1) * R);
      }
    for (let k = 0; k < 4; k++) {
      const a = (k / 4) * TAU;
      p.push(Math.cos(a) * R, R, Math.sin(a) * R, Math.cos(a) * R, H - R, Math.sin(a) * R);
    }
    for (const rot of [0, Math.PI / 2])
      for (const [cy, s] of [
        [R, -1],
        [H - R, 1],
      ] as const)
        for (let i = 0; i < 14; i++) {
          const a0 = (i / 14) * Math.PI;
          const a1 = ((i + 1) / 14) * Math.PI;
          const f = (a: number): P3 => {
            const h = Math.cos(a) * R;
            return [h * Math.cos(rot), cy + s * Math.sin(a) * R, h * Math.sin(rot)];
          };
          p.push(...f(a0), ...f(a1));
        }
    const g = new THREE.BufferGeometry();
    g.setAttribute('position', new THREE.Float32BufferAttribute(p, 3));
    const ls = new THREE.LineSegments(g, new THREE.LineBasicMaterial({ color: 0x6ee07a, transparent: true, opacity: 0.95, depthTest: false }));
    ls.renderOrder = 9;
    return ls;
  }
  private toast(text: string, col: string): void {
    this.toasts.push({ text, col, t0: this.time, p: this.pos.clone().add(new THREE.Vector3(0, this.H + 0.9, 0)) });
    if (this.toasts.length > 3) this.toasts.shift();
  }
  private respawn(): void {
    this.pos.set(0.5, 0, 4);
    this.vel.set(0, 0, 0);
    this.wp = 0;
    this.wpT = 0;
    this.grounded = false;
  }
  private seg(p: THREE.Vector3): void {
    this.A[0] = this.B[0] = p.x;
    this.A[2] = this.B[2] = p.z;
    this.A[1] = p.y + this.R;
    this.B[1] = p.y + this.H - this.R;
  }
  /** 이 자리에 캡슐을 두면 무엇과 겹치나 */
  private overlaps(p: THREE.Vector3): boolean {
    const col = this.col!;
    this.seg(p);
    const R = this.R - 0.02;
    col.near(p.x - R, p.y, p.z - R, p.x + R, p.y + this.H, p.z + R, this.near);
    for (const i of this.near) if (col.segTri(i, this.A, this.B, this.cs, this.ct) < R * R) return true;
    return false;
  }
  /** 한 걸음 (고정 1/60 초) */
  private stepSim(dt: number, wish: THREE.Vector3, jumpPress: boolean): void {
    const col = this.col!;
    const cosMax = Math.cos((this.maxSlope * Math.PI) / 180);
    const wasGrounded = this.grounded;
    // 1) 입력 → 속도
    const sp = 4.2;
    const a = wasGrounded ? 28 : 9;
    this.vel.x = damp(this.vel.x, wish.x * sp, a * 0.35, dt);
    this.vel.z = damp(this.vel.z, wish.z * sp, a * 0.35, dt);
    // 2) 점프 — 버퍼 · 코요테
    if (jumpPress) {
      this.bufT = this.buffer + 1e-4;
      this.bufAir = !wasGrounded;
    }
    if (wasGrounded) {
      this.sinceGround = 0;
      this.jumped = false;
    } else this.sinceGround += dt;
    let jumpedNow = false;
    if (this.bufT > 0 && !this.jumped && (wasGrounded || this.sinceGround <= this.coyote)) {
      this.vel.y = 6.8;
      jumpedNow = true;
      this.jumped = true;
      if (!wasGrounded) this.toast(`코요테 점프! 떨어진 뒤 ${this.sinceGround.toFixed(2)}초`, '#ffd25a');
      else if (this.bufAir) this.toast('점프 버퍼! 땅 닿기 전에 누른 점프', '#8fe0ff');
      this.bufT = 0;
      this.grounded = false;
    }
    this.bufT = Math.max(0, this.bufT - dt);
    // 3) 중력 (바닥이면 살짝 눌러 붙임)
    if (this.grounded && !jumpedNow) this.vel.y = -0.6;
    else this.vel.y -= 18 * dt;
    this.vel.y = Math.max(this.vel.y, -16);
    this.pos.addScaledVector(this.vel, dt);
    // 4) 밀어내기
    this.contacts.length = 0;
    let ground = false;
    let gny = 1;
    let wallHit = false;
    let steepHit = false;
    let lowBlock = false;
    for (let it = 0; it < 4; it++) {
      this.seg(this.pos);
      const R = this.R;
      col.near(this.pos.x - R, this.pos.y - 0.05, this.pos.z - R, this.pos.x + R, this.pos.y + this.H, this.pos.z + R, this.near);
      let moved = false;
      for (const i of this.near) {
        this.seg(this.pos);
        const d2 = col.segTri(i, this.A, this.B, this.cs, this.ct);
        if (d2 >= R * R - 1e-6) continue;
        const d = Math.sqrt(d2);
        let nx: number;
        let ny: number;
        let nz: number;
        if (d > 1e-5) {
          nx = (this.cs[0] - this.ct[0]) / d;
          ny = (this.cs[1] - this.ct[1]) / d;
          nz = (this.cs[2] - this.ct[2]) / d;
        } else {
          nx = col.nr[i * 3]!;
          ny = col.nr[i * 3 + 1]!;
          nz = col.nr[i * 3 + 2]!;
        }
        const depth = R - d;
        let kind: Kind;
        if (ny >= cosMax) {
          kind = 'ground';
          this.pos.y += depth / ny;
          if (this.vel.y < 0) this.vel.y = 0;
          ground = true;
          gny = Math.min(gny, ny);
        } else if (ny > 0.08) {
          kind = 'steep';
          const hl = Math.hypot(nx, nz) || 1;
          const hx = nx / hl;
          const hz = nz / hl;
          this.pos.x += hx * depth * 1.02;
          this.pos.z += hz * depth * 1.02;
          const vn = this.vel.x * hx + this.vel.z * hz;
          if (vn < 0) {
            this.vel.x -= hx * vn;
            this.vel.z -= hz * vn;
          }
          steepHit = true;
        } else if (ny > -0.3) {
          kind = 'wall';
          this.pos.x += nx * depth;
          this.pos.y += ny * depth;
          this.pos.z += nz * depth;
          const vn = this.vel.x * nx + this.vel.z * nz;
          if (vn < 0) {
            this.vel.x -= nx * vn;
            this.vel.z -= nz * vn;
          }
          wallHit = true;
        } else {
          kind = 'ceil';
          this.pos.x += nx * depth;
          this.pos.y += ny * depth;
          this.pos.z += nz * depth;
          if (this.vel.y > 0) this.vel.y = 0;
        }
        if (kind !== 'ground' && kind !== 'ceil' && this.ct[1] - this.pos.y <= this.stepH + 0.03) lowBlock = true;
        moved = true;
        if (this.contacts.length < 4 && !this.contacts.some((c) => c.kind === kind && Math.abs(c.n[0] - nx) + Math.abs(c.n[1] - ny) + Math.abs(c.n[2] - nz) < 0.2))
          this.contacts.push({ p: [this.ct[0], this.ct[1], this.ct[2]], n: [nx, ny, nz], kind });
      }
      if (!moved) break;
    }
    // 5) 계단 오르기 — 벽에 막혔고 그 턱이 계단 높이 이하면 올라섬
    if (lowBlock && wasGrounded && !jumpedNow && wish.lengthSq() > 0.01 && this.stepH > 0.01) {
      const up = this.stepH + 0.02;
      const test = this.pos.clone().add(new THREE.Vector3(wish.x * 0.12, up, wish.z * 0.12));
      if (!this.overlaps(test)) {
        const fx = test.x + wish.x * this.R * 0.75;
        const fz = test.z + wish.z * this.R * 0.75;
        if (col.down(fx, test.y, fz, up + 0.05, this.hit) && this.hit.ny >= cosMax && this.hit.y > this.pos.y + 0.03 && this.hit.y - this.pos.y <= up) {
          const rise = this.hit.y - this.pos.y;
          this.pos.set(fx, this.hit.y + 0.005, fz);
          if (this.overlaps(this.pos)) this.pos.set(test.x, this.hit.y + 0.005, test.z);
          ground = true;
          gny = 1;
          wallHit = false;
          steepHit = false;
          if (this.time - this.stepFlash > 0.35) this.toast(`계단 +${rise.toFixed(2)}`, '#bff0a8');
          this.stepFlash = this.time;
        }
      }
    }
    // 6) 바닥 붙기 — 내리막 · 계단 내려갈 때 붕 뜨지 않게
    if (!ground && wasGrounded && !jumpedNow && this.vel.y <= 0.01) {
      if (col.down(this.pos.x, this.pos.y + 0.05, this.pos.z, this.stepH + 0.12, this.hit) && this.hit.ny >= cosMax) {
        this.pos.y = this.hit.y;
        ground = true;
        gny = this.hit.ny;
        this.vel.y = 0;
      }
    }
    this.grounded = ground;
    if (ground) this.groundNy = gny;
    this.kindNow = ground ? 'ground' : steepHit ? 'steep' : wallHit ? 'wall' : 'air';
    if (ground && wallHit) this.kindNow = 'wall';
  }
  protected tick(t: number, dt: number): void {
    if (!this.col) return;
    this.time = t;
    if (this.big) this.input.attach(this.w, this.h);
    const manual = performance.now() - this.input.lastKey < 4000;
    const wish = new THREE.Vector3();
    let press = false;
    if (manual) {
      const k = this.input.keys;
      const f = (k.has('w') || k.has('arrowup') ? 1 : 0) - (k.has('s') || k.has('arrowdown') ? 1 : 0);
      const s = (k.has('d') || k.has('arrowright') ? 1 : 0) - (k.has('a') || k.has('arrowleft') ? 1 : 0);
      const fw = new THREE.Vector3();
      this.camera.getWorldDirection(fw);
      fw.y = 0;
      fw.normalize();
      const rt = new THREE.Vector3(-fw.z, 0, fw.x);
      wish.addScaledVector(fw, f).addScaledVector(rt, s);
      if (wish.lengthSq() > 1) wish.normalize();
      const sp = k.has(' ');
      press = sp && !this.prevSpace;
      this.prevSpace = sp;
    }
    this.acc = Math.min(this.acc + dt, 0.07);
    while (this.acc >= 1 / 60) {
      this.acc -= 1 / 60;
      if (!manual) press = this.autopilot(wish, 1 / 60) || press;
      this.stepSim(1 / 60, wish, press);
      press = false;
    }
    // 구덩이에 빠지면 처음으로
    if (this.pos.y < -1.2) {
      this.pitT += dt;
      if (this.pitT > 1.3) {
        this.toast('구덩이에 빠짐 — 처음 자리로', '#ff9a8a');
        this.respawn();
        this.pitT = 0;
      }
    } else this.pitT = 0;
    // 보이는 것
    const hs = Math.hypot(this.vel.x, this.vel.z);
    if (hs > 0.3) this.yaw = this.yaw + angWrap(Math.atan2(this.vel.x, this.vel.z) - this.yaw) * (1 - Math.exp(-14 * dt));
    const rig = this.rig;
    this.vis.x = this.pos.x;
    this.vis.z = this.pos.z;
    this.vis.y = Math.abs(this.vis.y - this.pos.y) > 0.6 ? this.pos.y : damp(this.vis.y, this.pos.y, 22, dt);
    rig.root.position.copy(this.vis);
    rig.yaw.rotation.y = this.yaw;
    animRig(rig, dt, hs, !this.grounded, t);
    this.capLines.position.copy(this.vis);
    this.followCam(1 - Math.exp(-2.5 * dt));
    const kc = this.kindNow === 'air' ? 0xf4ead8 : KIND_COL[this.kindNow];
    (this.capLines.material as THREE.LineBasicMaterial).color.setHex(kc);
    for (let i = 0; i < 4; i++) {
      const a = this.arrows[i]!;
      const c = this.contacts[i];
      a.visible = !!c;
      if (!c) continue;
      a.position.set(c.p[0], c.p[1], c.p[2]);
      a.setDirection(new THREE.Vector3(c.n[0], c.n[1], c.n[2]));
      a.setColor(KIND_COL[c.kind]);
    }
  }
  /** 자동 시연 — 길 따라 걷고, 정해진 곳에서 점프 */
  private autopilot(wish: THREE.Vector3, dt: number): boolean {
    const w = ROUTE_A[this.wp]!;
    this.wpT += dt;
    const dx = w.x - this.pos.x;
    const dz = w.z - this.pos.z;
    const d = Math.hypot(dx, dz);
    if (d > 0.01) wish.set(dx / d, 0, dz / d);
    let press = false;
    if (w.act === 'coyote') {
      if (!this.grounded && !this.jumped && this.sinceGround > 0.06 && this.pos.x > 1.9) {
        press = true;
        this.pendingBuffer = true;
      } else if (this.pendingBuffer && !this.grounded && this.vel.y < -1 && this.pos.x > 4.6) {
        if (this.col!.down(this.pos.x, this.pos.y + 0.02, this.pos.z, 0.45, this.hit) && this.pos.y - this.hit.y > 0.12) {
          press = true;
          this.pendingBuffer = false;
        }
      }
    }
    if (d < 0.35 || this.wpT > (w.tmax ?? 9)) {
      this.wp = (this.wp + 1) % ROUTE_A.length;
      this.wpT = 0;
      this.pendingBuffer = false;
    }
    return press;
  }
  protected overlay(w: number, h: number): void {
    const u = this.hud.u;
// … (100줄 더 — 전체는 라이브 견본 페이지의 「코드」 탭)
```

## 관련 기술
- 다음에 해 볼 기술: [카메라가 벽을 뚫지 않게](https://ai-techstudio.web.app/ai/t/i481.md) `i481` · [작은 행성 산책 (구면 중력 · 따라 도는 카메라)](https://ai-techstudio.web.app/ai/t/i536.md) `i536` · [전투 판정 (휘두르기 · 히트스톱 · 넉백)](https://ai-techstudio.web.app/ai/t/i484.md) `i484`
