# AI 꾸러미 — 포인터 끌기 · 3D 고르기 — Pointer Events + setPointerCapture
> 누른 화면 점에서 카메라 광선(Raycaster)을 쏘아 맞은 3D 말을 고르고, setPointerCapture 로 손가락이 판 밖에 나가도 끌기가 이어지게 한다.  
> 견본: https://ai-techstudio.web.app/#t/u79

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

## 주문서

### 만들어 줘: 포인터 끌기 · 3D 고르기 — Pointer Events + setPointerCapture

#### 1. 목표
3D 판 위 차 · 블록 밀기에서 3D 말 고르기 · 끌기를 만들어 줘 — pointerdown 에서 Raycaster 로 맞은 말을 고르고 setPointerCapture, pointermove 에서는 광선이 판 높이와 만나는 점으로 칸 좌표를 구해 옮기고, pointerup 에서 가까운 칸에 붙이기. 조작감은 칸에 착 붙는 퍼즐.

#### 2. 핵심 기술 용어
- **Pointer Events + setPointerCapture** — 포인터 이벤트 · 붙잡기 — 밖으로 나가도 move · up 을 계속 받음
- **THREE.Raycaster.setFromCamera** — 화면 점 → 카메라 광선
- **NDC (normalized device coordinates)** — 화면 좌표를 −1 ~ 1 로
- **Ray–plane intersection** — 광선이 판(바닥) 높이와 만나는 점

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

#### 4. 조건
- pointer 이벤트(pointerdown · move · up · cancel)로 마우스 · 터치를 함께 — touch / mouse 이벤트 따로 쓰지 않기
- pointerdown 에서 setPointerCapture, pointerId 로 같은 손가락만 받기
- 이미 끄는 손가락이 있으면 두 번째 손가락은 무시 (끌던 것이 칸 사이에 멈추지 않게)
- 광선 대상은 고를 메시 배열만 (외곽선 껍데기 · 장식 제외)
- 캔버스 CSS 에 touch-action: none (브라우저 스크롤 · 확대가 끌기를 가로채지 않게)

#### 5. 완성 기준 (이게 보이면 성공)
- 말을 누르면 광선이 그 말에 닿아 들리고, 끌면 손가락을 따라간다
- 끄는 도중 판 밖(캔버스 밖)으로 나갔다 돌아와도 끌기가 끊기지 않는다
- 놓으면 가장 가까운 칸에 착 붙는다
- 폰에서 두 손가락이 닿아도 끌던 말이 멈추지 않는다

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

## 원리
- 화면 점을 NDC 로: x = (clientX − left) / width × 2 − 1, y = −((clientY − top) / height × 2 − 1).
- raycaster.setFromCamera(ndc, camera) 후 intersectObjects(고를 것들) — 첫 번째가 가장 가까운 것.
- 끄는 동안은 물체 대신 판 높이(y = 0.4) 평면과 광선이 만나는 점을 쓴다: t = (0.4 − 원점.y) / 방향.y.
- pointerdown 에서 setPointerCapture(pointerId) 하면 손가락이 캔버스 밖으로 나가도 move · up 이 그 요소로 온다.
- pointerId 를 기억해 다른 손가락의 move · up 은 무시한다.

