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:
1 parent
25db84ed87
commit
5a7f71fdba
7 files changed
+80
-27
No files matched your search
Binary file not shown.
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; } };
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user