Files
timmy-tower/the-matrix/js/agents.js
alexpaynex 93bd48f8ea Update Timmy's appearance to match reference with new colors and details
Refactors the `buildTimmy` function to update Timmy's robe color to royal purple, add celestial gold star decorations, and implement a silver beard and hair, along with a pulsing orange magic orb effect.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 418bf6f8-212b-4bb0-a7a5-8231a061da4e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 7cc95df8-ef94-4761-8b47-9c13fedbba9a
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/9f85e954-647c-46a5-90a7-396e495a805a/418bf6f8-212b-4bb0-a7a5-8231a061da4e/Q83Uqvu
Replit-Helium-Checkpoint-Created: true
2026-03-19 03:23:28 +00:00

583 lines
22 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';
const TIMMY_POS = new THREE.Vector3(0, 0, -2);
const CRYSTAL_POS = new THREE.Vector3(0.6, 1.15, -4.1);
const agentStates = { alpha: 'idle', beta: 'idle', gamma: 'idle', delta: 'idle' };
function deriveTimmyState() {
if (agentStates.gamma === 'working') return 'working';
if (agentStates.beta === 'thinking' || agentStates.alpha === 'thinking') return 'thinking';
if (Object.values(agentStates).some(s => s !== 'idle')) return 'active';
return 'idle';
}
let scene = null;
let timmy = null;
// ── 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 ───────────────────────────────────────────────────────────────
export function initAgents(sceneRef) {
scene = sceneRef;
timmy = buildTimmy(scene);
}
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 — silver-white, below chin ─────────────────────────────────────
const beardMat = new THREE.MeshStandardMaterial({
color: 0xd8d4cc,
emissive: 0x888880,
emissiveIntensity: 0.08,
roughness: 0.88,
});
// Main beard volume: wide cone hanging below chin
const beard = new THREE.Mesh(new THREE.ConeGeometry(0.18, 0.38, 7), beardMat);
beard.position.set(0, -0.32, 0.20);
beard.rotation.x = 0.22; // tip slightly forward
head.add(beard);
// Moustache: small cylinder across upper lip
const moustache = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 0.22, 6), beardMat.clone());
moustache.position.set(0, -0.10, 0.35);
moustache.rotation.z = Math.PI / 2;
head.add(moustache);
// ── 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,
};
}
// ── updateAgents (called every frame) ────────────────────────────────────────
export function updateAgents(time) {
if (!timmy) return;
const t = time * 0.001;
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.group.position.y = bodyBob;
timmy.head.rotation.z = headTilt;
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;
const pipX = Math.sin(t * 0.38 + 1.4) * 3.2;
const pipZ = Math.sin(t * 0.65 + 0.8) * 2.2 - 1.8;
const pipY = 1.55 + Math.sin(t * 1.6) * 0.32;
timmy.pip.position.set(pipX, pipY, pipZ);
timmy.pip.rotation.x += 0.022;
timmy.pip.rotation.y += 0.031;
timmy.pipLight.intensity = 0.55 + Math.sin(t * 2.1) * 0.2;
// ── 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';
}
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;
scene = null;
}