341 lines
10 KiB
JavaScript
341 lines
10 KiB
JavaScript
/**
|
|
* interaction.js — Camera controls + agent touch/click interaction.
|
|
*
|
|
* Adds raycasting so users can tap/click on agents to see their info
|
|
* and optionally start a conversation. The info popup appears as a
|
|
* DOM overlay anchored near the clicked agent.
|
|
*
|
|
* Resolves Issue #44 — Touch-to-interact
|
|
*/
|
|
import * as THREE from 'three';
|
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
import { getAgentDefs } from './agents.js';
|
|
import { colorToCss } from './agent-defs.js';
|
|
|
|
let controls;
|
|
let camera;
|
|
let renderer;
|
|
let scene;
|
|
|
|
/* ── Raycasting state ── */
|
|
const raycaster = new THREE.Raycaster();
|
|
const pointer = new THREE.Vector2();
|
|
|
|
/** Currently selected agent id (null if nothing selected) */
|
|
let selectedAgentId = null;
|
|
|
|
/** The info popup DOM element */
|
|
let $popup = null;
|
|
|
|
/* ── Public API ── */
|
|
|
|
export function initInteraction(cam, ren, scn) {
|
|
camera = cam;
|
|
renderer = ren;
|
|
scene = scn;
|
|
|
|
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);
|
|
controls.update();
|
|
|
|
renderer.domElement.addEventListener('contextmenu', e => e.preventDefault());
|
|
|
|
// Pointer events (works for mouse and touch)
|
|
renderer.domElement.addEventListener('pointerdown', _onPointerDown, { passive: true });
|
|
renderer.domElement.addEventListener('pointermove', _onPointerMove, { passive: true });
|
|
renderer.domElement.addEventListener('pointerup', _onPointerUp, { passive: true });
|
|
|
|
_ensurePopup();
|
|
}
|
|
|
|
export function updateControls() {
|
|
if (controls) controls.update();
|
|
}
|
|
|
|
/**
|
|
* Called each frame from the render loop so the popup can track a
|
|
* selected agent's screen position.
|
|
*/
|
|
export function updateInteraction() {
|
|
if (!selectedAgentId || !$popup || $popup.style.display === 'none') return;
|
|
_positionPopup(selectedAgentId);
|
|
}
|
|
|
|
/** Deselect the current agent and hide the popup. */
|
|
export function deselectAgent() {
|
|
selectedAgentId = null;
|
|
if ($popup) $popup.style.display = 'none';
|
|
}
|
|
|
|
/**
|
|
* Dispose orbit controls and event listeners (used on world teardown).
|
|
*/
|
|
export function disposeInteraction() {
|
|
if (controls) {
|
|
controls.dispose();
|
|
controls = null;
|
|
}
|
|
if (renderer) {
|
|
renderer.domElement.removeEventListener('pointerdown', _onPointerDown);
|
|
renderer.domElement.removeEventListener('pointermove', _onPointerMove);
|
|
renderer.domElement.removeEventListener('pointerup', _onPointerUp);
|
|
}
|
|
deselectAgent();
|
|
}
|
|
|
|
/* ── Internal: pointer handling ── */
|
|
|
|
let _pointerDownPos = { x: 0, y: 0 };
|
|
let _pointerMoved = false;
|
|
|
|
function _onPointerDown(e) {
|
|
_pointerDownPos.x = e.clientX;
|
|
_pointerDownPos.y = e.clientY;
|
|
_pointerMoved = false;
|
|
}
|
|
|
|
function _onPointerMove(e) {
|
|
const dx = e.clientX - _pointerDownPos.x;
|
|
const dy = e.clientY - _pointerDownPos.y;
|
|
if (Math.abs(dx) + Math.abs(dy) > 6) _pointerMoved = true;
|
|
}
|
|
|
|
function _onPointerUp(e) {
|
|
// Ignore drags — only respond to taps/clicks
|
|
if (_pointerMoved) return;
|
|
_handleTap(e.clientX, e.clientY);
|
|
}
|
|
|
|
/* ── Raycasting ── */
|
|
|
|
function _handleTap(clientX, clientY) {
|
|
if (!camera || !scene) return;
|
|
|
|
pointer.x = (clientX / window.innerWidth) * 2 - 1;
|
|
pointer.y = -(clientY / window.innerHeight) * 2 + 1;
|
|
raycaster.setFromCamera(pointer, camera);
|
|
|
|
// Collect all agent group meshes
|
|
const agentDefs = getAgentDefs();
|
|
const meshes = [];
|
|
for (const def of agentDefs) {
|
|
// Each agent group is a direct child of the scene
|
|
scene.traverse(child => {
|
|
if (child.isGroup && child.children.length > 0) {
|
|
// Check if this group's first mesh color matches an agent
|
|
const coreMesh = child.children.find(c => c.isMesh && c.geometry?.type === 'IcosahedronGeometry');
|
|
if (coreMesh) {
|
|
meshes.push({ mesh: child, agentId: _matchGroupToAgent(child, agentDefs) });
|
|
}
|
|
}
|
|
});
|
|
break; // only need to traverse once
|
|
}
|
|
|
|
// Raycast against all scene objects, find the nearest agent group or memory orb
|
|
const allMeshes = [];
|
|
scene.traverse(obj => { if (obj.isMesh) allMeshes.push(obj); });
|
|
const intersects = raycaster.intersectObjects(allMeshes, false);
|
|
|
|
let hitAgentId = null;
|
|
let hitFact = null;
|
|
|
|
for (const hit of intersects) {
|
|
// 1. Check if it's a memory orb
|
|
if (hit.object.id && hit.object.id.startsWith('fact_')) {
|
|
hitFact = {
|
|
id: hit.object.id,
|
|
data: hit.object.userData
|
|
};
|
|
break;
|
|
}
|
|
|
|
// 2. Walk up to find the agent group
|
|
let obj = hit.object;
|
|
while (obj && obj.parent) {
|
|
const matched = _matchGroupToAgent(obj, agentDefs);
|
|
if (matched) {
|
|
hitAgentId = matched;
|
|
break;
|
|
}
|
|
obj = obj.parent;
|
|
}
|
|
if (hitAgentId) break;
|
|
}
|
|
|
|
if (hitAgentId) {
|
|
_selectAgent(hitAgentId);
|
|
} else if (hitFact) {
|
|
_selectFact(hitFact.id, hitFact.data);
|
|
} else {
|
|
deselectAgent();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Try to match a Three.js group to an agent by comparing positions.
|
|
*/
|
|
function _matchGroupToAgent(group, agentDefs) {
|
|
if (!group.isGroup) return null;
|
|
for (const def of agentDefs) {
|
|
// Agent positions: (def.x, ~0, def.z) — the group y bobs, so just check xz
|
|
const dx = Math.abs(group.position.x - (def.position?.x ?? 0));
|
|
const dz = Math.abs(group.position.z - (def.position?.z ?? 0));
|
|
// getAgentDefs returns { id, label, role, color, state } — no position.
|
|
// We need to compare the group position to the known AGENT_DEFS x/z.
|
|
// Since getAgentDefs doesn't return position, match by finding the icosahedron
|
|
// core color against agent color.
|
|
const coreMesh = group.children.find(c => c.isMesh && c.material?.emissive);
|
|
if (coreMesh) {
|
|
const meshColor = coreMesh.material.color.getHex();
|
|
if (meshColor === def.color) return def.id;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/* ── Agent selection & popup ── */
|
|
|
|
function _selectAgent(agentId) {
|
|
selectedAgentId = agentId;
|
|
const defs = getAgentDefs();
|
|
const agent = defs.find(d => d.id === agentId);
|
|
if (!agent) return;
|
|
|
|
_ensurePopup();
|
|
const color = colorToCss(agent.color);
|
|
const stateLabel = (agent.state || 'idle').toUpperCase();
|
|
const stateColor = agent.state === 'active' ? '#00ff41' : '#33aa55';
|
|
|
|
$popup.innerHTML = `
|
|
<div class="agent-popup-header" style="border-color:${color}">
|
|
<span class="agent-popup-name" style="color:${color}">${_esc(agent.label)}</span>
|
|
<span class="agent-popup-close" id="agent-popup-close">×</span>
|
|
</div>
|
|
<div class="agent-popup-role">${_esc(agent.role)}</div>
|
|
<div class="agent-popup-state" style="color:${stateColor}">● ${stateLabel}</div>
|
|
<button class="agent-popup-talk" id="agent-popup-talk" style="border-color:${color};color:${color}">
|
|
TALK →
|
|
</button>
|
|
`;
|
|
$popup.style.display = 'block';
|
|
|
|
// Position near agent
|
|
_positionPopup(agentId);
|
|
|
|
// Close button
|
|
const $close = document.getElementById('agent-popup-close');
|
|
if ($close) $close.addEventListener('click', deselectAgent);
|
|
|
|
// Talk button — focus the chat input and prefill
|
|
const $talk = document.getElementById('agent-popup-talk');
|
|
if ($talk) {
|
|
$talk.addEventListener('click', () => {
|
|
const $input = document.getElementById('chat-input');
|
|
if ($input) {
|
|
$input.focus();
|
|
$input.placeholder = `Say something to ${agent.label}...`;
|
|
}
|
|
deselectAgent();
|
|
});
|
|
}
|
|
}
|
|
|
|
function _selectFact(factId, data) {
|
|
selectedAgentId = null; // clear agent selection
|
|
_ensurePopup();
|
|
|
|
const categoryColors = {
|
|
user_pref: '#00ffaa',
|
|
project: '#00aaff',
|
|
tool: '#ffaa00',
|
|
general: '#ffffff',
|
|
};
|
|
const color = categoryColors[data.category] || '#cccccc';
|
|
|
|
$popup.innerHTML = `
|
|
<div class="agent-popup-header" style="border-color:${color}">
|
|
<span class="agent-popup-name" style="color:${color}">Memory Fact</span>
|
|
<span class="agent-popup-close" id="agent-popup-close">×</span>
|
|
</div>
|
|
<div class="agent-popup-role" style="font-style: italic;">Category: ${_esc(data.category || 'general')}</div>
|
|
<div class="agent-popup-state" style="margin: 8px 0; line-height: 1.4; font-size: 0.9em;">${_esc(data.content)}</div>
|
|
<div class="agent-popup-state" style="color:#aaa; font-size: 0.8em;">ID: ${_esc(factId)}</div>
|
|
`;
|
|
$popup.style.display = 'block';
|
|
|
|
_positionPopup(factId);
|
|
|
|
const $close = document.getElementById('agent-popup-close');
|
|
if ($close) $close.addEventListener('click', deselectAgent);
|
|
}
|
|
|
|
function _positionPopup(id) {
|
|
if (!camera || !renderer || !$popup) return;
|
|
|
|
let targetObj = null;
|
|
scene.traverse(obj => {
|
|
if (targetObj) return;
|
|
// If it's an agent ID, we find the group. If it's a fact ID, we find the mesh.
|
|
if (id.startsWith('fact_')) {
|
|
if (obj.id === id) targetObj = obj;
|
|
} else {
|
|
if (obj.isGroup) {
|
|
const defs = getAgentDefs();
|
|
const def = defs.find(d => d.id === id);
|
|
if (def) {
|
|
const core = obj.children.find(c => c.isMesh && c.material?.emissive);
|
|
if (core && core.material.color.getHex() === def.color) {
|
|
targetObj = obj;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
if (!targetObj) return;
|
|
|
|
const worldPos = new THREE.Vector3();
|
|
targetObj.getWorldPosition(worldPos);
|
|
worldPos.y += 1.5;
|
|
|
|
const screenPos = worldPos.clone().project(camera);
|
|
const hw = window.innerWidth / 2;
|
|
const hh = window.innerHeight / 2;
|
|
const sx = screenPos.x * hw + hw;
|
|
const sy = -screenPos.y * hh + hh;
|
|
|
|
if (screenPos.z > 1) {
|
|
$popup.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
const popW = $popup.offsetWidth || 180;
|
|
const popH = $popup.offsetHeight || 120;
|
|
const x = Math.min(Math.max(sx - popW / 2, 8), window.innerWidth - popW - 8);
|
|
const y = Math.min(Math.max(sy - popH - 12, 8), window.innerHeight - popH - 60);
|
|
|
|
$popup.style.left = x + 'px';
|
|
$popup.style.top = y + 'px';
|
|
}
|
|
|
|
/* ── Popup DOM ── */
|
|
|
|
function _ensurePopup() {
|
|
if ($popup) return;
|
|
$popup = document.createElement('div');
|
|
$popup.id = 'agent-popup';
|
|
$popup.style.display = 'none';
|
|
document.body.appendChild($popup);
|
|
}
|
|
|
|
function _esc(str) {
|
|
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
}
|