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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user