diff --git a/assets/audio/time.m4a b/assets/audio/time.m4a new file mode 100644 index 0000000..e78c7ee Binary files /dev/null and b/assets/audio/time.m4a differ 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) {