The code moves into src/: index, server and the test harness keep the root

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-11 13:45:23 +02:00
1 parent c122745390
commit 1f636530b9
16 files changed
+16 -13

No files matched your search

+398
View File
@@ -0,0 +1,398 @@
// All sound: WebAudio context, synthesized SFX, recorded umpire lines, speech fallback.
let AC = null;
export const audio = () => {
if (!AC) AC = new (window.AudioContext || window.webkitAudioContext)();
if (AC.state === 'suspended') AC.resume();
return AC;
};
// Umpire lines are pre-recorded files (assets/audio/*.m4a) played via WebAudio —
// works on mobile where speechSynthesis is unreliable. say() remains the fallback.
const VOICE = {};
let reverbNode = null;
function reverb(ac) { // stadium PA tail: exponentially decaying noise as impulse response
if (reverbNode) return reverbNode;
const len = ac.sampleRate * 1.3, ir = ac.createBuffer(2, len, ac.sampleRate);
for (let c = 0; c < 2; c++) {
const d = ir.getChannelData(c);
for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, 2.8);
}
reverbNode = ac.createConvolver(); reverbNode.buffer = ir;
const wet = ac.createGain(); wet.gain.value = 0.35;
reverbNode.connect(wet).connect(ac.destination);
return reverbNode;
}
// The umpire is one man with one microphone. Several triggers landing together used to play two or
// three lines on top of each other; a line that would start while he is still talking is DROPPED.
// The check happens at play time (not call time) so a first-play fetch/decode is judged fairly.
let voiceUntil = 0;
export function voice(name, delay = 0, rate = 1) {
const ac = audio();
const play = buf => {
const at = ac.currentTime + delay;
if (at < voiceUntil) return;
voiceUntil = at + buf.duration / rate + 0.2; // + a breath, so lines never butt against each other
const src = ac.createBufferSource(); src.buffer = buf;
src.playbackRate.value = rate; // < 1 = slower and lower — the umpire's dark register
src.connect(ac.destination); // dry
const send = ac.createGain(); send.gain.value = 1.8; // umpire rides a wetter PA tail than the crowd sfx
src.connect(send).connect(reverb(ac)); // extra reverb send — the big-stadium announcer echo
src.start(ac.currentTime + delay);
};
if (VOICE[name]) return play(VOICE[name]);
fetch('assets/audio/' + name + '.m4a')
.then(r => { if (!r.ok) throw 0; return r.arrayBuffer(); })
.then(ab => ac.decodeAudioData(ab))
.then(buf => { VOICE[name] = buf; play(buf); })
.catch(() => say(name.replace(/_/g, ' '), delay));
}
// Recorded one-shot SFX (assets/audio/tennis-*.wav): ball hits, applause, the crowd's aww.
// Same load-once-then-cache pattern as voice(), but dry (no PA reverb) and with a volume knob.
const SFX = {};
export function sfx(name, vol = 1, opts = {}) {
const ac = audio();
const play = buf => {
const src = ac.createBufferSource(); src.buffer = buf;
if (opts.rate) src.playbackRate.value = opts.rate; // < 1 stretches it a touch longer (and lower)
const g = ac.createGain(); g.gain.value = vol;
src.connect(g).connect(ac.destination);
if (opts.reverb) g.connect(reverb(ac)); // wet stadium tail on top of the dry sample
src.start();
};
if (SFX[name]) return play(SFX[name]);
// a name with an extension is used as-is (weapon-click2.mp3); bare names keep the .wav default
fetch('assets/audio/' + (name.includes('.') ? name : name + '.wav'))
.then(r => { if (!r.ok) throw 0; return r.arrayBuffer(); })
.then(ab => ac.decodeAudioData(ab))
.then(buf => { SFX[name] = buf; play(buf); })
.catch(() => {}); // missing sample = silence, never a thrown error mid-rally
}
// the crowd's groan — stretched a touch and pushed through the PA reverb for a longer, wetter sigh
export function aww(vol = 1) { sfx('tennis-aww', vol * 0.5, { reverb: true }); }
// Looping background beds (scene atmosphere, mode music): each starts once, highpass 'lowcut' + quiet gain.
const beds = new Map(); // file -> {ac, g, vol} once playing (null while loading); see fadeBed
const ducked = new Set(); // beds asked to duck while they were STILL LOADING — see fadeBed
function loopBed(file, lowcut, vol, loopEnd = 0) {
// audio() FIRST, before the already-started early return: it is also what RESUMES a context the
// browser suspended under its autoplay policy. The menu theme is started at boot, before any
// gesture exists, so it plays into a suspended context and stays silent — and the call that has
// to wake it up is a second menuMusic() from the first tap, which would otherwise return here
// without ever reaching audio().
const ac = audio();
if (beds.has(file)) return;
beds.set(file, null);
fetch('assets/audio/' + file) // full filename: the beds are a mix of .wav and .m4a, see below
.then(r => { if (!r.ok) throw 0; return r.arrayBuffer(); })
.then(ab => ac.decodeAudioData(ab))
.then(buf => {
const src = ac.createBufferSource(); src.buffer = buf; src.loop = true;
// loopEnd = the source's TRUE length, for a bed that is compressed. AAC pads the last packet,
// and Chrome hands back a buffer ~17 ms longer than the master — silence spliced in at every
// seam, once a loop, on the title screen. Trimming the seam is what keeps the loop seamless.
// Zero (the default, and every uncompressed bed) leaves the whole buffer in play.
if (loopEnd) src.loopEnd = loopEnd;
const hp = ac.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = lowcut;
// start SILENT if it was ducked while loading: a multi-megabyte file takes seconds to fetch
// and decode, and a player who taps straight through the picker would otherwise have the
// menu theme come up over the match, several seconds late, at full level.
const g = ac.createGain(); g.gain.value = ducked.has(file) ? 0 : vol;
src.connect(hp).connect(g).connect(ac.destination); src.start();
beds.set(file, { ac, g, vol });
})
.catch(() => beds.delete(file)); // failed start (decode/network) can retry next call
}
export const ambient = () => loopBed('tennis-ambient-loop.wav', 500, 0.035); // whole-scene atmosphere, from first tap
// ...and the splatter bed, same treatment: 5.9 MB of 24-bit WAV down to 287 KB. Its master is
// 982597 frames at 48 kHz, which is the un-round 20.47077 s below — an AAC loop needs the SOURCE
// length as loopEnd, not the decoder's padded one. See the note on menuMusic.
export const zombieMusic = () => loopBed('zombie-mode.m4a', 800, 0.2, 20.47077);
// Menu theme: every menu screen and the death summary. Barely highpassed — this one is MUSIC, not
// an atmosphere bed, so the low end is the point. Started by the FIRST TAP (main.js `go`), never at
// boot: a title screen that starts playing at you before you have touched it is rude, and on a phone
// the autoplay policy would have held it silent until that tap anyway.
// AAC, not WAV: 48 s of stereo music is 8.3 MB as a WAV and this one downloads on every page load,
// before the player has decided to play anything. 768 KB encoded, and the master is exactly 48.000 s
// — which is the loopEnd, because the decoded buffer is NOT (measured 48.0174 s in Chrome; see the
// padding note in loopBed). Re-encode the file and you must re-measure that number:
// fetch(f).then(r=>r.arrayBuffer()).then(a=>new AudioContext().decodeAudioData(a)).then(b=>b.duration)
export const menuMusic = () => loopBed('background-music.m4a', 30, 0.65, 48);
// duck a bed to silence (out=true) or back to its base level over `secs` — the sudden-death duel mutes the music
function fadeBed(file, out, secs = 0.7) {
if (out) ducked.add(file); else ducked.delete(file);
const b = beds.get(file);
if (!b) return; // still loading — the flag above is the level it will start at
const t = b.ac.currentTime;
b.g.gain.cancelScheduledValues(t);
b.g.gain.setValueAtTime(b.g.gain.value, t); // anchor at current level so a mid-fade toggle stays smooth
b.g.gain.linearRampToValueAtTime(out ? 0 : b.vol, t + secs);
}
export const zombieDuck = out => fadeBed('zombie-mode.m4a', out); // key must match loopBed's, filename and all
// The menu theme is never stopped, only faded — the buffer is already decoded, so fading it back up
// on the death screen costs nothing and can't miss its cue waiting on a re-fetch.
export const menuDuck = out => fadeBed('background-music.m4a', out);
let sysVoice = null;
function say(text, delay = 0) { // fallback when a recorded umpire line is missing
try {
if (!sysVoice) sysVoice = speechSynthesis.getVoices().find(v => v.lang.startsWith('en-GB')) || null;
const u = new SpeechSynthesisUtterance(text);
if (sysVoice) u.voice = sysVoice; u.rate = 0.88; u.pitch = 0.75; u.volume = 0.9;
setTimeout(() => speechSynthesis.speak(u), delay * 1000);
} catch (e) {}
}
// ONE noise buffer, shared. Every swish/pock/thud/splat/clap used to fill a fresh Float32 buffer
// (thousands of Math.random() calls, ~30 KB) per sound — during a horde the gun did that a dozen
// times a second, and applause() did it ~200 times per cheer. Callers play a random slice instead
// (src.start's offset + duration), so repeats never phase-align and the node still stops on time.
const NOISE_LEN = 1.5;
let noise = null, noiseAc = null;
function noiseBuf(ac) {
if (noiseAc !== ac) { // a fresh context invalidates the old buffer
noise = ac.createBuffer(1, ac.sampleRate * NOISE_LEN, ac.sampleRate);
const d = noise.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
noiseAc = ac;
}
return noise;
}
const nOff = len => Math.random() * (NOISE_LEN - len); // a random slice into the shared buffer
// Filtered noise sweep. Default = the racket cutting air (rising, short) — the sound of a whiff.
// The sweep is parameterised because a DESCENDING, longer one is a panel swooshing in and settling:
// same six lines of Web Audio, opposite reading. See whoosh() below.
export function swish(vol = 0.3, f0 = 1600, f1 = 3200, dur = 0.16) {
try {
const ac = audio(), t = ac.currentTime;
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac);
const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.setValueAtTime(f0, t);
bp.frequency.exponentialRampToValueAtTime(f1, t + dur * 0.75); bp.Q.value = 1.5;
const g = ac.createGain();
g.gain.setValueAtTime(0.001, t); g.gain.exponentialRampToValueAtTime(vol, t + dur * 0.3);
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
src.connect(bp).connect(g).connect(ac.destination); src.start(t, nOff(dur), dur);
} catch (e) {}
}
export const whoosh = (vol = 0.34) => swish(vol, 4200, 700, 0.3); // a panel flying in and settling
// THE FLAMETHROWER. It was borrowing pock(0.25, 300) — a short dry bandpassed tick — on every other
// round, so the loudest-looking weapon in the game sounded like someone tapping a table. A flame is
// broadband ROAR with a low guttural body under it, and it has to survive being retriggered ~15
// times a second without turning into a buzz, so each call is a soft-attacked overlapping puff that
// sums into a continuous sheet rather than a train of clicks.
export function flame(vol = 0.5) {
try {
const ac = audio(), t = ac.currentTime, dur = 0.26;
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac);
// the roar: wide noise under a lowpass that opens then closes, which is the breath of it
const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.Q.value = 3;
lp.frequency.setValueAtTime(700, t);
lp.frequency.exponentialRampToValueAtTime(2600, t + dur * 0.25);
lp.frequency.exponentialRampToValueAtTime(500, t + dur);
const g = ac.createGain(); // soft attack: a hard one is a click, and 15/s of clicks is a buzz
g.gain.setValueAtTime(0.001, t);
g.gain.exponentialRampToValueAtTime(vol, t + 0.03);
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(); // the guttural body under the hiss
o.type = 'sawtooth';
o.frequency.setValueAtTime(64 + Math.random() * 22, t);
o.frequency.exponentialRampToValueAtTime(38, t + dur);
og.gain.setValueAtTime(0.001, t);
og.gain.exponentialRampToValueAtTime(vol * 0.35, t + 0.04);
og.gain.exponentialRampToValueAtTime(0.001, t + dur);
o.connect(og).connect(ac.destination); o.start(t); o.stop(t + dur + 0.02);
} catch (e) {}
}
export function pock(vol = 0.8, freq = 900) { // racket contact / bounce thock
try {
const ac = audio(), t = ac.currentTime;
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac);
const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = freq; bp.Q.value = 1.2;
const g = ac.createGain();
g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.07);
src.connect(bp).connect(g).connect(ac.destination); src.start(t, nOff(0.06), 0.06);
const o = ac.createOscillator(), og = ac.createGain(); // low thump body
o.frequency.setValueAtTime(180, t); o.frequency.exponentialRampToValueAtTime(70, t + 0.08);
og.gain.setValueAtTime(vol * 0.5, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.09);
o.connect(og).connect(ac.destination); o.start(t); o.stop(t + 0.1);
} catch (e) {}
}
// 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 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);
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 * 0.8, t); g.gain.exponentialRampToValueAtTime(0.001, t + dur);
src.connect(bp).connect(g).connect(ac.destination); src.start(t, nOff(dur), dur);
// 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);
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, nOff(0.07 * k), 0.07 * k);
} 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);
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, nOff(dur), dur);
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);
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, nOff(0.18 * k), 0.18 * k);
} catch (e) {}
}
export function boom(vol = 0.9) { // bomb ball — sub-bass thump under a filtered noise blast
try {
const ac = audio(), t = ac.currentTime;
const o = ac.createOscillator(), og = ac.createGain(); // 80Hz sine diving to 30Hz
o.frequency.setValueAtTime(80, t); o.frequency.exponentialRampToValueAtTime(30, t + 0.35);
og.gain.setValueAtTime(vol, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
o.connect(og).connect(ac.destination); o.start(t); o.stop(t + 0.42);
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac); // the blast
const lp = ac.createBiquadFilter(); lp.type = 'lowpass';
lp.frequency.setValueAtTime(2200, t); lp.frequency.exponentialRampToValueAtTime(200, t + 0.35);
const g = ac.createGain();
g.gain.setValueAtTime(vol * 0.8, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
src.connect(lp).connect(g).connect(ac.destination); src.start(t, nOff(0.4), 0.4);
} 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); // 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, nOff(0.09), 0.09);
// 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;
const o = ac.createOscillator(); o.type = 'sawtooth';
o.frequency.setValueAtTime(300, t);
o.frequency.exponentialRampToValueAtTime(950, t + 0.25);
o.frequency.exponentialRampToValueAtTime(500, t + dur);
const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 1400; bp.Q.value = 0.8;
const g = ac.createGain();
g.gain.setValueAtTime(0.001, t); g.gain.exponentialRampToValueAtTime(vol, t + 0.06);
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
o.connect(bp).connect(g).connect(ac.destination); o.start(t); o.stop(t + dur + 0.05);
} catch (e) {}
}
export function moan(vol = 0.1) { // distant undead ambience
try {
const ac = audio(), t = ac.currentTime, dur = 1.2 + Math.random() * 0.8;
const o = ac.createOscillator(); o.type = 'sawtooth';
const f0 = 65 + Math.random() * 45;
o.frequency.setValueAtTime(f0, t);
o.frequency.linearRampToValueAtTime(f0 * 1.25, t + dur * 0.4);
o.frequency.linearRampToValueAtTime(f0 * 0.9, t + dur);
const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 320;
const g = ac.createGain();
g.gain.setValueAtTime(0.001, t); g.gain.exponentialRampToValueAtTime(vol, t + dur * 0.3);
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
o.connect(lp).connect(g).connect(ac.destination); o.start(t); o.stop(t + dur + 0.05);
} catch (e) {}
}
export function applause(seconds = 2.4, vol = 0.5) { // ponytail: synthesized claps; swap for a sample if it reads flat
try {
const ac = audio(), t0 = ac.currentTime, n = 90 * seconds;
for (let i = 0; i < n; i++) {
const t = t0 + Math.random() * seconds;
const env = Math.min(1, 4 * (1 - Math.abs(2 * (t - t0) / seconds - 1))); // fade in/out
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac);
const bp = ac.createBiquadFilter(); bp.type = 'bandpass';
bp.frequency.value = 1000 + Math.random() * 1600; bp.Q.value = 2.5;
const g = ac.createGain();
g.gain.setValueAtTime(vol * env * (0.3 + Math.random() * 0.7), t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.03);
src.connect(bp).connect(g).connect(ac.destination); src.start(t, nOff(0.025), 0.025);
}
} catch (e) {}
}
+217
View File
@@ -0,0 +1,217 @@
// THE BEAT MACHINERY — every transition between the modes that can OWN THE COURT lives here:
// the sudden-death duel (G.interlude), the horde (G.horde), the chapter boss (G.bossFight), the
// changeover breather (G.changeover), and the clear-advance that hands play from one to the next
// (proceedAfterClear). THE RULE: one mode owns the court at a time, and every start* function
// enforces that itself — a caller never has to know what else is running. All three field freezes
// came from two of these overlapping; if you add a mode, it starts here and it refuses here.
// (beats <-> gameplay and beats <-> zombies are runtime-only import cycles, same pattern as
// input <-> gameplay: the imported bindings are only touched inside functions, never at load.)
import { TUNE, UI, bossArt, COURT, HOME } from './config.js';
import { G, P, ball, score, setupPoint, sendBoy } from './state.js';
import { voice, zombieDuck, aww } from './audio.js';
import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie } from './zombies.js';
import { cheer, aside, endBurst, deliverTin, showModal, logSwing } from './gameplay.js';
// the court is mopped up (or was already clear): play advances to whatever the won game earned —
// a changeover breather, a plain even-game beat, or (on a set) the centered SET card then a horde.
export function proceedAfterClear() {
G.clearing = false;
const mode = G.afterClear, side = G.clearWinner;
G.afterClear = null;
// A clear is the ONE moment the court is guaranteed empty — the natural slot for the scheduled
// duel. (A wall-clock timer would need its own "wait for a safe moment" and could fire mid-swarm.)
// The chapter boss outranks everything here, including a set: a boss walking on into a standing
// wave (or into a horde) would be unreadable. Like the duel it parks the interrupted beat.
if (G.bossDue) { const c = G.bossDue; G.bossDue = 0; G.afterDuel = mode || 'game'; startBossFight(c); return; }
// The set/horde beat outranks the duel; the duel just waits for the next clear.
if (mode !== 'set' && G.duelDue) { G.duelDue = false; G.afterDuel = mode || 'game'; startDuel(); return; }
if (mode === 'set') {
G.card = { title: 'SET ' + (G.sets[0] + G.sets[1]), // the set just banked (sets already ++'d at game over)
sub1: side === 'P' ? 'THE SET IS YOURS' : 'SET TO THEM',
color: side === 'P' ? UI.pick : UI.hot, t: TUNE.setCardT };
G.pendingHorde = true; // the horde begins when the card's pause ends
ball.active = false; G.phase = 'over'; G.overT = TUNE.setCardT;
} else if (mode === 'changeover') {
startChangeover();
} else { // 'game': even-game beat, then the next wave arms
ball.active = false; G.phase = 'over'; G.overT = TUNE.gameBeatT;
}
// (no 'GAME N' flash on either path anymore — the bar under the scoreboard prints the game
// number permanently, and the flash was the same news shouted a second time)
}
// changeover (after odd games, as the rules demand): a forced breather — no spawns,
// no serving, the boys mop. Zombies already on court keep walking; it's calm, not safe.
function startChangeover() {
G.changeover = TUNE.changeoverT; // seconds left, not a flag: "Time." is called when THIS runs out
G.phase = 'over'; G.overT = TUNE.changeoverT;
// out with the mop, across the near court where the gore is — NOT to the ball: a dead ball
// parked off the field (sendBoy's z guard) meant no boy came out at all, and the breather is
// now long enough that an empty court reads as the game having stalled.
sendBoy(0, COURT.L * 0.75, true); // near enough the camera to actually watch him work
// no CHANGEOVER flash — the countdown panel says 'CHANGEOVER · RESUMING IN n' for the whole
// breather (render), so the flash was the same word with a shorter lifespan
aside('ump_still_a_match', 2.6); // the breather is when he reminds you this is a match
// no tins here anymore — weapons are won in the duel, never handed out on a schedule
}
// ---- the interlude: order is restored, one sudden-death point vs the racket zombie.
// Tennis rules apply for exactly one point — the absurdity peak: the umpire never stopped watching.
// This is now the ONLY source of weapons in the game: win the point or leave empty-handed.
export function startDuel() {
// Hard mutual exclusion, HERE, where every caller routes through: a duel must never begin while
// another control mode owns the court. Each of these has ended a run when they overlapped.
if (G.interlude || G.horde || G.bossFight) return;
if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // the schedule guarantees an opponent
for (const z of zombies) if (z.variant === 'r') z.dueled = true; // a veteran can be challenged again — this marks the fresh duel
G.weapon = 0; // tennis rules for the duel: plain balls, no tins
G.mgT = 0; endBurst(); pellets.length = 0; // ...and a burst in progress is over. The umpire has restored order.
G.interlude = { introT: 2.4 }; // the intro beat: serving blocked while the umpire announces
G.boy = null; // ...and the mop boy is waved off the court (sendBoy is barred for the duration)
ball.active = false; setupPoint();
zombieDuck(true); // fade the zombie music out for the 1:1 duel
voice('play_resume', 0.2); voice('sudden_death', 2.4);
if (!G.seen.duel) { // the rules change back — worth one real interruption, once per run
G.seen.duel = true;
showModal({ title: 'SUDDEN DEATH', color: UI.hot,
body: ["IT'S TENNIS AGAIN — ONE POINT", 'WIN IT AND THEY HAND YOU A WEAPON'] });
} else G.flash = { text: 'SUDDEN DEATH', t: 1.6 };
}
// ---- the chapter boss: every TUNE.bossEvery games, the court is cleared and ONE thing walks on.
// No other spawns until it is down, so the fight is legible and so banking a bomb for it is a real
// decision (which is the point — tins arrive every 3 games, so there was never a reason to save one).
// It threatens BOTH channels: its volleys chip your hp, and letting it reach you is the ordinary
// instant death. G.bossFight holds the chapter number while it runs.
export function startBossFight(chapter) {
if (G.bossFight) return;
G.bossFight = chapter;
zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // a clean stage
G.horde = null; G.mgT = 0; endBurst(); G.clearing = false;
ball.active = false; setupPoint();
spawnChapterBoss(chapter);
zombieDuck(true); // the music drops out for it
voice('ump_boss_arrives', 0, 0.9); voice('sudden_death', 2.6, 0.85);
const art = bossArt(chapter);
G.card = { title: art.name, color: UI.bad, t: TUNE.setCardT };
G.flash = null;
// (the four-line machine instruction modal is gone — people figure it out)
}
// the chapter boss is down: the parked game beat resumes and normal spawning comes back
export function endBossFight() {
if (!G.bossFight) return;
const c = G.bossFight; G.bossFight = 0;
zombieDuck(false);
shots.length = 0;
cheer(3.4, 0.9); voice('unbelievable', 0.3);
// hand back the game beat the boss interrupted FIRST — same contract resolveInterlude uses. It
// used to run LAST, and its 'set' branch writes a routine SET card and a short overT: on a game
// that is both a set and a boss chapter (game 30), the EPIC card below was overwritten before it
// had drawn a single frame, and the beat was cut to setCardT. The boss card wins now.
if (G.afterDuel) { G.afterClear = G.afterDuel; G.afterDuel = null; proceedAfterClear(); }
// The longest fight in the game deserves more than the two seconds a routine set gets. Big word,
// long hold — the chapter and the crowd move down to the subs where they belong.
// (no 'GAME n NEXT' line: the bar under the scoreboard prints the game number permanently, and a
// card that repeats what the HUD already says is the clutter this pass is meant to remove.)
G.card = { title: 'EPIC!', sub1: 'YOU DID IT!', color: UI.pick, t: TUNE.bossWinT,
wait: TUNE.bossDeathT }; // ...after the body has finished coming apart
ball.active = false;
// a duel the handback just started owns the phase; otherwise hold the boss beat, and never
// SHORTEN a longer one the handback set (a changeover's 5 s outlasts this)
if (!G.interlude) { G.phase = 'over'; G.overT = Math.max(G.overT, TUNE.bossWinT + TUNE.bossDeathT); }
}
// ---- THE DEADLOCK WATCHDOG.
// A run that hard-locks costs the player everything, and this file has form: `mgT` was once left
// above zero by a beat that moved the phase underneath it, and `mgT > 0` blocks serving, so the
// game sat there with the gun racket on screen. This is the net.
//
// It cannot misfire on a legitimate wait, and that is the whole design: every legitimate reason you
// cannot serve — a horde, a burst, a changeover, the duel's announcement, an 'over' beat — has a
// CLOCK counting down toward letting you act. So the test is not "how long has the player been
// blocked" (a horde blocks you for fifteen honest seconds); it is "is the thing that should be
// releasing me actually moving". If none of those numbers changes for stuckT seconds while the
// player is blocked and no popup is waiting on them, the state is dead and nothing will revive it.
//
// It also LOGS what was frozen, into the same miss log the scoreboard tap already shows — a lock
// that unsticks itself and leaves no evidence is worse than one that does not.
let stuckT = 0, stuckSig = '';
export function watchdog(dt) {
if (G.mode !== 'splatter' || G.modal) { stuckT = 0; return; } // a popup waits on the PLAYER: legitimate
// INVARIANT NET, before the clock-based test — because the clock-based test is blind to exactly
// this: a horde's own timer keeps ticking, so the sig always "moves" while the player is welded
// in place. Two states the guards above should make impossible; if a path still reaches them,
// repair on the spot and leave evidence in the miss log.
if (G.horde && G.interlude) { // a duel inside a horde: no steering, no serving, spawns keep coming
logSwing({ result: 'INVARIANT', reason: 'duel active during horde — interlude dropped, duel re-scheduled' });
G.interlude = false; G.duelDue = true; G.duelLerp = 0;
}
if (G.horde && G.phase !== 'rally') { // pretoss under a horde = the finger steers nothing
logSwing({ result: 'INVARIANT', reason: `phase '${G.phase}' during horde — forced back to rally`, phase: G.phase });
G.phase = 'rally'; ball.active = false;
P.pose = 'idle'; P.poseT = 0; P.swingT = -1;
}
const blocked = G.horde || G.mgT > 0 || G.changeover || G.phase === 'over'
|| (G.interlude && G.interlude.introT > 0)
|| (G.phase === 'toss' && ball.y < 2.0); // a toss on the floor: only a held aim keeps it there
if (!blocked) { stuckT = 0; return; }
const sig = [G.horde && G.horde.t, G.mgT, G.changeover, G.interlude && G.interlude.introT,
G.phase === 'over' && G.overT, ball.y].map(v => (+v || 0).toFixed(2)).join('|');
if (sig !== stuckSig) { stuckSig = sig; stuckT = 0; return; } // something is still moving — fine
if ((stuckT += dt) < TUNE.stuckT) return;
logSwing({ result: 'UNSTUCK', reason: `nothing moved for ${TUNE.stuckT}s — released a dead latch`,
phase: G.phase, sig, aim: !!G.aim });
G.horde = null; G.mgT = 0; G.changeover = false; G.interlude = false;
G.aim = null; endBurst(); setupPoint();
stuckT = 0; stuckSig = '';
}
export function resolveInterlude(side) {
if (!G.interlude) return;
G.interlude = false;
zombieDuck(false); // duel over — bring the music back up
ball.active = false; G.phase = 'over'; G.overT = 2.2;
if (side === 'P') {
cheer(3, 0.7);
deliverTin(); // win the point, win the next tin
// ...and the trainer comes out. THE reason to want a duel rather than merely tolerate it — it is
// the only healing in the game, and blood is the only thing you cannot buy back otherwise. It
// gets its OWN popup (queued behind the weapon), with the red cross over it: folded in as a line
// of body text under a weapon icon it read as a footnote to the tin rather than as the prize.
if (G.hp < G.hpMax) {
G.hp = Math.min(G.hpMax, G.hp + TUNE.healPerDuel);
voice('ump_trainer', 0.2, 0.95);
showModal({ title: 'WOUNDS TREATED', icon: 'x_cross', iconH: 52, color: UI.pick,
body: ['THE TRAINER PATCHED YOU UP', G.hp + ' OF ' + G.hpMax + ' BACK'] });
}
} else {
voice('game_opponent', 0.4); aww(0.85); // lost the sudden-death duel
score.games[1]++; G.runGames++; // it goes on the scoreboard
if (score.games[1] >= 6) { G.sets[1]++; score.games = [0, 0]; }
G.flash = { text: 'GAME. THEM.', t: 1.6 };
for (let k = 0; k < 5; k++) // and the dead lose their patience: an instant surge
spawnZombie((Math.random() * 2 - 1) * 5, -1.5 - Math.random() * 2, Math.random() < 0.5 ? 'a' : 'b');
}
if (G.afterDuel) { // a scheduled duel interrupted a game beat — hand it back now that it's settled
G.afterClear = G.afterDuel; G.afterDuel = null;
proceedAfterClear();
}
}
// the swarm keeps coming: no serving, the racket auto-fires, the thumb drag steers you
export function startHorde() {
if (G.horde || G.interlude || G.bossFight) return; // same contract as startDuel: one mode owns the court
G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked
const grow = 1 + (G.sets[0] + G.sets[1]) * TUNE.hordeGrow; // each set banked makes the next wave worse
// the gun grows WITH the wave: spawn rate and length both scale by grow, so a flat gun fell
// further behind on every set banked (the second horde was where every run ended)
G.horde = { t: TUNE.hordeLen * grow, spawnT: 0, mgT: 0, rate: TUNE.hordeRate * grow, mgRate: TUNE.mgRate * grow };
ball.active = false; G.phase = 'rally'; // ball gone; 'rally' lets the movement block steer P
P.pose = 'idle'; P.poseT = 0; P.swingT = -1; // clear the triggering shot's serve/swing pose
P.z = HOME.P.z; // brace at the baseline — the horde is a hold-the-line stand (finger steers x only)
aside('ump_spectators', 0.4); // a horde IS the crowd on the court. (The near-identical 'remain seated'
// that used to play right before it belongs to the transition, not here.)
G.flash = { text: 'THEY KEEP COMING', t: 1.2 };
}
+771
View File
@@ -0,0 +1,771 @@
// All tuning and constants. TUNE is live-editable in game via the T panel;
// the exported consts below are structural values that code shares but the panel doesn't show.
export const TUNE = {
camZ: 42, camY: 9, camF: 406, horizon: 40, // camF/horizon are per-orientation, set in fitViewport
gravity: 9.2, // slightly floaty vs 9.8 for readability
spinFactor: 0.9, // extra dive on quality shots (fake topspin)
bounceY: 0.58, bounceXZ: 0.78,
playerSpeed: 5.4, aiSpeed: 4.3,
reach: 1.3, aiReach: 1.6, // tight contact radius: the assist positions you, the tap must be honest
swingWindup: 0.06, // s before contact window opens
swingWindow: 0.16, // s contact window length
// ALL FOUR PACE NUMBERS ARE 1.2x THEIR ORIGINALS (16/12 and 19/22) — the whole ball, tennis and
// splatter, his and theirs, moved together so nothing about the relative feel shifts.
baseShotSpeed: 19.2, qualityShotSpeed: 14.4, // horizontal m/s = base + q*quality; perfect timing = real pace
// the slingshot's pace range. Weighted hard toward QUALITY so a full draw is a smash you can
// see: 26 m/s floppy at the minimum pull, 49 m/s at full — the draw nearly DOUBLES the pace,
// which is the "harder pull, faster ball" relationship, and it is in serveQualitySpeed's share
// of the pair. (baseShotSpeed is the rally volley — shared with tennis and the CPU.)
serveSpeed: 22.8, serveQualitySpeed: 26.4,
serveSpin: 1.3, // extra gravity a full-power serve carries (was a fixed 0.35). Without it the
// net-clearance loop ate the whole smash: a flat fast ball can't get over the
// tape AND come down on a target, so it got throttled back to walking pace.
// Heavy topspin lets it leave flat and DIVE, which is what a smash actually is.
aimError: 2.4, // m of scatter at quality 0
perfectMs: 45, // |offset from window center| for PERFECT
// The tennis CPU only exists in the OPENING match (after the transition, tennis never comes
// back — duels use racket* dials), so these three are the "how long until the first game is won"
// knobs. Testers needed several games to get past him: softer cap, twice the whiffs, and
// aiFlubLoss rubber-bands him — every game HE takes adds that much flub, so a struggling player
// faces an opponent that visibly starts falling apart rather than a wall.
aiQualityMin: 0.45, aiQualityMax: 0.72, aiFlub: 0.18,
aiFlubLoss: 0.08,
aiReaction: 0.45, // s before the CPU reads your shot — well-aimed corners are winners
autoAssist: matchMedia('(pointer: coarse)').matches ? 1.15 : 0, // auto-run speed factor (mobile)
// ---- THE SIDESTEP. Free walking stays 'rally'-only (see the movement block in gameplay), which
// left you rooted to the spot through a whole boss fight — the drag is the slingshot and there was
// no gesture left over that could mean "move". A dodge is not a walk: it is one committed step
// with a cooldown, so it answers a volley without turning the serve stance into a joystick.
dodgeDist: 1.9, // m of ground the sidestep covers
dodgeT: 0.18, // s it takes — fast enough to be a reaction, slow enough to read as a step
dodgeCool: 0.5, // s before another. THE anti-spam knob: shorter and you skate across the court
dodgeIFrame: 0.28,// s untouchable, covering the step and a hair after. Beats BOTH damage channels
// (a boss volley and the instant-death body), which is the whole point of it
zSpeed: 0.7, // zombie shamble speed m/s
// ---- THE DIFFICULTY DIRECTOR. Every dial below used to bottom out by about game 12, which is why
// a 50-game run stopped changing (games 12..50 were arithmetically identical). They are all keyed
// on G.runGames now and all keep moving; the ceilings that remain exist for perf or fairness, not
// because the curve ran out. See PROJECT.md "The difficulty director".
// Calibrated to TRACK the old curve through roughly game 12 and then keep going, rather than to
// replace it: the first pass at this made games 1-30 easier, which is the opposite of the ask.
zSpawnEvery: 3.0, // s between spawns in game 1
zSpawnAccel: 0.16, // hyperbolic tightening: gap = zSpawnEvery / (1 + games * this). g5 1.67s,
// g11 1.09s (old floored at 1.0 here and stopped dead), g20 0.71s, g30 0.52s
zSpawnMin: 0.35, // hard spawn-gap floor, safety only — reached around game 41
zMax: 3, // shamblers on court in game 1
zMaxPerGame: 0.9, // cap growth per finished game (was a flat +1 into a wall at game 11)
zMaxCap: 24, // absolute shambler cap — readability ceiling, reached ~game 24
zSpeedPerGame: 0.13, // shamble-speed ramp per finished game. Was TIME-based (splatT/70) and hit its
// ceiling after 105 seconds of splatter, i.e. during game 2 — so shamble speed
// was never a progression lever at all. g12 matches the old top speed (1.75
// m/s), and it climbs to 2.7 m/s by game 22.
zSpeedMax: 2.9, // cap on that ramp (top speed = zSpeed * 3.9). runnerVMax still caps runners.
nastyPerGame: 0.03, // share of spawns drawn from the advanced roster, per finished game
nastyMax: 0.75, // ...capped here (g14 matches the old 0.42, g25 hits the cap). The old mix was
// frozen at exactly 0.42 from game 12 to the end of the run.
hordeGrow: 0.2, // horde length AND spawn rate both scale by this per set already banked
zHordeCap: 34, // hard body ceiling DURING a horde. A horde ignores the director cap on
// purpose (that is the point of it), but hordeGrow means a late wave now tries
// for ~120 spawns where the old fixed 12 s x 2.5/s could only ever ask for 30,
// and a 40-body court on a 270px-wide portrait screen is mush, not menace.
// ---- PLAYER HEALTH. A SECOND, SEPARATE damage channel: only ranged attacks touch it. A shambler
// reaching you is still instant permadeath (G.mode='eaten') — that decision is settled, see PROJECT.md.
// There is no HP bar and there never will be: the state shows as blood on the man.
playerHp: 4, // hits from ranged attacks you can absorb before you go down
hurtGrace: 1.1, // s of invulnerability after a hit — a burst must not delete you in one volley
healPerDuel: 2, // hp back for winning a sudden-death duel. THE reason to want the duel.
// ---- the rogue ball machine ('m'): real tennis furniture that shoots at you. It never shambles,
// so it never crosses your baseline and never scores — it is a standing threat you choose when to
// deal with, which is the target-priority decision the game was missing.
machEvery: 4, // games before the first machine can appear, and games between them after
machMax: 2, // machines allowed on court at once
machHp: 6, // splat-quality hits to wreck one. It is a steel cart, not a body — and it now
// shows the damage on the way down (z_machw1 dented, z_machw2 split open)
machRoll: 1.6, // m/s it trundles when it repositions. It CANNOT fire while rolling, which is
// both honest (a rolling cart cannot aim) and the window you use to close on it
machRepo: 7, // s between repositions, jittered. "Furniture that occasionally wheels itself
// somewhere else" — it stops being a landmark you can ignore once it is aimed away
machCycle: 3.4, // s between its shots
machWind: 0.9, // s of TELEGRAPH before each shot — the fairness contract, same idea as shriek()
machSpeed: 13, // m/s it fires at
machDmg: 1, // player hp a shot costs if you don't return it
shotHitR: 0.42, // m — how close a hostile ball must ACTUALLY be to hit you. Measured: the old test
// (0.55 m with the parry's 0.8 z-weighting) fired while the ball was still 0.68 m
// up-court and drawn up around his head — "visually the ball is not yet at the
// player but somehow the player already is hit". Depth is NOT weighted here.
shotHitY: 1.75, // m — and it has to be at body height, so a lob passing overhead never counts
parryPow: 0.85, // shot quality of a RETURNED machine ball — a parry is a real weapon
// The parry gets its OWN generous reach and window, not the normal swing's. Returning a shot is the
// intended answer, on a phone, with one thumb, against something aimed at you — it has to be
// forgiving. parryWindow must stay <= swingWindup + swingWindow*VOLLEY.window (0.348 s) or the
// swing clock in update() expires before the window closes.
// TIGHTENED after Oliver's second playtest: "I can tap pretty early and it returns the ball".
// At parryArm 6.0 / window 0.34 / reach 2.4 the shot flew INTO reach during the window, so any tap
// once it was vaguely close worked and the timing stopped mattering. At 13 m/s a 3.4 m arm is about
// 0.26 s of "the tap counts", which is a real reaction test and still landable one-thumbed.
parryArm: 3.4, // m — an incoming shot this close makes the next tap a SWING instead of a serve
parryReach: 1.7, // m — how far off the racket a return still connects (normal volley reach is 1.82)
parryWindow: 0.2, // s the return window stays open after the tap
// A return is a GLANCING blow: contact off the middle of the racket face throws it that many
// degrees off straight up-court. Returns used to mirror the incoming line — straight back at the
// thrower — so a boss handed you a free wound with every volley. Meeting it dead centre still
// sends it back at him; that is now a placement you earn by moving your feet.
parryDeflect: 55, // ° of deflection at the edge of the reach
parryJitter: 8, // ° of scatter on top — a bat-away is never surgical
// ---- chapter bosses: one every bossEvery games, escalating. The racket zombie is demoted to an
// occasional opponent (racketEvery) so the boss is the only thing that reads as a wall.
bossEvery: 10, // games between chapter bosses
bossHp: 20, // hp of the FIRST one; each later chapter adds bossHpPerChapter. 10 was far too
// soft — three banked flame balls took chapter 1 down (2 impact + 2.4 burn each).
// 16 -> 20 alongside the movement below: a boss that holds its ground and dodges
// is only harder if it also lives long enough for the dodging to matter.
bossHpPerChapter: 10,
bossScale: 1.5, // body height multiplier at chapter 1 (a shambler is 1.8 m)
bossScalePer: 0.18, // ...growing per chapter, so chapter 4 towers
bossSpeed: 0.42, // m/s it advances — slow, deliberate, unstoppable
bossCycle: 2.6, // s between its ranged attacks (it uses the machine's projectile)
bossVolley: 3, // shots per attack — STAGGERED IN TIME, not fanned across space. Fanning them
// +-1.9 m meant only the middle ball could ever reach you and the other two were
// decoration; Oliver called that out. Three real threats in a rhythm instead.
bossSalvoGap: 0.42, // s between the shots of one volley
bossWind: 0.5, // s of telegraph before a boss throws — shorter than the machine's 0.9 on purpose:
// he asked for a tighter reaction time, and a boss should be harder than furniture
bossDeathT: 0.7, // s of GAME time the chapter card holds off after the boss dies. The card used to
// land on the same frame as the kill and bury it — you never saw the thing you
// spent a whole fight earning. Game time, so the slow motion below stretches it
// to roughly a second and a half on the clock.
bossSlowT: 1.2, // s of REAL time the world runs slow while a boss comes apart
bossSlowMo: 0.35, // ...and how slow
bossShotSpeed: 15.5, // m/s a boss throws at (the machine's is machSpeed 13)
// ---- boss MOVEMENT. A boss used to have no AI at all: it fell through to the crowd's aimless
// ±0.15 m/s wobble and crawled forward until it simply arrived on top of you. Each design now owns
// a movement style (BOSS_ART.move) and these are the shared numbers it spends.
bossStrafe: 1.7, // m/s sideways — bossSpeed above is only the FORWARD crawl
bossPaceX: 3.4, // m either side of the centre line a pacing boss turns at
bossShadowX: 1.7, // m either side of YOUR lane a 'shadow' boss turns at — its beat follows you
bossZMin: -1.0, // the band a boss holds. Without a clamp a fight ends by the boss walking into
bossZMax: 5.6, // you (~57 s at bossSpeed) rather than by you killing it — and every extra hp
// above just made that MORE likely, not less. Only 'charge' breaks the band.
bossChargeV: 4.2, // m/s the physio rushes at once it commits
bossChargeT: 2.2, // s ONE RUSH takes, start to arrival — the charge speed is derived from it and
// from how far away it is (bossAI), so the run-up reads the same every time and
// is long enough to see coming. Was 1.5 s at a flat 4.2 m/s, which only reached
// you at all because the boss crept nearer every cycle until it was on top of you.
bossStrike: 0.4, // m SHORT of you the rush stops — its STRIKE LINE, reached exactly as the rush
// timer runs out, so the boss turns and withdraws on the same frame it arrives.
// A rush that stopped ON you left it standing there afterwards, and no amount of
// sidestepping gets you out from under that (dodgeCool 0.5 s vs a 0.28 s
// i-frame). It cannot run PAST you either: a body over the baseline is deleted
// and scores a point (see updateZombies), which quietly despawned the boss
// mid-fight the first time this ran through. At 0.4 m the contact test still
// reaches about 0.85 m either side of the lane, and the sidestep covers 1.9.
bossChargeRest: 2.2, // s of held ground between charges — the window you get to hit it
bossHopT: 0.55, // s per mascot hop (it moves in arcs, not in a slide)
bossHopX: 2.0, // m of ground one hop covers (the eased arc lands it at about 2/pi of this)
racketEvery: 3, // games between racket-zombie (plain opponent) spawns — was EVERY won game, which
// is why he read as "the boss" and why a real boss would not have stood out
asideEvery: 55, // s between the umpire's sarcastic asides in splatter (jittered +-40%). He never
// acknowledged that the match had changed; now he comments on it, disapprovingly.
racketHpPerGame: 0.12, // the racket zombie stiffens as the run goes on (floored into whole hp)
// ---- damage. Bodies with hp (racket boss 'r', brute 'g') take damage in these units, so a big
// boss is just a bigger hp number and every weapon stays proportional. Fractions are legal: they
// bank on zb.chip and only whole points come off hp (the wound SKINS switch on hp thresholds).
dmgBall: 1, // a splat-quality ball or serve
dmgSide: 1, // one TRIPLE side ball — used to pass straight through the boss doing nothing
dmgPellet: 0.34, // one machine-gun round — three chip a point off. Also used to no-op on the boss
dmgBomb: 3, // caught in a blast. Was an instant kill that ignored hp entirely
dmgFlame: 2, // a direct flame-ball touch (and it sets the boss alight, which it didn't before)
bossBurnMul: 0.4, // burnDps multiplier while a boss body cooks — it burns, just slower than a body
bossHitCool: 0.2, // s a boss body is immune to further BALL contact after taking a hit. A flame ball
// has full pierce (it is not consumed on contact, so it can cut a lane), and a boss
// is not removed when hit — so the same ball sat inside its radius and re-wounded it
// EVERY FRAME. That is why one flame ball killed a 16 hp chapter boss.
changeoverT: 5.0, // s the changeover breather lasts (after odd games, as the rules demand)
boyMopSpeed: 2.2, // m/s a MOPPING ball boy crosses at (a fetching one still sprints at 7.5)
boyMopDwell: 1.8, // s he stands on a stain pushing the mop before moving to the next one
boyMaxJobs: 4, // stains one boy will clean in a round — a kill streak must not employ him forever
boyStride: 0.28, // m of travel per animation frame. Frames are driven by DISTANCE, not time, so
// the feet cannot slide: change a boy's speed and his legs keep up on their own.
gameBeatT: 1.6, // s the game-end beat lasts on even (non-changeover) games
eatenR: 0.9, // m a shambler must close to eat you — matches the visual sprite overlap
zSnap: 0.9, // m of lateral slack at zero range — the aim cone's base width (fat-thumb forgiveness)
killsPerPoint: 3, // splatter: zombie kills to score ONE tennis point (1 = every kill scores, old behavior)
zSplatQ: 0.5, // min shot quality to splatter; below = stagger pushback
zPushback: 1.4, // m a staggered zombie is knocked back
splatterOverT: 0.1, // s the spent-ball 'over' beat lasts before the next serve is armed
stuckT: 6, // s the deadlock watchdog waits before letting go of a frozen latch (see update)
tinCardT: 2.8, // s the won-duel weapon card holds (it does NOT wait for a tap). The swipe-in
// and swipe-out eat ~0.55 s of this, so every card number below is REAL time on
// screen minus that — 2.0 left the old static hold, and read as a flicker.
// the slingshot: the pull's DIRECTION picks a lane, its LENGTH is power. Deliberately DECOUPLED —
// reaching a far shambler must not cost a longer drag than the screen has room for. The old
// mapping (pull length = reach) is why you had to drag off the bottom of the phone.
pullMin: 18, // px of pull below which the release cancels — no shot, nothing spent
pullFull: 130, // px of pull for a full-power shot. Keep this well inside one thumb's travel — but
// 95 was so short that EVERY pull maxed out by accident and the meter never
// meant anything. A full draw should cost a deliberate stretch of the thumb.
// Capped short of ~150 because a pull that STARTS low on the screen has to
// still be completable downward before the finger runs off the glass.
pullQMin: 0.12, // shot quality at pullMin, scaling to 1.0 at pullFull. Below zSplatQ it only
// staggers, so a lazy flick can't kill: THE anti-spam knob.
perfectBand: 1.28,// ...and the PRAISE is a separate, narrower thing. Quality SATURATES at pullFull
// — every pull past 130 px is exactly 1.0 — so "quality == 1" meant "you pulled
// far enough", and the game shouted PERFECT at almost every serve. Oliver:
// "it displays perfect a lot even when nothing special happened". Praise now
// needs the pull to STOP in the band pullFull..pullFull*this (130-166 px), which
// is a target you can miss. The pull clicks when you enter it (see input.js) and
// goes quiet when you overshoot, so the window is learnable by feel.
praiseEvery: 14, // s between praise words, on the umpire's-asides model (asideEvery below). The
// band alone was not enough: you learn to hit it, and then the HUD talks on
// every other serve again. The CLICK is the reliable feedback for a good pull —
// the word is garnish, and garnish on every plate is just food.
aimCone: 0.10, // lane half-width added per meter of range. This is what makes the pull's ANGLE
// mean something: at 0.45 the cone was ±10 m at the far baseline — the whole
// court — so every downward pull picked the same body and the angle felt inert.
// 0.10 gives ±2.9 m out there: you have to actually point at a lane.
pullHintAfter: 1.5, // s of doing nothing in the serve stance before the pull gesture demos itself
aimTie: 0.12, // how much worse-aimed a NEARER body may be and still win the lane (0..1 of the
// cone half-width). 0 = pure best-aligned wins; 1 = the old "nearest in the
// lane always wins", which is what made the magnet grab the wrong shambler.
aimReach: 22, // m the aim ray runs when no shambler stands in the lane
// How the camera moves while you're drawing the slingshot. NEGATIVE (the default) drops it and
// pushes in — you crouch into the shot. POSITIVE lifts it and leans back for a steeper, more
// top-down look, which reads the depth behind the net better but feels less like aiming.
// Both 0 = the old fixed camera.
aimCamY: 0, // m of camera height change (negative drops you into the shot)
aimCamZ: 0, // m of camera dolly (negative = closer). Both 0 = the camera holds still,
// which is where Oliver landed: the aim graphics carry the read on their own.
backstopZ: -1.8, // z of the far backstop — must sit IN FRONT of the -2 retire line, or balls die before they reach it
backstopKeep: 0.55, // pace a ball keeps after the rebound — still lethal, slower
backstopPop: 1.8, // m/s of lift off the fence, so the rebound travels back instead of dying at the net posts
meleeCool: 1.0, // s between racket swishes — melee is the panic button, not the default
meleeCrowd: 0.38, // ...but the panic button PANICS. The cooldown shortens with how many bodies are
// actually on top of you, so a late horde stops outrunning your only close-range
// answer: cool = meleeCool / (1 + (near-1)*meleeCrowd), floored at meleeCoolMin.
// 1 body 1.00s, 4 bodies 0.47s, 8 bodies 0.28s.
meleeCoolMin: 0.22,
meleeCrowdR: 3.4, // m — the radius that counts as "on top of you" for that scaling (> meleeRange,
// so the swish speeds up as they close in, not only once they are already on you)
zPierce: 0.75, // pace a ball keeps after a splatter — hot balls plow through lined-up pairs
// ---- SLICE. Not a tin and not a rack slot: it is what a PERFECT serve DOES. Cut the ball and
// each body it kills strikes it on toward the next one instead of it plowing straight through.
// Every other weapon is something you are handed; this one is a technique, which is why it is
// earned on the timing window (the toss ritual's 45 ms, or a pull that STOPS inside perfectBand)
// rather than won out of a barrel. Only a KILL carries it on — a wound ends the chain — so it is
// a crowd skill shot that does nothing to a brute, a steward or a boss, and it hands the plain
// ball a route to the DOUBLE/TRIPLE KILL ladder only a bomb or a lucky line-up could reach.
// It is ONE new velocity per kill and then ordinary ballistics: see sliceCarom for why the ball
// must never steer, whatever it costs in prettiness.
sliceHops: 2, // extra bodies one perfect serve is struck on to. 2 => TRIPLE KILL is the ceiling
sliceR: 6.5, // m it will reach for the next body. Wider reads as the game aiming for you;
// much narrower and a spread-out crowd never chains at all
sliceArc: 120, // deg: widest bearing off the ball's heading it will take. A carom can physically
// go anywhere, but one that doubles back down-court reads as a stray deflection
// rather than as a cut, and the chain should be walking INTO the crowd anyway
// The schedule calls the DUEL, not the tin: weapons are only ever won, but a duel comes round
// on its own so there is always a chance at one. Fires off the next hand-cleared wave.
duelFirst: 2, // game after which the first scheduled sudden-death duel is called
duelEvery: 3, // games between scheduled duels after the first
// How many a fresh tin holds now lives on its WEAPONS row (`tin`), because the rack is no longer
// four fixed types — bombMax/tinTriples/tinFlames/tinGuns lived here and nothing read them after
// the table landed, which is a value you can tune in the panel all day for no effect.
wCap: 99, // per-type cap on the courtside rack. Effectively NO cap: at 9 a late run spent
// every duel win on a full tin, and the answer to game 70 is a bigger stack, not
// a rationed one. 99 rather than Infinity only because the count is drawn as
// text in a rack slot and '×999' does not fit.
rackH: 30, // px height of the bottom weapon rack (feel knob: how much court it covers).
// 22 logical px is ~30 CSS px on a phone — under half Apple's 44px target, which
// is why switching tins felt fiddly. rackHit also pads the touch band (render.js).
bombRadius: 4.2, // m blast radius of a bomb ball — 2.5 barely cleared the bodies stood on the crater
hordeLen: 12, // s a horde wave lasts
hordeRate: 3.5, // zombies spawned per second during a horde. Raised WITH the gun buff below: the
// wave is meant to leave a standing crowd on your half when it ends, not to be a
// twelve-second interruption that clears itself.
mgRate: 12, // machine-gun pellets fired per second during a horde. It scales with hordeGrow too
// (see startHorde): at 6/s single-target the gun simply could not out-kill a grown
// wave — Oliver died on the second horde of every run.
mgSpread: 6, // ± degrees of pellet spread — a cone that sweeps bodies, not a needle up the middle
mgPelletR: 0.5, // m of extra hit radius on a pellet: a fat round. "Bigger ball = more damage."
dmgPelletHp: 2, // hp a pellet takes off a body — shamblerHp is 2, so the horde gun one-shots them
mgPickupLen: 8, // s the machine-gun tin (4th delivery) auto-fires outside hordes
// ---- TEST MODE (triple-tap the scoreboard). A sandbox for trying the new content: a steady
// MEDIUM wave regardless of how far into a run you are, and every barrel weapon on the rack,
// kept topped up so nothing runs dry mid-test. It overrides the difficulty director rather than
// editing it — G.runGames keeps climbing underneath and the run is still a real run.
testZMax: 16, // bodies on court. Was 8 and the sandbox kept running dry between kills — a
// crowd weapon needs a crowd to test it against
testZSpeed: 1.15, // m/s shamble — medium, and FIXED (no per-game ramp)
testSpawnEvery: 0.9,// s between spawns: they keep coming
testLevel: 20, // the roster level pickVariant pretends to be at, so every body type can turn up
// ---- THE LASER's wedge: aim and release like a serve, and a plane of light widens down the lane
beamLen: 23, // m it reaches — the DRAWN length as well as the kill length. It used to be only
// the kill: the drawn length was solved each frame so the far end landed on the
// middle of the ad strip, which meant turning this knob did nothing you could
// see. One meaning now. Tuned WITH beamY: a lower plane reaches the ads sooner,
// so dropping one wants the other shortened. Oliver's numbers, 2026-08-03.
beamY: 0.95, // m — the height the plane HOLDS down the court, and the line it cuts bodies at.
// It used to slope to the ground by about 20 m out, which meant that across the
// whole far half it lay flat on the grass: nothing to cut, and the split would
// have been invisible exactly where the crowd stands. Chest on a shambler, belly
// on a brute, thigh on the roller man — so a bigger body visibly takes it lower.
beamOriginLift: 0.44, // m the apex sits ABOVE beamY. It is a lift, not an absolute height, so
// lowering beamY brings the near end down with it — set as an absolute, dropping
// the plane left the apex hanging at chest height with the beam sloping away
// beneath it. It leaves the PLAYER, not the racket head: emitted from the racket
// (2.2 m) the apex floated above him and read as detached.
beamRise: 0.14, // fraction of the length it takes to settle from the origin to that height
beamW0: 0.08, // m half-width at the racket — very nearly a point
beamW1: 3.1, // ...and at the far end. It IS a cone — but a gentle one. At 5.0 the world width
// grew so much faster than the projection shrank that on screen it opened out
// while the court receded, and the eye read that as a flat shape stuck to the
// glass. Growing slowly, the two roughly cancel and it lies in the world.
// A/B, live from the T panel on the phone. 1 = draw the laser the way the BALL's aim band is
// drawn: a path down the lane whose width comes from each point's OWN projection scale, so it
// tapers into the distance and sits in the world. 0 = the solid widening cone.
// The cone is a real projection, but it GROWS in world width (0.06 m -> beamW1), so on screen it
// opens out while everything else recedes — which is what reads as a flat sticker.
beamBorder: 0.18, // px of the two outer edge lines — a hairline. At 1 they were the heaviest
// thing on the court and boxed the cone in.
beamBand: 0, // 0 now: the CONE won, once it stopped ballooning (see beamW1). >= 0.5 is ON —
// the panel steps by 0.02 and a nudge to 0.02 used to flip the renderer silently
beamBandW: 0.30, // world half-width of that band — CONSTANT, which is what makes it taper
beamT: 0.42, // s the wedge stays drawn
dmgBeam: 8, // what it takes off a BOSS. An ordinary body is not damaged by the beam, it is
// DELETED by it (see fireBeam); bosses "must suffer severely" but survive.
// ---- the weapon barrel: the only way to get the four weapons that are not on the duel ladder
barrelEvery: 3, // games between barrels being rolled on (like the ball machine's machEvery)
barrelRamp: 8, // ...and the gap SHRINKS by one for every this many games finished, floored at
// every game (g8 every 2, g16+ every game). Late runs live on the barrels: past
// the point where every tin is capped, fast tapping was the only way left to
// keep up, and "more inventory for strategic use" is the answer to that.
barrelRoll: 2.6, // m/s it trundles across the far court — fast enough to have to be led
barrelHp: 1, // anything pops it: ball, pellet, bomb, flame, swish. It is a target, not a body
barrelH: 1.15, // drawn height (a body is 1.8)
fps: 60, // frame cap — 30 halves battery/heat but Oliver felt it; smoothness wins (0 = uncapped)
lowPower: 1, // >= 0.5 caps RES at 3 (−44% of pixels painted per frame) — the battery saver,
// flippable live from the T panel. DEFAULT ON since 2026-08-08: Oliver judged
// the sharpness trade invisible on his phone. Play speed and fps are untouched.
// Set to 0 for the full-fat near-1:1 render.
runnerSpeed: 3.2, // runner ('f') speed multiplier over the shamble
runnerVMax: 4.2, // hard runner speed cap — the late-game ramp must stay humanly reactable
shamblerHp: 2, // splat-quality hits a classic shambler ('a'/'b') soaks before it dies — only the runner ('f') is still one-shot glass
stewardHp: 5, // the steward ('d'). Was 3 and died like a shambler, which made a bigger body read
// as decoration; at 5 he is a brute's worth of hp on a faster, ordinary-looking
// walker, and he has a wound skin (z_dw) so the second half of that shows
hordeSteward: 0.2, // share of horde spawns that come out as stewards (from game 4)
// ---- the late roster. Three bodies that arrive as the run goes on, filling the gap between the
// 2 hp shamblers and the 5 hp brute and then extending past it. Each has one wound skin per hit it
// can still take, so the damage is readable without a bar (the rule the boss's missing bar set).
// These are now REAL: the ball path used to one-shot anything it had no branch for, so all three
// died to a single good serve no matter what these said. Retuned once they actually bite — 9 was
// fine as a number nobody felt and is a long grind as a number you do.
ballboyHp: 3, // the ball boy ('e') — a stock-sized body that takes three, with two wound skins
bouncerHp: 5, // the bouncer ('h') — 2.4 m, shoves through the crowd instead of shambling round it
rollerHp: 7, // the roller man ('k') — 3.2 m, hauling a court roller. The heaviest thing on court
rollerStride: 0.62, // m he covers per lurch frame — longer than the brute's 0.5: a bigger body,
// a longer stride, or his feet slide the way the brute's used to
// fire: the flame ball no longer just kills what it touches, it starts an epidemic.
burnTime: 3.0, // s a body burns before the fire goes out
burnDps: 2.0, // hp lost per second while burning — burnTime*burnDps must exceed bruteHp or tanks walk out of the fire
burnSpread: 2.2, // m — a burning body ignites anything this close, every frame it burns
burnIgniteR: 4.5, // m — the flame ball's ignition radius around the body it kills
burnBallR: 2.4, // m — the flame ball also lights anything it FLIES PAST. Without this the ball's
// full pierce simply killed the whole lane outright and nothing was left to catch fire.
burnPanic: 1.0, // walk-speed multiplier while burning (1 = off; crank it for panicked sprinting)
setCardT: 2.8, // s the centered SET card holds (the 'kurze unterbrechung' before the horde)
bossWinT: 4.4, // ...and s the EPIC! card holds after a chapter boss. Its OWN number: a boss is
// the longest fight in the game and the win was over before it registered, but
// stretching setCardT would have made every routine set announcement drag too.
modalIn: 0.34, // s the modal swooshes in — icon+title from the right, body cascading from the
// left, each on easeOutBack. The gesture that dismisses is swallowed until it
// lands, so a panic tap can't skip a popup before it has been read.
bruteStride: 0.5, // m the brute covers per lurch frame — it is the slowest thing on court, so
// its cycle is driven by ground covered rather than by the clock
bruteHp: 5, // splat-quality hits (or pellets) a brute ('g') soaks before it dies — one wound skin per stage
shieldDown: 1.5, // s a shield zombie's ('s') chair stays dropped after a block — the kill window
meleeRange: 1.7, // m — a racket swish cleaves shamblers this close to you
racketHp: 3, // splat-quality hits a racket zombie (mini-boss) takes before it dies
racketStride: 0.42, // m the racket zombie covers per walk frame — his cycle steps off GROUND
// COVERED, so he stops animating when he stops moving
racketSpeed: 2.8, // m/s the racket zombie shambles to chase a ball
racketQMax: 0.55, // its return quality is capped here (slower, sloppier than the tennis CPU)
racketFlub: 0.2, // chance the racket zombie whiffs a return
missHud: 0, // show the last miss reason on screen (full log: T.G.missLog) — off; dev-only
killPips: 1, // splatter: show the kills-to-next-point pips on the scoreboard
};
// the death animation's timeline, in seconds of G.eatenT. A: thrash · B: you burst into gore
// · C: the summary fades in. The restart tap is gated past the whole thing so a panic tap
// during the burst can't skip it.
// assets/audio/tennis-applaus.wav runs 4.16s and always plays in full, so the crowd must keep
// clapping at least that long — every cheer() call was passing a shorter beat, which left the
// stand sitting back down while the applause was still audible.
export const APPLAUSE_T = 4.2;
// A: they drag you down and you fight it on your knees · B (EATEN_BURST): the crowd closes over you
// and what is left is the mound · C (EATEN_SUMMARY): the summary fades in over them still feeding.
// It is deliberately long. Oliver: "the movies surround and bite them, blood spills, and then the
// person basically drowns in the zombies" — that cannot happen in half a second.
export const EATEN_BURST = 2.3, EATEN_SUMMARY = 3.6, EATEN_RESTART = 4.6;
export const EATEN_FEED_R = 1.15; // m — how tight the feeding ring packs around the body
// UI accent colours, one place so the whole HUD can be re-tuned at once. Deliberately
// desaturated from the original arcade primaries: they were shouting over the artwork.
// (NOT the gore — blood and piles keep their loud reds; only text and interactive UI.)
export const UI = { hot: '#d9705f', warm: '#e0cc72', pick: '#cbd06a', cream: '#e8e8dc',
dim: '#8fa0b8', bad: '#a34a42', ball: '#dbe063',
// the weapon barrel's glow, and the only pink on the court: nothing else in the
// palette is near it, so "pink means a weapon is in there" needs no explaining
pink: '#ff5cf0' };
// ------------------------------------------------------------------ courts ----
// A court type is pure DATA: adding one is a row here plus its four PNGs, never a change to render().
// Each carries TWO palettes because every court still rots — `tennis` is how it looks before the
// horde arrives, `splatter` is the drained version render.js lerps to (see paletteT).
// stripes mown lawn bands. Grass only: clay and acrylic are laid flat, and drawing stripes on
// them was the single thing that made a recoloured court still read as grass.
// line painted line colour. Chalk-white reads on green and blue; on clay it has to go warmer
// or the lines glow like neon.
// ads/crowd sprite BASE names; the clap frame is <crowd>2 (see the two-frame note in render()).
// bounceY/XZ copied into TUNE on selection, so the T panel keeps editing the live values and the
// three physics call sites never learn that courts exist. Grass skids low and fast, clay
// sits up and drags, acrylic is the middle it always was.
export const COURTS = [
{ id: 'grass', name: 'CENTRE COURT', sub: 'GRASS · fast, low skid', tile: 'court_grass',
ads: 'x_ads', crowd: 'x_crowd', stripes: true, line: '#e8e8dc', border: '#e8e8dc',
bounceY: 0.50, bounceXZ: 0.84,
tennis: { stands: '#232e42', tierA: '#2a3750', tierB: '#26324a', board: '#0e5c2e',
out: '#295c2d', lawn: '#4cae52', stripe: '#46a54c' },
splatter: { stands: '#181426', tierA: '#201a33', tierB: '#1c172e', board: '#3a1212',
out: '#20331c', lawn: '#5e7747', stripe: '#576f42' } },
{ id: 'clay', name: 'TERRE ROUGE', sub: 'CLAY · slow, high kick', tile: 'court_clay',
ads: 'x_ads_c', crowd: 'x_crowd_c', stripes: false, line: '#efe3cf', border: '#efe3cf',
bounceY: 0.66, bounceXZ: 0.70,
tennis: { stands: '#4a3025', tierA: '#5a3a2c', tierB: '#513428', board: '#8a4426',
out: '#7a3f26', lawn: '#c4652f', stripe: '#c4652f' },
splatter: { stands: '#2a1a18', tierA: '#33201d', tierB: '#2d1c19', board: '#3a1212',
out: '#4a2a1c', lawn: '#8a4a2a', stripe: '#8a4a2a' } },
{ id: 'hard', name: 'THE SLAB', sub: 'HARD · true bounce', tile: 'court_hard',
ads: 'x_ads_h', crowd: 'x_crowd_h', stripes: false, line: '#f2f4f8', border: '#f2f4f8',
bounceY: 0.58, bounceXZ: 0.78,
tennis: { stands: '#1d2836', tierA: '#243141', tierB: '#1f2b3a', board: '#12525e',
out: '#2c7a52', lawn: '#1f5c93', stripe: '#1f5c93' },
splatter: { stands: '#141a26', tierA: '#1a2130', tierB: '#161d2a', board: '#3a1212',
out: '#25452f', lawn: '#1c3e5c', stripe: '#1c3e5c' } },
];
// The tour, picked on the same screen as the court. Only the two TENNIS PLAYERS and the turned CPU
// (the racket zombie — a zombified opponent) have a second drawing; the horde, the officials and
// the ball boys are shared, because they are staff, not the draw. `sfx` is the sprite-name suffix
// render.js appends: null means "the frames as drawn", which is the men's set.
// dx/apex are optional and override BOUNCE_IDLE's pair: she reaches further out and higher than he
// does, so the pre-serve ball has to meet a different palm. Same rule as BOUNCE_IDLE — measured off
// the drawn hand by eye, not calculated.
export const TOURS = [
{ id: 'men', name: "MEN'S SINGLES", tile: 'tour_m', sfx: null },
{ id: 'women', name: "WOMEN'S SINGLES", tile: 'tour_w', sfx: '_f', dx: 0.46, apex: 0.78 },
];
// netW was the singles-stick width (10.06) while the court is drawn to the DOUBLES lines, so the
// net stopped 0.46 m short of each sideline and visibly failed to meet the court edge. The posts
// now stand exactly on the doubles sidelines.
export const COURT = { L: 23.77, W: 8.23, dblW: 10.97, netZ: 11.885, netH: 0.914, netW: 10.97 };
COURT.svcFar = COURT.netZ - 6.4; // far service line
COURT.svcNear = COURT.netZ + 6.4; // near service line
// world sizes (meters)
export const PLAYER_H = 1.88;
// The chapter-boss ROSTER. One design per chapter, cycling — chapter 5 gets design 1 again, but at
// far more hp and much bigger, so it still escalates. Each entry is pure DATA so adding a design is a
// sheet plus a row here, never a change to drawZombie.
// walk: two frames, stepped off DISTANCE COVERED (never time — see the animation invariant)
// wounds: damage skins, healthiest first. However many there are, they are spread evenly over hp.
// throws: the same states mid-throw, used while windT > 0. Index is clamped, so a design with
// fewer throw frames than wound stages simply reuses its last one.
// Every name must be ALL LOWERCASE and a whole word — see the case-collision note in the pipeline.
//
// BEHAVIOUR is data too, and every field below is OPTIONAL — anything left out falls back to the
// shared TUNE.boss* number, so a row with only art in it behaves exactly like a boss did before:
// move 'pace' | 'mirror' | 'advance' | 'charge' | 'bounce' (spawnChapterBoss -> bossAI)
// hpMul / speedMul scale this design's hp and its forward crawl
// volley shots per attack · gap s between them (0 = the whole volley leaves in ONE frame,
// which is a WALL to dodge rather than a rhythm to parry) · spread m of scatter
// cycle s between attacks · wind s of telegraph · shot m/s it throws at
export const BOSS_ART = [
{ name: 'THE GROUNDSKEEPER', walk: ['z_boss1', 'z_boss2'],
wounds: ['z_bossw1', 'z_bossw2', 'z_bossw3'],
throws: ['z_bosst0', 'z_bosst1', 'z_bosst2', 'z_bosst3'],
move: 'pace', speedMul: 0.7 },
{ name: 'THE LINE JUDGE', walk: ['z_judge1', 'z_judge2'],
wounds: ['z_judgew'], throws: ['z_judget', 'z_judgewt'],
move: 'shadow' }, // he walks the line, and the line is wherever you are
{ name: 'THE CHAIR UMPIRE', walk: ['z_chair1', 'z_chair2'],
wounds: ['z_chairw'], throws: ['z_chairt', 'z_chairwt'],
move: 'surge', hpMul: 1.1 }, // the immovable one — he just keeps coming, and coming back
{ name: 'THE HEAD COACH', walk: ['z_coach1', 'z_coach2'],
wounds: ['z_coachw1', 'z_coachw2', 'z_coachw3'],
throws: ['z_coacht0', 'z_coacht1', 'z_coacht2', 'z_coacht3'],
move: 'pace', paceMul: 1.9, hpMul: 0.9, // never where he was: drills you while running the line
volley: 5, gap: 0.14, cycle: 2.2, wind: 0.4, shot: 19 },
{ name: 'THE PHYSIO', walk: ['z_physio1', 'z_physio2'],
wounds: ['z_physiow1', 'z_physiow2', 'z_physiow3'],
throws: ['z_physiot0', 'z_physiot1', 'z_physiot2', 'z_physiot3'],
// THE BODY IS THE WEAPON — which it never once was. One ball every 3.4 s and a charge that gave
// back most of its own ground meant the fight was over long before it arrived: Oliver, "the
// physio does basically nothing but move towards the net, too easy". It covers the ground half
// again as fast now (speedMul) and drills you on the way in, and since the charge style itself
// retreats to the front of the band after every rush (bossAI), it arrives OVER AND OVER rather
// than either never or once and for good.
move: 'charge', hpMul: 0.9, speedMul: 1.45,
volley: 3, gap: 0.26, cycle: 2.4, wind: 0.3, shot: 22, spread: 0.5 },
{ name: 'THE SPONSOR MASCOT', walk: ['z_mascot1', 'z_mascot2'],
wounds: ['z_mascotw1', 'z_mascotw2', 'z_mascotw3'],
throws: ['z_mascott0', 'z_mascott1', 'z_mascott2', 'z_mascott3'],
move: 'bounce', hpMul: 1.3, speedMul: 0.6, // soaks, and answers with a WALL: five at once, so
volley: 5, gap: 0, spread: 1.6, cycle: 3.2, wind: 0.65, shot: 14 }, // there is nothing to parry
{ name: 'THE STRINGER', walk: ['z_string1', 'z_string2'],
wounds: ['z_stringw1', 'z_stringw2', 'z_stringw3'],
throws: ['z_stringt0', 'z_stringt1', 'z_stringt2', 'z_stringt3'],
move: 'shadow', paceMul: 1.6, hpMul: 1.1, // he never mis-strings: he takes your lane and puts
volley: 2, gap: 0.28, spread: 0.1, cycle: 2.1, wind: 0.34, shot: 23 }, // two dead-straight ones on it
{ name: 'THE TOURNAMENT DIRECTOR', walk: ['z_dir1', 'z_dir2'],
wounds: ['z_dirw1', 'z_dirw2', 'z_dirw3'],
throws: ['z_dirt0', 'z_dirt1', 'z_dirt2', 'z_dirt3'],
move: 'surge', hpMul: 1.2, // the one fight you cannot stand still in: he has a
volley: 2, gap: 0.4, spread: 0.7, cycle: 3.0, wind: 0.5, // radio, and he calls security with it
line: 'HE IS CALLING SECURITY.', adds: { every: 6, variant: 'd', cap: 5 } },
{ name: 'THE STRAWBERRY SELLER', walk: ['z_berry1', 'z_berry2'],
wounds: ['z_berryw1', 'z_berryw2', 'z_berryw3'],
throws: ['z_berryt0', 'z_berryt1', 'z_berryt2', 'z_berryt3'],
move: 'charge', hpMul: 1.4, speedMul: 1.15, // a tray of cream and a ladle: he
volley: 4, gap: 0.12, spread: 1.4, cycle: 3.0, wind: 0.55, shot: 15 }, // sprays, then comes for you
// THE FINAL BOSS, and the only row that is not in the chapter rotation: bossArt() hands him out
// every TENTH chapter (game 100, then 200). Two things make him the wall the run ends on.
// `rally` he RETURNS anything that is not a splatter-quality kill shot — see the boss branch in
// zombies.js. Every other body in the game can be worn down with soft balls; he is the
// one you have to out-HIT, and a lazy serve comes straight back at your head.
// `phases` three rows that override this one as his health falls (bossPlan), each with its own
// cry. He holds the baseline and rallies, then runs the line drilling volleys, then
// drops the trophy and comes for you. Same body, three fights.
{ name: 'THE CHAMPION', walk: ['z_champ1', 'z_champ2'],
wounds: ['z_champw1', 'z_champw2', 'z_champw3'],
throws: ['z_champt0', 'z_champt1', 'z_champt2', 'z_champt3'],
rally: true, hpMul: 1.6, line: 'HE RETURNS EVERYTHING.',
phases: [
{ move: 'pace', paceMul: 0.9, volley: 1, cycle: 3.2, wind: 0.6, shot: 17, spread: 0.3 },
{ move: 'shadow', paceMul: 1.8, volley: 4, gap: 0.22, cycle: 2.2, wind: 0.4, shot: 20, spread: 0.5,
cry: 'SECOND SET.' },
{ move: 'charge', speedMul: 1.5, volley: 3, gap: 0.1, cycle: 1.9, wind: 0.3,
shot: 24, spread: 1.1, cry: 'MATCH POINT.' },
] },
];
// WHICH design a chapter gets. The roster is walked one design per chapter, and every TENTH chapter
// is THE CHAMPION — last in the array and never part of the rotation. The floor(ch/10) term takes
// his chapters back out of the count, so chapter 11 opens the second lap on the groundskeeper
// instead of skipping him.
export const bossArt = ch => ch % 10 === 0 ? BOSS_ART[BOSS_ART.length - 1]
: BOSS_ART[(ch - 1 - Math.floor(ch / 10)) % (BOSS_ART.length - 1)];
// The roster, as the BODY COUNT screen reads it. Until now a variant's NAME lived only in the
// comment above its hp value — fine while nothing ever had to print one. The key is the `variant`
// char the zombie already carries, except that 'a' and 'b' fold together (they are one shambler in
// two shirts, and two identical rows on the stats list would just look like a bug) and a chapter
// boss files under 'B0'..'B5' by BOSS_ART index, so the screen can name the design that got you.
// `spr` is the frame the row draws — the first walk frame, which every body has.
// The barrel ('o') is deliberately absent: it is loot, not an opponent, and killZombie scores it
// as nothing. Keep that agreement — a barrel row would put a crate in a list of the dead.
export const ENEMIES = {
a: { name: 'SHAMBLER', spr: 'z_a1', sub: 'THE ORDINARY DEAD' },
d: { name: 'THE STEWARD', spr: 'z_d1', sub: 'HI-VIS · ONE HIT TOUGHER' },
e: { name: 'THE BALL BOY', spr: 'z_e1', sub: 'STILL FETCHING' },
f: { name: 'THE RUNNER', spr: 'z_f1', sub: 'ONE HIT · BUT HE SPRINTS' },
g: { name: 'THE BRUTE', spr: 'z_g1', sub: 'SLOW · DRAGS A POLE' },
h: { name: 'THE BOUNCER', spr: 'z_h1', sub: 'BUILT LIKE A DOOR' },
k: { name: 'THE ROLLER MAN', spr: 'z_k1', sub: 'HAULS THE COURT ROLLER' },
s: { name: 'THE SPECTATOR', spr: 'z_s1', sub: 'BLOCKS WITH HIS CHAIR' },
// The man you were playing, after the transition turns him (gameplay.js spawns him as 'c'). One
// per run, and he is NOT the racket zombie below — that one keeps coming back. Missing this row
// is what put a second "SHAMBLER" on the screen: an unmapped key falls back to the shambler art.
c: { name: 'THE OPPONENT', spr: 'z_c1', sub: 'HE TURNED MID-MATCH' },
r: { name: 'THE CLUB PRO', spr: 'z_c1', sub: 'STILL HAS A BACKHAND' },
m: { name: 'THE BALL MACHINE', spr: 'z_mach1', sub: 'FIRES BACK · FURNITURE' },
};
for (let i = 0; i < BOSS_ART.length; i++) // the chapter bosses, named where they already are
ENEMIES['B' + i] = { name: BOSS_ART[i].name, spr: BOSS_ART[i].walk[0], boss: true,
sub: i === BOSS_ART.length - 1 ? 'THE FINAL · EVERY TENTH CHAPTER' : 'CHAPTER BOSS' };
// Which wound stage a body is in: 0 = unhurt, 1..n = the skins, spread evenly over its full health.
// This is the rule the chapter boss already used, and it turns out to reproduce the brute's old
// hardcoded thresholds EXACTLY (5 hp over 4 skins = one skin per hit) and the shamblers' single skin
// too — so there is now one rule for damage readout instead of three, and it is the one that keeps
// working when a body's hp is not one-per-skin. Every caller passes its own stage count.
export const woundStage = (zb, n) => {
const f = zb.hp / (zb.hpMax || 1);
return f >= 1 ? 0 : Math.min(n, Math.max(1, Math.ceil((1 - f) * n)));
};
// Wound skins by variant, HEALTHIEST FIRST — the same ordering BOSS_ART.wounds uses, indexed by
// woundStage above. This one table replaced two branches in drawZombie that did the same job against
// different constants (the brute had a hardcoded stage array, everything else a single skin behind
// `'abd'.includes(...)`), which is why a new multi-stage body is a row here and no code at all.
// Each name's `…b` twin — the stride swapped — is looked up automatically, so a wounded body keeps
// WALKING instead of freezing and sliding. 'r' and 'B' carry their skins elsewhere ('B' on its
// design row) but read them through the very same woundStage call.
export const ZWOUND = {
a: ['z_aw'], b: ['z_bw'], d: ['z_dw'],
e: ['z_ew', 'z_ew2'],
h: ['z_hw', 'z_hw2', 'z_hw3'],
g: ['z_gw', 'z_gw2', 'z_gw3', 'z_gw4'],
k: ['z_kw', 'z_kw2', 'z_kw3', 'z_kw4'],
};
// ---- THE ARSENAL. One table, keyed by id, replacing the five places the roster used to be spelled
// out (SLOTS, slotStock, WDESC, drawRack's three parallel arrays, and deliverTin's index mapping).
// The rack no longer has a fixed weapon per slot: G.rack holds up to four of these in the order you
// GATHERED them, and slot 0 stays BALL. A weapon is defined entirely by its row.
// kind 'serve' — arms the next serve: you aim and release it like a normal ball
// kind 'burst' — commits to a braced window that FIRES ITSELF: `len` seconds at `rate` rounds/s
// There is no third kind. A tap-per-round weapon shipped once and Oliver read it as broken —
// "the laser fired only when I tapped, that was confusing" — so a barrel weapon is now always one
// of exactly two things: the normal ball gesture made spectacular, or something that fires on its
// own for a while. Nothing in between.
// The pellet fields (r, dmg, hp, pierce, boom, ignite, arc, trail, col, speed, life) are stamped
// onto the round by firePellet, so every auto-firing weapon is the ONE pellet loop with different
// numbers — `arc` is what turns a flat round into a lobbed one.
export const WEAPONS = {
bomb: { label: 'BOMB', icon: 'w_bomb', tin: 3, kind: 'serve',
desc: ['DETONATES ON CONTACT', 'CLEARS A WHOLE CROWD AT ONCE'] },
triple: { label: 'TRIPLE', icon: 'w_triple', tin: 10, kind: 'serve', // was 3 — Oliver asked for ten
desc: ['ONE SERVE, THREE BALLS', 'FANNED WIDE ACROSS THE LANE'] },
fire: { label: 'FIRE', icon: 'w_fire', tin: 3, kind: 'serve',
desc: ['KILLS ON ANY TOUCH', 'AND SETS THE OTHERS ALIGHT'] },
gun: { label: 'GUN', icon: 'w_gun', tin: 1, kind: 'burst', len: 8, rate: 12,
r: 0.5, dmg: 0.34, hp: 2, speed: 26,
desc: ['HOLD THE LINE AND SPRAY', 'A BURST, NOT A SERVE'] },
// ---- the four that come out of a barrel. Won by shooting one, never delivered.
// A LOBBED tennis ball twice the weight of yours, thrown on its own beat, changing colour every
// shot and dragging its colour behind it. The showiest thing in the game and the one you meet first.
// rate was 1.6: about one lob every two thirds of a second, which is a long time to stand still
// late on. The abort in selectWeapon is the real answer to "I picked the wrong tin", but a lob
// cannon you cannot leave AND cannot feel working was two problems, so it throws faster now.
beacon: { label: 'BEACON', icon: 'w_beacon', tin: 1, kind: 'burst', len: 11, rate: 2.4,
r: 1.2, dmg: 2, hp: 4, arc: 1, hue: 1, trail: 1, trailR: 0.035, glow: 3.2, speed: 19,
sfx: 'thump', // it LOBS — a soft low thump as it goes, not a rifle crack
desc: ['IT THROWS ITSELF', 'TWICE THE BALL, EVERY COLOUR'] },
// "make this one way more pronounced": twice the rounds a second, a wider and longer cone, fat
// glowing rounds that throw their own embers, and every one of them lights what it touches.
// It REACHES THE FAR BASELINE (life 1.15 s x 23 m/s = ~26 m), it PIERCES, and it does no impact
// damage at all: dmg/hp 0. Fire is not a bullet — being hit sets you alight and the burn does the
// killing, so a lit crowd walks a few steps and goes down together. That is the whole weapon.
// trail 2 -> 4 and a WIDE trailR: an ignite round throws fat drifting embers rather than the
// beacon's one-pixel thread (see the pe.ignite branch in the trail block), so the stream reads as
// a body of fire with edges instead of a line of dots. glow 2 -> 2.8 to match.
flamer: { label: 'FLAMER', icon: 'w_flamer', tin: 1, kind: 'burst', len: 6, rate: 30,
r: 1.15, dmg: 0, hp: 0, pierce: true, speed: 23, life: 1.15, ignite: true, igniteR: 2.6,
trail: 4, trailR: 0.34, col: '#ff8420', glow: 2.8, spread: 3.1, sfx: 'flame',
desc: ['A WALL OF FIRE', 'AND THE FIRE SPREADS'] },
// The normal ball gesture, made spectacular: pull, aim, release — and instead of a ball you get a
// blue plane that widens down the court and cuts through everything standing in it.
laser: { label: 'LASER', icon: 'w_laser', tin: 6, kind: 'serve', beam: 1,
// NOT magenta: the icon art is chroma-keyed and the key eats every pink/purple
// (min(r,b) > g+40), so a magenta laser came back as a hollow outline. Pink stays
// the BARREL's identity, where it is a procedural glow and never goes near the key.
col: '#3f74ff', // saturated: a pale blue washed out to grey over the lawn
desc: ['AIM AND RELEASE', 'A WIDENING WALL OF LIGHT'] },
// Lobbed, self-throwing, and it detonates like a bomb where it lands. Deliberately a LATE pickup
// (see pickBarrelWeapon) — Oliver: "best later in the game when there is really a lot going on".
rocket: { label: 'ROCKET', icon: 'w_rocket', tin: 1, kind: 'burst', len: 9, rate: 1.1,
r: 0.75, dmg: 3, hp: 9, arc: 1, boom: true, jet: 3, speed: 38, sfx: 'swoosh',
col: '#ffd050', glow: 1.8,
desc: ['IT LAUNCHES ITSELF', 'AND EVERY ONE IS A BOMB'] },
};
// Every row knows its own key. The rack has always looked weapons up BY key and passed the row
// around after that, so nothing needed the id — the stats screen does, because a kill has to be
// filed under the weapon that made it long after the row itself is out of scope.
for (const k in WEAPONS) WEAPONS[k].id = k;
// What a pink barrel can be carrying — the four above, which you cannot win any other way. NOT a
// flat 1-in-4: on that, Oliver "got the laser almost every time" and never once saw the beacon.
// Weighted, gated, and it never hands you the same thing twice running (see pickBarrelWeapon).
// weight relative chance once it is unlocked
// from games finished before it can appear at all
export const BARREL_WEAPONS = {
beacon: { weight: 4, from: 0 }, // the showpiece, and the one you should meet first
flamer: { weight: 3, from: 2 },
laser: { weight: 2, from: 6 },
rocket: { weight: 3, from: 12 }, // the late-game answer: it should never turn up in game three
};
// Gathered weapons the rack holds (plus slot 0 = BALL, so ten tiles). Was 4, and at four you were
// throwing away a barrel almost every time one popped — Oliver: "that makes replacing the ball rarer
// if not unnecessary at all". The `temp` valve below survives for the eleventh, which nobody will see.
export const RACK_MAX = 9;
export const RACK_ROW = 5; // tiles per row — past five the rack grows a SECOND row upward, so a tile
// never shrinks below the thumb target rackHit was widened for
export const MACH_H = 1.55; // was 1.15 — waist high read as a prop you could ignore. Still furniture
// (a body is PLAYER_H 1.88), but big enough to be a thing on the court.
// Drawn height of a body on the far side, by variant. render draws to it AND the serve aims at a
// fraction of it, so the two cannot drift — a shot at a brute has to arrive at the brute's chest,
// not at the height a shambler's chest happens to be.
export const zombieH = zb => zb.variant === 'o' ? TUNE.barrelH
: zb.variant === 'g' ? 2.7 : zb.variant === 'd' ? 2.15
: zb.variant === 'k' ? 3.2 : zb.variant === 'h' ? 2.4
: zb.variant === 'r' ? 1.85 : zb.variant === 'm' ? MACH_H
: zb.variant === 'B' ? 1.8 * (zb.scale || 1.5) : 1.8;
// Where on that body the ball is aimed, and where the aim mark is drawn. Solving the shot to the
// GROUND under a shambler meant the ball came down past it and struck at the ankles — the wound is
// on the torso, so the ball should arrive there.
export const Z_CHEST = 0.58;
export const BOY_H = 1.5; // was 1.2 — the mopping boy read as a distant speck at that scale
// how far off-court a boy starts. The mop boy comes in from just off-screen (he is slow, and
// waiting 8s for him to walk into shot is dead time); a fetching boy sprints in from further out.
export const BOY_ENTER = { mop: 9.5, run: 13 };
export const UMP_H = 3.0;
// The chair is 1.12 m wide as drawn (x_ump is 42x112 at UMP_H 3.0), so at 5.6 its near legs stood
// 0.45 m INSIDE the doubles line, on the court. 6.05 puts them exactly on the line; anything much
// beyond ~6.3 clips the right edge of the portrait viewport (7.1 was fully off-screen).
export const UMP_X = 6.05;
// contact rules: outside this height band the ball can't be hit (below = it's past you)
export const CONTACT = { minY: 0.24, maxY: 2.3 };
// volleys (pre-bounce contact) get extra forgiveness: the ball passes at full speed and
// there's no bounce cue to time against, so reach and swing window widen by these factors
export const VOLLEY = { reach: 1.4, window: 1.8 };
// how deep from the far/near baseline a player's interception zone extends
export const ZONE_DEPTH = 9.5;
// recovery positions between shots
export const HOME = { P: { x: 0, z: 22.8 }, O: { x: 0, z: 2.5 } };
// where the near player is allowed to move
export const P_BOUNDS = { x: 6.5, zMin: COURT.netZ + 1.2, zMax: 26 };
// serve toss: release height and upward speed (apex derives from these + gravity)
export const TOSS = { y0: 1.8, vy: 4.6 };
// shot target depth from the receiving baseline, by aim (up = deep, none = mid, down = short)
export const SHOT_DEPTH = { deep: 3.0, mid: 5.4, short: 8.6 };
// how long swing/serve poses hold before returning to idle (s)
export const POSE_TIME = 0.38;
// the server's idle ball bounce: sideways offset to the free hand, apex height, tempo.
// tempo drives the ball, the hand sprite AND the pock sound — one value, always in sync.
// Measured off the sprite, not guessed: the player is RIGHT-handed, so he pats the ball with his
// LEFT hand — the left-hand side of the image — whose palm sits ~0.69 m above his feet and ~0.36 m
// off his centre line. The ball tops out just under that hand instead of beside it.
// dx/apex are per-man because the two sprites reach differently, and a single pair put the
// opponent's ball out by his racket instead of under his palm. Both are FEEL KNOBS measured off the
// drawn palm by eye — a skin-detector guess at the hand lands on a knee, so tune these by looking.
export const BOUNCE_IDLE = { dx: 0.36, apex: 0.68, tempo: 3.6, // the player, drawn from behind
oDx: 0.45, oApex: 0.55 }; // the opponent, drawn from the front
// generated sprite sheets: the idle pose is this many pixels tall; all scales derive from it
export const SPRITE_H = 224; // 2x remaster (was 112). RES is 3, so the player at the baseline
// rasterises into ~170 DEVICE pixels — a 112px source was being UPSCALED
// 1.5x, which is why near sprites looked soft and mushy. Every sprite that
// goes through drawChar/drawBoy/the wound-stage height rule must share this
// scale, so unmatched sprites were 2x-upscaled rather than left at 112.
+6
View File
@@ -0,0 +1,6 @@
// Canvas handles — imported by everyone, imports nothing (keeps module cycles harmless).
export const cv = document.getElementById('c');
// alpha:false — render() clears the whole frame to an opaque colour anyway, so declaring the canvas
// opaque lets the compositor skip blending a 2.5-megapixel layer 60 times a second (battery, not
// looks). desynchronized is a further no-op-where-unsupported compositor shortcut (Chrome).
export const ctx = cv.getContext('2d', { alpha: false, desynchronized: true });
+1497
View File
File diff suppressed because it is too large. Load diff
+213
View File
@@ -0,0 +1,213 @@
// Keyboard + one-handed touch. Exposes movement axes and the flick-aim for gameplay.
// Note: input <-> gameplay is an intentional import cycle; both only call each other's
// functions at runtime, never during module evaluation.
import { cv } from './dom.js';
import { audio, pock } from './audio.js';
import { TUNE, WEAPONS } from './config.js';
import { G, score } from './state.js';
import { keyPressed, dragServe, selectWeapon, serveToss, pullQuality, pullPerfect, dismissModal, slotId, dodge } from './gameplay.js';
import { rackHit } from './render.js';
import { togglePanel } from './panel.js';
const keys = {};
addEventListener('keydown', e => {
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(e.key)) e.preventDefault();
if (dismissModal()) return; // a modal eats the whole keystroke — dismissing must never also serve
if (e.key === 't' || e.key === 'T') togglePanel();
if (e.key >= '0' && e.key <= '9') selectWeapon((+e.key + 9) % 10); // pick a rack slot: 1..9 then 0 = the tenth
if (!keys[e.code]) keyPressed(e.code);
keys[e.code] = true;
});
addEventListener('keyup', e => keys[e.code] = false);
const touchAxis = { x: 0, z: 0 };
export const axisX = () => touchAxis.x || (keys.ArrowRight || keys.KeyD ? 1 : 0) - (keys.ArrowLeft || keys.KeyA ? 1 : 0);
export const axisZ = () => touchAxis.z || (keys.ArrowUp || keys.KeyW ? -1 : 0) + (keys.ArrowDown || keys.KeyS ? 1 : 0);
// One-handed touch: hold-drag = run, quick FLICK = swing aimed in the flick direction,
// plain tap = neutral swing / toss / serve. Extra finger tap still swings (two-thumb play).
let stick = null; // {id, x0, y0, t0, moved}
let flickAim = null; // {ax, az, t} aim override for the swing just triggered
const DEAD = 14;
// gameplay consumes the flick at contact; returns null if none or expired
export function takeFlickAim(maxAgeMs = 450) {
if (flickAim && performance.now() - flickAim.t < maxAgeMs) {
const aim = flickAim; // carries ax/az (lanes, tennis) AND fx/fy/mag (splatter free-aim)
flickAim = null;
return aim;
}
return null;
}
// the two-stage serve: a drag that STARTS at a serveable pretoss aims a visible reticle
// (G.aim, drawn by render); release fires the serve there, dragging back home cancels.
// EVERY phase of splatter play, so the drag never silently turns into something else. The old
// "drag walks the player" mode between shots was invisible state and read as a bug.
// a weapon you UNLOAD (burst or shot) rather than serve — it takes the tap directly, so the
// slingshot reticle must stay out of its way. Was `G.weapon !== 4` back when slot 4 was always GUN.
const isGunLike = () => WEAPONS[slotId(G.weapon)]?.kind === 'burst'; // a laser is SERVED, so it keeps the reticle
const canAimServe = () =>
!G.modal // the world is frozen behind a popup — the thumb that clears it must not draw a bow
&& G.mode === 'splatter' // every phase of it; 'eaten' is its own mode and never gets here
&& score.server === 'P' && !isGunLike() // a gun-like weapon pops on tap, no reticle
&& !G.interlude && !G.horde && !(G.mgT > 0) && !G.changeover; // the duel serves like tennis: no reticle
let steerPX = null; // live absolute finger x (-1..1 across the canvas) — machine-gun steering reads it
export const touchPX = () => steerPX;
// Restarting a run no longer reloads the page (see state.resetState), so this module's own state has
// to be put back by hand: a key still held, or a half-finished drag, would otherwise be inherited by
// the next run's very first frame.
export function resetInput() {
for (const k in keys) delete keys[k];
touchAxis.x = touchAxis.z = 0;
stick = flickAim = steerPX = spare = null;
}
// A LOST TOUCHEND LOCKS THE GAME, part two. visibilitychange/blur (below) only catches the case
// where the app goes away. The other one: a system edge gesture (Control Center on the right edge —
// exactly where the horde's absolute finger-steering parks your thumb) steals the finger and iOS
// delivers neither touchend nor touchcancel. `stick` then survives forever, so every later
// touchstart falls through to the extra-finger branch (swish, and only swish) while touchmove
// ignores the new finger's id — leaving steerPX frozen and the player welded to the screen edge.
// Any new touch event proves the old finger is gone if it isn't in e.touches. Drop it then.
const dropStale = e => {
if (stick && ![...e.touches].some(t => t.identifier === stick.id)) {
stick = null; touchAxis.x = touchAxis.z = 0; steerPX = null; G.aim = null;
}
};
// A LOST TOUCHEND LOCKS THE GAME, part three. dropStale above trusts e.touches — but iOS can
// steal a finger for an edge gesture WITHOUT tearing the touch down, and then WebKit lists the
// dead touch in e.touches forever, so dropStale never fires. (Proven in the field: the player's
// escape taps still swished, so touchstart ran dropStale and still found the id listed.) By data
// the zombie is indistinguishable from a real thumb parked still at the edge; by INTENT it isn't:
// a welded player puts a new finger down and DRAGS. So remember the newest extra finger, and if
// it drags past the deadzone while the stick has been silent over a second in an auto-fire mode
// (the only modes that steer by absolute finger x), the drag takes the stick. A parked thumb
// plus a melee tap stays legit two-thumb play — a tap doesn't drag, so it steals nothing.
let spare = null; // {id, x0, y0} — the newest extra finger, candidate to inherit a zombie stick
const STALE_MS = 1000;
const stickStolen = t =>
stick && spare && t.identifier === spare.id && (G.horde || G.mgT > 0)
&& performance.now() - stick.seen > STALE_MS
&& Math.hypot(t.clientX - spare.x0, t.clientY - spare.y0) > DEAD;
cv.addEventListener('touchstart', e => {
e.preventDefault(); audio(); dropStale(e);
const r = cv.getBoundingClientRect();
for (const t of e.changedTouches) {
if (dismissModal()) return; // the popup owns this gesture and nothing else sees it
// A TAP that lands NEAR the rack but on no tile is a miss at a tile, not a shot — Space here
// instantly armed whatever burst weapon was selected ("selecting the flamer activated the
// gun"). Only the TAP dies, decided at touchend: a drag or hold starting in the same spot is
// a deliberate gesture (burst steering parks the thumb low, serve pulls start anywhere) and
// must keep working, so the stick is still created.
let nearRack = false;
if (!G.horde) { // rack tap selects a tin — a burst can be aborted out of, a horde cannot (selectWeapon)
const slot = rackHit((t.clientX - r.left) / r.width, (t.clientY - r.top) / r.height);
if (slot >= 0) { selectWeapon(slot); continue; }
nearRack = slot === -2;
}
if (!stick) {
// `r` rides along: the canvas is position: fixed, so its rect only moves on viewport changes
// — re-measuring it per touchmove (60-120 Hz on iOS) was layout work for a constant answer
stick = { id: t.identifier, x0: t.clientX, y0: t.clientY, t0: performance.now(), r,
seen: performance.now(), nearRack,
moved: false, aimable: canAimServe() }; // context locked at touch start — a movement drag never morphs into a serve
// the reticle appears the moment the thumb lands, but mag 0 aims at nothing — you have to pull
if (stick.aimable) G.aim = { fx: 0, fy: 0, mag: 0 };
else steerPX = (t.clientX - r.left) / r.width * 2 - 1;
}
else { spare = { id: t.identifier, x0: t.clientX, y0: t.clientY }; if (!nearRack) keyPressed('Space'); }
}
}, { passive: false });
cv.addEventListener('touchmove', e => {
e.preventDefault(); dropStale(e); // unpins the player as soon as any finger moves; the next touchstart takes the stick
if (!stick) return;
for (const t of e.changedTouches) {
if (stickStolen(t)) // the zombie stick loses to a finger that provably wants to steer
stick = { id: t.identifier, x0: t.clientX, y0: t.clientY, t0: performance.now(),
r: stick.r, seen: performance.now(), moved: true, adopted: true, aimable: false };
if (t.identifier !== stick.id) continue;
stick.seen = performance.now();
const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0, dist = Math.hypot(dx, dy);
if (dist > DEAD) stick.moved = true;
if (stick.aimable) { // slingshot: PULL BACK, launch forward — the shot is the inverse of the drag
// WHICH WAY YOU LEFT THE DEADZONE DECIDES WHAT THE GESTURE IS, once, and it never changes its
// mind after. Down-ish is the pull the game already teaches; SIDEWAYS is the sidestep — the
// only way to move in the serve stance, which is where a whole boss fight is spent. No timing
// window and no invisible mode: the direction you moved is the thing you asked for.
if (dist > DEAD && !stick.tossed && !stick.dodged && Math.abs(dx) > Math.abs(dy) * 1.6) {
stick.dodged = true; G.aim = null; dodge(Math.sign(dx));
}
if (stick.dodged) continue; // spent: no toss, no reticle, and nothing fires on release
if (dist > DEAD) {
if (!stick.tossed) { serveToss(); stick.tossed = true; } // the pull kicks off the toss animation
G.aim = { fx: -dx / dist, fy: -dy / dist, mag: dist };
// the moment the pull is strong enough to actually splatter, it clicks — you can feel
// where the threshold is without watching the reticle
const hot = pullQuality(dist) >= TUNE.zSplatQ;
if (hot !== !!stick.hot) { stick.hot = hot; if (hot) pock(0.45, 1250); }
// ...and a second, brighter click at the top of the draw, where the shot is praised. It
// goes SILENT again when you pull past the band, so overshooting is something you hear.
// Without this the perfect window would be invisible skill — a target nothing marks.
const sweet = pullPerfect(dist);
if (sweet !== !!stick.sweet) { stick.sweet = sweet; if (sweet) pock(0.5, 1950); }
} else G.aim = { fx: 0, fy: 0, mag: 0 };
continue;
}
const r = stick.r;
steerPX = (t.clientX - r.left) / r.width * 2 - 1;
touchAxis.x = Math.abs(dx) > DEAD ? Math.sign(dx) : 0;
touchAxis.z = Math.abs(dy) > DEAD ? Math.sign(dy) : 0;
}
}, { passive: false });
const endTouch = e => {
e.preventDefault();
for (const t of e.changedTouches) {
if (!stick || t.identifier !== stick.id) continue;
const dt = performance.now() - stick.t0;
const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0, dist = Math.hypot(dx, dy);
const aim = G.aim; G.aim = null; steerPX = null;
if (stick.aimable && stick.moved) { // slingshot release
// only a real pull fires — walked back under pullMin, it cancels and nothing is spent.
// (a bare hold used to fire at a free auto-target; that was the whole exploit)
if (aim && dist >= TUNE.pullMin) dragServe(aim);
}
else if (!stick.moved && dt < 250) { if (!stick.nearRack) keyPressed('Space'); } // tap — dead if it was a near-miss at the rack
else if (!stick.adopted && dt < 300 && dist > 28) { // flick = aimed swing — a rescue drag is never a swing
flickAim = { ax: Math.abs(dx) > dist * 0.38 ? Math.sign(dx) : 0,
az: Math.abs(dy) > dist * 0.38 ? Math.sign(dy) : 0,
fx: dx / dist, fy: dy / dist, mag: dist, // raw vector: splatter free-aim
t: performance.now() };
keyPressed('Space');
}
stick = null; touchAxis.x = 0; touchAxis.z = 0;
}
};
cv.addEventListener('touchend', endTouch, { passive: false });
cv.addEventListener('touchcancel', endTouch, { passive: false });
// A DRAG THAT NEVER ENDS LOCKS THE GAME. `stick` and `G.aim` are only cleared by the touchend for
// the matching identifier — and that event is not guaranteed: switch apps, pull the notification
// shade, take a call, and iOS tears the touch down without always delivering touchend OR
// touchcancel. What is left behind is a held slingshot: `G.aim` set forever, which keeps the
// uncaught-toss retoss from firing (see the `!G.aim` guard in update) and leaves the player in the
// toss phase with the ball on the floor, unable to serve or step. Dropping the gesture whenever the
// page stops being visible costs nothing — you cannot be mid-pull while looking at another app.
const dropGesture = () => { resetInput(); G.aim = null; };
addEventListener('visibilitychange', () => { if (document.hidden) dropGesture(); });
addEventListener('blur', dropGesture);
addEventListener('pagehide', dropGesture);
// Mouse/desktop dismissal. Registered ONLY on fine pointers: on a touch screen pointerdown
// fires before touchstart, so a shared handler would clear the modal and let the touchstart
// below it fall through into a serve — exactly the swallowed-gesture bug.
if (!matchMedia('(pointer: coarse)').matches)
cv.addEventListener('pointerdown', () => dismissModal());
+386
View File
@@ -0,0 +1,386 @@
// Splatter Tennis — boot, main loop, intro overlay, and the window.T test hooks.
// World units: meters. z axis = court depth, 0 at FAR baseline, 23.77 at NEAR baseline.
// Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update)
// render (camera/drawing) · input (keys/touch) · audio (sfx/umpire) · panel (T tuning UI)
import { TUNE, COURTS, TOURS, EATEN_RESTART, WEAPONS, bossArt } from './config.js';
import { P, O, ball, score, G, setupPoint, selectCourt, selectTour } from './state.js';
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, toggleTestMode } from './gameplay.js';
import { startDuel, resolveInterlude, startBossFight, endBossFight } from './beats.js';
import { fitViewport, render, proj, RES, SPRV, LOAD } from './render.js';
import { voice, ambient, menuMusic, menuDuck } from './audio.js';
import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js';
import { cv } from './dom.js';
import * as stats from './stats.js';
import './input.js';
let last = performance.now();
// T.pause(true) freezes the WORLD but keeps drawing, so a value changed in devtools shows up on the
// next frame without the scene moving on underneath you. Halting rAF instead (the old test recipe)
// freezes the picture too, which is useless for looking at a shot.
let paused = false;
// CALM STATES RUN SLOW. The battery bill is fill-rate × frame-rate, and two screens can sit on a
// phone for minutes while fully static: a SETTLED modal (the world is frozen behind it — only the
// blink still moves) and the death summary once its fade and gore have played out. 20-odd fps
// there is invisible and thirds the drain; live play stays at TUNE.fps, untouched — the 60 was a
// feel decision and this deliberately never applies to anything that moves.
const calmFps = () => G.modal && G.modal.in >= 1 ? 20
: G.mode === 'eaten' && G.eatenT > EATEN_RESTART + 1 ? 24 : 0;
let lowPowerApplied = false; // the last lowPower state fitViewport was run under (T-panel flips it live)
function frame(now) {
requestAnimationFrame(frame); // schedule FIRST: one bad frame must never kill the loop (a thrown error froze the game for good)
if ((TUNE.lowPower >= 0.5) !== lowPowerApplied) { lowPowerApplied = TUNE.lowPower >= 0.5; fitViewport(); }
const cap = calmFps() || TUNE.fps;
if (cap && now - last < 1000 / cap - 2) return; // frame cap (battery): skip RAF ticks until it's our beat
// THE MENU IS OPAQUE (#intro: fixed, inset 0, solid background). Painting the whole court at RES
// density behind it — for the minutes a phone idles on the splash and stats screens — was pure
// battery burn nobody could see. The world holds still too; nothing on it predates the menu.
if (menuUp()) { last = now; return; }
if (paused) { last = now; render(); return; }
let dt = Math.min(0.04, (now - last) / 1000); last = now;
// A chapter boss dies in slow motion. The clock that ENDS it runs at real speed, so the effect is
// always the same length in seconds however far the world has been slowed.
if (G.slowT > 0) { G.slowT -= dt; dt *= TUNE.bossSlowMo; }
update(dt); render();
}
fitViewport();
// iOS PWA cold start. Launched from the homescreen, WebKit resolves the viewport AND every env()
// safe-area inset once, against a provisional geometry, and never revisits them until the geometry
// is "exercised" — which is why the game only filled the screen after a rotate and back, and why
// waiting for an event could never work: nothing fires. Toggling viewport-fit off and on is the
// known WebKit workaround: it forces that recalculation without the user rotating anything.
// Standalone only, because that is the only mode with the bug.
if (navigator.standalone || matchMedia('(display-mode: standalone)').matches) {
const meta = document.querySelector('meta[name=viewport]'), real = meta.content;
meta.content = real.replace('viewport-fit=cover', 'viewport-fit=auto');
requestAnimationFrame(() => { meta.content = real; requestAnimationFrame(fitViewport); });
}
setupPoint();
// warm both faces so the canvas never paints a system-serif fallback frame
if (document.fonts) for (const f of ['700 8px Silkscreen', '400 8px Silkscreen', '400 20px Bangers'])
document.fonts.load(f);
// The overlay is a four-stage machine: splash -> {setup, stats, ranks}. The title screen IS the
// menu — a separate menu stage behind it showed the same logo on the same backdrop and read as the
// title screen failing to advance. One overlay throughout, swapped by data-stage, so the court never
// flashes between screens; HIDDEN during play rather than removed, so a restart puts it back.
const intro = document.getElementById('intro');
const menuUp = () => !intro.hidden;
const remembered = k => { try { return localStorage.getItem(k); } catch { return null; } };
let picked = remembered('court') || COURTS[0].id;
let pickedTour = remembered('tour') || TOURS[0].id;
const go = stage => { intro.dataset.stage = stage; menuMusic(); }; // every stage change is a gesture, so it is also a safe place to (re)start the theme
// --- tour tiles, built from the table. Same "tap selects, SERVE UP starts" rule as the court rows —
// the setup screen picks WHO and WHERE, and nothing on it commits you.
const tourList = document.getElementById('tours');
for (const t of TOURS) {
const b = document.createElement('button');
b.className = t.id === pickedTour ? 'on' : '';
b.innerHTML = `<img src="assets/${t.tile}.png?v=${SPRV}" alt=""><b>${t.name}</b>`;
b.addEventListener('click', () => {
pickedTour = t.id;
for (const el of tourList.children) el.classList.toggle('on', el === b);
summary();
});
tourList.appendChild(b);
}
// --- court rows, built from the table.
// The two meters come out of the court's OWN bounce numbers rather than a pair of hand-written
// ratings, so a physics change moves the bars with it and the screen can never advertise a court
// that plays differently from what it says.
// SPEED how much pace survives the bounce — bounceXZ, grass .84 fastest, clay .70 slowest
// BOUNCE how much of it comes back UP — bounceY, clay .66 highest, grass .50 lowest
// The two divisors just stretch those narrow real ranges across a full bar; they carry no physics.
const meter = (label, v, cls = '') =>
`<div class="meter ${cls}"><u>${label}</u><s><i style="width:${
Math.round(Math.min(1, Math.max(0.08, v)) * 100)}%"></i></s></div>`;
const courtList = document.getElementById('courtList');
for (const c of COURTS) {
const b = document.createElement('button');
b.className = 'court' + (c.id === picked ? ' on' : ''); // your last court comes up pre-picked
b.innerHTML = `<img src="assets/${c.tile}.png?v=${SPRV}" alt=""><span class="cb">`
+ `<b>${c.name}</b><i>${c.sub}</i>`
+ meter('SPEED', (c.bounceXZ - 0.62) / 0.26)
+ meter('BOUNCE', (c.bounceY - 0.44) / 0.28, 'b')
+ '</span>';
// Tapping a tile SELECTS it and nothing more. It used to start the match on the spot, which gave
// you no way to look at the three of them and no way back out of the screen.
b.addEventListener('click', () => {
picked = c.id;
for (const el of courtList.children) el.classList.toggle('on', el === b);
summary();
});
courtList.appendChild(b);
}
// what the setup footer says you are about to play, and what it is worth beating
function summary() {
const t = TOURS.find(x => x.id === pickedTour) || TOURS[0];
const c = COURTS.find(x => x.id === picked) || COURTS[0];
const best = stats.total().best;
document.getElementById('sSum').innerHTML =
`<span>${t.name.replace(' SINGLES', '')} · ${c.name}</span>`
+ `<span>${best ? best + (best === 1 ? ' GAME' : ' GAMES') + ' TO BEAT' : 'FIRST RUN'}</span>`;
}
// The splash carries ONE number, on the STATS button. A boxed BEST RUN / WORLD RANK bar across the
// top came out of the mockup and went again: it sat on the best part of the art and said twice what
// the two buttons underneath already say. Re-read whenever the splash comes back up, because a run
// has just ended and the count has moved.
function refreshSplash() {
document.getElementById('mKills').textContent = stats.total().kills.toLocaleString('en-GB') + ' KILLS';
summary();
}
function start() {
if (!menuUp()) return;
selectTour(pickedTour);
selectCourt(picked);
menuDuck(true); // the theme belongs to the menus — hand the court over to the ambience
intro.hidden = true; ambient(); voice('quiet_please'); // low atmosphere bed starts with the first gesture
}
let stPeriod = 'all';
const openStats = () => { stats.drawStats(stPeriod, SPRV); go('stats'); };
document.getElementById('mStart').addEventListener('click', () => go('setup'));
document.getElementById('mStats').addEventListener('click', openStats);
document.getElementById('mRanks').addEventListener('click', () => { go('ranks'); stats.drawRanks(); });
// --- THE NAME PROMPT. It appears on exactly one occasion: a run that beat your own best kill
// count, on the tap that takes you off the death screen. Anything more often is a form standing
// between you and the next match.
const nbName = document.getElementById('nbName'), nbMsg = document.getElementById('nbMsg');
let nbRun = null;
function askName(run) {
nbRun = run;
document.getElementById('nbScore').innerHTML =
`<b>${run.kills.toLocaleString('en-GB')}</b> KILLS · <b>${run.games}</b> GAME${run.games === 1 ? '' : 'S'}`;
nbName.value = stats.savedName();
nbMsg.textContent = 'PUT IT ON THE WORLD BOARD.';
document.getElementById('nbSend').disabled = false;
go('newbest');
// focus only where a keyboard is already there. On a phone this would throw the soft keyboard up
// over the score you just earned, before you have read it.
if (!matchMedia('(pointer: coarse)').matches) nbName.focus();
}
document.getElementById('nbSkip').addEventListener('click', () => { nbRun = null; go('splash'); });
document.getElementById('nbSend').addEventListener('click', async () => {
const name = stats.tidyName(nbName.value);
if (!name) { nbMsg.textContent = 'A NAME FIRST.'; nbName.focus(); return; }
nbName.value = name; // show what is actually going on the board
if (!nbRun) return go('splash');
const btn = document.getElementById('nbSend');
btn.disabled = true; nbMsg.textContent = 'SENDING…'; // the double-tap posts twice otherwise
try {
const r = await stats.submit(name, nbRun.kills, nbRun.games);
nbRun = null;
go('ranks');
stats.drawRanks(r.scores); // the board came back with the reply — no second round trip
} catch (e) {
btn.disabled = false;
nbMsg.textContent = e.taken ? 'THAT NAME IS SPOKEN FOR. PICK ANOTHER.'
: 'THE BOARD DID NOT ANSWER. TRY AGAIN?';
if (e.taken) { nbName.select(); nbName.focus(); }
}
});
nbName.addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('nbSend').click(); });
document.getElementById('cStart').addEventListener('click', start);
for (const id of ['sBack', 'stBack', 'rkBack', 'scBack'])
document.getElementById(id).addEventListener('click', () => go('splash'));
document.getElementById('bsBack').addEventListener('click', () => go('secret'));
// ---- THE BACK ROOM: five taps in the TOP-LEFT CORNER of the splash (#hot, an invisible box in
// index.html), and nothing on the screen says so. It replaces the triple-tap on the in-play
// scoreboard, which sat on top of the miss-log tap and which the testteam kept opening by accident.
// The corner rather than the logo: on the way to PLAY MATCH your thumb crosses the middle of the
// splash all the time, and a hidden door you can open by accident is just a bug with a secret.
let logoTaps = [];
document.getElementById('hot').addEventListener('pointerdown', () => {
const now = performance.now();
logoTaps = logoTaps.filter(t => now - t < 2000);
logoTaps.push(now);
if (logoTaps.length >= 5) { logoTaps = []; go('secret'); }
});
// Both entries drop you into a REAL run rather than a special mode — same update loop, same
// scoring, same death — which is the only way a test tells you anything about the game.
// This is laserScene's trick: force the turn and then step the transition out in one go, because
// the cinematic is ten seconds you did not come here to watch.
function intoSplatter() {
if (G.mode === 'tennis') forceZombies();
for (let i = 0; i < 900 && G.mode !== 'splatter'; i++) update(1 / 60);
G.modal = null; G.horde = null; G.interlude = false; G.changeover = 0; G.clearing = false;
G.mgT = 0; G.gunId = null; G.beam = null; G.hp = G.hpMax = TUNE.playerHp;
zombies.length = 0; shots.length = 0; pellets.length = 0;
score.server = 'P'; setupPoint();
}
document.getElementById('scWeapons').addEventListener('click', () => {
start(); intoSplatter(); toggleTestMode();
});
document.getElementById('scBosses').addEventListener('click', () => go('bosses'));
// The list is built by ASKING bossArt which design each chapter gets, so it can never drift from
// what the run actually spawns — including the champion sitting on chapter 10 rather than 7.
const bossList = document.getElementById('bossList');
for (let ch = 1; ch <= 10; ch++) {
const art = bossArt(ch), b = document.createElement('button');
b.innerHTML = `<img src="assets/${art.walk[0]}.png?v=${SPRV}" alt="">`
+ `<b>${art.name}</b><i>CHAPTER ${ch} · GAME ${ch * TUNE.bossEvery}</i>`;
b.addEventListener('click', () => {
start(); intoSplatter();
G.runGames = ch * TUNE.bossEvery; // the roster, the HUD and the spawn ramp all read this one
G.bossDue = 0; // ...and the schedule must not queue a SECOND fight behind it
for (const id of Object.keys(WEAPONS)) grantWeapon(id, TUNE.wCap); // full arsenal, every tin
G.weapon = 0;
startBossFight(ch);
});
bossList.appendChild(b);
}
// the three period tabs are one listener on the strip — the button carries the period in data-p
document.getElementById('stTabs').addEventListener('click', e => {
const b = e.target.closest('button'); if (!b) return;
stPeriod = b.dataset.p;
for (const el of e.currentTarget.children) el.classList.toggle('on', el === b);
stats.drawStats(stPeriod, SPRV);
});
refreshSplash();
// THE BOOT GATE. render.js fires all ~230 sprite requests at import; this only watches them and
// keeps the splash's buttons out of reach until the last one has landed and both faces are ready.
// Without it a cold start could open the stats screen — which hangs <img> tags on the same files —
// or start a rally while half the sheets were still in flight, and the missing ones drew as
// whatever the fallback was. onerror counts too (render.js), so a dead network still finishes the
// bar rather than parking the player on it forever.
const splash = document.getElementById('splash'), bootFill = document.querySelector('#boot i');
splash.classList.add('busy');
const loaded = () => !splash.classList.contains('busy');
// setInterval, NOT requestAnimationFrame: rAF does not fire in a hidden tab, and a phone that
// launches the app and locks — or an install that loads in the background — would come back to a
// bar frozen at whatever it reached and a menu with no buttons on it. An interval still ticks
// (throttled to about a second), so the gate always opens.
const tick = setInterval(function watchLoad() {
const frac = LOAD.total ? LOAD.done / LOAD.total : 1;
bootFill.style.width = (frac * 100).toFixed(1) + '%';
if (frac < 1) return;
clearInterval(tick);
// fonts.ready resolves once nothing is pending — including the three faces warmed above — so the
// first canvas frame is never the system serif.
(document.fonts ? document.fonts.ready : Promise.resolve()).then(() => splash.classList.remove('busy'));
}, 100);
// THE FIRST TAP ANYWHERE ON THE OVERLAY STARTS THE THEME, and does nothing else — the screens have
// real buttons now. This is the gesture the autoplay policy is waiting for, which is why the theme
// is not started at boot. menuMusic itself is a no-op once the bed exists (loopBed's beds.has
// guard), so coming back from a run picks up the track already playing rather than restarting it.
intro.addEventListener('pointerdown', () => menuMusic());
// which build am I looking at? Push = deploy and the browser cannot see git, so the served file's
// own timestamp IS the version marker — it changes on every deploy, with nothing to remember to bump.
// Match it against `git log -1 --format=%cd` when a phone looks suspiciously out of date.
fetch('src/main.js', { method: 'HEAD' }) // page-relative: index.html sits at the root, the code in src/
.then(r => { const t = r.headers.get('last-modified'); if (!t) return;
document.getElementById('build').textContent = 'BUILD ' + new Date(t).toLocaleString('en-GB').replace(',', ''); })
.catch(() => {}); // no header, no line — never a broken start screen
// Keyboard, per stage. On the splash Enter/Space goes to setup and S opens the stats; on the setup
// screen 1..N picks a court and Enter starts; Escape backs out to the splash from anywhere.
addEventListener('keydown', e => {
if (!menuUp()) return;
menuMusic(); // a key is a gesture too — unlock the theme on a desktop that never taps
const stage = intro.dataset.stage;
if (e.key === 'Escape') return go('splash');
if (stage === 'splash') {
if (!loaded()) return; // the boot bar hides the buttons; a key must not walk past it
if (e.key === 'Enter' || e.key === ' ') go('setup');
else if (e.key === 's' || e.key === 'S') openStats();
return;
}
if (stage !== 'setup') return; // stats and ranks are read-only: Escape is the whole interface
const n = parseInt(e.key, 10);
if (n >= 1 && n <= COURTS.length) courtList.children[n - 1].click();
else if (e.key === 'Enter' || e.key === ' ') start();
});
// A run the player walks out on is still a run. killPlayer banks a death; this banks everything
// else — the tab closed mid-match, the phone's browser killed in the background. `pagehide` is the
// one that actually fires on iOS, where beforeunload/unload do not. endRun is idempotent and
// no-ops on an empty run, so closing the tab at the menu writes nothing.
addEventListener('pagehide', () => {
if (G.mode === 'splatter') stats.endRun(G.runGames, G.sets[0], G.splatT, false);
});
// eaten: any tap or key returns to the title — but only once the death animation has played out, so
// a panic tap during the gore burst can't skip it. A modal owns its own gesture (see input.js).
// NOT a page reload any more: that reset everything honestly but also tore down the AudioContext, so
// the theme restarted from silence. restartRun puts every bag back from a pristine copy, and the
// bed that has been playing under the death summary simply carries on into the title screen.
const maybeRestart = () => {
if (G.modal || G.mode !== 'eaten' || G.eatenT <= EATEN_RESTART) return;
restartRun();
refreshSplash(); // the run just ended: the splash count has moved
intro.hidden = false;
// stats.lastRun() survives restartRun on purpose — it lives in the stats module, not on G, which
// has just been restored from PRISTINE. Consumed on read, so the prompt appears once.
const run = stats.lastRun();
if (run && run.newKillBest && run.kills > 0) askName(run);
else go('splash');
};
cv.addEventListener('pointerdown', maybeRestart);
addEventListener('keydown', maybeRestart);
// A tap on the SCOREBOARD toggles the miss-log overlay, and that is now all it does. The triple-tap
// that opened test mode has moved to the back room on the splash (see below): a cheat you can trip
// by accident mid-run is a bug, and this one sat on the one thing you tap to read the log.
cv.addEventListener('pointerdown', e => {
const r = cv.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width * (cv.width / RES); // logical coords
const y = (e.clientY - r.top) / r.height * (cv.height / RES);
if (x >= 6 && x <= 138 && y >= 6 && y <= 36) G.logShow = !G.logShow; // the scoreboard
});
// ---- A POSED SCENE for tuning a shot: `T.laserScene()`, or load with #laser. Six bodies of
// different types at known depths, the laser armed, a pull held.
// IT DOES NOT PAUSE, deliberately. The first version froze the world so the picture would hold
// still — and then every TUNE value you changed did nothing visible, because nothing redrew. The
// bodies are held still individually (v = 0) and spawning is off instead, so the picture is stable
// while the render loop keeps running and an edit shows on the very next frame.
function laserScene() {
const intro = document.getElementById('intro');
if (intro) intro.hidden = true;
paused = false;
if (G.mode === 'tennis') forceZombies();
for (let i = 0; i < 600; i++) update(1 / 60); // ride the transition out into splatter
G.modal = null; G.horde = null; G.interlude = false; G.changeover = 0; G.clearing = false;
G.mgT = 0; G.gunId = null; G.beam = null; G.bossFight = 0; G.hp = G.hpMax = TUNE.playerHp;
zombies.length = 0; pellets.length = 0; parts.length = 0; gore.length = 0; piles.length = 0;
score.server = 'P'; setupPoint();
P.x = 0;
TUNE.zMax = 0; // nothing new wanders into the shot
for (const [x, z, v] of [[0, 20, 'a'], [0.75, 16, 'b'], [-0.85, 12, 'k'],
[0.85, 8.5, 'g'], [-0.75, 5.5, 'h'], [0, 3, 'e']]) {
spawnZombie(x, z, v);
zombies[zombies.length - 1].v = 0; // held still: the scene is a photograph
}
G.rack.length = 0; G.temp = null;
G.weapon = grantWeapon('laser');
G.modal = null;
G.aim = { fx: 0, fy: -1, mag: 100 }; // a full pull straight up-court (fy is NEGATIVE)
return 'posed and LIVE — change TUNE (T panel or console) and it redraws. T.laserFire() to shoot it.';
}
// fire the posed shot, then pose it again so you can keep tuning
function laserFire() {
G.aim = null; score.server = 'P'; setupPoint();
keyPressed('Space');
return 'fired — T.laserScene() to re-pose';
}
// a plain timer, not the load event: this module runs as a module, so `load` may already have gone
if (location.hash === '#laser') setTimeout(laserScene, 600);
requestAnimationFrame(frame);
// exposed for in-browser sanity checks and simulated playtests
window.T = { TUNE, ball, P, O, score, G, zombies, laserScene, laserFire, pause: v => { paused = v !== false; }, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, spawnBarrel, grantWeapon, toggleTestMode, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight,
stats, step: dt => update(dt), render, press: keyPressed,
get phase() { return G.phase; }, set phase(v) { G.phase = v; } };
+18
View File
@@ -0,0 +1,18 @@
// Live tuning panel (T key): number inputs for every TUNE value.
import { TUNE } from './config.js';
let panel = null;
export function togglePanel() {
if (panel) { panel.remove(); panel = null; return; }
panel = document.createElement('div');
panel.style.cssText = 'position:fixed;top:8px;right:8px;background:#141a26;color:#cdd6e4;font:11px monospace;padding:8px;border:1px solid #3a4a66;max-height:90vh;overflow:auto';
for (const k of Object.keys(TUNE)) {
const row = document.createElement('div');
const v = TUNE[k];
row.innerHTML = `<label>${k} <input type="number" step="${v < 2 ? 0.02 : 1}" value="${v}" style="width:60px;background:#0c1018;color:#fff;border:1px solid #333"></label>`;
row.querySelector('input').oninput = e => TUNE[k] = parseFloat(e.target.value) || 0;
panel.appendChild(row);
}
document.body.appendChild(panel);
}
+2043
View File
File diff suppressed because it is too large. Load diff
+190
View File
@@ -0,0 +1,190 @@
// All mutable game state in one place, plus the point-reset that re-arms it.
import { HOME, BOY_ENTER, TUNE, COURT, COURTS, TOURS } from './config.js';
import { gore } from './zombies.js'; // runtime-only cycle (zombies imports state): sendBoy reads gore
// to decide whether a mopping round is worth walking out for
export const P = { x: 1.3, z: 22.8, side: 'P', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 };
export const O = { x: -1.3, z: 1.0, side: 'O', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 };
export const ball = { x: 0, y: 1, z: 20, vx: 0, vy: 0, vz: 0, g: 9.2,
active: false, lastHit: 'P', bounces: 0, collected: false, kills: 0, bomb: false, flame: false,
praise: null, // a perfect serve ARMS its praise word here; it pops at the impact, not the release
// which weapon launched the ball in flight — the stats screen files a kill under it long after
// the WEAPONS row itself is out of scope. Lives on the ball so PRISTINE resets it for free.
wid: 'ball' };
export const score = { pts: [0, 0], games: [0, 0], server: 'P' }; // pts index 0=P 1=O
export const crowd = { cheer: 0, dots: [] }; // dots are (re)built by render.buildCrowd
// The court you picked on the start screen. Everything visual reads `court` directly; the two
// bounce numbers are COPIED INTO TUNE instead, so the T panel still edits the live physics and
// gameplay's three bounce sites stay untouched.
export let court = COURTS[0];
export function selectCourt(id) {
court = COURTS.find(c => c.id === id) || COURTS[0];
TUNE.bounceY = court.bounceY; TUNE.bounceXZ = court.bounceXZ;
try { localStorage.setItem('court', court.id); } catch {} // private mode: a forgotten pick is fine
return court;
}
// The tour you picked on the same screen. Nothing but the SPRITE NAME depends on it — see SK() in
// render.js. A tour is a skin: same physics, same AI, same scoring.
export let tour = TOURS[0];
export function selectTour(id) {
tour = TOURS.find(t => t.id === id) || TOURS[0];
try { localStorage.setItem('tour', tour.id); } catch {}
return tour;
}
// everything that used to be loose top-level lets
export const G = {
mode: 'tennis', // tennis | transition | splatter
transT: 0, // transition timeline clock
tFlags: null, // one-shot markers within the transition
hideO: false, // the opponent has... left the match
everTransitioned: false,
phase: 'pretoss', // pretoss | toss | rally | over
serveNum: 1,
overT: 0,
hitstop: 0,
flash: null, // {text, t}
shake: null, // {t, mag} — camera shake, decays like flash (bomb impact)
boomFlash: 0, // s of white flash frame left (bomb impact)
lastOrderT: -9, // idleT of the last 'Order' call — bomb chains mustn't spam the umpire
idleT: 0,
boy: null, // ball boy sprinting across: {x, z, dir, runT}
aiServeT: 0,
aiSwingCool: 0,
aiReactT: 9, // time since the player's last hit; CPU reads the shot after TUNE.aiReaction
wasServe: false, // the ball in flight is a serve (first bounce must hit the box)
splatT: 0, // time survived in splatter mode — drives the difficulty ramp
// The courtside rack. Slots 1-4 are NOT fixed weapon types anymore: they hold what you have
// GATHERED, in pickup order, so which four you are carrying is a run-by-run thing. Stock still
// ACCUMULATES per weapon (capped at TUNE.wCap). Slot 0 stays BALL — infinite, and how you bank a
// special instead of spending it.
rack: [], // up to RACK_MAX { id, n } — the gathered weapons, in the order they arrived
temp: null, // { id, n } — a FIFTH weapon, riding slot 0 until it is spent. A barrel arms
// itself on the spot, so it always has somewhere to go even with a full rack.
weapon: 0, // selected slot: 0 = BALL (or temp, if one is riding it), 1..4 = G.rack[i-1]
mgT: 0, // s of braced auto-fire left — no serving while it runs (gun/beacon/flamer,
// and it doubles as the window a tap-fired laser or rocket is held for)
mgCool: 0, // s until the next pellet in that burst
gunId: null, // which WEAPONS row that burst is firing — rate, radius, damage, colour
beam: null, // the laser's live wedge {x, z, ux, uz, t} — drawn, then gone. Not a projectile.
testMode: false, // the scoreboard triple-tap sandbox: medium wave, every new weapon on the rack
deliveries: 0, // tins delivered this run — indexes the weapon ladder
duelDue: false, // the schedule called a duel — fire it at the next hand-cleared court
afterDuel: null, // the game beat the scheduled duel interrupted ('changeover' | 'game'), resumed when it resolves
aim: null, // live two-stage serve aim {fx, fy, mag} while the thumb is down (splatter pretoss)
idleHintT: 0, // s the player has sat in pretoss without touching anything — after a beat the
// pull gesture demonstrates itself (render.drawPullHint)
meleeT: 0, // s of racket-swish cooldown left
rackGlow: null, // {slot, t} — a freshly gathered barrel weapon pulses its rack tile briefly
// instead of equipping itself (popBarrel keeps your current selection)
tossDrops: 0, // consecutive DROPPED tosses (tennis) — ≥2 shows the ghost-hand apex hint
passStreak: 0, // consecutive rally balls that passed untouched (tennis) — ≥2 shows TAP TO HIT
// ---- the sidestep. The movement block is 'rally'-only on purpose, so this integrates OUTSIDE it
// and is the only way to move in the serve stance (which is where a boss fight is spent).
dodgeDir: 0, // -1 | 0 | 1 — which way the current step is going, 0 = standing
dodgeT: 0, // s of the step left
dodgeCd: 0, // s until another is allowed
dodgeI: 0, // s of invulnerability left (checked by hurtPlayer AND the eat test)
// ---- the SECOND damage channel. Only ranged attacks (ball machine, chapter boss) touch this;
// a shambler reaching you is still instant permadeath, deliberately. No HP bar anywhere — the
// state is drawn as blood on the man (drawChar's spatter + bleedPlayer's live trickle).
hp: TUNE.playerHp,
hpMax: TUNE.playerHp,
hurtT: 0, // s of post-hit grace left; also drives the red hit flash
hurtFlash: 0, // s of the impact wash left
asideT: 14, // s until the umpire's next unsolicited remark (TUNE.asideEvery)
praiseT: 0, // s until the next praise word is allowed (TUNE.praiseEvery)
bossDue: 0, // chapter number the schedule owes you — fired at the next hand-cleared court
bossFight: 0, // chapter number of the boss currently on court, 0 = none. Blocks all other spawns.
slowT: 0, // s of REAL time left running the world in slow motion (a chapter boss dying)
bossBurst: null, // {x, z, t} — the shockwave ring thrown off by that death
interlude: false, // sudden-death duel vs the racket zombie: falsy, or {introT} while announcing
duelLerp: 0, // 0..1 — how far the court has returned to tennis colors for the duel
horde: null, // active horde wave {t, spawnT, mgT} — machine-gun racket, no serving
killCount: 0, // splatter: kills banked toward the next point (see TUNE.killsPerPoint)
runGames: 0, // games finished this run (either side) — the difficulty director
sets: [0, 0], // splatter sets won [you, them]; 6 games = a set = a horde
changeover: false, // the odd-game breather: no spawns, no serving, the boys mop
clearing: false, // a game just ended: the surviving wave stays and must be killed by HAND before play advances (no auto-sweep). No new spawns, kills don't score.
afterClear: null, // what happens once the court is clear: 'changeover' | 'game' | 'set'
clearWinner: null, // 'P'|'O' — who took the game/set being cleared (for the milestone card)
pendingHorde: false,// a set was won; the horde starts after the set card's pause
card: null, // {title, sub1, sub2, color, t} — centered milestone card (set won, duel); the reusable 'block'
// the modal: a real interruption. Unlike card/flash it FREEZES the world (update() early-returns)
// and only a tap or key clears it, so a popup can be read without being eaten while you read it.
modal: null, // {title, body: [lines], icon, iconH, color, onDismiss}
modalQ: [], // popups waiting behind the open one — see showModal
seen: {}, // one-shot markers so a first-time-only modal fires once per run
zoom: null, // {x, z, k, t, T} — a scripted punch-in on a world point (the transition's turn, a boss death)
netRipple: null, // {x, t} — the ball clipped the tape; the net's top line wobbles there briefly
best: null, // best run {games, sets}, loaded from localStorage when you get eaten
newBest: false, // this run beat the stored best — the eaten summary celebrates it
lastScoreT: -9, // idleT of the last score voice — rate-limits umpire during a horde
eatenT: 0, // time since you were eaten — the death-animation clock, and it gates the restart tap
eatenAt: null, // {x, z} where you fell — the gore mound blooms there
missLog: [], // swing diagnostics ring buffer (tennis mode) — inspect via T.G.missLog
swingSnaps: null, // ball-approach samples during the live swing, feeds the miss log
rallyNear: null, // closest the incoming ball got this rally (for never-swung passes)
swung: false, // player swung at least once this rally — whiff log supersedes PASSED
lastMiss: null, // {text, at} — most recent miss reason, drawn on the HUD briefly
logShow: false, // tap the scoreboard to toggle the on-screen miss-log dump
};
// A pristine deep copy of every mutable bag, taken at module load before anything can touch them.
// This is what replaced `location.reload()` on death: the reload was an honest full reset, but it
// also tore down the AudioContext, so the menu theme restarted from silence every time you died.
// Everything above is plain data — primitives, nulls, small arrays and {} — so a structured clone
// is the whole job.
const PRISTINE = structuredClone({ G, P, O, ball, score });
export function resetState() {
const p = structuredClone(PRISTINE);
Object.assign(G, p.G); Object.assign(P, p.P); Object.assign(O, p.O);
Object.assign(ball, p.ball); Object.assign(score, p.score);
G.hp = G.hpMax = TUNE.playerHp; // re-read: the T panel may have moved it since boot
crowd.cheer = 0;
}
export const other = s => s === 'P' ? 'O' : 'P';
export const deuceSide = () => (score.pts[0] + score.pts[1]) % 2 === 0; // even points → deuce (right) side
export function setupPoint() {
G.phase = 'pretoss'; ball.active = false; ball.bounces = 0;
G.swingSnaps = null; G.rallyNear = null; G.swung = false;
if (G.mode === 'splatter') { // always your serve, from wherever you're standing
score.server = 'P';
P.pose = 'idle'; P.swingT = -1; P.facing = 1;
return;
}
const sv = score.server === 'P' ? P : O, rc = score.server === 'P' ? O : P;
const sx = deuceSide() ? 1.3 : -1.3;
sv.x = sv.side === 'P' ? sx : -sx; // deuce side mirrors across the net
sv.z = sv.side === 'P' ? 24.3 : -0.5;
rc.x = -sv.x; rc.z = rc.side === 'P' ? 22.5 : HOME.O.z - 1.3;
P.pose = O.pose = 'idle'; P.swingT = O.swingT = -1;
P.facing = O.facing = 1; // canonical righty stance — bounce/serve sprites must not render mirrored
G.aiServeT = score.server === 'O' ? 1.4 : 0;
}
// fetch a dead ball — or, in splatter, come out to CLEAN: a different sprite sheet and a slower
// walk, because mopping is not sprinting. Mopping is the DEFAULT once the gore starts; the plain
// running fetch-boy survives for tennis mode, where there is nothing on the grass but a ball.
// Is there a stain worth walking out for? Must match mopRound's own target test, or he walks on,
// finds nothing he can reach, and walks straight off again — which is exactly what Oliver saw:
// "the mopboy sometimes moves over the court without anything to mop".
const mopWorthIt = () => gore.some(d => d.a >= 0.15 && d.z >= COURT.netZ - 2);
export function sendBoy(tx = ball.x, tz = ball.z, mop = G.mode === 'splatter') {
if (G.interlude) return; // the sudden-death duel is clean tennis: nobody mops through it
if (mop && !mopWorthIt()) return; // no job, no boy
if (tz < -3 || tz > 27) return; // target left the field lengthwise, let it go
// ONE boy, one slot. Every kill used to call this and teleport him back to the wing mid-stride,
// which is why he flickered in and out: a kill streak restarted him several times a second.
// A boy already out on the court keeps working — he just adds the new mess to his round.
if (G.boy) { if (mop) { G.boy.mop = true; G.boy.jobs = Math.min(TUNE.boyMaxJobs, (G.boy.jobs || 1) + 1); } return; }
const dir = tx > 0 ? 1 : -1; // enter from the opposite side so the pickup is mid-run
const e = mop ? BOY_ENTER.mop : BOY_ENTER.run;
G.boy = { x: dir > 0 ? -e : e, z: tz, dir, runT: 0, dist: 0, mop, jobs: 1 };
}
+257
View File
@@ -0,0 +1,257 @@
// stats.js — what the game remembers about you, and the two menu screens that read it back.
//
// Everything the BODY COUNT screen shows comes out of ONE localStorage key: an array of per-run
// records, newest last. ALL TIME / LAST MATCH / 7 DAYS are three filters over that array rather
// than three stored counters, so a period can be added later without a migration and without a
// second write path that can drift out of step with the first.
//
// The live run is a module-local bag, not a field on G. G is deep-cloned into PRISTINE at load and
// restored wholesale by restartRun(), so a counter living there would be reset by machinery that
// knows nothing about stats — fine today, and exactly the kind of coupling that breaks quietly the
// first time the reset order changes. endRun() is the only thing that empties this bag.
import { WEAPONS, ENEMIES } from './config.js';
const KEY = 'splatterRuns';
// ponytail: the array is capped, so "ALL TIME" means "your last CAP runs". At ~170 bytes a record
// that is ~50 KB against a 5 MB quota, and 300 deaths is months of play. If anyone ever passes it,
// the upgrade is a second key holding rolled-up lifetime totals that the cap never touches — not a
// bigger cap, which just moves the cliff.
const CAP = 300;
// A run in progress. `z` is kills by enemy row key, `w` is kills by weapon id.
const bag = () => ({ k: 0, mk: 0, z: {}, w: {} });
let run = bag();
// Has the CURRENT run already been written? An emptied bag is not enough to answer that: the games
// count lives on G and survives the flush, so a second endRun would sail past a "nothing happened"
// guard and bank a duplicate with zero kills. `pagehide` really does fire more than once — the
// bfcache dismisses and restores the same page — which is exactly how this was caught.
// Cleared by the next kill, so a fresh run re-arms itself with no reset call to forget.
let banked = false;
// ---------------------------------------------------------------- storage ----
// Private-mode Safari throws on both read and write. Stats are a nicety; a menu that will not open
// is not. Every path through this file degrades to "no stats" and never to an exception.
function load() {
try {
const a = JSON.parse(localStorage.getItem(KEY) || 'null');
if (Array.isArray(a)) return a;
return migrate();
} catch { return []; }
}
// The only number the game used to keep was localStorage.splatterBest = {games, sets}, written
// inside killPlayer. Rather than orphan it, it comes across as a single synthetic run the first
// time this module looks — so an existing player's best survives the upgrade instead of resetting
// to zero on the device they actually play on.
function migrate() {
try {
const b = JSON.parse(localStorage.splatterBest || 'null');
if (!b || !b.games) return [];
const seed = [{ t: Date.now(), g: b.games, s: b.sets || 0, d: 0, k: 0, mk: 0, dead: 1, z: {}, w: {} }];
localStorage.setItem(KEY, JSON.stringify(seed));
localStorage.removeItem('splatterBest'); // one source of truth from here on
return seed;
} catch { return []; }
}
const save = a => { try { localStorage.setItem(KEY, JSON.stringify(a.slice(-CAP))); } catch {} };
// ------------------------------------------------------------- collection ----
// One kill. `variant` is the zombie's own char; 'a' and 'b' are one shambler in two shirts and fold
// together, and a chapter boss arrives already keyed 'B0'..'B5' by design (see zombies.killZombie).
export function tally(variant, weapon) {
const z = variant === 'b' ? 'a' : variant;
banked = false; // something happened: this is a live run again
run.k++;
run.z[z] = (run.z[z] || 0) + 1;
run.w[weapon] = (run.w[weapon] || 0) + 1;
}
// A shot that took two or more at once — counted ONCE per shot, on the transition to 2, so a
// massacre of six is one multi-kill and not five.
export function multiKill() { run.mk++; }
// The run is over: fold it into the record and hand back the best run so far. `dead` separates a
// death from a tab closed mid-match — both are worth recording, only one is a death.
// Idempotent: a run with nothing in it writes nothing, so death-then-close cannot double-count and
// closing the tab at the menu does not invent an empty match.
export function endRun(games, sets, secs, dead = true) {
const prev = load();
const best = prev.reduce((b, r) => r.g > b.games ? { games: r.g, sets: r.s } : b, { games: 0, sets: 0 });
// TWO bests, because they answer different questions. `newBest` is GAMES and belongs to the death
// summary, which has always celebrated how long you lasted. `newKillBest` is KILLS, which is what
// the world board ranks — so it is the one that earns you a name-entry prompt.
const bestKills = prev.reduce((m, r) => Math.max(m, r.k || 0), 0);
const kills = run.k;
if (banked || (!run.k && !games)) { run = bag(); return { ...best, newBest: false, newKillBest: false }; }
const newBest = games > best.games;
const newKillBest = kills > bestKills;
prev.push({ t: Date.now(), g: games, s: sets, d: Math.round(secs || 0),
k: kills, mk: run.mk, dead: dead ? 1 : 0, z: run.z, w: run.w });
save(prev);
run = bag(); banked = true;
last = { kills, games, newKillBest };
return { ...best, newBest, newKillBest, kills, games };
}
// ------------------------------------------------------------- aggregation ----
// `runs` is injectable so check() can assert against a fixture without touching localStorage.
export function read(period, runs = load()) {
const rs = period === 'last' ? runs.slice(-1)
: period === '7d' ? runs.filter(r => Date.now() - r.t < 7 * 864e5)
: runs;
const out = { kills: 0, multi: 0, deaths: 0, longest: 0, matches: rs.length, z: {}, w: {} };
for (const r of rs) {
out.kills += r.k || 0; out.multi += r.mk || 0; out.deaths += r.dead || 0;
out.longest = Math.max(out.longest, r.g || 0);
for (const k in r.z) out.z[k] = (out.z[k] || 0) + r.z[k];
for (const k in r.w) out.w[k] = (out.w[k] || 0) + r.w[k];
}
return out;
}
// the two figures the splash carries
export function total() {
const a = read('all');
return { kills: a.kills, best: a.longest };
}
// ------------------------------------------------------------ leaderboard ----
// The world board, ranked on KILLS IN A SINGLE RUN — the game's own currency, and fine-grained
// enough that the order means something (games survived is a small integer and would be a wall of
// ties). Games ride along as the tiebreak and as a column.
// Two routes, no auth. See server.py: the board is a wall for writing your name on.
const NAME_KEY = 'splatterName', ID_KEY = 'splatterId';
export const savedName = () => { try { return localStorage.getItem(NAME_KEY) || ''; } catch { return ''; } };
// Your claim on your name. Minted once, never shown, never leaves this device except to prove a
// submission is yours — see server.py. It is not a login and it protects nothing but the row: lose
// your browser storage and you lose the name, which is what having no accounts costs.
function myId() {
try {
let v = localStorage.getItem(ID_KEY);
if (!v) { v = Array.from(crypto.getRandomValues(new Uint8Array(12)))
.map(b => b.toString(36).padStart(2, '0')).join(''); localStorage.setItem(ID_KEY, v); }
return v;
} catch { return ''; } // private mode: no claim, and the first stranger to use the name keeps it
}
// The run just flushed, held here rather than on G — G is restored from PRISTINE by restartRun(),
// and the name prompt has to survive exactly that. Consumed on read so it prompts once.
let last = null;
export function lastRun() { const r = last; last = null; return r; }
export async function board() {
const r = await fetch('api/scores', { cache: 'no-store' });
if (!r.ok) throw new Error('board ' + r.status);
return (await r.json()).scores || [];
}
// The SAME normalisation server.py's clean() applies. Both ends have to agree or nothing lines up:
// the server stored OLIVER, the client remembered "oliver", and the board then failed to highlight
// the row you had just written. Normalising here also means the prefill shows what will really
// appear on the board.
export const tidyName = s =>
String(s).toUpperCase().replace(/[^A-Z0-9 ._-]/g, '').trim().slice(0, 12);
export async function submit(rawName, kills, games) {
const name = tidyName(rawName);
if (!name) throw new Error('empty name');
try { localStorage.setItem(NAME_KEY, name); } catch {} // type it once, ever
const r = await fetch('api/scores', { method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ n: name, k: kills, g: games, id: myId() }) });
if (r.status === 409) { const e = new Error('name taken'); e.taken = true; throw e; }
if (!r.ok) throw new Error('submit ' + r.status);
return r.json(); // {ok, rank, scores}
}
const n = x => x.toLocaleString('en-GB');
// Names come off the wire and are written by other people. server.py whitelists them down to
// [A-Z0-9 ._-] so nothing dangerous can survive the round trip — this is the second lock on the
// same door, because the alternative is trusting a remote string into innerHTML.
const esc = s => String(s).replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const wIcon = id => id === 'ball' ? 'w_ball' : WEAPONS[id] ? WEAPONS[id].icon : null;
// ----------------------------------------------------------------- screens ----
// Both screens are built here rather than in main.js: they are the only readers of the shapes
// above, and main.js is the boot file, not a view layer.
export function drawStats(period, v) {
const d = read(period);
document.getElementById('stPeriod').textContent =
period === 'last' ? 'LAST MATCH' : period === '7d' ? '7 DAYS' : 'ALL TIME';
document.getElementById('stFigs').innerHTML = [
['KILLS', n(d.kills)], ['MULTI-KILLS', n(d.multi)], ['DEATHS', n(d.deaths)],
].map(([k, val], i) => `<div class="fig${i === 2 ? ' bad' : ''}"><b>${k}</b><i>${val}</i></div>`).join('');
// sorted by count, and only bodies you have actually met — an empty roster of everything would
// read as a checklist of things to go and do, which this screen is not
const rows = Object.entries(d.z).filter(([, c]) => c > 0).sort((a, b) => b[1] - a[1]);
const top = rows.length ? rows[0][1] : 1;
document.getElementById('stZ').innerHTML = rows.length ? rows.map(([k, c]) => {
// a key with no row is a save written by an older build: name it off the key, stripped, because
// this is the one string on the screen that does not come from our own tables
const e = ENEMIES[k] || { name: k.replace(/[^A-Za-z0-9]/g, '').toUpperCase() || '?', spr: 'z_a1', sub: '' };
return `<div class="zrow${e.boss ? ' boss' : ''}">
<img src="assets/${e.spr}.png?v=${v}" alt="">
<div class="zb"><b>${e.name}</b><span>${n(c)}</span>
<div class="pb"><s style="width:${Math.max(3, c / top * 100)}%"></s></div>
<i>${e.sub}</i></div></div>`;
}).join('') : '<p class="none">NOTHING YET. GO AND EARN A ROW.</p>';
const ws = Object.entries(d.w).filter(([id, c]) => c > 0 && wIcon(id)).sort((a, b) => b[1] - a[1]);
document.getElementById('stW').innerHTML = ws.length ? ws.map(([id, c]) =>
`<div class="wtile"><img src="assets/${wIcon(id)}.png?v=${v}" alt=""><b>${n(c)}</b></div>`).join('')
: '<p class="none">NO WEAPONS FIRED.</p>';
document.getElementById('stFoot').innerHTML =
`<b>LONGEST RUN</b><i>${d.longest} GAME${d.longest === 1 ? '' : 'S'}</i>`;
}
// The real board. Async, and every failure lands on a line of copy rather than an empty screen —
// the server being down must never be indistinguishable from nobody having played.
export async function drawRanks(rows) {
const el = document.getElementById('rkList');
if (!rows) {
el.innerHTML = '<p class="none">READING THE BOARD…</p>';
try { rows = await board(); }
catch { el.innerHTML = '<p class="none">THE BOARD IS UNREACHABLE.</p>'; return; }
}
if (!rows.length) { el.innerHTML = '<p class="none">NOBODY IS ON THE BOARD YET. BE FIRST.</p>'; return; }
const me = savedName();
el.innerHTML = rows.map((r, i) =>
`<div class="rkrow${r.n === me ? ' me' : ''}"><b>${n(i + 1)}</b><span>${esc(r.n)}</span>`
+ `<i>${n(r.k || 0)} · ${r.g || 0}G</i></div>`).join('');
}
// ------------------------------------------------------------------ check ----
// The one runnable check: the period filter and the aggregation, against a fixture. `read` takes an
// injected array precisely so this needs no localStorage and cannot disturb a real save.
export function check() {
const day = 864e5, now = Date.now();
const fx = [
{ t: now - 9 * day, g: 3, s: 0, d: 60, k: 10, mk: 1, dead: 1, z: { a: 8, g: 2 }, w: { ball: 10 } },
{ t: now - 2 * day, g: 7, s: 1, d: 90, k: 5, mk: 0, dead: 1, z: { g: 5 }, w: { bomb: 5 } },
{ t: now - 1000, g: 1, s: 0, d: 10, k: 1, mk: 2, dead: 0, z: { a: 1 }, w: { ball: 1 } },
];
const all = read('all', fx), wk = read('7d', fx), last = read('last', fx);
const eq = (got, want, what) => { if (got !== want) throw new Error(`${what}: ${got} !== ${want}`); };
eq(all.kills, 16, 'all kills');
eq(all.deaths, 2, 'all deaths'); // the abandoned run is recorded but is not a death
eq(all.longest, 7, 'all longest');
eq(all.z.a, 9, 'all shamblers');
eq(wk.kills, 6, '7d kills'); // the 9-day-old run is outside the window
eq(wk.z.a, 1, '7d shamblers');
eq(last.kills, 1, 'last kills');
eq(last.multi, 2, 'last multi');
eq(Object.entries(all.z).sort((a, b) => b[1] - a[1])[0][0], 'a', 'sort');
// tidyName has to agree with server.py's clean() or the board cannot highlight your own row —
// it is the one rule duplicated across two languages, so it is the one worth pinning down.
eq(tidyName(' oliver '), 'OLIVER', 'name upper+trim');
eq(tidyName('<b>hi</b>'), 'BHIB', 'name strips markup');
eq(tidyName('ABCDEFGHIJKLMNOPQRSTUVWXYZ').length, 12, 'name capped at 12');
eq(tidyName('...'), '...', 'name keeps the allowed punctuation');
return 'ok';
}
+1330
View File
File diff suppressed because it is too large. Load diff