Files
timmy-tower/the-matrix/js/hud-labels.js

190 lines
7.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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&nbsp;&nbsp;: <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 320 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;
}