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

Binary file not shown.
+6 -2
View File
@@ -18,8 +18,12 @@ export const TUNE = {
aiReaction: 0.45, // s before the CPU reads your shot — well-aimed corners are winners
autoAssist: matchMedia('(pointer: coarse)').matches ? 1.15 : 0, // auto-run speed factor (mobile)
zSpeed: 0.7, // zombie shamble speed m/s
zSpawnEvery: 2.6, // s between spawns
zMax: 6, // shamblers on court at once
zSpawnEvery: 3.0, // s between spawns in game 1 — shrinks by zSpawnStep per finished game
zSpawnMin: 1.0, // spawn-gap floor late in a run
zSpawnStep: 0.22, // spawn-gap reduction per finished game
zMax: 3, // shamblers on court in game 1 (cap grows +1 per game to zMaxCap; 0 = spawns off, for tests)
zMaxCap: 14, // absolute shambler cap
changeoverT: 4.5, // s the changeover breather lasts (after odd games, as the rules demand)
zSplatQ: 0.5, // min shot quality to splatter; below = stagger pushback
zPushback: 1.4, // m a staggered zombie is knocked back
splatterOverT: 0.1, // s the spent-ball 'over' beat lasts before the next serve is armed
+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();
}
}
}
+3 -3
View File
@@ -5,10 +5,10 @@
import { TUNE, COURT, UMP_H } from './config.js';
import { P, O, ball, score, G, setupPoint } from './state.js';
import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies } from './gameplay.js';
import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint } from './gameplay.js';
import { fitViewport, render, proj } from './render.js';
import { voice } from './audio.js';
import { zombies, spawnZombie, piles, gore, parts, pellets } from './zombies.js';
import { zombies, spawnZombie, piles, gore, parts, pellets, explode } from './zombies.js';
import { cv } from './dom.js';
import './input.js';
@@ -54,6 +54,6 @@ cv.addEventListener('pointerdown', e => {
requestAnimationFrame(frame);
// exposed for in-browser sanity checks and simulated playtests
window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, piles, gore, parts, pellets, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies,
window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, piles, gore, parts, pellets, explode, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies,
step: dt => update(dt), render, press: keyPressed,
get phase() { return G.phase; }, set phase(v) { G.phase = v; } };
+12 -7
View File
@@ -335,22 +335,27 @@ export function render() {
const ptTxt = i => score.pts[i] >= 3 && score.pts[0] >= 3 && score.pts[1] >= 3
? (score.pts[i] === score.pts[1 - i] ? '40' : (score.pts[i] > score.pts[1 - i] ? 'AD' : '40'))
: ['0', '15', '30', '40'][Math.min(score.pts[i], 3)];
const setsTxt = i => G.mode === 'tennis' ? '' : G.sets[i] + '·'; // splatter scoreboard: SETS·GAMES PTS
ctx.fillStyle = score.server === 'P' ? '#f8e048' : '#c8d0dc';
ctx.fillText('YOU ' + score.games[0] + ' ' + ptTxt(0), 12, 10);
ctx.fillText('YOU ' + setsTxt(0) + score.games[0] + ' ' + ptTxt(0), 12, 10);
for (let b = 0; b < G.bombs; b++) { // stocked bomb balls: black pips with an orange fuse rim
ctx.fillStyle = '#111'; ctx.beginPath(); ctx.arc(96 + b * 8, 14, 2.6, 0, 7); ctx.fill();
ctx.strokeStyle = '#ff8010'; ctx.lineWidth = 0.75; ctx.stroke();
}
ctx.fillStyle = score.server === 'O' ? '#f8e048' : '#c8d0dc';
ctx.fillText((G.mode === 'tennis' ? 'CPU ' : 'THEM ') + score.games[1] + ' ' + ptTxt(1), 12, 22);
ctx.fillText((G.mode === 'tennis' ? 'CPU ' : 'THEM ') + setsTxt(1) + score.games[1] + ' ' + ptTxt(1), 12, 22);
ctx.textAlign = 'center';
if (G.mode === 'eaten') {
if (G.mode === 'eaten') { // the run summary — permadeath makes this scoreboard the whole game
ctx.fillStyle = 'rgba(10,4,6,0.72)'; ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#c02020'; ctx.font = 'bold 22px monospace';
ctx.fillText('EATEN.', W / 2, H / 2 - 30);
ctx.fillStyle = '#c02020'; ctx.font = 'bold 16px monospace';
ctx.fillText('GAME, SET, MASSACRE.', W / 2, H / 2 - 32);
ctx.fillStyle = '#c8d0dc'; ctx.font = 'bold 10px monospace';
ctx.fillText('GAMES YOU ' + score.games[0] + ' — THEM ' + score.games[1], W / 2, H / 2);
if (G.eatenT > 0.7) { ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace'; ctx.fillText('TAP TO PLAY AGAIN', W / 2, H / 2 + 20); }
ctx.fillText('SURVIVED ' + G.runGames + ' GAMES · ' + G.sets[0] + ' SETS', W / 2, H / 2 - 6);
if (G.best) {
ctx.fillStyle = '#8fa0b8'; ctx.font = 'bold 8px monospace';
ctx.fillText('BEST ' + G.best.games + ' GAMES · ' + G.best.sets + ' SETS', W / 2, H / 2 + 10);
}
if (G.eatenT > 0.7) { ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace'; ctx.fillText('TAP TO PLAY AGAIN', W / 2, H / 2 + 28); }
}
if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde) {
ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace';
+5 -1
View File
@@ -32,7 +32,11 @@ export const G = {
bombs: 0, // bomb-ball stock (max TUNE.bombMax); the next serve auto-consumes one
killMeter: 0, // splatter kills toward the next bomb reward (every TUNE.bombEvery)
horde: null, // active horde wave {t, spawnT, mgT} — machine-gun racket, no serving
splatGames: 0, // splatter games you've won; the 2nd (and every one after) launches a horde
runGames: 0, // games finished this run (either side) — the difficulty director
sets: [0, 0], // splatter sets won [you, them]; 6 games = a set = a horde
changeover: false, // the odd-game breather: no spawns, no serving, the boys mop
shameGame: false, // they took a game — the next new-balls delivery is withheld
best: null, // best run {games, sets}, loaded from localStorage when you get eaten
lastScoreT: -9, // idleT of the last score voice — rate-limits umpire during a horde
eatenT: 0, // time since you were eaten — gates the restart tap
missLog: [], // swing diagnostics ring buffer (tennis mode) — inspect via T.G.missLog
+14 -7
View File
@@ -6,7 +6,7 @@
import { TUNE, COURT } from './config.js';
import { G, P, ball, sendBoy, setupPoint } from './state.js';
import { splat, moan, pock, voice, boom, swish } from './audio.js';
import { splatPoint, cheer } from './gameplay.js';
import { splatPoint, cheer, spentBall } from './gameplay.js';
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer
@@ -118,12 +118,14 @@ export function updateZombies(dt) {
if (G.mode === 'splatter') { // transition spawns are scripted, not timed
G.splatT += dt;
if (G.horde) tickHorde(dt); // horde: dense spawns + machine gun, ignores zMax
else {
if (G.horde) tickHorde(dt); // horde: dense spawns + machine gun, ignores the cap
else if (!G.changeover) { // changeovers are a real breather — no fresh spawns
spawnT -= dt;
if (spawnT <= 0 && zombies.length < TUNE.zMax) {
// the spawn director: the match's age (games finished) sets both crowd size and tempo
const cap = Math.min(TUNE.zMaxCap, TUNE.zMax + G.runGames);
if (spawnT <= 0 && zombies.length < cap) {
spawnZombie((Math.random() * 2 - 1) * 4.8, -1.5 - Math.random(), Math.random() < 0.5 ? 'a' : 'b');
spawnT = TUNE.zSpawnEvery;
spawnT = Math.max(TUNE.zSpawnMin, TUNE.zSpawnEvery - G.runGames * TUNE.zSpawnStep);
}
}
}
@@ -148,13 +150,18 @@ export function updateZombies(dt) {
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
try { // the run is over — settle it against the best (games survived, sets as tiebreak)
const b = JSON.parse(localStorage.splatterBest || 'null') || { games: 0, sets: 0 };
if (G.runGames > b.games) { b.games = G.runGames; b.sets = G.sets[0]; localStorage.splatterBest = JSON.stringify(b); }
G.best = b;
} catch (e) { G.best = null; } // private mode etc. — the summary just skips the best line
}
if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) {
const d = Math.hypot(ball.x - zb.x, ball.z - zb.z);
if (d < 0.55) {
if (ball.bomb) { // detonate on first contact, whatever the pace
explode(ball.x, ball.z); ball.active = false; ball.bomb = false;
G.phase = 'over'; G.overT = TUNE.splatterOverT; break; // zombies mutated — stop this frame's loop
explode(ball.x, ball.z); ball.bomb = false;
spentBall(); break; // zombies mutated — stop this frame's loop
}
if (zb.variant === 'r') { // mini-boss: splat-quality wounds it; soft balls it rallies (racketAI)
if ((ball.pow || 0) >= TUNE.zSplatQ) {