## 핵심 코드 — 광선으로 고르기 · 붙잡기 · 판 높이 평면으로 끌기
(발췌: src/game/games/parking/ParkingGame.ts onDown · onMove · onUp + parking/stage.ts setRay · groundCell 을 줄임)
```ts
const ray = new THREE.Raycaster();
const hits: THREE.Object3D[] = []; // 고를 메시만 (userData.car = 번호)

function setRay(clientX: number, clientY: number): void {
  const r = renderer.domElement.getBoundingClientRect();
  ray.setFromCamera(new THREE.Vector2(((clientX - r.left) / r.width) * 2 - 1, -((clientY - r.top) / r.height) * 2 + 1), camera);
}
function pick(clientX: number, clientY: number): number {
  setRay(clientX, clientY);
  const h = ray.intersectObjects(hits, false)[0];
  return h ? (h.object.userData['car'] as number) : -1;
}
/** 화면 점 → 판 위(y = 0.4) 점 */
function ground(clientX: number, clientY: number): THREE.Vector3 | null {
  setRay(clientX, clientY);
  const t = (0.4 - ray.ray.origin.y) / ray.ray.direction.y;
  return t > 0 ? ray.ray.at(t, new THREE.Vector3()) : null;
}

let drag: { car: number; id: number; grab: THREE.Vector3; start: THREE.Vector3 } | null = null;
const el = renderer.domElement;
el.style.touchAction = 'none';
el.addEventListener('pointerdown', (e) => {
  if (drag) return; // 이미 끄는 손가락이 있으면 무시
  const car = pick(e.clientX, e.clientY);
  const g = ground(e.clientX, e.clientY);
  if (car < 0 || !g) return;
  drag = { car, id: e.pointerId, grab: g, start: cars[car].position.clone() };
  el.setPointerCapture(e.pointerId); // 밖으로 나가도 move · up 을 받는다
});
el.addEventListener('pointermove', (e) => {
  if (!drag || drag.id !== e.pointerId) return;
  const g = ground(e.clientX, e.clientY);
  if (g) cars[drag.car].position.copy(drag.start).add(g.sub(drag.grab));
});
el.addEventListener('pointerup', (e) => {
  if (!drag || drag.id !== e.pointerId) return;
  snapToCell(drag.car); // 가까운 칸에 붙이기
  drag = null;
});
```

## 흔한 실수 · 확인 목록
- [ ] **두 번째 손가락이 끌기를 덮어쓰면 끌던 차가 칸 사이에 멈춘다** — 이미 끄는 중이면 다른 pointerId 는 무시한다 (빵빵 주차장에서 겪음).
- [ ] **setPointerCapture 없이 끌면 손가락이 캔버스 밖으로 나가는 순간 끌기가 끊긴다** — pointerdown 에서 붙잡는다 — up · cancel 때 저절로 풀린다.
- [ ] **합성 이벤트로만 시험하면 실제 손가락 · 마우스에서 생기는 버그를 놓친다** — 실제 클릭 · 끌기(Playwright mouse · 실제 폰)로 끝까지 풀어 본다.
- [ ] **touch-action 을 안 끄면 폰에서 끌 때 화면이 스크롤된다** — 캔버스에 touch-action: none.

## 완성 기준 체크리스트
- [ ] 말을 누르면 광선이 그 말에 닿아 들리고, 끌면 손가락을 따라간다
- [ ] 끄는 도중 판 밖(캔버스 밖)으로 나갔다 돌아와도 끌기가 끊기지 않는다
- [ ] 놓으면 가장 가까운 칸에 착 붙는다
- [ ] 폰에서 두 손가락이 닿아도 끌던 말이 멈추지 않는다

