import { useRef, RefObject } from 'react'; import { useFrame, useThree } from '@react-three/fiber'; import { useXR } from '@react-three/xr'; import * as THREE from 'three'; import { getAllModelLocalGroups } from '@/lib/modelTransforms'; /** * Quest 3 thumbstick locomotion (rig-relative). * * Mapeamento (thumbstick = axes[2]/axes[3] em xr-standard): * • Para frente (axes[1] < -0.7) → exibe mira no modelo em tempo real. * Ao soltar (axis volta a ~0) → teletransporta para a mira. * Se não houver modelo atingido pelo olhar, projeta mira a 0.5m. * • Para trás (axes[1] > 0.7) → passo de 0.3 m oposto ao olhar. * Mapeado diretamente para facilitar pequenos ajustes. * • Lateral (|axes[0]| > 0.7) → snap-rotate ±30° no eixo Y do rig. * * NÃO lê grip ou trigger — esses são responsabilidade exclusiva de * XRGrabbable e XRControllerMeasure, respectivamente. */ interface Props { /** Ref para o que envolve ; sua pose move o usuário. */ rigRef: RefObject; } const DEAD = 0.7; const RELEASE = 0.3; const SNAP_DEG = 30; const STEP_BACK = 0.3; const STEP_FWD_NO_HIT = 0.5; export function ControllerLocomotion({ rigRef }: Props) { const { camera } = useThree(); const session = useXR((s) => s.session); const fwdPushed = useRef(false); const backLatched = useRef(false); const rotLatched = useRef<0 | -1 | 1>(0); const raycaster = useRef(new THREE.Raycaster()); const reticleRef = useRef(null); useFrame(() => { const rig = rigRef.current; const reticle = reticleRef.current; if (!rig || !session) { if (reticle && reticle.visible) reticle.visible = false; return; } let stickX = 0; let stickY = 0; for (const src of session.inputSources) { const gp = src.gamepad; if (!gp || gp.axes.length < 4) continue; // Preferencia pelo stick esquerdo para locomoção if (src.handedness === 'left') { stickX = gp.axes[2] ?? 0; stickY = gp.axes[3] ?? 0; break; } if (src.handedness === 'right' && stickX === 0 && stickY === 0) { stickX = gp.axes[2] ?? 0; stickY = gp.axes[3] ?? 0; } } // ── Snap-rotate (lateral) ────────────────────────────────────── if (Math.abs(stickX) > DEAD) { const dir: -1 | 1 = stickX > 0 ? 1 : -1; if (rotLatched.current !== dir) { rotLatched.current = dir; rig.rotation.y -= (dir * SNAP_DEG * Math.PI) / 180; } } else if (Math.abs(stickX) < RELEASE) { rotLatched.current = 0; } // ── Back step ────────────────────────────────────────────────── if (stickY > DEAD) { if (!backLatched.current) { backLatched.current = true; const headDir = new THREE.Vector3(); camera.getWorldDirection(headDir); headDir.y = 0; if (headDir.lengthSq() > 1e-6) { headDir.normalize(); rig.position.addScaledVector(headDir, -STEP_BACK); } } } else if (stickY < RELEASE) { backLatched.current = false; } // ── Forward → teleport on release ───────────────────────────── if (stickY < -DEAD) { fwdPushed.current = true; // Realiza raycast a partir da direção que o usuário está olhando (câmera) const origin = new THREE.Vector3(); camera.getWorldPosition(origin); const dir = new THREE.Vector3(); camera.getWorldDirection(dir); raycaster.current.set(origin, dir); raycaster.current.far = 30; // Filtra o raycast para atingir exclusivamente as peças 3D carregadas const modelsObjects = getAllModelLocalGroups(); const hits = raycaster.current.intersectObjects(modelsObjects, true); const hit = hits.find((h) => { const o = h.object; if (!(o instanceof THREE.Mesh)) return false; if (o.userData.__edgeLine) return false; return true; }); const target = hit ? hit.point.clone() : origin.clone().add(dir.multiplyScalar(STEP_FWD_NO_HIT)); if (reticle) { // Converte o ponto de mundo para o espaço local do rig const localTarget = target.clone(); rig.worldToLocal(localTarget); reticle.position.copy(localTarget); if (hit && hit.face) { const normalWorld = hit.face.normal.clone(); const nm = new THREE.Matrix3().getNormalMatrix(hit.object.matrixWorld); normalWorld.applyMatrix3(nm).normalize(); // Converte normal do mundo para o espaço local do rig para orientar o retículo const rigWorldQuat = new THREE.Quaternion(); rig.getWorldQuaternion(rigWorldQuat); const normalLocal = normalWorld.clone().applyQuaternion(rigWorldQuat.invert()); const quaternion = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), normalLocal); reticle.quaternion.copy(quaternion); } else { reticle.rotation.set(-Math.PI / 2, 0, 0); } reticle.visible = true; } } else if (fwdPushed.current && stickY > -RELEASE) { fwdPushed.current = false; if (reticle && reticle.visible) { reticle.visible = false; // Recupera o ponto projetado e calcula o deslocamento em relação à câmera const localTarget = reticle.position.clone(); const targetWorld = localTarget.clone(); rig.localToWorld(targetWorld); const camWorld = new THREE.Vector3(); camera.getWorldPosition(camWorld); const delta = new THREE.Vector3().subVectors(targetWorld, camWorld); delta.y = 0; // preserva altura do rig do usuário rig.position.add(delta); } } else { if (reticle && reticle.visible) { reticle.visible = false; } } }); return ( ); }