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:
1 parent
d05b49909e
commit
74aa209753
5 files changed
+248
-14
No files matched your search
+28
-1
@@ -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
|
||||
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)
|
||||
|
||||
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.
|
||||
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.
|
||||
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/`):
|
||||
- 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.
|
||||
|
||||
+1
-1
@@ -1140,6 +1140,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</html>
|
||||
+156
@@ -522,3 +522,159 @@ export function applause(seconds = 2.4, vol = 0.5) { // ponytail: synthesized cl
|
||||
}
|
||||
} 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
@@ -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 { 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 { drillKill, drillCaught } from './drill.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();
|
||||
}
|
||||
|
||||
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
|
||||
// gameplay.restartRun — a restart is in-place now, not a page reload.
|
||||
export function resetZombies() {
|
||||
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) {
|
||||
@@ -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
|
||||
// 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.
|
||||
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
|
||||
@@ -487,7 +489,7 @@ function bossDeath(zb) {
|
||||
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.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);
|
||||
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,
|
||||
@@ -595,7 +597,10 @@ function ignite(x, z, r) {
|
||||
// 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
|
||||
// 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;
|
||||
while (zb.chip >= 1 && zb.hp > 0) { zb.chip -= 1; zb.hp--; took++; }
|
||||
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
|
||||
// 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.
|
||||
@@ -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
|
||||
// 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 (++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'];
|
||||
// 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) {
|
||||
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 (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) {
|
||||
zb.windT -= dt;
|
||||
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 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,
|
||||
// no ramp, on the one effect documented as the fairness contract.
|
||||
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) {
|
||||
@@ -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); }
|
||||
else {
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// 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 { 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 { drillQueue, drillLegal, arrival, drillMult, DRILL_TYPES } = await import('./src/drill.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;
|
||||
});
|
||||
|
||||
// ---- 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
|
||||
// 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
|
||||
|
||||
Reference in new issue
Block a user