docs: add direction field to README custom-agent example

This commit is contained in:
Replit Agent
2026-03-18 23:52:06 +00:00
commit fe2d9a31e3
14 changed files with 1769 additions and 0 deletions

28
js/agent-defs.js Normal file
View File

@@ -0,0 +1,28 @@
/**
* agent-defs.js — Single source of truth for all agent definitions.
*
* To add a new agent, append one entry to AGENT_DEFS below and pick an
* unused (x, z) position. No other file needs to be edited.
*
* Fields:
* id — unique string key used in WebSocket messages and state maps
* label — display name shown in the 3D HUD and chat panel
* color — hex integer (0xRRGGBB) used for Three.js materials and lights
* role — human-readable role string shown under the label sprite
* direction — cardinal facing direction (for future mesh orientation use)
* x, z — world-space position on the horizontal plane (y is always 0)
*/
export const AGENT_DEFS = [
{ id: 'alpha', label: 'ALPHA', color: 0x00ff88, role: 'orchestrator', direction: 'north', x: 0, z: -6 },
{ id: 'beta', label: 'BETA', color: 0x00aaff, role: 'worker', direction: 'east', x: 6, z: 0 },
{ id: 'gamma', label: 'GAMMA', color: 0xff6600, role: 'validator', direction: 'south', x: 0, z: 6 },
{ id: 'delta', label: 'DELTA', color: 0xaa00ff, role: 'monitor', direction: 'west', x: -6, z: 0 },
];
/**
* Convert an integer color (e.g. 0x00ff88) to a CSS hex string ('#00ff88').
* Useful for DOM styling and canvas rendering.
*/
export function colorToCss(intColor) {
return '#' + intColor.toString(16).padStart(6, '0');
}

157
js/agents.js Normal file
View File

@@ -0,0 +1,157 @@
import * as THREE from 'three';
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
const agents = new Map();
let scene;
let connectionLines = [];
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() {
const mat = new THREE.MeshStandardMaterial({
color: this.color,
emissive: this.color,
emissiveIntensity: 0.4,
roughness: 0.3,
metalness: 0.8,
});
const geo = new THREE.IcosahedronGeometry(0.7, 1);
this.core = new THREE.Mesh(geo, mat);
this.group.add(this.core);
const ringGeo = new THREE.TorusGeometry(1.1, 0.04, 8, 32);
const ringMat = new THREE.MeshBasicMaterial({ color: this.color, transparent: true, opacity: 0.5 });
this.ring = new THREE.Mesh(ringGeo, ringMat);
this.ring.rotation.x = Math.PI / 2;
this.group.add(this.ring);
const glowGeo = new THREE.SphereGeometry(1.3, 16, 16);
const glowMat = new THREE.MeshBasicMaterial({
color: this.color,
transparent: true,
opacity: 0.05,
side: THREE.BackSide,
});
this.glow = new THREE.Mesh(glowGeo, 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;
}
}
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() {
connectionLines.forEach(l => scene.remove(l));
connectionLines = [];
const agentList = [...agents.values()];
const lineMat = new THREE.LineBasicMaterial({
color: 0x003300,
transparent: true,
opacity: 0.4,
});
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) <= 8) {
const points = [a.position.clone(), b.position.clone()];
const geo = new THREE.BufferGeometry().setFromPoints(points);
const line = new THREE.Line(geo, lineMat.clone());
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,
}));
}

85
js/effects.js vendored Normal file
View File

@@ -0,0 +1,85 @@
import * as THREE from 'three';
let rainParticles;
let rainPositions;
let rainVelocities;
const RAIN_COUNT = 2000;
export function initEffects(scene) {
initMatrixRain(scene);
initStarfield(scene);
}
function initMatrixRain(scene) {
const geo = new THREE.BufferGeometry();
const positions = new Float32Array(RAIN_COUNT * 3);
const velocities = new Float32Array(RAIN_COUNT);
const colors = new Float32Array(RAIN_COUNT * 3);
for (let i = 0; i < RAIN_COUNT; i++) {
positions[i * 3] = (Math.random() - 0.5) * 100;
positions[i * 3 + 1] = Math.random() * 50 + 5;
positions[i * 3 + 2] = (Math.random() - 0.5) * 100;
velocities[i] = 0.05 + Math.random() * 0.15;
const brightness = 0.3 + Math.random() * 0.7;
colors[i * 3] = 0;
colors[i * 3 + 1] = brightness;
colors[i * 3 + 2] = 0;
}
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
rainPositions = positions;
rainVelocities = velocities;
const mat = new THREE.PointsMaterial({
size: 0.12,
vertexColors: true,
transparent: true,
opacity: 0.7,
sizeAttenuation: true,
});
rainParticles = new THREE.Points(geo, mat);
scene.add(rainParticles);
}
function initStarfield(scene) {
const count = 500;
const geo = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
positions[i * 3] = (Math.random() - 0.5) * 300;
positions[i * 3 + 1] = Math.random() * 80 + 10;
positions[i * 3 + 2] = (Math.random() - 0.5) * 300;
}
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const mat = new THREE.PointsMaterial({
color: 0x003300,
size: 0.08,
transparent: true,
opacity: 0.5,
});
const stars = new THREE.Points(geo, mat);
scene.add(stars);
}
export function updateEffects(_time) {
if (!rainParticles) return;
for (let i = 0; i < RAIN_COUNT; i++) {
rainPositions[i * 3 + 1] -= rainVelocities[i];
if (rainPositions[i * 3 + 1] < -1) {
rainPositions[i * 3 + 1] = 40 + Math.random() * 20;
rainPositions[i * 3] = (Math.random() - 0.5) * 100;
rainPositions[i * 3 + 2] = (Math.random() - 0.5) * 100;
}
}
rainParticles.geometry.attributes.position.needsUpdate = true;
}

