feat(task-21): Timmy face expressions + emotion engine

## 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)
This commit is contained in:
alexpaynex
2026-03-19 03:13:51 +00:00
parent 9ff5ef683d
commit 8d48eb06b3

View File

@@ -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.20.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.200.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) {