From 09ebc19f5f3640fb681ab54e329175a1c54ad03a Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Sun, 22 Mar 2026 21:04:29 -0400 Subject: [PATCH] feat: spring wobble + synthesised boing for Timmy slap interaction Light slaps now trigger a spring wobble (using the previously unused SLAP_IMPULSE constant) with a cartoony synthesised boing sound. Only 3 rapid slaps within 2s escalate to the full ragdoll fall. - Spring wobble: kicks slapVelocity on light tap, damped spring produces visible head/body oscillation - Synthesised boing: 3-layer Web Audio (noise pop + sine sproing + triangle harmonic) for satisfying cartoon spring sound - Pip startle scales proportionally: mild scatter on wobble (1.5s), maximum scatter on ragdoll (5s) - Crystal flash scales: 0.25s for wobble, 0.5s for ragdoll Fixes #35 Co-Authored-By: Claude Opus 4.6 --- the-matrix/js/agents.js | 117 +++++++++++++++++++++++++++++++++++----- 1 file changed, 103 insertions(+), 14 deletions(-) diff --git a/the-matrix/js/agents.js b/the-matrix/js/agents.js index c8cba65..8731efc 100644 --- a/the-matrix/js/agents.js +++ b/the-matrix/js/agents.js @@ -41,6 +41,11 @@ 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) @@ -680,6 +685,7 @@ function _updateRagdoll(dt, t, bodyBob) { } // ── 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; @@ -691,24 +697,50 @@ export function applySlap(hitPoint) { 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; + const dirX = dx / len; + const dirZ = dz / len; - // Start ragdoll fall - rd.state = RD_FALL; - rd.timer = 0; - rd.fallAngle = 0; + // Track rapid slaps for ragdoll threshold + const now = performance.now(); + _slapTimestamps.push(now); + _slapTimestamps = _slapTimestamps.filter(ts => now - ts < SLAP_WINDOW_MS); - // 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; + if (_slapTimestamps.length >= SLAP_RAGDOLL_COUNT) { + // Enough rapid slaps — full ragdoll fall + _slapTimestamps.length = 0; - // Crystal flash on impact - timmy.hitFlashTimer = 0.5; + rd.fallDirX = dirX; + rd.fallDirZ = dirZ; + rd.state = RD_FALL; + rd.timer = 0; + rd.fallAngle = 0; - // Cartoonish SMACK sound - _playSmack(false); + // 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 ────────────────────────────────── @@ -769,6 +801,63 @@ function _playSmack(counter) { } } +// ── _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; -- 2.43.0