The dead club doubles its staff, and the draw is made where you can see it

Six new opponent families (head coach, stringer, strawberry seller, chair
umpire, spectator, roller man) through the famroll/zsheet route, placed by
hierarchy into an 18-rung ladder; the champion steps up behind them.

THE DRAW: picking the tournament now rolls its own bracket in front of you —
only the final was ever settled — and what lands is what walks on.

Netplay, off Oliver's cellular report: the mid-flight early-decide was missing
its !G.net gate, so the striker judged its own outbound flight and the two
scoreboards drifted a point apart into a court nobody could serve on. The
host's court now rides the room, its scoreboard rides the pretoss stream, and
a called ball rolls to its own halt instead of freezing where the call caught it.

Plus the night court, and somebody in the stand is taking pictures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-15 11:49:19 +02:00
1 parent 4c47295a73
commit 9cc3ac54b3
207 files changed
+632 -81

No files matched your search

+12 -3
View File
@@ -342,14 +342,17 @@ export function watchdog(dt) {
// the match mode ends. Regicide is inherited for free: smashing the ball into the body still wounds
// it, and killing it outright wins the game early (killZombie fires resolveInterlude('P'), which
// lands in matchGameWon via the empty-court check).
export function startMatch(cup = false) {
// `draw` is the bracket screen's settled rungs, one per CUP_ROUNDS entry (main.buildBracket). It is
// optional and null means "roll each round as it comes", which is what the mode did before the
// screen existed and is still what the #cup back door gets.
export function startMatch(cup = false, draw = null) {
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return false;
G.match = { opp: 1, ending: false };
// THE TOURNAMENT rides the whole match machinery and differs in exactly three places: who walks
// on (a rung DRAWN from the round's band — cupDraw sets d.opp, so every ladder dial downstream
// scales off the drawn rung untouched), how deep the body's pool is (matchOpponent multiplies
// by the round's `need`), and where a won game goes (cupGameWon). See config.CUP_ROUNDS.
if (cup) { G.match.cup = { round: 0, g: 0 }; cupDraw(); }
if (cup) { G.match.cup = { round: 0, g: 0, draw: draw || null }; cupDraw(); }
G.matchFall = null;
G.hp = G.hpMax = TUNE.matchPlayerHp; // a real match is longer than four mistakes — the gore
// mode's 4 was "too weak" here, felt not calculated
@@ -380,9 +383,15 @@ export function startMatch(cup = false) {
// The round's rung, drawn from its band. d.opp carries it so matchFoe, the ladder speed dials and
// the felled arithmetic all read the drawn rung with no cup-awareness of their own.
// A DRAW MADE ON SCREEN IS THE DRAW THAT IS PLAYED. When the bracket screen settled the rungs they
// arrive here as cup.draw and this simply reads them off — rolling again would mean the faces the
// player watched land had nothing to do with what walks on, which is worse than showing nothing.
// Guarded on the band all the same: a saved or hand-passed draw that names a rung this round does
// not contain falls back to an honest roll rather than dealing the champion in round one.
function cupDraw() {
const cup = G.match.cup, R = CUP_ROUNDS[cup.round];
cup.rung = R.band[Math.floor(Math.random() * R.band.length)];
const drawn = cup.draw && cup.draw[cup.round];
cup.rung = R.band.includes(drawn) ? drawn : R.band[Math.floor(Math.random() * R.band.length)];
G.match.opp = cup.rung;
}
+86 -25
View File
@@ -233,6 +233,13 @@ export const TUNE = {
asideEvery: 55, // s between the umpire's sarcastic asides in splatter (jittered +-40%). He never
// acknowledged that the match had changed; now he comments on it, disapprovingly.
racketHpPerGame: 0.12, // the racket zombie stiffens as the run goes on (floored into whole hp)
// CAMERA FLASHES IN THE STAND (render.tickFlashes). MEAN seconds between them — the actual gap is
// jittered 0.35x..1.95x so they never tick, and halved again while the crowd is cheering. Turn
// flashEvery UP to quieten them; it is the one knob. Life is deliberately shorter than a frame
// pair at 60 fps would need: a flash you can look AT is a light, a flash you only catch is a
// photograph being taken.
flashEvery: 5.0,
flashLife: 0.16,
// ---- damage. Bodies with hp (racket boss 'r', brute 'g') take damage in these units, so a big
// boss is just a bigger hp number and every weapon stays proportional. Fractions are legal: they
// bank on zb.chip and only whole points come off hp (the wound SKINS switch on hp thresholds).
@@ -624,6 +631,24 @@ export const COURTS = [
out: '#2c7a52', lawn: '#1f5c93', stripe: '#1f5c93' },
splatter: { stands: '#141a26', tierA: '#1a2130', tierB: '#161d2a', board: '#3a1212',
out: '#25452f', lawn: '#1c3e5c', stripe: '#1c3e5c' } },
// THE NIGHT COURT (Oliver, 2026-08-15: "a doomy gloomy court, the lines are bright"). The only
// court whose TENNIS palette is already darker than everything else's SPLATTER one — which is
// the whole idea: it starts where the others end up, and the rot then takes it further still.
// The lines need no new knob to be bright: paintBackdrop paints every line in UI.cream, so on a
// near-black surface they read as chalk under floodlights on their own. The ball is UI.ball and
// is bright for the same reason. It borrows the hard court's night-blue stands and boards rather
// than growing two more sheets — the stands are meant to be a wall of dark up there.
// `dim` is the one new knob: how far down the STAND is taken before the rot even starts (render's
// standDim). Without it the borrowed hard-court crowd sits floodlit and blue above a near-black
// court and the whole picture stops being a night. Nothing else in COURTS carries it, so it is 0
// everywhere else and reads as absent.
{ id: 'gloom', name: 'THE NIGHT COURT', sub: 'AFTER DARK · TRUE BOUNCE, NO LIGHT', tile: 'court_gloom',
ads: 'x_ads_h', crowd: 'x_crowd_h', stripes: false, line: '#dff6ff', border: '#c8dbe6', dim: 0.62,
bounceY: 0.58, bounceXZ: 0.80,
tennis: { stands: '#0a0d15', tierA: '#0f131e', tierB: '#0c1019', board: '#131a2b',
out: '#0f1a15', lawn: '#16261e', stripe: '#16261e' },
splatter: { stands: '#06050b', tierA: '#0b0813', tierB: '#08060f', board: '#2c0e0e',
out: '#0a120d', lawn: '#101c15', stripe: '#101c15' } },
];
// The tour, picked on the same screen as the court. Only the two TENNIS PLAYERS and the turned CPU
@@ -692,11 +717,15 @@ export const DIFFS = [
// across the whole round instead of being spent in game one. Blood carries between rounds; the
// trainer's heal-per-game is what makes the distance survivable. Deathmatch (id 'zombie') stays
// beside it as the arcade run.
// The bands widened with the ladder (12 rungs -> 18, 2026-08-15): the shape is unchanged — the
// bottom third opens, the middle third is the quarter, everything up to management is the semi,
// and the final is the honours board — but every round now draws from a deeper hat, which is
// exactly what the bracket screen's shuffle is showing you.
export const CUP_ROUNDS = [
{ name: 'ROUND ONE', band: [1, 2, 3], need: 1 },
{ name: 'QUARTER-FINAL', band: [4, 5, 6], need: 2 },
{ name: 'SEMI-FINAL', band: [7, 8, 9, 10, 11], need: 2 },
{ name: 'THE FINAL', band: [12], need: 2 },
{ name: 'ROUND ONE', band: [1, 2, 3, 4, 5], need: 1 },
{ name: 'QUARTER-FINAL', band: [6, 7, 8, 9, 10], need: 2 },
{ name: 'SEMI-FINAL', band: [11, 12, 13, 14, 15, 16, 17], need: 2 },
{ name: 'THE FINAL', band: [18], need: 2 },
];
export const TOURS = [
@@ -826,41 +855,62 @@ export const bossArt = ch => ch % 10 === 0 ? BOSS_ART[BOSS_ART.length - 1]
// hierarchy the thing across the net used to be. That is the narrative, and the difficulty ramp is
// the same list read as numbers — every dial climbs monotonically, so "start easy and get harder"
// is a property of the table rather than something the code has to arrange.
// 1-2 the ones who fetch and sweep. Slow, wild, forgiving.
// 3-4 the ones who keep order. They start covering ground.
// 5 the entertainment. The costume still fits.
// 6-8 the ones who cover and judge. Width arrives: they begin painting lines.
// 9-10 the ones who actually played. The club pro's own body, and the brute behind him.
// 11-12 management, and then the man whose name is on the honours board.
// 1-3 the ones who fetch, sweep and watch. Slow, wild, forgiving.
// 4-6 the ground staff and the ones who keep order. They start covering ground.
// 7-8 the entertainment and the legs.
// 9-12 the trades and the officials. Width arrives: they begin painting lines.
// 13-16 the ones who actually worked the players, and then played themselves.
// 17-18 management, and then the man whose name is on the honours board.
// EIGHTEEN RUNGS since 2026-08-15 (Oliver: expand the roster, "the champion can then be even a
// little bit harder to beat"). The six new ones are interleaved rather than stacked on the end —
// a ladder is a hierarchy, not an arrival order, so the spectator belongs at the bottom with the
// ball boy and the head coach belongs up beside the club pro. Every new rung wears a family rolled
// off the body it already has in the gore mode, same rule as the twelve before it.
const OPPONENTS = [
{ short: 'SHAMBLER', name: 'THE SHAMBLER', fam: 'sh',
line: 'HE WAS HERE BEFORE ANY OF THIS.', speed: 0.72, pace: 0.72, wide: 0.75, flub: 1.35 },
{ short: 'BALL BOY', name: 'THE BALL BOY', fam: 'bb',
line: 'HE STILL FETCHES. HE JUST BITES NOW.', speed: 0.82, pace: 0.8, wide: 0.82, flub: 1.2 },
line: 'HE STILL FETCHES. HE JUST BITES NOW.', speed: 0.80, pace: 0.78, wide: 0.82, flub: 1.25 },
{ short: 'PUNTER', name: 'THE SPECTATOR', fam: 'sp',
line: 'HE PAID FOR THE WHOLE SEAT.', speed: 0.86, pace: 0.82, wide: 0.86, flub: 1.18 },
{ short: 'KEEPER', name: 'THE GROUNDSKEEPER', fam: 'gk',
line: 'HE MOWED THIS LAWN FOR THIRTY YEARS.', speed: 0.9, pace: 0.88, wide: 0.9, flub: 1.05 },
// 5: the heavy of the ground staff. Slow legs, but everything he touches comes back flat and hard
{ short: 'ROLLER', name: 'THE ROLLER MAN', fam: 'rm', h: 1.95,
line: 'HE FLATTENED THIS COURT. THEN THE REST.', speed: 0.88, pace: 0.98, wide: 0.92, flub: 1.0 },
{ short: 'STEWARD', name: 'THE STEWARD', fam: 'st',
line: 'HE IS ASKING YOU TO LEAVE.', speed: 0.98, pace: 0.95, wide: 1.0 },
// 5-6: the entertainment, and then width arrives with the ones who judge
line: 'HE IS ASKING YOU TO LEAVE.', speed: 0.98, pace: 0.95, wide: 1.0, flub: 0.98 },
// 7-8: the entertainment, and then the legs
{ short: 'MASCOT', name: 'THE MASCOT', fam: 'ma',
line: 'THE CROWD STILL LOVES HIM.', speed: 1.02, pace: 0.98, wide: 1.05, flub: 1.0 },
line: 'THE CROWD STILL LOVES HIM.', speed: 1.02, pace: 0.98, wide: 1.05, flub: 0.95 },
{ short: 'RUNNER', name: 'THE RUNNER', fam: 'ru',
line: 'HE COVERS THE COURT. ALL OF IT.', speed: 1.22, pace: 1.0, wide: 1.05, flub: 0.95 },
line: 'HE COVERS THE COURT. ALL OF IT.', speed: 1.22, pace: 1.0, wide: 1.05, flub: 0.92 },
// 9-12: the trades and the officials — width arrives here
{ short: 'SELLER', name: 'THE STRAWBERRY SELLER', fam: 'sb',
line: 'CREAM WITH THAT?', speed: 1.04, pace: 1.06, wide: 1.10, flub: 0.90 },
{ short: 'STRINGER', name: 'THE STRINGER', fam: 'sg',
line: 'HE STRUNG YOUR RACKET. HE KNOWS ITS LIMIT.', speed: 1.06, pace: 1.12, wide: 1.14, flub: 0.86 },
{ short: 'JUDGE', name: 'THE LINE JUDGE', fam: 'lj',
line: 'EVERY BALL OF YOURS IS OUT.', speed: 1.05, pace: 1.05, wide: 1.28, flub: 0.85 },
line: 'EVERY BALL OF YOURS IS OUT.', speed: 1.05, pace: 1.08, wide: 1.28, flub: 0.84 },
{ short: 'UMPIRE', name: 'THE CHAIR UMPIRE', fam: 'cu',
line: 'HIS CALL WAS ALWAYS FINAL.', speed: 1.08, pace: 1.14, wide: 1.26, flub: 0.80 },
{ short: 'PHYSIO', name: 'THE PHYSIO', fam: 'ph',
line: 'HE KNOWS EXACTLY WHERE IT HURTS.', speed: 1.1, pace: 1.1, wide: 1.22, flub: 0.82 },
line: 'HE KNOWS EXACTLY WHERE IT HURTS.', speed: 1.12, pace: 1.16, wide: 1.24, flub: 0.78 },
{ short: 'COACH', name: 'THE HEAD COACH', fam: 'hc',
line: 'HE TAUGHT YOU EVERY SHOT YOU HAVE.', speed: 1.20, pace: 1.20, wide: 1.22, flub: 0.72 },
{ short: 'CLUB PRO', name: 'THE CLUB PRO',
line: 'HE GAVE LESSONS ON THIS COURT.', speed: 1.12, pace: 1.15, wide: 1.15, flub: 0.8 },
line: 'HE GAVE LESSONS ON THIS COURT.', speed: 1.16, pace: 1.22, wide: 1.20, flub: 0.70 },
{ short: 'THE BULK', name: 'THE BULK', fam: 'br', h: 2.0,
line: 'NOTHING GETS PAST HIM TWICE.', speed: 1.0, pace: 1.3, wide: 1.2, flub: 0.75 },
line: 'NOTHING GETS PAST HIM TWICE.', speed: 1.02, pace: 1.34, wide: 1.24, flub: 0.66 },
{ short: 'DIRECTOR', name: 'THE DIRECTOR', fam: 'dr',
line: 'HE CAN HAVE YOU REMOVED.', speed: 1.22, pace: 1.22, wide: 1.25, flub: 0.65 },
// 12: the man whose name is on the honours board. `h` is WORLD height (zombieH reads it, so the
line: 'HE CAN HAVE YOU REMOVED.', speed: 1.26, pace: 1.28, wide: 1.28, flub: 0.60 },
// 18: the man whose name is on the honours board. `h` is WORLD height (zombieH reads it, so the
// serve aims at the body actually drawn): four family rolls could not make the art itself bulky
// against the pose sheet — see _famroll.py's pair-reference note — so his size is stature.
// Six new rungs stand between him and the start now, so he took the step up Oliver asked for:
// he reads the court faster, hits harder, paints wider, and misses half as often as he did.
{ short: 'CHAMPION', name: 'THE CHAMPION', fam: 'ch', h: 2.1,
line: 'HE RETURNS EVERYTHING.', speed: 1.35, pace: 1.3, wide: 1.3, flub: 0.5 },
line: 'HE RETURNS EVERYTHING.', speed: 1.45, pace: 1.42, wide: 1.36, flub: 0.38 },
];
// Who opponent N is. This used to reuse the chapter roster's cycling (bossArt) so the two lists
@@ -872,6 +922,10 @@ export const matchFoe = n => {
const i = (n - 1) % OPPONENTS.length;
return { key: 'L' + i, fam: 'r', ...OPPONENTS[i] };
};
// How many rungs there ARE, for everything that has to know the shape of the ladder rather than
// one rung of it: the ramp test, and the bracket screen's shuffle. OPPONENTS stays private —
// exporting the length is the whole of what anyone outside needs.
export const LADDER = OPPONENTS.length;
// ---- THE SPRITE BENCH's stops (back room only, see beats.startBench). Each is either an ANIMATED
// state or ONE NAMED FRAME, so frames the game cannot reach yet — the serve, the fall — can still be
@@ -905,7 +959,8 @@ export const BENCH_STAGES = ['HEALTHY', 'ARM OFF', 'HEADLESS'];
// that (15 to 48 away from z_br1, where a good frame scores under 8.5) and refuses them, which is
// the gate working, not the gate being wrong. Re-registering it is one entry here; see the note at
// the top of _master.py for the fix its next attempt needs.
export const ZFAMS = ['r', 'br', 'sh', 'bb', 'st', 'ru', 'gk', 'lj', 'dr', 'ch', 'ph', 'ma'];
export const ZFAMS = ['r', 'br', 'sh', 'bb', 'st', 'ru', 'gk', 'lj', 'dr', 'ch', 'ph', 'ma',
'hc', 'sg', 'sb', 'cu', 'sp', 'rm'];
// HEAD CROPS for the opponent intro card — a straight source-pixel crop out of each family's own
// healthy frame (z_<fam>1.png), so the card's icon is the body on court, never separate art.
@@ -977,8 +1032,14 @@ for (let i = 0; i < BOSS_ART.length; i++) // the chapter bosses, named wher
// Separate keys, not shared ones: the two are different fights with different bodies, they are
// counted on different screens (the mode split in stats.js), and one row cannot mean both. The 'L'
// prefix is kept as-is because it is written into saved run records — renaming it would orphan them.
for (let i = 0; i < BOSS_ART.length; i++)
ENEMIES['L' + i] = { name: BOSS_ART[i].name, spr: 'z_r1', sub: OPPONENTS[i].line };
// ...and it reads OPPONENTS, not BOSS_ART. It used to take the NAME off the boss roster and the
// line off this one — which was the same answer only while the two lists were the same list. They
// were decoupled 2026-08-13 and the screen has been mislabelling rungs ever since (L4 printed THE
// PHYSIO over the mascot's line); at 18 rungs against 10 designs it would also have left the top
// six with no row at all. One list, its own names, its own bodies.
for (let i = 0; i < OPPONENTS.length; i++)
ENEMIES['L' + i] = { name: OPPONENTS[i].name, sub: OPPONENTS[i].line,
spr: 'z_' + (OPPONENTS[i].fam || 'r') + '1' };
// Which wound stage a body is in: 0 = unhurt, 1..n = the skins, spread evenly over its full health.
// This is the rule the chapter boss already used, and it turns out to reproduce the brute's old
+23 -8
View File
@@ -545,6 +545,12 @@ export function advancePoint(side) { // shared scoring math; returns whether a g
return false;
}
// A DECIDED FLIGHT. Everywhere but netplay the ball stops dead where it always has; over the wire
// it keeps its physics and rolls to a natural halt, because the striker's phone is a whole court
// and a latency away from the call and freezing its ball mid-roll read as the game loop stopping
// (Oliver, 2026-08-15). `ball.scored` is what switches off every scoring consequence downstream.
const endFlight = () => { if (G.net) ball.scored = true; else ball.active = false; };
export function pointTo(side, reason) { // exported for net.js: a remote verdict lands here
// netplay: this phone judged the live flight — the verdict goes over the wire before it is
// applied, so both scoreboards move on the same call. netJudging() is false while a REMOTE
@@ -1005,6 +1011,7 @@ function matchOutOfReachFlight(rz, pred) {
function onBounce() {
ball.bounces++;
pock(0.35, 500);
if (ball.scored) return; // a called ball still bounces and still sounds; it just cannot score twice
// a deferred praise word that met no body lands with the ball — the DRAW was still perfect
if (ball.praise) { popText(ball.x, 0.3, ball.z, ball.praise); ball.praise = null; }
if (G.mode === 'splatter' && ball.bomb && ball.bounces === 1) { // a bomb that missed everyone still blows up
@@ -1022,7 +1029,7 @@ function onBounce() {
if (G.net && ball.lastHit === 'P') return;
if (ball.bounces === 1) {
const okSide = ball.lastHit === 'P' ? ball.z < COURT.netZ : ball.z > COURT.netZ;
if (!inCourt(ball.x, ball.z) || !okSide) { ball.active = false; resolveInterlude(other(ball.lastHit)); voice('out'); }
if (!inCourt(ball.x, ball.z) || !okSide) { endFlight(); resolveInterlude(other(ball.lastHit)); voice('out'); }
// THE FALL BEATS THE BALL (Oliver: "the zombie falls after the ball is lost although we know
// this earlier — it could play out as ball lost BECAUSE of the falling"). A winner that just
// bounced in and is provably beyond the body's reach has already decided the point, so it
@@ -1047,7 +1054,7 @@ function onBounce() {
resolveInterlude('P');
G.matchEarly = false;
}
} else if (ball.bounces === 2) { ball.active = false; resolveInterlude(ball.lastHit); }
} else if (ball.bounces === 2) { endFlight(); resolveInterlude(ball.lastHit); }
return;
}
if (G.mode !== 'tennis') return; // no lines, no faults — the umpire has other concerns
@@ -1059,16 +1066,16 @@ function onBounce() {
if (ball.bounces === 1) {
const wasServe = G.wasServe; G.wasServe = false;
if (wasServe) {
if (!inServeBox(ball.x, ball.z)) { ball.active = false; fault(); }
if (!inServeBox(ball.x, ball.z)) { endFlight(); fault(); }
return;
}
// must land on the side opposite the hitter
const okSide = ball.lastHit === 'P' ? ball.z < COURT.netZ : ball.z > COURT.netZ;
if (!inCourt(ball.x, ball.z) || !okSide) {
ball.active = false; pointTo(other(ball.lastHit), 'out');
endFlight(); pointTo(other(ball.lastHit), 'out');
}
} else if (ball.bounces === 2) {
ball.active = false; pointTo(ball.lastHit, null);
endFlight(); pointTo(ball.lastHit, null);
}
}
@@ -1845,7 +1852,14 @@ export function update(dt) {
// (predictLanding is exact), so a ball that will bounce in and is provably beyond the body's
// reach resolves MID-FLIGHT — the collapse starts while the ball is still in the air. The
// bounce-1 hook in onBounce stays as the fallback for anything too close to call up here.
if (G.match && !G.match.ending && !G.match.decided && G.phase === 'rally'
// !G.net for the same reason its bounce-1 sibling carries it, and MISSING IT WAS THE DEATHMATCH
// DESYNC ("both players stand on the same side and nobody can serve", cellular): this predicts
// the CPU's legs, and a human on another phone is not predictable. Worse, it made the STRIKER a
// judge of its own outbound flight — resolveInterlude sends the verdict — so on a slow link both
// phones scored the same rally, the two scoreboards drifted a point apart, and setupPoint then
// placed each P on its own deuce/ad side while each waited for the other to serve. The one rule
// is that the receiving phone judges; this was the one place still breaking it.
if (G.match && !G.net && !G.match.ending && !G.match.decided && G.phase === 'rally'
&& ball.active && ball.lastHit === 'P' && ball.vz < 0 && ball.z < COURT.netZ - 0.3
&& matchFallWillPlay()) {
const arz = zombies.find(z => z.variant === 'r');
@@ -1875,7 +1889,8 @@ export function update(dt) {
onBounce();
if (ball.active && Math.abs(ball.vy) < 0.8) { // rolled out — dead ball
ball.active = false;
if (G.mode === 'splatter' && G.interlude) { // a roller nobody returned: point to the hitter
if (ball.scored) { /* already called: this IS the natural halt it was left rolling for */ }
else if (G.mode === 'splatter' && G.interlude) { // a roller nobody returned: point to the hitter
if (ball.lastHit === 'X') spentBall(); // spent on the boss's body — no point, re-serve the duel
else resolveInterlude(ball.lastHit);
} else if (G.mode === 'splatter') { // hand the server a fresh one, fast — they're coming
@@ -1885,7 +1900,7 @@ export function update(dt) {
}
}
// 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) {
if (G.mode === 'splatter' && ball.active && !ball.scored && ball.lastHit === 'O' && ball.z > COURT.L + 0.3) {
ball.active = false;
if (G.interlude) resolveInterlude('O'); // sudden death: its passing shot ends the duel
else { if (!G.horde && !G.clearing) splatPoint('O'); spentBall(); } // clearing = mop-up, no scoring either way
+80 -7
View File
@@ -3,7 +3,7 @@
// Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update)
// render (camera/drawing) · input (keys/touch) · audio (sfx/umpire) · panel (T tuning UI)
import { TUNE, COURTS, TOURS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt } from './config.js';
import { TUNE, COURTS, TOURS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, CUP_ROUNDS, matchFoe } from './config.js';
import { P, O, ball, score, G, setupPoint, selectCourt, selectTour } from './state.js';
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.js';
import { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs } from './beats.js';
@@ -117,7 +117,8 @@ function netChip(label, join, death = false) {
if (!code) return;
}
start('classic'); // the waiting court is classic either way; a death room swaps it on pairing
if (startNetVs()) netConnect(code, death);
// the host's court rides the room too (server.py) — the guest plays where the host plays
if (startNetVs()) netConnect(code, death, picked);
});
return b;
}
@@ -219,6 +220,69 @@ for (const c of COURTS) {
courtList.appendChild(b);
}
// ---- THE DRAW (Oliver, 2026-08-15: "when starting the tournament there should be a view of the
// tournament hierarchy but only the finale is settled — the game against the champion... random
// browsing through the roster in the individual spots which begins fast, gets slower and then
// settles on a random character, so that every tournament is new").
//
// THE SCREEN IS THE DRAW, NOT A TEASER. `cupPicks` is what walks on: startMatch takes it and
// cupDraw reads it instead of rolling its own, so the face you watched land in ROUND ONE is the
// body across the net. A screen that showed a different draw from the one being played would be
// worse than no screen — it would teach the player that the ceremony is a lie.
//
// The roll is one recursive setTimeout per row whose gap grows with p^2 — fast, then visibly
// slowing, then stopped. No rAF: the menu deliberately parks the game loop (see menuUp), and three
// staggered timers are the whole animation.
const cupRows = document.getElementById('cupRows'), cupGo = document.getElementById('cupGo');
let cupPicks = null, cupSpins = [];
// the portrait: every family's own head crop (assets/_heads.py), the club pro's included — the
// same file the in-play walk-on card uses, so the draw and the court show the same face
const foeArt = f => `assets/z_${f.fam}head.png?v=${SPRV}`;
function buildBracket() {
for (const t of cupSpins) clearTimeout(t);
cupSpins = [];
cupPicks = CUP_ROUNDS.map(r => r.band[Math.floor(Math.random() * r.band.length)]);
cupRows.innerHTML = '';
cupGo.disabled = true;
document.getElementById('brState').textContent = 'DRAWING…';
document.getElementById('brSum').innerHTML =
`<span>ONLY THE FINAL WAS EVER SETTLED</span><span>${
(COURTS.find(c => c.id === picked) || COURTS[0]).name}</span>`;
CUP_ROUNDS.forEach((r, i) => {
const fixed = r.band.length === 1; // the final: one rung, nothing to roll
const row = document.createElement('div');
row.className = 'cupRow' + (fixed ? ' fin set' : '');
row.innerHTML = `<u>${r.name}</u><img alt=""><b></b><i>${
r.need === 1 ? 'ONE GAME' : r.need + ' GAMES'}</i>`;
cupRows.appendChild(row);
const img = row.querySelector('img'), nm = row.querySelector('b');
const show = n => { const f = matchFoe(n); img.src = foeArt(f); nm.textContent = f.name; };
if (fixed) return show(cupPicks[i]);
const t0 = performance.now(), len = 1500 + i * 550; // 1.5 / 2.05 / 2.6 s — they land in order
(function roll() {
const p = Math.min(1, (performance.now() - t0) / len);
if (p >= 1) {
show(cupPicks[i]);
row.classList.add('set');
if (cupRows.querySelectorAll('.set').length === CUP_ROUNDS.length) {
cupGo.disabled = false;
document.getElementById('brState').textContent = 'THE DRAW IS MADE';
}
return;
}
show(r.band[Math.floor(Math.random() * r.band.length)]);
cupSpins.push(setTimeout(roll, 45 + 330 * p * p));
})();
});
}
document.getElementById('brBack').addEventListener('click', () => {
for (const t of cupSpins) clearTimeout(t); // a screen you walked out of must stop spinning
cupSpins = [];
go('setup');
});
document.getElementById('cupGo').addEventListener('click', () => start('cup'));
// what the setup footer says you are about to play, and what it is worth beating
function summary() {
const t = TOURS.find(x => x.id === pickedTour) || TOURS[0];
@@ -267,7 +331,9 @@ function start(modeId = pickedMode) {
// the difficulty rides on G like G.classic does, so a mid-run restart keeps the CPU you chose
G.diff = G.classic ? (DIFFS.find(d => d.id === pickedDiff) || DIFFS[1]) : null;
if (modeId === 'zombie') { intoSplatter(); startMatch(); }
if (modeId === 'cup') { intoSplatter(); startMatch(true); }
// ...and the cup carries the draw the bracket screen just made. Null (the #cup back door) still
// means "roll it yourself", which is what the mode did before the screen existed.
if (modeId === 'cup') { intoSplatter(); startMatch(true, cupPicks); }
}
let stPeriod = 'all', stMode = stats.MODE_GORE;
// the stats screen opens on the mode you last PICKED to play — that is the one whose numbers you
@@ -328,7 +394,12 @@ nbName.addEventListener('keydown', e => { if (e.key === 'Enter') document.getEle
// SERVE UP dropped you into the gore mode whatever the setup screen said, and picking CLASSIC or
// ZOMBIE TENNIS did nothing at all. A default parameter only applies to `undefined`, and a click
// handler always passes something.
document.getElementById('cStart').addEventListener('click', () => start());
// SERVE UP starts the run — except in the cup, where the draw has to be made first. That screen
// carries its own start button, so this stays the one place a run begins from the setup screen.
document.getElementById('cStart').addEventListener('click', () => {
if (pickedMode === 'cup') { buildBracket(); go('bracket'); return; }
start();
});
for (const id of ['mBack', 'stBack', 'rkBack', 'scBack'])
document.getElementById(id).addEventListener('click', () => go('splash'));
// the wizard walks back the way it came: who-and-where returns to the mode's own second step when
@@ -476,7 +547,9 @@ addEventListener('keydown', e => {
if (stage !== 'setup') return; // stats and ranks are read-only: Escape is the whole interface
const n = parseInt(e.key, 10);
if (n >= 1 && n <= COURTS.length) courtList.children[n - 1].click();
else if (e.key === 'Enter' || e.key === ' ') start();
// through the BUTTON, not start() — the cup's draw screen sits between the two and a keyboard
// must not be the one way to walk past it
else if (e.key === 'Enter' || e.key === ' ') document.getElementById('cStart').click();
});
// A run the player walks out on is still a run. killPlayer banks a death; this banks everything
@@ -590,11 +663,11 @@ if (location.hash === '#bench') setTimeout(() => { start(null); intoSplatter();
// #net hosts classic, #netd hosts a death match, #net-ABCD joins — the netplay doors from a URL
if (location.hash === '#netd') setTimeout(() => {
start('classic');
if (startNetVs()) netConnect(null, true);
if (startNetVs()) netConnect(null, true, picked);
}, 700);
else if (location.hash.startsWith('#net')) setTimeout(() => {
start('classic');
if (startNetVs()) netConnect(location.hash.slice(5).toUpperCase() || null);
if (startNetVs()) netConnect(location.hash.slice(5).toUpperCase() || null, false, picked);
}, 700);
requestAnimationFrame(frame);
+37 -9
View File
@@ -18,7 +18,7 @@
// ponytail: no reconnect-and-resume. A dropped radio ends the match with a card; the recorded
// plan calls reconnection "the bulk of the real work" and a prototype must not start there.
import { COURT, UI } from './config.js';
import { G, P, O, ball, score, setupPoint } from './state.js';
import { G, P, O, ball, score, setupPoint, selectCourt, court } from './state.js';
import { pointTo, fault, showModal, tossBall } from './gameplay.js';
import { startMatch, matchReady, resolveInterlude, netMatchWon } from './beats.js'; // runtime-only cycle: beats sends verdicts back through this module
import { zombies } from './zombies.js';
@@ -83,7 +83,7 @@ export function netApplyShot(m) {
function netApplyPoint(m) {
applying = true; // their verdict — apply it without judging or echoing
ball.active = false;
ball.scored = true; // ...and OUR ball keeps rolling to its own halt (see endFlight)
const side = m.win === 'me' ? 'O' : 'P';
if (G.match) resolveInterlude(side); // death match: the interlude's plumbing owns the point
else pointTo(side, m.reason);
@@ -92,14 +92,36 @@ function netApplyPoint(m) {
function netApplyFault() {
applying = true;
ball.active = false;
ball.scored = true; // the fault is called; the serve still lands and rolls away
fault(); // we are the server here: second serve arms exactly as ever
applying = false;
}
function netStart(side, death) {
// THE SCOREBOARDS RECONCILE, and the host is the tie-break. Every verdict is a message, and a
// message that never lands leaves the two sims a point apart forever — which is not a cosmetic
// drift: setupPoint reads score.pts for the deuce/ad side and score.server for who tosses, so one
// lost 'pt' put BOTH players on the same half of their own court, each waiting for the other to
// serve, with no way out (Oliver, over cellular). The gate is `pretoss` ONLY: that is the settled
// state between points, where nothing is mid-beat and any verdict still in flight has long landed.
// Mirrored like everything else here — their P is our O.
// ponytail: server + points only. score.games can drift too, but it costs a wrong number on a
// board rather than a court nobody can serve on; sync it if that ever actually happens.
function netSync(m) {
if (!G.net.ready || G.net.dead || G.phase !== 'pretoss' || !m.p) return;
const srv = m.srv === 'P' ? 'O' : 'P', pts = [m.p[1], m.p[0]];
if (score.server === srv && score.pts[0] === pts[0] && score.pts[1] === pts[1]) return;
score.server = srv; score.pts = pts;
if (G.match) matchReady(); else setupPoint();
}
function netStart(side, death, courtId) {
G.net.ready = true; G.net.side = side;
G.modal = null; G.modalQ.length = 0; // the waiting card has done its job
// THE HOST'S COURT RIDES THE ROOM, exactly as the death-match pick does — two phones playing the
// same rally on two different surfaces disagree about the bounce as well as the picture, and the
// guest never chose. Safe mid-run: selectCourt copies the bounce pair into TUNE and the backdrop
// cache keys on court.id, so the next frame repaints.
if (courtId && courtId !== court.id) selectCourt(courtId);
if (death && !G.match) {
// THE HOST PICKED DEATH MATCH, and the start message carries it to both phones — the joiner
// (and the host itself) has been waiting on an ordinary classic court, so the court is rebuilt
@@ -145,7 +167,7 @@ export function netMsg(m) {
showModal({ title: m.code, color: UI.pick,
body: ['THE ROOM CODE — TELL YOUR OPPONENT', 'WAITING FOR THEM TO JOIN'] });
}
else if (m.t === 'start') netStart(m.side, m.death);
else if (m.t === 'start') netStart(m.side, m.death, m.court);
else if (m.t === 'gone') netDead('NO SUCH ROOM — RELOAD AND HOST ONE');
else if (m.t === 'bye') netDead('YOUR OPPONENT LEFT — RELOAD TO PLAY AGAIN');
else if (m.t === 'toss') netApplyToss();
@@ -153,7 +175,7 @@ export function netMsg(m) {
else if (m.t === 'pt') netApplyPoint(m);
else if (m.t === 'fault') netApplyFault();
else if (m.t === 'dead') netMatchWon(); // their blood ran out: this side's win ending
else if (m.t === 'pos') G.net.opp = m;
else if (m.t === 'pos') { G.net.opp = m; if (m.srv) netSync(m); }
}
// ticked from update() while G.net: the position stream out, the O sprite in
@@ -162,7 +184,10 @@ export function netTick(dt) {
if (!n || !n.ready) return;
if ((posT += dt) >= 0.1) {
posT = 0;
send({ t: 'pos', x: r3(P.x), z: r3(P.z), m: P.pose === 'run' ? 1 : 0 });
// the HOST's stream carries the scoreboard as well (netSync) — one authority, no new message
// type, and it only rides the frames where the guest can actually act on it
send({ t: 'pos', x: r3(P.x), z: r3(P.z), m: P.pose === 'run' ? 1 : 0,
...(n.side === 'A' && G.phase === 'pretoss' ? { srv: score.server, p: score.pts } : {}) });
}
const o = n.opp;
if (o) {
@@ -175,9 +200,12 @@ export function netTick(dt) {
}
}
export function netConnect(code, death = false) {
export function netConnect(code, death = false, courtId = null) {
// joining carries only the code — the host's room already knows the mode and the court
const q = code ? ['room=' + encodeURIComponent(code)]
: [death ? 'death=1' : '', courtId ? 'court=' + encodeURIComponent(courtId) : ''].filter(Boolean);
const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host
+ '/ws' + (code ? '?room=' + encodeURIComponent(code) : death ? '?death=1' : '');
+ '/ws' + (q.length ? '?' + q.join('&') : '');
ws = new WebSocket(url);
ws.onmessage = e => { let m; try { m = JSON.parse(e.data); } catch { return; } netMsg(m); };
ws.onclose = () => netDead('THE LINE DROPPED — RELOAD TO PLAY AGAIN');
+53 -6
View File
@@ -221,7 +221,7 @@ function farB(z) {
// Asset cache-buster — bump with main.js?v=N (see the img.src note below). EXPORTED because the
// menu screens hang <img> tags on the same files, and two copies of this number is one copy too
// many: the one that does not get bumped is the one that serves a cached 404.
export const SPRV = 125;
export const SPRV = 129;
// The boot bar in main.js watches this. Nothing here CHANGES when things load — every sprite was
// already requested at import — it just makes the wait visible and holds the menu back until the
// last sheet has landed. See the onerror note below for the other half of "sometimes missing".
@@ -273,9 +273,10 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','
// filenames typed out here (each of which is a chance to typo one and have a body
// vanish for a frame — the oldest bug in this file).
...ZFAMS.flatMap(famFrames),
// the card's 4K-cut head icons (assets/_heads.py). 'r' has no fam sheet — his
// card keeps the HEAD_CROP fallback, so no file and no 404 for him.
...ZFAMS.filter(k => k !== 'r').map(k => 'z_' + k + 'head'),
// the card's head icons (assets/_heads.py), 'r' included since 2026-08-15: the
// club pro's is cut from z_c1, the turned opponent, who IS him. The HEAD_CROP
// fallback below him stays as the net for a family that has no head file yet.
...ZFAMS.map(k => 'z_' + k + 'head'),
'w_ball','w_bomb','w_triple','w_fire','w_gun', // weapon-rack icons
'w_beacon','w_laser','w_rocket','w_flamer', // ...and the four that come out of a barrel
'z_barrel1','z_barrel2', // the weapon barrel, two frames so it ROLLS
@@ -348,6 +349,50 @@ function buildCrowd() {
});
}
// CAMERA FLASHES IN THE STAND (Oliver, 2026-08-15: "random sporadic subtle flashes like someone is
// photographing, slightly increasing during applause"). AN OVERLAY, never backdrop content: the
// backdrop is one cached blit and anything that moves in it would repaint the whole scene every
// frame (see drawBackdrop's key). Clocked off G.idleT rather than dt for the same reason the clap
// ripple is — this function has no dt, and idleT is the one clock that never stops.
// Each flash is a two-frame pop: a hot core, a soft bloom, gone in ~TUNE.flashLife. Rate doubles
// while the crowd is on its feet, which is the "slightly increase during applause" — a stand that
// went white during a point would be a different, much louder feature.
const flashes = []; // {x, y, r, t0}
let flashNext = 0;
function tickFlashes(standsBottom) {
const now = G.idleT, top = Math.max(4, standsBottom - AD_H - 4);
if (now >= flashNext) {
// the gap is exponential-ish so they never look metronomic; cheer halves the mean wait
const mean = TUNE.flashEvery / (crowd.cheer > 0 ? 2.2 : 1);
flashNext = now + mean * (0.35 + Math.random() * 1.6);
flashes.push({ x: 6 + Math.random() * (W - 12), y: 6 + Math.random() * (top - 6),
// small. The first cut was r 2.2-4.2 and its bloom drew a 6 px disc that read as
// a LAMP in the stand rather than a shutter going off half a second ago
r: 0.9 + Math.random() * 1.1, t0: now });
if (flashes.length > 12) flashes.shift();
}
for (let i = flashes.length - 1; i >= 0; i--) {
const f = flashes[i], a = 1 - (now - f.t0) / TUNE.flashLife;
if (a <= 0) { flashes.splice(i, 1); continue; }
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.globalAlpha = a * a * 0.30; ctx.fillStyle = '#8fa6d8'; // the bloom, cold and dim
ctx.beginPath(); ctx.arc(f.x, f.y, f.r * 2.4, 0, 7); ctx.fill();
ctx.globalAlpha = Math.min(1, a * 1.5); ctx.fillStyle = '#fdfdff'; // the bulb itself
ctx.beginPath(); ctx.arc(f.x, f.y, f.r * 0.8, 0, 7); ctx.fill();
ctx.restore();
}
ctx.globalAlpha = 1;
}
// How dark the STAND is: the court's own night (`dim` on its COURTS row — 0 everywhere but the
// night court) plus the rot the splatter palette brings with it. ONE function because it is applied
// in two places and they must agree exactly — the cached sheet and the live clap columns drawn over
// it. Two copies of this number is a stand whose clapping columns pop brighter than the rest of it,
// which is the same class of bug as the crowd frames drifting apart. Capped: past ~0.85 the stand
// is a black rectangle and the applause stops reading at all.
const standDim = pt => Math.min(0.85, (court.dim || 0) + 0.26 * pt);
// ---------------------------------------------------------------- backdrop ----
// THE STATIC SCENE, PRE-COMPOSED. Stands, ads, rot tint, out area, court, lines and the run-off
// apron are pixel-identical frame to frame except while a palette lerps (the 6 s transition, the
@@ -389,7 +434,8 @@ function paintBackdrop(c, pal, pt) {
// the rot dims the stadium: one tint over crowd + boards, fading home during duels. 0.26, not
// 0.5: on an already-dark crowd sheet 0.5 buried the stands; 0.26 still reads as "the lights
// went wrong".
if (pt > 0) { c.fillStyle = `rgba(12,8,20,${0.26 * pt})`; c.fillRect(0, 0, W, standsBottom); }
const dim = standDim(pt);
if (dim > 0) { c.fillStyle = `rgba(12,8,20,${dim})`; c.fillRect(0, 0, W, standsBottom); }
// ground + court (Super Tennis lawn); everything outside the doubles court is OUT
const dw = COURT.dblW / 2;
quadXZ(-14, -6, 14, 30, pal.out); // out area. NEVER let z approach
@@ -460,7 +506,7 @@ function drawBackdrop(pal, pt) {
if (crowd.cheer > 0 && crowdImg && clapImg) {
const crowdH = standsBottom - AD_H + 2;
const cols = 16, sw = clapImg.width / cols, cw = W / cols;
const tint = pt > 0 ? `rgba(12,8,20,${0.26 * pt})` : null;
const d = standDim(pt), tint = d > 0 ? `rgba(12,8,20,${d})` : null;
ctx.save(); ctx.beginPath(); ctx.rect(0, 0, W, standsBottom - AD_H); ctx.clip();
for (let i = 0; i < cols; i++) {
if (!(Math.floor(G.idleT * 9 + i * 0.7) % 2)) continue;
@@ -469,6 +515,7 @@ function drawBackdrop(pal, pt) {
}
ctx.restore();
}
tickFlashes(standsBottom); // ...and somebody in there is taking pictures
return standsBottom;
}
+7 -1
View File
@@ -8,6 +8,12 @@ export const P = { x: 1.3, z: 22.8, side: 'P', pose: 'idle', poseT: 0, swingT: -
export const O = { x: -1.3, z: 1.0, side: 'O', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 };
export const ball = { x: 0, y: 1, z: 20, vx: 0, vy: 0, vz: 0, g: 9.2,
active: false, lastHit: 'P', bounces: 0, collected: false, kills: 0, bomb: false, flame: false,
// NETPLAY: THE CALLED BALL PLAYS OUT. A verdict used to freeze the ball where it was — on the
// striker's phone that is mid-roll, a whole court away from the call, and it read as the game
// loop stopping. `scored` means "this flight is decided": physics keeps running, every scoring
// consequence below is switched off, and the ball dies where a real one would, by rolling to a
// stop. Cleared by setupPoint with everything else that arms a point.
scored: false,
praise: null, // a perfect serve ARMS its praise word here; it pops at the impact, not the release
// which weapon launched the ball in flight — the stats screen files a kill under it long after
// the WEAPONS row itself is out of scope. Lives on the ball so PRISTINE resets it for free.
@@ -171,7 +177,7 @@ export const other = s => s === 'P' ? 'O' : 'P';
export const deuceSide = () => (score.pts[0] + score.pts[1]) % 2 === 0; // even points → deuce (right) side
export function setupPoint() {
G.phase = 'pretoss'; ball.active = false; ball.bounces = 0;
G.phase = 'pretoss'; ball.active = false; ball.bounces = 0; ball.scored = false;
G.swingSnaps = null; G.rallyNear = null; G.swung = false;
if (G.mode === 'splatter' && !G.match) { // always your serve, from wherever you're standing
// (the MATCH falls through to the tennis branch below: