/** * interaction.js — Click/tap interaction in FPS navigation mode. * * - Raycasts against Timmy on pointerdown to apply slap. * - Short-tap detection (touchstart + touchend < 200ms) on right-half of * mobile screen to also attempt slap / agent inspect. * - No OrbitControls — navigation is handled by navigation.js. */ import * as THREE from 'three'; import { AGENT_DEFS } from './agent-defs.js'; import { showInspectPopup } from './hud-labels.js'; let _canvas = null; let _camera = null; let _timmyGroup = null; let _applySlap = null; const _raycaster = new THREE.Raycaster(); const _pointer = new THREE.Vector2(); const _noCtxMenu = e => e.preventDefault(); // Agent inspect proxy — screen-space proximity check (80px threshold) const INSPECT_RADIUS_PX = 80; // Timmy world pos (for screen-space proximity check) const _TIMMY_WORLD = new THREE.Vector3(0, 1.9, -2); const _agentWorlds = AGENT_DEFS.map(d => ({ id: d.id, pos: new THREE.Vector3(d.x, 2.8, d.z) })); // ── Public API ──────────────────────────────────────────────────────────────── export function registerSlapTarget(timmyGroup, applyFn) { _timmyGroup = timmyGroup; _applySlap = applyFn; } export function initInteraction(camera, renderer) { _camera = camera; _canvas = renderer.domElement; _canvas.addEventListener('contextmenu', _noCtxMenu); _canvas.addEventListener('pointerdown', _onPointerDown, { capture: true }); } export function updateControls() { /* no-op — navigation.js handles movement */ } export function disposeInteraction() { if (_canvas) { _canvas.removeEventListener('contextmenu', _noCtxMenu); _canvas.removeEventListener('pointerdown', _onPointerDown, { capture: true }); _canvas = null; } _camera = null; _timmyGroup = null; _applySlap = null; } // ── Internal ────────────────────────────────────────────────────────────────── function _toScreen(worldPos) { const v = worldPos.clone().project(_camera); return { x: ( v.x * 0.5 + 0.5) * window.innerWidth, y: (-v.y * 0.5 + 0.5) * window.innerHeight, behind: v.z > 1, }; } function _distPx(sx, sy, screenPos) { if (screenPos.behind) return Infinity; const dx = sx - screenPos.x; const dy = sy - screenPos.y; return Math.sqrt(dx * dx + dy * dy); } function _hitTestTimmy(clientX, clientY) { if (!_timmyGroup || !_applySlap || !_camera || !_canvas) return false; const rect = _canvas.getBoundingClientRect(); _pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1; _pointer.y = ((clientY - rect.top) / rect.height) * -2 + 1; _raycaster.setFromCamera(_pointer, _camera); const hits = _raycaster.intersectObject(_timmyGroup, true); if (hits.length > 0) { _applySlap(hits[0].point); return true; } // Fallback: screen-space proximity to Timmy (for mobile when mesh is small) const ts = _toScreen(_TIMMY_WORLD); if (_distPx(clientX, clientY, ts) < INSPECT_RADIUS_PX) { _applySlap(new THREE.Vector3(0, 1.2, -2)); return true; } return false; } function _tryAgentInspect(clientX, clientY) { // Check Timmy first const ts = _toScreen(_TIMMY_WORLD); if (_distPx(clientX, clientY, ts) < INSPECT_RADIUS_PX) { showInspectPopup('timmy', clientX, clientY); return true; } // Sub-agents let best = null, bestDist = INSPECT_RADIUS_PX; for (const ag of _agentWorlds) { const s = _toScreen(ag.pos); const d = _distPx(clientX, clientY, s); if (d < bestDist) { best = ag; bestDist = d; } } if (best) { showInspectPopup(best.id, clientX, clientY); return true; } return false; } // Track touch for short-tap detection on mobile let _tapStart = 0; let _tapX = 0, _tapY = 0; function _onPointerDown(event) { // Record tap start for short-tap detection _tapStart = Date.now(); _tapX = event.clientX; _tapY = event.clientY; const isTouch = event.pointerType === 'touch'; if (isTouch) { // For mobile: add a pointerup listener to detect short tap const onUp = (upEvent) => { _canvas.removeEventListener('pointerup', onUp); const dt = Date.now() - _tapStart; const moved = Math.hypot(upEvent.clientX - _tapX, upEvent.clientY - _tapY); if (dt < 220 && moved < 18) { // Short tap — try slap then inspect if (!_hitTestTimmy(upEvent.clientX, upEvent.clientY)) { _tryAgentInspect(upEvent.clientX, upEvent.clientY); } } }; _canvas.addEventListener('pointerup', onUp, { once: true }); } else { // Desktop click: only fire when pointer lock is already active // (otherwise navigation.js requests lock on this same click — that's fine, // both can fire; slap + lock request together is acceptable) if (document.pointerLockElement === _canvas) { // Cast from screen center in FPS mode _pointer.set(0, 0); _raycaster.setFromCamera(_pointer, _camera); if (_timmyGroup && _applySlap) { const hits = _raycaster.intersectObject(_timmyGroup, true); if (hits.length > 0) { _applySlap(hits[0].point); event.stopImmediatePropagation(); } } } } }