diff --git a/app.js b/app.js index 60689a0..25cd354 100644 --- a/app.js +++ b/app.js @@ -34,6 +34,8 @@ let debugOverlay; let frameCount = 0, lastFPSTime = 0, fps = 0; let chatOpen = true; let loadProgress = 0; +let timmyAgent = null; +let agentState = null; // ═══ INIT ═══ function init() { @@ -77,6 +79,7 @@ function init() { createDustParticles(); updateLoad(85); createAmbientStructures(); + createTimmyAgent(); updateLoad(90); // Post-processing @@ -787,6 +790,332 @@ function createAmbientStructures() { scene.add(pedestal); } +// ═══ TIMMY AGENT ═══ +function createTimmyAgent() { + agentState = { + mode: 'IDLE', // IDLE, PACING, LOOKING, READING, ATTENTION + activity: 'WAITING', // WAITING, THINKING, PROCESSING + modeTimer: 0, + modeDelay: 3 + Math.random() * 3, + activityTimer: 0, + activityDelay: 4 + Math.random() * 4, + pacingWaypoints: null, // set below after THREE is available + pacingIndex: 0, + basePos: new THREE.Vector3(5, 0, -4), + worldPos: new THREE.Vector3(5, 0, -4), + facingAngle: Math.PI + 0.5, // face roughly toward center + _lookTargetAngle: 0, + }; + + agentState.pacingWaypoints = [ + new THREE.Vector3(5, 0, -4), + new THREE.Vector3(8, 0, -6), + new THREE.Vector3(6, 0, -8.5), + new THREE.Vector3(3, 0, -6), + new THREE.Vector3(4, 0, -2.5), + ]; + + timmyAgent = new THREE.Group(); + timmyAgent.position.copy(agentState.worldPos); + timmyAgent.name = 'timmy-agent'; + + // ── Body (capsule) ── + const bodyGeo = new THREE.CapsuleGeometry(0.22, 0.7, 4, 8); + const bodyMat = new THREE.MeshPhysicalMaterial({ + color: NEXUS.colors.primary, + emissive: NEXUS.colors.primary, + emissiveIntensity: 0.8, + roughness: 0.1, + metalness: 0.4, + transparent: true, + opacity: 0.85, + }); + const body = new THREE.Mesh(bodyGeo, bodyMat); + body.position.y = 1.15; + body.name = 'agent-body'; + timmyAgent.add(body); + + // ── Head ── + const headGeo = new THREE.SphereGeometry(0.2, 16, 16); + const headMat = new THREE.MeshPhysicalMaterial({ + color: NEXUS.colors.primary, + emissive: NEXUS.colors.primary, + emissiveIntensity: 1.2, + roughness: 0.0, + metalness: 0.5, + transparent: true, + opacity: 0.9, + }); + const head = new THREE.Mesh(headGeo, headMat); + head.position.y = 1.95; + head.name = 'agent-head'; + timmyAgent.add(head); + + // ── Eyes (golden) ── + for (const side of [-1, 1]) { + const eyeGeo = new THREE.SphereGeometry(0.035, 8, 8); + const eyeMat = new THREE.MeshBasicMaterial({ color: NEXUS.colors.gold }); + const eye = new THREE.Mesh(eyeGeo, eyeMat); + eye.position.set(side * 0.09, 1.98, 0.16); + eye.name = 'agent-eye-' + (side > 0 ? 'r' : 'l'); + timmyAgent.add(eye); + } + + // ── Aura ring (horizontal, waist level) ── + const auraGeo = new THREE.TorusGeometry(0.45, 0.025, 8, 32); + const auraMat = new THREE.MeshBasicMaterial({ + color: NEXUS.colors.secondary, + transparent: true, + opacity: 0.7, + }); + const aura = new THREE.Mesh(auraGeo, auraMat); + aura.position.y = 1.15; + aura.rotation.x = Math.PI / 2; + aura.name = 'agent-aura'; + timmyAgent.add(aura); + + // ── Activity Indicators (above head) ── + + // WAITING: slow-pulse sphere + const waitMat = new THREE.MeshBasicMaterial({ + color: NEXUS.colors.primary, + transparent: true, + opacity: 0.6, + }); + const waitInd = new THREE.Mesh(new THREE.SphereGeometry(0.1, 8, 8), waitMat); + waitInd.position.y = 2.5; + waitInd.name = 'indicator-waiting'; + timmyAgent.add(waitInd); + + // THINKING: golden wireframe octahedron (spinning) + const thinkMat = new THREE.MeshBasicMaterial({ + color: NEXUS.colors.gold, + transparent: true, + opacity: 0.9, + wireframe: true, + }); + const thinkInd = new THREE.Mesh(new THREE.OctahedronGeometry(0.13, 0), thinkMat); + thinkInd.position.y = 2.5; + thinkInd.name = 'indicator-thinking'; + thinkInd.visible = false; + timmyAgent.add(thinkInd); + + // PROCESSING: teal torus ring (spinning) + const processMat = new THREE.MeshBasicMaterial({ + color: NEXUS.colors.primary, + transparent: true, + opacity: 0.9, + }); + const processInd = new THREE.Mesh(new THREE.TorusGeometry(0.13, 0.022, 8, 24), processMat); + processInd.position.y = 2.5; + processInd.name = 'indicator-processing'; + processInd.visible = false; + timmyAgent.add(processInd); + + // ── Name label (billboard) ── + const labelCanvas = document.createElement('canvas'); + labelCanvas.width = 256; + labelCanvas.height = 48; + const lctx = labelCanvas.getContext('2d'); + lctx.font = 'bold 22px "JetBrains Mono", monospace'; + lctx.fillStyle = '#4af0c0'; + lctx.textAlign = 'center'; + lctx.fillText('◈ TIMMY', 128, 32); + const labelTex = new THREE.CanvasTexture(labelCanvas); + const labelMesh = new THREE.Mesh( + new THREE.PlaneGeometry(1.6, 0.3), + new THREE.MeshBasicMaterial({ map: labelTex, transparent: true, side: THREE.DoubleSide }) + ); + labelMesh.position.y = 2.85; + labelMesh.name = 'agent-label'; + timmyAgent.add(labelMesh); + + // ── Ambient point light attached to agent ── + const agentLight = new THREE.PointLight(NEXUS.colors.primary, 0.8, 5, 2); + agentLight.position.y = 1.5; + timmyAgent.add(agentLight); + + scene.add(timmyAgent); +} + +// ═══ TIMMY AGENT UPDATE ═══ +function updateTimmyAgent(elapsed, delta) { + if (!timmyAgent || !agentState) return; + + const state = agentState; + state.modeTimer += delta; + state.activityTimer += delta; + + // ── Attention: check player proximity ── + const toPlayer = new THREE.Vector3().subVectors(playerPos, state.worldPos); + toPlayer.y = 0; + const playerDist = toPlayer.length(); + const nearPlayer = playerDist < 7; + + // ── Facing angle target ── + let targetAngle = state.facingAngle; + + if (nearPlayer) { + // Face the player — override current mode briefly + targetAngle = Math.atan2(toPlayer.x, toPlayer.z); + if (state.mode !== 'ATTENTION') { + state.mode = 'ATTENTION'; + state.modeTimer = 0; + state.modeDelay = 2 + Math.random() * 2; + } + } else { + // ── Idle mode state machine ── + if (state.modeTimer >= state.modeDelay) { + state.modeTimer = 0; + const roll = Math.random(); + + if (state.mode === 'IDLE' || state.mode === 'ATTENTION') { + if (roll < 0.30) { + state.mode = 'PACING'; + state.modeDelay = 5 + Math.random() * 6; + state.pacingIndex = (state.pacingIndex + 1) % state.pacingWaypoints.length; + } else if (roll < 0.55) { + state.mode = 'LOOKING'; + state.modeDelay = 3 + Math.random() * 3; + state._lookTargetAngle = Math.random() * Math.PI * 2; + } else if (roll < 0.70) { + state.mode = 'READING'; + state.modeDelay = 4 + Math.random() * 5; + } else { + state.mode = 'IDLE'; + state.modeDelay = 2 + Math.random() * 3; + } + } else { + // All active modes return to IDLE + state.mode = 'IDLE'; + state.modeDelay = 2 + Math.random() * 3; + } + } + + // ── Per-mode behavior ── + if (state.mode === 'PACING') { + const target = state.pacingWaypoints[state.pacingIndex]; + const toTarget = new THREE.Vector3().subVectors(target, state.worldPos); + toTarget.y = 0; + const dist = toTarget.length(); + if (dist > 0.12) { + const step = toTarget.clone().normalize().multiplyScalar(Math.min(1.4 * delta, dist)); + state.worldPos.add(step); + targetAngle = Math.atan2(step.x, step.z); + } else { + state.pacingIndex = (state.pacingIndex + 1) % state.pacingWaypoints.length; + } + } else if (state.mode === 'LOOKING') { + const t = state.modeTimer / state.modeDelay; + targetAngle = state._lookTargetAngle + Math.sin(t * Math.PI * 2.5) * 0.9; + } else if (state.mode === 'READING') { + // Face the terminal group at (0, 0, -8) + const dx = 0 - state.worldPos.x; + const dz = -8 - state.worldPos.z; + targetAngle = Math.atan2(dx, dz); + } else { + // IDLE: subtle ambient sway + targetAngle = state.facingAngle + Math.sin(elapsed * 0.25) * 0.08; + } + } + + // Smooth facing rotation (lerp angle) + let diff = targetAngle - state.facingAngle; + while (diff > Math.PI) diff -= Math.PI * 2; + while (diff < -Math.PI) diff += Math.PI * 2; + state.facingAngle += diff * Math.min(delta * 3.5, 1); + + timmyAgent.position.set(state.worldPos.x, 0, state.worldPos.z); + timmyAgent.rotation.y = state.facingAngle; + + // ── Floating bob ── + const bobSpeed = state.mode === 'PACING' ? 3.5 : 1.1; + const bobAmp = state.mode === 'PACING' ? 0.05 : 0.12; + const bob = Math.sin(elapsed * bobSpeed) * bobAmp; + + const bodyMesh = timmyAgent.getObjectByName('agent-body'); + const headMesh = timmyAgent.getObjectByName('agent-head'); + const eyeL = timmyAgent.getObjectByName('agent-eye-l'); + const eyeR = timmyAgent.getObjectByName('agent-eye-r'); + const aura = timmyAgent.getObjectByName('agent-aura'); + + if (bodyMesh) bodyMesh.position.y = 1.15 + bob; + if (headMesh) headMesh.position.y = 1.95 + bob; + if (eyeL) { eyeL.position.y = 1.98 + bob; } + if (eyeR) { eyeR.position.y = 1.98 + bob; } + if (aura) { + aura.position.y = 1.15 + bob; + aura.rotation.z = elapsed * 1.6; + } + + // ── Activity state machine ── + if (state.activityTimer >= state.activityDelay) { + state.activityTimer = 0; + const roll = Math.random(); + if (state.activity === 'WAITING') { + if (roll < 0.28) { + state.activity = 'THINKING'; + state.activityDelay = 2 + Math.random() * 3; + } else if (roll < 0.48) { + state.activity = 'PROCESSING'; + state.activityDelay = 3 + Math.random() * 4; + } else { + state.activityDelay = 3 + Math.random() * 5; + } + } else { + state.activity = 'WAITING'; + state.activityDelay = 4 + Math.random() * 5; + } + } + + // ── Activity indicators visibility & animation ── + const waitInd = timmyAgent.getObjectByName('indicator-waiting'); + const thinkInd = timmyAgent.getObjectByName('indicator-thinking'); + const processInd = timmyAgent.getObjectByName('indicator-processing'); + const indY = 2.55 + bob; + + waitInd.visible = (state.activity === 'WAITING'); + thinkInd.visible = (state.activity === 'THINKING'); + processInd.visible = (state.activity === 'PROCESSING'); + + if (waitInd.visible) { + waitInd.position.y = indY + Math.sin(elapsed * 1.5) * 0.06; + waitInd.material.opacity = 0.35 + 0.3 * Math.sin(elapsed * 1.2); + } + if (thinkInd.visible) { + thinkInd.position.y = indY + Math.sin(elapsed * 2.2) * 0.05; + thinkInd.rotation.y = elapsed * 2.8; + thinkInd.rotation.x = elapsed * 1.6; + } + if (processInd.visible) { + processInd.position.y = indY; + processInd.rotation.z = elapsed * 4.5; + processInd.rotation.x = Math.sin(elapsed * 2) * 0.35; + } + + // ── Body emissive pulse per activity ── + if (bodyMesh) { + const base = nearPlayer ? 1.3 : 0.8; + let intensity; + if (state.activity === 'THINKING') { + intensity = base + Math.sin(elapsed * 4.5) * 0.55; + } else if (state.activity === 'PROCESSING') { + intensity = base + Math.sin(elapsed * 7) * 0.35; + } else { + intensity = base + Math.sin(elapsed * 1.1) * 0.15; + } + bodyMesh.material.emissiveIntensity = intensity; + if (headMesh) headMesh.material.emissiveIntensity = intensity * 1.1; + } + + // ── Billboard label — always face player ── + const label = timmyAgent.getObjectByName('agent-label'); + if (label) { + label.position.y = 2.85 + bob; + label.rotation.y = playerRot.y - state.facingAngle; + } +} + // ═══ CONTROLS ═══ function setupControls() { document.addEventListener('keydown', (e) => { @@ -844,7 +1173,22 @@ function sendChatMessage() { addChatMessage('user', text); input.value = ''; + // Timmy reacts: switch to THINKING then PROCESSING + if (agentState) { + agentState.activity = 'THINKING'; + agentState.activityTimer = 0; + agentState.activityDelay = 99; // hold until response + // Also face the player + agentState.mode = 'ATTENTION'; + agentState.modeTimer = 0; + agentState.modeDelay = 3; + } + // Simulate Timmy response + const responseDelay = 500 + Math.random() * 1000; + setTimeout(() => { + if (agentState) { agentState.activity = 'PROCESSING'; } + }, responseDelay * 0.4); setTimeout(() => { const responses = [ 'Processing your request through the harness...', @@ -857,7 +1201,12 @@ function sendChatMessage() { ]; const resp = responses[Math.floor(Math.random() * responses.length)]; addChatMessage('timmy', resp); - }, 500 + Math.random() * 1000); + if (agentState) { + agentState.activity = 'WAITING'; + agentState.activityTimer = 0; + agentState.activityDelay = 3 + Math.random() * 4; + } + }, responseDelay); input.blur(); } @@ -950,6 +1299,9 @@ function gameLoop() { core.material.emissiveIntensity = 1.5 + Math.sin(elapsed * 2) * 0.5; } + // Animate Timmy agent + updateTimmyAgent(elapsed, delta); + // Render composer.render();