- js/config.js: connection config with URL param + env var override - WS URL, auth token, mock mode toggle - Computed isLive and wsUrlWithAuth getters - Resolves #7 (config.js) - Resolves #11 (Phase 1 shared-secret auth via query param) - js/websocket.js: refactored to use Config for live/mock switching - Live mode: real WS with reconnection + exponential backoff - Auth token appended as ?token= on WS connect - agent_joined handler dispatches to addAgent() for hot-add - sendMessage() public API for UI → backend communication - js/agents.js: dynamic agent hot-add and removal - addAgent(def): spawns 3D avatar at runtime without reload - autoPlace(): finds unoccupied circular slot (radius 8+) - removeAgent(id): clean dispose + connection line rebuild - Connection distance threshold 8→14 for larger agent rings - Resolves #12 (dynamic agent hot-add)
263 lines
7.5 KiB
JavaScript
263 lines
7.5 KiB
JavaScript
import * as THREE from 'three';
|
|
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
|
|
|
|
const agents = new Map();
|
|
let scene;
|
|
let connectionLines = [];
|
|
|
|
/* ── Shared geometries (created once, reused by all agents) ── */
|
|
const SHARED_GEO = {
|
|
core: new THREE.IcosahedronGeometry(0.7, 1),
|
|
ring: new THREE.TorusGeometry(1.1, 0.04, 8, 32),
|
|
glow: new THREE.SphereGeometry(1.3, 16, 16),
|
|
};
|
|
|
|
/* ── Shared connection line material (one instance for all lines) ── */
|
|
const CONNECTION_MAT = new THREE.LineBasicMaterial({
|
|
color: 0x003300,
|
|
transparent: true,
|
|
opacity: 0.4,
|
|
});
|
|
|
|
class Agent {
|
|
constructor(def) {
|
|
this.id = def.id;
|
|
this.label = def.label;
|
|
this.color = def.color;
|
|
this.role = def.role;
|
|
this.position = new THREE.Vector3(def.x, 0, def.z);
|
|
this.state = 'idle';
|
|
this.pulsePhase = Math.random() * Math.PI * 2;
|
|
|
|
this.group = new THREE.Group();
|
|
this.group.position.copy(this.position);
|
|
|
|
this._buildMeshes();
|
|
this._buildLabel();
|
|
}
|
|
|
|
_buildMeshes() {
|
|
// Per-agent materials (need unique color + mutable emissiveIntensity)
|
|
const coreMat = new THREE.MeshStandardMaterial({
|
|
color: this.color,
|
|
emissive: this.color,
|
|
emissiveIntensity: 0.4,
|
|
roughness: 0.3,
|
|
metalness: 0.8,
|
|
});
|
|
|
|
this.core = new THREE.Mesh(SHARED_GEO.core, coreMat);
|
|
this.group.add(this.core);
|
|
|
|
const ringMat = new THREE.MeshBasicMaterial({ color: this.color, transparent: true, opacity: 0.5 });
|
|
this.ring = new THREE.Mesh(SHARED_GEO.ring, ringMat);
|
|
this.ring.rotation.x = Math.PI / 2;
|
|
this.group.add(this.ring);
|
|
|
|
const glowMat = new THREE.MeshBasicMaterial({
|
|
color: this.color,
|
|
transparent: true,
|
|
opacity: 0.05,
|
|
side: THREE.BackSide,
|
|
});
|
|
this.glow = new THREE.Mesh(SHARED_GEO.glow, glowMat);
|
|
this.group.add(this.glow);
|
|
|
|
const light = new THREE.PointLight(this.color, 1.5, 10);
|
|
this.group.add(light);
|
|
this.light = light;
|
|
}
|
|
|
|
_buildLabel() {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = 256; canvas.height = 64;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.fillStyle = 'rgba(0,0,0,0)';
|
|
ctx.fillRect(0, 0, 256, 64);
|
|
ctx.font = 'bold 22px Courier New';
|
|
ctx.fillStyle = colorToCss(this.color);
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(this.label, 128, 28);
|
|
ctx.font = '14px Courier New';
|
|
ctx.fillStyle = '#007722';
|
|
ctx.fillText(this.role.toUpperCase(), 128, 50);
|
|
|
|
const tex = new THREE.CanvasTexture(canvas);
|
|
const spriteMat = new THREE.SpriteMaterial({ map: tex, transparent: true });
|
|
this.sprite = new THREE.Sprite(spriteMat);
|
|
this.sprite.scale.set(2.4, 0.6, 1);
|
|
this.sprite.position.y = 2;
|
|
this.group.add(this.sprite);
|
|
}
|
|
|
|
update(time) {
|
|
const pulse = Math.sin(time * 0.002 + this.pulsePhase);
|
|
const active = this.state === 'active';
|
|
|
|
const intensity = active ? 0.6 + pulse * 0.4 : 0.2 + pulse * 0.1;
|
|
this.core.material.emissiveIntensity = intensity;
|
|
this.light.intensity = active ? 2 + pulse : 0.8 + pulse * 0.3;
|
|
|
|
const scale = active ? 1 + pulse * 0.08 : 1 + pulse * 0.03;
|
|
this.core.scale.setScalar(scale);
|
|
this.ring.rotation.y += active ? 0.03 : 0.008;
|
|
this.ring.material.opacity = 0.3 + pulse * 0.2;
|
|
|
|
this.group.position.y = this.position.y + Math.sin(time * 0.001 + this.pulsePhase) * 0.15;
|
|
}
|
|
|
|
setState(state) {
|
|
this.state = state;
|
|
}
|
|
|
|
/**
|
|
* Dispose per-agent GPU resources (materials + textures).
|
|
* Shared geometries are NOT disposed here — they outlive individual agents.
|
|
*/
|
|
dispose() {
|
|
this.core.material.dispose();
|
|
this.ring.material.dispose();
|
|
this.glow.material.dispose();
|
|
this.sprite.material.map.dispose();
|
|
this.sprite.material.dispose();
|
|
}
|
|
}
|
|
|
|
export function initAgents(sceneRef) {
|
|
scene = sceneRef;
|
|
|
|
AGENT_DEFS.forEach(def => {
|
|
const agent = new Agent(def);
|
|
agents.set(def.id, agent);
|
|
scene.add(agent.group);
|
|
});
|
|
|
|
buildConnectionLines();
|
|
}
|
|
|
|
function buildConnectionLines() {
|
|
// Dispose old line geometries before removing
|
|
connectionLines.forEach(l => {
|
|
scene.remove(l);
|
|
l.geometry.dispose();
|
|
// Material is shared — do NOT dispose here
|
|
});
|
|
connectionLines = [];
|
|
|
|
const agentList = [...agents.values()];
|
|
|
|
for (let i = 0; i < agentList.length; i++) {
|
|
for (let j = i + 1; j < agentList.length; j++) {
|
|
const a = agentList[i];
|
|
const b = agentList[j];
|
|
if (a.position.distanceTo(b.position) <= 14) {
|
|
const points = [a.position.clone(), b.position.clone()];
|
|
const geo = new THREE.BufferGeometry().setFromPoints(points);
|
|
const line = new THREE.Line(geo, CONNECTION_MAT);
|
|
connectionLines.push(line);
|
|
scene.add(line);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function updateAgents(time) {
|
|
agents.forEach(agent => agent.update(time));
|
|
}
|
|
|
|
export function getAgentCount() {
|
|
return agents.size;
|
|
}
|
|
|
|
export function setAgentState(agentId, state) {
|
|
const agent = agents.get(agentId);
|
|
if (agent) agent.setState(state);
|
|
}
|
|
|
|
export function getAgentDefs() {
|
|
return [...agents.values()].map(a => ({
|
|
id: a.id, label: a.label, role: a.role, color: a.color, state: a.state,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Dynamic agent hot-add (Issue #12).
|
|
*
|
|
* Spawns a new 3D agent at runtime when the backend sends an agent_joined event.
|
|
* If x/z are not provided, the agent is auto-placed in the next available slot
|
|
* on a circle around the origin (radius 8) to avoid overlapping existing agents.
|
|
*
|
|
* @param {object} def — Agent definition { id, label, color, role, direction, x, z }
|
|
* @returns {boolean} true if added, false if agent with that id already exists
|
|
*/
|
|
export function addAgent(def) {
|
|
if (agents.has(def.id)) {
|
|
console.warn('[Agents] Agent', def.id, 'already exists — skipping hot-add');
|
|
return false;
|
|
}
|
|
|
|
// Auto-place if no position given
|
|
if (def.x == null || def.z == null) {
|
|
const placed = autoPlace();
|
|
def.x = placed.x;
|
|
def.z = placed.z;
|
|
}
|
|
|
|
const agent = new Agent(def);
|
|
agents.set(def.id, agent);
|
|
scene.add(agent.group);
|
|
|
|
// Rebuild connection lines to include the new agent
|
|
buildConnectionLines();
|
|
|
|
console.info('[Agents] Hot-added agent:', def.id, 'at', def.x, def.z);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Find an unoccupied position on a circle around the origin.
|
|
* Tries radius 8 first (same ring as the original 4), then expands.
|
|
*/
|
|
function autoPlace() {
|
|
const existing = [...agents.values()].map(a => a.position);
|
|
const RADIUS_START = 8;
|
|
const RADIUS_STEP = 4;
|
|
const ANGLE_STEP = Math.PI / 6; // 30° increments = 12 slots per ring
|
|
const MIN_DISTANCE = 3; // minimum gap between agents
|
|
|
|
for (let r = RADIUS_START; r <= RADIUS_START + RADIUS_STEP * 3; r += RADIUS_STEP) {
|
|
for (let angle = 0; angle < Math.PI * 2; angle += ANGLE_STEP) {
|
|
const x = Math.round(r * Math.sin(angle) * 10) / 10;
|
|
const z = Math.round(r * Math.cos(angle) * 10) / 10;
|
|
const candidate = new THREE.Vector3(x, 0, z);
|
|
const tooClose = existing.some(p => p.distanceTo(candidate) < MIN_DISTANCE);
|
|
if (!tooClose) {
|
|
return { x, z };
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: random offset if all slots taken (very unlikely)
|
|
return { x: (Math.random() - 0.5) * 20, z: (Math.random() - 0.5) * 20 };
|
|
}
|
|
|
|
/**
|
|
* Remove an agent from the scene and dispose its resources.
|
|
* Useful for agent_left events.
|
|
*
|
|
* @param {string} agentId
|
|
* @returns {boolean} true if removed
|
|
*/
|
|
export function removeAgent(agentId) {
|
|
const agent = agents.get(agentId);
|
|
if (!agent) return false;
|
|
|
|
scene.remove(agent.group);
|
|
agent.dispose();
|
|
agents.delete(agentId);
|
|
buildConnectionLines();
|
|
|
|
console.info('[Agents] Removed agent:', agentId);
|
|
return true;
|
|
}
|