Let the horde gun keep up with the horde

The run ended on the second wave every time: the gun fired 6 flat rounds a
second, hit one body each, and every shambler soaked one — while hordeGrow
made each wave longer and denser. Doubled the rate and scaled it with the
wave (14/s at one set banked, 21/s at four), gave a round 0.5m of extra
reach, a wider cone, and enough damage to drop a shambler outright. Raised
the spawn rate to match, so surviving a wave still leaves a standing crowd
on your half — that pressure is the point of it.

Also: he no longer carries a second racket at his hip while holding one
forward (the run frames are the racket-less art), the umpire stopped talking
over himself and saying "remain seated" twice, and a timed freeze now shows
what it is counting down to instead of reading as dead controls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-31 08:07:35 +02:00
1 parent 571cd4c399
commit e904dfefa6
5 files changed
+47 -13

No files matched your search

+8
View File
@@ -24,9 +24,17 @@ function reverb(ac) { // stadium PA tail: exponentially decaying noise as impuls
return reverbNode; 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) { export function voice(name, delay = 0, rate = 1) {
const ac = audio(); const ac = audio();
const play = buf => { 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; const src = ac.createBufferSource(); src.buffer = buf;
src.playbackRate.value = rate; // < 1 = slower and lower — the umpire's dark register src.playbackRate.value = rate; // < 1 = slower and lower — the umpire's dark register
src.connect(ac.destination); // dry src.connect(ac.destination); // dry
+9 -2
View File
@@ -185,8 +185,15 @@ export const TUNE = {
// is why switching tins felt fiddly. rackHit also pads the touch band (render.js). // 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 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 hordeLen: 12, // s a horde wave lasts
hordeRate: 2.5, // zombies spawned per second during a horde hordeRate: 3.5, // zombies spawned per second during a horde. Raised WITH the gun buff below: the
mgRate: 6, // machine-gun pellets fired per second during a horde // 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 mgPickupLen: 8, // s the machine-gun tin (4th delivery) auto-fires outside hordes
fps: 60, // frame cap — 30 halves battery/heat but Oliver felt it; smoothness wins (0 = uncapped) fps: 60, // frame cap — 30 halves battery/heat but Oliver felt it; smoothness wins (0 = uncapped)
runnerSpeed: 3.2, // runner ('f') speed multiplier over the shamble runnerSpeed: 3.2, // runner ('f') speed multiplier over the shamble
+5 -3
View File
@@ -534,12 +534,14 @@ function startHorde() {
G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked
G.pendingGame = false; // the horde IS the announcement G.pendingGame = false; // the horde IS the announcement
const grow = 1 + (G.sets[0] + G.sets[1]) * TUNE.hordeGrow; // each set banked makes the next wave worse const grow = 1 + (G.sets[0] + G.sets[1]) * TUNE.hordeGrow; // each set banked makes the next wave worse
G.horde = { t: TUNE.hordeLen * grow, spawnT: 0, mgT: 0, rate: TUNE.hordeRate * grow }; // 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 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.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) P.z = HOME.P.z; // brace at the baseline — the horde is a hold-the-line stand (finger steers x only)
voice('remain_seated', 0.3); aside('ump_spectators', 0.4); // a horde IS the crowd on the court. (The near-identical 'remain seated'
aside('ump_spectators', 2.2); // a horde IS the crowd on the court // that used to play right before it belongs to the transition, not here.)
G.flash = { text: 'THEY KEEP COMING', t: 1.2 }; G.flash = { text: 'THEY KEEP COMING', t: 1.2 };
} }
+19 -3
View File
@@ -416,6 +416,10 @@ function drawChar(ch) {
if (ch.pose === 'swing') name = pre + (ch.poseT < 0.15 ? 'swing1' : 'swing2'); if (ch.pose === 'swing') name = pre + (ch.poseT < 0.15 ? 'swing1' : 'swing2');
if (ch.pose === 'serve') name = pre + (G.phase === 'toss' ? 'toss' : 'smash'); if (ch.pose === 'serve') name = pre + (G.phase === 'toss' ? 'toss' : 'smash');
if (ch.pose === 'run') p.y -= Math.floor(ch.runT * 18) % 2; // 1px run bob if (ch.pose === 'run') p.y -= Math.floor(ch.runT * 18) % 2; // 1px run bob
// Auto-fire: the idle sprite carries a racket at his hip AND drawMgRacket puts one in his hands —
// two rackets. The run frames are the racket-less art, so he jogs on the spot instead.
if (ch.side === 'P' && ch.pose === 'idle' && G.mode === 'splatter' && (G.horde || G.mgT > 0))
name = pre + (Math.floor(G.idleT * 6) % 2 ? 'run1' : 'run2');
const img = SPR[name]; const img = SPR[name];
drawShadow(p.x, p.y, hpx * 0.22, hpx * 0.06); drawShadow(p.x, p.y, hpx * 0.22, hpx * 0.06);
if (img) { if (img) {
@@ -450,7 +454,8 @@ function drawMgRacket() { // horde: the player braces the racket forward and up-
ctx.strokeStyle = '#b07030'; ctx.lineWidth = 1.5; // the head, aimed forward ctx.strokeStyle = '#b07030'; ctx.lineWidth = 1.5; // the head, aimed forward
ctx.beginPath(); ctx.ellipse(tip.x, tip.y, Math.max(2, tip.s * 0.14), Math.max(3, tip.s * 0.22), 0, 0, 7); ctx.stroke(); ctx.beginPath(); ctx.ellipse(tip.x, tip.y, Math.max(2, tip.s * 0.14), Math.max(3, tip.s * 0.22), 0, 0, 7); ctx.stroke();
const cool = G.horde ? G.horde.mgT : G.mgCool; // horde or GUN-tin pickup — same racket const cool = G.horde ? G.horde.mgT : G.mgCool; // horde or GUN-tin pickup — same racket
if (cool > 1 / TUNE.mgRate - 0.06) { // muzzle flash right after a shot const rate = G.horde ? G.horde.mgRate || TUNE.mgRate : TUNE.mgRate;
if (cool > 1 / rate - 0.03) { // muzzle flash right after a shot
ctx.fillStyle = '#fff0a0'; ctx.beginPath(); ctx.arc(tip.x, tip.y, Math.max(2, tip.s * 0.12), 0, 7); ctx.fill(); ctx.fillStyle = '#fff0a0'; ctx.beginPath(); ctx.arc(tip.x, tip.y, Math.max(2, tip.s * 0.12), 0, 7); ctx.fill();
} }
} }
@@ -805,7 +810,7 @@ export function render() {
for (const pe of pellets) { for (const pe of pellets) {
const sh = proj(pe.x, 0, pe.z); drawShadow(sh.x, sh.y, Math.max(1, sh.s * 0.04), Math.max(1, sh.s * 0.02), 0.25); const sh = proj(pe.x, 0, pe.z); drawShadow(sh.x, sh.y, Math.max(1, sh.s * 0.04), Math.max(1, sh.s * 0.02), 0.25);
const pp = proj(pe.x, pe.y, pe.z); const pp = proj(pe.x, pe.y, pe.z);
ctx.fillStyle = UI.warm; ctx.fillRect((pp.x | 0) - 1, (pp.y | 0) - 1, 2, 2); ctx.fillStyle = UI.warm; ctx.fillRect((pp.x | 0) - 2, (pp.y | 0) - 2, 4, 4); // fat round — it hits like one
} }
// death, in three stages. A: you are still there, thrashing and going red as they crowd in. // death, in three stages. A: you are still there, thrashing and going red as they crowd in.
// B (from EATEN_BURST): the sprite is gone and you ARE the gore — playerGore() drops the mound. // B (from EATEN_BURST): the sprite is gone and you ARE the gore — playerGore() drops the mound.
@@ -905,7 +910,18 @@ export function render() {
if (G.eatenT > EATEN_RESTART) { ctx.fillStyle = UI.warm; ctx.font = F(8); ctx.fillText('TAP TO PLAY AGAIN', W / 2, H / 2 + 26); } if (G.eatenT > EATEN_RESTART) { ctx.fillStyle = UI.warm; ctx.font = F(8); ctx.fillText('TAP TO PLAY AGAIN', W / 2, H / 2 + 26); }
ctx.restore(); ctx.restore();
} }
if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !(G.mgT > 0) && !G.aim) { // while pulling, the draw meter owns this slot // Timed freezes swallow every input until they run out, which reads as the game having hung
// ("sometimes the controls just feel frozen"). Show the clock instead of hiding it. A modal is
// excluded on purpose — that one waits for YOU, and says so.
const waitT = G.modal || G.mode === 'eaten' ? 0
: Math.max(G.changeover > 0 ? G.changeover : 0,
G.phase === 'over' ? G.overT : 0,
G.interlude ? G.interlude.introT : 0);
if (waitT > 0.5) {
ctx.font = F(7, 0); ctx.fillStyle = hexA(UI.warm, 0.8);
ctx.fillText('RESUMING IN ' + Math.ceil(waitT), W / 2, 78); // under the flash line, clear of the court and the man
}
if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !(G.mgT > 0) && !G.aim && waitT <= 0.5) { // while pulling, the draw meter owns this slot
ctx.fillStyle = UI.warm; ctx.font = F(8); ctx.fillStyle = UI.warm; ctx.font = F(8);
const touch = IS_TOUCH, switchHint = touch ? 'TAP THE RACK TO SWITCH' : '1-5 TO SWITCH'; const touch = IS_TOUCH, switchHint = touch ? 'TAP THE RACK TO SWITCH' : '1-5 TO SWITCH';
const sel = ['PLAIN', 'BOMB', 'TRIPLE', 'FIRE', 'GUN'][G.weapon]; const sel = ['PLAIN', 'BOMB', 'TRIPLE', 'FIRE', 'GUN'][G.weapon];
+6 -5
View File
@@ -304,10 +304,11 @@ function splatter(i, fanfare = true) { // the MAIN ball hit a zombie
function spawnHordeZombie() { // all from the far baseline, across the court — never off-screen at the sides function spawnHordeZombie() { // all from the far baseline, across the court — never off-screen at the sides
spawnZombie((Math.random() * 2 - 1) * 5, -1.5 - Math.random(), Math.random() < 0.5 ? 'a' : 'b'); spawnZombie((Math.random() * 2 - 1) * 5, -1.5 - Math.random(), Math.random() < 0.5 ? 'a' : 'b');
} }
let mgTick = 0;
function firePellet() { function firePellet() {
const a = (Math.random() * 2 - 1) * 4 * Math.PI / 180, s = 26; // ±4° spread, straight up-court (-z) const a = (Math.random() * 2 - 1) * TUNE.mgSpread * Math.PI / 180, s = 26; // spread cone, straight up-court (-z)
pellets.push({ x: P.x, y: 0.9, z: P.z, vx: Math.sin(a) * s, vz: -Math.cos(a) * s, t: 1.2 }); pellets.push({ x: P.x, y: 0.9, z: P.z, vx: Math.sin(a) * s, vz: -Math.cos(a) * s, t: 1.2 });
pock(0.25, 1400); // rapid fire tick; if it clips on device, gate to every 2nd pellet if (++mgTick % 2 === 0) pock(0.25, 1400); // rapid fire tick, every 2nd round — at 12+/s every round buzzes
} }
export function spawnRacketZombie() { // one takes up residence on the far court — an opponent, of sorts export function spawnRacketZombie() { // one takes up residence on the far court — an opponent, of sorts
// He stiffens as the run goes on. hpMax is stored because the wound skins and bleed() anchors read // He stiffens as the run goes on. hpMax is stored because the wound skins and bleed() anchors read
@@ -338,7 +339,7 @@ function tickHorde(dt) {
if (zombies.length < TUNE.zHordeCap) spawnHordeZombie(); // ...but bounded, see TUNE.zHordeCap if (zombies.length < TUNE.zHordeCap) spawnHordeZombie(); // ...but bounded, see TUNE.zHordeCap
h.spawnT = 1 / (h.rate || TUNE.hordeRate); h.spawnT = 1 / (h.rate || TUNE.hordeRate);
} }
if ((h.mgT -= dt) <= 0) { firePellet(); h.mgT = 1 / TUNE.mgRate; } if ((h.mgT -= dt) <= 0) { firePellet(); h.mgT = 1 / (h.mgRate || TUNE.mgRate); }
if (h.t <= 0) endHorde(); if (h.t <= 0) endHorde();
} }
@@ -578,12 +579,12 @@ export function updateZombies(dt) {
for (let j = zombies.length - 1; j >= 0; j--) { for (let j = zombies.length - 1; j >= 0; j--) {
const zb = zombies[j]; const zb = zombies[j];
if (!zb) continue; if (!zb) continue;
if (Math.hypot(zb.x - pe.x, zb.z - pe.z) < hitR(zb)) { if (Math.hypot(zb.x - pe.x, zb.z - pe.z) < hitR(zb) + TUNE.mgPelletR) {
// Pellets used to skip the boss entirely, so a GUN burst (and every horde) did literally // Pellets used to skip the boss entirely, so a GUN burst (and every horde) did literally
// nothing to him with no feedback at all. They CHIP him now: dmgPellet is fractional, so // nothing to him with no feedback at all. They CHIP him now: dmgPellet is fractional, so
// three rounds take a point off and the gun is a slow, committed answer rather than a no-op. // three rounds take a point off and the gun is a slow, committed answer rather than a no-op.
if (isBoss(zb)) { if (wound(zb, TUNE.dmgPellet, false)) killZombie(j, true); else pock(0.45, 240); } if (isBoss(zb)) { if (wound(zb, TUNE.dmgPellet, false)) killZombie(j, true); else pock(0.45, 240); }
else if (zb.hp > 1) { zb.hp--; pock(0.5, 260); } // brutes AND shamblers soak pellets — wound, don't kill else if ((zb.hp -= TUNE.dmgPelletHp) > 0) pock(0.5, 260); // only brutes soak now; a shambler drops to one round
else killZombie(j, true); else killZombie(j, true);
hit = true; break; hit = true; break;
} }