Files
timmy-tower/the-matrix/js/interaction.js
Alexander Whitestone 448bce87df WIP: Claude Code progress on #17
Automated salvage commit — agent session ended (exit 124).
Work in progress, may need continuation.
2026-03-23 16:30:13 -04:00

195 lines
6.4 KiB
JavaScript

/**
* 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;
let _meterObjects = [];
let _meterCallback = 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 registerMeterTarget(objects, onClickFn) {
_meterObjects = objects || [];
_meterCallback = onClickFn;
}
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;
_meterObjects = [];
_meterCallback = 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 _hitTestMeter(clientX, clientY) {
if (!_meterObjects.length || !_meterCallback || !_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.intersectObjects(_meterObjects, false);
if (hits.length > 0) { _meterCallback(); return true; }
return false;
}
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 meter, then slap, then inspect
if (_hitTestMeter(upEvent.clientX, upEvent.clientY)) return;
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);
// Check power meter first
if (_meterObjects.length && _meterCallback) {
const meterHits = _raycaster.intersectObjects(_meterObjects, false);
if (meterHits.length > 0) {
_meterCallback();
event.stopImmediatePropagation();
return;
}
}
if (_timmyGroup && _applySlap) {
const hits = _raycaster.intersectObject(_timmyGroup, true);
if (hits.length > 0) {
_applySlap(hits[0].point);
event.stopImmediatePropagation();
}
}
}
}
}