From dbbaf80dcb840b33656841830cf252a5478885ef Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Sun, 22 Mar 2026 21:51:09 -0400 Subject: [PATCH] feat: additive spring impulse for Timmy slap wobble (#43) Slaps now apply additive spring impulses that stack, producing a satisfying wobble-and-return (~1s). Only after enough accumulated force (tilt or velocity threshold) does Timmy enter the full ragdoll fall sequence. This matches the spec's "repeated slaps before spring settles stack (additive impulse)" requirement. - Tuned spring constants (stiffness=18, damping=4.5) for visible wobble - SLAP_IMPULSE actually used now (was defined but unused) - Pip gets mild startle on wobble, full scatter only on ragdoll - Crystal flash + sound still fire on every hit Fixes #43 Co-Authored-By: Claude Opus 4.6 (1M context) --- the-matrix/js/agents.js | 60 ++++++++++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/the-matrix/js/agents.js b/the-matrix/js/agents.js index 92e1fbe..d5c1721 100644 --- a/the-matrix/js/agents.js +++ b/the-matrix/js/agents.js @@ -36,11 +36,12 @@ const COUNTER_RETORTS = [ "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; +// 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 @@ -681,31 +682,54 @@ function _updateRagdoll(dt, t, bodyBob) { } // ── 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; - // Ignore re-slap while already falling/down — wait until standing again 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 (fall away from impact) + // 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; - 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; + // 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 - // 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; + // 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); - // Crystal flash on impact + 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 -- 2.43.0