# AI 꾸러미 — 적 AI (상태 기계 · 행동 나무) — Finite state machine (FSM)
> 경비의 행동을 순찰 · 의심 · 추격 · 공격 · 귀환 상태로 나누고, 매 장면 행동 나무를 위에서부터 훑어 처음 성공하는 가지를 고른다 — 시야 원뿔과 발소리로 주인공을 알아챈다.  
> 견본: https://ai-techstudio.web.app/#t/i483

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

## 주문서

### 만들어 줘: 적 AI (상태 기계 · 행동 나무) — Finite state machine (FSM)

#### 1. 목표
던전 고블린 경비에 순찰 → 의심 → 추격 → 공격 → 귀환 상태를 가진 적 AI 를 만들어 줘 — 시야 원뿔 · 발소리 · 지금 상태 표시 포함. 분위기는 돌 던전 · 횃불.

#### 2. 핵심 기술 용어
- **Finite state machine (FSM)** — 지금 상태 하나 + 바뀌는 조건
- **Behavior tree (selector)** — 위에서부터 조건을 보고 처음 성공한 가지를 실행
- **Vision cone + line of sight** — 시야 각 · 거리 안이고 벽에 안 가리면 보임
- **Noise perception · breadcrumbs** — 발소리 반경 안이면 들음 · 지나온 길 점을 따라 귀환

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

#### 4. 조건
- 「보임」은 각도 · 거리 · 벽 가림 세 가지를 모두 검사
- 행동 고르기(나무)와 행동 하기(이동 · 휘두르기)를 나눈다
- 상태가 바뀔 때만 말풍선(「들켰다!」 「놓쳤다…」)을 띄운다
- 시야 부채꼴은 벽에서 잘리게 (광선 끝을 이은 다각형)
- 시야 반각 · 시야 거리 · 발소리 반경 슬라이더 + 「살금살금」(뛰어도 소리 없음)

#### 5. 완성 기준 (이게 보이면 성공)
- 주인공이 기둥 뒤에 있으면 시야 부채꼴이 기둥에서 잘리고 들키지 않는다
- 주인공이 뛰면 소리 고리가 퍼지고, 반경 안의 적이 그 자리로 와 두리번거린다
- 추격하다 놓치면 지나온 길을 되짚어 순찰 길로 돌아간다
- 오른쪽 행동 나무에서 지금 실행 중인 마디에 불이 들어온다

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

## 원리
- 보기: 주인공까지 거리 < 6.2 이고, 바라보는 방향과의 각이 반각 36° 안이고, 그 방향 광선이 벽에 먼저 막히지 않으면 「보임」.
- 듣기: 주인공이 뛸 때 0.33초마다 소리 고리, 적이 반경 3.6 안이면 그 자리를 4.5초 기억한다.
- 행동 나무(선택자): 공격 중이거나 1.15 안에서 보이면 공격 → 0.3초 넘게 보이면 추격 → 기억이 있으면 의심(그곳에 가서 두리번) → 순찰 길에서 벗어났으면 귀환 → 아니면 순찰.
- 추격 · 의심 중엔 0.45 마다 길 점(빵 부스러기)을 떨어뜨리고, 귀환은 그 점을 거꾸로 따라 순찰 길로 돌아온다.
- 상태마다 색(순찰 초록 · 의심 노랑 · 추격 빨강 · 공격 주황 · 귀환 파랑)으로 머리 위 표시 · 바닥 시야 부채꼴을 칠한다.

