## 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)
426 lines
15 KiB
JavaScript
426 lines
15 KiB
JavaScript
import * as THREE from 'three';
|
|
|
|
const TIMMY_POS = new THREE.Vector3(0, 0, -2);
|
|
const CRYSTAL_POS = new THREE.Vector3(0.6, 1.15, -4.1);
|
|
|
|
const agentStates = { alpha: 'idle', beta: 'idle', gamma: 'idle', delta: 'idle' };
|
|
|
|
function deriveTimmyState() {
|
|
if (agentStates.gamma === 'working') return 'working';
|
|
if (agentStates.beta === 'thinking' || agentStates.alpha === 'thinking') return 'thinking';
|
|
if (Object.values(agentStates).some(s => s !== 'idle')) return 'active';
|
|
return 'idle';
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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;
|
|
brim.rotation.x = Math.PI / 2;
|
|
group.add(brim);
|
|
|
|
const hat = new THREE.Mesh(new THREE.ConeGeometry(0.33, 0.78, 8), hatMat.clone());
|
|
hat.position.y = 3.33;
|
|
group.add(hat);
|
|
|
|
const starMat = new THREE.MeshStandardMaterial({ color: 0xffd700, emissive: 0xffaa00, emissiveIntensity: 1.0 });
|
|
const star = new THREE.Mesh(new THREE.OctahedronGeometry(0.07, 0), starMat);
|
|
star.position.y = 3.76;
|
|
group.add(star);
|
|
|
|
// 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;
|
|
belt.rotation.x = Math.PI / 2;
|
|
group.add(belt);
|
|
|
|
sc.add(group);
|
|
|
|
// ── Crystal ball ─────────────────────────────────────────────────────────
|
|
|
|
const crystalGroup = new THREE.Group();
|
|
crystalGroup.position.copy(CRYSTAL_POS);
|
|
|
|
const cbMat = new THREE.MeshPhysicalMaterial({
|
|
color: 0x88ddff,
|
|
emissive: 0x004466,
|
|
emissiveIntensity: 0.5,
|
|
roughness: 0.0,
|
|
metalness: 0.0,
|
|
transmission: 0.65,
|
|
transparent: true,
|
|
opacity: 0.88,
|
|
});
|
|
const cb = new THREE.Mesh(new THREE.SphereGeometry(0.34, 32, 32), cbMat);
|
|
crystalGroup.add(cb);
|
|
|
|
const pedMat = new THREE.MeshStandardMaterial({ color: 0x4a3020, roughness: 0.85 });
|
|
const ped = new THREE.Mesh(new THREE.CylinderGeometry(0.14, 0.19, 0.24, 8), pedMat);
|
|
ped.position.y = -0.3;
|
|
crystalGroup.add(ped);
|
|
|
|
const crystalLight = new THREE.PointLight(0x44bbff, 0.8, 5);
|
|
crystalGroup.add(crystalLight);
|
|
|
|
sc.add(crystalGroup);
|
|
|
|
// ── Pip familiar ─────────────────────────────────────────────────────────
|
|
|
|
const pipMat = new THREE.MeshStandardMaterial({
|
|
color: 0xffaa00,
|
|
emissive: 0xff6600,
|
|
emissiveIntensity: 0.85,
|
|
roughness: 0.3,
|
|
});
|
|
const pip = new THREE.Mesh(new THREE.TetrahedronGeometry(0.17, 0), pipMat);
|
|
pip.position.set(2.5, 1.6, -1);
|
|
const pipLight = new THREE.PointLight(0xffaa00, 0.7, 5);
|
|
pip.add(pipLight);
|
|
sc.add(pip);
|
|
|
|
// ── Speech bubble ────────────────────────────────────────────────────────
|
|
|
|
const bubbleCanvas = document.createElement('canvas');
|
|
bubbleCanvas.width = 512;
|
|
bubbleCanvas.height = 128;
|
|
const bubbleTex = new THREE.CanvasTexture(bubbleCanvas);
|
|
const bubbleMat = new THREE.SpriteMaterial({ map: bubbleTex, transparent: true, opacity: 0 });
|
|
const bubble = new THREE.Sprite(bubbleMat);
|
|
bubble.scale.set(3.2, 0.8, 1);
|
|
bubble.position.set(TIMMY_POS.x, 5.4, TIMMY_POS.z);
|
|
sc.add(bubble);
|
|
|
|
// ── Face state ───────────────────────────────────────────────────────────
|
|
|
|
return {
|
|
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;
|
|
const vs = deriveTimmyState();
|
|
const pulse = Math.sin(t * 1.8 + timmy.pulsePhase);
|
|
const pulse2 = Math.sin(t * 3.5 + timmy.pulsePhase * 1.5);
|
|
|
|
let bodyBob, headTilt, crystalI, cbPulseSpeed, robeGlow;
|
|
switch (vs) {
|
|
case 'working':
|
|
bodyBob = Math.sin(t * 4.2) * 0.11;
|
|
headTilt = Math.sin(t * 2.8) * 0.09;
|
|
crystalI = 3.8 + pulse * 1.4;
|
|
cbPulseSpeed = 3.2;
|
|
robeGlow = 0.18;
|
|
break;
|
|
case 'thinking':
|
|
bodyBob = Math.sin(t * 1.6) * 0.06;
|
|
headTilt = Math.sin(t * 1.1) * 0.13;
|
|
crystalI = 1.9 + pulse2 * 0.7;
|
|
cbPulseSpeed = 1.6;
|
|
robeGlow = 0.09;
|
|
break;
|
|
case 'active':
|
|
bodyBob = Math.sin(t * 2.2) * 0.07;
|
|
headTilt = Math.sin(t * 0.9) * 0.05;
|
|
crystalI = 1.3 + pulse * 0.5;
|
|
cbPulseSpeed = 1.3;
|
|
robeGlow = 0.05;
|
|
break;
|
|
default:
|
|
bodyBob = Math.sin(t * 0.85) * 0.04;
|
|
headTilt = Math.sin(t * 0.42) * 0.025;
|
|
crystalI = 0.45 + pulse * 0.2;
|
|
cbPulseSpeed = 0.55;
|
|
robeGlow = 0.0;
|
|
}
|
|
|
|
timmy.group.position.y = bodyBob;
|
|
timmy.head.rotation.z = headTilt;
|
|
|
|
timmy.crystalLight.intensity = crystalI;
|
|
timmy.cbMat.emissiveIntensity = 0.28 + (crystalI / 6) * 0.72;
|
|
timmy.crystalGroup.rotation.y += 0.004 * cbPulseSpeed;
|
|
const cbScale = 1 + Math.sin(t * cbPulseSpeed) * 0.022;
|
|
timmy.cb.scale.setScalar(cbScale);
|
|
|
|
timmy.robe.material.emissive = new THREE.Color(0x4400aa);
|
|
timmy.robe.material.emissiveIntensity = robeGlow;
|
|
|
|
const pipX = Math.sin(t * 0.38 + 1.4) * 3.2;
|
|
const pipZ = Math.sin(t * 0.65 + 0.8) * 2.2 - 1.8;
|
|
const pipY = 1.55 + Math.sin(t * 1.6) * 0.32;
|
|
timmy.pip.position.set(pipX, pipY, pipZ);
|
|
timmy.pip.rotation.x += 0.022;
|
|
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) {
|
|
if (agentId in agentStates) agentStates[agentId] = state;
|
|
}
|
|
|
|
export function setSpeechBubble(text) {
|
|
if (!timmy) return;
|
|
const canvas = timmy.bubbleCanvas;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
ctx.fillStyle = 'rgba(8, 6, 16, 0.9)';
|
|
_roundRect(ctx, 6, 6, canvas.width - 12, canvas.height - 12, 14);
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#5599dd';
|
|
ctx.lineWidth = 2;
|
|
_roundRect(ctx, 6, 6, canvas.width - 12, canvas.height - 12, 14);
|
|
ctx.stroke();
|
|
|
|
ctx.fillStyle = '#aaddff';
|
|
ctx.font = 'bold 21px Courier New';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
|
|
const words = text.split(' ');
|
|
const lines = [];
|
|
let line = '';
|
|
for (const w of words) {
|
|
const test = line ? `${line} ${w}` : w;
|
|
if (test.length > 42) { lines.push(line); line = w; } else line = test;
|
|
}
|
|
if (line) lines.push(line);
|
|
|
|
const lineH = 30;
|
|
const startY = canvas.height / 2 - ((lines.length - 1) * lineH) / 2;
|
|
lines.slice(0, 3).forEach((l, i) => ctx.fillText(l, canvas.width / 2, startY + i * lineH));
|
|
|
|
timmy.bubbleTex.needsUpdate = true;
|
|
timmy.bubbleMat.opacity = 1;
|
|
timmy.speechTimer = 9;
|
|
}
|
|
|
|
function _roundRect(ctx, x, y, w, h, r) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + r, y);
|
|
ctx.lineTo(x + w - r, y);
|
|
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
|
ctx.lineTo(x + w, y + h - r);
|
|
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
|
ctx.lineTo(x + r, y + h);
|
|
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
|
ctx.lineTo(x, y + r);
|
|
ctx.quadraticCurveTo(x, y, x + r, y);
|
|
ctx.closePath();
|
|
}
|
|
|
|
export function getAgentCount() { return 1; }
|
|
|
|
export function getAgentStates() { return { ...agentStates }; }
|
|
|
|
export function applyAgentStates(snapshot) {
|
|
if (!snapshot) return;
|
|
for (const [k, v] of Object.entries(snapshot)) {
|
|
if (k in agentStates) agentStates[k] = v;
|
|
}
|
|
}
|
|
|
|
export function getAgentDefs() {
|
|
return [{ id: 'timmy', label: 'TIMMY', role: 'wizard', color: 0x5599ff, state: deriveTimmyState() }];
|
|
}
|
|
|
|
export function disposeAgents() {
|
|
if (!timmy) return;
|
|
[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;
|
|
scene = null;
|
|
}
|