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:
1 parent
571cd4c399
commit
e904dfefa6
5 files changed
+47
-13
No files matched your search
@@ -24,9 +24,17 @@ function reverb(ac) { // stadium PA tail: exponentially decaying noise as impuls
|
||||
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
|
||||
|
||||
@@ -185,8 +185,15 @@ export const TUNE = {
|
||||
// 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: 2.5, // zombies spawned per second during a horde
|
||||
mgRate: 6, // machine-gun pellets fired per second during a horde
|
||||
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
|
||||
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
|
||||
|
||||
+5
-3
@@ -534,12 +534,14 @@ function startHorde() {
|
||||
G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked
|
||||
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
|
||||
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
|
||||
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)
|
||||
voice('remain_seated', 0.3);
|
||||
aside('ump_spectators', 2.2); // a horde IS the crowd on the court
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -416,6 +416,10 @@ function drawChar(ch) {
|
||||
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 === '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];
|
||||
drawShadow(p.x, p.y, hpx * 0.22, hpx * 0.06);
|
||||
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.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
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -805,7 +810,7 @@ export function render() {
|
||||
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 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.
|
||||
// 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); }
|
||||
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);
|
||||
const touch = IS_TOUCH, switchHint = touch ? 'TAP THE RACK TO SWITCH' : '1-5 TO SWITCH';
|
||||
const sel = ['PLAIN', 'BOMB', 'TRIPLE', 'FIRE', 'GUN'][G.weapon];
|
||||
|
||||
+6
-5
@@ -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
|
||||
spawnZombie((Math.random() * 2 - 1) * 5, -1.5 - Math.random(), Math.random() < 0.5 ? 'a' : 'b');
|
||||
}
|
||||
let mgTick = 0;
|
||||
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 });
|
||||
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
|
||||
// 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
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -578,12 +579,12 @@ export function updateZombies(dt) {
|
||||
for (let j = zombies.length - 1; j >= 0; j--) {
|
||||
const zb = zombies[j];
|
||||
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
|
||||
// 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.
|
||||
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);
|
||||
hit = true; break;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user