# AI 꾸러미 — 전투 판정 (휘두르기 · 히트스톱 · 넉백) — Swept hit detection
> 칼이 지난 프레임부터 지금까지 쓸고 간 부채꼴로 맞음을 판정하고, 맞는 순간 잠깐 멈춤 · 번쩍 · 밀려남으로 손맛을 준다.  
> 견본: https://ai-techstudio.web.app/#t/i484

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

## 주문서

### 만들어 줘: 전투 판정 (휘두르기 · 히트스톱 · 넉백) — Swept hit detection

#### 1. 목표
쿼터뷰 액션의 칼 휘두르기에 근접 공격 판정을 만들어 줘 — 무기가 쓸고 간 부채꼴로 맞음을 재고, 맞으면 히트스톱 · 번쩍 · 넉백 · 데미지 숫자. 분위기는 통쾌한 액션.

#### 2. 핵심 기술 용어
- **Swept hit detection** — 쓸기 판정 — 이전 자세 ~ 지금 자세 사이 전체로 판정
- **Hitstop (hit pause)** — 맞는 순간 잠깐 멈춤
- **Knockback** — 맞고 밀려남 (속도 + 지수 감쇠)
- **Damage popup · screen shake** — 데미지 숫자 · 화면 흔들림

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

#### 4. 조건
- 판정은 지금 자세 하나가 아니라 이전 프레임 ~ 지금 사이 쓸기로 (프레임이 떨어져도 안 빠지게)
- 히트스톱은 전체 게임 시간이 아니라 때린 쪽 · 맞은 쪽의 시간만 멈추기 (UI · 다른 적은 계속)
- 넉백 · 흔들림은 dt 기반 지수 감쇠 — 60 · 120Hz 화면에서 같은 결과
- 판정 넓이(쓸기 부채꼴)를 눈으로 볼 수 있는 보기 단추

#### 5. 완성 기준 (이게 보이면 성공)
- 왼쪽(숫자만) · 오른쪽(히트스톱 · 번쩍 · 넉백 · 흔들림)을 나란히 두면, 오른쪽이 확실히 「세게 맞은」 느낌
- 휘두를 때 부채꼴 판정 영역이 보이고, 겹치는 순간 빨갛게 바뀐다
- 「히트스톱 (초)」 슬라이더 0 ~ 0.25 로 느낌 차이를 비교할 수 있다
- 프레임을 일부러 떨어뜨려도(30fps) 맞음이 빠지지 않는다

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

## 원리
- 빠른 칼은 한 프레임에 크게 돌아서, 지금 자세만 검사하면 적을 그냥 지나친다 (터널링).
- 그래서 지난 프레임 각도 ~ 지금 각도 사이 부채꼴 전체와 적의 원이 겹치는지 본다.
- 한 번 휘두를 때 한 번만 맞게 hit 표시를 둔다.
- 맞는 순간 때린 쪽 · 맞은 쪽의 시간을 50~100ms 멈추면(히트스톱) 「묵직하게 맞았다」고 느낀다.
- 넉백은 속도를 주고 매 프레임 지수로 줄인다 (v *= exp(−k·dt)) — 프레임 수와 상관없이 같은 거리.

