diff --git a/the-matrix/js/agents.js b/the-matrix/js/agents.js index 2e83159..933af8a 100644 --- a/the-matrix/js/agents.js +++ b/the-matrix/js/agents.js @@ -14,14 +14,31 @@ function deriveTimmyState() { let scene = null; let timmy = null; -let _audioCtx = null; -let _lastFrameTime = 0; +let _audioCtx = null; +let _lastFrameTime = 0; +let _cameraShakeStr = 0; // 0-1, read by getCameraShakeStrength() -// ── Spring physics constants ─────────────────────────────────────────────────── +// ── 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.44; // ~25° -const SLAP_IMPULSE = 0.28; +const MAX_TILT_RAD = 0.12; +const SLAP_IMPULSE = 0.18; // ── Face emotion targets per internal state ─────────────────────────────────── // lidScale: 0 = fully closed, 1 = wide open @@ -371,9 +388,17 @@ function buildTimmy(sc) { faceParams: { lidScale: 0.44, pupilScale: 0.90, smileAmount: 0.08 }, faceTarget: { lidScale: 0.44, pupilScale: 0.90, smileAmount: 0.08 }, _overrideMood: null, - // Spring physics (slap ragdoll) - slapOffset: { x: 0, z: 0 }, - slapVelocity: { x: 0, z: 0 }, + // 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 }, @@ -425,17 +450,10 @@ export function updateAgents(time) { robeGlow = 0.0; } - timmy.group.position.y = bodyBob; timmy.head.rotation.z = headTilt; - // ── Spring ragdoll integration ──────────────────────────────────────────── - const so = timmy.slapOffset, sv = timmy.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; + // ── Ragdoll state machine (handles group.rotation + position.y) ─────────── + _updateRagdoll(dt, t, bodyBob); // ── Crystal ball flash on slap hit ─────────────────────────────────────── if (timmy.hitFlashTimer > 0) { @@ -534,53 +552,201 @@ export function setFaceEmotion(mood) { timmy._overrideMood = MOOD_ALIASES[mood] ?? 'idle'; } +// ── _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; - // XZ direction from Timmy origin to hit point — tilt group away from impact + // 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 nx = dx / len, nz = dz / len; + rd.fallDirX = dx / len; + rd.fallDirZ = dz / len; - // Add angular impulse: Z impact → X rotation, X impact → Z rotation - timmy.slapVelocity.x += nz * SLAP_IMPULSE; - timmy.slapVelocity.z -= nx * SLAP_IMPULSE; + // Start ragdoll fall + rd.state = RD_FALL; + rd.timer = 0; + rd.fallAngle = 0; - // Pip startle: scatter in a random direction - timmy.pipStartleTimer = 3.0; - timmy.pipStartleDir.x = (Math.random() - 0.5) * 5.0; - timmy.pipStartleDir.z = (Math.random() - 0.5) * 5.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 ball flash + // Crystal flash on impact timmy.hitFlashTimer = 0.5; - // Synthesised boing SFX - _playBoing(); + // Cartoonish SMACK sound + _playSmack(false); } -function _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; - const osc = _audioCtx.createOscillator(); - const gain = _audioCtx.createGain(); - osc.connect(gain); - gain.connect(_audioCtx.destination); + // ① 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); - osc.type = 'sine'; - osc.frequency.setValueAtTime(260, _audioCtx.currentTime); - osc.frequency.exponentialRampToValueAtTime(90, _audioCtx.currentTime + 0.32); + // ② 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); - gain.gain.setValueAtTime(0.55, _audioCtx.currentTime); - gain.gain.exponentialRampToValueAtTime(0.001, _audioCtx.currentTime + 0.38); + // ③ 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); - osc.start(_audioCtx.currentTime); - osc.stop(_audioCtx.currentTime + 0.40); } catch (_) { - // Autoplay policy or audio not supported — silently skip + // Autoplay policy / audio not supported — silently skip } } @@ -589,6 +755,12 @@ 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; } diff --git a/the-matrix/js/main.js b/the-matrix/js/main.js index d5d5fd1..8831618 100644 --- a/the-matrix/js/main.js +++ b/the-matrix/js/main.js @@ -2,7 +2,7 @@ import { initWorld, onWindowResize, disposeWorld } from './world.js'; import { initAgents, updateAgents, getAgentCount, disposeAgents, getAgentStates, applyAgentStates, - getTimmyGroup, applySlap, + getTimmyGroup, applySlap, getCameraShakeStrength, } from './agents.js'; import { initEffects, updateEffects, disposeEffects } from './effects.js'; import { initUI, updateUI } from './ui.js'; @@ -61,7 +61,21 @@ function buildWorld(firstInit, stateSnapshot) { connectionState: getConnectionState(), }); + // Camera shake — apply transient offset, render, then restore (no drift) + const shakeStr = getCameraShakeStrength(); + let sx = 0, sy = 0; + if (shakeStr > 0) { + const mag = shakeStr * 0.22; + sx = (Math.random() - 0.5) * mag; + sy = (Math.random() - 0.5) * mag * 0.45; + camera.position.x += sx; + camera.position.y += sy; + } renderer.render(scene, camera); + if (shakeStr > 0) { + camera.position.x -= sx; + camera.position.y -= sy; + } } animate();