Phase 7: the scoreboard is the progression — spawn director, changeovers, sets, permadeath

- Spawn director: gap and court cap scale with games finished (runGames);
  game 1 is three lonely shamblers, late runs approach zSpawnMin/zMaxCap
- Changeover after odd games: 4.5s forced breather, no spawns, no serving
  (melee still works), boys mop, umpire calls 'Time.' (new time.m4a)
- 6 games = a set; set end launches the horde (replaces the splatGames>=2 trigger)
- Zombies taking a game sets shameGame (phase 9 withholds the delivery)
- Eaten = the run is over: GAME, SET, MASSACRE summary with games/sets survived
  and best run (localStorage); HUD shows sets in splatter (SETS·GAMES PTS)
- spentBall() helper so dead-ball paths can't clobber a changeover's beat
- T surface: splatPoint + explode exposed for verify scripts

Verified: game-1 cap 3 held 25s; changeover blocks taps+spawns then resumes;
6 games -> set+horde -> racket zombie; runGames=6 tempo ~6 spawns/10s;
eaten writes best; tennis regression 11/12 CPU returns; no console errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-07-12 09:54:01 +02:00
1 parent 25db84ed87
commit 5a7f71fdba
7 files changed
+80 -27

No files matched your search

+40 -7
View File
@@ -180,21 +180,46 @@ function pointTo(side, reason) {
// splatter-mode scoring: points land mid-flight without interrupting play.
// quiet (during a horde) rate-limits the umpire so he doesn't call every rapid-fire point.
// The scoreboard IS the progression: games drive spawn pressure, 6 games = a set = a horde,
// odd games end in a changeover breather.
export function splatPoint(side, quiet = false) {
const gameOver = advancePoint(side);
if (gameOver) {
G.runGames++;
if (side === 'O') G.shameGame = true; // they took a game — Oliver pays at the next new-balls delivery
voice(side === 'P' ? 'game_set_massacre' : 'game_opponent', 0.5); // game-win lines always speak
cheer(2.2, 0.5);
if (side === 'P' && ++G.splatGames >= 2) startHorde(); // 2nd splatter game (and every one after): the horde
const i = side === 'P' ? 0 : 1;
if (score.games[i] >= 6) { // set to the winner — and the court answers with a horde
G.sets[i]++; score.games = [0, 0];
startHorde();
} else if ((score.games[0] + score.games[1]) % 2 === 1) startChangeover(); // real rule: after odd games
} else if (!quiet || G.idleT - G.lastScoreT > 2) {
voice(scoreKey(), 0.45); G.lastScoreT = G.idleT;
if (side === 'P' && !quiet) cheer(0.7, 0.25);
}
}
// 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 = true;
G.phase = 'over'; G.overT = TUNE.changeoverT;
sendBoy();
G.flash = { text: 'CHANGEOVER', t: 1.4 };
}
// retire the splatter ball; a running changeover's long beat must not be clobbered short
export function spentBall() {
ball.active = false;
G.phase = 'over';
if (!G.changeover) G.overT = TUNE.splatterOverT;
}
// the swarm keeps coming: no serving, the racket auto-fires, the thumb drag steers you
function startHorde() {
if (G.horde) return;
G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked
G.horde = { t: TUNE.hordeLen, spawnT: 0, mgT: 0 };
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
@@ -246,8 +271,8 @@ function onBounce() {
ball.bounces++;
pock(0.35, 500);
if (G.mode === 'splatter' && ball.bomb && ball.bounces === 1) { // a bomb that missed everyone still blows up
explode(ball.x, ball.z); ball.active = false; ball.bomb = false;
G.phase = 'over'; G.overT = TUNE.splatterOverT; return;
explode(ball.x, ball.z); ball.bomb = false;
spentBall(); return;
}
if (G.mode !== 'tennis') return; // no lines, no faults — the umpire has other concerns
if (ball.bounces === 1) {
@@ -401,6 +426,7 @@ export function keyPressed(code) {
audio(); // unlock on first gesture
if (G.mode === 'splatter') meleeSwipe(P.x, P.z, TUNE.meleeRange); // a swish cleaves adjacent shamblers
if (G.horde) return; // horde: the racket auto-fires; a tap only swishes, never serves
if (G.changeover) return; // the breather is mandatory — the melee swish above still works
// splatter: a tap/flick during the brief 'over' beat re-arms the next serve immediately
if (G.mode === 'splatter' && G.phase === 'over') { ball.active = false; setupPoint(); }
if (G.phase === 'pretoss' && score.server === 'P') {
@@ -558,14 +584,15 @@ export function update(dt) {
if (ball.active && Math.abs(ball.vy) < 0.8) { // rolled out — dead ball
ball.active = false;
if (G.mode === 'splatter') { // hand the server a fresh one, fast — they're coming
G.phase = 'over'; G.overT = TUNE.splatterOverT; sendBoy();
spentBall(); sendBoy();
if (Math.random() < 0.12) voice('new_balls', 0.3);
} else if (ball.bounces === 1) pointTo(ball.lastHit, null);
}
}
// a racket zombie's return that gets past you (crosses your baseline) is a point for THEM
if (G.mode === 'splatter' && ball.active && ball.lastHit === 'O' && ball.z > COURT.L + 0.3) {
ball.active = false; if (!G.horde) splatPoint('O'); G.phase = 'over'; G.overT = TUNE.splatterOverT;
if (!G.horde) splatPoint('O'); // may start a changeover — spentBall respects its beat
spentBall();
}
// splatter: retire spent or hopeless balls fast so the next serve comes right up.
// z-bounds gate on travel direction — a serve born behind the baseline (P.z up to 26)
@@ -574,7 +601,7 @@ export function update(dt) {
((ball.lastHit === 'X' && ball.bounces >= 1)
|| (ball.z < -2 && ball.vz < 0) || (ball.z > 25.5 && ball.vz > 0)
|| Math.abs(ball.x) > 7.5)) {
ball.active = false; G.phase = 'over'; G.overT = TUNE.splatterOverT;
spentBall();
}
if (G.phase === 'toss' && ball.y <= 1.4 && ball.vy < 0) { // uncaught toss → retoss
ball.active = false; G.phase = 'pretoss';
@@ -595,5 +622,11 @@ export function update(dt) {
}
if (G.outMarker && (G.outMarker.t -= dt) <= 0) G.outMarker = null;
if (G.phase === 'over') { G.overT -= dt; if (G.overT <= 0) setupPoint(); }
if (G.phase === 'over') {
G.overT -= dt;
if (G.overT <= 0) {
if (G.changeover) { G.changeover = false; voice('time', 0, 0.95); } // "Time." — play resumes
setupPoint();
}
}
}