[claude] Spring wobble + boing SFX for Timmy slap (#35) #52

Merged
Rockachopa merged 1 commits from claude/issue-35 into main 2026-03-23 14:51:37 +00:00

View File

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