[claude] Spring wobble + boing SFX for Timmy slap (#35) #52
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user