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
583 lines
22 KiB
JavaScript
583 lines
22 KiB
JavaScript
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.2–0.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.20–0.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;
|
||
}
|