## 핵심 코드 — 쓸기 판정 + 히트스톱 + 넉백 (한 프레임)
(발췌: demos/demosGame3dB.ts sweepHits() · demoCombat() update 를 정리)
```ts
const BLADE0 = 0.35, BLADE1 = 1.4; // 칼날이 닿는 거리 (손잡이 ~ 끝)

/** 쓸기 부채꼴 [prev, cur] 가 적의 원(거리 d · 각 phi · 반지름 r)과 겹치나 */
function sweepHits(cur: number, prev: number, d: number, phi: number, r: number): boolean {
  if (d - r > BLADE1 || d + r < BLADE0) return false;
  const del = Math.asin(Math.min(1, r / Math.max(d, 1e-3))); // 원이 차지하는 반각
  return phi + del >= Math.min(cur, prev) && phi - del <= Math.max(cur, prev);
}

let stop = 0, hit = false, kb = 0, kbv = 0, prevYaw = REST;
function update(dt: number, yaw: number, swinging: boolean, enemy: { d: number; phi: number }) {
  const dtl = stop > 0 ? 0 : dt;          // 히트스톱 동안 이 싸움의 시간만 멈춘다
  if (stop > 0) stop -= dt;
  if (swinging && !hit && dtl > 0 && sweepHits(yaw, prevYaw, enemy.d, enemy.phi, 0.42)) {
    hit = true;                            // 한 번 휘두를 때 한 번만
    stop = 0.06;                           // 60ms 멈춤
    kbv = 5.2;                             // 넉백 속도
    flash = 1; shake = 1; squash = 1;      // 번쩍 · 흔들림 · 찌그러짐 (각자 dt 로 줄어듦)
    showDamage(18 + Math.floor(Math.random() * 30));
  }
  kb += kbv * dtl;
  kbv *= Math.exp(-dtl * 7.5);             // 지수 감쇠 — 화면 주사율과 상관없이 같은 거리
  if (dtl > 0) prevYaw = yaw;
  if (!swinging) hit = false;
}
```

## 흔한 실수 · 확인 목록
- [ ] **지금 자세만 검사하면 빠른 칼이 적을 통과한다** — 이전 각 ~ 지금 각 부채꼴로 쓸기. 견본은 그 부채꼴을 셰이더로 바닥에 그려 보여 준다.
- [ ] **Time.timeScale 같은 전체 멈춤으로 히트스톱을 하면 UI · 소리까지 멈춘다** — 때린 쪽 · 맞은 쪽의 시간(dtl)만 0 으로. 견본은 두 줄이 각자 멈춘다.
- [ ] **각도가 ±π 를 넘어가면 부채꼴이 뒤집힌다** — 휘두르는 동안 각도를 감지 말고(unwrap) 이어지게 쓰거나, 비교 전에 같은 구간으로 맞춘다.
- [ ] **넉백을 「프레임마다 0.9 배」로 줄이면 120Hz 화면에서 덜 밀린다** — exp(−k·dt) 로 줄인다.
- [ ] **히트스톱이 너무 길면 끊겨 보인다** — 보통 0.04 ~ 0.1초. 큰 공격만 길게.

## 완성 기준 체크리스트
- [ ] 왼쪽(숫자만) · 오른쪽(히트스톱 · 번쩍 · 넉백 · 흔들림)을 나란히 두면, 오른쪽이 확실히 「세게 맞은」 느낌
- [ ] 휘두를 때 부채꼴 판정 영역이 보이고, 겹치는 순간 빨갛게 바뀐다
- [ ] 「히트스톱 (초)」 슬라이더 0 ~ 0.25 로 느낌 차이를 비교할 수 있다
- [ ] 프레임을 일부러 떨어뜨려도(30fps) 맞음이 빠지지 않는다

## 이 기술 정보
- id: `i484` · 분류: 게임 시스템 · AI › 3D 게임 기본기 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 적 하나에 각도 비교 몇 번. 데미지 숫자는 캔버스 텍스처를 돌려 쓰면 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i484
- 쓰면 좋을 때: 칼 · 망치 · 주먹처럼 휘두르는 근접 공격 / 맞았는지 잘 모르겠다는 말을 들을 때 — 손맛(주스)을 더할 때
- 쓰지 말 때: 총알처럼 빠른 직선 투사체 — 광선(레이) 쓸기가 맞다 / 턴제 · 퍼즐 — 히트스톱은 리듬을 깬다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### sweepHits — `src/demos/demosGame3dB.ts:888`
```ts
function sweepHits(cur: number, prev: number, d: number, phi: number, rr: number): boolean {
  if (d - rr > BLADE1 || d + rr < BLADE0) return false;
  const del = Math.asin(Math.min(1, rr / Math.max(d, 1e-3)));
  return phi + del >= Math.min(cur, prev) && phi - del <= Math.max(cur, prev);
}
```

