From 9ff5ef683d980676ef746e6b64fe03b1e172dacc Mon Sep 17 00:00:00 2001 From: alexpaynex <55271826-alexpaynex@users.noreply.replit.com> Date: Thu, 19 Mar 2026 03:09:45 +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 — face expression system added to Timmy wizard ## Face geometry (all parented to head — follow head.rotation.z tilt) - White sclera eyes (MeshStandardMaterial f5f2e8, emissive 0x777777@0.10) replace the old flat dark-blue spheres - Dark pupils (MeshBasicMaterial 0x07070f) as child meshes of each sclera; they scale with the parent eye for squint effect - Mouth arc: TubeGeometry built from QuadraticBezierCurve3; control point moves ±0.065 on Y for smile/frown; rebuilt via _buildMouthGeo() only when |smileDelta| > 0.016 (throttled to avoid per-frame GC pressure) - All face meshes are children of `head` — head.rotation.z carries every face component naturally with the existing head-tilt animation ## FACE_TARGETS lookup table (lidScale, pupilScale, smileAmount) - idle (contemplative): 0.44 / 0.90 / 0.08 — half-lid, neutral - active (curious): 0.92 / 1.25 / 0.38 — wide eyes + dilated pupils, smile - thinking (focused): 0.30 / 0.72 / -0.06 — squint + constricted pupils, flat - working (attentive): 0.22 / 0.80 / 0.18 — very squint, slight grin ## setFaceEmotion(mood) exported API - Accepts both task-spec names (contemplative|curious|focused|attentive) and internal state names (idle|active|thinking|working) via MOOD_ALIASES - Immediately sets faceTarget; lerp in updateAgents() handles the smooth transition ## Per-frame lerp (rate 0.055/frame) in updateAgents - lidScale → eyeL.scale.y / eyeR.scale.y (squash for squint) - pupilScale → pupilL.scale / pupilR.scale (uniform dilation) - smileAmount → drives TubeGeometry rebuild when drift > 0.016 ## Lip-sync while speaking (~1 Hz) - speechTimer > 0: smileTarget = 0.28 + sin(t*6.283)*0.22 - Returns to mood target when timer expires ## Validation - Vite build: clean (14 modules, 542 kB, no errors) - testkit: 27/27 PASS (after server restart to clear rate-limit counters) --- the-matrix/js/agents.js | 188 ++++++++++++++++++++-------------------- 1 file changed, 95 insertions(+), 93 deletions(-) diff --git a/the-matrix/js/agents.js b/the-matrix/js/agents.js index 010412c..e9f9e2d 100644 --- a/the-matrix/js/agents.js +++ b/the-matrix/js/agents.js @@ -15,56 +15,43 @@ function deriveTimmyState() { let scene = null; let timmy = null; -// ── Face emotion targets per Timmy state ───────────────────────────────────── -// lidScale: 0=fully closed, 1=wide open -// smileAmount: -1=frown, 0=neutral, 1=big smile +// ── 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, smileAmount: 0.08 }, // contemplative: half-lid, neutral - active: { lidScale: 0.92, smileAmount: 0.38 }, // curious: wide eyes, smile - thinking: { lidScale: 0.30, smileAmount: -0.06 }, // focused: squint, flat - working: { lidScale: 0.22, smileAmount: 0.18 }, // attentive: very squint, slight grin + 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 }; -// ── Mouth canvas drawing ────────────────────────────────────────────────────── -function _drawMouth(canvas, smileAmount, speaking) { - const ctx = canvas.getContext('2d'); - ctx.clearRect(0, 0, canvas.width, canvas.height); +// 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', +}; - const w = canvas.width; - const h = canvas.height; - const x1 = 22, x2 = w - 22; - const midX = w / 2; - const midY = h / 2; +// ── Mouth arc geometry helpers ──────────────────────────────────────────────── - // Control point: up for smile, down for frown - const ctrlY = midY - smileAmount * 10; - - ctx.lineWidth = 3.5; - ctx.lineCap = 'round'; - - if (speaking) { - // Upper lip - ctx.strokeStyle = '#c48a58'; - ctx.beginPath(); - ctx.moveTo(x1, midY - 3); - ctx.quadraticCurveTo(midX, ctrlY - 3, x2, midY - 3); - ctx.stroke(); - // Lower lip (slightly lower → open mouth) - ctx.strokeStyle = '#a06840'; - ctx.beginPath(); - ctx.moveTo(x1 + 8, midY + 5); - ctx.quadraticCurveTo(midX, ctrlY + 14, x2 - 8, midY + 5); - ctx.stroke(); - } else { - ctx.strokeStyle = '#b87848'; - ctx.beginPath(); - ctx.moveTo(x1, midY); - ctx.quadraticCurveTo(midX, ctrlY, x2, midY); - ctx.stroke(); - } +function _buildMouthGeo(smileAmount) { + // smileAmount: +1 = smile (arc bows down), 0 = flat, -1 = frown (arc bows up) + const ctrlY = -smileAmount * 0.065; // control point moves down for smile + 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); } -// ── buildTimmy ──────────────────────────────────────────────────────────────── +// ── Build Timmy ─────────────────────────────────────────────────────────────── export function initAgents(sceneRef) { scene = sceneRef; @@ -82,16 +69,16 @@ function buildTimmy(sc) { robe.castShadow = true; group.add(robe); - // Head + // Head (pivot for face — tilts via rotation.z in updateAgents) const headMat = new THREE.MeshStandardMaterial({ color: 0xf2d0a0, roughness: 0.7 }); 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 features — parented to head so they follow head tilt ──────────── + // ── Face (ALL parented to head — follow head tilt naturally) ───────────── - // White sclera (eye background) + // White sclera background for each eye const scleraMat = new THREE.MeshStandardMaterial({ color: 0xf5f2e8, emissive: 0x777777, @@ -100,9 +87,9 @@ function buildTimmy(sc) { }); const scleraGeo = new THREE.SphereGeometry(0.079, 10, 10); + // Head-relative positions: head center = (0,0,0), face = +z direction + // group y=2.65, z=0.31 → head-relative y=0.05, z=0.31 const eyeL = new THREE.Mesh(scleraGeo, scleraMat); - // Position relative to head center (head is at group y=2.6, z=0) - // Eyes at group y=2.65, z=0.31 → head-relative y=0.05, z=0.31 eyeL.position.set(-0.14, 0.05, 0.31); head.add(eyeL); @@ -110,33 +97,29 @@ function buildTimmy(sc) { eyeR.position.set(0.14, 0.05, 0.31); head.add(eyeR); - // Dark pupils (children of sclera — scale with eye) + // Dark pupils — children of sclera so they scale with eye Y (squint effect) 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); // slightly forward inside sclera + pupilL.position.set(0, 0, 0.057); // forward from sclera center eyeL.add(pupilL); const pupilR = new THREE.Mesh(pupilGeo, pupilMat.clone()); pupilR.position.set(0, 0, 0.057); eyeR.add(pupilR); - // Mouth — canvas sprite (always faces camera — correct for face feature) - const mouthCanvas = document.createElement('canvas'); - mouthCanvas.width = 128; - mouthCanvas.height = 32; - const mouthTex = new THREE.CanvasTexture(mouthCanvas); - const mouthMat = new THREE.SpriteMaterial({ map: mouthTex, transparent: true }); - const mouth = new THREE.Sprite(mouthMat); - mouth.scale.set(0.40, 0.10, 1); - // Position in group space: just below eye level (eyes at group y≈2.65, mouth lower) - mouth.position.set(0, 2.42, 0.30); - group.add(mouth); - - // Draw initial mouth - _drawMouth(mouthCanvas, 0.08, false); - mouthTex.needsUpdate = true; + // Mouth — TubeGeometry arc, child of head so it follows head tilt + const mouthMat = new THREE.MeshStandardMaterial({ + color: 0x8a4a28, + roughness: 0.7, + metalness: 0.0, + }); + const mouthInitialGeo = _buildMouthGeo(0.08); // start at idle smile + const mouth = new THREE.Mesh(mouthInitialGeo, mouthMat); + // Position in head-local space: lower face (y=-0.18), on the face surface (z≈0.30) + mouth.position.set(0, -0.18, 0.30); + head.add(mouth); // ── Hat ────────────────────────────────────────────────────────────────── @@ -218,26 +201,27 @@ function buildTimmy(sc) { bubble.position.set(TIMMY_POS.x, 5.4, TIMMY_POS.z); sc.add(bubble); - // ── Face state ─────────────────────────────────────────────────────────── + // ── Face lerp state ─────────────────────────────────────────────────────── return { group, robe, head, hat, star, eyeL, eyeR, pupilL, pupilR, - mouth, mouthCanvas, mouthTex, mouthMat, + mouth, cb, cbMat, crystalGroup, crystalLight, pip, pipLight, pipMat, bubble, bubbleCanvas, bubbleTex, bubbleMat, pulsePhase: Math.random() * Math.PI * 2, speechTimer: 0, - // Lerped face state - faceParams: { lidScale: 0.44, smileAmount: 0.08 }, - faceTarget: { lidScale: 0.44, smileAmount: 0.08 }, - _mouthLastSmile: null, - _mouthLastSpeaking: false, + // 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 }, + // Track last geometry rebuild to throttle TubeGeometry updates + _lastMouthSmile: 0.08, }; } -// ── updateAgents ───────────────────────────────────────────────────────────── +// ── updateAgents (called every frame) ──────────────────────────────────────── export function updateAgents(time) { if (!timmy) return; @@ -307,37 +291,55 @@ export function updateAgents(time) { // ── Face expression lerp ───────────────────────────────────────────────── const faceTarget = FACE_TARGETS[vs] ?? FACE_TARGETS.idle; - timmy.faceTarget.lidScale = faceTarget.lidScale; + timmy.faceTarget.lidScale = faceTarget.lidScale; + timmy.faceTarget.pupilScale = faceTarget.pupilScale; timmy.faceTarget.smileAmount = faceTarget.smileAmount; const LERP = 0.055; - timmy.faceParams.lidScale += (timmy.faceTarget.lidScale - timmy.faceParams.lidScale) * LERP; - // Lip-sync: oscillate smile/open-mouth while speaking (~1 Hz) + // 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); override smileAmount target const speaking = timmy.speechTimer > 0; - let smileTarget; - if (speaking) { - smileTarget = 0.28 + Math.sin(t * 6.283) * 0.22; // 1 Hz - } else { - smileTarget = timmy.faceTarget.smileAmount; - } + const smileTarget = speaking + ? 0.28 + Math.sin(t * 6.283) * 0.22 // 1 Hz oscillation + : timmy.faceTarget.smileAmount; timmy.faceParams.smileAmount += (smileTarget - timmy.faceParams.smileAmount) * LERP; - // Apply lid scale to eyes (squash Y axis for squint/wide-open effect) + // 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; - // Redraw mouth canvas only when value changes meaningfully - const smileDelta = Math.abs(timmy.faceParams.smileAmount - (timmy._mouthLastSmile ?? 999)); - const speakingChanged = speaking !== timmy._mouthLastSpeaking; - if (smileDelta > 0.016 || speakingChanged) { - _drawMouth(timmy.mouthCanvas, timmy.faceParams.smileAmount, speaking); - timmy.mouthTex.needsUpdate = true; - timmy._mouthLastSmile = timmy.faceParams.smileAmount; - timmy._mouthLastSpeaking = speaking; + // Apply pupil dilation — uniform scale on pupil meshes + const ps = timmy.faceParams.pupilScale; + timmy.pupilL.scale.setScalar(ps); + timmy.pupilR.scale.setScalar(ps); + + // Rebuild TubeGeometry mouth arc when smile value changes enough + const smileDelta = Math.abs(timmy.faceParams.smileAmount - timmy._lastMouthSmile); + if (smileDelta > 0.016) { + timmy.mouth.geometry.dispose(); + timmy.mouth.geometry = _buildMouthGeo(timmy.faceParams.smileAmount); + timmy._lastMouthSmile = timmy.faceParams.smileAmount; } } +// ── 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. + +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; +} + export function setAgentState(agentId, state) { if (agentId in agentStates) agentStates[agentId] = state; } @@ -416,8 +418,8 @@ export function disposeAgents() { [timmy.eyeL, timmy.eyeR].forEach(m => { if (m) { m.geometry?.dispose(); m.material?.dispose(); } }); - timmy.mouthTex?.dispose(); - timmy.mouthMat?.dispose(); + timmy.mouth?.geometry?.dispose(); + timmy.mouth?.material?.dispose(); timmy.bubbleTex?.dispose(); timmy.bubbleMat?.dispose(); timmy = null;