From 35babd24007ce3d698ea3649e29d281e958ebe64 Mon Sep 17 00:00:00 2001 From: alexpaynex <55271826-alexpaynex@users.noreply.replit.com> Date: Thu, 19 Mar 2026 03:31:01 +0000 Subject: [PATCH] Task #22: Slap/ragdoll physics on Timmy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What was done - agents.js: spring physics (stiffness=7, damping=0.8, clamp ±0.44 rad) on timmy.group.rotation.x/z via slapOffset/slapVelocity integrated per-frame with proper dt (capped at 50ms for tab-background safety) - agents.js: applySlap(hitPoint) — computes XZ impulse direction from hit point relative to TIMMY_POS, adds angular velocity, triggers pip startle + crystal flash - agents.js: _playBoing() — lazy AudioContext, sine oscillator 260→90 Hz with exponential gain decay (0.38s) - agents.js: Pip startle — 3s decay timer, random scatter direction offset, 4x spin speed while startled, boosted pip light intensity - agents.js: Crystal ball hit flash — hitFlashTimer=0.5s, intensity spikes to 10 and fades; normal crystalLight/cbMat logic when not flashing - agents.js: getTimmyGroup() export for raycaster target - interaction.js: registerSlapTarget(group, applyFn) — stores targets - interaction.js: _onPointerDown capture-phase listener — raycasts against timmyGroup recursively, calls applySlap on hit, suppresses OrbitControls drag for 220ms via stopImmediatePropagation + controls.enabled toggle - main.js: imports getTimmyGroup, applySlap, registerSlapTarget; wires registerSlapTarget(getTimmyGroup(), applySlap) after initInteraction ## Verification - Vite build: clean, 14 modules, 0 errors - /tower HTTP 200 - Testkit: 27/27 PASS --- the-matrix/js/agents.js | 111 ++++++++++++++++++++++++++++++++--- the-matrix/js/interaction.js | 48 +++++++++++++++ the-matrix/js/main.js | 4 +- 3 files changed, 154 insertions(+), 9 deletions(-) diff --git a/the-matrix/js/agents.js b/the-matrix/js/agents.js index f7540cc..2e83159 100644 --- a/the-matrix/js/agents.js +++ b/the-matrix/js/agents.js @@ -14,6 +14,14 @@ function deriveTimmyState() { let scene = null; let timmy = null; +let _audioCtx = null; +let _lastFrameTime = 0; + +// ── Spring physics constants ─────────────────────────────────────────────────── +const SPRING_STIFFNESS = 7.0; +const SPRING_DAMPING = 0.80; +const MAX_TILT_RAD = 0.44; // ~25° +const SLAP_IMPULSE = 0.28; // ── Face emotion targets per internal state ─────────────────────────────────── // lidScale: 0 = fully closed, 1 = wide open @@ -363,6 +371,14 @@ 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 }, + // Pip startle + pipStartleTimer: 0, + pipStartleDir: { x: 0, z: 0 }, + // Crystal ball hit flash + hitFlashTimer: 0, }; } @@ -371,6 +387,9 @@ function buildTimmy(sc) { 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); @@ -409,8 +428,24 @@ export function updateAgents(time) { timmy.group.position.y = bodyBob; timmy.head.rotation.z = headTilt; - timmy.crystalLight.intensity = crystalI; - timmy.cbMat.emissiveIntensity = 0.28 + (crystalI / 6) * 0.72; + // ── 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; + + // ── 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); @@ -424,13 +459,18 @@ export function updateAgents(time) { 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; - const pipX = Math.sin(t * 0.38 + 1.4) * 3.2; - const pipZ = Math.sin(t * 0.65 + 0.8) * 2.2 - 1.8; - const pipY = 1.55 + Math.sin(t * 1.6) * 0.32; + // ── 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; - timmy.pip.rotation.y += 0.031; - timmy.pipLight.intensity = 0.55 + Math.sin(t * 2.1) * 0.2; + 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) { @@ -494,6 +534,61 @@ export function setFaceEmotion(mood) { timmy._overrideMood = MOOD_ALIASES[mood] ?? 'idle'; } +// ── 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 + 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; + + // Add angular impulse: Z impact → X rotation, X impact → Z rotation + timmy.slapVelocity.x += nz * SLAP_IMPULSE; + timmy.slapVelocity.z -= nx * SLAP_IMPULSE; + + // 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; + + // Crystal ball flash + timmy.hitFlashTimer = 0.5; + + // Synthesised boing SFX + _playBoing(); +} + +function _playBoing() { + try { + if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + if (_audioCtx.state === 'suspended') _audioCtx.resume(); + + const osc = _audioCtx.createOscillator(); + const gain = _audioCtx.createGain(); + osc.connect(gain); + gain.connect(_audioCtx.destination); + + osc.type = 'sine'; + osc.frequency.setValueAtTime(260, _audioCtx.currentTime); + osc.frequency.exponentialRampToValueAtTime(90, _audioCtx.currentTime + 0.32); + + gain.gain.setValueAtTime(0.55, _audioCtx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, _audioCtx.currentTime + 0.38); + + osc.start(_audioCtx.currentTime); + osc.stop(_audioCtx.currentTime + 0.40); + } catch (_) { + // Autoplay policy or audio not supported — silently skip + } +} + +// ── getTimmyGroup — used by interaction.js for raycasting ──────────────────── +export function getTimmyGroup() { + return timmy ? timmy.group : null; +} + export function setAgentState(agentId, state) { if (agentId in agentStates) agentStates[agentId] = state; } diff --git a/the-matrix/js/interaction.js b/the-matrix/js/interaction.js index 0d3a2fa..2bf5bea 100644 --- a/the-matrix/js/interaction.js +++ b/the-matrix/js/interaction.js @@ -1,10 +1,28 @@ +import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; let controls; let _canvas; +let _camera = null; +let _timmyGroup = null; +let _applySlap = null; +const _raycaster = new THREE.Raycaster(); +const _pointer = new THREE.Vector2(); const _noCtxMenu = e => e.preventDefault(); +// ── registerSlapTarget ──────────────────────────────────────────────────────── +// Call after initAgents() with Timmy's group and the applySlap function. +// The capture-phase pointerdown listener will hit-test against the group and +// call applySlap(hitPoint) — also suppressing the OrbitControls drag. + +export function registerSlapTarget(timmyGroup, applyFn) { + _timmyGroup = timmyGroup; + _applySlap = applyFn; +} + export function initInteraction(camera, renderer) { + _camera = camera; + controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.05; @@ -17,6 +35,32 @@ export function initInteraction(camera, renderer) { _canvas = renderer.domElement; _canvas.addEventListener('contextmenu', _noCtxMenu); + + // Capture phase so we intercept before OrbitControls' bubble-phase handler. + // If Timmy is hit we call stopImmediatePropagation() to suppress the orbit drag. + _canvas.addEventListener('pointerdown', _onPointerDown, { capture: true }); +} + +function _onPointerDown(event) { + if (!_timmyGroup || !_applySlap || !_camera) return; + + const rect = _canvas.getBoundingClientRect(); + _pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; + _pointer.y = ((event.clientY - rect.top) / rect.height) * -2 + 1; + + _raycaster.setFromCamera(_pointer, _camera); + const hits = _raycaster.intersectObject(_timmyGroup, true); + + if (hits.length > 0) { + event.stopImmediatePropagation(); // block OrbitControls drag + _applySlap(hits[0].point); + + // Re-enable orbit after a short pause to avoid accidental rotation + if (controls) { + controls.enabled = false; + setTimeout(() => { if (controls) controls.enabled = true; }, 220); + } + } } export function updateControls() { @@ -30,10 +74,14 @@ export function updateControls() { export function disposeInteraction() { if (_canvas) { _canvas.removeEventListener('contextmenu', _noCtxMenu); + _canvas.removeEventListener('pointerdown', _onPointerDown, { capture: true }); _canvas = null; } if (controls) { controls.dispose(); controls = null; } + _camera = null; + _timmyGroup = null; + _applySlap = null; } diff --git a/the-matrix/js/main.js b/the-matrix/js/main.js index 5c5fbca..d5d5fd1 100644 --- a/the-matrix/js/main.js +++ b/the-matrix/js/main.js @@ -2,10 +2,11 @@ import { initWorld, onWindowResize, disposeWorld } from './world.js'; import { initAgents, updateAgents, getAgentCount, disposeAgents, getAgentStates, applyAgentStates, + getTimmyGroup, applySlap, } from './agents.js'; import { initEffects, updateEffects, disposeEffects } from './effects.js'; import { initUI, updateUI } from './ui.js'; -import { initInteraction, disposeInteraction } from './interaction.js'; +import { initInteraction, disposeInteraction, registerSlapTarget } from './interaction.js'; import { initWebSocket, getConnectionState, getJobCount } from './websocket.js'; import { initPaymentPanel } from './payment.js'; @@ -22,6 +23,7 @@ function buildWorld(firstInit, stateSnapshot) { if (stateSnapshot) applyAgentStates(stateSnapshot); initInteraction(camera, renderer); + registerSlapTarget(getTimmyGroup(), applySlap); if (firstInit) { initUI();