diff --git a/app.js b/app.js index 4b51de8..9a03e40 100644 --- a/app.js +++ b/app.js @@ -438,6 +438,76 @@ function createTerminalPanel(parent, x, y, rot, title, color, lines) { batcaveTerminals.push({ group, scanMat, borderMat }); } +// ═══ AGENT IDLE BEHAVIOR SYSTEM ═══ +const AGENT_STATES = { IDLE: 'IDLE', PACING: 'PACING', LOOKING: 'LOOKING', READING: 'READING' }; +const ACTIVITY_STATES = { NONE: 'NONE', WAITING: 'WAITING', THINKING: 'THINKING', PROCESSING: 'PROCESSING' }; + +function createActivityIndicator(color) { + const group = new THREE.Group(); + group.position.y = 4.2; + group.visible = false; + + // WAITING — pulsing sphere + const waitGeo = new THREE.SphereGeometry(0.18, 16, 16); + const waitMat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.85 }); + const waitMesh = new THREE.Mesh(waitGeo, waitMat); + waitMesh.name = 'indicator_waiting'; + waitMesh.visible = false; + group.add(waitMesh); + + // THINKING — wireframe octahedron + const thinkGeo = new THREE.OctahedronGeometry(0.2, 0); + const thinkMat = new THREE.MeshBasicMaterial({ color, wireframe: true }); + const thinkMesh = new THREE.Mesh(thinkGeo, thinkMat); + thinkMesh.name = 'indicator_thinking'; + thinkMesh.visible = false; + group.add(thinkMesh); + + // PROCESSING — spinning torus ring + const procGeo = new THREE.TorusGeometry(0.18, 0.04, 8, 32); + const procMat = new THREE.MeshBasicMaterial({ color }); + const procMesh = new THREE.Mesh(procGeo, procMat); + procMesh.name = 'indicator_processing'; + procMesh.visible = false; + group.add(procMesh); + + return { group, waitMesh, thinkMesh, procMesh }; +} + +function setAgentActivity(agent, state) { + agent.activityState = state; + agent.indicator.group.visible = (state !== ACTIVITY_STATES.NONE); + agent.indicator.waitMesh.visible = (state === ACTIVITY_STATES.WAITING); + agent.indicator.thinkMesh.visible = (state === ACTIVITY_STATES.THINKING); + agent.indicator.procMesh.visible = (state === ACTIVITY_STATES.PROCESSING); +} + +function buildPacingPath(station) { + // Small 3-waypoint circuit around the station + const r = 1.8; + return [ + new THREE.Vector3(station.x - r, 0, station.z), + new THREE.Vector3(station.x, 0, station.z + r), + new THREE.Vector3(station.x + r, 0, station.z - r * 0.5), + ]; +} + +function pickNextState(agent) { + const weights = { + [AGENT_STATES.IDLE]: 40, + [AGENT_STATES.PACING]: 25, + [AGENT_STATES.LOOKING]: 20, + [AGENT_STATES.READING]: 15, + }; + const total = Object.values(weights).reduce((a, b) => a + b, 0); + let r = Math.random() * total; + for (const [state, w] of Object.entries(weights)) { + r -= w; + if (r <= 0) return state; + } + return AGENT_STATES.IDLE; +} + // ═══ AGENT PRESENCE SYSTEM ═══ function createAgentPresences() { const agentData = [ @@ -491,16 +561,30 @@ function createAgentPresences() { label.position.y = 3.8; group.add(label); + // Activity Indicator + const indicator = createActivityIndicator(color); + group.add(indicator.group); + scene.add(group); - agents.push({ - id: data.id, - group, - orb, - halo, - color, - station: data.station, + agents.push({ + id: data.id, + group, + orb, + halo, + color, + station: data.station, targetPos: new THREE.Vector3(data.pos.x, 0, data.pos.z), - wanderTimer: 0 + // Idle state machine + state: AGENT_STATES.IDLE, + stateTimer: 2 + Math.random() * 4, + lookAngle: 0, + lookSpeed: 0.4 + Math.random() * 0.3, + pacingPath: buildPacingPath(data.station), + pacingIdx: 0, + // Activity indicators + indicator, + activityState: ACTIVITY_STATES.NONE, + activityLocked: false, }); }); } @@ -1065,6 +1149,19 @@ function sendChatMessage() { if (!text) return; addChatMessage('user', text); input.value = ''; + + // Drive Timmy activity indicators + const timmy = agents.find(a => a.id === 'timmy'); + if (timmy) { + timmy.activityLocked = true; + setAgentActivity(timmy, ACTIVITY_STATES.THINKING); + } + + const delay = 500 + Math.random() * 1000; + if (timmy) { + setTimeout(() => setAgentActivity(timmy, ACTIVITY_STATES.PROCESSING), delay * 0.4); + } + setTimeout(() => { const responses = [ 'Processing your request through the harness...', @@ -1077,7 +1174,14 @@ function sendChatMessage() { ]; const resp = responses[Math.floor(Math.random() * responses.length)]; addChatMessage('timmy', resp); - }, 500 + Math.random() * 1000); + if (timmy) { + setAgentActivity(timmy, ACTIVITY_STATES.WAITING); + setTimeout(() => { + setAgentActivity(timmy, ACTIVITY_STATES.NONE); + timmy.activityLocked = false; + }, 2000); + } + }, delay); input.blur(); } @@ -1336,24 +1440,7 @@ function gameLoop() { }); // Animate Agents - agents.forEach((agent, i) => { - // Wander logic - agent.wanderTimer -= delta; - if (agent.wanderTimer <= 0) { - agent.wanderTimer = 3 + Math.random() * 5; - agent.targetPos.set( - agent.station.x + (Math.random() - 0.5) * 4, - 0, - agent.station.z + (Math.random() - 0.5) * 4 - ); - } - agent.group.position.lerp(agent.targetPos, delta * 0.5); - - agent.orb.position.y = 3 + Math.sin(elapsed * 2 + i) * 0.15; - agent.halo.rotation.z = elapsed * 0.5; - agent.halo.scale.setScalar(1 + Math.sin(elapsed * 3 + i) * 0.1); - agent.orb.material.emissiveIntensity = 2 + Math.sin(elapsed * 4 + i) * 1; - }); + updateAgents(elapsed, delta); // Animate Power Meter powerMeterBars.forEach((bar, i) => { @@ -1420,6 +1507,125 @@ function onResize() { composer.setSize(w, h); } +// ═══ AGENT IDLE ANIMATION ═══ +function updateAgents(elapsed, delta) { + const ATTENTION_RADIUS = 7; + const terminalFacing = new THREE.Vector3(0, 0, -8); // batcave terminal bank Z + + agents.forEach((agent, i) => { + const stationWorld = new THREE.Vector3(agent.station.x, 0, agent.station.z); + + // ── Attention system: face player when close ── + const toPlayer = new THREE.Vector3( + playerPos.x - agent.group.position.x, + 0, + playerPos.z - agent.group.position.z + ); + const playerDist = toPlayer.length(); + const playerNearby = playerDist < ATTENTION_RADIUS && !agent.activityLocked; + + if (playerNearby) { + const targetAngle = Math.atan2(toPlayer.x, toPlayer.z); + const currentAngle = agent.group.rotation.y; + const diff = ((targetAngle - currentAngle + Math.PI * 3) % (Math.PI * 2)) - Math.PI; + agent.group.rotation.y += diff * Math.min(delta * 3, 1); + } + + // ── State machine (skip if activity locked or player nearby) ── + if (!playerNearby && !agent.activityLocked) { + agent.stateTimer -= delta; + + if (agent.stateTimer <= 0) { + agent.state = pickNextState(agent); + switch (agent.state) { + case AGENT_STATES.IDLE: + agent.stateTimer = 4 + Math.random() * 6; + agent.targetPos.copy(stationWorld); + break; + case AGENT_STATES.PACING: + agent.stateTimer = 8 + Math.random() * 6; + agent.pacingIdx = 0; + break; + case AGENT_STATES.LOOKING: + agent.stateTimer = 4 + Math.random() * 4; + agent.lookAngle = agent.group.rotation.y; + break; + case AGENT_STATES.READING: + agent.stateTimer = 5 + Math.random() * 5; + agent.targetPos.copy(stationWorld); + break; + } + } + + // ── Movement per state ── + if (agent.state === AGENT_STATES.PACING) { + const wp = agent.pacingPath[agent.pacingIdx]; + const toWp = new THREE.Vector3(wp.x - agent.group.position.x, 0, wp.z - agent.group.position.z); + if (toWp.length() < 0.3) { + agent.pacingIdx = (agent.pacingIdx + 1) % agent.pacingPath.length; + } else { + agent.group.position.addScaledVector(toWp.normalize(), delta * 1.2); + agent.group.rotation.y += (Math.atan2(toWp.x, toWp.z) - agent.group.rotation.y) * Math.min(delta * 4, 1); + } + } else if (agent.state === AGENT_STATES.READING) { + // Face the terminal bank + const toTerminal = new THREE.Vector3( + terminalFacing.x - agent.group.position.x, + 0, + terminalFacing.z - agent.group.position.z + ); + const targetAngle = Math.atan2(toTerminal.x, toTerminal.z); + agent.group.rotation.y += (targetAngle - agent.group.rotation.y) * Math.min(delta * 2, 1); + agent.group.position.lerp(agent.targetPos, delta * 0.4); + } else if (agent.state === AGENT_STATES.LOOKING) { + // Slow environmental scan left/right + agent.lookAngle += Math.sin(elapsed * agent.lookSpeed + i) * delta * 0.8; + agent.group.rotation.y += (agent.lookAngle - agent.group.rotation.y) * Math.min(delta * 1.5, 1); + agent.group.position.lerp(agent.targetPos, delta * 0.3); + } else { + // IDLE — drift gently back to station + agent.group.position.lerp(agent.targetPos, delta * 0.3); + } + } + + // ── Orb & halo animation ── + const bobAmt = agent.activityState === ACTIVITY_STATES.THINKING ? 0.25 : 0.15; + agent.orb.position.y = 3 + Math.sin(elapsed * 2 + i) * bobAmt; + agent.halo.rotation.z = elapsed * 0.5; + agent.halo.scale.setScalar(1 + Math.sin(elapsed * 3 + i) * 0.1); + const baseEmissive = agent.activityState === ACTIVITY_STATES.NONE ? 2 : 3; + agent.orb.material.emissiveIntensity = baseEmissive + Math.sin(elapsed * 4 + i) * 1; + + // ── Activity indicator animation ── + if (agent.activityState !== ACTIVITY_STATES.NONE) { + // Floating bob + agent.indicator.group.position.y = 4.2 + Math.sin(elapsed * 2 + i * 1.3) * 0.1; + + if (agent.activityState === ACTIVITY_STATES.WAITING) { + const pulse = 0.7 + Math.sin(elapsed * 4 + i) * 0.3; + agent.indicator.waitMesh.scale.setScalar(pulse); + agent.indicator.waitMesh.material.opacity = 0.5 + pulse * 0.35; + } else if (agent.activityState === ACTIVITY_STATES.THINKING) { + agent.indicator.thinkMesh.rotation.y = elapsed * 2.5; + agent.indicator.thinkMesh.rotation.x = elapsed * 1.5; + } else if (agent.activityState === ACTIVITY_STATES.PROCESSING) { + agent.indicator.procMesh.rotation.z = elapsed * 4; + agent.indicator.procMesh.rotation.x = Math.sin(elapsed * 1.2) * 0.5; + } + + // Billboard — indicator faces camera + const toCamera = new THREE.Vector3( + camera.position.x - agent.group.position.x, + 0, + camera.position.z - agent.group.position.z + ); + if (toCamera.length() > 0.01) { + agent.indicator.group.rotation.y = Math.atan2(toCamera.x, toCamera.z); + } + } + }); +} + // ═══ AGENT SIMULATION ═══ function simulateAgentThought() { const agentIds = ['timmy', 'kimi', 'claude', 'perplexity'];