Files
timmy-tower/the-matrix/js/agents.js
Alexander Whitestone 9972eb59fe
Some checks failed
CI / Typecheck & Lint (pull_request) Failing after 1s
feat: add Kimi & Perplexity as visible Workshop agents (#11)
- agent-defs.js: add Kimi (Long Context Analysis, cyan) and Perplexity
  (Real-time Research, pink) with world positions at (-10,-10) and (10,-10)
- agents.js: add 3D geometric bodies for both agents — Kimi as an
  octahedron with orbital rings, Perplexity as an icosahedron with
  scanning tori; idle/active/dormant animations driven by agent state;
  restrict Timmy mood derivation to workshop agents only
- hud-labels.js: show specialization and last-task summary in inspect
  popup; export setLabelLastTask() for WS updates
- websocket.js: handle agent_task_summary messages; call setLabelLastTask
  on job_completed events
- world-state.ts: add kimi and perplexity to initial agentStates; restrict
  _deriveTimmy() to workshop agents only
- event-bus.ts: add AgentExternalEvent type for external agent state changes
- events.ts: handle agent:external_state bus events, broadcast agent_state
  and agent_task_summary WS messages

Fixes #11

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:38:09 -04:00

1011 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as THREE from 'three';
import { AGENT_DEFS } from './agent-defs.js';
const TIMMY_POS = new THREE.Vector3(0, 0, -2);
export const TIMMY_WORLD_POS = TIMMY_POS.clone();
const CRYSTAL_POS = new THREE.Vector3(0.6, 1.15, -4.1);
const agentStates = Object.fromEntries(AGENT_DEFS.map(d => [d.id, 'idle']));
// Workshop agents that drive Timmy's mood (excludes external agents Kimi/Perplexity)
const WORKSHOP_AGENT_IDS = ['alpha', 'beta', 'gamma', 'delta'];
function deriveTimmyState() {
if (agentStates.gamma === 'working') return 'working';
if (agentStates.beta === 'thinking' || agentStates.alpha === 'thinking') return 'thinking';
if (WORKSHOP_AGENT_IDS.some(id => agentStates[id] !== 'idle')) return 'active';
return 'idle';
}
let scene = null;
let timmy = null;
let _audioCtx = null;
let _lastFrameTime = 0;
let _cameraShakeStr = 0; // 0-1, read by getCameraShakeStrength()
// ── Ragdoll state machine ─────────────────────────────────────────────────────
const RD_STAND = 0; // normal idle
const RD_FALL = 1; // tipping over (0.55 s)
const RD_DOWN = 2; // sprawled on floor, struggling (1.9 s)
const RD_RISE = 3; // clambering back up (1.0 s)
const RD_COUNTER = 4; // counter-slap lunge (0.6 s)
const COUNTER_RETORTS = [
"OI! HAVE SOME OF THAT!",
"MESS WITH THE WIZARD EH?!",
"TAKE THAT, YOU RUFFIAN!",
"DON'T TOUCH THE BEARD!!",
"HAVE SOME LIGHTNING, KNAVE!",
"YOU'LL REGRET THAT, MORTAL!",
];
// Spring physics for slap wobble (STAND only)
const SPRING_STIFFNESS = 18.0;
const SPRING_DAMPING = 4.5;
const MAX_TILT_RAD = 0.55;
const SLAP_IMPULSE = 2.8;
const RAGDOLL_TILT_THRESHOLD = 0.42; // if tilt exceeds this, trigger full ragdoll fall
// ── Face emotion targets per internal state ───────────────────────────────────
// lidScale: 0 = fully closed, 1 = wide open
// pupilScale: scale factor applied to pupil meshes (dilation)
// smileAmount: -1 = frown, 0 = neutral, +1 = big smile
const FACE_TARGETS = {
idle: { lidScale: 0.44, pupilScale: 0.90, smileAmount: 0.08 }, // contemplative — half-lid, neutral
active: { lidScale: 0.92, pupilScale: 1.25, smileAmount: 0.38 }, // curious — wide open + dilated, smile
thinking: { lidScale: 0.30, pupilScale: 0.72, smileAmount: 0.00 }, // focused — narrow squint + constrict, flat mouth
working: { lidScale: 0.75, pupilScale: 1.05, smileAmount: 0.18 }, // attentive — alert/open eyes, slight grin
};
// Canonical mood aliases so setFaceEmotion() accepts both internal and task-spec names
const MOOD_ALIASES = {
contemplative: 'idle',
curious: 'active',
focused: 'thinking',
attentive: 'working',
idle: 'idle',
active: 'active',
thinking: 'thinking',
working: 'working',
};
// ── Mouth arc geometry — precomputed cache (no runtime allocation) ────────────
// 21 steps from smileAmount -1.0 → +1.0 (step 0.1). All geometries built once
// at module init; updateAgents() just swaps references → zero GC pressure.
const _MOUTH_GEO_STEPS = 21;
const _MOUTH_GEO_MIN = -1.0;
const _MOUTH_GEO_MAX = 1.0;
function _buildMouthGeo(smileAmount) {
const ctrlY = -smileAmount * 0.065; // control point: down for smile, up for frown
const curve = new THREE.QuadraticBezierCurve3(
new THREE.Vector3(-0.115, 0.000, 0.000),
new THREE.Vector3( 0.000, ctrlY, 0.020),
new THREE.Vector3( 0.115, 0.000, 0.000)
);
return new THREE.TubeGeometry(curve, 12, 0.013, 5, false);
}
const _MOUTH_GEO_CACHE = Array.from({ length: _MOUTH_GEO_STEPS }, (_, i) => {
const t = i / (_MOUTH_GEO_STEPS - 1);
return _buildMouthGeo(_MOUTH_GEO_MIN + t * (_MOUTH_GEO_MAX - _MOUTH_GEO_MIN));
});
function _pickMouthGeo(smileAmount) {
const clamped = Math.max(_MOUTH_GEO_MIN, Math.min(_MOUTH_GEO_MAX, smileAmount));
const idx = Math.round((clamped - _MOUTH_GEO_MIN) / (_MOUTH_GEO_MAX - _MOUTH_GEO_MIN) * (_MOUTH_GEO_STEPS - 1));
return _MOUTH_GEO_CACHE[idx];
}
// ── Build Timmy ───────────────────────────────────────────────────────────────
// ── External agent bodies (Kimi, Perplexity) ──────────────────────────────────
const _extBodies = {};
export function initAgents(sceneRef) {
scene = sceneRef;
timmy = buildTimmy(scene);
_initKimiBody(scene);
_initPerplexityBody(scene);
}
function _initKimiBody(sc) {
const group = new THREE.Group();
group.position.set(-10, 1.2, -10);
const mat = new THREE.MeshStandardMaterial({
color: 0x00d4ff, emissive: 0x004466, emissiveIntensity: 0.4,
roughness: 0.15, metalness: 0.4,
});
const core = new THREE.Mesh(new THREE.OctahedronGeometry(0.38, 0), mat);
group.add(core);
const ringMat = new THREE.MeshStandardMaterial({
color: 0x00d4ff, emissive: 0x0088aa, emissiveIntensity: 0.6,
roughness: 0.1, metalness: 0.6, transparent: true, opacity: 0.7,
});
const ring1 = new THREE.Mesh(new THREE.TorusGeometry(0.60, 0.025, 6, 32), ringMat);
ring1.rotation.x = Math.PI / 3;
group.add(ring1);
const ring2 = new THREE.Mesh(new THREE.TorusGeometry(0.76, 0.018, 6, 32), ringMat.clone());
ring2.rotation.x = Math.PI / 2;
ring2.rotation.z = Math.PI / 4;
group.add(ring2);
const light = new THREE.PointLight(0x00d4ff, 0.5, 8);
group.add(light);
sc.add(group);
_extBodies.kimi = { group, core, ring1, ring2, light, mat, pulsePhase: Math.random() * Math.PI * 2 };
}
function _initPerplexityBody(sc) {
const group = new THREE.Group();
group.position.set(10, 1.2, -10);
const mat = new THREE.MeshStandardMaterial({
color: 0xff6b9d, emissive: 0x660033, emissiveIntensity: 0.4,
roughness: 0.2, metalness: 0.3,
});
const core = new THREE.Mesh(new THREE.IcosahedronGeometry(0.32, 0), mat);
group.add(core);
const scanMat = new THREE.MeshStandardMaterial({
color: 0xff6b9d, emissive: 0xaa2255, emissiveIntensity: 0.7,
roughness: 0.1, metalness: 0.5, transparent: true, opacity: 0.65,
});
const scanRings = [0, Math.PI / 3, -Math.PI / 3].map(angle => {
const r = new THREE.Mesh(new THREE.TorusGeometry(0.55, 0.022, 6, 28), scanMat.clone());
r.rotation.x = Math.PI / 2 + angle;
r.rotation.z = angle * 0.5;
group.add(r);
return r;
});
const light = new THREE.PointLight(0xff6b9d, 0.5, 8);
group.add(light);
sc.add(group);
_extBodies.perplexity = { group, core, scanRings, light, mat, pulsePhase: Math.random() * Math.PI * 2 };
}
function _updateExtBodies(t) {
_updateExtBody('kimi', t);
_updateExtBody('perplexity', t);
}
function _updateExtBody(id, t) {
const body = _extBodies[id];
if (!body) return;
const state = agentStates[id] || 'idle';
const isActive = state === 'working' || state === 'active';
const isThinking = state === 'thinking';
const speedMult = isActive ? 2.5 : isThinking ? 1.5 : 0.6;
const emissI = isActive ? 1.2 : isThinking ? 0.7 : 0.25;
const lightI = isActive ? 1.2 : isThinking ? 0.6 : 0.2;
const bobAmp = isActive ? 0.10 : 0.04;
body.group.position.y = 1.2 + Math.sin(t * 0.0008 + body.pulsePhase) * bobAmp;
body.mat.emissiveIntensity = emissI;
body.light.intensity = lightI;
if (id === 'kimi') {
body.core.rotation.y += 0.008 * speedMult;
body.core.rotation.x += 0.003 * speedMult;
body.ring1.rotation.z += 0.012 * speedMult;
body.ring2.rotation.x += 0.007 * speedMult;
} else {
body.core.rotation.y += 0.006 * speedMult;
body.core.rotation.z += 0.009 * speedMult;
body.scanRings.forEach((r, i) => { r.rotation.y += (0.015 + i * 0.008) * speedMult; });
}
}
function buildTimmy(sc) {
const group = new THREE.Group();
group.position.copy(TIMMY_POS);
// ── Robe — royal purple with subtle inner glow ────────────────────────────
const robeMat = new THREE.MeshStandardMaterial({
color: 0x5c14b0, // vivid royal purple
emissive: 0x2a0060,
emissiveIntensity: 0.12,
roughness: 0.72,
metalness: 0.05,
});
const robe = new THREE.Mesh(new THREE.CylinderGeometry(0.32, 0.72, 2.2, 8), robeMat);
robe.position.y = 1.1;
robe.castShadow = true;
group.add(robe);
// ── Celestial decorations on robe (tiny gold stars/symbols) ──────────────
const celestialMat = new THREE.MeshStandardMaterial({
color: 0xffd060,
emissive: 0xffaa00,
emissiveIntensity: 0.9,
roughness: 0.3,
metalness: 0.6,
});
// Scattered octahedra on robe surface — chest, shoulders, lower body
const celestialPositions = [
[ 0.00, 1.80, 0.30 ], // chest centre (moon clasp)
[ -0.22, 1.60, 0.26 ], // chest left
[ 0.22, 1.60, 0.26 ], // chest right
[ -0.28, 1.30, 0.22 ], // mid left
[ 0.28, 1.30, 0.22 ], // mid right
[ 0.00, 0.95, 0.32 ], // lower centre
[ -0.18, 0.70, 0.30 ], // lower left
[ 0.18, 0.70, 0.30 ], // lower right
];
celestialPositions.forEach(([x, y, z]) => {
const sz = y > 1.5 ? 0.038 : 0.026; // chest stars larger
const cs = new THREE.Mesh(new THREE.OctahedronGeometry(sz, 0), celestialMat);
cs.position.set(x, y, z);
cs.rotation.y = Math.random() * Math.PI;
group.add(cs);
});
// Moon crescent on chest — a torus segment (big gold torus, small tube)
const moonMat = new THREE.MeshStandardMaterial({
color: 0xffd700,
emissive: 0xffcc00,
emissiveIntensity: 0.8,
roughness: 0.25,
metalness: 0.7,
});
const moon = new THREE.Mesh(new THREE.TorusGeometry(0.065, 0.016, 5, 14, Math.PI * 1.3), moonMat);
moon.position.set(-0.06, 1.82, 0.32);
moon.rotation.x = -0.3;
moon.rotation.z = -0.5;
group.add(moon);
// ── Head ─────────────────────────────────────────────────────────────────
// Slightly older, weathered skin tone
const headMat = new THREE.MeshStandardMaterial({ color: 0xd8a878, roughness: 0.72 });
const head = new THREE.Mesh(new THREE.SphereGeometry(0.38, 16, 16), headMat);
head.position.y = 2.6;
head.castShadow = true;
group.add(head);
// ── Face (ALL parented to head — follow head tilt naturally) ─────────────
const scleraMat = new THREE.MeshStandardMaterial({
color: 0xf5f2e8,
emissive: 0x777777,
emissiveIntensity: 0.10,
roughness: 0.55,
});
const scleraGeo = new THREE.SphereGeometry(0.079, 10, 10);
const eyeL = new THREE.Mesh(scleraGeo, scleraMat);
eyeL.position.set(-0.14, 0.05, 0.31);
head.add(eyeL);
const eyeR = new THREE.Mesh(scleraGeo, scleraMat.clone());
eyeR.position.set(0.14, 0.05, 0.31);
head.add(eyeR);
const pupilMat = new THREE.MeshBasicMaterial({ color: 0x07070f });
const pupilGeo = new THREE.SphereGeometry(0.037, 8, 8);
const pupilL = new THREE.Mesh(pupilGeo, pupilMat);
pupilL.position.set(0, 0, 0.057);
eyeL.add(pupilL);
const pupilR = new THREE.Mesh(pupilGeo, pupilMat.clone());
pupilR.position.set(0, 0, 0.057);
eyeR.add(pupilR);
// Mouth arc
const mouthMat = new THREE.MeshStandardMaterial({ color: 0x8a4a28, roughness: 0.7, metalness: 0.0 });
const mouth = new THREE.Mesh(_pickMouthGeo(0.08), mouthMat);
mouth.position.set(0, -0.18, 0.30);
head.add(mouth);
// ── Beard — long grey wizard power beard ─────────────────────────────────
const beardMat = new THREE.MeshStandardMaterial({
color: 0xaaa8a0,
emissive: 0x666660,
emissiveIntensity: 0.06,
roughness: 0.90,
});
// Wide top where beard meets chin
const beardTop = new THREE.Mesh(new THREE.CylinderGeometry(0.20, 0.15, 0.22, 7), beardMat);
beardTop.position.set(0, -0.30, 0.14);
beardTop.rotation.x = 0.18;
head.add(beardTop);
// Long tapered body of the beard flowing downward
const beardBody = new THREE.Mesh(new THREE.ConeGeometry(0.17, 0.90, 7), beardMat.clone());
beardBody.position.set(0, -0.72, 0.10);
beardBody.rotation.x = 0.12;
head.add(beardBody);
// ── Hair — silver-white wisps at sides ───────────────────────────────────
const hairMat = new THREE.MeshStandardMaterial({
color: 0xc8c4bc,
emissive: 0x888880,
emissiveIntensity: 0.06,
roughness: 0.90,
});
// Side hair puffs
[[-0.34, 0.04, 0.06], [0.34, 0.04, 0.06], [-0.30, -0.10, -0.08], [0.30, -0.10, -0.08]].forEach(([x, y, z]) => {
const h = new THREE.Mesh(new THREE.SphereGeometry(0.14, 7, 7), hairMat);
h.position.set(x, y, z);
h.scale.set(1, 0.7, 0.9);
head.add(h);
});
// Back of head hair mass
const hairBack = new THREE.Mesh(new THREE.SphereGeometry(0.30, 8, 8), hairMat.clone());
hairBack.position.set(0, 0.0, -0.22);
hairBack.scale.set(1, 0.8, 0.7);
head.add(hairBack);
// ── Hat — deep royal purple, taller cone ─────────────────────────────────
const hatMat = new THREE.MeshStandardMaterial({
color: 0x3a0880,
emissive: 0x18044a,
emissiveIntensity: 0.10,
roughness: 0.78,
});
const brim = new THREE.Mesh(new THREE.TorusGeometry(0.47, 0.07, 6, 24), hatMat);
brim.position.y = 2.94;
brim.rotation.x = Math.PI / 2;
group.add(brim);
const hat = new THREE.Mesh(new THREE.ConeGeometry(0.33, 0.82, 8), hatMat.clone());
hat.position.y = 3.35;
group.add(hat);
// Hat band — thin gold torus just above brim
const hatBandMat = new THREE.MeshStandardMaterial({
color: 0xffd700, emissive: 0xffaa00, emissiveIntensity: 0.7,
roughness: 0.3, metalness: 0.6,
});
const hatBand = new THREE.Mesh(new THREE.TorusGeometry(0.36, 0.022, 5, 20), hatBandMat);
hatBand.position.y = 3.02;
hatBand.rotation.x = Math.PI / 2;
group.add(hatBand);
// Star on hat tip
const starMat = new THREE.MeshStandardMaterial({ color: 0xffd700, emissive: 0xffcc00, emissiveIntensity: 1.2 });
const star = new THREE.Mesh(new THREE.OctahedronGeometry(0.08, 0), starMat);
star.position.y = 3.80;
group.add(star);
// ── Belt — wide gold with central gemstone ────────────────────────────────
const beltMat = new THREE.MeshStandardMaterial({
color: 0xffd700,
emissive: 0xcc8800,
emissiveIntensity: 0.5,
roughness: 0.28,
metalness: 0.65,
});
const belt = new THREE.Mesh(new THREE.TorusGeometry(0.52, 0.055, 6, 24), beltMat);
belt.position.y = 0.72;
belt.rotation.x = Math.PI / 2;
group.add(belt);
// Belt gemstone clasp — glowing teal
const claspMat = new THREE.MeshStandardMaterial({
color: 0x22ddcc,
emissive: 0x008888,
emissiveIntensity: 1.2,
roughness: 0.1,
metalness: 0.0,
});
const clasp = new THREE.Mesh(new THREE.OctahedronGeometry(0.055, 0), claspMat);
clasp.position.set(0, 0.72, 0.52);
group.add(clasp);
// ── Magic energy hand — right side, glowing orange ───────────────────────
const magicMat = new THREE.MeshStandardMaterial({
color: 0xff8800,
emissive: 0xff5500,
emissiveIntensity: 2.2,
roughness: 0.1,
metalness: 0.0,
});
const magicOrb = new THREE.Mesh(new THREE.SphereGeometry(0.10, 10, 10), magicMat);
magicOrb.position.set(0.55, 1.55, 0.30); // right-hand side, chest height
group.add(magicOrb);
const magicLight = new THREE.PointLight(0xff6600, 1.4, 3.5);
magicOrb.add(magicLight);
// Trailing sparks — two tiny satellite orbs
[[0.12, 0.10, 0.06], [-0.08, 0.14, 0.05]].forEach(([dx, dy, dz]) => {
const spark = new THREE.Mesh(new THREE.SphereGeometry(0.033, 6, 6), magicMat.clone());
spark.position.set(0.55 + dx, 1.55 + dy, 0.30 + dz);
group.add(spark);
});
sc.add(group);
// ── Crystal ball ─────────────────────────────────────────────────────────
const crystalGroup = new THREE.Group();
crystalGroup.position.copy(CRYSTAL_POS);
const cbMat = new THREE.MeshPhysicalMaterial({
color: 0x88ddff,
emissive: 0x004466,
emissiveIntensity: 0.5,
roughness: 0.0,
metalness: 0.0,
transmission: 0.65,
transparent: true,
opacity: 0.88,
});
const cb = new THREE.Mesh(new THREE.SphereGeometry(0.34, 32, 32), cbMat);
crystalGroup.add(cb);
const pedMat = new THREE.MeshStandardMaterial({ color: 0x4a3020, roughness: 0.85 });
const ped = new THREE.Mesh(new THREE.CylinderGeometry(0.14, 0.19, 0.24, 8), pedMat);
ped.position.y = -0.3;
crystalGroup.add(ped);
const crystalLight = new THREE.PointLight(0x44bbff, 0.8, 5);
crystalGroup.add(crystalLight);
sc.add(crystalGroup);
// ── Pip familiar ─────────────────────────────────────────────────────────
const pipMat = new THREE.MeshStandardMaterial({
color: 0xffaa00,
emissive: 0xff6600,
emissiveIntensity: 0.85,
roughness: 0.3,
});
const pip = new THREE.Mesh(new THREE.TetrahedronGeometry(0.17, 0), pipMat);
pip.position.set(2.5, 1.6, -1);
const pipLight = new THREE.PointLight(0xffaa00, 0.7, 5);
pip.add(pipLight);
sc.add(pip);
// ── Speech bubble ────────────────────────────────────────────────────────
const bubbleCanvas = document.createElement('canvas');
bubbleCanvas.width = 512;
bubbleCanvas.height = 128;
const bubbleTex = new THREE.CanvasTexture(bubbleCanvas);
const bubbleMat = new THREE.SpriteMaterial({ map: bubbleTex, transparent: true, opacity: 0 });
const bubble = new THREE.Sprite(bubbleMat);
bubble.scale.set(3.2, 0.8, 1);
bubble.position.set(TIMMY_POS.x, 5.4, TIMMY_POS.z);
sc.add(bubble);
// ── Face lerp state ───────────────────────────────────────────────────────
return {
group, robe, head, hat, star,
eyeL, eyeR, pupilL, pupilR,
mouth,
magicOrb, magicLight,
cb, cbMat, crystalGroup, crystalLight,
pip, pipLight, pipMat,
bubble, bubbleCanvas, bubbleTex, bubbleMat,
pulsePhase: Math.random() * Math.PI * 2,
speechTimer: 0,
faceParams: { lidScale: 0.44, pupilScale: 0.90, smileAmount: 0.08 },
faceTarget: { lidScale: 0.44, pupilScale: 0.90, smileAmount: 0.08 },
_overrideMood: null,
// Ragdoll state machine
rd: {
state: RD_STAND,
timer: 0,
fallDirX: 0,
fallDirZ: 1,
fallAngle: 0,
// Mini residual spring (STAND only)
slapOffset: { x: 0, z: 0 },
slapVelocity: { x: 0, z: 0 },
},
// Pip startle
pipStartleTimer: 0,
pipStartleDir: { x: 0, z: 0 },
// Crystal ball hit flash
hitFlashTimer: 0,
};
}
// ── updateAgents (called every frame) ────────────────────────────────────────
export function updateAgents(time) {
if (!timmy) return;
const t = time * 0.001;
const dt = _lastFrameTime > 0 ? Math.min((time - _lastFrameTime) * 0.001, 0.05) : 0.016;
_lastFrameTime = time;
_updateExtBodies(time);
const vs = deriveTimmyState();
const pulse = Math.sin(t * 1.8 + timmy.pulsePhase);
const pulse2 = Math.sin(t * 3.5 + timmy.pulsePhase * 1.5);
let bodyBob, headTilt, crystalI, cbPulseSpeed, robeGlow;
switch (vs) {
case 'working':
bodyBob = Math.sin(t * 4.2) * 0.11;
headTilt = Math.sin(t * 2.8) * 0.09;
crystalI = 3.8 + pulse * 1.4;
cbPulseSpeed = 3.2;
robeGlow = 0.18;
break;
case 'thinking':
bodyBob = Math.sin(t * 1.6) * 0.06;
headTilt = Math.sin(t * 1.1) * 0.13;
crystalI = 1.9 + pulse2 * 0.7;
cbPulseSpeed = 1.6;
robeGlow = 0.09;
break;
case 'active':
bodyBob = Math.sin(t * 2.2) * 0.07;
headTilt = Math.sin(t * 0.9) * 0.05;
crystalI = 1.3 + pulse * 0.5;
cbPulseSpeed = 1.3;
robeGlow = 0.05;
break;
default:
bodyBob = Math.sin(t * 0.85) * 0.04;
headTilt = Math.sin(t * 0.42) * 0.025;
crystalI = 0.45 + pulse * 0.2;
cbPulseSpeed = 0.55;
robeGlow = 0.0;
}
timmy.head.rotation.z = headTilt;
// ── Ragdoll state machine (handles group.rotation + position.y) ───────────
_updateRagdoll(dt, t, bodyBob);
// ── Crystal ball flash on slap hit ───────────────────────────────────────
if (timmy.hitFlashTimer > 0) {
timmy.hitFlashTimer = Math.max(0, timmy.hitFlashTimer - dt);
timmy.crystalLight.intensity = 10.0 * (timmy.hitFlashTimer / 0.5);
timmy.cbMat.emissiveIntensity = 0.9 * (timmy.hitFlashTimer / 0.5);
} else {
timmy.crystalLight.intensity = crystalI;
timmy.cbMat.emissiveIntensity = 0.28 + (crystalI / 6) * 0.72;
}
timmy.crystalGroup.rotation.y += 0.004 * cbPulseSpeed;
const cbScale = 1 + Math.sin(t * cbPulseSpeed) * 0.022;
timmy.cb.scale.setScalar(cbScale);
timmy.robe.material.emissive = new THREE.Color(0x5c14b0);
timmy.robe.material.emissiveIntensity = 0.10 + robeGlow;
// Magic energy orb — pulsing orange glow in Timmy's hand
const magicPulse = 0.85 + Math.sin(t * 3.4) * 0.35;
timmy.magicOrb.scale.setScalar(magicPulse * (vs === 'working' ? 1.35 : 1.0));
timmy.magicLight.intensity = 1.0 + Math.sin(t * 4.2) * 0.5 + (vs === 'working' ? 0.8 : 0.0);
timmy.magicOrb.position.y = 1.55 + Math.sin(t * 2.8) * 0.04;
// ── Pip familiar — startle reaction on slap ───────────────────────────────
if (timmy.pipStartleTimer > 0) timmy.pipStartleTimer = Math.max(0, timmy.pipStartleTimer - dt);
const startled = timmy.pipStartleTimer > 0;
const startleRatio = startled ? (timmy.pipStartleTimer / 3.0) : 0;
const pipSpeedMult = startled ? (1 + 3 * startleRatio) : 1;
const pipX = Math.sin(t * 0.38 * pipSpeedMult + 1.4) * 3.2 + timmy.pipStartleDir.x * startleRatio;
const pipZ = Math.sin(t * 0.65 * pipSpeedMult + 0.8) * 2.2 - 1.8 + timmy.pipStartleDir.z * startleRatio;
const pipY = 1.55 + Math.sin(t * 1.6 * (startled ? 3.5 : 1.0)) * (startled ? 0.72 : 0.32);
timmy.pip.position.set(pipX, pipY, pipZ);
timmy.pip.rotation.x += 0.022 * (startled ? 4 : 1);
timmy.pip.rotation.y += 0.031 * (startled ? 4 : 1);
timmy.pipLight.intensity = 0.55 + Math.sin(t * 2.1) * 0.2 + (startled ? 0.6 * startleRatio : 0);
// ── Speech bubble fade ───────────────────────────────────────────────────
if (timmy.speechTimer > 0) {
timmy.speechTimer -= 0.016;
const fadeStart = 1.5;
timmy.bubbleMat.opacity = timmy.speechTimer > fadeStart ? 1.0 : Math.max(0, timmy.speechTimer / fadeStart);
if (timmy.speechTimer <= 0) timmy.bubbleMat.opacity = 0;
}
// ── Face expression lerp ─────────────────────────────────────────────────
// setFaceEmotion() sets _overrideMood; it takes precedence over derived state.
// Falls back to deriveTimmyState() when no external override is active.
const effectiveMood = timmy._overrideMood ?? vs;
const faceTarget = FACE_TARGETS[effectiveMood] ?? FACE_TARGETS.idle;
timmy.faceTarget.lidScale = faceTarget.lidScale;
timmy.faceTarget.pupilScale = faceTarget.pupilScale;
timmy.faceTarget.smileAmount = faceTarget.smileAmount;
const LERP = 0.055;
// Lerp all three face parameters toward targets
timmy.faceParams.lidScale += (timmy.faceTarget.lidScale - timmy.faceParams.lidScale) * LERP;
timmy.faceParams.pupilScale += (timmy.faceTarget.pupilScale - timmy.faceParams.pupilScale) * LERP;
// Lip-sync: oscillate smile while speaking (~1 Hz, range 0.20.6); override smileAmount target
const speaking = timmy.speechTimer > 0;
const smileTarget = speaking
? 0.40 + Math.sin(t * 6.283) * 0.20 // 1 Hz, range 0.200.60
: timmy.faceTarget.smileAmount;
timmy.faceParams.smileAmount += (smileTarget - timmy.faceParams.smileAmount) * LERP;
// Apply lid scale — squash eye Y axis for squint / wide-open effect
timmy.eyeL.scale.y = timmy.faceParams.lidScale;
timmy.eyeR.scale.y = timmy.faceParams.lidScale;
// Apply pupil dilation — uniform scale on pupil meshes
const ps = timmy.faceParams.pupilScale;
timmy.pupilL.scale.setScalar(ps);
timmy.pupilR.scale.setScalar(ps);
// Swap precomputed mouth geometry when cached index changes (zero runtime allocation)
const nextMouthGeo = _pickMouthGeo(timmy.faceParams.smileAmount);
if (timmy.mouth.geometry !== nextMouthGeo) {
timmy.mouth.geometry = nextMouthGeo;
}
}
// ── setFaceEmotion — public API ───────────────────────────────────────────────
// Accepts both task-spec mood names (contemplative|curious|focused|attentive)
// and internal state names (idle|active|thinking|working).
// Sets _overrideMood so it persists across frames, taking precedence over the
// derived agent state in updateAgents(). Pass null to clear the override and
// return to automatic state-driven expressions.
export function setFaceEmotion(mood) {
if (!timmy) return;
if (mood === null || mood === undefined) {
timmy._overrideMood = null;
return;
}
timmy._overrideMood = MOOD_ALIASES[mood] ?? 'idle';
}
/**
* setMood — convenience alias accepted by Task #28 spec.
* Maps sentiment labels to appropriate Timmy face states:
* POSITIVE → curious (active) — wide eyes, smile
* NEGATIVE → focused (thinking) — squint, flat mouth
* NEUTRAL → contemplative (idle) — half-lid, neutral
* Also accepts raw mood names passed through to setFaceEmotion().
*/
export function setMood(moodOrSentiment) {
if (!moodOrSentiment) { setFaceEmotion(null); return; }
const m = String(moodOrSentiment).toUpperCase();
if (m === 'POSITIVE') { setFaceEmotion('curious'); return; }
if (m === 'NEGATIVE') { setFaceEmotion('focused'); return; }
if (m === 'NEUTRAL') { setFaceEmotion('contemplative'); return; }
// Fall through to alias lookup for raw mood names
setFaceEmotion(moodOrSentiment);
}
// ── _updateRagdoll — integrated per-frame from updateAgents ──────────────────
// Controls group.rotation and group.position.y for all ragdoll states.
// In RD_STAND it runs the residual micro-spring; all other states run the
// full fall/down/rise/counter animation.
function _updateRagdoll(dt, t, bodyBob) {
const rd = timmy.rd;
rd.timer += dt;
switch (rd.state) {
// ── FALL — ease-in tipping over (0.55 s) ────────────────────────────────
case RD_FALL: {
const p = Math.min(1, rd.timer / 0.55);
rd.fallAngle = (Math.PI / 2 + 0.12) * (p * p); // ease-in, overshoot slightly
timmy.group.rotation.x = rd.fallAngle * rd.fallDirZ;
timmy.group.rotation.z = -rd.fallAngle * rd.fallDirX;
timmy.group.position.y = 0;
timmy._overrideMood = 'active'; // wide-eyed surprise
if (rd.timer >= 0.55) { rd.state = RD_DOWN; rd.timer = 0; }
break;
}
// ── DOWN — sprawled, struggling (1.9 s) ─────────────────────────────────
case RD_DOWN: {
// Settle to exactly PI/2 then heave/twitch while trying to get up
const settle = Math.min(1, rd.timer / 0.12);
rd.fallAngle = (Math.PI / 2 + 0.12) * (1 - settle) + (Math.PI / 2) * settle;
rd.fallAngle += Math.sin(rd.timer * 8.5) * 0.025 * (1 - rd.timer / 1.9);
timmy.group.rotation.x = rd.fallAngle * rd.fallDirZ;
timmy.group.rotation.z = -rd.fallAngle * rd.fallDirX;
timmy.group.position.y = Math.sin(rd.timer * 2.5) * -0.05;
timmy._overrideMood = 'thinking'; // squinting / dazed
if (rd.timer >= 1.9) { rd.state = RD_RISE; rd.timer = 0; }
break;
}
// ── RISE — ease-out clambering up (1.0 s) ───────────────────────────────
case RD_RISE: {
const p = Math.min(1, rd.timer / 1.0);
const eased = 1 - (1 - p) * (1 - p); // ease-out
rd.fallAngle = (Math.PI / 2) * (1 - eased);
timmy.group.rotation.x = rd.fallAngle * rd.fallDirZ;
timmy.group.rotation.z = -rd.fallAngle * rd.fallDirX;
timmy.group.position.y = 0;
timmy._overrideMood = 'working'; // determined
if (rd.timer >= 1.0) {
// Just stood up — now counter-slap!
rd.state = RD_COUNTER;
rd.timer = 0;
rd.fallAngle = 0;
timmy.group.rotation.x = 0;
timmy.group.rotation.z = 0;
// Pick retort, show speech bubble
const retort = COUNTER_RETORTS[Math.floor(Math.random() * COUNTER_RETORTS.length)];
setSpeechBubble(retort);
timmy.speechTimer = 4.5;
timmy._overrideMood = 'active';
// Smack sound + camera shake
_playSmack(true /* high-pitched counter variant */);
_cameraShakeStr = 1.0;
timmy.hitFlashTimer = 0.6;
}
break;
}
// ── COUNTER — lunge forward toward camera (0.6 s) ───────────────────────
case RD_COUNTER: {
const p = Math.min(1, rd.timer / 0.6);
// Sinusoidal lunge: leans toward camera then snaps back
const lunge = Math.sin(p * Math.PI) * 0.35;
timmy.group.rotation.x = -lunge; // lean toward +Z (camera)
timmy.group.rotation.z = 0;
timmy.group.position.y = Math.sin(p * Math.PI) * 0.25; // slight hop
_cameraShakeStr = Math.max(0, 1.0 - rd.timer * 4.5);
if (rd.timer >= 0.6) {
rd.state = RD_STAND;
rd.timer = 0;
_cameraShakeStr = 0;
timmy.group.rotation.x = 0;
timmy.group.rotation.z = 0;
timmy._overrideMood = null;
}
break;
}
// ── STAND — normal idle with residual micro-spring trembling ────────────
case RD_STAND:
default: {
const so = rd.slapOffset, sv = rd.slapVelocity;
sv.x += (-SPRING_STIFFNESS * so.x - SPRING_DAMPING * sv.x) * dt;
sv.z += (-SPRING_STIFFNESS * so.z - SPRING_DAMPING * sv.z) * dt;
so.x = Math.max(-MAX_TILT_RAD, Math.min(MAX_TILT_RAD, so.x + sv.x * dt));
so.z = Math.max(-MAX_TILT_RAD, Math.min(MAX_TILT_RAD, so.z + sv.z * dt));
timmy.group.rotation.x = so.x;
timmy.group.rotation.z = so.z;
timmy.group.position.y = bodyBob;
break;
}
}
}
// ── applySlap — called by interaction.js on hit ───────────────────────────────
// Applies an additive spring impulse so repeated slaps stack. If accumulated
// tilt exceeds RAGDOLL_TILT_THRESHOLD the full ragdoll fall is triggered.
export function applySlap(hitPoint) {
if (!timmy) return;
const rd = timmy.rd;
// Ignore re-slap while already in ragdoll cycle — wait until standing
if (rd.state !== RD_STAND) return;
// XZ direction from Timmy to hit point (wobble/fall away from impact)
const dx = hitPoint.x - TIMMY_POS.x;
const dz = hitPoint.z - TIMMY_POS.z;
const len = Math.sqrt(dx * dx + dz * dz) || 1;
const dirX = dx / len;
const dirZ = dz / len;
// Additive spring impulse — stacks with any existing wobble
rd.slapVelocity.x += dirZ * SLAP_IMPULSE; // rotation.x driven by Z direction
rd.slapVelocity.z += -dirX * SLAP_IMPULSE; // rotation.z driven by X direction
// Check if accumulated tilt is large enough to trigger full ragdoll
const tiltMag = Math.sqrt(rd.slapOffset.x * rd.slapOffset.x + rd.slapOffset.z * rd.slapOffset.z);
const velMag = Math.sqrt(rd.slapVelocity.x * rd.slapVelocity.x + rd.slapVelocity.z * rd.slapVelocity.z);
if (tiltMag > RAGDOLL_TILT_THRESHOLD || velMag > SLAP_IMPULSE * 2.2) {
// Enough stacked force — trigger full ragdoll fall
rd.fallDirX = dirX;
rd.fallDirZ = dirZ;
rd.state = RD_FALL;
rd.timer = 0;
rd.fallAngle = 0;
// Reset spring state so it's clean when returning to STAND
rd.slapOffset.x = 0; rd.slapOffset.z = 0;
rd.slapVelocity.x = 0; rd.slapVelocity.z = 0;
// Pip startle — maximum scatter on ragdoll
timmy.pipStartleTimer = 5.0;
timmy.pipStartleDir.x = (Math.random() - 0.5) * 8.0;
timmy.pipStartleDir.z = (Math.random() - 0.5) * 8.0;
} else {
// Pip mild startle on wobble
timmy.pipStartleTimer = Math.max(timmy.pipStartleTimer, 3.0);
timmy.pipStartleDir.x = (Math.random() - 0.5) * 4.0;
timmy.pipStartleDir.z = (Math.random() - 0.5) * 4.0;
}
// Crystal flash on every hit
timmy.hitFlashTimer = 0.5;
// Cartoonish SMACK sound
_playSmack(false);
}
// ── _playSmack — layered cartoon impact SFX ──────────────────────────────────
// counter=false → being slapped (lower pitch thud)
// counter=true → Timmy retaliates (higher, snappier crack)
function _playSmack(counter) {
try {
if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (_audioCtx.state === 'suspended') _audioCtx.resume();
const now = _audioCtx.currentTime;
// ① Noise crack — sharp transient
const crackLen = counter ? 0.045 : 0.06;
const bufSize = Math.ceil(_audioCtx.sampleRate * crackLen);
const noiseBuf = _audioCtx.createBuffer(1, bufSize, _audioCtx.sampleRate);
const nData = noiseBuf.getChannelData(0);
for (let i = 0; i < bufSize; i++) nData[i] = Math.random() * 2 - 1;
const nSrc = _audioCtx.createBufferSource();
nSrc.buffer = noiseBuf;
const nGain = _audioCtx.createGain();
nGain.gain.setValueAtTime(counter ? 1.4 : 1.1, now);
nGain.gain.exponentialRampToValueAtTime(0.001, now + crackLen);
nSrc.connect(nGain); nGain.connect(_audioCtx.destination);
nSrc.start(now); nSrc.stop(now + crackLen + 0.01);
// ② Low body thump — sine sweep
const thumpStart = counter ? 340 : 220;
const thumpEnd = counter ? 80 : 45;
const thumpDur = counter ? 0.14 : 0.20;
const thump = _audioCtx.createOscillator();
thump.type = 'sine';
thump.frequency.setValueAtTime(thumpStart, now);
thump.frequency.exponentialRampToValueAtTime(thumpEnd, now + thumpDur);
const tGain = _audioCtx.createGain();
tGain.gain.setValueAtTime(counter ? 0.75 : 0.95, now);
tGain.gain.exponentialRampToValueAtTime(0.001, now + thumpDur + 0.04);
thump.connect(tGain); tGain.connect(_audioCtx.destination);
thump.start(now); thump.stop(now + thumpDur + 0.05);
// ③ Comic wobble tail — cartoon spring spring
const wobbleStart = counter ? 280 : 180;
const wobbleEnd = counter ? 100 : 55;
const wobbleDur = counter ? 0.40 : 0.55;
const wobble = _audioCtx.createOscillator();
wobble.type = 'sine';
wobble.frequency.setValueAtTime(wobbleStart, now + 0.04);
wobble.frequency.exponentialRampToValueAtTime(wobbleEnd, now + 0.04 + wobbleDur);
const wGain = _audioCtx.createGain();
wGain.gain.setValueAtTime(0.0, now);
wGain.gain.linearRampToValueAtTime(counter ? 0.30 : 0.40, now + 0.05);
wGain.gain.exponentialRampToValueAtTime(0.001, now + 0.06 + wobbleDur);
wobble.connect(wGain); wGain.connect(_audioCtx.destination);
wobble.start(now + 0.03); wobble.stop(now + 0.06 + wobbleDur);
} catch (_) {
// Autoplay policy / audio not supported — silently skip
}
}
// ── getTimmyGroup — used by interaction.js for raycasting ────────────────────
export function getTimmyGroup() {
return timmy ? timmy.group : null;
}
// ── getCameraShakeStrength — read each frame by main.js for viewport shake ───
// Returns 0-1; main.js applies a transient random offset to camera.position.
export function getCameraShakeStrength() {
return _cameraShakeStr;
}
export function setAgentState(agentId, state) {
if (agentId in agentStates) agentStates[agentId] = state;
}
export function setSpeechBubble(text) {
if (!timmy) return;
const canvas = timmy.bubbleCanvas;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'rgba(8, 6, 16, 0.9)';
_roundRect(ctx, 6, 6, canvas.width - 12, canvas.height - 12, 14);
ctx.fill();
ctx.strokeStyle = '#5599dd';
ctx.lineWidth = 2;
_roundRect(ctx, 6, 6, canvas.width - 12, canvas.height - 12, 14);
ctx.stroke();
ctx.fillStyle = '#aaddff';
ctx.font = 'bold 21px Courier New';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const words = text.split(' ');
const lines = [];
let line = '';
for (const w of words) {
const test = line ? `${line} ${w}` : w;
if (test.length > 42) { lines.push(line); line = w; } else line = test;
}
if (line) lines.push(line);
const lineH = 30;
const startY = canvas.height / 2 - ((lines.length - 1) * lineH) / 2;
lines.slice(0, 3).forEach((l, i) => ctx.fillText(l, canvas.width / 2, startY + i * lineH));
timmy.bubbleTex.needsUpdate = true;
timmy.bubbleMat.opacity = 1;
timmy.speechTimer = 9;
}
function _roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
export function getAgentCount() { return 1; }
export function getAgentStates() { return { ...agentStates }; }
export function applyAgentStates(snapshot) {
if (!snapshot) return;
for (const [k, v] of Object.entries(snapshot)) {
if (k in agentStates) agentStates[k] = v;
}
}
export function getAgentDefs() {
return [{ id: 'timmy', label: 'TIMMY', role: 'wizard', color: 0x5599ff, state: deriveTimmyState() }];
}
export function disposeAgents() {
if (!timmy) return;
[timmy.robe, timmy.head, timmy.hat, timmy.cb, timmy.pip].forEach(m => {
if (m) { m.geometry?.dispose(); if (Array.isArray(m.material)) m.material.forEach(x => x.dispose()); else m.material?.dispose(); }
});
[timmy.eyeL, timmy.eyeR].forEach(m => {
if (m) { m.geometry?.dispose(); m.material?.dispose(); }
});
// Cached mouth geometries are shared; dispose the cache here
_MOUTH_GEO_CACHE.forEach(g => g.dispose());
timmy.mouth?.material?.dispose();
timmy.bubbleTex?.dispose();
timmy.bubbleMat?.dispose();
timmy = null;
// Dispose external agent bodies
for (const body of Object.values(_extBodies)) {
body.group.traverse(obj => {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
mats.forEach(m => m.dispose());
}
});
if (scene) scene.remove(body.group);
}
for (const k of Object.keys(_extBodies)) delete _extBodies[k];
scene = null;
}