21
js/interaction.js Normal file
View File

@@ -0,0 +1,21 @@
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
let controls;
export function initInteraction(camera, renderer) {
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.screenSpacePanning = false;
controls.minDistance = 5;
controls.maxDistance = 80;
controls.maxPolarAngle = Math.PI / 2.1;
controls.target.set(0, 0, 0);
controls.update();
renderer.domElement.addEventListener('contextmenu', e => e.preventDefault());
}
export function updateControls() {
if (controls) controls.update();
}

49
js/main.js Normal file
View File

@@ -0,0 +1,49 @@
import { initWorld, onWindowResize } from './world.js';
import { initAgents, updateAgents, getAgentCount } from './agents.js';
import { initEffects, updateEffects } from './effects.js';
import { initUI, updateUI } from './ui.js';
import { initInteraction } from './interaction.js';
import { initWebSocket, getConnectionState, getJobCount } from './websocket.js';
let frameCount = 0;
let lastFpsTime = performance.now();
let currentFps = 0;
function main() {
const { scene, camera, renderer } = initWorld();
initEffects(scene);
initAgents(scene);
initInteraction(camera, renderer);
initUI();
initWebSocket(scene);
window.addEventListener('resize', () => onWindowResize(camera, renderer));
function animate() {
requestAnimationFrame(animate);
const now = performance.now();
frameCount++;
if (now - lastFpsTime >= 1000) {
currentFps = Math.round(frameCount * 1000 / (now - lastFpsTime));
frameCount = 0;
lastFpsTime = now;
}
updateEffects(now);
updateAgents(now);
updateUI({
fps: currentFps,
agentCount: getAgentCount(),
jobCount: getJobCount(),
connectionState: getConnectionState(),
});
renderer.render(scene, camera);
}
animate();
}
main();

84
js/ui.js Normal file
View File

@@ -0,0 +1,84 @@
import { getAgentDefs } from './agents.js';
import { colorToCss } from './agent-defs.js';
const $agentCount = document.getElementById('agent-count');
const $activeJobs = document.getElementById('active-jobs');
const $fps = document.getElementById('fps');
const $agentList = document.getElementById('agent-list');
const $connStatus = document.getElementById('connection-status');
const $chatPanel = document.getElementById('chat-panel');
const MAX_CHAT_ENTRIES = 12;
const chatEntries = [];
export function initUI() {
renderAgentList();
}
function renderAgentList() {
const defs = getAgentDefs();
$agentList.innerHTML = defs.map(a => {
const css = colorToCss(a.color);
return `<div class="agent-row">
<span class="label">[</span>
<span style="color:${css}">${a.label}</span>
<span class="label">]</span>
<span id="agent-state-${a.id}" style="color:#003300"> IDLE</span>
</div>`;
}).join('');
}
export function updateUI({ fps, agentCount, jobCount, connectionState }) {
$fps.textContent = `FPS: ${fps}`;
$agentCount.textContent = `AGENTS: ${agentCount}`;
$activeJobs.textContent = `JOBS: ${jobCount}`;
if (connectionState === 'connected') {
$connStatus.textContent = '● CONNECTED';
$connStatus.className = 'connected';
} else if (connectionState === 'connecting') {
$connStatus.textContent = '◌ CONNECTING...';
$connStatus.className = '';
} else {
$connStatus.textContent = '○ OFFLINE';
$connStatus.className = '';
}
const defs = getAgentDefs();
defs.forEach(a => {
const el = document.getElementById(`agent-state-${a.id}`);
if (el) {
el.textContent = ` ${a.state.toUpperCase()}`;
el.style.color = a.state === 'active' ? '#00ff41' : '#003300';
}
});
}
/**
* Append a line to the chat panel.
* @param {string} agentLabel — display name
* @param {string} message — message text (HTML-escaped before insertion)
* @param {string} cssColor — CSS color string, e.g. '#00ff88'
*/
export function appendChatMessage(agentLabel, message, cssColor) {
const color = cssColor || '#00ff41';
const entry = document.createElement('div');
entry.className = 'chat-entry';
entry.innerHTML = `<span class="agent-name" style="color:${color}">${agentLabel}</span>: ${escapeHtml(message)}`;
chatEntries.push(entry);
if (chatEntries.length > MAX_CHAT_ENTRIES) {
const removed = chatEntries.shift();
$chatPanel.removeChild(removed);
}
$chatPanel.appendChild(entry);
$chatPanel.scrollTop = $chatPanel.scrollHeight;
}
function escapeHtml(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}

