Polish round: modal system, softened palette, pixel font, and the 14 plan items

The three foundations first, since everything else leans on them:

- G.modal is the only thing that stops the world. update() early-returns on it
  so no zombie steps, no ball flight, no spawn timers run while a popup is on
  screen — you can read it without being eaten. Dismissal swallows the gesture;
  the pointerdown dismisser is registered only on pointer:fine, because on touch
  pointerdown beats touchstart and a shared handler cleared the modal and then
  served with the same thumb.
- UI in config.js is the accent palette. The arcade primaries were shouting over
  the artwork; the gore reds deliberately stay loud.
- Every canvas font string now goes through F(). Bare `monospace` resolved to
  Courier — a slab serif — which was the "generic serif" bug, not taste.

Then: weapons are only ever WON. deliverTin() has exactly one caller now
(resolveInterlude('P')); the old game schedule calls the DUEL instead, fired off
proceedAfterClear() where the court is guaranteed empty. Four popups (splatter
tutorial with a generated illustration, first duel, weapon received, horde),
wCap 9 with the tin amounts tunable, a 5-hp brute that comes apart over four
wound skins, fire that ignites the neighbours and chains outward, a three-stage
death where you become the biggest gore mound on the court, and a 7s changeover
with a ball boy who finally has a mop.

New art: assets/logo.png, x_howto, z_gw2-4, x_boymop1-4, plus assets/_key.py for
standalone art (chroma-key + crop, no frame splitting, no forced sprite height).

Benchmarks re-run and holding: CPU returns 23/24 serves, 30 human-tapped points
= 50 hits / 0 whiffs, 26 simulated games with no exceptions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-27 05:28:04 +03:00
1 parent 5faaa99467
commit a09a5d7559
23 files changed
+392 -103

No files matched your search

