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; } /** * Snapshot current agent states for preservation across WebGL context loss. * @returns {Object.} agentId → state string */ export function getAgentStates() { const snapshot = {}; for (const [id, agent] of agents) { snapshot[id] = agent.state || 'idle'; } return snapshot; } /** * Reapply a state snapshot after world rebuild. * @param {Object.} snapshot */ export function applyAgentStates(snapshot) { if (!snapshot) return; for (const [id, state] of Object.entries(snapshot)) { const agent = agents.get(id); if (agent) agent.state = state; } } /** * Dispose all agent resources (used on world teardown). */ export function disposeAgents() { for (const [id, agent] of agents) { scene.remove(agent.group); agent.dispose(); } agents.clear(); }