## 핵심 코드 — 보기 (원뿔 + 벽 광선) · 행동 나무 선택자
(발췌: demos/demosGame3dA.ts AIDemo.tick() 을 정리)
```ts
type AState = 'patrol' | 'suspect' | 'chase' | 'attack' | 'return';

// ── 보기: 거리 · 각도 · 벽 가림
const dx = hero.x - ex, dz = hero.z - ez;
const d = Math.hypot(dx, dz) || 1e-6;
const ca = (Math.sin(eyaw) * dx + Math.cos(eyaw) * dz) / d;               // 바라보는 방향과의 cos
const inCone = d < range && ca > Math.cos((fov * Math.PI) / 180);        // range 6.2 · fov 36°
const block = ray2(wallSegs, ex, ez, dx / d, dz / d, d);                  // 벽까지 거리
seen = inCone && block >= d - 0.05;
if (seen) { seenT = Math.min(1, seenT + dt); memory = { x: hero.x, z: hero.z }; memT = 3; }
else seenT = Math.max(0, seenT - dt * 0.6);
memT -= dt;
if (memT <= 0) memory = null;

// ── 행동 나무: 위에서부터 처음 성공하는 가지
const swinging = st === 'attack' && atkT < 0.85;
let pick: AState;
if (swinging || (seen && d < 1.15)) pick = 'attack';
else if (seen && (seenT > 0.3 || st === 'chase' || st === 'attack')) pick = 'chase';
else if (memory) pick = 'suspect';                                         // 본 곳 · 들은 곳으로 가서 두리번
else if (crumbs.length || patrolDist() > 0.3) pick = 'return';            // 길 점을 거꾸로 따라
else pick = 'patrol';
if (pick !== st) { onEnter(pick, st); st = pick; }

// ── 행동
if (st === 'patrol') { if (moveEnemy(PATROL[pi]!.x, PATROL[pi]!.z, 1.3, dt) < 0.15) pi = (pi + 1) % PATROL.length; }
else if (st === 'chase') moveEnemy(hero.x, hero.z, 2.9, dt);
else if (st === 'return') { const c = crumbs[crumbs.length - 1]; if (c && moveEnemy(c.x, c.z, 1.6, dt) < 0.2) crumbs.pop(); }
```

## 흔한 실수 · 확인 목록
- [ ] **각도 · 거리만 보면 벽 너머로 주인공을 알아챈다** — 적 → 주인공 광선이 벽 선분에 먼저 닿는지 본다.
- [ ] **한 장면 보였다고 바로 추격하면 스치기만 해도 들킨다** — 보인 시간(seenT)이 0.3초를 넘어야 추격. 안 보이면 0.6배 빠르기로 줄어든다.
- [ ] **추격 뒤 순찰 길로 곧장 돌아가면 벽을 뚫고 지나간다** — 쫓는 동안 길 점을 남기고 귀환할 때 거꾸로 따라간다 (또는 길찾기).
- [ ] **상태가 바뀌는 매 장면 말풍선을 띄우면 깜빡인다** — 바뀌는 순간(pick ≠ st)에만 띄운다.

## 완성 기준 체크리스트
- [ ] 주인공이 기둥 뒤에 있으면 시야 부채꼴이 기둥에서 잘리고 들키지 않는다
- [ ] 주인공이 뛰면 소리 고리가 퍼지고, 반경 안의 적이 그 자리로 와 두리번거린다
- [ ] 추격하다 놓치면 지나온 길을 되짚어 순찰 길로 돌아간다
- [ ] 오른쪽 행동 나무에서 지금 실행 중인 마디에 불이 들어온다

