diff --git a/index.html b/index.html index 6e51dd4..9adb4c3 100644 --- a/index.html +++ b/index.html @@ -159,6 +159,44 @@ @media (max-width: 500px) { #status-panel { top: 100px !important; left: 16px; right: auto; } } + + /* ── Touch controls (mobile) ── */ + .tap-feedback { + position: fixed; + z-index: 50; + pointer-events: none; + color: #00ff41; + font-family: 'Courier New', monospace; + font-size: 13px; + text-shadow: 0 0 8px #00ff41; + animation: tapPop 1.2s ease-out forwards; + transform: translateX(-50%); + white-space: nowrap; + } + @keyframes tapPop { + 0% { opacity: 1; transform: translateX(-50%) translateY(0); } + 100% { opacity: 0; transform: translateX(-50%) translateY(-24px); } + } + + #touch-hint { + position: fixed; + bottom: 80px; left: 50%; + transform: translateX(-50%); + background: rgba(0, 10, 0, 0.88); + border: 1px solid #003300; + color: #007722; + font-family: 'Courier New', monospace; + font-size: 11px; + letter-spacing: 1.5px; + padding: 8px 18px; + z-index: 30; + pointer-events: none; + text-align: center; + opacity: 0; + transition: opacity 0.4s; + white-space: nowrap; + } + #touch-hint.visible { opacity: 1; } @@ -182,6 +220,7 @@
OFFLINE
+
DRAG · PINCH TO ZOOM · TAP AGENT
diff --git a/js/agents.js b/js/agents.js index dcef57c..dfa27f1 100644 --- a/js/agents.js +++ b/js/agents.js @@ -285,6 +285,14 @@ export function applyAgentStates(snapshot) { } } +/** + * Get core meshes for raycasting (tap-to-interact). + * Returns array of { mesh, id, label }. + */ +export function getAgentMeshes() { + return [...agents.values()].map(a => ({ mesh: a.core, id: a.id, label: a.label })); +} + /** * Dispose all agent resources (used on world teardown). */ diff --git a/js/interaction.js b/js/interaction.js index ae76212..0a73083 100644 --- a/js/interaction.js +++ b/js/interaction.js @@ -1,19 +1,90 @@ +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.enableDamping = true; + controls.dampingFactor = 0.05; controls.screenSpacePanning = false; - controls.minDistance = 5; - controls.maxDistance = 80; - controls.maxPolarAngle = Math.PI / 2.1; + 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() { @@ -21,11 +92,17 @@ export function updateControls() { } /** - * Dispose orbit controls (used on world teardown). + * 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; }