[claude] Agent idle behaviors in 3D world (#8) #32
354
app.js
354
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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user