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

## What changed
- the-matrix/js/agents.js fully rewritten with face expression system

## Face geometry
- Replaced flat dark-blue eye spheres with white sclera (MeshStandardMaterial,
  emissive 0x777777@0.10, roughness 0.55) + dark pupils (MeshBasicMaterial 0x07070f)
  as child meshes of sclera
- Eyes are now children of the head mesh (not the group) so they naturally
  follow head.rotation.z tilts driven by the existing animation loop
- Mouth added as a canvas Sprite (128x32, always faces camera) parented to the
  group so it bobs with Timmy's body; drawn via quadraticCurveTo bezier arc

## Emotion → face parameter mapping (FACE_TARGETS table)
- idle (contemplative): lidScale=0.44, smileAmount=0.08  — half-lid, neutral
- active (curious):     lidScale=0.92, smileAmount=0.38  — wide eyes, smile
- thinking (focused):   lidScale=0.30, smileAmount=-0.06 — squint, flat mouth
- working (attentive):  lidScale=0.22, smileAmount=0.18  — very squint, slight grin

## Per-frame lerp (updateAgents)
- faceParams lerped toward faceTarget at rate 0.055/frame (smooth, no snap)
- eyeL.scale.y / eyeR.scale.y driven by faceParams.lidScale (squash = squint)
- Mouth canvas redrawn only when |smileDelta| > 0.016 or speakingChanged
  (avoids unnecessary texture uploads every frame)

## Lip-sync while speaking
- While speechTimer > 0: smileTarget = 0.28 + sin(t*6.283)*0.22 (~1 Hz)
- _drawMouth() renders two-lip "open mouth" shape when speaking=true
- Returns to mood expression when speechTimer expires

## Validation
- Vite build: clean (14 modules, 529 kB bundle, no errors)
- testkit: 27/27 PASS (no regressions)
- No out-of-scope changes (backend untouched)
This commit is contained in:
alexpaynex
2026-03-19 03:04:17 +00:00
parent ad63b01223
commit 7f402c5c7f
2 changed files with 163 additions and 11 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

View File

@@ -15,6 +15,57 @@ 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
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
};
// ── Mouth canvas drawing ──────────────────────────────────────────────────────
function _drawMouth(canvas, smileAmount, speaking) {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
const w = canvas.width;
const h = canvas.height;
const x1 = 22, x2 = w - 22;
const midX = w / 2;
const midY = h / 2;
// 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();
}
}
// ── buildTimmy ────────────────────────────────────────────────────────────────
export function initAgents(sceneRef) {
scene = sceneRef;
timmy = buildTimmy(scene);
@@ -24,18 +75,71 @@ function buildTimmy(sc) {
const group = new THREE.Group();
group.position.copy(TIMMY_POS);
// Robe
const robeMat = new THREE.MeshStandardMaterial({ color: 0x2d1b4e, roughness: 0.82, metalness: 0.08 });
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);
// Head
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 ────────────
// White sclera (eye background)
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);
// 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);
const eyeR = new THREE.Mesh(scleraGeo, scleraMat.clone());
eyeR.position.set(0.14, 0.05, 0.31);
head.add(eyeR);
// Dark pupils (children of sclera — scale with eye)
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
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;
// ── Hat ──────────────────────────────────────────────────────────────────
const hatMat = new THREE.MeshStandardMaterial({ color: 0x1a0a2e, roughness: 0.82 });
const brim = new THREE.Mesh(new THREE.TorusGeometry(0.46, 0.07, 6, 24), hatMat);
brim.position.y = 2.94;
@@ -51,15 +155,7 @@ function buildTimmy(sc) {
star.position.y = 3.76;
group.add(star);
const eyeMat = new THREE.MeshBasicMaterial({ color: 0x1a3a5c });
const eyeGeo = new THREE.SphereGeometry(0.07, 8, 8);
const eyeL = new THREE.Mesh(eyeGeo, eyeMat);
eyeL.position.set(-0.15, 2.65, 0.31);
group.add(eyeL);
const eyeR = new THREE.Mesh(eyeGeo, eyeMat.clone());
eyeR.position.set(0.15, 2.65, 0.31);
group.add(eyeR);
// Belt
const beltMat = new THREE.MeshStandardMaterial({ color: 0xffd700, emissive: 0xaa8800, emissiveIntensity: 0.4 });
const belt = new THREE.Mesh(new THREE.TorusGeometry(0.52, 0.04, 6, 24), beltMat);
belt.position.y = 0.72;
@@ -68,6 +164,8 @@ function buildTimmy(sc) {
sc.add(group);
// ── Crystal ball ─────────────────────────────────────────────────────────
const crystalGroup = new THREE.Group();
crystalGroup.position.copy(CRYSTAL_POS);
@@ -94,6 +192,8 @@ function buildTimmy(sc) {
sc.add(crystalGroup);
// ── Pip familiar ─────────────────────────────────────────────────────────
const pipMat = new THREE.MeshStandardMaterial({
color: 0xffaa00,
emissive: 0xff6600,
@@ -106,6 +206,8 @@ function buildTimmy(sc) {
pip.add(pipLight);
sc.add(pip);
// ── Speech bubble ────────────────────────────────────────────────────────
const bubbleCanvas = document.createElement('canvas');
bubbleCanvas.width = 512;
bubbleCanvas.height = 128;
@@ -116,15 +218,27 @@ function buildTimmy(sc) {
bubble.position.set(TIMMY_POS.x, 5.4, TIMMY_POS.z);
sc.add(bubble);
// ── Face state ───────────────────────────────────────────────────────────
return {
group, robe, head, hat, star, cb, cbMat, crystalGroup, crystalLight,
group, robe, head, hat, star,
eyeL, eyeR, pupilL, pupilR,
mouth, mouthCanvas, mouthTex, mouthMat,
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,
};
}
// ── updateAgents ─────────────────────────────────────────────────────────────
export function updateAgents(time) {
if (!timmy) return;
const t = time * 0.001;
@@ -183,12 +297,45 @@ export function updateAgents(time) {
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 ─────────────────────────────────────────────────
const faceTarget = FACE_TARGETS[vs] ?? FACE_TARGETS.idle;
timmy.faceTarget.lidScale = faceTarget.lidScale;
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)
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;
}
timmy.faceParams.smileAmount += (smileTarget - timmy.faceParams.smileAmount) * LERP;
// Apply lid scale to eyes (squash 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;
}
}
export function setAgentState(agentId, state) {
@@ -263,9 +410,14 @@ export function getAgentDefs() {
export function disposeAgents() {
if (!timmy) return;
[timmy.robe, timmy.head, timmy.hat, timmy.cb, timmy.ped, timmy.pip].forEach(m => {
[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(); }
});
timmy.mouthTex?.dispose();
timmy.mouthMat?.dispose();
timmy.bubbleTex?.dispose();
timmy.bubbleMat?.dispose();
timmy = null;