115
js/websocket.js Normal file
View File

@@ -0,0 +1,115 @@
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
import { setAgentState } from './agents.js';
import { appendChatMessage } from './ui.js';
const WS_URL = import.meta.env.VITE_WS_URL || '';
const agentById = Object.fromEntries(AGENT_DEFS.map(d => [d.id, d]));
let ws = null;
let connectionState = 'disconnected';
let jobCount = 0;
let reconnectTimer = null;
const RECONNECT_DELAY_MS = 5000;
export function initWebSocket(_scene) {
if (!WS_URL) {
connectionState = 'disconnected';
return;
}
connect();
}
function connect() {
if (ws) {
ws.onclose = null;
ws.close();
}
connectionState = 'connecting';
try {
ws = new WebSocket(WS_URL);
} catch {
connectionState = 'disconnected';
scheduleReconnect();
return;
}
ws.onopen = () => {
connectionState = 'connected';
clearTimeout(reconnectTimer);
ws.send(JSON.stringify({
type: 'subscribe',
channel: 'agents',
clientId: crypto.randomUUID(),
}));
};
ws.onmessage = (event) => {
try {
handleMessage(JSON.parse(event.data));
} catch {
}
};
ws.onerror = () => {
connectionState = 'disconnected';
};
ws.onclose = () => {
connectionState = 'disconnected';
scheduleReconnect();
};
}
function scheduleReconnect() {
clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS);
}
function handleMessage(msg) {
switch (msg.type) {
case 'agent_state': {
if (msg.agentId && msg.state) {
setAgentState(msg.agentId, msg.state);
}
break;
}
case 'job_started': {
jobCount++;
if (msg.agentId) setAgentState(msg.agentId, 'active');
logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} started`);
break;
}
case 'job_completed': {
if (jobCount > 0) jobCount--;
if (msg.agentId) setAgentState(msg.agentId, 'idle');
logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} completed`);
break;
}
case 'chat': {
const def = agentById[msg.agentId];
if (def && msg.text) {
appendChatMessage(def.label, msg.text, colorToCss(def.color));
}
break;
}
case 'agent_count':
break;
default:
break;
}
}
function logEvent(text) {
appendChatMessage('SYS', text, colorToCss(0x003300));
}
export function getConnectionState() {
return connectionState;
}
export function getJobCount() {
return jobCount;
}

60
js/world.js Normal file
View File

@@ -0,0 +1,60 @@
import * as THREE from 'three';
let scene, camera, renderer;
export function initWorld() {
scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);
scene.fog = new THREE.FogExp2(0x000000, 0.035);
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 500);
camera.position.set(0, 12, 28);
camera.lookAt(0, 0, 0);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.prepend(renderer.domElement);
addLights(scene);
addGrid(scene);
return { scene, camera, renderer };
}
function addLights(scene) {
const ambient = new THREE.AmbientLight(0x001a00, 0.6);
scene.add(ambient);
const point = new THREE.PointLight(0x00ff41, 2, 80);
point.position.set(0, 20, 0);
scene.add(point);
const fill = new THREE.DirectionalLight(0x003300, 0.4);
fill.position.set(-10, 10, 10);
scene.add(fill);
}
function addGrid(scene) {
const grid = new THREE.GridHelper(100, 40, 0x003300, 0x001a00);
grid.position.y = -0.01;
scene.add(grid);
const planeGeo = new THREE.PlaneGeometry(100, 100);
const planeMat = new THREE.MeshBasicMaterial({
color: 0x000a00,
transparent: true,
opacity: 0.5,
});
const plane = new THREE.Mesh(planeGeo, planeMat);
plane.rotation.x = -Math.PI / 2;
plane.position.y = -0.02;
scene.add(plane);
}
export function onWindowResize(camera, renderer) {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}