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;
}