### demoCombat — `src/demos/demosGame3dB.ts:1060`
```ts
function demoCombat(): Scene3D {
  const st = makeStage({ w: 18, d: 11, wallH: 2.6, torches: [[-4.2, 9], [0, 9], [4.2, 9]], cam: [-0.2, 5.0, 6.0], look: [-0.2, 0.55, -0.45], fov: 38, shadow: 6 });
  const { scene, cam, bin } = st;
  const painter = new Painter(0x0b090e);
  const R = rng(484);
  const glowTex = bin.add(texOf('glow', 64, 64, paintGlow));
  const starTex = bin.add(texOf('star', 128, 128, paintStar));
  const ringG = bin.add(new THREE.RingGeometry(BLADE0, BLADE1, 56, 1, SW_A1, SW_A0 - SW_A1));
  const hurtG = bin.add(new THREE.RingGeometry(0.42, 0.5, 40));
  const opt = { compare: true, showSweep: true, stopLen: 0.06, knock: 1 };
  const pose: Pose = { yaw: REST, tilt: -0.7, lean: 0 };
  const lanes: Lane[] = [];
  const mkLane = (juice: boolean): Lane => {
    const g = new THREE.Group();
    scene.add(g);
    const knight = makeKnight(bin, juice ? 0x2f5fb8 : 0x6a6f7c, juice ? 0xd8313b : 0x8a8f98);
    knight.g.position.set(-0.85, 0, 0);
    g.add(knight.g);
    const slime = makeSlime(bin, juice ? 0x62cc74 : 0x7fae86);
    slime.g.position.set(0.75, 0, 0);
    g.add(slime.g);
    const ribbon = new Ribbon(bin, juice ? new THREE.Color(1, 0.98, 0.9) : new THREE.Color(0.75, 0.75, 0.75), juice ? new THREE.Color(0.3, 0.62, 1.0) : new THREE.Color(0.35, 0.35, 0.4));
    scene.add(ribbon.mesh);
    const sweepU = { uCur: { value: SW_A0 }, uPrev: { value: SW_A0 }, uStart: { value: SW_A0 }, uOn: { value: 1 }, uHit: { value: 0 } };
    const sweep = new THREE.Mesh(ringG, bin.add(new THREE.ShaderMaterial({ uniforms: sweepU, vertexShader: SWEEP_VS, fragmentShader: SWEEP_FS, transparent: true, depthWrite: false, side: THREE.DoubleSide })));
    sweep.rotation.x = -Math.PI / 2;
    sweep.position.copy(knight.g.position).add(PIVOT);
    sweep.position.y = 0.04;
    sweep.renderOrder = 3;
    g.add(sweep);
    const hurtMat = bin.add(new THREE.MeshBasicMaterial({ color: 0xffd34a, transparent: true, opacity: 0.8, depthWrite: false }));
    const hurt = new THREE.Mesh(hurtG, hurtMat);
    hurt.rotation.x = -Math.PI / 2;
    hurt.position.y = 0.035;
    slime.g.add(hurt);
    const pop = new Pop(bin);
    scene.add(pop.spr);
    const sparks = new Sparks(bin, glowTex);
    scene.add(sparks.pts);
    const star = new THREE.Sprite(bin.add(new THREE.SpriteMaterial({ map: starTex, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, toneMapped: false, opacity: 0 })));
    star.renderOrder = 8;
    scene.add(star);
    return { g, juice, knight, slime, ribbon, sweep, sweepU, hurt, hurtMat, pop, sparks, star, starAge: 9, lastCyc: -1, stopAcc: 0, stop: 0, lastC: 0, prevYaw: REST, hit: false, kb: 0, kbv: 0, squash: 0, flash: 0, shake: 0, hitGlow: 0 };
  };
  const plain = mkLane(false);
  const juicy = mkLane(true);
  lanes.push(plain, juicy);
  const layout = (): void => {
    plain.g.visible = opt.compare;
    plain.ribbon.mesh.visible = opt.compare;
    plain.g.position.x = -2.45;
    juicy.g.position.x = opt.compare ? 0.95 : -0.35;
    hud.dirty = true;
  };
  const hud = new Hud((g, w, h, u) => {
    const s = 11 * u;
    if (opt.compare) {
      tag(g, '효과 끔 — 숫자만', w * 0.2, 16 * u, s, '#c9ccd4', 'center');
      tag(g, '효과 켬 — 멈춤 · 번쩍 · 밀림', w * 0.66, 16 * u, s, '#ffd27a', 'center');
    } else tag(g, `히트스톱 ${opt.stopLen.toFixed(2)}초 · 번쩍 · 넉백 · 흔들림`, w / 2, 16 * u, s, '#ffd27a', 'center');
    if (opt.showSweep) tag(g, '파랑 부채꼴 = 칼이 쓸고 간 판정 영역', 8 * u, h - 14 * u, 9.5 * u, '#9fdcff');
  });
  layout();
  const tmp = new THREE.Vector3();
  const pv = new THREE.Vector3();
  const update = (t: number, dt: number): void => {
    st.tick(t);
    let camShake = 0;
    for (const L of lanes) {
      const dtl = L.stop > 0 ? 0 : dt;
      if (L.stop > 0) L.stop -= dt;
      // 두 줄을 같은 박자로: 이번 판에서 멈춘 만큼만 늦는다 (판이 바뀌면 다시 맞춘다)
      const cyc = Math.floor(t / 2.4);
      if (cyc !== L.lastCyc) {
        L.lastCyc = cyc;
        L.stopAcc = 0;
        L.hit = false;
        L.prevYaw = REST;
      }
      L.stopAcc += dt - dtl;
      const c = Math.max(0, t - cyc * 2.4 - L.stopAcc);
      L.lastC = c;
      swingPose(c, pose);
      applyPose(L.knight, pose);
      const yaw = pose.yaw;
      // 슬라임: 넉백 · 되돌아오기 · 숨쉬기
      L.kb += L.kbv * dtl;
      L.kbv *= Math.exp(-dtl * 7.5);
      if (c > 1.3) L.kb -= L.kb * Math.min(1, dtl * 2.6);
      L.squash = Math.max(0, L.squash - dtl * 5);
      const breath = Math.sin(t * 3.2 + (L.juice ? 0 : 1)) * 0.04;
      const hop = c > 1.3 && L.kb > 0.08 ? Math.abs(Math.sin(c * 12)) * 0.12 : 0;
      L.slime.g.position.set(0.75 + L.kb, hop, 0);
      const sq = L.squash;
      L.slime.blob.scale.set(1 + 0.3 * sq - breath * 0.5, 1 - 0.35 * sq + breath, 1 + 0.3 * sq - breath * 0.5);
      // 맞음 판정 (휘두르는 동안만)
      L.g.updateMatrixWorld(true);
      L.knight.g.localToWorld(pv.copy(PIVOT));
      L.slime.g.getWorldPosition(tmp);
      const dx = tmp.x - pv.x;
      const dz = tmp.z - pv.z;
      const d = Math.hypot(dx, dz);
      const phi = Math.atan2(-dz, dx);
      const swinging = c >= SW_T0 && c <= SW_T1 + 0.01;
      if (swinging && !L.hit && dtl > 0 && sweepHits(yaw, L.prevYaw, d, phi, 0.42)) {
        L.hit = true;
        const n = 18 + Math.floor(R() * 30);
        L.pop.show(n, L.juice, tmp.x, 1.25, tmp.z + 0.1);
        L.hitGlow = 1;
        if (L.juice) {
          L.stop = opt.stopLen;
          L.flash = 1;
          L.kbv = 5.2 * opt.knock;
          L.squash = 1;
          L.shake = 1;
          L.sparks.burst(tmp.x - 0.3, 0.6, tmp.z + 0.05, R);
          L.star.position.set(tmp.x - 0.32, 0.62, tmp.z + 0.1);
          L.starAge = 0;
        }
      }
      // 쓸기 볼륨 보기
      const u = L.sweepU;
      u.uOn!.value = opt.showSweep ? 1 : 0;
      L.hurt.visible = opt.showSweep;
      if (swinging || (c > SW_T1 && c < SW_T1 + 0.25)) {
        u.uCur!.value = yaw;
        u.uPrev!.value = swinging ? L.prevYaw : yaw;
        u.uStart!.value = SW_A0;
      } else {
        u.uCur!.value = u.uPrev!.value = u.uStart!.value = SW_A0 + 1;
      }
      L.hitGlow = Math.max(0, L.hitGlow - dt * 3);
      u.uHit!.value = L.hitGlow;
      L.hurtMat.color.setHex(L.hitGlow > 0.05 ? 0xff4a3a : 0xffd34a);
      if (dtl > 0) L.prevYaw = yaw;
      // 궤적 리본: 머리 = 지금 각, 꼬리 = 조금 전 각
      if (c >= SW_T0 && c < SW_T1 + TRAIL + 0.02) {
        const tail = swingPose(Math.max(SW_T0, Math.min(c, SW_T1) - TRAIL), { yaw: 0, tilt: 0, lean: 0 }).yaw;
        const head = c > SW_T1 ? lerp(yaw, tail, 0) : yaw;
        const fade = c > SW_T1 ? 1 - (c - SW_T1) / (TRAIL + 0.02) : 1;
        const tl = c > SW_T1 ? lerp(tail, head, clamp((c - SW_T1) / TRAIL, 0, 1)) : tail;
        L.ribbon.sweep(L.knight.body, head, tl, fade * (L.juice ? 1 : 0.6));
      } else L.ribbon.mat.uniforms.uA!.value = 0;
      // 번쩍 (진짜 시간) · 숫자 · 불꽃
      L.flash = Math.max(0, L.flash - dt / 0.13);
      for (const m of L.slime.flashMats) m.emissive.setScalar(L.flash * 1.6);
      L.pop.tick(dt);
      L.sparks.tick(dt);
      L.starAge += dt;
      const sa = L.starAge;
      L.star.material.opacity = sa < 0.16 ? 1 - sa / 0.16 : 0;
      const ss = 0.5 + easeOut(sa / 0.16) * 1.3;
      L.star.scale.set(ss, ss, 1);
      L.shake = Math.max(0, L.shake - dt / 0.22);
      if (L.juice) camShake = L.shake;
    }
    // 흔들림 — 비교 중엔 켠 쪽 무대만, 혼자면 카메라
    const sx = Math.sin(t * 91) * 0.07 * camShake;
    const sy = Math.cos(t * 77) * 0.05 * camShake;
    if (opt.compare) {
      juicy.g.position.y = sy;
      juicy.g.position.z = sx * 0.5;
      cam.position.set(-0.2, 5.0, 6.0);
      cam.lookAt(-0.2, 0.55, -0.45);
    } else {
      juicy.g.position.y = 0;
      juicy.g.position.z = 0;
      cam.position.set(0.1 + sx, 4.6 + sy, 5.6);
      cam.lookAt(0.1 + sx * 0.6, 0.75 + sy * 0.6, 0);
    }
  };
  const controls: Control[] = [
    { type: 'toggle', label: '나란히 비교 (끔 / 켬)', value: true, on: (v) => ((opt.compare = v), layout()) },
    { type: 'toggle', label: '판정 영역(쓸기 부채꼴) 보기', value: true, on: (v) => ((opt.showSweep = v), (hud.dirty = true)) },
    { type: 'range', label: '히트스톱 (초)', min: 0, max: 0.25, step: 0.01, value: 0.06, on: (v) => ((opt.stopLen = v), (hud.dirty = true)) },
    { type: 'range', label: '넉백 세기', min: 0, max: 2.5, step: 0.1, value: 1, on: (v) => (opt.knock = v) },
  ];
  return {
    scene,
    camera: cam,
    update,
    render: (r) => painter.draw(r, scene, cam, hud),
    controls,
    dispose: () => {
      bin.dispose();
      hud.dispose();
    },
  };
}
```

## 관련 기술
- 먼저 알면 좋은 기술: [캐릭터 조작기 (경사 · 계단 · 점프 · 벽 미끄러지기)](https://ai-techstudio.web.app/ai/t/i480.md) `i480` · [만화 연출 층 (DOM)](https://ai-techstudio.web.app/ai/t/u38.md) `u38`
- 다음에 해 볼 기술: [던전 스킬 연출 (마법 · 피격)](https://ai-techstudio.web.app/ai/t/i490.md) `i490` · [카메라가 벽을 뚫지 않게](https://ai-techstudio.web.app/ai/t/i481.md) `i481`