## 이 기술 정보
- id: `u79` · 분류: 게임 시스템 · AI › 입력 · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 누를 때 광선 한 번 · 끌 때 평면 계산 한 번. 고를 대상만 배열로 넘기면 빠르다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u79
- 쓰면 좋을 때: 3D 판 위 물체를 누르고 끄는 모든 게임 / 판 밖으로 손이 나가도 끌기가 끊기면 안 될 때
- 쓰지 말 때: 물체가 아주 많고 삼각형이 많은 모델 — 대신 보이지 않는 단순 상자로 고르거나 BVH(i67) / 2D 캔버스 게임 — 광선 없이 좌표 계산으로

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### u79 견본 항목 — `src/demos/demosSystem.ts:997`
```ts
  u79: {
    kind: '2d',
    caption: '카메라에서 손가락 쪽으로 광선(Raycaster)을 쏘아 맞은 말을 고르고, 판 밖으로 나가도 끌기가 이어져요(setPointerCapture)',
    make() {
      const P = 6;
      const iso = (i: number, j: number): [number, number] => [170 + (i - j) * 22, 64 + (i + j) * 11];
      return {
        draw(g, w, h, t) {
          stage(g, w, h, SKY);
          const k = (t % P) / P;
          // 판 영역 (캔버스)
          box(g, 72, 40, 200, 140, 10, 'rgba(255,255,255,0.45)', '#7ab0e0', 1.5);
          txt(g, '게임 화면', 254, 50, 7.5, '#5a86b8', 'center', 800);
          for (let i = 0; i < 4; i++)
            for (let j = 0; j < 4; j++) {
              const [x, y] = iso(i, j);
              g.beginPath();
              g.moveTo(x, y - 11);
              g.lineTo(x + 22, y);
              g.lineTo(x, y + 11);
              g.lineTo(x - 22, y);
              g.closePath();
              g.fillStyle = (i + j) % 2 ? '#f4d9a8' : '#e6bf82';
              g.fill();
              g.strokeStyle = '#c9985a';
              g.lineWidth = 1;
              g.stroke();
            }
          // 말 위치: 0~0.25 다가감, 0.25~0.75 끌기, 0.75~ 놓음
          const from = iso(0.5, 1.5);
          const to = iso(2.5, 2.5);
          const outPt: [number, number] = [292, 120];
          let cur: [number, number];
          let piece: [number, number] = from;
          const dragK = clamp((k - 0.28) / 0.5);
          if (k < 0.25) cur = [lerp(30, from[0], ease(k / 0.25)), lerp(180, from[1] - 14, ease(k / 0.25))];
          else if (k < 0.78) {
            // 판 밖으로 한 번 나갔다가 돌아옴
            const p = dragK;
            const mid: [number, number] = p < 0.5 ? [lerp(from[0], outPt[0], ease(p * 2)), lerp(from[1], outPt[1], ease(p * 2))] : [lerp(outPt[0], to[0], ease(p * 2 - 1)), lerp(outPt[1], to[1], ease(p * 2 - 1))];
            cur = [mid[0], mid[1] - 14];
            piece = [clamp(mid[0], 120, 230), clamp(mid[1], 70, 150)];
          } else {
            cur = [to[0], to[1] - 14 - (k - 0.78) * 60];
            piece = to;
          }
          const held = k > 0.25 && k < 0.78;
          // 카메라와 광선
          box(g, 10, 14, 34, 22, 5, '#3a4266');
          circle(g, 34, 25, 7, '#7ab0ff', '#fff', 1.5);
          txt(g, '카메라', 27, 44, 8, '#3a4266', 'center', 800);
          if (k > 0.2 && k < 0.8) {
            g.globalAlpha = 0.9;
            line(g, 40, 26, piece[0], piece[1] - 8, '#ff5a8a', 2, [5, 3]);
            g.globalAlpha = 1;
            circle(g, piece[0], piece[1] - 8, 4, '#ff5a8a');
          }
          // 말
          circle(g, piece[0], piece[1] + 2, 12, 'rgba(0,0,0,0.15)');
          const lift = held ? 6 : 0;
          circle(g, piece[0], piece[1] - 6 - lift, 12, held ? '#ffd23f' : '#ff7a6b', '#a0402a', 2);
          face(g, piece[0], piece[1] - 6 - lift, 9);
          // 손가락
          g.save();
          g.translate(cur[0], cur[1]);
          if (held) circle(g, 0, 0, 10 + Math.sin(t * 10) * 2, 'rgba(255,90,138,0.25)');
          box(g, -4, 0, 8, 16, 4, '#ffd9c0', '#c9895a', 1.2);
          box(g, -7, 10, 14, 14, 5, '#ffd9c0', '#c9895a', 1.2);
          g.restore();
          if (held && cur[0] > 272) pill(g, '판 밖이어도 계속 잡힘', 250, 182, '#ff5a8a', '#fff', 8);
          else pill(g, held ? '끌기 중 · pointer capture' : k < 0.25 ? '광선으로 고르기' : '놓기 → 칸에 착', 172, 190, '#3a4f8c', '#fff', 8);
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [손 떨림 걸러내기 (누름 vs 끌기 문턱)](https://ai-techstudio.web.app/ai/t/i425.md) `i425`
- 다음에 해 볼 기술: [BVH 빠른 고르기 (three-mesh-bvh)](https://ai-techstudio.web.app/ai/t/i67.md) `i67` · [끌기 축 잠금](https://ai-techstudio.web.app/ai/t/i419.md) `i419`
- 참고 문서: [three.js 문서 — Raycaster](https://threejs.org/docs/#api/en/core/Raycaster) · [MDN — Element.setPointerCapture()](https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture)
