import * as THREE from 'three'; const TIMMY_POS = new THREE.Vector3(0, 0, -2); export const TIMMY_WORLD_POS = TIMMY_POS.clone(); 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; let _audioCtx = null; let _lastFrameTime = 0; let _cameraShakeStr = 0; // 0-1, read by getCameraShakeStrength() // ── Ragdoll state machine ───────────────────────────────────────────────────── const RD_STAND = 0; // normal idle const RD_FALL = 1; // tipping over (0.55 s) const RD_DOWN = 2; // sprawled on floor, struggling (1.9 s) const RD_RISE = 3; // clambering back up (1.0 s) const RD_COUNTER = 4; // counter-slap lunge (0.6 s) const COUNTER_RETORTS = [ "OI! HAVE SOME OF THAT!", "MESS WITH THE WIZARD EH?!", "TAKE THAT, YOU RUFFIAN!", "DON'T TOUCH THE BEARD!!", "HAVE SOME LIGHTNING, KNAVE!", "YOU'LL REGRET THAT, MORTAL!", ]; // Residual mini-spring for slight trembling post-fall (STAND only) const SPRING_STIFFNESS = 7.0; const SPRING_DAMPING = 0.80; const MAX_TILT_RAD = 0.12; const SLAP_IMPULSE = 0.18; // ── 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, 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 const MOOD_ALIASES = { contemplative: 'idle', curious: 'active', focused: 'thinking', attentive: 'working', idle: 'idle', active: 'active', thinking: 'thinking', working: 'working', }; // ── Mouth arc geometry — precomputed cache (no runtime allocation) ──────────── // 21 steps from smileAmount -1.0 → +1.0 (step 0.1). All geometries built once // at module init; updateAgents() just swaps references → zero GC pressure. const _MOUTH_GEO_STEPS = 21; const _MOUTH_GEO_MIN = -1.0; const _MOUTH_GEO_MAX = 1.0; function _buildMouthGeo(smileAmount) { const ctrlY = -smileAmount * 0.065; // control point: down for smile, up for frown 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); } const _MOUTH_GEO_CACHE = Array.from({ length: _MOUTH_GEO_STEPS }, (_, i) => { const t = i / (_MOUTH_GEO_STEPS - 1); return _buildMouthGeo(_MOUTH_GEO_MIN + t * (_MOUTH_GEO_MAX - _MOUTH_GEO_MIN)); }); function _pickMouthGeo(smileAmount) { const clamped = Math.max(_MOUTH_GEO_MIN, Math.min(_MOUTH_GEO_MAX, smileAmount)); const idx = Math.round((clamped - _MOUTH_GEO_MIN) / (_MOUTH_GEO_MAX - _MOUTH_GEO_MIN) * (_MOUTH_GEO_STEPS - 1)); return _MOUTH_GEO_CACHE[idx]; } // ── Build Timmy ─────────────────────────────────────────────────────────────── export function initAgents(sceneRef) { scene = sceneRef; timmy = buildTimmy(scene); } function buildTimmy(sc) { const group = new THREE.Group(); group.position.copy(TIMMY_POS); // ── Robe — royal purple with subtle inner glow ──────────────────────────── const robeMat = new THREE.MeshStandardMaterial({ color: 0x5c14b0, // vivid royal purple emissive: 0x2a0060, emissiveIntensity: 0.12, roughness: 0.72, metalness: 0.05, }); 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); // ── Celestial decorations on robe (tiny gold stars/symbols) ────────────── const celestialMat = new THREE.MeshStandardMaterial({ color: 0xffd060, emissive: 0xffaa00, emissiveIntensity: 0.9, roughness: 0.3, metalness: 0.6, }); // Scattered octahedra on robe surface — chest, shoulders, lower body const celestialPositions = [ [ 0.00, 1.80, 0.30 ], // chest centre (moon clasp) [ -0.22, 1.60, 0.26 ], // chest left [ 0.22, 1.60, 0.26 ], // chest right [ -0.28, 1.30, 0.22 ], // mid left [ 0.28, 1.30, 0.22 ], // mid right [ 0.00, 0.95, 0.32 ], // lower centre [ -0.18, 0.70, 0.30 ], // lower left [ 0.18, 0.70, 0.30 ], // lower right ]; celestialPositions.forEach(([x, y, z]) => { const sz = y > 1.5 ? 0.038 : 0.026; // chest stars larger const cs = new THREE.Mesh(new THREE.OctahedronGeometry(sz, 0), celestialMat); cs.position.set(x, y, z); cs.rotation.y = Math.random() * Math.PI; group.add(cs); }); // Moon crescent on chest — a torus segment (big gold torus, small tube) const moonMat = new THREE.MeshStandardMaterial({ color: 0xffd700, emissive: 0xffcc00, emissiveIntensity: 0.8, roughness: 0.25, metalness: 0.7, }); const moon = new THREE.Mesh(new THREE.TorusGeometry(0.065, 0.016, 5, 14, Math.PI * 1.3), moonMat); moon.position.set(-0.06, 1.82, 0.32); moon.rotation.x = -0.3; moon.rotation.z = -0.5; group.add(moon); // ── Head ───────────────────────────────────────────────────────────────── // Slightly older, weathered skin tone const headMat = new THREE.MeshStandardMaterial({ color: 0xd8a878, roughness: 0.72 }); 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 (ALL parented to head — follow head tilt naturally) ───────────── 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); 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); 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); eyeL.add(pupilL); const pupilR = new THREE.Mesh(pupilGeo, pupilMat.clone()); pupilR.position.set(0, 0, 0.057); eyeR.add(pupilR); // Mouth arc const mouthMat = new THREE.MeshStandardMaterial({ color: 0x8a4a28, roughness: 0.7, metalness: 0.0 }); const mouth = new THREE.Mesh(_pickMouthGeo(0.08), mouthMat); mouth.position.set(0, -0.18, 0.30); head.add(mouth); // ── Beard — long grey wizard power beard ───────────────────────────────── const beardMat = new THREE.MeshStandardMaterial({ color: 0xaaa8a0, emissive: 0x666660, emissiveIntensity: 0.06, roughness: 0.90, }); // Wide top where beard meets chin const beardTop = new THREE.Mesh(new THREE.CylinderGeometry(0.20, 0.15, 0.22, 7), beardMat); beardTop.position.set(0, -0.30, 0.14); beardTop.rotation.x = 0.18; head.add(beardTop); // Long tapered body of the beard flowing downward const beardBody = new THREE.Mesh(new THREE.ConeGeometry(0.17, 0.90, 7), beardMat.clone()); beardBody.position.set(0, -0.72, 0.10); beardBody.rotation.x = 0.12; head.add(beardBody); // ── Hair — silver-white wisps at sides ─────────────────────────────────── const hairMat = new THREE.MeshStandardMaterial({ color: 0xc8c4bc, emissive: 0x888880, emissiveIntensity: 0.06, roughness: 0.90, }); // Side hair puffs [[-0.34, 0.04, 0.06], [0.34, 0.04, 0.06], [-0.30, -0.10, -0.08], [0.30, -0.10, -0.08]].forEach(([x, y, z]) => { const h = new THREE.Mesh(new THREE.SphereGeometry(0.14, 7, 7), hairMat); h.position.set(x, y, z); h.scale.set(1, 0.7, 0.9); head.add(h); }); // Back of head hair mass const hairBack = new THREE.Mesh(new THREE.SphereGeometry(0.30, 8, 8), hairMat.clone()); hairBack.position.set(0, 0.0, -0.22); hairBack.scale.set(1, 0.8, 0.7); head.add(hairBack); // ── Hat — deep royal purple, taller cone ───────────────────────────────── const hatMat = new THREE.MeshStandardMaterial({ color: 0x3a0880, emissive: 0x18044a, emissiveIntensity: 0.10, roughness: 0.78, }); const brim = new THREE.Mesh(new THREE.TorusGeometry(0.47, 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.82, 8), hatMat.clone()); hat.position.y = 3.35; group.add(hat); // Hat band — thin gold torus just above brim const hatBandMat = new THREE.MeshStandardMaterial({ color: 0xffd700, emissive: 0xffaa00, emissiveIntensity: 0.7, roughness: 0.3, metalness: 0.6, }); const hatBand = new THREE.Mesh(new THREE.TorusGeometry(0.36, 0.022, 5, 20), hatBandMat); hatBand.position.y = 3.02; hatBand.rotation.x = Math.PI / 2; group.add(hatBand); // Star on hat tip const starMat = new THREE.MeshStandardMaterial({ color: 0xffd700, emissive: 0xffcc00, emissiveIntensity: 1.2 }); const star = new THREE.Mesh(new THREE.OctahedronGeometry(0.08, 0), starMat); star.position.y = 3.80; group.add(star); // ── Belt — wide gold with central gemstone ──────────────────────────────── const beltMat = new THREE.MeshStandardMaterial({ color: 0xffd700, emissive: 0xcc8800, emissiveIntensity: 0.5, roughness: 0.28, metalness: 0.65, }); const belt = new THREE.Mesh(new THREE.TorusGeometry(0.52, 0.055, 6, 24), beltMat); belt.position.y = 0.72; belt.rotation.x = Math.PI / 2; group.add(belt); // Belt gemstone clasp — glowing teal const claspMat = new THREE.MeshStandardMaterial({ color: 0x22ddcc, emissive: 0x008888, emissiveIntensity: 1.2, roughness: 0.1, metalness: 0.0, }); const clasp = new THREE.Mesh(new THREE.OctahedronGeometry(0.055, 0), claspMat); clasp.position.set(0, 0.72, 0.52); group.add(clasp); // ── Magic energy hand — right side, glowing orange ─────────────────────── const magicMat = new THREE.MeshStandardMaterial({ color: 0xff8800, emissive: 0xff5500, emissiveIntensity: 2.2, roughness: 0.1, metalness: 0.0, }); const magicOrb = new THREE.Mesh(new THREE.SphereGeometry(0.10, 10, 10), magicMat); magicOrb.position.set(0.55, 1.55, 0.30); // right-hand side, chest height group.add(magicOrb); const magicLight = new THREE.PointLight(0xff6600, 1.4, 3.5); magicOrb.add(magicLight); // Trailing sparks — two tiny satellite orbs [[0.12, 0.10, 0.06], [-0.08, 0.14, 0.05]].forEach(([dx, dy, dz]) => { const spark = new THREE.Mesh(new THREE.SphereGeometry(0.033, 6, 6), magicMat.clone()); spark.position.set(0.55 + dx, 1.55 + dy, 0.30 + dz); group.add(spark); }); 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 lerp state ─────────────────────────────────────────────────────── return { group, robe, head, hat, star, eyeL, eyeR, pupilL, pupilR, mouth, magicOrb, magicLight, cb, cbMat, crystalGroup, crystalLight, pip, pipLight, pipMat, bubble, bubbleCanvas, bubbleTex, bubbleMat, pulsePhase: Math.random() * Math.PI * 2, speechTimer: 0, faceParams: { lidScale: 0.44, pupilScale: 0.90, smileAmount: 0.08 }, faceTarget: { lidScale: 0.44, pupilScale: 0.90, smileAmount: 0.08 }, _overrideMood: null, // Ragdoll state machine rd: { state: RD_STAND, timer: 0, fallDirX: 0, fallDirZ: 1, fallAngle: 0, // Mini residual spring (STAND only) slapOffset: { x: 0, z: 0 }, slapVelocity: { x: 0, z: 0 }, }, // Pip startle pipStartleTimer: 0, pipStartleDir: { x: 0, z: 0 }, // Crystal ball hit flash hitFlashTimer: 0, }; } // ── updateAgents (called every frame) ──────────────────────────────────────── export function updateAgents(time) { if (!timmy) return; const t = time * 0.001; const dt = _lastFrameTime > 0 ? Math.min((time - _lastFrameTime) * 0.001, 0.05) : 0.016; _lastFrameTime = time; 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.head.rotation.z = headTilt; // ── Ragdoll state machine (handles group.rotation + position.y) ─────────── _updateRagdoll(dt, t, bodyBob); // ── Crystal ball flash on slap hit ─────────────────────────────────────── if (timmy.hitFlashTimer > 0) { timmy.hitFlashTimer = Math.max(0, timmy.hitFlashTimer - dt); timmy.crystalLight.intensity = 10.0 * (timmy.hitFlashTimer / 0.5); timmy.cbMat.emissiveIntensity = 0.9 * (timmy.hitFlashTimer / 0.5); } else { 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(0x5c14b0); timmy.robe.material.emissiveIntensity = 0.10 + robeGlow; // Magic energy orb — pulsing orange glow in Timmy's hand const magicPulse = 0.85 + Math.sin(t * 3.4) * 0.35; timmy.magicOrb.scale.setScalar(magicPulse * (vs === 'working' ? 1.35 : 1.0)); timmy.magicLight.intensity = 1.0 + Math.sin(t * 4.2) * 0.5 + (vs === 'working' ? 0.8 : 0.0); timmy.magicOrb.position.y = 1.55 + Math.sin(t * 2.8) * 0.04; // ── Pip familiar — startle reaction on slap ─────────────────────────────── if (timmy.pipStartleTimer > 0) timmy.pipStartleTimer = Math.max(0, timmy.pipStartleTimer - dt); const startled = timmy.pipStartleTimer > 0; const startleRatio = startled ? (timmy.pipStartleTimer / 3.0) : 0; const pipSpeedMult = startled ? (1 + 3 * startleRatio) : 1; const pipX = Math.sin(t * 0.38 * pipSpeedMult + 1.4) * 3.2 + timmy.pipStartleDir.x * startleRatio; const pipZ = Math.sin(t * 0.65 * pipSpeedMult + 0.8) * 2.2 - 1.8 + timmy.pipStartleDir.z * startleRatio; const pipY = 1.55 + Math.sin(t * 1.6 * (startled ? 3.5 : 1.0)) * (startled ? 0.72 : 0.32); timmy.pip.position.set(pipX, pipY, pipZ); timmy.pip.rotation.x += 0.022 * (startled ? 4 : 1); timmy.pip.rotation.y += 0.031 * (startled ? 4 : 1); timmy.pipLight.intensity = 0.55 + Math.sin(t * 2.1) * 0.2 + (startled ? 0.6 * startleRatio : 0); // ── 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 ───────────────────────────────────────────────── // 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; const LERP = 0.055; // 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, range 0.2–0.6); override smileAmount target const speaking = timmy.speechTimer > 0; const smileTarget = speaking ? 0.40 + Math.sin(t * 6.283) * 0.20 // 1 Hz, range 0.20–0.60 : timmy.faceTarget.smileAmount; timmy.faceParams.smileAmount += (smileTarget - timmy.faceParams.smileAmount) * LERP; // 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; // Apply pupil dilation — uniform scale on pupil meshes const ps = timmy.faceParams.pupilScale; timmy.pupilL.scale.setScalar(ps); timmy.pupilR.scale.setScalar(ps); // Swap precomputed mouth geometry when cached index changes (zero runtime allocation) const nextMouthGeo = _pickMouthGeo(timmy.faceParams.smileAmount); if (timmy.mouth.geometry !== nextMouthGeo) { timmy.mouth.geometry = nextMouthGeo; } } // ── setFaceEmotion — public API ─────────────────────────────────────────────── // Accepts both task-spec mood names (contemplative|curious|focused|attentive) // and internal state names (idle|active|thinking|working). // 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; if (mood === null || mood === undefined) { timmy._overrideMood = null; return; } timmy._overrideMood = MOOD_ALIASES[mood] ?? 'idle'; } /** * setMood — convenience alias accepted by Task #28 spec. * Maps sentiment labels to appropriate Timmy face states: * POSITIVE → curious (active) — wide eyes, smile * NEGATIVE → focused (thinking) — squint, flat mouth * NEUTRAL → contemplative (idle) — half-lid, neutral * Also accepts raw mood names passed through to setFaceEmotion(). */ export function setMood(moodOrSentiment) { if (!moodOrSentiment) { setFaceEmotion(null); return; } const m = String(moodOrSentiment).toUpperCase(); if (m === 'POSITIVE') { setFaceEmotion('curious'); return; } if (m === 'NEGATIVE') { setFaceEmotion('focused'); return; } if (m === 'NEUTRAL') { setFaceEmotion('contemplative'); return; } // Fall through to alias lookup for raw mood names setFaceEmotion(moodOrSentiment); } // ── _updateRagdoll — integrated per-frame from updateAgents ────────────────── // Controls group.rotation and group.position.y for all ragdoll states. // In RD_STAND it runs the residual micro-spring; all other states run the // full fall/down/rise/counter animation. function _updateRagdoll(dt, t, bodyBob) { const rd = timmy.rd; rd.timer += dt; switch (rd.state) { // ── FALL — ease-in tipping over (0.55 s) ──────────────────────────────── case RD_FALL: { const p = Math.min(1, rd.timer / 0.55); rd.fallAngle = (Math.PI / 2 + 0.12) * (p * p); // ease-in, overshoot slightly timmy.group.rotation.x = rd.fallAngle * rd.fallDirZ; timmy.group.rotation.z = -rd.fallAngle * rd.fallDirX; timmy.group.position.y = 0; timmy._overrideMood = 'active'; // wide-eyed surprise if (rd.timer >= 0.55) { rd.state = RD_DOWN; rd.timer = 0; } break; } // ── DOWN — sprawled, struggling (1.9 s) ───────────────────────────────── case RD_DOWN: { // Settle to exactly PI/2 then heave/twitch while trying to get up const settle = Math.min(1, rd.timer / 0.12); rd.fallAngle = (Math.PI / 2 + 0.12) * (1 - settle) + (Math.PI / 2) * settle; rd.fallAngle += Math.sin(rd.timer * 8.5) * 0.025 * (1 - rd.timer / 1.9); timmy.group.rotation.x = rd.fallAngle * rd.fallDirZ; timmy.group.rotation.z = -rd.fallAngle * rd.fallDirX; timmy.group.position.y = Math.sin(rd.timer * 2.5) * -0.05; timmy._overrideMood = 'thinking'; // squinting / dazed if (rd.timer >= 1.9) { rd.state = RD_RISE; rd.timer = 0; } break; } // ── RISE — ease-out clambering up (1.0 s) ─────────────────────────────── case RD_RISE: { const p = Math.min(1, rd.timer / 1.0); const eased = 1 - (1 - p) * (1 - p); // ease-out rd.fallAngle = (Math.PI / 2) * (1 - eased); timmy.group.rotation.x = rd.fallAngle * rd.fallDirZ; timmy.group.rotation.z = -rd.fallAngle * rd.fallDirX; timmy.group.position.y = 0; timmy._overrideMood = 'working'; // determined if (rd.timer >= 1.0) { // Just stood up — now counter-slap! rd.state = RD_COUNTER; rd.timer = 0; rd.fallAngle = 0; timmy.group.rotation.x = 0; timmy.group.rotation.z = 0; // Pick retort, show speech bubble const retort = COUNTER_RETORTS[Math.floor(Math.random() * COUNTER_RETORTS.length)]; setSpeechBubble(retort); timmy.speechTimer = 4.5; timmy._overrideMood = 'active'; // Smack sound + camera shake _playSmack(true /* high-pitched counter variant */); _cameraShakeStr = 1.0; timmy.hitFlashTimer = 0.6; } break; } // ── COUNTER — lunge forward toward camera (0.6 s) ─────────────────────── case RD_COUNTER: { const p = Math.min(1, rd.timer / 0.6); // Sinusoidal lunge: leans toward camera then snaps back const lunge = Math.sin(p * Math.PI) * 0.35; timmy.group.rotation.x = -lunge; // lean toward +Z (camera) timmy.group.rotation.z = 0; timmy.group.position.y = Math.sin(p * Math.PI) * 0.25; // slight hop _cameraShakeStr = Math.max(0, 1.0 - rd.timer * 4.5); if (rd.timer >= 0.6) { rd.state = RD_STAND; rd.timer = 0; _cameraShakeStr = 0; timmy.group.rotation.x = 0; timmy.group.rotation.z = 0; timmy._overrideMood = null; } break; } // ── STAND — normal idle with residual micro-spring trembling ──────────── case RD_STAND: default: { const so = rd.slapOffset, sv = rd.slapVelocity; sv.x += (-SPRING_STIFFNESS * so.x - SPRING_DAMPING * sv.x) * dt; sv.z += (-SPRING_STIFFNESS * so.z - SPRING_DAMPING * sv.z) * dt; so.x = Math.max(-MAX_TILT_RAD, Math.min(MAX_TILT_RAD, so.x + sv.x * dt)); so.z = Math.max(-MAX_TILT_RAD, Math.min(MAX_TILT_RAD, so.z + sv.z * dt)); timmy.group.rotation.x = so.x; timmy.group.rotation.z = so.z; timmy.group.position.y = bodyBob; break; } } } // ── applySlap — called by interaction.js on hit ─────────────────────────────── export function applySlap(hitPoint) { if (!timmy) return; // Ignore re-slap while already falling/down — wait until standing again const rd = timmy.rd; if (rd.state !== RD_STAND) return; // XZ direction from Timmy to hit point (fall away from impact) const dx = hitPoint.x - TIMMY_POS.x; const dz = hitPoint.z - TIMMY_POS.z; const len = Math.sqrt(dx * dx + dz * dz) || 1; rd.fallDirX = dx / len; rd.fallDirZ = dz / len; // Start ragdoll fall rd.state = RD_FALL; rd.timer = 0; rd.fallAngle = 0; // Pip startle — maximum scatter timmy.pipStartleTimer = 5.0; timmy.pipStartleDir.x = (Math.random() - 0.5) * 8.0; timmy.pipStartleDir.z = (Math.random() - 0.5) * 8.0; // Crystal flash on impact timmy.hitFlashTimer = 0.5; // Cartoonish SMACK sound _playSmack(false); } // ── _playSmack — layered cartoon impact SFX ────────────────────────────────── // counter=false → being slapped (lower pitch thud) // counter=true → Timmy retaliates (higher, snappier crack) function _playSmack(counter) { try { if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)(); if (_audioCtx.state === 'suspended') _audioCtx.resume(); const now = _audioCtx.currentTime; // ① Noise crack — sharp transient const crackLen = counter ? 0.045 : 0.06; const bufSize = Math.ceil(_audioCtx.sampleRate * crackLen); const noiseBuf = _audioCtx.createBuffer(1, bufSize, _audioCtx.sampleRate); const nData = noiseBuf.getChannelData(0); for (let i = 0; i < bufSize; i++) nData[i] = Math.random() * 2 - 1; const nSrc = _audioCtx.createBufferSource(); nSrc.buffer = noiseBuf; const nGain = _audioCtx.createGain(); nGain.gain.setValueAtTime(counter ? 1.4 : 1.1, now); nGain.gain.exponentialRampToValueAtTime(0.001, now + crackLen); nSrc.connect(nGain); nGain.connect(_audioCtx.destination); nSrc.start(now); nSrc.stop(now + crackLen + 0.01); // ② Low body thump — sine sweep const thumpStart = counter ? 340 : 220; const thumpEnd = counter ? 80 : 45; const thumpDur = counter ? 0.14 : 0.20; const thump = _audioCtx.createOscillator(); thump.type = 'sine'; thump.frequency.setValueAtTime(thumpStart, now); thump.frequency.exponentialRampToValueAtTime(thumpEnd, now + thumpDur); const tGain = _audioCtx.createGain(); tGain.gain.setValueAtTime(counter ? 0.75 : 0.95, now); tGain.gain.exponentialRampToValueAtTime(0.001, now + thumpDur + 0.04); thump.connect(tGain); tGain.connect(_audioCtx.destination); thump.start(now); thump.stop(now + thumpDur + 0.05); // ③ Comic wobble tail — cartoon spring spring const wobbleStart = counter ? 280 : 180; const wobbleEnd = counter ? 100 : 55; const wobbleDur = counter ? 0.40 : 0.55; const wobble = _audioCtx.createOscillator(); wobble.type = 'sine'; wobble.frequency.setValueAtTime(wobbleStart, now + 0.04); wobble.frequency.exponentialRampToValueAtTime(wobbleEnd, now + 0.04 + wobbleDur); const wGain = _audioCtx.createGain(); wGain.gain.setValueAtTime(0.0, now); wGain.gain.linearRampToValueAtTime(counter ? 0.30 : 0.40, now + 0.05); wGain.gain.exponentialRampToValueAtTime(0.001, now + 0.06 + wobbleDur); wobble.connect(wGain); wGain.connect(_audioCtx.destination); wobble.start(now + 0.03); wobble.stop(now + 0.06 + wobbleDur); } catch (_) { // Autoplay policy / audio not supported — silently skip } } // ── getTimmyGroup — used by interaction.js for raycasting ──────────────────── export function getTimmyGroup() { return timmy ? timmy.group : null; } // ── getCameraShakeStrength — read each frame by main.js for viewport shake ─── // Returns 0-1; main.js applies a transient random offset to camera.position. export function getCameraShakeStrength() { return _cameraShakeStr; } 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(); } }); // Cached mouth geometries are shared; dispose the cache here _MOUTH_GEO_CACHE.forEach(g => g.dispose()); timmy.mouth?.material?.dispose(); timmy.bubbleTex?.dispose(); timmy.bubbleMat?.dispose(); timmy = null; scene = null; }