+93 -32
View File
@@ -1,11 +1,11 @@
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
import { TUNE, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE } from './config.js';
import { TUNE, UI, EATEN_BURST, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE } from './config.js';
import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } from './state.js';
import { audio, pock, swish, applause, voice, sfx, aww, zombieMusic, zombieDuck } from './audio.js';
import { axisX, axisZ, takeFlickAim, touchPX } from './input.js';
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
import { updateZombies, spawnZombie, spawnRacketZombie, zombies, explode, meleeSwipe, extras, parts } from './zombies.js';
import { updateZombies, spawnZombie, spawnRacketZombie, zombies, explode, meleeSwipe, extras, parts, playerGore } from './zombies.js';
// ------------------------------------------------------------- ballistics ----
// Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g.
@@ -209,6 +209,17 @@ function scoreKey() {
return N[s[0]] + '_' + N[s[1]];
}
// ---- the modal: the only thing in the game that stops the world. Play resumes on a tap, not
// on a timer, so a popup can be READ. showModal never clobbers an open one (a kill cascade can
// try to open two in the same frame) and only ever touches G — never the zombie array it may
// be called from mid-iteration.
export function showModal(m) { if (!G.modal) G.modal = m; }
export function dismissModal() {
const m = G.modal; if (!m) return false;
G.modal = null; if (m.onDismiss) m.onDismiss();
return true; // the caller must swallow the gesture: dismissing must never also serve
}
export function cheer(seconds, vol) {
sfx('tennis-applaus', Math.min(1.5, vol * 2.2)); // recorded crowd; vol scales with the size of the moment (gain > 1 amplifies)
crowd.cheer = Math.max(crowd.cheer, seconds);
@@ -257,11 +268,11 @@ export function splatPoint(side, quiet = false) {
const gameOver = advancePoint(side);
if (gameOver) {
G.runGames++;
if (side === 'O') G.shameGame = true; // they took a game — the next new-balls delivery is withheld
// the new-balls rule arms you: deliveries land at the changeover that follows the scheduled game
if (G.runGames === TUNE.ballChangeFirst ||
(G.runGames > TUNE.ballChangeFirst && (G.runGames - TUNE.ballChangeFirst) % TUNE.ballChangeEvery === 0))
G.tinDue = true;
// the schedule calls a DUEL, not a tin: it comes round on its own so there's always a chance
// at a weapon, but you still have to win the point to be handed one. Fires at the next clear.
if (G.runGames === TUNE.duelFirst ||
(G.runGames > TUNE.duelFirst && (G.runGames - TUNE.duelFirst) % TUNE.duelEvery === 0))
G.duelDue = true;
voice(side === 'P' ? 'game_set_massacre' : 'game_opponent', 0.5); // game-win lines always speak
if (side === 'P') cheer(2.2, 0.5); else aww(0.85); // your game = applause, theirs = a groan
const i = side === 'P' ? 0 : 1;
@@ -291,11 +302,15 @@ export function proceedAfterClear() {
G.clearing = false;
const mode = G.afterClear, side = G.clearWinner;
G.afterClear = null;
// A clear is the ONE moment the court is guaranteed empty — the natural slot for the scheduled
// duel. (A wall-clock timer would need its own "wait for a safe moment" and could fire mid-swarm.)
// The set/horde beat outranks it; the duel just waits for the next clear.
if (mode !== 'set' && G.duelDue) { G.duelDue = false; G.afterDuel = mode || 'game'; startDuel(); return; }
if (mode === 'set') {
G.card = { title: 'SET ' + (G.sets[0] + G.sets[1]), // the set just banked (sets already ++'d at game over)
sub1: side === 'P' ? 'THE SET IS YOURS' : 'SET TO THEM',
sub2: 'SETS ' + G.sets[0] + ' – ' + G.sets[1],
color: side === 'P' ? '#e6ee3a' : '#ff5050', t: TUNE.setCardT };
color: side === 'P' ? UI.pick : UI.hot, t: TUNE.setCardT };
G.pendingHorde = true; // the horde begins when the card's pause ends
ball.active = false; G.phase = 'over'; G.overT = TUNE.setCardT;
} else if (mode === 'changeover') {
@@ -310,13 +325,12 @@ export function proceedAfterClear() {
function startChangeover() {
G.changeover = true;
G.phase = 'over'; G.overT = TUNE.changeoverT;
sendBoy();
if (G.tinDue) { // the boys arm you — unless they took a game since the last delivery
G.tinDue = false;
if (G.shameGame) G.flash = { text: 'NO NEW BALLS', t: 1.6 }; // the umpire withholds
else deliverTin();
G.shameGame = false; // the slate is clean either way
} else G.flash = { text: 'CHANGEOVER', t: 1.4 };
// out with the mop, across the near court where the gore is — NOT to the ball: a dead ball
// parked off the field (sendBoy's z guard) meant no boy came out at all, and the breather is
// now long enough that an empty court reads as the game having stalled.
sendBoy(0, COURT.L * 0.62, true);
G.flash = { text: 'CHANGEOVER', t: 1.4 };
// no tins here anymore — weapons are won in the duel, never handed out on a schedule
}
// ---- the courtside rack: five slots, PLAIN plus the four tins. Stock lives on G; slot 0 is
@@ -332,19 +346,44 @@ export function selectWeapon(i) {
// the weapon ladder: each delivery opens the next tin in the cycle and ADDS it to the rack
// (capped per type at TUNE.wCap) — old stock is kept, so you can bank a bomb for the horde
// instead of spending it now. G.weapon picks which tin the next serve throws.
const WDESC = { // what the tin you just won actually does — one line, shown on the pickup modal
BOMB: ['DETONATES ON CONTACT', 'CLEARS A WHOLE CROWD AT ONCE'],
TRIPLE: ['ONE SERVE, THREE BALLS', 'FANNED WIDE ACROSS THE LANE'],
FIRE: ['KILLS ON ANY TOUCH', 'AND SETS THE OTHERS ALIGHT'],
GUN: ['HOLD THE LINE AND SPRAY', 'A BURST, NOT A SERVE'],
};
export function deliverTin() {
const w = G.deliveries++ % 4, add = k => Math.min(TUNE.wCap, k);
if (w === 0) G.bombs = add(G.bombs + TUNE.bombMax);
else if (w === 1) G.triples = add(G.triples + 3);
else if (w === 2) G.flames = add(G.flames + 3);
else G.guns = add(G.guns + 1); // one machine-gun charge — a burst you pop when you choose, not on arrival
else if (w === 1) G.triples = add(G.triples + TUNE.tinTriples);
else if (w === 2) G.flames = add(G.flames + TUNE.tinFlames);
else G.guns = add(G.guns + TUNE.tinGuns); // machine-gun charges — a burst you pop when you choose, not on arrival
if (G.weapon !== 0) G.weapon = w + 1; // equip the new tin — but never yank you off a deliberate PLAIN (banking)
sendBoy(); voice('new_balls', 0.6);
G.flash = { text: 'NEW BALLS: ' + SLOTS[w + 1], t: 1.6 };
const name = SLOTS[w + 1];
showModal({ title: name, body: WDESC[name], icon: ['w_bomb', 'w_triple', 'w_fire', 'w_gun'][w],
iconH: 40, color: UI.warm });
}
// ---- the interlude: order is restored, one sudden-death point vs the racket zombie.
// Tennis rules apply for exactly one point — the absurdity peak: the umpire never stopped watching.
// This is now the ONLY source of weapons in the game: win the point or leave empty-handed.
export function startDuel() {
if (G.interlude) return;
if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // the schedule guarantees an opponent
for (const z of zombies) if (z.variant === 'r') z.dueled = true; // a veteran can be challenged again — this marks the fresh duel
G.weapon = 0; // tennis rules for the duel: plain balls, no tins
G.interlude = { introT: 2.4 }; // the intro beat: serving blocked while the umpire announces
ball.active = false; setupPoint();
zombieDuck(true); // fade the zombie music out for the 1:1 duel
voice('play_resume', 0.2); voice('sudden_death', 2.4);
if (!G.seen.duel) { // the rules change back — worth one real interruption, once per run
G.seen.duel = true;
showModal({ title: 'SUDDEN DEATH', color: UI.hot,
body: ["IT'S TENNIS AGAIN — ONE POINT", 'WIN IT AND THEY HAND YOU A WEAPON'] });
} else G.flash = { text: 'SUDDEN DEATH', t: 1.6 };
}
export function resolveInterlude(side) {
if (!G.interlude) return;
G.interlude = false;
@@ -355,12 +394,16 @@ export function resolveInterlude(side) {
deliverTin(); // win the point, win the next tin — on the spot
} else {
voice('game_opponent', 0.4); aww(0.85); // lost the sudden-death duel
score.games[1]++; G.runGames++; G.shameGame = true; // it goes on the scoreboard
score.games[1]++; G.runGames++; // it goes on the scoreboard
if (score.games[1] >= 6) { G.sets[1]++; score.games = [0, 0]; }
G.flash = { text: 'GAME. THEM.', t: 1.6 };
for (let k = 0; k < 5; k++) // and the dead lose their patience: an instant surge
spawnZombie((Math.random() * 2 - 1) * 5, -1.5 - Math.random() * 2, Math.random() < 0.5 ? 'a' : 'b');
}
if (G.afterDuel) { // a scheduled duel interrupted a game beat — hand it back now that it's settled
G.afterClear = G.afterDuel; G.afterDuel = null;
proceedAfterClear();
}
}
// retire the splatter ball; a running changeover's long beat must not be clobbered short
@@ -407,7 +450,14 @@ function transition(dt) {
once('turn', 3.4, () => { spawnZombie(O.x, O.z, 'c'); G.hideO = true; }); // the opponent... turns
once('mob', 4.8, () => { spawnZombie(-3.5, -1.6, 'a'); spawnZombie(2.5, -2.1, 'b'); });
once('seated', 5.4, () => voice('remain_seated', 0, 0.95));
once('go', 8.2, () => { G.mode = 'splatter'; score.server = 'P'; setupPoint(); voice('play', 0.4, 0.9); zombieMusic(); });
once('go', 8.2, () => {
G.mode = 'splatter'; score.server = 'P'; setupPoint(); voice('play', 0.4, 0.9); zombieMusic();
// the mechanic changed under you — the one popup that gets an illustration, because it has
// to TEACH the gesture, not just announce it
showModal({ title: 'THE MATCH HAS CHANGED', color: UI.hot, icon: 'x_howto', iconH: 120,
body: ['PULL BACK TO AIM — THEN LET GO', 'THE FURTHER YOU PULL, THE HARDER IT HITS',
'A WEAK SHOT ONLY STAGGERS THEM'] });
});
}
function fault() {
@@ -672,7 +722,11 @@ function expirePose(ch, dt) {
export function update(dt) {
if (G.hitstop > 0) { G.hitstop -= dt; return; }
if (G.mode === 'eaten') { // the match is over in the truest sense
G.idleT += dt; G.eatenT += dt;
G.idleT += dt;
const was = G.eatenT; G.eatenT += dt;
if (was < EATEN_BURST && G.eatenT >= EATEN_BURST) playerGore(); // phase B: you become the gore
if (G.shake && (G.shake.t -= dt) <= 0) G.shake = null;
if (G.boomFlash > 0) G.boomFlash -= dt;
updateZombies(dt);
return;
}
@@ -680,6 +734,9 @@ export function update(dt) {
if (G.card) { G.card.t -= dt; if (G.card.t <= 0) G.card = null; }
if (G.shake && (G.shake.t -= dt) <= 0) G.shake = null;
if (G.boomFlash > 0) G.boomFlash -= dt;
// A modal freezes the WORLD, not the clock: no zombie steps, no ball flight, no spawn timers,
// so nobody gets eaten while reading a popup. idleT keeps running for the blink.
if (G.modal) { G.idleT += dt; return; }
crowd.cheer = Math.max(0, crowd.cheer - dt);
G.idleT += dt;
const w = BOUNCE_IDLE.tempo;
@@ -757,14 +814,11 @@ export function update(dt) {
// --- interlude trigger: the court is clear, an opponent holds the far court, you're about
// to serve — the umpire recognizes a legal match and restores order. One point. Sudden death.
// The schedule is a FLOOR, not a ceiling: this lucky-clear trigger stays, and a bonus duel is
// pure upside for weapon supply.
if (G.mode === 'splatter' && !G.interlude && !G.horde && !G.changeover && !(G.mgT > 0)
&& G.phase === 'pretoss' && zombies.length === 1 && zombies[0].variant === 'r' && !zombies[0].dueled) {
zombies[0].dueled = true; // one duel per opponent — after that it just rallies
G.weapon = 0; // tennis rules for the duel: force plain balls, no tins
G.interlude = { introT: 2.4 }; // the intro beat: serving blocked, centered card, colors return
zombieDuck(true); // fade the zombie music out for the 1:1 duel
voice('play_resume', 0.2); voice('sudden_death', 2.4);
}
&& G.phase === 'pretoss' && zombies.length === 1 && zombies[0].variant === 'r' && !zombies[0].dueled)
startDuel();
if (G.interlude && G.interlude.introT > 0) G.interlude.introT -= dt;
// the duel is played under tennis lights: lerp the palette home while it runs, back after
G.duelLerp = Math.max(0, Math.min(1, G.duelLerp + (G.interlude ? dt : -dt) / 0.8));
@@ -877,8 +931,9 @@ export function update(dt) {
// ball boy: sprint across, scoop the ball in passing, despawn off the far side
const boy = G.boy;
if (boy) {
boy.x += boy.dir * 7.5 * dt; boy.runT += dt;
if (!ball.collected && !ball.active) {
// mopping is not sprinting: the mop crossing takes ~6.6s, which is what fills the changeover
boy.x += boy.dir * (boy.mop ? TUNE.boyMopSpeed : 7.5) * dt; boy.runT += dt;
if (!boy.mop && !ball.collected && !ball.active) { // a mop boy has no ball to chase — he must hold his lane and clean it
const dz = ball.z - boy.z;
boy.z += Math.sign(dz) * Math.min(Math.abs(dz), 3 * dt); // track the rolling ball a little
if (Math.abs(boy.x - ball.x) < 0.4 && Math.abs(dz) < 1.2) ball.collected = true;
@@ -890,7 +945,13 @@ export function update(dt) {
if (G.phase === 'over') {
G.overT -= dt;
if (G.overT <= 0) {
if (G.pendingHorde) { G.pendingHorde = false; startHorde(); return; } // the set card's pause is over — release the horde
if (G.pendingHorde) { // the set card's pause is over — but the horde is a different CONTROL MODE
G.pendingHorde = false; // (racket auto-fires, the finger steers you absolutely),
showModal({ title: 'THEY KEEP COMING', color: UI.hot, // so it gets a real block, not just a card
body: ['THE RACKET FIRES ON ITS OWN', 'SLIDE TO AIM · HOLD THE BASELINE'],
onDismiss: startHorde });
return;
}
if (G.changeover) { G.changeover = false; voice('time', 0, 0.95); } // "Time." — play resumes
// auto-fire modes must NEVER re-enter the serve loop: an 'over' beat expiring into
// setupPoint() -> pretoss left the player locked in the serve posture while the gun