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

## 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)
This commit is contained in:
alexpaynex
2026-03-19 03:09:45 +00:00
parent 7f402c5c7f
commit 9ff5ef683d

View File

@@ -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;