Give the machine, fire, gun and boss their missing sounds

All four synthesized in audio.js like every other weapon: a squeaking
castor, a court-wide crackle clock plus a throttled catch, a real gun
crack, and one shared boss voice (stomp per stride, wind-up breath,
bark, pain, death roar) pitched by body size. Closes OPEN 13 pending
the phone verdict. v355.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5.5 committed 2026-09-23 09:16:27 +02:00
1 parent d05b49909e
commit 74aa209753
5 files changed
+248 -14

No files matched your search

+28 -1
View File
@@ -5066,6 +5066,31 @@ Release cache version: `main.js?v=353`. Pre-release validation: 142 game tests a
menu browser checks pass. Deploy staging first, verify the served release, then promote the same menu browser checks pass. Deploy staging first, verify the served release, then promote the same
commit to production. Staging URL: `https://splatter-tennis-staging.wuwi.one`. commit to production. Staging URL: `https://splatter-tennis-staging.wuwi.one`.
## Session log: THE FOUR MISSING SOUNDS (2026-09-23, v355)
Oliver: *"mach die sounds, schlag mir quellen vor"* (OPEN 13). Source decision: **synthesize all four
in `audio.js`**, the way every weapon already is — no sample, no licence, no download, and each one
pitches off the existing `size` = bodySize convention. Samples stay the fallback per sound if one
reads flat on the phone (candidates offered: Kenney.nl CC0 packs, Freesound filtered to CC0, the
Sonniss GDC bundle, or recording a real trolley castor on the iPhone for the squeak).
- **Machine castor** `squeak()` — stick-slip: two or three FM-wobbled triangle chirps, 60% of the
existing 0.4 s grind ticks while it rolls. The approach cue; the click stays its fire tell.
- **Fire** — `crackle()` (tiny highpassed pops over a hiss) on ONE court clock in `updateZombies`,
rate and level ~sqrt(bodies burning), so a lit wave is denser, never N loops summed. `whump()` is
the body catching, fired from `ignite()` and throttled to once per 0.25 s for an epidemic.
- **Gun** `gunshot()` — crack + punch + action, every part < 60 ms, every other round as before (it
replaced the racket pock). The dead `w.ignite ? 300` branch went with it (the flamer has its own case).
- **Boss** — ONE shared voice for all ten designs, pitched by body (the splat() rule). `stomp()` per
drawn stride (off `zb.dist`/`bruteStride`, so it lands with the foot; crunch carries it on phone
speakers, the sub is for headphones); `growl()` rising over the whole wind-up (the boss's tell,
replacing the click it shared with the machine), a bark on release, a throttled (0.9 s) pain growl
from `wound()`, and a long falling roar in `bossDeath`.
All seven log to `sfxTest`; one test pins every event, one whump per epidemic, and the crackle
clock staying court-wide. Offline-rendered peaks sit between pock and splat (nothing clips).
147 tests, default seed plus 1, 42, 1234, 20260907. **Needs Oliver's ear on the phone.**
## Session log: VS A FRIEND — the friend's racket makes a noise, and the duel has a rematch (2026-09-11, v354) ## Session log: VS A FRIEND — the friend's racket makes a noise, and the duel has a rematch (2026-09-11, v354)
Oliver, two finds off a two-phone session: *"im vs. a friend mode wird der ballsound des gegners Oliver, two finds off a two-phone session: *"im vs. a friend mode wird der ballsound des gegners
@@ -5157,7 +5182,9 @@ Nothing here is broken; these are known gaps and unverified risks.
that catches your shots. Recorded so the ideas are not lost, not as a to-do. that catches your shots. Recorded so the ideas are not lost, not as a to-do.
12. **`ump_no_deal` does not exist.** Oliver asked for its removal; `ump_no_appeal` was removed as the 12. **`ump_no_deal` does not exist.** Oliver asked for its removal; `ump_no_appeal` was removed as the
closest match. Worth one confirmation that no other line is still unwanted. closest match. Worth one confirmation that no other line is still unwanted.
13. **Four sounds are missing and need RESEARCH first** (find/choose the source before implementing — 13. **~~Four sounds are missing~~ BUILT 2026-09-23, SYNTHESIZED (v355) — see the session log; the ear
verdict is Oliver's phone.** Original note kept for the reasoning:
**Four sounds are missing and need RESEARCH first** (find/choose the source before implementing —
every existing sfx is either synthesised in `audio.js` or a wav in `assets/audio/`): every existing sfx is either synthesised in `audio.js` or a wav in `assets/audio/`):
- the **ball machine** while it moves — a squeaking wheel/castor, so the furniture announces - the **ball machine** while it moves — a squeaking wheel/castor, so the furniture announces
itself. It already telegraphs its shot (`machWind` 0.9 s); this is the *approach* cue. itself. It already telegraphs its shot (`machWind` 0.9 s); this is the *approach* cue.
+1 -1
View File
@@ -1140,6 +1140,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div> </div>
</div> </div>
<span id="recLamp" hidden>REC</span> <span id="recLamp" hidden>REC</span>
<script type="module" src="src/main.js?v=354"></script> <script type="module" src="src/main.js?v=355"></script>
</body> </body>
</html> </html>
+156
View File
@@ -522,3 +522,159 @@ export function applause(seconds = 2.4, vol = 0.5) { // ponytail: synthesized cl
} }
} catch (e) {} } catch (e) {}
} }
// ---- OPEN 13's four missing sounds (2026-09-23): the machine's castor, fire on a body, the gun, and
// the boss. All SYNTHESIZED, like every weapon above — no sample, no licence, no download, and each
// one pitches off the same `size` convention splat()/thud() use. They log their name to sfxTest so
// the suite can assert the EVENT fires; whether it sounds right is a phone verdict.
const logged = n => { if (sfxTest.log) sfxTest.log.push(n); };
const sizeK = s => Math.max(0.7, Math.min(2.4, s));
// THE BALL MACHINE'S CASTOR. A squeak is stick-slip friction: a high tone that wobbles fast and
// stutters in two or three chirps, each a little off the last. A clean whistle reads as a bird.
export function squeak(vol = 0.14) {
logged('squeak');
try {
const ac = audio(), t0 = ac.currentTime, n = 2 + (Math.random() * 2 | 0);
let t = t0;
for (let i = 0; i < n; i++) {
const d = 0.04 + Math.random() * 0.05, f = 2000 + Math.random() * 900;
const o = ac.createOscillator(); o.type = 'triangle';
o.frequency.setValueAtTime(f, t);
o.frequency.linearRampToValueAtTime(f * (0.88 + Math.random() * 0.25), t + d);
const lfo = ac.createOscillator(), lg = ac.createGain(); // the stutter: fast, shallow FM
lfo.frequency.value = 50 + Math.random() * 40; lg.gain.value = f * 0.05;
lfo.connect(lg).connect(o.frequency);
const g = ac.createGain();
g.gain.setValueAtTime(0.001, t); g.gain.exponentialRampToValueAtTime(vol, t + 0.008);
g.gain.exponentialRampToValueAtTime(0.001, t + d);
o.connect(g).connect(ac.destination);
o.start(t); o.stop(t + d + 0.01); lfo.start(t); lfo.stop(t + d + 0.01);
t += d + 0.015 + Math.random() * 0.04;
}
} catch (e) {}
}
// FIRE ON A BODY. Deliberately NOT a loop node: zombies.js fires one of these on a clock whose rate
// follows how much is burning, so a lit-up wave is a DENSER crackle, never ten loops summed into
// noise. Each call is a few tiny bright pops (fat spitting) over a short low hiss.
export function crackle(vol = 0.18) {
logged('crackle');
try {
const ac = audio(), t0 = ac.currentTime, buf = noiseBuf(ac);
const hp = ac.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 2400;
hp.connect(ac.destination);
for (let n = 3 + (Math.random() * 4 | 0); n > 0; n--) {
const t = t0 + Math.random() * 0.14, d = 0.004 + Math.random() * 0.01;
const src = ac.createBufferSource(); src.buffer = buf;
const g = ac.createGain();
g.gain.setValueAtTime(vol * (0.4 + Math.random() * 0.6), t); g.gain.exponentialRampToValueAtTime(0.001, t + d);
src.connect(g).connect(hp); src.start(t, nOff(d), d);
}
const src = ac.createBufferSource(); src.buffer = buf; // the hiss under it
const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 900;
const g = ac.createGain();
g.gain.setValueAtTime(0.001, t0); g.gain.exponentialRampToValueAtTime(vol * 0.3, t0 + 0.05);
g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.2);
src.connect(lp).connect(g).connect(ac.destination); src.start(t0, nOff(0.2), 0.2);
} catch (e) {}
}
// A BODY CATCHING. The crackle is the fire; this is the moment it takes — air sucked in and let go,
// a lowpass that opens and shuts over a short sub. Throttled by the caller: an epidemic lights many
// bodies a frame and must say so once.
export function whump(vol = 0.35) {
logged('whump');
try {
const ac = audio(), t = ac.currentTime, dur = 0.34;
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac);
const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.Q.value = 2;
lp.frequency.setValueAtTime(260, t);
lp.frequency.exponentialRampToValueAtTime(1900, t + 0.07);
lp.frequency.exponentialRampToValueAtTime(300, t + dur);
const g = ac.createGain();
g.gain.setValueAtTime(0.001, t); g.gain.exponentialRampToValueAtTime(vol, t + 0.05);
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
src.connect(lp).connect(g).connect(ac.destination); src.start(t, nOff(dur), dur);
const o = ac.createOscillator(), og = ac.createGain();
o.frequency.setValueAtTime(110, t); o.frequency.exponentialRampToValueAtTime(50, t + 0.25);
og.gain.setValueAtTime(0.001, t); og.gain.exponentialRampToValueAtTime(vol * 0.6, t + 0.04);
og.gain.exponentialRampToValueAtTime(0.001, t + 0.28);
o.connect(og).connect(ac.destination); o.start(t); o.stop(t + 0.3);
} catch (e) {}
}
// THE GUN. It was the racket's pock at 1400 Hz — a tick. A shot is a hard crack (broadband, instant
// attack), a short punch under it, and the action cycling just behind. Every part is under ~60 ms so
// rounds at 6+/s stay separate shots instead of smearing into the buzz PROJECT.md warned about.
export function gunshot(vol = 0.2) {
logged('gunshot');
try {
const ac = audio(), t = ac.currentTime, buf = noiseBuf(ac);
const src = ac.createBufferSource(); src.buffer = buf;
const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 0.7;
bp.frequency.value = 1500 + Math.random() * 600;
const g = ac.createGain();
g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.045);
src.connect(bp).connect(g).connect(ac.destination); src.start(t, nOff(0.05), 0.05);
const o = ac.createOscillator(), og = ac.createGain(); // the punch
o.frequency.setValueAtTime(160, t); o.frequency.exponentialRampToValueAtTime(60, t + 0.05);
og.gain.setValueAtTime(vol * 0.8, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.055);
o.connect(og).connect(ac.destination); o.start(t); o.stop(t + 0.06);
const c = ac.createBufferSource(); c.buffer = buf; // the action
const hp = ac.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 4500;
const cg = ac.createGain(); cg.gain.setValueAtTime(vol * 0.3, t + 0.03);
cg.gain.exponentialRampToValueAtTime(0.001, t + 0.042);
c.connect(hp).connect(cg).connect(ac.destination); c.start(t + 0.03, nOff(0.012), 0.012);
} catch (e) {}
}
// THE BOSS'S FOOT. Phone speakers play nothing under ~100 Hz, so a "deep" footfall made of sub alone
// is silence on the target device: the weight is carried by the clay CRUNCH, and the sub is for
// headphones. One per stride frame, so it lands with the drawn foot.
export function stomp(vol = 0.35, size = 2) {
logged('stomp');
try {
const ac = audio(), t = ac.currentTime, k = sizeK(size);
const o = ac.createOscillator(), og = ac.createGain();
o.frequency.setValueAtTime(130 / k, t); o.frequency.exponentialRampToValueAtTime(40 / k, t + 0.12);
og.gain.setValueAtTime(vol, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.15);
o.connect(og).connect(ac.destination); o.start(t); o.stop(t + 0.16);
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac);
const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 1.4;
bp.frequency.setValueAtTime(900 / k, t); bp.frequency.exponentialRampToValueAtTime(300 / k, t + 0.1);
const g = ac.createGain();
g.gain.setValueAtTime(vol * 0.7, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.1);
src.connect(bp).connect(g).connect(ac.destination); src.start(t, nOff(0.1), 0.1);
} catch (e) {}
}
// THE BOSS'S THROAT. moan() is one saw through a lowpass — a distant ambience. A boss in front of
// you needs a voice: two detuned saws, a vowel formant, and a fast tremolo that IS the rasp. One
// shared voice for all ten designs, pitched by body (OPEN 13 asked: one set or one each — one set,
// the splat() rule: the same event happening to a bigger body). `rise` > 1 bends up (the wind-up,
// drawing breath to throw); < 1 bends down (hurt, dying).
export function growl(vol = 0.3, size = 2, dur = 0.5, rise = 0.7) {
logged('growl');
try {
const ac = audio(), t = ac.currentTime, k = sizeK(size), f0 = 190 / k;
const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 2.2; // the vowel: an open "aah"
bp.frequency.setValueAtTime(720 / Math.sqrt(k), t);
bp.frequency.linearRampToValueAtTime(720 / Math.sqrt(k) * (rise > 1 ? 1.3 : 0.7), t + dur);
const trem = ac.createGain(); trem.gain.value = 0.6; // the rasp
const lfo = ac.createOscillator(), lg = ac.createGain();
lfo.frequency.value = 24 + Math.random() * 8; lg.gain.value = 0.4;
lfo.connect(lg).connect(trem.gain);
const g = ac.createGain();
g.gain.setValueAtTime(0.001, t); g.gain.exponentialRampToValueAtTime(vol, t + Math.min(0.08, dur * 0.3));
g.gain.setValueAtTime(vol, t + dur * 0.6); g.gain.exponentialRampToValueAtTime(0.001, t + dur);
bp.connect(trem).connect(g).connect(ac.destination);
for (const det of [1, 1.013]) {
const o = ac.createOscillator(); o.type = 'sawtooth';
o.frequency.setValueAtTime(f0 * det, t);
o.frequency.exponentialRampToValueAtTime(f0 * det * rise, t + dur);
o.connect(bp); o.start(t); o.stop(t + dur + 0.02);
}
lfo.start(t); lfo.stop(t + dur + 0.02);
} catch (e) {}
}
+24 -11
View File
@@ -5,7 +5,7 @@
import { TUNE, UI, COURT, CONTACT, BOSS_ART, bossArt, WEAPONS, BARREL_WEAPONS, TIN_LEAGUE, woundStage, zombieH, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js'; import { TUNE, UI, COURT, CONTACT, BOSS_ART, bossArt, WEAPONS, BARREL_WEAPONS, TIN_LEAGUE, woundStage, zombieH, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js';
import { G, P, O, ball, score, sendBoy, setupPoint } from './state.js'; import { G, P, O, ball, score, sendBoy, setupPoint } from './state.js';
import { splat, moan, pock, thud, zap, voice, boom, swish, shriek, aww, sfx , whoosh, flame } from './audio.js'; import { splat, moan, pock, thud, zap, voice, boom, swish, shriek, aww, sfx , whoosh, flame, squeak, crackle, whump, gunshot, stomp, growl } from './audio.js';
import { haptic } from './haptics.js'; import { haptic } from './haptics.js';
import { drillKill, drillCaught } from './drill.js'; import { drillKill, drillCaught } from './drill.js';
import { splatPoint, cheer, spentBall, aside, grantWeapon, endBurst, sliceCarom } from './gameplay.js'; import { splatPoint, cheer, spentBall, aside, grantWeapon, endBurst, sliceCarom } from './gameplay.js';
@@ -34,13 +34,13 @@ export function popText(x, y, z, text, col) {
if (pops.length > 10) pops.shift(); if (pops.length > 10) pops.shift();
} }
let spawnT = 1.5, moanT = 3; let spawnT = 1.5, moanT = 3, crackleT = 0, whumpT = 0, bossHurtT = 0; // the last three throttle the fire and boss sounds
// Every body, decal and projectile on the court, plus this module's own timers. Called by // Every body, decal and projectile on the court, plus this module's own timers. Called by
// gameplay.restartRun — a restart is in-place now, not a page reload. // gameplay.restartRun — a restart is in-place now, not a page reload.
export function resetZombies() { export function resetZombies() {
for (const a of [zombies, gore, piles, parts, pellets, extras, shots, pops]) a.length = 0; for (const a of [zombies, gore, piles, parts, pellets, extras, shots, pops]) a.length = 0;
spawnT = 1.5; moanT = 3; mgTick = 0; lastBarrel = null; spawnT = 1.5; moanT = 3; crackleT = whumpT = bossHurtT = 0; mgTick = 0; lastBarrel = null;
} }
export function spawnZombie(x, z, variant) { export function spawnZombie(x, z, variant) {
@@ -253,7 +253,9 @@ function bossAI(zb, dt) {
zb.x = Math.max(-TUNE.bossPaceX - 0.6, Math.min(TUNE.bossPaceX + 0.6, zb.x)); // never off the sides zb.x = Math.max(-TUNE.bossPaceX - 0.6, Math.min(TUNE.bossPaceX + 0.6, zb.x)); // never off the sides
// The stride steps off GROUND COVERED (the brute's rule). A strafing boss read off zb.z alone // The stride steps off GROUND COVERED (the brute's rule). A strafing boss read off zb.z alone
// would slide sideways with both feet frozen — the exact defect the distance rule exists to stop. // would slide sideways with both feet frozen — the exact defect the distance rule exists to stop.
zb.dist = (zb.dist || 0) + Math.hypot(zb.x - x0, zb.z - z0); const d0 = zb.dist || 0;
zb.dist = d0 + Math.hypot(zb.x - x0, zb.z - z0);
if (Math.floor(zb.dist / TUNE.bruteStride) !== Math.floor(d0 / TUNE.bruteStride)) stomp(0.35, bodySize(zb)); // one per drawn stride
} }
// HE CALLS SECURITY. A design with `adds` puts bodies on the court while you fight it — the one // HE CALLS SECURITY. A design with `adds` puts bodies on the court while you fight it — the one
@@ -487,7 +489,7 @@ function bossDeath(zb) {
G.zoom = { x: zb.x, z: zb.z, k: 1.22, t: 0, T: 0.8 }; G.zoom = { x: zb.x, z: zb.z, k: 1.22, t: 0, T: 0.8 };
G.bossBurst = { x: zb.x, z: zb.z, t: 0.75 }; // the shockwave ring render.js draws 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; G.shake = { t: 0.5, mag: 7 }; G.boomFlash = 0.12;
boom(); splat(1, bodySize(zb)); cheer(4, 1); voice('unbelievable', 0.5); // the biggest body in the game, at its lowest boom(); splat(1, bodySize(zb)); growl(0.45, bodySize(zb), 1.4, 0.4); cheer(4, 1); voice('unbelievable', 0.5); // the biggest body in the game, at its lowest
const h = 1.8 * (zb.scale || 1.5); 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 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, x: zb.x, y: h * (0.2 + Math.random() * 0.7), z: zb.z,
@@ -595,7 +597,10 @@ function ignite(x, z, r) {
// so fire is a real answer to a big body instead of a no-op // so fire is a real answer to a big body instead of a no-op
// remember WHAT lit it: a body that burns to death is credited to the flamer or the fire ball // remember WHAT lit it: a body that burns to death is credited to the flamer or the fire ball
// that started it, not to whatever happens to be firing when it finally goes down // that started it, not to whatever happens to be firing when it finally goes down
if (Math.hypot(zb.x - x, zb.z - z) < r) { zb.burnT = TUNE.burnTime; zb.burnBy = CAUSE; if (zb.hp === undefined) zb.hp = 1; } if (Math.hypot(zb.x - x, zb.z - z) < r) {
zb.burnT = TUNE.burnTime; zb.burnBy = CAUSE; if (zb.hp === undefined) zb.hp = 1;
if (whumpT <= 0) { whump(); whumpT = 0.25; } // an epidemic lights many a frame: say it once
}
} }
} }
@@ -612,6 +617,8 @@ function wound(zb, amount, fx = true) {
let took = 0; let took = 0;
while (zb.chip >= 1 && zb.hp > 0) { zb.chip -= 1; zb.hp--; took++; } while (zb.chip >= 1 && zb.hp > 0) { zb.chip -= 1; zb.hp--; took++; }
if (took && fx) { splat(0.5, bodySize(zb)); woundBurst(zb); } if (took && fx) { splat(0.5, bodySize(zb)); woundBurst(zb); }
// the boss FEELS it — throttled, because the gun chips it a dozen times a second
if (took && zb.variant === 'B' && bossHurtT <= 0) { growl(0.3, bodySize(zb), 0.45, 0.6); bossHurtT = 0.9; }
// A PHASED BOSS TURNING IS AN EVENT, not a silent change of numbers. Same body, new fight: the // A PHASED BOSS TURNING IS AN EVENT, not a silent change of numbers. Same body, new fight: the
// crowd comes up, the court shakes, and he says which set this is. Without the beat the champion's // crowd comes up, the court shakes, and he says which set this is. Without the beat the champion's
// second gear would arrive as "the boss suddenly got faster" and read as the game glitching. // second gear would arrive as "the boss suddenly got faster" and read as the game glitching.
@@ -811,7 +818,7 @@ export function firePellet(w = WEAPONS.gun) {
// rate 30 = 10 overlapping 0.26 s puffs a second, which sums to a continuous roar; firing one per // rate 30 = 10 overlapping 0.26 s puffs a second, which sums to a continuous roar; firing one per
// round just stacks 30 gains on top of each other and clips. // round just stacks 30 gains on top of each other and clips.
else if (w.sfx === 'flame') { if (++mgTick % 3 === 0) flame(0.5); } else if (w.sfx === 'flame') { if (++mgTick % 3 === 0) flame(0.5); }
else if (++mgTick % 2 === 0) pock(0.25, w.ignite ? 300 : 1400); else if (++mgTick % 2 === 0) gunshot(); // the one ignite weapon is the flamer, which has its own case
} }
const HUES = ['#ff6b6b', '#ffd166', '#7bffa0', '#6bd5ff', '#c08bff', '#ff8bd5']; const HUES = ['#ff6b6b', '#ffd166', '#7bffa0', '#6bd5ff', '#c08bff', '#ff8bd5'];
// The next body a slice is struck on to: nearest live thing to the kill it just made, and it may // The next body a slice is struck on to: nearest live thing to the kill it just made, and it may
@@ -1007,7 +1014,12 @@ export function updateParts(dt) {
} }
export function updateZombies(dt) { export function updateZombies(dt) {
moanT -= dt; moanT -= dt; whumpT -= dt; bossHurtT -= dt;
// FIRE HAS A SOUND OF ITS OWN: one crackle clock for the whole court, faster and a little louder
// the more is burning (sqrt, so a lit wave is dense, not deafening) — never one loop per body.
let lit = 0;
for (const zb of zombies) if (zb && zb.burnT > 0) lit++;
if (lit && (crackleT -= dt) <= 0) { crackle(Math.min(0.26, 0.12 + 0.035 * lit)); crackleT = Math.max(0.07, 0.2 / Math.sqrt(lit)); }
if (moanT <= 0) { moan(); moanT = 2.5 + Math.random() * 4; } if (moanT <= 0) { moan(); moanT = 2.5 + Math.random() * 4; }
if (G.net && G.net.death && G.match) bleedO(); // the friend's damage, read as blood (see bleedO) if (G.net && G.net.death && G.match) bleedO(); // the friend's damage, read as blood (see bleedO)
@@ -1113,7 +1125,7 @@ export function updateZombies(dt) {
} else if (zb.windT > 0) { } else if (zb.windT > 0) {
zb.windT -= dt; zb.windT -= dt;
if (zb.windT <= 0) { if (zb.windT <= 0) {
if (boss) { zb.salvo = A?.volley ?? TUNE.bossVolley; zb.salvoT = 0; } // the volley starts next frame if (boss) { zb.salvo = A?.volley ?? TUNE.bossVolley; zb.salvoT = 0; growl(0.32, bodySize(zb), 0.18, 0.6); } // the volley starts next frame, on a bark
else fireAtPlayer(zb.x, zb.z, 0, TUNE.machSpeed); else fireAtPlayer(zb.x, zb.z, 0, TUNE.machSpeed);
} }
} else if ((zb.fireT -= dt) <= 0) { } else if ((zb.fireT -= dt) <= 0) {
@@ -1123,7 +1135,8 @@ export function updateZombies(dt) {
// with wind > 0.5 spent the first stretch at an invalid negative alpha — full brightness, // with wind > 0.5 spent the first stretch at an invalid negative alpha — full brightness,
// no ramp, on the one effect documented as the fairness contract. // no ramp, on the one effect documented as the fairness contract.
zb.windT = zb.windMax = boss ? (A?.wind ?? TUNE.bossWind) : TUNE.machWind; zb.windT = zb.windMax = boss ? (A?.wind ?? TUNE.bossWind) : TUNE.machWind;
pock(0.3, 1500); // the tell: a mechanical click as it loads if (boss) growl(0.32, bodySize(zb), zb.windT, 1.35); // the tell: he draws breath for the whole wind-up
else pock(0.3, 1500); // the machine's tell: a mechanical click as it loads
} }
} }
if (G.mode === 'eaten' && G.eatenAt && zb.variant !== 'm' && G.eatenT < EATEN_SUMMARY) { if (G.mode === 'eaten' && G.eatenAt && zb.variant !== 'm' && G.eatenT < EATEN_SUMMARY) {
@@ -1171,7 +1184,7 @@ export function updateZombies(dt) {
if (Math.abs(d) < 0.06) { zb.rollTo = undefined; zb.repoT = TUNE.machRepo * (0.7 + Math.random() * 0.6); } if (Math.abs(d) < 0.06) { zb.rollTo = undefined; zb.repoT = TUNE.machRepo * (0.7 + Math.random() * 0.6); }
else { else {
zb.x += Math.sign(d) * Math.min(Math.abs(d), TUNE.machRoll * dt); zb.x += Math.sign(d) * Math.min(Math.abs(d), TUNE.machRoll * dt);
if ((zb.rollT -= dt) <= 0) { pock(0.16, 150); zb.rollT = 0.4; } // castors grinding on the clay if ((zb.rollT -= dt) <= 0) { pock(0.16, 150); if (Math.random() < 0.6) squeak(); zb.rollT = 0.4; } // castors grinding, and one of them squeaks: it announces itself
} }
} else if ((zb.repoT -= dt) <= 0) { zb.rollTo = (Math.random() * 2 - 1) * 3.6; zb.rollT = 0; } } else if ((zb.repoT -= dt) <= 0) { zb.rollTo = (Math.random() * 2 - 1) * 3.6; zb.rollT = 0; }
} }
+39 -1
View File
@@ -95,7 +95,7 @@ const { mirror, netMsg, netTest, netTick, netAgain } = await import('./src/net.j
// the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a // the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a
// player would, so the tests step a live court // player would, so the tests step a live court
const startDrill = (...a) => { const r = _startDrill(...a); G.modal = null; G.modalQ.length = 0; return r; }; const startDrill = (...a) => { const r = _startDrill(...a); G.modal = null; G.modalQ.length = 0; return r; };
const { zombies, spawnZombie, pellets, parts, explode, hurtPlayer } = await import('./src/zombies.js'); const { zombies, spawnZombie, pellets, parts, explode, hurtPlayer, firePellet } = await import('./src/zombies.js');
const { killPlayer } = await import('./src/zombies.js'); const { killPlayer } = await import('./src/zombies.js');
const { drillQueue, drillLegal, arrival, drillMult, DRILL_TYPES } = await import('./src/drill.js'); const { drillQueue, drillLegal, arrival, drillMult, DRILL_TYPES } = await import('./src/drill.js');
const cfg = await import('./src/config.js'); const cfg = await import('./src/config.js');
@@ -2127,6 +2127,44 @@ test('netplay: the friend\'s strike makes a noise, pitched off the ball it just
audio.sfxTest.log = null; audio.sfxTest.log = null;
}); });
// ---- OPEN 13: the four missing sounds (2026-09-23). Synthesized, so the ear is the phone's job;
// what the suite pins is that each one fires at its EVENT, and that fire stays one clock, not a
// sound per body.
test('the machine squeaks, fire crackles and catches, the gun cracks, the boss stomps and growls', () => {
const heard = n => audio.sfxTest.log.includes(n);
freshSplatter();
audio.sfxTest.log = [];
spawnZombie(0, 8, 'm');
const m = zombies[zombies.length - 1]; m.rollTo = 3; m.rollT = 0; m.fireT = 99;
for (let i = 0; i < 60 * 4 && !heard('squeak'); i++) { update(DT); G.hp = 9; }
assert.ok(heard('squeak'), 'a rolling machine announces itself');
freshSplatter();
audio.sfxTest.log = [];
for (let k = 0; k < 8; k++) spawnZombie(-1.4 + k * 0.4, 6, 'a');
zombies[0].burnT = 3;
update(DT);
assert.ok(heard('crackle'), 'a burning body crackles');
assert.equal(audio.sfxTest.log.filter(n => n === 'whump').length, 1, 'an epidemic lights the row, and says so ONCE');
const lit = zombies.filter(z => z.burnT > 0).length;
audio.sfxTest.log = [];
for (let i = 0; i < 60; i++) update(DT);
const crackles = audio.sfxTest.log.filter(n => n === 'crackle').length;
assert.ok(lit >= 3 && crackles >= 3 && crackles <= 16, `one court clock, not one per body: ${crackles} crackles/s for ${lit} lit`);
audio.sfxTest.log = [];
for (let k = 0; k < 4; k++) firePellet();
assert.equal(audio.sfxTest.log.filter(n => n === 'gunshot').length, 2, 'the gun cracks every other round');
freshSplatter();
startBossFight(1);
audio.sfxTest.log = [];
for (let i = 0; i < 60 * 30 && !(heard('stomp') && heard('growl')); i++) { update(DT); G.hp = 9; }
assert.ok(heard('stomp'), 'the boss has feet');
assert.ok(heard('growl'), 'and a throat: the wind-up draws breath');
audio.sfxTest.log = null;
});
// ---- THE REMATCH. The relay cannot re-pair (its room code is popped on pairing), so playing the // ---- THE REMATCH. The relay cannot re-pair (its room code is popped on pairing), so playing the
// same friend again is the SOCKET THAT IS STILL OPEN: one 'again' frame, both phones reset the run // same friend again is the SOCKET THAT IS STILL OPEN: one 'again' frame, both phones reset the run
// under the decisions the room already carries. The desync to guard against is the two courts // under the decisions the room already carries. The desync to guard against is the two courts