[claude] Agent idle behaviors in 3D world (#8) #32

Closed
claude wants to merge 1 commits from claude/the-nexus:claude/issue-8 into main

354
app.js
View File

@@ -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();