From 3d97c0f1775f4a5c4a21fd35bf9e7e236927bf83 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Mon, 23 Mar 2026 18:27:20 -0400 Subject: [PATCH] feat: Add 3D job type indicators in the Workshop Implements 3D visual indicators for job types in the Workshop area, enhancing visual feedback during job execution. - Exports from for external object management. - Introduces , , and functions in to manage the lifecycle and animation of category-specific 3D objects (quill, brackets, spider, lightbulb, magnifying glass, generic orb). - Integrates indicator spawning and removal with and WebSocket events in , positioning them dynamically near the agent. - Adds to the main animation loop in for continuous bobbing and rotation. Fixes #16 --- the-matrix/js/effects.js | 202 ++++++++++++++++++++++++++++++++++++- the-matrix/js/main.js | 3 +- the-matrix/js/websocket.js | 30 +++++- the-matrix/js/world.js | 2 +- 4 files changed, 233 insertions(+), 4 deletions(-) diff --git a/the-matrix/js/effects.js b/the-matrix/js/effects.js index 501f218..c5eeba5 100644 --- a/the-matrix/js/effects.js +++ b/the-matrix/js/effects.js @@ -5,10 +5,196 @@ let dustPositions = null; let dustVelocities = null; const DUST_COUNT = 600; +// Job Indicators +const _activeJobIndicators = new Map(); +const INDICATOR_Y_OFFSET = 3.5; // Height above Timmy +const INDICATOR_X_OFFSET = 1.0; // Offset from Timmy's center for multiple jobs + +const JOB_INDICATOR_DEFS = { + writing: { + create: () => { + // Quill (cone for feather, cylinder for handle) + const quillGroup = new THREE.Group(); + const featherGeo = new THREE.ConeGeometry(0.15, 0.6, 4); + const featherMat = new THREE.MeshStandardMaterial({ color: 0xc8c4bc, roughness: 0.8 }); + const feather = new THREE.Mesh(featherGeo, featherMat); + feather.position.y = 0.3; + feather.rotation.x = Math.PI / 8; + quillGroup.add(feather); + + const handleGeo = new THREE.CylinderGeometry(0.04, 0.04, 0.4, 8); + const handleMat = new THREE.MeshStandardMaterial({ color: 0x3d2506, roughness: 0.7 }); + const handle = new THREE.Mesh(handleGeo, handleMat); + handle.position.y = -0.2; + quillGroup.add(handle); + return quillGroup; + }, + color: 0xe8d5a0, // parchment-like + }, + coding: { + create: () => { + // Brackets (simple box geometry) + const bracketsGroup = new THREE.Group(); + const bracketMat = new THREE.MeshStandardMaterial({ color: 0x5599dd, emissive: 0x224466, emissiveIntensity: 0.3, roughness: 0.4 }); + const bracketGeo = new THREE.BoxGeometry(0.05, 0.3, 0.05); + + const br1 = new THREE.Mesh(bracketGeo, bracketMat); + br1.position.set(-0.1, 0.0, 0); + bracketsGroup.add(br1); + + const br2 = br1.clone(); + br2.position.set(0.1, 0.0, 0); + bracketsGroup.add(br2); + + const crossbarGeo = new THREE.BoxGeometry(0.25, 0.05, 0.05); + const crossbar1 = new THREE.Mesh(crossbarGeo, bracketMat); + crossbar1.position.set(0, 0.125, 0); + bracketsGroup.add(crossbar1); + + const crossbar2 = crossbar1.clone(); + crossbar2.position.set(0, -0.125, 0); + bracketsGroup.add(crossbar2); + return bracketsGroup; + }, + color: 0x5599dd, // code-editor blue + }, + research: { + create: () => { + // Spider (simple sphere body, cylinder legs) - very simplified + const spiderGroup = new THREE.Group(); + const bodyMat = new THREE.MeshStandardMaterial({ color: 0x444444, roughness: 0.9 }); + const body = new THREE.Mesh(new THREE.SphereGeometry(0.15, 8, 8), bodyMat); + spiderGroup.add(body); + + const legMat = new THREE.MeshStandardMaterial({ color: 0x222222, roughness: 0.9 }); + const legGeo = new THREE.CylinderGeometry(0.015, 0.015, 0.4, 4); + + const legPositions = [ + [0.18, 0.0, 0.08, Math.PI / 4], [-0.18, 0.0, 0.08, -Math.PI / 4], + [0.22, 0.0, -0.05, Math.PI / 2], [-0.22, 0.0, -0.05, -Math.PI / 2], + [0.18, 0.0, -0.18, 3 * Math.PI / 4], [-0.18, 0.0, -0.18, -3 * Math.PI / 4], + ]; + + legPositions.forEach(([x, y, z, rotY]) => { + const leg = new THREE.Mesh(legGeo, legMat); + leg.position.set(x, y - 0.1, z); + leg.rotation.z = Math.PI / 2; + leg.rotation.y = rotY; + spiderGroup.add(leg); + }); + return spiderGroup; + }, + color: 0x8b0000, // dark red, investigative + }, + creative: { + create: () => { + // Lightbulb (sphere with small cylinder base) + const bulbGroup = new THREE.Group(); + const bulbMat = new THREE.MeshStandardMaterial({ color: 0xffddaa, emissive: 0xffaa00, emissiveIntensity: 0.8, transparent: true, opacity: 0.9, roughness: 0.1 }); + const bulb = new THREE.Mesh(new THREE.SphereGeometry(0.2, 16, 12), bulbMat); + bulbGroup.add(bulb); + + const baseMat = new THREE.MeshStandardMaterial({ color: 0x888888, roughness: 0.6 }); + const base = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.1, 0.15, 8), baseMat); + base.position.y = -0.25; + bulbGroup.add(base); + return bulbGroup; + }, + color: 0xffaa00, // bright idea yellow + }, + analysis: { + create: () => { + // Magnifying glass (torus for rim, plane for lens) + const magGroup = new THREE.Group(); + const rimMat = new THREE.MeshStandardMaterial({ color: 0xbb9900, roughness: 0.4, metalness: 0.7 }); + const rim = new THREE.Mesh(new THREE.TorusGeometry(0.2, 0.03, 8, 20), rimMat); + magGroup.add(rim); + + const handleMat = new THREE.MeshStandardMaterial({ color: 0x3d2506, roughness: 0.7 }); + const handle = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.4, 6), handleMat); + handle.position.set(0.25, -0.25, 0); + handle.rotation.z = Math.PI / 4; + magGroup.add(handle); + + const lensMat = new THREE.MeshPhysicalMaterial({ color: 0xaaffff, transmission: 0.8, roughness: 0.1, transparent: true }); + const lens = new THREE.Mesh(new THREE.CircleGeometry(0.17, 16), lensMat); + // Lens is a plane, so it will be rotated to face the camera or just set its position + // For simplicity, make it a thin cylinder or sphere segment to give it depth + const lensGeo = new THREE.CylinderGeometry(0.17, 0.17, 0.02, 16); + const thinLens = new THREE.Mesh(lensGeo, lensMat); + magGroup.add(thinLens); + + return magGroup; + }, + color: 0x88ddff, // clear blue, analytic + }, + other: { // Generic glowing orb + create: () => { + const orbMat = new THREE.MeshStandardMaterial({ color: 0x800080, emissive: 0x550055, emissiveIntensity: 0.8, roughness: 0.2 }); + return new THREE.Mesh(new THREE.SphereGeometry(0.2, 16, 16), orbMat); + }, + color: 0x800080, // purple + }, +}; + export function initEffects(scene) { initDustMotes(scene); } +// Map to hold job indicator objects by jobId +const jobIndicators = new Map(); + +export function createJobIndicator(category, jobId, position) { + const def = JOB_INDICATOR_DEFS[category] || JOB_INDICATOR_DEFS.other; + const indicatorGroup = new THREE.Group(); + indicatorGroup.userData.jobId = jobId; + indicatorGroup.userData.category = category; + + const object = def.create(); + object.scale.setScalar(0.7); // Make indicators a bit smaller + indicatorGroup.add(object); + + // Add a subtle glowing point light to the indicator + const pointLight = new THREE.PointLight(def.color, 0.8, 3); + indicatorGroup.add(pointLight); + + indicatorGroup.position.copy(position); + + jobIndicators.set(jobId, indicatorGroup); + return indicatorGroup; +} + +export function updateJobIndicators(time) { + const t = time * 0.001; + jobIndicators.forEach(indicator => { + // Simple bobbing motion + indicator.position.y += Math.sin(t * 2.5 + indicator.userData.jobId.charCodeAt(0)) * 0.002; + // Rotation + indicator.rotation.y += 0.01; + }); +} + +export function dissolveJobIndicator(jobId, scene) { + const indicator = jobIndicators.get(jobId); + if (indicator) { + // TODO: Implement particle dissolve effect here + // For now, just remove and dispose + scene.remove(indicator); + if (indicator.children.length > 0) { + const object = indicator.children[0]; + if (object.geometry) object.geometry.dispose(); + if (object.material) { + if (Array.isArray(object.material)) object.material.forEach(m => m.dispose()); + else object.material.dispose(); + } + } + indicator.children.forEach(child => { + if (child.isLight) child.dispose(); + }); + jobIndicators.delete(jobId); + } +} + function initDustMotes(scene) { const geo = new THREE.BufferGeometry(); const positions = new Float32Array(DUST_COUNT * 3); @@ -76,4 +262,18 @@ export function disposeEffects() { } dustPositions = null; dustVelocities = null; -} + jobIndicators.forEach(indicator => { + if (indicator.children.length > 0) { + const object = indicator.children[0]; + if (object.geometry) object.geometry.dispose(); + if (object.material) { + if (Array.isArray(object.material)) object.material.forEach(m => m.dispose()); + else object.material.dispose(); + } + } + indicator.children.forEach(child => { + if (child.isLight) child.dispose(); + }); + }); + jobIndicators.clear(); +} \ No newline at end of file diff --git a/the-matrix/js/main.js b/the-matrix/js/main.js index 3c25033..4c89cc6 100644 --- a/the-matrix/js/main.js +++ b/the-matrix/js/main.js @@ -5,7 +5,7 @@ import { getTimmyGroup, applySlap, getCameraShakeStrength, TIMMY_WORLD_POS, } from './agents.js'; -import { initEffects, updateEffects, disposeEffects } from './effects.js'; +import { initEffects, updateEffects, disposeEffects, updateJobIndicators } from './effects.js'; import { initUI, updateUI } from './ui.js'; import { initInteraction, disposeInteraction, registerSlapTarget } from './interaction.js'; import { initWebSocket, getConnectionState, getJobCount } from './websocket.js'; @@ -81,6 +81,7 @@ function buildWorld(firstInit, stateSnapshot) { updateEffects(now); updateAgents(now); + updateJobIndicators(now); updateUI({ fps: currentFps, agentCount: getAgentCount(), diff --git a/the-matrix/js/websocket.js b/the-matrix/js/websocket.js index cf4f714..ac15b4b 100644 --- a/the-matrix/js/websocket.js +++ b/the-matrix/js/websocket.js @@ -1,7 +1,10 @@ -import { setAgentState, setSpeechBubble, applyAgentStates, setMood } from './agents.js'; +import * as THREE from 'three'; +import { scene } from './world.js'; // Import the scene +import { setAgentState, setSpeechBubble, applyAgentStates, setMood, TIMMY_WORLD_POS } from './agents.js'; import { appendSystemMessage, appendDebateMessage, showCostTicker, updateCostTicker } from './ui.js'; import { sentiment } from './edge-worker-client.js'; import { setLabelState } from './hud-labels.js'; +import { createJobIndicator, dissolveJobIndicator } from './effects.js'; function resolveWsUrl() { const explicit = import.meta.env.VITE_WS_URL; @@ -19,6 +22,10 @@ let reconnectTimer = null; let visitorId = null; const RECONNECT_DELAY_MS = 5000; +// Map to keep track of active job indicator positions for offsetting +const _jobIndicatorOffsets = new Map(); +let _nextJobOffsetIndex = 0; + export function initWebSocket(_scene) { visitorId = crypto.randomUUID(); connect(); @@ -95,6 +102,21 @@ function handleMessage(msg) { setLabelState(msg.agentId, 'active'); } appendSystemMessage(`job ${(msg.jobId || '').slice(0, 8)} started`); + + // Spawn 3D job indicator + if (msg.jobId && msg.category) { + const offsetMultiplier = _jobIndicatorOffsets.size; // Simple way to spread them out + const indicatorPosition = TIMMY_WORLD_POS.clone().add( + new THREE.Vector3( + (offsetMultiplier % 2 === 0 ? 1 : -1) * (Math.floor(offsetMultiplier / 2) + 1) * 0.7, // Alternate left/right + 3.5, // Height above Timmy + -0.5 + ) + ); + const indicator = createJobIndicator(msg.category, msg.jobId, indicatorPosition); + scene.add(indicator); + _jobIndicatorOffsets.set(msg.jobId, indicatorPosition); // Store position, not index, for cleaner removal + } break; } @@ -105,6 +127,12 @@ function handleMessage(msg) { setLabelState(msg.agentId, 'idle'); } appendSystemMessage(`job ${(msg.jobId || '').slice(0, 8)} complete`); + + // Dissolve 3D job indicator + if (msg.jobId) { + dissolveJobIndicator(msg.jobId, scene); + _jobIndicatorOffsets.delete(msg.jobId); + } break; } diff --git a/the-matrix/js/world.js b/the-matrix/js/world.js index 62f67da..4912a71 100644 --- a/the-matrix/js/world.js +++ b/the-matrix/js/world.js @@ -1,6 +1,6 @@ import * as THREE from 'three'; -let scene, camera, renderer; +export let scene, camera, renderer; const _worldObjects = []; export function initWorld(existingCanvas) { -- 2.43.0