190 lines
7.1 KiB
JavaScript
190 lines
7.1 KiB
JavaScript
/**
|
||
* hud-labels.js — AR floating HTML labels projected from 3D world positions.
|
||
*
|
||
* Each label is a <div> element that gets repositioned every frame by projecting
|
||
* the agent's 3D world position through the camera to screen space.
|
||
*
|
||
* Exports:
|
||
* initHudLabels(scene, camera, agentDefs, getTimmyPosition)
|
||
* updateHudLabels(camera, renderer, agentStates)
|
||
* setLabelState(id, state) — called from websocket.js on agent_state events
|
||
* disposeHudLabels()
|
||
*/
|
||
|
||
import * as THREE from 'three';
|
||
import { colorToCss, AGENT_DEFS } from './agent-defs.js';
|
||
|
||
// Specialization lookup built once from AGENT_DEFS
|
||
const _specializations = Object.fromEntries(
|
||
AGENT_DEFS.filter(d => d.specialization).map(d => [d.id, d.specialization])
|
||
);
|
||
|
||
const _proj = new THREE.Vector3();
|
||
let _camera = null;
|
||
let _labels = []; // { el, worldPos: THREE.Vector3, id }
|
||
|
||
// ── State cache (updated from WS) ────────────────────────────────────────────
|
||
const _states = {};
|
||
const _lastTasks = {};
|
||
|
||
// ── Inspect popup ─────────────────────────────────────────────────────────────
|
||
let _inspectEl = null;
|
||
let _inspectTimer = null;
|
||
|
||
export function initHudLabels(camera, agentDefs, timmyWorldPos) {
|
||
_camera = camera;
|
||
|
||
const container = document.getElementById('ar-labels') || (() => {
|
||
const c = document.createElement('div');
|
||
c.id = 'ar-labels';
|
||
c.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:15;overflow:hidden;';
|
||
document.body.appendChild(c);
|
||
return c;
|
||
})();
|
||
|
||
// Timmy label
|
||
_labels.push(_makeLabel(container, 'timmy', 'TIMMY', 'Wizard · Master Agent', '#c77dff',
|
||
timmyWorldPos.clone().add(new THREE.Vector3(0, 1.1, 0))));
|
||
|
||
// Sub-agent labels
|
||
for (const def of agentDefs) {
|
||
const col = colorToCss(def.color);
|
||
const pos = new THREE.Vector3(def.x, 2.8, def.z);
|
||
_labels.push(_makeLabel(container, def.id, def.label, def.role, col, pos));
|
||
_states[def.id] = 'idle';
|
||
}
|
||
|
||
_states['timmy'] = 'idle';
|
||
|
||
// Inspect popup (shared, shown on tap)
|
||
_inspectEl = document.createElement('div');
|
||
_inspectEl.id = 'inspect-popup';
|
||
_inspectEl.style.cssText = [
|
||
'position:fixed;transform:translate(-50%,-100%) translateY(-14px)',
|
||
'background:rgba(8,6,20,0.92);border:1px solid rgba(120,80,200,0.6)',
|
||
'border-radius:10px;padding:10px 16px;min-width:140px;max-width:220px',
|
||
'color:#ddd;font-family:Courier New,monospace;font-size:11px;line-height:1.7',
|
||
'pointer-events:none;z-index:30;display:none',
|
||
'box-shadow:0 0 24px rgba(100,60,180,0.4)',
|
||
'backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)',
|
||
].join(';');
|
||
document.body.appendChild(_inspectEl);
|
||
}
|
||
|
||
function _makeLabel(container, id, name, role, color, worldPos) {
|
||
const el = document.createElement('div');
|
||
el.className = 'ar-label';
|
||
el.dataset.id = id;
|
||
el.style.cssText = [
|
||
'position:absolute;transform:translate(-50%,-100%)',
|
||
'pointer-events:none;transition:opacity 0.3s',
|
||
'text-align:center;line-height:1.4',
|
||
].join(';');
|
||
|
||
el.innerHTML = `
|
||
<div class="ar-label-name" style="color:${color};font-family:Courier New,monospace;
|
||
font-size:11px;font-weight:bold;letter-spacing:2px;
|
||
text-shadow:0 0 10px ${color}88;white-space:nowrap;">
|
||
${name}
|
||
</div>
|
||
<div class="ar-label-role" style="color:${color}99;font-family:Courier New,monospace;
|
||
font-size:9px;letter-spacing:1px;text-transform:uppercase;white-space:nowrap;">
|
||
${role}
|
||
</div>
|
||
<div class="ar-label-state" style="margin-top:2px;">
|
||
<span class="ar-dot" style="display:inline-block;width:6px;height:6px;
|
||
border-radius:50%;background:${color};opacity:0.7;
|
||
box-shadow:0 0 6px ${color};margin-right:4px;vertical-align:middle;"></span>
|
||
<span class="ar-state-text" style="color:${color}bb;font-family:Courier New,monospace;
|
||
font-size:9px;letter-spacing:1px;">idle</span>
|
||
</div>
|
||
<div class="ar-label-tick" style="width:1px;height:14px;background:${color}55;
|
||
margin:3px auto 0;"></div>
|
||
`;
|
||
|
||
container.appendChild(el);
|
||
return { el, worldPos, id, color };
|
||
}
|
||
|
||
export function setLabelLastTask(id, summary) {
|
||
_lastTasks[id] = summary;
|
||
}
|
||
|
||
export function setLabelState(id, state) {
|
||
_states[id] = state;
|
||
const entry = _labels.find(l => l.id === id);
|
||
if (!entry) return;
|
||
const stateEl = entry.el.querySelector('.ar-state-text');
|
||
const dot = entry.el.querySelector('.ar-dot');
|
||
if (stateEl) stateEl.textContent = state;
|
||
const pulse = state !== 'idle';
|
||
if (dot) dot.style.animation = pulse ? 'ar-pulse 1s ease-in-out infinite' : '';
|
||
}
|
||
|
||
export function showInspectPopup(id, screenX, screenY) {
|
||
if (!_inspectEl) return;
|
||
const entry = _labels.find(l => l.id === id);
|
||
if (!entry) return;
|
||
|
||
const state = _states[id] || 'idle';
|
||
const uptime = Math.floor(performance.now() / 1000);
|
||
const spec = _specializations[id];
|
||
const lastTask = _lastTasks[id];
|
||
_inspectEl.innerHTML = `
|
||
<div style="color:${entry.color};font-weight:bold;letter-spacing:2px;font-size:12px;margin-bottom:6px;">
|
||
${id.toUpperCase()}
|
||
</div>
|
||
${spec ? `<div style="color:${entry.color}99;margin-bottom:4px;font-size:10px;letter-spacing:1px;">⬡ ${spec}</div>` : ''}
|
||
<div style="color:#aaa;margin-bottom:2px;">state : <span style="color:${entry.color}">${state}</span></div>
|
||
<div style="color:#aaa;margin-bottom:2px;">uptime : ${uptime}s</div>
|
||
<div style="color:#aaa;margin-bottom:2px;">network: <span style="color:#44ff88">connected</span></div>
|
||
${lastTask ? `<div style="color:#888;font-size:9px;margin-top:4px;border-top:1px solid #333;padding-top:4px;">last: ${lastTask.slice(0, 60)}</div>` : ''}
|
||
`;
|
||
_inspectEl.style.left = `${screenX}px`;
|
||
_inspectEl.style.top = `${screenY}px`;
|
||
_inspectEl.style.display = 'block';
|
||
|
||
if (_inspectTimer) clearTimeout(_inspectTimer);
|
||
_inspectTimer = setTimeout(() => {
|
||
if (_inspectEl) _inspectEl.style.display = 'none';
|
||
}, 2800);
|
||
}
|
||
|
||
const _ndc = new THREE.Vector3();
|
||
|
||
export function updateHudLabels(camera, renderer) {
|
||
if (!camera) return;
|
||
|
||
const W = renderer.domElement.clientWidth || window.innerWidth;
|
||
const H = renderer.domElement.clientHeight || window.innerHeight;
|
||
|
||
for (const label of _labels) {
|
||
_ndc.copy(label.worldPos);
|
||
_ndc.project(camera);
|
||
|
||
// Behind camera or outside NDC → hide
|
||
if (_ndc.z > 1 || Math.abs(_ndc.x) > 1.8 || Math.abs(_ndc.y) > 1.8) {
|
||
label.el.style.opacity = '0';
|
||
continue;
|
||
}
|
||
|
||
const sx = (_ndc.x * 0.5 + 0.5) * W;
|
||
const sy = (_ndc.y * -0.5 + 0.5) * H;
|
||
|
||
label.el.style.left = `${sx}px`;
|
||
label.el.style.top = `${sy}px`;
|
||
|
||
// Fade with distance: full opacity 3–20 units, fade out beyond 20
|
||
const dist = _proj.copy(label.worldPos).distanceTo(camera.position);
|
||
const alpha = Math.max(0, Math.min(1, 1 - (dist - 20) / 10));
|
||
label.el.style.opacity = alpha.toFixed(2);
|
||
}
|
||
}
|
||
|
||
export function disposeHudLabels() {
|
||
for (const l of _labels) l.el.remove();
|
||
_labels = [];
|
||
_inspectEl?.remove();
|
||
_inspectEl = null;
|
||
}
|