1 Commits

Author SHA1 Message Date
Alexander Whitestone
d5f407b892 feat: add mobile-responsive touch controls (#1)
- 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>
2026-03-23 14:08:58 -04:00
3 changed files with 130 additions and 6 deletions

View File

@@ -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; }
</style>
</head>
<body>
@@ -182,6 +220,7 @@
<button id="chat-clear-btn" title="Clear chat history" style="position:fixed;bottom:60px;right:16px;background:transparent;border:1px solid #003300;color:#00aa00;font-family:monospace;font-size:.7rem;padding:2px 6px;cursor:pointer;z-index:20;opacity:.6">✕ CLEAR</button>
<div id="bark-container"></div>
<div id="connection-status">OFFLINE</div>
<div id="touch-hint">DRAG · PINCH TO ZOOM · TAP AGENT</div>
</div>
<div id="chat-input-bar">
<input id="chat-input" type="text" placeholder="Say something to the Workshop..." autocomplete="off" />

View File

@@ -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).
*/

View File

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