From 5a7f71fdba8f09f8a89aa0c028816205e674c40e Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sun, 12 Jul 2026 09:54:01 +0200 Subject: [PATCH] =?UTF-8?q?Phase=207:=20the=20scoreboard=20is=20the=20prog?= =?UTF-8?q?ression=20=E2=80=94=20spawn=20director,=20changeovers,=20sets,?= =?UTF-8?q?=20permadeath?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- assets/audio/time.m4a | Bin 0 -> 6967 bytes config.js | 8 +++++-- gameplay.js | 47 +++++++++++++++++++++++++++++++++++------- main.js | 6 +++--- render.js | 19 ++++++++++------- state.js | 6 +++++- zombies.js | 21 ++++++++++++------- 7 files changed, 80 insertions(+), 27 deletions(-) create mode 100644 assets/audio/time.m4a diff --git a/assets/audio/time.m4a b/assets/audio/time.m4a new file mode 100644 index 0000000000000000000000000000000000000000..e78c7eef68ac42fbe15dbf3acdd7f10db15914e8 GIT binary patch literal 6967 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 literal 0 HcmV?d00001 diff --git a/config.js b/config.js index 74a986f..faebe0d 100644 --- a/config.js +++ b/config.js @@ -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 diff --git a/gameplay.js b/gameplay.js index 9dc7758..51fb16c 100644 --- a/gameplay.js +++ b/gameplay.js @@ -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(); + } + } } diff --git a/main.js b/main.js index d35d239..8a5c621 100644 --- a/main.js +++ b/main.js @@ -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; } }; diff --git a/render.js b/render.js index dc6ed1a..21c7c8e 100644 --- a/render.js +++ b/render.js @@ -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'; diff --git a/state.js b/state.js index f1d57c0..3a5932f 100644 --- a/state.js +++ b/state.js @@ -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 diff --git a/zombies.js b/zombies.js index 84674a6..fbb919b 100644 --- a/zombies.js +++ b/zombies.js @@ -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) {