## 이 기술 정보
- id: `i483` · 분류: 게임 시스템 · AI › 3D 게임 기본기 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 적 하나당 시야 광선 1개 + 부채꼴 그리기 광선 40개(벽 선분과 교차). 적 수십 개도 가볍다 — 부채꼴은 보일 때만 그린다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i483
- 쓰면 좋을 때: 몰래 지나가기 · 숨바꼭질처럼 「들킬까 말까」가 놀이일 때 / 적이 여럿이고 각자 다르게 움직여야 할 때
- 쓰지 말 때: 보드게임 상대 — 탐색 AI(i340 · i347)가 맞다 / 상태가 수십 개로 늘어날 때 — 상태 기계 대신 행동 나무 데이터로 나눠 관리

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### AIDemo — `src/demos/demosGame3dA.ts:2688`
```ts
class AIDemo extends Stage {
  private ready2 = false;
  private segs: Seg2[] = [];
  private foots: Rect[] = [];
  private hero!: Rig;
  private gob!: Rig;
  // 주인공
  private px = -5.6;
  private pz = -3.6;
  private pyaw = 0;
  private pstep = 0;
  private pwait = 0;
  private hidePil: P2 | null = null;
  private patrolT = 0;
  private fleeFrom = 0;
  private freeze = 0;
  private boost = 0;
  private hitFlash = 0;
  private stepT = 0;
  private pmove = 0;
  private prun = false;
  // 적
  private ex = 6.0;
  private ez = 0.4;
  private eyaw = -Math.PI / 2;
  private st: AState = 'patrol';
  private pi = 3;
  private seen = false;
  private seenT = 0;
  private memory: P2 | null = null;
  private memT = 0;
  private lookT = 0;
  private crumbs: P2[] = [];
  private atkT = 0;
  private hitDone = false;
  private emove = 0;
  private bt: NodeSt[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
  // 시각화
  private fan!: THREE.Mesh;
  private fanEdge!: THREE.Line;
  private los!: THREE.Line;
  private rings: { m: THREE.Mesh; t: number; x: number; z: number }[] = [];
  private toasts: { text: string; col: string; t0: number; x: number; z: number }[] = [];
  private time = 0;
  private fov = 36;
  private range = 6.2;
  private noiseR = 3.6;
  private quiet = false;
  private readonly N = 40;
  private pw = 0;
  constructor() {
    super();
    this.camera.fov = 40;
    this.camera.position.set(0.8, 18.6, 10.6);
    this.camera.lookAt(0, -0.4, -0.3);
    this.buildArena(
      (b) => {
        slab(b, -7.6, -5.6, 7.6, 5.6);
        wall(b, -7.6, -5.6, 7.6, -5, 2.2);
        wall(b, -7.6, -5, -7, 5.6, 2.2);
        wall(b, -7, 5, 7.6, 5.6, 0.35);
        wall(b, 7, -5, 7.6, 5, 0.35);
        wall(b, -2.2, -5, -1.8, -1.3, 1.8);
        wall(b, 1.5, 1.6, 5.0, 2.0, 1.8);
        pillar(b, -4.4, 1.8, 0.9, 2.0);
        pillar(b, 2.8, -2.0, 0.9, 2.0);
        pillar(b, -0.4, 3.2, 0.8, 2.0);
        b.box(5.6, 0, 3.4, 6.5, 0.7, 4.3, WOOD, { topColor: 0xa36d40 });
        b.box(-6.6, 0, 4.0, -5.8, 0.6, 4.6, WOOD, { topColor: 0xa36d40 });
        return () => b.tiles(-7, -5, 7, 5, STONE, () => false, 13);
      },
      (b) => {
        this.foots = b.foot.filter((f) => f.h > 0.3);
        this.segs = rectSegs(b.foot.filter((f) => f.h > 1.2));
        this.segs.push({ ax: -7, az: -5, bx: 7, bz: -5 }, { ax: 7, az: -5, bx: 7, bz: 5 }, { ax: 7, az: 5, bx: -7, bz: 5 }, { ax: -7, az: 5, bx: -7, bz: -5 });
      },
    );
    this.phases.push(() => {
      this.addTorch(-4.6, 1.6, -4.88);
      this.addTorch(3.6, 1.6, -4.88);
      this.hero = makeKnight('knight', this.ink);
      this.gob = makeKnight('goblin', this.ink);
      this.gob.root.scale.setScalar(1.08);
      this.root.add(this.hero.root, this.gob.root);
      // 시야 부채꼴
      const fg = new THREE.BufferGeometry();
      fg.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array((this.N + 2) * 3), 3));
      const idx: number[] = [];
      for (let i = 0; i < this.N; i++) idx.push(0, i + 2, i + 1);
      fg.setIndex(idx);
      this.fan = new THREE.Mesh(fg, new THREE.MeshBasicMaterial({ color: 0x7be08a, transparent: true, opacity: 0.26, depthWrite: false, toneMapped: false, side: THREE.DoubleSide }));
      this.fan.renderOrder = 3;
      this.fan.frustumCulled = false;
      const eg = new THREE.BufferGeometry();
      eg.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array((this.N + 3) * 3), 3));
      this.fanEdge = new THREE.Line(eg, new THREE.LineBasicMaterial({ color: 0x7be08a, transparent: true, opacity: 0.9, depthWrite: false, toneMapped: false }));
      this.fanEdge.renderOrder = 4;
      this.fanEdge.frustumCulled = false;
      const lg = new THREE.BufferGeometry();
      lg.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(6), 3));
      this.los = new THREE.Line(lg, new THREE.LineDashedMaterial({ color: 0xffffff, dashSize: 0.18, gapSize: 0.12, transparent: true, depthWrite: false, toneMapped: false }));
      this.los.renderOrder = 5;
      this.los.frustumCulled = false;
      this.root.add(this.fan, this.fanEdge, this.los);
      const rg = new THREE.RingGeometry(0.93, 1, 48);
      for (let i = 0; i < 5; i++) {
        const m = new THREE.Mesh(rg, new THREE.MeshBasicMaterial({ color: 0xfff0d0, transparent: true, opacity: 0, depthWrite: false, side: THREE.DoubleSide, toneMapped: false }));
        m.rotation.x = -Math.PI / 2;
        m.position.y = 0.06;
        m.renderOrder = 4;
        m.visible = false;
        this.root.add(m);
        this.rings.push({ m, t: 9, x: 0, z: 0 });
      }
      this.ready2 = true;
    });
    this.controls = [
      { type: 'range', label: '시야 반각 (°)', min: 15, max: 70, step: 1, value: 36, on: (v) => (this.fov = v) },
      { type: 'range', label: '시야 거리', min: 3, max: 10, step: 0.1, value: 6.2, on: (v) => (this.range = v) },
      { type: 'range', label: '발소리 반경', min: 1, max: 6, step: 0.1, value: 3.6, on: (v) => (this.noiseR = v) },
      { type: 'toggle', label: '살금살금 (뛰어도 소리 없음)', value: false, on: (v) => (this.quiet = v) },
    ];
  }
  private toast(text: string, col: string, x: number, z: number): void {
    this.toasts.push({ text, col, t0: this.time, x, z });
    if (this.toasts.length > 3) this.toasts.shift();
  }
  /** 원(반지름 r)을 벽 · 기둥 밖으로 밀어내기 */
  private pushOut(p: P2, r: number): void {
    for (const f of this.foots) {
      const cx = clamp(p.x, f.x0, f.x1);
      const cz = clamp(p.z, f.z0, f.z1);
      const dx = p.x - cx;
      const dz = p.z - cz;
      const d = Math.hypot(dx, dz);
      if (d < r && d > 1e-6) {
        p.x = cx + (dx / d) * r;
        p.z = cz + (dz / d) * r;
      }
    }
    p.x = clamp(p.x, -7 + r, 7 - r);
    p.z = clamp(p.z, -5 + r, 5 - r);
  }
  private moveEnemy(tx: number, tz: number, sp: number, dt: number): number {
    const dx = tx - this.ex;
    const dz = tz - this.ez;
    const d = Math.hypot(dx, dz);
    if (d < 0.05) return d;
    const s = Math.min(d, sp * dt);
    const p = { x: this.ex + (dx / d) * s, z: this.ez + (dz / d) * s };
    this.pushOut(p, 0.35);
    this.ex = p.x;
    this.ez = p.z;
    this.eyaw += angWrap(Math.atan2(dx, dz) - this.eyaw) * (1 - Math.exp(-10 * dt));
    this.emove = sp;
    if (this.st !== 'patrol' && this.st !== 'return') {
      const last = this.crumbs[this.crumbs.length - 1];
      if (!last || Math.hypot(last.x - this.ex, last.z - this.ez) > 0.45) this.crumbs.push({ x: this.ex, z: this.ez });
    }
    return d;
  }
  private patrolDist(): number {
    let best = Infinity;
    for (let i = 0; i < 4; i++) {
      const a = PATROL[i]!;
      const b = PATROL[(i + 1) % 4]!;
      const ux = b.x - a.x;
      const uz = b.z - a.z;
      const k = clamp(((this.ex - a.x) * ux + (this.ez - a.z) * uz) / (ux * ux + uz * uz), 0, 1);
      best = Math.min(best, Math.hypot(this.ex - (a.x + ux * k), this.ez - (a.z + uz * k)));
    }
    return best;
  }

  protected tick(t: number, dt: number): void {
    if (!this.ready2) return;
    this.time = t;
    this.hitFlash = Math.max(0, this.hitFlash - dt);
    this.boost = Math.max(0, this.boost - dt);
    this.emove = 0;
    // ── 주인공 (정해진 길 · 쫓기면 도망)
    if ((this.st === 'chase' || this.st === 'attack') && !this.hidePil) {
      // 적보다 내가 더 가까운 기둥 가운데 가장 가까운 것
      let best: P2 | null = null;
      for (const pl of PILLARS) {
        const dm = Math.hypot(pl.x - this.px, pl.z - this.pz);
        if (Math.hypot(pl.x - this.ex, pl.z - this.ez) < dm) continue;
        if (!best || dm < Math.hypot(best.x - this.px, best.z - this.pz)) best = pl;
      }
      this.hidePil = best ?? PILLARS[0]!;
      this.fleeFrom = this.pstep;
      this.pwait = 0;
      this.freeze = 1.5;
    }
    this.patrolT = this.st === 'patrol' ? this.patrolT + dt : 0;
    let tx = this.px;
    let tz = this.pz;
    let sp = 0;
    let run = false;
    if (this.hidePil) {
      // 기둥을 사이에 두고 적의 반대쪽에 숨기
      const pl = this.hidePil;
      const ax = pl.x - this.ex;
      const az = pl.z - this.ez;
      const al = Math.hypot(ax, az) || 1;
      tx = pl.x + (ax / al) * 1.05;
      tz = pl.z + (az / al) * 1.05;
      const far = Math.hypot(tx - this.px, tz - this.pz) > 0.6;
      sp = this.boost > 0 ? 5 : far ? 2.4 : 1.8;
      run = far;
      if (this.freeze > 0 && this.boost <= 0) {
        // 들킨 순간 얼어붙음 (깜짝)
        this.freeze -= dt;
        tx = this.px;
        tz = this.pz;
        sp = 0;
        run = false;
        this.pyaw += angWrap(Math.atan2(this.ex - this.px, this.ez - this.pz) - this.pyaw) * (1 - Math.exp(-10 * dt));
      }
      if (this.patrolT > 1.2) {
        this.hidePil = null;
        this.pstep = this.fleeFrom >= 5 || this.fleeFrom === 0 ? 0 : 5;
        this.pwait = 0.4;
      }
    } else if (this.pwait > 0) {
      this.pwait -= dt;
    } else {
      const s = SCRIPT[this.pstep]!;
      const hold = (s.sync === 'near' && !(this.st === 'patrol' && this.ez > -0.5 && this.ex < 4.6)) || (s.sync === 'patrol' && this.st !== 'patrol') || (s.sync === 'bottom' && !(this.st === 'patrol' && this.ez > -0.5 && this.ex < 5.8));
      tx = hold ? this.px : s.x;
      tz = hold ? this.pz : s.z;
      sp = hold ? 0 : s.sp;
      run = !!s.run;
      if (!hold && Math.hypot(tx - this.px, tz - this.pz) < 0.15) {
        this.pwait = s.wait ?? 0;
        this.pstep = (this.pstep + 1) % SCRIPT.length;
      }
    }
    const pdx = tx - this.px;
    const pdz = tz - this.pz;
    const pd = Math.hypot(pdx, pdz);
    this.pmove = damp(this.pmove, pd > 0.05 ? sp : 0, 10, dt);
    if (pd > 0.05) {
      const s = Math.min(pd, this.pmove * dt);
      const p = { x: this.px + (pdx / pd) * s, z: this.pz + (pdz / pd) * s };
      this.pushOut(p, 0.3);
      this.px = p.x;
      this.pz = p.z;
      this.pyaw += angWrap(Math.atan2(pdx, pdz) - this.pyaw) * (1 - Math.exp(-12 * dt));
    }
    this.prun = run && this.pmove > 2.5;
    // 발소리 — 뛰면 0.33초마다 소리 고리, 반경 안이면 적이 들음
    if (this.prun && !this.quiet) {
      this.stepT += dt;
      if (this.stepT > 0.33) {
        this.stepT = 0;
        const r = this.rings.find((x) => x.t > 0.75) ?? this.rings[0]!;
        r.t = 0;
        r.x = this.px;
        r.z = this.pz;
        if (Math.hypot(this.px - this.ex, this.pz - this.ez) <= this.noiseR && this.st !== 'chase' && this.st !== 'attack') {
          if (!this.memory || this.memT < 3.5) this.toast('발소리를 들음!', '#ffd24a', this.ex, this.ez);
          this.memory = { x: this.px, z: this.pz };
          this.memT = 4.5;
          this.lookT = 0;
        }
      }
    }
    // ── 적: 보기 (시야 원뿔 + 벽 광선 검사)
    const dx = this.px - this.ex;
    const dz = this.pz - this.ez;
    const d = Math.hypot(dx, dz) || 1e-6;
    const ca = (Math.sin(this.eyaw) * dx + Math.cos(this.eyaw) * dz) / d;
    const inCone = d < this.range && ca > Math.cos((this.fov * Math.PI) / 180);
    const block = ray2(this.segs, this.ex, this.ez, dx / d, dz / d, d);
    this.seen = inCone && block >= d - 0.05;
    if (this.seen) {
      this.seenT = Math.min(1, this.seenT + dt);
      this.memory = { x: this.px, z: this.pz };
      this.memT = 3;
      this.lookT = 0;
    } else this.seenT = Math.max(0, this.seenT - dt * 0.6);
    this.memT -= dt;
    if (this.memT <= 0) this.memory = null;
    // ── 행동 나무 (위에서부터 처음 성공하는 가지)
    const bt = this.bt;
    bt.fill(0);
    bt[0] = 3;
    let pick: AState;
    const swinging = this.st === 'attack' && this.atkT < 0.85;
    if (swinging || (this.seen && d < 1.15)) {
      bt[1] = 2;
      bt[2] = 3;
      pick = 'attack';
    } else {
      bt[1] = 1;
      if (this.seen && (this.seenT > 0.3 || this.st === 'chase' || this.st === 'attack')) {
        bt[3] = 2;
        bt[4] = 3;
        pick = 'chase';
      } else {
        bt[3] = 1;
        if (this.memory) {
          bt[5] = 2;
          bt[6] = 3;
          pick = 'suspect';
        } else {
          bt[5] = 1;
          if (this.crumbs.length || this.patrolDist() > 0.3) {
            bt[7] = 2;
            bt[8] = 3;
            pick = 'return';
          } else {
            bt[7] = 1;
            bt[9] = 3;
            pick = 'patrol';
          }
        }
      }
    }
    if (pick !== this.st) {
      if (pick === 'chase' && this.st !== 'attack') this.toast('들켰다! 추격', '#ff6a5a', this.ex, this.ez);
      if (pick === 'return' && (this.st === 'suspect' || this.st === 'chase')) this.toast('놓쳤다… 돌아감', '#9cc8ff', this.ex, this.ez);
      if (pick === 'attack') {
        this.atkT = 0;
        this.hitDone = false;
      }
      if (pick === 'patrol') {
        let bi = 0;
        PATROL.forEach((p, i) => {
          if (Math.hypot(p.x - this.ex, p.z - this.ez) < Math.hypot(PATROL[bi]!.x - this.ex, PATROL[bi]!.z - this.ez)) bi = i;
        });
        this.pi = bi;
        this.crumbs.length = 0;
      }
      this.st = pick;
    }
    // ── 행동
    const gob = this.gob;
    gob.armR.rotation.x = damp(gob.armR.rotation.x, 0, 8, dt);
    if (this.st === 'patrol') {
      const p = PATROL[this.pi]!;
      if (this.moveEnemy(p.x, p.z, 1.3, dt) < 0.15) this.pi = (this.pi + 1) % 4;
    } else if (this.st === 'suspect' && this.memory) {
      const m = this.memory;
      if (Math.hypot(m.x - this.ex, m.z - this.ez) > 0.35 && this.lookT === 0) this.moveEnemy(m.x, m.z, 1.9, dt);
      else {
        this.lookT += dt;
        this.eyaw += Math.sin(this.lookT * 2.6) * dt * 2.4;
        if (this.lookT > 2.6) {
          this.memory = null;
          this.memT = 0;
        }
      }
    } else if (this.st === 'chase') {
      this.moveEnemy(this.px, this.pz, 2.9, dt);
    } else if (this.st === 'attack') {
      this.atkT += dt;
      this.eyaw += angWrap(Math.atan2(dx, dz) - this.eyaw) * (1 - Math.exp(-14 * dt));
      const k = this.atkT;
      gob.armR.rotation.x = k < 0.35 ? lerp(0, -2.5, k / 0.35) : k < 0.5 ? lerp(-2.5, 0.9, (k - 0.35) / 0.15) : lerp(0.9, 0, clamp((k - 0.5) / 0.35, 0, 1));
      if (k > 0.45 && !this.hitDone) {
        this.hitDone = true;
        if (d < 1.5) {
          const p = { x: this.px + (dx / d) * 0.9, z: this.pz + (dz / d) * 0.9 };
          this.pushOut(p, 0.3);
          this.px = p.x;
          this.pz = p.z;
          this.hitFlash = 0.5;
          this.boost = 1.8;
          this.toast('쿵!', '#ffb070', this.px, this.pz);
        }
      }
    } else if (this.st === 'return') {
      const c = this.crumbs[this.crumbs.length - 1];
      if (c) {
        if (this.moveEnemy(c.x, c.z, 1.6, dt) < 0.2) this.crumbs.pop();
      } else {
        const p = PATROL[this.pi]!;
        this.moveEnemy(p.x, p.z, 1.6, dt);
      }
    }
    // ── 보이는 것
    const hero = this.hero;
    hero.root.position.set(this.px, 0, this.pz);
    hero.yaw.rotation.y = this.pyaw;
    animRig(hero, dt, this.pmove, false, t);
    hero.body.rotation.z = Math.sin(this.hitFlash * 40) * this.hitFlash * 0.5;
    gob.root.position.set(this.ex, 0, this.ez);
    gob.yaw.rotation.y = this.eyaw;
    animRig(gob, dt, this.emove, false, t);
    if (this.st === 'attack') gob.body.rotation.x = -0.15;
    const col = ST_INFO[this.st].hex;
    (this.fan.material as THREE.MeshBasicMaterial).color.setHex(col);
    (this.fan.material as THREE.MeshBasicMaterial).opacity = this.seen ? 0.36 : 0.24;
    (this.fanEdge.material as THREE.LineBasicMaterial).color.setHex(col);
    const fp = this.fan.geometry.getAttribute('position') as THREE.BufferAttribute;
    const ep = this.fanEdge.geometry.getAttribute('position') as THREE.BufferAttribute;
    const half = (this.fov * Math.PI) / 180;
    const y = 0.07;
// … (276줄 더 — 전체는 라이브 견본 페이지의 「코드」 탭)
```

## 관련 기술
- 먼저 알면 좋은 기술: [캐릭터 조작기 (경사 · 계단 · 점프 · 벽 미끄러지기)](https://ai-techstudio.web.app/ai/t/i480.md) `i480`
- 다음에 해 볼 기술: [시야와 안개 (가 본 곳만 밝게)](https://ai-techstudio.web.app/ai/t/i487.md) `i487` · [몬테카를로 트리 탐색 (UCT)](https://ai-techstudio.web.app/ai/t/i347.md) `i347`
