diff --git a/attached_assets/IMG_6192_1773889165962.jpeg b/attached_assets/IMG_6192_1773889165962.jpeg new file mode 100644 index 0000000..acf4774 Binary files /dev/null and b/attached_assets/IMG_6192_1773889165962.jpeg differ diff --git a/the-matrix/js/agents.js b/the-matrix/js/agents.js index 2d6f176..010412c 100644 --- a/the-matrix/js/agents.js +++ b/the-matrix/js/agents.js @@ -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;