This repository has been archived on 2026-03-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
2026-03-23 14:51:34 +00:00

958 lines
37 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
// Multi-tap ragdoll threshold: N slaps within SLAP_WINDOW_MS triggers full fall
const SLAP_RAGDOLL_COUNT = 3;
const SLAP_WINDOW_MS = 2000;
let _slapTimestamps = [];
// ── 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.20.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.200.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 ───────────────────────────────
// Light slap → spring wobble + boing. Rapid repeated slaps → full ragdoll fall.
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;
const dirX = dx / len;
const dirZ = dz / len;
// Track rapid slaps for ragdoll threshold
const now = performance.now();
_slapTimestamps.push(now);
_slapTimestamps = _slapTimestamps.filter(ts => now - ts < SLAP_WINDOW_MS);
if (_slapTimestamps.length >= SLAP_RAGDOLL_COUNT) {
// Enough rapid slaps — full ragdoll fall
_slapTimestamps.length = 0;
rd.fallDirX = dirX;
rd.fallDirZ = dirZ;
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);
} else {
// Light slap — spring wobble only
rd.slapVelocity.x += dirX * SLAP_IMPULSE;
rd.slapVelocity.z += dirZ * SLAP_IMPULSE;
// Pip startle — mild scatter
timmy.pipStartleTimer = 1.5;
timmy.pipStartleDir.x = (Math.random() - 0.5) * 3.0;
timmy.pipStartleDir.z = (Math.random() - 0.5) * 3.0;
// Mild crystal flash
timmy.hitFlashTimer = 0.25;
// Synthesised boing
_playBoing();
}
}
// ── _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
}
}
// ── _playBoing — synthesised cartoon spring sound for light slaps ─────────────
// Two-layer: a quick rising sine "sproing" + a brief noise pop for attack.
function _playBoing() {
try {
if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (_audioCtx.state === 'suspended') _audioCtx.resume();
const now = _audioCtx.currentTime;
// ① Short noise pop — gives the boing percussive attack
const popLen = 0.025;
const popBuf = _audioCtx.createBuffer(1, Math.ceil(_audioCtx.sampleRate * popLen), _audioCtx.sampleRate);
const popData = popBuf.getChannelData(0);
for (let i = 0; i < popData.length; i++) popData[i] = Math.random() * 2 - 1;
const popSrc = _audioCtx.createBufferSource();
popSrc.buffer = popBuf;
const popGain = _audioCtx.createGain();
popGain.gain.setValueAtTime(0.5, now);
popGain.gain.exponentialRampToValueAtTime(0.001, now + popLen);
popSrc.connect(popGain);
popGain.connect(_audioCtx.destination);
popSrc.start(now);
popSrc.stop(now + popLen + 0.01);
// ② Rising sine "sproing" — pitch sweep up then down
const boingDur = 0.35;
const boing = _audioCtx.createOscillator();
boing.type = 'sine';
boing.frequency.setValueAtTime(180, now);
boing.frequency.linearRampToValueAtTime(520, now + 0.08);
boing.frequency.exponentialRampToValueAtTime(140, now + boingDur);
const bGain = _audioCtx.createGain();
bGain.gain.setValueAtTime(0.45, now);
bGain.gain.exponentialRampToValueAtTime(0.001, now + boingDur);
boing.connect(bGain);
bGain.connect(_audioCtx.destination);
boing.start(now);
boing.stop(now + boingDur + 0.01);
// ③ Harmonics — a second oscillator one octave up for richness
const harm = _audioCtx.createOscillator();
harm.type = 'triangle';
harm.frequency.setValueAtTime(360, now);
harm.frequency.linearRampToValueAtTime(1040, now + 0.08);
harm.frequency.exponentialRampToValueAtTime(280, now + boingDur);
const hGain = _audioCtx.createGain();
hGain.gain.setValueAtTime(0.15, now);
hGain.gain.exponentialRampToValueAtTime(0.001, now + boingDur * 0.7);
harm.connect(hGain);
hGain.connect(_audioCtx.destination);
harm.start(now);
harm.stop(now + boingDur + 0.01);
} 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;
}