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:
1 parent
5faaa99467
commit
a09a5d7559
23 files changed
+392
-103
No files matched your search
+93
-32
@@ -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
|
||||
|
||||
Reference in new issue
Block a user