| name | verify |
| description | 3D 갤러리 앱을 실행하고 브라우저로 전체 관람 플로우(입장→이동→감상→복귀)를 검증하는 레시피 |
3D 갤러리 검증 레시피
실행
npm run dev
Chrome DevTools MCP로 접속.
인터랙션 모델 (검증 기준)
- 최초 입장은 로비: 포스터 월(슬롯 4개)의 전시 포스터를 조준·클릭하면 0.4s 검정 페이드 후 전시장으로. 전시장 뒷벽 EXIT 문 클릭 → 로비 복귀. 전환 내내 포인터 락 유지, 전환 중 입력은 무시된다.
- 포인터 락이 곧 "관람 중": 입장 클릭으로 락 획득 → 걷기·감상 내내 락 유지(커서 없음).
- 클릭 = 조준 대상 kind별 — artwork: 감상 진입('클릭하여 감상'), poster: 전시 입장('클릭하여 「제목」 입장'), exit: 로비 복귀('클릭하여 로비로 나가기'). 감상 중 클릭 = 감상 종료(락 유지, 즉시 걷기).
- ESC = 순수 일시정지 (어디서든 동일): 락이 풀리면 모드(걷기/감상/집중보기)는 전혀 바뀌지 않고 blur 대기 화면(zIndex 20)이 전부를 덮는다. 재입장 클릭 = 이전 상태 그대로 재개 — 감상 중이었으면 감상 화면으로, 집중 보기였으면 집중 보기로 돌아온다. 락 해제는 어떤 모드 전환도 일으키지 않는다 (src/store.test.ts의 불변식 테스트로 고정됨).
- F(집중 보기 토글)는 감상 + 락 상태에서만 동작. 일시정지(언락) 중 키 입력은 모두 무시된다.
- 입장 전/일시정지 중에는 WASD 이동·회전이 차단된다.
- 정보 카드는 표시 전용(닫기 버튼 없음, pointerEvents none).
자동화 환경의 함정 (필독)
-
콘솔에서 import('/src/store.ts')는 앱과 다른 모듈 인스턴스를 만든다 — HMR을 거친 앱 그래프는 ?t=타임스탬프가 붙은 URL을 쓰기 때문. 앱 스토어를 진단하려면 변환된 소스에서 실제 URL을 추출해 import하라: const url = (await fetch('/src/ui/Hud.tsx').then(r=>r.text())).match(/"([^"]*store\.ts[^"]*)"/)[1]. 이걸 모르면 "스토어가 안 바뀐다"는 가짜 진단이 나온다.
-
initScript에서 document.documentElement에 MutationObserver를 붙이면 조용히 실패한다 (그 시점엔 documentElement가 없음). DOMContentLoaded 이후 document.body에 붙일 것.
-
로딩 진행률 검증: 로컬 자산은 순식간이라 문구가 안 보인다. emulate로 Slow 3G + exhibitions.json 포스터를 임시로 큰 원격 URL(picsum)로 바꿔 검증 후 원복.
-
포인터 락 요청은 브라우저 창이 OS 포커스를 가질 때만 성공한다. CDP 클릭이 락을 못 걸고 콘솔에 Unable to use Pointer Lock API가 찍히면 select_page를 bringToFront: true로 호출한 뒤 다시 클릭하라. 그래도 불안정하면 아래 락 스텁을 써라.
-
스크린샷 캡처는 실제 포인터 락을 강제로 풀어버린다 (일시정지 트리거와 동일). 실제 락으로 테스트할 때는 조작→상태확인(evaluate)→스크린샷 순서를 지켜라.
-
락 스텁: 앱 상태 머신만 검증할 때는 실제 락 대신 주입한다. 스크린샷이 락을 깨는 문제도 사라진다.
let fakeLock = null
Object.defineProperty(Document.prototype, 'pointerLockElement', { configurable: true, get: () => fakeLock })
window.__setLock = (on) => { fakeLock = on ? document.querySelector('canvas') : null; document.dispatchEvent(new Event('pointerlockchange')) }
-
키 입력: window.dispatchEvent(new KeyboardEvent('keydown', {code:'KeyW'})) 후 setTimeout으로 홀드, keyup으로 종료. 이동 속도 3m/s. 락(또는 스텁)이 켜져 있어야 이동한다.
-
감상 진입/종료 클릭: canvas.dispatchEvent(new MouseEvent('click', {bubbles:true})) — isTrusted 체크 없음.
-
캔버스 CDP 클릭: 캔버스는 a11y 트리에 없으므로 canvas.setAttribute('role','button') + aria-label 후 snapshot→click.
상태 확인 스니펫 (evaluate_script)
- 카메라 위치/회전 읽기: R3F 내부는 콘솔에서 접근 불가(
canvas.__r3f 없음). Player가 포인터 모드에서 매 프레임 Raycaster.setFromCamera(coords, camera)를 호출하므로, 앱과 같은 three 모듈 인스턴스의 프로토타입을 훅하면 카메라를 얻는다 (점프 y 궤적·yaw 측정에 사용; 터치 모드는 탭 시점에만 레이캐스트하므로 매 프레임 샘플이 없다):
const threeUrl = (await fetch('/src/scene/Player.tsx').then(r=>r.text())).match(/import \* as THREE from "([^"]+)"/)[1]
const THREE = await import(threeUrl)
const orig = THREE.Raycaster.prototype.setFromCamera
THREE.Raycaster.prototype.setFromCamera = function (c, cam) { window.__cam = cam; return orig.call(this, c, cam) }
- 입장/일시정지 화면: p 텍스트 '클릭하여 입장' 존재
- 조준 힌트(상단 알약): div 텍스트 '클릭하여 감상' 존재
- 감상 패널 열림: h2 제목 존재
- 락:
document.pointerLockElement !== null (스텁 사용 시 스텁 값)
회전 게이트 회귀 테스트 (ESC 버그 재발 방지 — 필수 실행)
합성 mousemove는 Chrome에서 movementX를 지원하므로 회전을 실제로 검증할 수 있다:
const moveMouse = (dx) => document.dispatchEvent(new MouseEvent('mousemove', { movementX: dx, movementY: 0, bubbles: true }))
파랑새 앞에서 조준 힌트가 켜진 상태를 기준으로:
- 락 on + 회전(
moveMouse(40) ×20) → 힌트 꺼짐 (회전 동작 확인)
- 락 off(일시정지) + 회전 → 락 재획득 후 힌트가 그대로 켜져 있어야 함 (일시정지 중 회전 차단)
- 감상 진입 → 클릭 복귀 → 회전 → 힌트 꺼짐 (복귀 후 회전 동작)
- 감상 진입 → 락 off(ESC 일시정지) → 락 on(재개 — 감상 화면 그대로여야 함) → 클릭(감상 종료) → 회전 → 힌트 꺼짐 (일시정지 재개 후 회전 동작 — 과거 버그 계열)
하나라도 실패하면 락 상태가 어딘가에 복제/캐시되고 있다는 뜻이다. CLAUDE.md 금지 목록 참조.
핵심 플로우 (재현 좌표)
- 로비 (최초 스폰 (0, 1.6, 7), -z 방향): 락 on → A 0.5초 + W 1.6초(x≈-1.5, z는 2에서 클램프) → '클릭하여 「만화적 순간들」 살펴보기' → 클릭(프리뷰) → 클릭(입장) → 1.4초 대기(페이드 왕복) → 전시장 스폰. 포스터 슬롯은 중앙에서 바깥으로 좌우 번갈아 채워진다(전시 2개면 x=∓1.5). 복귀는 전시장 스폰 근처에서 180° 회전(뒷벽 EXIT 문, 거리 ~1.5m) → '클릭하여 로비로 나가기' → 클릭.
- 전시장 스폰 (0, 1.6, 4.5), -z 방향. 입장 전 W 입력은 무시되어야 함.
- 락 on → 입장 화면 사라짐 → D 0.83초(x≈2.5) → W 2.33초(z≈-2.5) → 상단에 '클릭하여 감상'.
- 클릭 → 감상: 락 유지, 패널 열림(닫기 버튼 없어야 함), 그림은 패널 왼쪽 영역 중앙.
- 클릭 → 복귀: 락 유지, 패널 닫힘, 조준 힌트 재표시, 오버레이 없음.
- 감상 중 락 해제(ESC 시뮬레이션 = 스텁 off) → 모드 유지 + 즉시 blur 대기화면 (정보 패널이 blur 아래 그대로 보임). 스텁 on = 재개 → 감상 화면 그대로. 감상 종료는 오직 락 유지 중 클릭.
- 감상 종료(returning) 카메라 도착은 damp 특성상 최대 ~1.5초 — 검증 대기는 2초 이상 줘라.
- 벽 클램프: W 3초 이상 → 화면이 균일한 벽면으로 가득 차면 정상.
터치 모드 검증 (모바일)
- 강제 활성화: 리로드 전 initScript로 matchMedia 스텁 —
window.matchMedia = (q) => q === '(pointer: coarse)' ? { matches: true, media: q, addEventListener(){}, removeEventListener(){}, ... } : orig(q). inputMode는 마운트 시 1회 판정.
- 대기화면 '탭하여 입장' → 입장은 canvas에 click dispatch. 터치에는 포인터 락·ESC 일시정지·크로스헤어가 없다. 걷기 중에만 조이스틱 표시.
- 입력은 TouchControls 레이어(
style.touchAction === 'none' && style.inset === '0px'인 div)에 합성 PointerEvent(pointerType 'touch') dispatch:
- 조이스틱: 중심 (80, innerHeight-80). down → move로 오프셋(±56px = 풀틸트 3m/s, 유지되는 동안 계속 이동) → up으로 정지.
- 시점 드래그: 조이스틱 밖에서 down → move(감도 0.005 rad/px) → 400ms 후 up (탭 판정 10px/300ms 회피).
- 탭: down → 80ms → up (이동 없이) → 그 지점 레이캐스트로 상호작용 (작품=감상, 포스터=입장, EXIT=로비).
- 감상 중: 배경 탭(canvas click dispatch) = 관람 복귀. [크게 보기]/[닫기] 버튼은 element.click() — stopPropagation이 있어 배경 탭으로 번지지 않아야 정상.
- 함정: 합성 pointerdown에서는 setPointerCapture가 실패한다(try/catch로 무해 처리됨 — 콘솔에 NotFoundError가 보이면 회귀). pointerId는 실제 마우스(보통 1)와 겹치지 않게 10 이상 사용.
알려진 무해한 로그
THREE.Clock: This module has been deprecated — R3F 내부, 무시.
- troika 폰트 GPOS/GSUB debug 로그 — 무시.
- drei Text가 jsdelivr CDN에서 폰트를 받아온다 — 오프라인이면 라벨 텍스트만 안 보임.