Co-authored-by: Claude (Opus 4.6) <claude@hermes.local> Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
894 lines
35 KiB
JavaScript
894 lines
35 KiB
JavaScript
import * as THREE from 'three';
|
||
import { AGENT_DEFS } from './agent-defs.js';
|
||
|
||
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 = Object.fromEntries(AGENT_DEFS.map(d => [d.id, '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!",
|
||
];
|
||
|
||
// Spring physics for slap wobble (STAND only)
|
||
const SPRING_STIFFNESS = 18.0;
|
||
const SPRING_DAMPING = 4.5;
|
||
const MAX_TILT_RAD = 0.55;
|
||
const SLAP_IMPULSE = 2.8;
|
||
const RAGDOLL_TILT_THRESHOLD = 0.42; // if tilt exceeds this, trigger full ragdoll fall
|
||
|
||
// ── 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 ───────────────────────────────
|
||
// Applies an additive spring impulse so repeated slaps stack. If accumulated
|
||
// tilt exceeds RAGDOLL_TILT_THRESHOLD the full ragdoll fall is triggered.
|
||
export function applySlap(hitPoint) {
|
||
if (!timmy) return;
|
||
|
||
const rd = timmy.rd;
|
||
|
||
// Ignore re-slap while already in ragdoll cycle — wait until standing
|
||
if (rd.state !== RD_STAND) return;
|
||
|
||
// XZ direction from Timmy to hit point (wobble/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;
|
||
|
||
// Additive spring impulse — stacks with any existing wobble
|
||
rd.slapVelocity.x += dirZ * SLAP_IMPULSE; // rotation.x driven by Z direction
|
||
rd.slapVelocity.z += -dirX * SLAP_IMPULSE; // rotation.z driven by X direction
|
||
|
||
// Check if accumulated tilt is large enough to trigger full ragdoll
|
||
const tiltMag = Math.sqrt(rd.slapOffset.x * rd.slapOffset.x + rd.slapOffset.z * rd.slapOffset.z);
|
||
const velMag = Math.sqrt(rd.slapVelocity.x * rd.slapVelocity.x + rd.slapVelocity.z * rd.slapVelocity.z);
|
||
|
||
if (tiltMag > RAGDOLL_TILT_THRESHOLD || velMag > SLAP_IMPULSE * 2.2) {
|
||
// Enough stacked force — trigger full ragdoll fall
|
||
rd.fallDirX = dirX;
|
||
rd.fallDirZ = dirZ;
|
||
rd.state = RD_FALL;
|
||
rd.timer = 0;
|
||
rd.fallAngle = 0;
|
||
// Reset spring state so it's clean when returning to STAND
|
||
rd.slapOffset.x = 0; rd.slapOffset.z = 0;
|
||
rd.slapVelocity.x = 0; rd.slapVelocity.z = 0;
|
||
|
||
// Pip startle — maximum scatter on ragdoll
|
||
timmy.pipStartleTimer = 5.0;
|
||
timmy.pipStartleDir.x = (Math.random() - 0.5) * 8.0;
|
||
timmy.pipStartleDir.z = (Math.random() - 0.5) * 8.0;
|
||
} else {
|
||
// Pip mild startle on wobble
|
||
timmy.pipStartleTimer = Math.max(timmy.pipStartleTimer, 3.0);
|
||
timmy.pipStartleDir.x = (Math.random() - 0.5) * 4.0;
|
||
timmy.pipStartleDir.z = (Math.random() - 0.5) * 4.0;
|
||
}
|
||
|
||
// Crystal flash on every hit
|
||
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;
|
||
}
|