Some checks failed
CI / Typecheck & Lint (pull_request) Failing after 0s
Add a glowing orb power meter to the Workshop scene that reflects the session balance in real time. - power-meter.js: new Three.js module — transparent outer shell, inner orb that scales 0→1 with fill fraction, lightning bolt overlay, point light and equator ring accent; DOM text label projected above the orb shows current sats. Color interpolates red→yellow→cyan. Pulses bright on 'fill' event, quick flicker on 'drain'. - session.js: imports meter helpers; tracks _sessionMax (initial deposit); calls setMeterVisible/setMeterBalance in _applySessionUI; triggers fill/drain pulses on payment and job deduction; exports openSessionPanel() for click-to-open wiring; clears meter on _clearSession. - websocket.js: handles session_balance_update WS event — updates fill level and fires pulse. - interaction.js: adds registerClickTarget(group, callback) — wired for both FPS pointer-lock and non-lock modes and short taps. - main.js: wires initPowerMeter/updatePowerMeter/disposePowerMeter into the build/animate/teardown cycle; registers meter as click target that opens the session panel. Fixes #17 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
202 lines
6.6 KiB
JavaScript
202 lines
6.6 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;
|
|
|
|
// Registered 3D click targets: Array of { group: THREE.Object3D, callback: fn }
|
|
const _clickTargets = [];
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Register a Three.js Object3D as a clickable target.
|
|
* @param {THREE.Object3D} group The mesh/group to raycast against.
|
|
* @param {Function} callback Called when the user clicks the object.
|
|
*/
|
|
export function registerClickTarget(group, callback) {
|
|
_clickTargets.push({ group, callback });
|
|
}
|
|
|
|
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;
|
|
_clickTargets.length = 0;
|
|
}
|
|
|
|
// ── 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 _tryClickTargets(clientX, clientY) {
|
|
if (!_clickTargets.length || !_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);
|
|
for (const target of _clickTargets) {
|
|
if (!target.group.visible) continue;
|
|
const hits = _raycaster.intersectObject(target.group, true);
|
|
if (hits.length > 0) { target.callback(); return true; }
|
|
}
|
|
return false;
|
|
}
|
|
|
|
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 click targets, then slap, then inspect
|
|
if (!_tryClickTargets(upEvent.clientX, upEvent.clientY)) {
|
|
if (!_hitTestTimmy(upEvent.clientX, upEvent.clientY)) {
|
|
_tryAgentInspect(upEvent.clientX, upEvent.clientY);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
_canvas.addEventListener('pointerup', onUp, { once: true });
|
|
} else {
|
|
if (document.pointerLockElement === _canvas) {
|
|
// FPS mode: cast from screen centre
|
|
_pointer.set(0, 0);
|
|
_raycaster.setFromCamera(_pointer, _camera);
|
|
// Check registered click targets first
|
|
for (const target of _clickTargets) {
|
|
if (!target.group.visible) continue;
|
|
const hits = _raycaster.intersectObject(target.group, true);
|
|
if (hits.length > 0) {
|
|
target.callback();
|
|
event.stopImmediatePropagation();
|
|
return;
|
|
}
|
|
}
|
|
if (_timmyGroup && _applySlap) {
|
|
const hits = _raycaster.intersectObject(_timmyGroup, true);
|
|
if (hits.length > 0) {
|
|
_applySlap(hits[0].point);
|
|
event.stopImmediatePropagation();
|
|
}
|
|
}
|
|
} else {
|
|
// Not in pointer lock — still handle click targets (e.g. power meter)
|
|
_tryClickTargets(event.clientX, event.clientY);
|
|
}
|
|
}
|
|
}
|