3D visualization for AI agent swarms built with Three.js. Matrix green/noir cyberpunk aesthetic. - 4 agents: Timmy (orchestrator), Forge (builder), Seer (planner), Echo (comms) - Central core pillar, animated green grid, digital rain - Agent info panels, chat, task list, memory views - WebSocket protocol for real-time state updates - iPad-ready: touch controls, add-to-homescreen - Post-processing: bloom, scanlines, vignette - No build step — pure ES modules via esm.sh CDN Created with Perplexity Computer
525 lines
16 KiB
JavaScript
525 lines
16 KiB
JavaScript
// ===== Agents: Geometry, animations, labels, task objects =====
|
|
import * as THREE from 'three';
|
|
import { AGENT_DEFS, AGENT_IDS } from './websocket.js';
|
|
|
|
const AGENT_POSITIONS = {
|
|
timmy: { x: 0, z: -14 }, // North
|
|
forge: { x: 14, z: 0 }, // East
|
|
seer: { x: 0, z: 14 }, // South
|
|
echo: { x: -14, z: 0 }, // West
|
|
};
|
|
|
|
const AGENT_COLORS = {
|
|
timmy: 0x00ff41,
|
|
forge: 0xff8c00,
|
|
seer: 0x9d4edd,
|
|
echo: 0x00d4ff,
|
|
};
|
|
|
|
// Create text sprite
|
|
function createLabel(text, color) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = 256;
|
|
canvas.height = 64;
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
ctx.clearRect(0, 0, 256, 64);
|
|
ctx.font = '600 28px "JetBrains Mono", monospace';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillStyle = color;
|
|
ctx.shadowColor = color;
|
|
ctx.shadowBlur = 12;
|
|
ctx.fillText(text, 128, 32);
|
|
ctx.shadowBlur = 0;
|
|
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
texture.needsUpdate = true;
|
|
|
|
const mat = new THREE.SpriteMaterial({
|
|
map: texture,
|
|
transparent: true,
|
|
depthWrite: false,
|
|
});
|
|
const sprite = new THREE.Sprite(mat);
|
|
sprite.scale.set(4, 1, 1);
|
|
return sprite;
|
|
}
|
|
|
|
// Create hex platform
|
|
function createHexPlatform(color) {
|
|
const shape = new THREE.Shape();
|
|
const r = 3.5;
|
|
for (let i = 0; i < 6; i++) {
|
|
const angle = (Math.PI / 3) * i - Math.PI / 6;
|
|
const x = r * Math.cos(angle);
|
|
const y = r * Math.sin(angle);
|
|
if (i === 0) shape.moveTo(x, y);
|
|
else shape.lineTo(x, y);
|
|
}
|
|
shape.closePath();
|
|
|
|
const extrudeSettings = { depth: 0.2, bevelEnabled: false };
|
|
const geom = new THREE.ExtrudeGeometry(shape, extrudeSettings);
|
|
geom.rotateX(-Math.PI / 2);
|
|
|
|
const mat = new THREE.MeshBasicMaterial({
|
|
color: color,
|
|
transparent: true,
|
|
opacity: 0.08,
|
|
});
|
|
const mesh = new THREE.Mesh(geom, mat);
|
|
|
|
// Hex border
|
|
const edgePoints = [];
|
|
for (let i = 0; i <= 6; i++) {
|
|
const angle = (Math.PI / 3) * (i % 6) - Math.PI / 6;
|
|
edgePoints.push(new THREE.Vector3(r * Math.cos(angle), 0.22, r * Math.sin(angle)));
|
|
}
|
|
const lineGeom = new THREE.BufferGeometry().setFromPoints(edgePoints);
|
|
const lineMat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.55 });
|
|
const line = new THREE.Line(lineGeom, lineMat);
|
|
|
|
const group = new THREE.Group();
|
|
group.add(mesh);
|
|
group.add(line);
|
|
return group;
|
|
}
|
|
|
|
// ===== TIMMY - Wireframe humanoid =====
|
|
function createTimmy() {
|
|
const group = new THREE.Group();
|
|
const color = AGENT_COLORS.timmy;
|
|
const mat = new THREE.MeshBasicMaterial({ color, wireframe: true, transparent: true, opacity: 0.8 });
|
|
|
|
// Head
|
|
const head = new THREE.Mesh(new THREE.IcosahedronGeometry(0.45, 1), mat);
|
|
head.position.y = 3.2;
|
|
group.add(head);
|
|
|
|
// Torso
|
|
const torso = new THREE.Mesh(new THREE.BoxGeometry(0.9, 1.4, 0.5), mat);
|
|
torso.position.y = 2.0;
|
|
group.add(torso);
|
|
|
|
// Arms
|
|
[-0.65, 0.65].forEach(x => {
|
|
const arm = new THREE.Mesh(new THREE.BoxGeometry(0.2, 1.2, 0.2), mat);
|
|
arm.position.set(x, 2.0, 0);
|
|
group.add(arm);
|
|
});
|
|
|
|
// Legs
|
|
[-0.25, 0.25].forEach(x => {
|
|
const leg = new THREE.Mesh(new THREE.BoxGeometry(0.25, 1.2, 0.25), mat);
|
|
leg.position.set(x, 0.7, 0);
|
|
group.add(leg);
|
|
});
|
|
|
|
// Glow point
|
|
const glow = new THREE.PointLight(color, 1, 8);
|
|
glow.position.y = 2;
|
|
group.add(glow);
|
|
|
|
return group;
|
|
}
|
|
|
|
// ===== FORGE - Anvil/hammer geometric =====
|
|
function createForge() {
|
|
const group = new THREE.Group();
|
|
const color = AGENT_COLORS.forge;
|
|
const mat = new THREE.MeshBasicMaterial({ color, wireframe: true, transparent: true, opacity: 0.6 });
|
|
const wireMat = new THREE.MeshBasicMaterial({ color, wireframe: true, transparent: true, opacity: 0.5 });
|
|
|
|
// Base cube (anvil)
|
|
const base = new THREE.Mesh(new THREE.BoxGeometry(1.4, 0.8, 1.0), mat);
|
|
base.position.y = 1.2;
|
|
group.add(base);
|
|
|
|
// Top pyramid (hammer head)
|
|
const pyramid = new THREE.Mesh(new THREE.ConeGeometry(0.8, 1.2, 4), wireMat);
|
|
pyramid.position.y = 2.6;
|
|
pyramid.rotation.y = Math.PI / 4;
|
|
group.add(pyramid);
|
|
|
|
// Floating cubes
|
|
for (let i = 0; i < 3; i++) {
|
|
const cube = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.3, 0.3), wireMat.clone());
|
|
cube.position.set(
|
|
Math.cos(i * Math.PI * 2 / 3) * 1.5,
|
|
2.5 + i * 0.3,
|
|
Math.sin(i * Math.PI * 2 / 3) * 1.5
|
|
);
|
|
cube.userData.orbitIndex = i;
|
|
group.add(cube);
|
|
}
|
|
|
|
const glow = new THREE.PointLight(color, 1, 8);
|
|
glow.position.y = 2;
|
|
group.add(glow);
|
|
|
|
return group;
|
|
}
|
|
|
|
// ===== SEER - Crystal ball / icosahedron =====
|
|
function createSeer() {
|
|
const group = new THREE.Group();
|
|
const color = AGENT_COLORS.seer;
|
|
const mat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.6 });
|
|
const wireMat = new THREE.MeshBasicMaterial({ color, wireframe: true, transparent: true, opacity: 0.8 });
|
|
|
|
// Main crystal (icosahedron)
|
|
const crystal = new THREE.Mesh(new THREE.IcosahedronGeometry(1.0, 0), wireMat);
|
|
crystal.position.y = 2.5;
|
|
crystal.userData.isCrystal = true;
|
|
group.add(crystal);
|
|
|
|
// Inner glow sphere
|
|
const inner = new THREE.Mesh(new THREE.SphereGeometry(0.5, 16, 16), mat);
|
|
inner.position.y = 2.5;
|
|
group.add(inner);
|
|
|
|
// Orbiting small spheres
|
|
for (let i = 0; i < 5; i++) {
|
|
const orb = new THREE.Mesh(new THREE.SphereGeometry(0.12, 8, 8), mat.clone());
|
|
orb.userData.orbitIndex = i;
|
|
orb.userData.orbitRadius = 1.8;
|
|
orb.position.y = 2.5;
|
|
group.add(orb);
|
|
}
|
|
|
|
const glow = new THREE.PointLight(color, 1, 8);
|
|
glow.position.y = 2.5;
|
|
group.add(glow);
|
|
|
|
return group;
|
|
}
|
|
|
|
// ===== ECHO - Concentric pulse rings =====
|
|
function createEcho() {
|
|
const group = new THREE.Group();
|
|
const color = AGENT_COLORS.echo;
|
|
|
|
// Central sphere
|
|
const coreMat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.7 });
|
|
const core = new THREE.Mesh(new THREE.SphereGeometry(0.4, 16, 16), coreMat);
|
|
core.position.y = 2.2;
|
|
group.add(core);
|
|
|
|
// Concentric rings
|
|
for (let i = 0; i < 4; i++) {
|
|
const ring = new THREE.Mesh(
|
|
new THREE.TorusGeometry(1.0 + i * 0.6, 0.04, 8, 48),
|
|
new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.65 - i * 0.1 })
|
|
);
|
|
ring.position.y = 2.2;
|
|
ring.rotation.x = Math.PI / 2;
|
|
ring.userData.ringIndex = i;
|
|
ring.userData.baseScale = 1;
|
|
group.add(ring);
|
|
}
|
|
|
|
const glow = new THREE.PointLight(color, 1, 8);
|
|
glow.position.y = 2.2;
|
|
group.add(glow);
|
|
|
|
return group;
|
|
}
|
|
|
|
const AGENT_CREATORS = { timmy: createTimmy, forge: createForge, seer: createSeer, echo: createEcho };
|
|
|
|
// ===== Task Objects =====
|
|
const TASK_STATUS_COLORS = {
|
|
pending: 0xffffff,
|
|
in_progress: 0xff8c00,
|
|
completed: 0x00ff41,
|
|
failed: 0xff3333,
|
|
};
|
|
|
|
function createTaskObject(status) {
|
|
const geoms = [
|
|
new THREE.IcosahedronGeometry(0.25, 0),
|
|
new THREE.OctahedronGeometry(0.25, 0),
|
|
new THREE.TetrahedronGeometry(0.3, 0),
|
|
];
|
|
const geom = geoms[Math.floor(Math.random() * geoms.length)];
|
|
const color = TASK_STATUS_COLORS[status] || 0xffffff;
|
|
const mat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.7, wireframe: true });
|
|
const mesh = new THREE.Mesh(geom, mat);
|
|
return mesh;
|
|
}
|
|
|
|
// ===== Connection Lines =====
|
|
function createConnectionLine(scene, fromPos, toPos, color = 0x00ff41) {
|
|
const points = [
|
|
new THREE.Vector3(fromPos.x, 2, fromPos.z),
|
|
new THREE.Vector3(toPos.x, 2, toPos.z),
|
|
];
|
|
const geom = new THREE.BufferGeometry().setFromPoints(points);
|
|
const mat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.3 });
|
|
const line = new THREE.Line(geom, mat);
|
|
line.userData.isConnection = true;
|
|
scene.add(line);
|
|
return line;
|
|
}
|
|
|
|
// ===== Main Agents System =====
|
|
export function createAgents(scene) {
|
|
const agentGroups = {};
|
|
const agentPlatforms = {};
|
|
const taskObjects = {};
|
|
const connectionLines = {};
|
|
const agentStates = {};
|
|
|
|
// Create each agent
|
|
AGENT_IDS.forEach(id => {
|
|
const pos = AGENT_POSITIONS[id];
|
|
const color = AGENT_COLORS[id];
|
|
const colorHex = '#' + color.toString(16).padStart(6, '0');
|
|
|
|
// Platform
|
|
const platform = createHexPlatform(color);
|
|
platform.position.set(pos.x, 0, pos.z);
|
|
scene.add(platform);
|
|
agentPlatforms[id] = platform;
|
|
|
|
// Agent geometry
|
|
const agentGeo = AGENT_CREATORS[id]();
|
|
agentGeo.position.set(pos.x, 0, pos.z);
|
|
agentGeo.name = `agent_${id}`;
|
|
agentGeo.userData.agentId = id;
|
|
agentGeo.userData.isAgent = true;
|
|
scene.add(agentGeo);
|
|
agentGroups[id] = agentGeo;
|
|
|
|
// Label
|
|
const label = createLabel(AGENT_DEFS[id].name.toUpperCase(), colorHex);
|
|
label.position.set(pos.x, 5, pos.z);
|
|
scene.add(label);
|
|
|
|
// State
|
|
agentStates[id] = {
|
|
state: 'idle',
|
|
glowIntensity: 0.5,
|
|
targetGlow: 0.5,
|
|
bobOffset: Math.random() * Math.PI * 2,
|
|
};
|
|
});
|
|
|
|
return {
|
|
agentGroups,
|
|
agentPlatforms,
|
|
taskObjects,
|
|
connectionLines,
|
|
agentStates,
|
|
|
|
// Get the raycasting targets
|
|
getInteractables() {
|
|
const targets = [];
|
|
Object.values(agentGroups).forEach(group => {
|
|
group.traverse(child => {
|
|
if (child.isMesh) {
|
|
child.userData.agentId = group.userData.agentId;
|
|
child.userData.isAgent = true;
|
|
targets.push(child);
|
|
}
|
|
});
|
|
});
|
|
return targets;
|
|
},
|
|
|
|
setAgentState(agentId, state, glowIntensity) {
|
|
if (!agentStates[agentId]) return;
|
|
agentStates[agentId].state = state;
|
|
agentStates[agentId].targetGlow = glowIntensity;
|
|
},
|
|
|
|
highlightAgent(agentId, highlight) {
|
|
const group = agentGroups[agentId];
|
|
if (!group) return;
|
|
group.traverse(child => {
|
|
if (child.isMesh && child.material) {
|
|
if (highlight) {
|
|
child.material._origOpacity = child.material._origOpacity ?? child.material.opacity;
|
|
child.material.opacity = Math.min(1, child.material._origOpacity + 0.3);
|
|
} else if (child.material._origOpacity !== undefined) {
|
|
child.material.opacity = child.material._origOpacity;
|
|
}
|
|
}
|
|
});
|
|
},
|
|
|
|
updateConnection(agentA, agentB, active) {
|
|
const key = [agentA, agentB].sort().join('-');
|
|
if (active) {
|
|
if (!connectionLines[key]) {
|
|
const posA = AGENT_POSITIONS[agentA];
|
|
const posB = AGENT_POSITIONS[agentB];
|
|
if (posA && posB) {
|
|
connectionLines[key] = createConnectionLine(scene, posA, posB);
|
|
}
|
|
}
|
|
} else {
|
|
if (connectionLines[key]) {
|
|
connectionLines[key].geometry.dispose();
|
|
connectionLines[key].material.dispose();
|
|
scene.remove(connectionLines[key]);
|
|
delete connectionLines[key];
|
|
}
|
|
}
|
|
},
|
|
|
|
addTaskObject(taskId, agentId, status) {
|
|
if (taskObjects[taskId]) return;
|
|
const pos = AGENT_POSITIONS[agentId];
|
|
if (!pos) return;
|
|
const obj = createTaskObject(status);
|
|
obj.userData.taskId = taskId;
|
|
obj.userData.agentId = agentId;
|
|
obj.userData.orbitAngle = Math.random() * Math.PI * 2;
|
|
obj.userData.orbitRadius = 4.5 + Math.random() * 1.5;
|
|
obj.userData.orbitSpeed = 0.2 + Math.random() * 0.3;
|
|
obj.userData.orbitY = 1.5 + Math.random() * 2;
|
|
obj.position.set(pos.x, obj.userData.orbitY, pos.z);
|
|
scene.add(obj);
|
|
taskObjects[taskId] = obj;
|
|
},
|
|
|
|
updateTaskObject(taskId, status) {
|
|
const obj = taskObjects[taskId];
|
|
if (!obj) return;
|
|
const color = TASK_STATUS_COLORS[status] || 0xffffff;
|
|
obj.material.color.setHex(color);
|
|
},
|
|
|
|
removeTaskObject(taskId) {
|
|
const obj = taskObjects[taskId];
|
|
if (!obj) return;
|
|
obj.geometry.dispose();
|
|
obj.material.dispose();
|
|
scene.remove(obj);
|
|
delete taskObjects[taskId];
|
|
},
|
|
|
|
update(time, delta) {
|
|
// Animate agents
|
|
AGENT_IDS.forEach(id => {
|
|
const group = agentGroups[id];
|
|
const state = agentStates[id];
|
|
const pos = AGENT_POSITIONS[id];
|
|
|
|
// Floating bob
|
|
const bobSpeed = state.state === 'working' ? 2.5 : 1.2;
|
|
const bobAmp = state.state === 'working' ? 0.3 : 0.15;
|
|
const bob = Math.sin(time * bobSpeed + state.bobOffset) * bobAmp;
|
|
group.position.y = bob;
|
|
|
|
// Slow rotation
|
|
const rotSpeed = state.state === 'working' ? 0.4 : 0.15;
|
|
group.rotation.y = time * rotSpeed;
|
|
|
|
// Glow intensity lerp
|
|
state.glowIntensity += (state.targetGlow - state.glowIntensity) * delta * 2;
|
|
group.traverse(child => {
|
|
if (child.isPointLight) {
|
|
child.intensity = state.glowIntensity * 2;
|
|
}
|
|
});
|
|
|
|
// Agent-specific animations
|
|
if (id === 'forge') {
|
|
group.children.forEach(child => {
|
|
if (child.userData.orbitIndex !== undefined) {
|
|
const i = child.userData.orbitIndex;
|
|
const angle = time * 0.8 + i * (Math.PI * 2 / 3);
|
|
child.position.x = pos.x + Math.cos(angle) * 1.5;
|
|
child.position.z = pos.z + Math.sin(angle) * 1.5;
|
|
child.position.y = 2.5 + Math.sin(time * 1.5 + i) * 0.3;
|
|
child.rotation.x = time;
|
|
child.rotation.z = time * 0.7;
|
|
}
|
|
});
|
|
}
|
|
|
|
if (id === 'seer') {
|
|
group.children.forEach(child => {
|
|
if (child.userData.isCrystal) {
|
|
child.rotation.x = time * 0.5;
|
|
child.rotation.z = time * 0.3;
|
|
}
|
|
if (child.userData.orbitIndex !== undefined) {
|
|
const i = child.userData.orbitIndex;
|
|
const angle = time * 0.6 + i * (Math.PI * 2 / 5);
|
|
const r = child.userData.orbitRadius;
|
|
child.position.x = pos.x + Math.cos(angle) * r;
|
|
child.position.z = pos.z + Math.sin(angle) * r;
|
|
child.position.y = 2.5 + Math.sin(time + i * 0.5) * 0.5;
|
|
}
|
|
});
|
|
}
|
|
|
|
if (id === 'echo') {
|
|
group.children.forEach(child => {
|
|
if (child.userData.ringIndex !== undefined) {
|
|
const i = child.userData.ringIndex;
|
|
const pulse = Math.sin(time * 2 - i * 0.5) * 0.5 + 0.5;
|
|
const scale = 1 + pulse * 0.3;
|
|
child.scale.set(scale, scale, scale);
|
|
child.material.opacity = (0.6 - i * 0.12) * (0.5 + pulse * 0.5);
|
|
child.rotation.z = time * 0.3 * (i % 2 === 0 ? 1 : -1);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Animate task objects
|
|
Object.values(taskObjects).forEach(obj => {
|
|
const pos = AGENT_POSITIONS[obj.userData.agentId];
|
|
if (!pos) return;
|
|
obj.userData.orbitAngle += obj.userData.orbitSpeed * delta;
|
|
const angle = obj.userData.orbitAngle;
|
|
const r = obj.userData.orbitRadius;
|
|
obj.position.x = pos.x + Math.cos(angle) * r;
|
|
obj.position.z = pos.z + Math.sin(angle) * r;
|
|
obj.position.y = obj.userData.orbitY + Math.sin(time * 1.5 + angle) * 0.3;
|
|
obj.rotation.x = time * 0.8;
|
|
obj.rotation.z = time * 0.5;
|
|
});
|
|
|
|
// Animate connection lines (pulse opacity)
|
|
Object.values(connectionLines).forEach(line => {
|
|
line.material.opacity = 0.15 + Math.sin(time * 3) * 0.15;
|
|
});
|
|
},
|
|
|
|
dispose() {
|
|
Object.values(agentGroups).forEach(group => {
|
|
group.traverse(obj => {
|
|
if (obj.geometry) obj.geometry.dispose();
|
|
if (obj.material) obj.material.dispose();
|
|
});
|
|
scene.remove(group);
|
|
});
|
|
Object.values(agentPlatforms).forEach(p => {
|
|
p.traverse(obj => {
|
|
if (obj.geometry) obj.geometry.dispose();
|
|
if (obj.material) obj.material.dispose();
|
|
});
|
|
scene.remove(p);
|
|
});
|
|
Object.values(taskObjects).forEach(obj => {
|
|
obj.geometry.dispose();
|
|
obj.material.dispose();
|
|
scene.remove(obj);
|
|
});
|
|
Object.values(connectionLines).forEach(line => {
|
|
line.geometry.dispose();
|
|
line.material.dispose();
|
|
scene.remove(line);
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
export { AGENT_POSITIONS, AGENT_COLORS };
|