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:
1 parent
c122745390
commit
1f636530b9
16 files changed
+16
-13
No files matched your search
+398
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+213
@@ -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
@@ -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; } };
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+190
@@ -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
@@ -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 =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user