Add ragdoll physics and reactive camera shake for satisfying slaps

Implement ragdoll physics for agent interactions, including a state machine for falling, getting up, and counter-attacks. Introduce camera shake based on slap impact and export camera shake strength from agents.js. Update main.js to apply camera shake around the renderer.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 418bf6f8-212b-4bb0-a7a5-8231a061da4e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: b80e7d8c-b272-408c-8f8f-e4edd67ca534
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/9f85e954-647c-46a5-90a7-396e495a805a/418bf6f8-212b-4bb0-a7a5-8231a061da4e/Q83Uqvu
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
alexpaynex
2026-03-19 03:40:41 +00:00
parent a0df576ed6
commit 0419ada6e2
2 changed files with 229 additions and 43 deletions

View File

@@ -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;
}

View File

@@ -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();