diff --git a/HANDOFF.md b/HANDOFF.md index c8823bf..40b1a41 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1180,6 +1180,13 @@ this on any new pose with a raised prop. index, never a search — which is why the laser looked fine while the other four did not. Note also that spending re-indexes the rack (`spendSlot` splices), so a slot number goes stale the moment any tin empties. Look weapons up by ID, not by a slot you cached a frame ago. +- **Every sound in audio.js swallows its own errors** (`try { ... } catch (e) {}`), so a clean + console proves NOTHING about whether one played. To verify a sound really built its graph, count + nodes — patch `AudioContext.prototype.createBufferSource` / `createOscillator` / + `createBiquadFilter` and diff around the event. To verify a PARAMETER reached the synthesis, patch + `AudioParam.prototype.setValueAtTime` and look for the frequencies you expect (that is how the + size scaling on `thud`/`splat` was checked: 150 Hz body thump on a shambler, 100 on a brute, + 59 on a chapter-4 boss). - **A test that walks the game forward will quietly kill the player.** Three separate diagnostic runs above returned nonsense (`stillCarried: true`, no laser fired at all) purely because `G.mode` had become `'eaten'` several steps earlier and every gameplay entry point bails on that. When a probe diff --git a/audio.js b/audio.js index 90e8961..affe1e3 100644 --- a/audio.js +++ b/audio.js @@ -185,19 +185,72 @@ export function pock(vol = 0.8, freq = 900) { // racket contact / bounce thock } catch (e) {} } -export function splat(vol = 0.7) { // wet burst — a zombie meets a first serve +// Ball meets BODY, without killing it. This was borrowing pock() — the racket thock — which is a +// bright, dry, stationary-band click: exactly right for gut on felt and exactly wrong for felt on a +// torso, so every non-fatal hit sounded like the ball had struck a wall. `size` is zombieH / 1.8, so +// a shambler is 1, a brute ~1.5 and a late chapter boss ~2.4: the thump under it drops from 150 Hz +// to around 60, and the whole hit slows down with it. +export function thud(vol = 0.6, size = 1) { try { const ac = audio(), t = ac.currentTime; - const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, 0.22); - const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; - lp.frequency.setValueAtTime(1400, t); lp.frequency.exponentialRampToValueAtTime(180, t + 0.2); + const k = Math.max(0.7, Math.min(2.4, size)); + const dur = 0.15 * k; + // the slap. The band FALLS, where pock's sits still — a collapsing resonance is the wet part, + // and it is the same trick splat() uses, just shorter and much less of it. + const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, dur); + const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 3.4; + bp.frequency.setValueAtTime(780 / k, t); + bp.frequency.exponentialRampToValueAtTime(190 / k, t + dur); const g = ac.createGain(); - g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.22); - src.connect(lp).connect(g).connect(ac.destination); src.start(); - const o = ac.createOscillator(), og = ac.createGain(); // body thud - o.frequency.setValueAtTime(120, t); o.frequency.exponentialRampToValueAtTime(45, t + 0.15); - og.gain.setValueAtTime(vol * 0.7, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.16); - o.connect(og).connect(ac.destination); o.start(t); o.stop(t + 0.18); + g.gain.setValueAtTime(vol * 0.8, t); g.gain.exponentialRampToValueAtTime(0.001, t + dur); + src.connect(bp).connect(g).connect(ac.destination); src.start(t); + // the weight behind it — THIS is what actually carries the size difference + const o = ac.createOscillator(), og = ac.createGain(); + o.frequency.setValueAtTime(150 / k, t); o.frequency.exponentialRampToValueAtTime(52 / k, t + 0.11 * k); + og.gain.setValueAtTime(vol, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.13 * k); + o.connect(og).connect(ac.destination); o.start(t); o.stop(t + 0.15 * k); + // one short bright tick just behind the slap: something gave. Quiet on purpose — this is a hit, + // not a burst, and at splat()'s spatter level every rally would sound like a kill. + const d = 0.03 * k, sp = ac.createBufferSource(); sp.buffer = noiseBuf(ac, 0.07 * k); + const hp = ac.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 1100; + const spg = ac.createGain(); + spg.gain.setValueAtTime(vol * 0.18, t + d); + spg.gain.exponentialRampToValueAtTime(0.001, t + d + 0.07 * k); + sp.connect(hp).connect(spg).connect(ac.destination); sp.start(t + d); + } catch (e) {} +} + +// Wet burst — a body meeting a first serve. `size` is roughly zombieH / 1.8, so 1 is a shambler, +// ~1.5 a brute and ~2.4 a late chapter boss: everything below pitches DOWN and lasts LONGER with it. +// A runner pops; a boss comes apart. Same sound, and that is the point — it should read as the same +// event happening to a bigger body, not as a different event. +export function splat(vol = 0.7, size = 1) { + try { + const ac = audio(), t = ac.currentTime; + const k = Math.max(0.7, Math.min(2.4, size)); // clamp: nothing gets so big it stops being a splat + const dur = 0.22 * k; + // The wetness lives HERE. The old filter was a plain lowpass with no resonance, which is a dry + // hiss with the top taken off; a RESONANT band collapsing down the spectrum is a squelch. + const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, dur); + const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 3.2; + bp.frequency.setValueAtTime(1100 / k, t); + bp.frequency.exponentialRampToValueAtTime(130 / k, t + dur * 0.85); + const g = ac.createGain(); + g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.001, t + dur); + src.connect(bp).connect(g).connect(ac.destination); src.start(t); + const o = ac.createOscillator(), og = ac.createGain(); // body thud, heavier on a heavier body + o.frequency.setValueAtTime(120 / k, t); o.frequency.exponentialRampToValueAtTime(45 / k, t + 0.15 * k); + og.gain.setValueAtTime(vol * 0.7, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.16 * k); + o.connect(og).connect(ac.destination); o.start(t); o.stop(t + 0.18 * k); + // ...and what came out of it lands a moment later. This second, brighter burst is most of what + // reads as "wet" — one clean hit is a thud, a thud followed by spatter is a body. + const d = 0.06 * k, sp = ac.createBufferSource(); sp.buffer = noiseBuf(ac, 0.18 * k); + const hp = ac.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 900; + const spg = ac.createGain(); + spg.gain.setValueAtTime(0.001, t + d); + spg.gain.exponentialRampToValueAtTime(vol * 0.3, t + d + 0.02); + spg.gain.exponentialRampToValueAtTime(0.001, t + d + 0.18 * k); + sp.connect(hp).connect(spg).connect(ac.destination); sp.start(t + d); } catch (e) {} } @@ -217,6 +270,43 @@ export function boom(vol = 0.9) { // bomb ball — sub-bass thump under a filter } catch (e) {} } +// The laser on release. It was borrowing boom() — the BOMB — which is why the showiest weapon in +// the game sounded like the least showy one. This is a plane of light cutting a crowd in half, so: +// a crack, a dive, weight underneath it, and a ring after it has gone. +export function zap(vol = 0.5) { + try { + const ac = audio(), t = ac.currentTime, dur = 0.42; + const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, 0.09); // the crack: air leaving + const hp = ac.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 2200; + const ng = ac.createGain(); + ng.gain.setValueAtTime(vol * 0.85, t); ng.gain.exponentialRampToValueAtTime(0.001, t + 0.09); + src.connect(hp).connect(ng).connect(ac.destination); src.start(t); + // the body: two detuned saws DIVING through a resonant lowpass. The direction is the whole + // reading — a rising sweep is a charge-up, and this thing never charges, it discharges. + const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.Q.value = 12; + lp.frequency.setValueAtTime(5200, t); lp.frequency.exponentialRampToValueAtTime(220, t + dur * 0.8); + const bg = ac.createGain(); + bg.gain.setValueAtTime(vol, t); bg.gain.exponentialRampToValueAtTime(0.001, t + dur); + lp.connect(bg).connect(ac.destination); + for (const det of [1, 1.008]) { // the detune IS the width: one saw alone is a game-show buzzer + const o = ac.createOscillator(); o.type = 'sawtooth'; + o.frequency.setValueAtTime(1300 * det, t); + o.frequency.exponentialRampToValueAtTime(120 * det, t + dur * 0.7); + o.connect(lp); o.start(t); o.stop(t + dur); + } + const s = ac.createOscillator(), sg = ac.createGain(); // weight, or it is only hiss on a phone + s.frequency.setValueAtTime(150, t); s.frequency.exponentialRampToValueAtTime(48, t + 0.3); + sg.gain.setValueAtTime(vol * 0.7, t); sg.gain.exponentialRampToValueAtTime(0.001, t + 0.34); + s.connect(sg).connect(ac.destination); s.start(t); s.stop(t + 0.36); + const r = ac.createOscillator(), rg = ac.createGain(); // ...and it rings after it has gone + r.type = 'triangle'; r.frequency.setValueAtTime(3100, t); + r.frequency.exponentialRampToValueAtTime(1900, t + dur); + rg.gain.setValueAtTime(0.001, t); rg.gain.exponentialRampToValueAtTime(vol * 0.2, t + 0.05); + rg.gain.exponentialRampToValueAtTime(0.001, t + dur + 0.12); + r.connect(rg).connect(ac.destination); r.start(t); r.stop(t + dur + 0.14); + } catch (e) {} +} + export function shriek(vol = 0.35) { // a runner announces itself — harsh rising cry, the fairness contract try { const ac = audio(), t = ac.currentTime, dur = 0.7; diff --git a/index.html b/index.html index fa47f93..2d48429 100644 --- a/index.html +++ b/index.html @@ -184,6 +184,6 @@ - + diff --git a/zombies.js b/zombies.js index 8ea775f..f59e650 100644 --- a/zombies.js +++ b/zombies.js @@ -3,9 +3,9 @@ // your baseline — one crossing it is a point for THEM. // (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.) -import { TUNE, UI, COURT, VOLLEY, CONTACT, BOSS_ART, WEAPONS, BARREL_WEAPONS, woundStage, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js'; +import { TUNE, UI, COURT, VOLLEY, CONTACT, BOSS_ART, WEAPONS, BARREL_WEAPONS, woundStage, zombieH, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js'; import { G, P, ball, sendBoy, setupPoint } from './state.js'; -import { splat, moan, pock, voice, boom, swish, shriek, aww, sfx , whoosh } from './audio.js'; +import { splat, moan, pock, thud, zap, voice, boom, swish, shriek, aww, sfx , whoosh } from './audio.js'; import { splatPoint, cheer, spentBall, resolveInterlude, showModal, endBossFight, aside, grantWeapon, weaponModal, endBurst } from './gameplay.js'; export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'} @@ -296,6 +296,9 @@ const hitR = zb => zb.variant === 'g' ? 0.75 : zb.variant === 'd' ? 0.64 : zb.va : zb.variant === 'B' ? 0.55 * (zb.scale || 1) * 1.4 // a chapter boss is a barn door, as it should be : zb.variant === 'm' ? 0.8 // the machine is a wide low box : 0.55; +// How big it sounds. 1 = a shambler, and every impact sound scales its pitch and length off this, +// so a ball landing on a brute is audibly a bigger event than one landing on a runner. +const bodySize = zb => zombieH(zb) / 1.8; // THE LASER. Not a projectile: the serve gesture you already know, and instead of a ball you get a // widening plane of light down the lane you aimed at, killing everything standing inside the wedge @@ -307,7 +310,7 @@ export function fireBeam(dirX, dirZ) { // out of the PLAYER at chest height — same point the cone is measured from, so what you see and // what it kills are one geometry G.beam = { x: P.x, y: TUNE.beamY + TUNE.beamOriginLift, z: P.z, ux, uz, t: TUNE.beamT }; - boom(0.55); G.shake = { t: 0.18, mag: 3 }; G.boomFlash = 0.08; + zap(0.55); G.shake = { t: 0.18, mag: 3 }; G.boomFlash = 0.08; for (let i = zombies.length - 1; i >= 0; i--) { const zb = zombies[i]; if (!zb) continue; @@ -376,7 +379,7 @@ function bossDeath(zb) { G.slowT = TUNE.bossSlowT; // real seconds; main.js scales dt while it runs G.bossBurst = { x: zb.x, z: zb.z, t: 0.75 }; // the shockwave ring render.js draws G.shake = { t: 0.5, mag: 7 }; G.boomFlash = 0.12; - boom(); splat(1); cheer(4, 1); voice('unbelievable', 0.5); + boom(); splat(1, bodySize(zb)); cheer(4, 1); voice('unbelievable', 0.5); // the biggest body in the game, at its lowest const h = 1.8 * (zb.scale || 1.5); for (let k = 0; k < 90; k++) parts.push({ // on top of killZombie's own burst: a full body's worth x: zb.x, y: h * (0.2 + Math.random() * 0.7), z: zb.z, @@ -426,7 +429,7 @@ function killZombie(i, quiet) { }); } if (ash) { for (let k = 0; k < 5; k++) smokePuff(zb.x, 0.4 + Math.random() * 1.1, zb.z, 0.45); pock(0.35, 150); } else { - splat(); + splat(1, bodySize(zb)); // the burst is pitched by the body too, so a boss comes apart LOW sfx('headshot-' + (Math.random() < 0.5 ? 1 : 2), 0.6); // random headshot crack layered on the gore burst } sendBoy(zb.x, zb.z, true); // a boy comes out with the mop @@ -458,7 +461,7 @@ function wound(zb, amount, fx = true) { zb.chip = (zb.chip || 0) + amount; let took = 0; while (zb.chip >= 1 && zb.hp > 0) { zb.chip -= 1; zb.hp--; took++; } - if (took && fx) { splat(0.5); woundBurst(zb); } + if (took && fx) { splat(0.5, bodySize(zb)); woundBurst(zb); } return zb.hp <= 0; } @@ -945,20 +948,23 @@ export function updateZombies(dt) { if ((zb.hpMax || 0) > 1) { const meat = zb.variant !== 'm'; // the machine rings and does not bleed const heavy = 'gmhk'.includes(zb.variant); // a heavy body is not shoved back by a ball + const sz = bodySize(zb); if ((ball.pow || 0) >= TUNE.zSplatQ) { if (--zb.hp <= 0) { splatter(i); continue; } // the killing blow if (meat) woundBurst(zb); // a bloody wound, not a death - splat(0.5); pock(0.6, meat ? (heavy ? 220 : 280) : 700); // meat thuds, machine rings + // meat THUDS (wet, and pitched by how big the body is), the machine still rings + if (meat) { thud(0.65, sz); splat(0.3, sz); } else pock(0.6, 700); if (!heavy) zb.z -= TUNE.zPushback * 0.5; ball.vx *= 0.3; ball.vz *= 0.3; ball.lastHit = 'X'; continue; // ball spent on the wound } if (!heavy) zb.z -= TUNE.zPushback; // soft ball staggers the light ones, bounces off the rest - pock(0.6, 300); ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; continue; + if (meat) thud(0.4, sz); else pock(0.6, 500); // a soft ball off a body is a duller version of the same hit + ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; continue; } // chair-down shield zombies (and anything hit from behind) obey the normal rules: if ((ball.pow || 0) >= TUNE.zSplatQ) { splatter(i); continue; } zb.z -= TUNE.zPushback; // weak sitters only stagger them - pock(0.6, 300); + thud(0.4, bodySize(zb)); ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; // spent — one stagger per ball } } @@ -1087,13 +1093,13 @@ export function updateZombies(dt) { // TRIPLE serve read as "he can't be hurt". They wound him like the main ball now. if (isBoss(zb)) { if (e.pow >= TUNE.zSplatQ) { if (wound(zb, TUNE.dmgSide)) killZombie(j, !!G.horde); } - else pock(0.5, 300); + else thud(0.4, bodySize(zb)); } else if (zb.variant === 's' && e.vz < 0 && !(zb.shieldT > 0)) { zb.shieldT = TUNE.shieldDown; pock(0.9, 1800); } // chair blocks these too else if (e.pow >= TUNE.zSplatQ || zb.variant === 'f') { - if (zb.hp > 1) { zb.hp--; splat(0.4); } // wounds brutes AND shamblers + if (zb.hp > 1) { zb.hp--; thud(0.55, bodySize(zb)); splat(0.25, bodySize(zb)); } // wounds brutes AND shamblers else killZombie(j, !!G.horde); - } else { zb.z -= TUNE.zPushback; pock(0.5, 300); } + } else { zb.z -= TUNE.zPushback; thud(0.4, bodySize(zb)); } dead = true; break; // a side ball is spent on its first touch } }