diff --git a/audio.js b/audio.js index 9685a2c..141c24a 100644 --- a/audio.js +++ b/audio.js @@ -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 diff --git a/config.js b/config.js index cb93afb..73c4903 100644 --- a/config.js +++ b/config.js @@ -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 diff --git a/gameplay.js b/gameplay.js index ed35331..c4f28ab 100644 --- a/gameplay.js +++ b/gameplay.js @@ -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 }; } diff --git a/render.js b/render.js index 8508268..d84a931 100644 --- a/render.js +++ b/render.js @@ -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]; diff --git a/zombies.js b/zombies.js index a2a60f8..30ac6c2 100644 --- a/zombies.js +++ b/zombies.js @@ -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; }