Phase 5: horde waves + machine-gun racket
Winning your 2nd splatter game (G.splatGames) — or triple-tapping the
umpire in splatter (forceZombies now calls startHorde) — launches a 12s
horde (TUNE.hordeLen). During a horde: the match ball is gone and taps do
nothing; the racket auto-fires pellets straight up-court at TUNE.mgRate
(6/s, ±4° spread, 26 m/s, flat, 1.2s life) and the thumb DRAG steers you
(auto-assist disabled). Zombies pour in at TUNE.hordeRate (2.5/s) from the
far baseline and the sides, ignoring zMax; the speed ramp still applies.
Pellets live in a new zombies.js `pellets` array, kill on contact (always
splatter-quality). splatter() gains a fanfare flag — pellet kills make
gore/piles/points but skip the multi-kill streak so a horde doesn't spam
MASSACRE. splatPoint() gains a quiet flag: during a horde the umpire only
calls a score every >2s (game-win lines always speak); the HUD score keeps
ticking. Horde ends → setupPoint() resumes serving, voice('quiet_dark');
leftover zombies stay for you to mop up. Eaten stays lethal during hordes
(a zombie reaching you ends it and clears the swarm). Pellets render as
2×2 yellow tracers with a ground shadow; serve prompt suppressed while
G.horde. window.T exposes pellets + forceZombies.
NOTE for Oliver: per spec every game won after the 2nd triggers a horde,
and games won by the machine gun mid-horde count too, so play gets very
horde-heavy after game 2 — flag if you want a cooldown or a knob.
Verified via window.T: cheat + 2nd-game both launch the horde (1st does
not); ~29 spawns over exactly 12s; pellets stream (7 in flight) and clear
a lane, building piles/gore; 4 points scored but the umpire speaks once
(rate-limited); horde ends cleanly back to pretoss; eaten still fires
mid-horde; regression ladder green — tennis 11/12, Phase 1 flick-serve,
Phase 2 DOUBLE→TRIPLE, Phase 4 MASSACRE ×4; no console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
06025bdccf
commit
bf9553bd49
6 files changed
+84
-21
No files matched your search
+47
-9
@@ -4,7 +4,7 @@
|
||||
// (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.)
|
||||
|
||||
import { TUNE, COURT } from './config.js';
|
||||
import { G, P, ball, sendBoy } from './state.js';
|
||||
import { G, P, ball, sendBoy, setupPoint } from './state.js';
|
||||
import { splat, moan, pock, voice, boom } from './audio.js';
|
||||
import { splatPoint, cheer } from './gameplay.js';
|
||||
|
||||
@@ -12,6 +12,7 @@ export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
|
||||
export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer
|
||||
export const piles = []; // 3D-ish gore mounds {x, z, size 1..4, seed} — grow where kills cluster
|
||||
export const parts = []; // splatter particles {x, y, z, vx, vy, vz, t, dark}
|
||||
export const pellets = []; // machine-gun rounds during a horde {x, y, z, vx, vz, t}
|
||||
|
||||
let spawnT = 1.5, moanT = 3;
|
||||
|
||||
@@ -20,10 +21,9 @@ export function spawnZombie(x, z, variant) {
|
||||
zombies.push({ x, z, v: TUNE.zSpeed * ramp * (0.8 + Math.random() * 0.4), walkT: Math.random() * 9, variant });
|
||||
}
|
||||
|
||||
function splatter(i) {
|
||||
function splatter(i, fanfare = true) {
|
||||
const zb = zombies[i];
|
||||
zombies.splice(i, 1);
|
||||
ball.kills++; // this shot's running streak
|
||||
gore.push({ x: zb.x, z: zb.z, r: 0.45 + Math.random() * 0.35, a: 0.85 });
|
||||
if (gore.length > 40) gore.shift();
|
||||
const pl = piles.find(p => Math.hypot(p.x - zb.x, p.z - zb.z) < 0.6); // stack up where kills cluster
|
||||
@@ -38,7 +38,9 @@ function splatter(i) {
|
||||
splat();
|
||||
ball.vx *= TUNE.zPierce; ball.vz *= TUNE.zPierce; // keep enough pace to plow through the next
|
||||
sendBoy(zb.x, zb.z); // a boy sprints out to mop
|
||||
splatPoint('P');
|
||||
splatPoint('P', !!G.horde); // horde kills rate-limit the umpire so he doesn't machine-gun scores
|
||||
if (!fanfare) return; // machine-gun pellets splatter but skip the streak fanfare / bomb meter
|
||||
ball.kills++; // this shot's running streak
|
||||
// escalating multi-kill fanfare — one shot, ≥2 zombies
|
||||
if (ball.kills === 2) { G.flash = { text: 'DOUBLE KILL', t: 0.9 }; cheer(1.6, 0.45); voice('double_kill', 0.4); }
|
||||
else if (ball.kills === 3) { G.flash = { text: 'TRIPLE KILL', t: 0.9 }; cheer(2.2, 0.6); voice('triple_kill', 0.4); }
|
||||
@@ -50,6 +52,29 @@ function splatter(i) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- horde wave: dense spawns + an auto-firing machine-gun racket (pellets, not the match ball)
|
||||
function spawnHordeZombie() {
|
||||
if (Math.random() < 0.5) spawnZombie((Math.random() * 2 - 1) * 5, -1.5 - Math.random(), Math.random() < 0.5 ? 'a' : 'b'); // far baseline
|
||||
else spawnZombie(Math.random() < 0.5 ? -6 : 6, Math.random() * 8, Math.random() < 0.5 ? 'a' : 'b'); // in from the sides
|
||||
}
|
||||
function firePellet() {
|
||||
const a = (Math.random() * 2 - 1) * 4 * Math.PI / 180, s = 26; // ±4° spread, 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
|
||||
}
|
||||
function endHorde() {
|
||||
G.horde = null; pellets.length = 0;
|
||||
setupPoint(); // serve loop resumes; any zombies still standing are yours to mop up
|
||||
voice('quiet_dark', 0.5, 0.85);
|
||||
}
|
||||
function tickHorde(dt) {
|
||||
const h = G.horde;
|
||||
h.t -= dt;
|
||||
if ((h.spawnT -= dt) <= 0) { spawnHordeZombie(); h.spawnT = 1 / TUNE.hordeRate; }
|
||||
if ((h.mgT -= dt) <= 0) { firePellet(); h.mgT = 1 / TUNE.mgRate; }
|
||||
if (h.t <= 0) endHorde();
|
||||
}
|
||||
|
||||
// bomb ball: everything inside bombRadius splatters at once (each kill still ++ball.kills so
|
||||
// the Phase-2 fanfare escalates), plus a crater — big stain, size-3 pile, extra particles, boom.
|
||||
export function explode(x, z) {
|
||||
@@ -72,10 +97,13 @@ export function updateZombies(dt) {
|
||||
|
||||
if (G.mode === 'splatter') { // transition spawns are scripted, not timed
|
||||
G.splatT += dt;
|
||||
spawnT -= dt;
|
||||
if (spawnT <= 0 && zombies.length < TUNE.zMax) {
|
||||
spawnZombie((Math.random() * 2 - 1) * 4.8, -1.5 - Math.random(), Math.random() < 0.5 ? 'a' : 'b');
|
||||
spawnT = TUNE.zSpawnEvery;
|
||||
if (G.horde) tickHorde(dt); // horde: dense spawns + machine gun, ignores zMax
|
||||
else {
|
||||
spawnT -= dt;
|
||||
if (spawnT <= 0 && zombies.length < TUNE.zMax) {
|
||||
spawnZombie((Math.random() * 2 - 1) * 4.8, -1.5 - Math.random(), Math.random() < 0.5 ? 'a' : 'b');
|
||||
spawnT = TUNE.zSpawnEvery;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +122,7 @@ export function updateZombies(dt) {
|
||||
splat(1);
|
||||
voice('game_set_massacre', 0.8);
|
||||
G.mode = 'eaten'; ball.active = false; G.flash = null; // zombies keep shambling for ambience
|
||||
G.horde = null; pellets.length = 0; // the swarm stops; the eaten screen takes over
|
||||
}
|
||||
if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) {
|
||||
const d = Math.hypot(ball.x - zb.x, ball.z - zb.z);
|
||||
@@ -108,7 +137,16 @@ export function updateZombies(dt) {
|
||||
ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; // spent — one stagger per ball
|
||||
}
|
||||
}
|
||||
if (zb.z >= COURT.L + 0.7) { zombies.splice(i, 1); if (G.mode !== 'eaten') splatPoint('O'); } // it reached your baseline
|
||||
if (zb.z >= COURT.L + 0.7) { zombies.splice(i, 1); if (G.mode !== 'eaten') splatPoint('O', !!G.horde); } // it reached your baseline
|
||||
}
|
||||
|
||||
for (let i = pellets.length - 1; i >= 0; i--) { // machine-gun rounds: flat, fast, one-shot kills
|
||||
const pe = pellets[i];
|
||||
pe.t -= dt; pe.x += pe.vx * dt; pe.z += pe.vz * dt;
|
||||
let hit = false;
|
||||
for (let j = zombies.length - 1; j >= 0; j--)
|
||||
if (Math.hypot(zombies[j].x - pe.x, zombies[j].z - pe.z) < 0.55) { splatter(j, false); hit = true; break; }
|
||||
if (hit || pe.t <= 0) pellets.splice(i, 1);
|
||||
}
|
||||
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
|
||||
Reference in new issue
Block a user