From 8d48eb06b3d4a52999346f3c763d6c70a9acca63 Mon Sep 17 00:00:00 2001 From: alexpaynex <55271826-alexpaynex@users.noreply.replit.com> Date: Thu, 19 Mar 2026 03:13:51 +0000 Subject: [PATCH] feat(task-21): Timmy face expressions + emotion engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed - the-matrix/js/agents.js — complete face expression system on Timmy wizard ## Face geometry (all parented to head — follow head.rotation.z tilt) - White sclera eyes (MeshStandardMaterial f5f2e8, emissive 0x777777@0.10) replace old flat dark-blue eye spheres - Dark pupil spheres (MeshBasicMaterial 0x07070f) as children of each sclera; they scale with the parent eye for squint + animate independently for dilation - Mouth arc: TubeGeometry via QuadraticBezierCurve3; ctrlY = -smileAmount*0.065; rebuilt only when |smileDelta| > 0.016 (throttled, not per-frame GC) - All face meshes are children of `head` mesh — head.rotation.z carries every face feature naturally with the existing head-tilt animation ## FACE_TARGETS table (lidScale, pupilScale, smileAmount) - idle (contemplative): 0.44 / 0.90 / 0.08 — half-lid, neutral - active (curious): 0.92 / 1.25 / 0.38 — wide open + dilated pupils, smile - thinking (focused): 0.30 / 0.72 / 0.00 — narrow squint + constrict, flat mouth - working (attentive): 0.75 / 1.05 / 0.18 — alert/open eyes, slight grin ## setFaceEmotion(mood) — authoritative public API - Accepts task-spec names (contemplative|curious|focused|attentive) and internal names (idle|active|thinking|working) via MOOD_ALIASES - Sets timmy._overrideMood; persists across frames, takes precedence over deriveTimmyState() in updateAgents() - Call with null to clear override and return to automatic state-driven expressions ## Per-frame lerp (rate 0.055/frame) in updateAgents - Uses _overrideMood ?? deriveTimmyState() as effective mood - lidScale → eyeL.scale.y / eyeR.scale.y (squash for squint/wide-open) - pupilScale → pupilL/R.scale.setScalar() (uniform dilation) - smileAmount → drives thresholded TubeGeometry rebuild ## Lip-sync while speaking (1 Hz, range 0.20–0.60) - speechTimer > 0: smileTarget = 0.40 + sin(t*6.283)*0.20 - Returns to mood expression when timer expires ## Validation - Vite build: clean (14 modules, no errors) - testkit: 27/27 PASS (server restarted to clear rate-limit counters between runs) --- the-matrix/js/agents.js | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/the-matrix/js/agents.js b/the-matrix/js/agents.js index e9f9e2d..5c990a3 100644 --- a/the-matrix/js/agents.js +++ b/the-matrix/js/agents.js @@ -20,10 +20,10 @@ let timmy = null; // 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 - active: { lidScale: 0.92, pupilScale: 1.25, smileAmount: 0.38 }, // curious — wide + dilated - thinking: { lidScale: 0.30, pupilScale: 0.72, smileAmount: -0.06 }, // focused — squint + constrict - working: { lidScale: 0.22, pupilScale: 0.80, smileAmount: 0.18 }, // attentive — very squint, slight grin + 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 @@ -213,9 +213,11 @@ function buildTimmy(sc) { pulsePhase: Math.random() * Math.PI * 2, speechTimer: 0, // Current lerped face parameters - faceParams: { lidScale: 0.44, pupilScale: 0.90, smileAmount: 0.08 }, - // Target face parameters (set by emotion) - faceTarget: { lidScale: 0.44, pupilScale: 0.90, smileAmount: 0.08 }, + faceParams: { lidScale: 0.44, pupilScale: 0.90, smileAmount: 0.08 }, + // Target face parameters (set by emotion or derived state) + faceTarget: { lidScale: 0.44, pupilScale: 0.90, smileAmount: 0.08 }, + // External override: when non-null, setFaceEmotion() takes precedence over derived state + _overrideMood: null, // Track last geometry rebuild to throttle TubeGeometry updates _lastMouthSmile: 0.08, }; @@ -290,7 +292,10 @@ export function updateAgents(time) { } // ── Face expression lerp ───────────────────────────────────────────────── - const faceTarget = FACE_TARGETS[vs] ?? FACE_TARGETS.idle; + // 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; @@ -301,10 +306,10 @@ export function updateAgents(time) { 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); override smileAmount target + // 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.28 + Math.sin(t * 6.283) * 0.22 // 1 Hz oscillation + ? 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; @@ -329,15 +334,17 @@ export function updateAgents(time) { // ── setFaceEmotion — public API ─────────────────────────────────────────────── // Accepts both task-spec mood names (contemplative|curious|focused|attentive) // and internal state names (idle|active|thinking|working). -// Immediately updates faceTarget; lerp in updateAgents() does the rest. +// 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; - const key = MOOD_ALIASES[mood] ?? 'idle'; - const target = FACE_TARGETS[key]; - timmy.faceTarget.lidScale = target.lidScale; - timmy.faceTarget.pupilScale = target.pupilScale; - timmy.faceTarget.smileAmount = target.smileAmount; + if (mood === null || mood === undefined) { + timmy._overrideMood = null; + return; + } + timmy._overrideMood = MOOD_ALIASES[mood] ?? 'idle'; } export function setAgentState(agentId, state) {