forked from Rockachopa/the-matrix
- Configure OrbitControls touch mapping explicitly (one-finger orbit, two-finger pinch-zoom + pan) for iOS Safari and Android Chrome - Add tap-to-interact via Raycaster: tapping an agent fires a visitor_interaction event and shows a brief floating label - Add touch hint overlay (DRAG · PINCH TO ZOOM · TAP AGENT) that appears on first touch and fades after 3.5 s - Expose getAgentMeshes() from agents.js for raycasting targets - Clean up disposeInteraction() to remove pointer listeners Fixes #1 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
109 lines
3.1 KiB
JavaScript
109 lines
3.1 KiB
JavaScript
import * as THREE from 'three';
|
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
import { getAgentMeshes } from './agents.js';
|
|
import { sendVisitorInteraction } from './visitor.js';
|
|
|
|
let controls;
|
|
let _camera, _renderer;
|
|
const raycaster = new THREE.Raycaster();
|
|
const _tapStart = { x: 0, y: 0 };
|
|
const TAP_MAX_MOVE = 10; // px — movement above this = drag, not tap
|
|
let _touchHintShown = false;
|
|
|
|
export function initInteraction(camera, renderer) {
|
|
_camera = camera;
|
|
_renderer = renderer;
|
|
|
|
controls = new OrbitControls(camera, renderer.domElement);
|
|
controls.enableDamping = true;
|
|
controls.dampingFactor = 0.05;
|
|
controls.screenSpacePanning = false;
|
|
controls.minDistance = 5;
|
|
controls.maxDistance = 80;
|
|
controls.maxPolarAngle = Math.PI / 2.1;
|
|
controls.target.set(0, 0, 0);
|
|
|
|
// Explicit touch mapping: one finger = orbit, two fingers = pinch-zoom + pan
|
|
controls.touches = {
|
|
ONE: THREE.TOUCH.ROTATE,
|
|
TWO: THREE.TOUCH.DOLLY_PAN,
|
|
};
|
|
|
|
controls.update();
|
|
|
|
renderer.domElement.addEventListener('contextmenu', e => e.preventDefault());
|
|
renderer.domElement.addEventListener('pointerdown', _onPointerDown);
|
|
renderer.domElement.addEventListener('pointerup', _onPointerUp);
|
|
}
|
|
|
|
function _onPointerDown(e) {
|
|
_tapStart.x = e.clientX;
|
|
_tapStart.y = e.clientY;
|
|
|
|
// Show touch hint on first touch interaction
|
|
if (!_touchHintShown && e.pointerType === 'touch') {
|
|
_touchHintShown = true;
|
|
_showTouchHint();
|
|
}
|
|
}
|
|
|
|
function _onPointerUp(e) {
|
|
const dx = e.clientX - _tapStart.x;
|
|
const dy = e.clientY - _tapStart.y;
|
|
if (Math.sqrt(dx * dx + dy * dy) > TAP_MAX_MOVE) return; // drag, not tap
|
|
|
|
const rect = _renderer.domElement.getBoundingClientRect();
|
|
const ndc = new THREE.Vector2(
|
|
((e.clientX - rect.left) / rect.width) * 2 - 1,
|
|
-((e.clientY - rect.top) / rect.height) * 2 + 1,
|
|
);
|
|
raycaster.setFromCamera(ndc, _camera);
|
|
|
|
const meshes = getAgentMeshes();
|
|
const hits = raycaster.intersectObjects(meshes.map(m => m.mesh));
|
|
if (hits.length > 0) {
|
|
const hit = meshes.find(m => m.mesh === hits[0].object);
|
|
if (hit) {
|
|
sendVisitorInteraction(hit.id, 'tap');
|
|
_showTapFeedback(hit.label, e.clientX, e.clientY);
|
|
}
|
|
}
|
|
}
|
|
|
|
function _showTapFeedback(label, x, y) {
|
|
const el = document.createElement('div');
|
|
el.className = 'tap-feedback';
|
|
el.textContent = `[ ${label} ]`;
|
|
el.style.left = x + 'px';
|
|
el.style.top = (y - 40) + 'px';
|
|
document.body.appendChild(el);
|
|
setTimeout(() => el.remove(), 1200);
|
|
}
|
|
|
|
function _showTouchHint() {
|
|
const hint = document.getElementById('touch-hint');
|
|
if (!hint) return;
|
|
hint.classList.add('visible');
|
|
setTimeout(() => hint.classList.remove('visible'), 3500);
|
|
}
|
|
|
|
export function updateControls() {
|
|
if (controls) controls.update();
|
|
}
|
|
|
|
/**
|
|
* Dispose orbit controls and interaction listeners (used on world teardown).
|
|
*/
|
|
export function disposeInteraction() {
|
|
if (_renderer) {
|
|
_renderer.domElement.removeEventListener('pointerdown', _onPointerDown);
|
|
_renderer.domElement.removeEventListener('pointerup', _onPointerUp);
|
|
}
|
|
if (controls) {
|
|
controls.dispose();
|
|
controls = null;
|
|
}
|
|
_camera = null;
|
|
_renderer = null;
|
|
}
|