The leagues, phase 2: five bands, pace tiers, locked courts, the members' ground, the promotion ceremony

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-29 12:43:23 +02:00
1 parent 9133c0d351
commit 16bf5b9fed
12 files changed
+406 -71

No files matched your search

+47 -17
View File
@@ -7,9 +7,9 @@
// (beats <-> gameplay and beats <-> zombies are runtime-only import cycles, same pattern as
// input <-> gameplay: the imported bindings are only touched inside functions, never at load.)
import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME, racketStage, CUP_ROUNDS } from './config.js';
import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME, racketStage, leagueOf, cupRoundsFor } from './config.js';
import { endRun, MODE_MATCH } from './stats.js';
import { award } from './profile.js';
import { award, prof, save as profSave, push as profPush, setPromo } from './profile.js';
import { G, P, O, ball, score, setupPoint, sendBoy, other } from './state.js';
import { voice, zombieDuck, aww } from './audio.js';
import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.js';
@@ -354,7 +354,7 @@ 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).
// `draw` is the bracket screen's settled rungs, one per CUP_ROUNDS entry (main.buildBracket). It is
// `draw` is the bracket screen's settled rungs, one per cup round (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.
// `train` (2026-08-28) is a RUNG NUMBER and turns the run into the TRAINING COURT: that one
@@ -363,12 +363,20 @@ export function watchdog(dt) {
// it rides the ordinary match machinery behind the d.train flag; see matchGameWon / matchPoint.
export function startMatch(cup = false, draw = null, train = 0) {
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return false;
G.match = { opp: train || 1, ending: false, train: !!train };
// THE LEAGUES: the profile's league is the world (band, pace tier, court — see PROJECT.md).
// Stamped onto G so gameplay's leaguePace() and the dials below never read the profile module.
// Netplay and training stay league-1 worlds: a human opponent and a practice rep both play the
// shipped game (train still picks any met rung, but at the shipped pace).
G.league = G.net || train ? 1 : prof.lg;
const band = leagueOf(G.league).band;
// the arcade ladder opens at the league's own bottom rung and never leaves the band: past its
// top the league simply has nobody stronger, so the top body walks on again (see matchGameWon)
G.match = { opp: train || (G.net ? 1 : band[0]), ending: false, train: !!train, felled: 0 };
// 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, draw: draw || null }; cupDraw(); }
// by the round's `need`), and where a won game goes (cupGameWon). See config.cupRoundsFor.
if (cup) { G.match.cup = { round: 0, g: 0, draw: draw || null, rounds: cupRoundsFor(G.league) }; 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
@@ -412,9 +420,17 @@ export function startMatch(cup = false, draw = null, train = 0) {
// 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];
const cup = G.match.cup, R = cup.rounds[cup.round];
const drawn = cup.draw && cup.draw[cup.round];
cup.rung = R.band.includes(drawn) ? drawn : R.band[Math.floor(Math.random() * R.band.length)];
// WITHOUT REPLACEMENT: a league band is 3-4 rungs and every non-final round carries the whole
// sub-band, so the run remembers who has already fallen (cup.played) and never deals a body
// twice — a repeat would read as the bracket screen lying. The final's band is one rung and
// sails through untouched.
cup.played = cup.played || [];
const pool = R.band.filter(r => !cup.played.includes(r));
cup.rung = R.band.includes(drawn) && !cup.played.includes(drawn) ? drawn
: (pool.length ? pool : R.band)[Math.floor(Math.random() * (pool.length ? pool : R.band).length)];
cup.played.push(cup.rung);
G.match.opp = cup.rung;
}
@@ -649,8 +665,8 @@ function matchOpponent() {
// In the cup the round names itself on the second line.
const cup = G.match.cup;
showModal({ title: zb.foe.name, color: UI.hot, icon: 'z_' + zb.fam + 'head', iconH: 64,
body: [zb.foe.line].concat(cup ? [CUP_ROUNDS[cup.round].name + (CUP_ROUNDS[cup.round].need > 1
? ' · FIRST TO ' + CUP_ROUNDS[cup.round].need + ' GAMES' : '')] : []) });
body: [zb.foe.line].concat(cup ? [cup.rounds[cup.round].name + (cup.rounds[cup.round].need > 1
? ' · FIRST TO ' + cup.rounds[cup.round].need + ' GAMES' : '')] : []) });
// ...and a fresh body has read NOTHING yet: the habit tracker starts clean (see THE READ, config)
G.match.laneRun = 0; G.match.lastLane = undefined; G.match.surprised = G.match.expected = false;
// a FLAT pool per body: with regicide gone the body is pure scoreboard (matchPoint takes a
@@ -658,7 +674,7 @@ function matchOpponent() {
// racketAI instead (matchSpeedPer / matchFlubKeep). THE CUP MULTIPLIES BY THE ROUND'S NEED and
// matchPoint divides the same way, so the wound stages spread across the whole round instead of
// being spent in game one — the body enters game two already missing what game one cost it.
zb.hp = zb.hpMax = TUNE.matchOppHp * (cup ? CUP_ROUNDS[cup.round].need : 1);
zb.hp = zb.hpMax = TUNE.matchOppHp * (cup ? cup.rounds[cup.round].need : 1);
G.interlude = { introT: 1.6 }; // the duel machinery IS the match machinery
}
@@ -683,7 +699,7 @@ function matchPoint(side) {
// and only the game-winning point actually fells it, so the body and the scoreboard agree all
// the way to the end. (Ball hits wound it on top of this; that is the regicide path, kept.)
const was = racketStage(zb);
const div = 4 * ((d.cup && CUP_ROUNDS[d.cup.round].need) || 1); // the cup spreads the pool over the round
const div = 4 * ((d.cup && d.cup.rounds[d.cup.round].need) || 1); // the cup spreads the pool over the round
zb.hp = Math.max(1, zb.hp - Math.ceil(zb.hpMax / div));
// ONLY when the stage actually moved. A point that chips the pool without crossing a threshold
// has no limb to explain, and a second of collapse for nothing is a beat the player waits out.
@@ -774,7 +790,12 @@ function matchGameWon(zb, banked = false) {
// and handing them the roster's next name and face would break the fiction (the ladder dials the
// opp number drives are AI-only and dormant here anyway). Training parks the ladder the same
// way: the rung you picked is the rung that walks back on, as many times as you want it.
if (!G.net && !d.train) d.opp++;
if (!G.net && !d.train) {
d.felled = (d.felled || 0) + 1; // bodies actually felled (the board's number)
// ...and the ladder climbs only inside the league's band: past its top rung the league has
// nobody stronger, so the top body walks back on (THE LEAGUES — the cup is the way up)
d.opp = Math.min(d.opp + 1, leagueOf(G.league).band[1]);
}
// ALTERNATING SERVICE, as the sport has it. The match calls advancePoint directly, which banks
// the game but never touches the server — that flip lives in tennis's own pointTo, which the
// match bypasses (the same shape as the G.serveNum bug this file already carries a note about).
@@ -830,7 +851,7 @@ function matchGameWon(zb, banked = false) {
// THE CUP'S GAME. Mid-round: the same body plays on, wearing what the games have cost it. Round
// won: the kill, the mop beat, and the next round's draw — or, past the final, the trophy.
function cupGameWon(zb, banked = false) {
const d = G.match, cup = d.cup, R = CUP_ROUNDS[cup.round];
const d = G.match, cup = d.cup, R = cup.rounds[cup.round];
d.ending = true;
if (!banked) { score.games[0]++; score.pts = [0, 0]; }
G.runGames++;
@@ -853,7 +874,7 @@ function cupGameWon(zb, banked = false) {
d.afterFall = () => {
matchHealBeat();
cup.round++; cup.g = 0;
if (cup.round >= CUP_ROUNDS.length) return cupWon();
if (cup.round >= cup.rounds.length) return cupWon();
G.flash = { text: R.name + ' — YOURS.', t: 1.8 };
// the mop beat, exactly as the deathmatch runs it: the boy owns the court, then the next draw
sendBoy(0, 4.5, true);
@@ -877,10 +898,19 @@ function cupWon() {
ball.active = false; G.flash = null; G.modal = null; G.card = null;
cheer(4, 1); voice('unbelievable', 0.4);
award('cup');
// THE PROMOTION (the leagues): the cup of your CURRENT league is the gate up. Winning it moves
// the profile, unlocks the next league's court for every mode, and parks a ceremony that
// main.maybeRestart plays once the summary is tapped away. Winning league 5 crowns the profile
// instead (prof.ch) — the summary below already says CHAMPION OF THE DEAD CLUB.
if (!d.train && G.league === prof.lg) {
if (prof.lg < 5) { prof.lg++; setPromo({ to: prof.lg }); }
else if (!prof.ch) { prof.ch = 1; setPromo({ champion: true }); }
profSave(); profPush();
}
const games = score.games[0] + score.games[1];
const b = endRun(CUP_ROUNDS.length, games, G.splatT, false, MODE_MATCH);
const b = endRun(d.cup.rounds.length, games, G.splatT, false, MODE_MATCH);
G.best = b; G.newBest = b.newBest;
d.felled = CUP_ROUNDS.length; d.games = games; d.best = b.games;
d.felled = d.cup.rounds.length; d.games = games; d.best = b.games;
G.done = { won: true, t: 0, cup: true };
}
+51 -5
View File
@@ -552,6 +552,10 @@ export const TUNE = {
matchPlayerHp: 8, // your pool for the MATCH — richer than the gore mode's 4, because a real game
// has rallies you deserve to lose without the run ending ("the player is too
// weak in this mode"). Their point still costs 1; matchHeal is how you last
leaguePace: 0.06, // THE LEAGUES' pace tier: solo match modes run at 1 + (league-1)*this —
// ball pace on BOTH rackets and the opponent's legs together, so league 1
// IS the shipped game (factor 1.0, the calibration rule) and league 5
// plays ~24% faster. The one knob for "das Spiel wird schneller".
matchSpeedBase: 1.25, // racketAI speed multiplier for opponent ONE. 1.4 was calibrated against
// the broken recovery (the player never walked back to centre, so every
// wide return looked like opponent strength) — with real footwork restored
@@ -769,6 +773,20 @@ export const COURTS = [
out: '#0f1a15', lawn: '#16261e', stripe: '#16261e' },
splatter: { stands: '#06050b', tierA: '#0b0813', tierB: '#08060f', board: '#2c0e0e',
out: '#0a120d', lawn: '#101c15', stripe: '#101c15' } },
// THE MEMBERS' GROUND (2026-08-29, THE LEAGUES): the Dead Club's decayed place of honour and the
// top league's home — Oliver picked it over the abattoir and the dawn court. BRIGHTLY decayed,
// the deliberate opposite of gloom's dark: a dead grey-green lawn gone to seed, pale weathered
// marble stands, faded gold on the boards, cracked aged-cream lines. Borrows clay's warm crowd
// and ads sheets (the same move gloom made with hard's) with a light `dim` to push the stone
// back; the surface is the FASTEST in the game — a dead dry lawn skids — which lets the court
// table itself finish the pace ladder the leagues ride (clay slowest ... members fastest).
{ id: 'members', name: "THE MEMBERS' GROUND", sub: 'DEAD LAWN · FASTEST, LOWEST SKID', tile: 'court_members',
ads: 'x_ads_c', crowd: 'x_crowd_c', stripes: true, line: '#e8e2d0', border: '#d8d2c0', dim: 0.15,
bounceY: 0.47, bounceXZ: 0.87,
tennis: { stands: '#4a463c', tierA: '#565144', tierB: '#504b40', board: '#6e5a20',
out: '#5c6b4c', lawn: '#93a273', stripe: '#8a996a' },
splatter: { stands: '#28251d', tierA: '#302c22', tierB: '#2b2820', board: '#3a2e10',
out: '#3a4630', lawn: '#5c6a46', stripe: '#556340' } },
];
// The tour, picked on the same screen as the court. Only the two TENNIS PLAYERS and the turned CPU
@@ -850,12 +868,40 @@ export const DIFFS = [
// 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, 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 },
// THE LEAGUES (2026-08-29, the plan of record in PROJECT.md). Five bands over the 18-rung ladder,
// each with its home court; the club reads bottom-up, so the bands keep the roster's own
// hierarchy (ground staff -> club staff -> officials -> coaching staff -> the honours board).
// The tournament (below) draws from the CURRENT league's band, and winning it is promotion.
// `band` is [lo, hi] inclusive rungs. Court order deliberately rides the bounce table: clay is
// the slowest surface for the slowest league, grass the first prestige, and the top of the club
// plays on THE MEMBERS' GROUND. The pace tier is TUNE.leaguePace (gameplay.leaguePace()).
export const LEAGUES = [
{ id: 1, name: 'THE GRAVEYARD SHIFT', court: 'clay', band: [1, 4] },
{ id: 2, name: 'THE CLUB STAFF', court: 'hard', band: [5, 8] },
{ id: 3, name: 'THE OFFICIALS', court: 'grass', band: [9, 12] },
{ id: 4, name: 'THE COACHING STAFF', court: 'gloom', band: [13, 15] },
{ id: 5, name: 'THE HONOURS BOARD', court: 'members', band: [16, 18] },
];
export const leagueOf = lg => LEAGUES[Math.min(LEAGUES.length, Math.max(1, lg | 0 || 1)) - 1];
// The tournament, PER LEAGUE. Same shape CUP_ROUNDS always had (name/band/need) — round one is a
// single game so a run opens fast, and the drawn body's pool still scales with `need`
// (matchOpponent). The rounds ride ON the cup object (beats.startMatch stamps them), so every
// consumer reads cup.rounds and the full-ladder constant is gone.
// A league band is 3-4 rungs, so the draw is WITHOUT REPLACEMENT: every non-final round carries
// the whole sub-band and the run deals each body at most once (cup.played / buildBracket's used
// set), the final is always the band's top rung — "only the finale is settled", still. A 4-rung
// league is four rounds (a shuffle of three challengers), a 3-rung league is three.
export const cupRoundsFor = lg => {
const [lo, hi] = leagueOf(lg).band;
const open = [];
for (let r = lo; r < hi; r++) open.push(r);
const names = open.length >= 3 ? ['ROUND ONE', 'QUARTER-FINAL', 'SEMI-FINAL']
: ['ROUND ONE', 'SEMI-FINAL'];
return open.map((_, i) => ({ name: names[i] || 'ROUND ' + (i + 1), band: open.slice(),
need: i ? 2 : 1 }))
.concat([{ name: 'THE FINAL', band: [hi], need: 2 }]);
};
export const TOURS = [
// `kit` — HOW this tour's art takes a colour, because the two sets are dressed differently and the
+13 -7
View File
@@ -1,6 +1,6 @@
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
import { TUNE, UI, bossArt, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS, racketStage, matchWound, CUP_ROUNDS, LADDER } from './config.js';
import { TUNE, UI, bossArt, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS, racketStage, matchWound, LADDER } from './config.js';
import { P, O, ball, score, crowd, G, court, tour, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js';
import { audio, pock, swish, whoosh, applause, voice, voiceSeq, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js';
import { haptic } from './haptics.js';
@@ -158,6 +158,12 @@ export const swayOf = aim => aim.mag === undefined ? aim.ax
// as flat rather than resetting the lesson it just failed to use.
export const flatCount = (streak, aim) => (aim && Math.abs(swayOf(aim)) > 0.15 ? 0 : streak + 1);
// THE LEAGUES' pace tier: solo match modes run faster per league, everything else is factor 1 —
// league 1 IS the shipped game (the calibration rule). Multiplies ball pace on BOTH rackets
// (doHit, both serve paths) and the opponent's legs (rzChaseSpeed), so the world speeds up as one
// piece and the reach tests keep believing the legs they measure. Netplay is a human and exempt.
export const leaguePace = () => G.match && !G.net ? 1 + (G.league - 1) * TUNE.leaguePace : 1;
function doHit(ch, quality, aim) {
// aim: {ax: -1|0|1, az: -1|0|1} from held keys / flick at contact (az -1 = deep, 1 = short)
const toFar = ch.side === 'P';
@@ -173,7 +179,7 @@ function doHit(ch, quality, aim) {
ball.z = ch.z + (toFar ? -0.3 : 0.3);
const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality);
let tx, tz, aimedLane = false, sway = 0;
let s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed;
let s = (TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed) * leaguePace();
const ft = G.mode !== 'tennis' && !G.interlude && ch.side === 'P' // the duel aims like tennis: lanes, not free-aim
? (aim && aim.fx !== undefined ? flickTarget(ch, aim) : { zt: zombieTarget(aim) }) : null;
if (ft && ft.zt) {
@@ -379,7 +385,7 @@ function splatServeTarget(sv, aim, s, x0, z0, ex = 0) {
// The launch itself: topspin gravity, then trade pace for arc until the net is genuinely cleared.
// A shambler just the other side of the tape is only reachable on a loft.
function serveArc(x0, y0, z0, tx, tz, quality, ty = 0.08, hard = 0) { // hard: the aimed strike's 0..1
let s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed + hard * TUNE.serveHard;
let s = (TUNE.serveSpeed + quality * TUNE.serveQualitySpeed + hard * TUNE.serveHard) * leaguePace();
// the harder you hit it, the harder it dips — and the HARD serve dives harder still
// (serveHardSpin), which is what buys its net clearance: MEASURED with the replica solver,
// +9 m/s with the old dip was cut BELOW the plain flush by the loft loop (47.2 vs 49.3).
@@ -468,7 +474,7 @@ export function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
const sx = aim && aim.sx !== undefined ? aim.sx : 0;
const hard = (aim && aim.hard) || 0;
const pace = hard * TUNE.serveHard;
const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed + pace;
const s = (TUNE.serveSpeed + quality * TUNE.serveQualitySpeed + pace) * leaguePace();
// splatServeTarget + serveArc are shared with previewServe, so the arc drawn under your thumb
// is the one the ball flies. Only the aim error is added here and left out of the preview.
const st = splatServeTarget(sv, aim, s, ball.x, ball.z, (Math.random() * 2 - 1) * (1 - quality) * 0.8);
@@ -1126,10 +1132,10 @@ function matchFallWillPlay() {
const cup = G.match.cup;
const gameWin = score.pts[0] + 1 >= 4 && score.pts[0] + 1 - score.pts[1] >= 2;
// a game win is only THE KILL when it ends the round — a cup mid-round game keeps the body
if (gameWin) return !cup || cup.g + 1 >= CUP_ROUNDS[cup.round].need;
if (gameWin) return !cup || cup.g + 1 >= cup.rounds[cup.round].need;
// the same divisor matchPoint will use — the first version kept the deathmatch /4 and in the
// cup it promised falls that matchPoint then didn't deliver (a route to the stale-latch lock)
const div = 4 * ((cup && CUP_ROUNDS[cup.round].need) || 1);
const div = 4 * ((cup && cup.rounds[cup.round].need) || 1);
const after = Math.max(1, rz.hp - Math.ceil(rz.hpMax / div));
return racketStage({ hp: after, hpMax: rz.hpMax }) !== racketStage(rz); // a limb
}
@@ -1145,7 +1151,7 @@ function matchFallWillPlay() {
// One definition, because the read boost, the wrong-foot slow and the surprise whiff must all
// answer "how smart is this one" identically or the twist pulls in three directions at once.
const learnK = rz => G.match && !G.net ? Math.min(1, Math.max(0, (G.match.opp - 1) / (LADDER - 1))) : 0;
const rzChaseSpeed = rz => TUNE.racketSpeed
const rzChaseSpeed = rz => TUNE.racketSpeed * leaguePace()
* (G.match ? (TUNE.matchSpeedBase + (G.match.opp - 1) * TUNE.matchSpeedPer) * ((rz.foe && rz.foe.speed) || 1) : 1)
* (G.wasServe && ball.lastHit === 'P' ? TUNE.aiReturnDash : 1)
// wrong-footed: a body mid-lean is slow to turn — THE READ's one-ball cost, and it lives HERE
+90 -16
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, KITS, SKINS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, CUP_ROUNDS, matchFoe, LADDER } from './config.js';
import { TUNE, COURTS, TOURS, KITS, SKINS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, LEAGUES, leagueOf, cupRoundsFor, matchFoe, LADDER } from './config.js';
import { P, O, ball, score, G, setupPoint, sendBoy, selectCourt, selectTour, selectKit, selectSkin } from './state.js';
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, startGore, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.js';
import { startDuel, resolveInterlude, startHorde, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs, endNetVs } from './beats.js';
@@ -84,7 +84,7 @@ let pickedTour = remembered('tour') || TOURS[0].id;
// every stage change is a gesture, so it is also a safe place to (re)start the theme. The setup
// screen syncs its picker rows on entry: the identity screen writes the same picked* state, and
// rows that only ever toggle on their OWN taps would keep showing the boot-time selection.
const go = stage => { if (stage === 'setup') syncSetup(); intro.dataset.stage = stage; menuMusic(); };
const go = stage => { if (stage === 'setup') syncSetup(); if (stage === 'train') buildTrain(); intro.dataset.stage = stage; menuMusic(); };
// --- MODE selection in TWO STEPS (config.MENU + config.MODES): three posters on top, and the
// picked one deals its own second step underneath — classic deals the difficulty chips, zombie
@@ -303,6 +303,15 @@ refreshLook();
// The setup screen's rows, brought up to date with picks made elsewhere (the identity screen).
// Selection state lives in picked*; the rows are just its picture and must be repainted on entry.
function syncSetup() {
if (!courtUnlocked(picked)) picked = leagueOf(profile.prof.lg).court;
for (const el of courtList.children) {
const c = COURTS.find(x => x.id === el.dataset.id), l = courtLeague(el.dataset.id);
const open = courtUnlocked(el.dataset.id);
el.classList.toggle('lock', !open);
el.classList.toggle('on', open && el.dataset.id === picked);
// the sub line carries the lock and names the price — the tile stays visible, as a goal should
el.querySelector('i').textContent = open ? c.sub : 'LOCKED — REACH ' + l.name;
}
for (const el of tourList.children) el.classList.toggle('on', el.dataset.id === pickedTour);
for (const [rowId, nameId, table, cur] of [['kitRow', 'kitName', KITS, pickedKit],
['skinRow', 'skinName', SKINS, pickedSkin]]) {
@@ -362,9 +371,15 @@ const meter = (label, v, cls = '') =>
`<div class="meter ${cls}"><u>${label}</u><s><i style="width:${
Math.round(Math.min(1, Math.max(0.08, v)) * 100)}%"></i></s></div>`;
const courtList = document.getElementById('courtList');
// THE LEAGUES lock the courts: a court belongs to its league and unlocks for EVERY mode the
// moment you reach that league (settled: the unlock is the reward formula). A court no league
// names stays free forever.
const courtLeague = id => LEAGUES.find(l => l.court === id);
const courtUnlocked = id => { const l = courtLeague(id); return !l || l.id <= profile.prof.lg; };
for (const c of COURTS) {
const b = document.createElement('button');
b.className = 'court' + (c.id === picked ? ' on' : ''); // your last court comes up pre-picked
b.dataset.id = c.id;
b.innerHTML = `<img src="assets/${c.tile}.png?v=${SPRV}" alt=""><span class="cb">`
+ `<b>${c.name}</b><i>${c.sub}</i>`
+ meter('SPEED', (c.bounceXZ - 0.62) / 0.26)
@@ -373,12 +388,16 @@ for (const c of COURTS) {
// Tapping a tile SELECTS it and nothing more. It used to start the match on the spot, which gave
// you no way to look at the three of them and no way back out of the screen.
b.addEventListener('click', () => {
if (!courtUnlocked(c.id)) return; // locked: the row itself says which league opens it
picked = c.id;
for (const el of courtList.children) el.classList.toggle('on', el === b);
summary();
});
courtList.appendChild(b);
}
// a remembered pick that is locked for THIS profile (fresh install, adopted mirror) falls back
// to the league's own court rather than serving up a court the player has not earned
if (!courtUnlocked(picked)) picked = leagueOf(profile.prof.lg).court;
// ---- 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
@@ -402,14 +421,27 @@ 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)]);
const R = cupRoundsFor(profile.prof.lg);
// WITHOUT REPLACEMENT, like cupDraw: a league band is 3-4 rungs, so the non-final rounds are a
// shuffle of the sub-band — every tournament a new order, no body dealt twice, the final settled
cupPicks = [];
const used = new Set();
for (const r of R) {
const pool = r.band.filter(x => !used.has(x));
const pick = (pool.length ? pool : r.band)[Math.floor(Math.random() * (pool.length ? pool : r.band).length)];
used.add(pick); cupPicks.push(pick);
}
// the header counts the league's own rounds (a 3-rung league plays three)
document.querySelector('#bracket h3').firstChild.nodeValue =
R.length === 4 ? 'FOUR ROUNDS' : 'THREE ROUNDS';
cupRows.innerHTML = '';
cupGo.disabled = true;
document.getElementById('brState').textContent = 'DRAWING…';
const lg = leagueOf(profile.prof.lg);
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) => {
`<span>ONLY THE FINAL WAS EVER SETTLED</span><span>${lg.name} · ${
(COURTS.find(c => c.id === lg.court) || COURTS[0]).name}</span>`;
R.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' : '');
@@ -425,7 +457,7 @@ function buildBracket() {
if (p >= 1) {
show(cupPicks[i]);
row.classList.add('set');
if (cupRows.querySelectorAll('.set').length === CUP_ROUNDS.length) {
if (cupRows.querySelectorAll('.set').length === R.length) {
cupGo.disabled = false;
document.getElementById('brState').textContent = 'THE DRAW IS MADE';
}
@@ -449,14 +481,46 @@ document.getElementById('cupGo').addEventListener('click', () => start('cup'));
// tap-advances rule; the SERVE UP button routed here instead of starting (see cStart below).
let trainRung = 1;
const trainList = document.getElementById('trainList');
for (let n = 1; n <= LADDER; n++) {
const f = matchFoe(n), b = document.createElement('button');
b.innerHTML = `<img src="${foeArt(f)}" alt=""><b>${f.name}</b><i>RUNG ${n}</i>`;
b.addEventListener('click', () => { trainRung = n; start('train'); });
trainList.appendChild(b);
// rebuilt on entry (go('train')): you can only train who the club has let you meet — every rung
// up to the top of YOUR league's band. LADDER stays the cap for a maxed profile.
function buildTrain() {
trainList.innerHTML = '';
const top = Math.min(LADDER, leagueOf(profile.prof.lg).band[1]);
for (let n = 1; n <= top; n++) {
const f = matchFoe(n), b = document.createElement('button');
b.innerHTML = `<img src="${foeArt(f)}" alt=""><b>${f.name}</b><i>RUNG ${n}</i>`;
b.addEventListener('click', () => { trainRung = n; start('train'); });
trainList.appendChild(b);
}
}
document.getElementById('trBack').addEventListener('click', () => go('setup'));
// --- THE PROMOTION CEREMONY (the leagues). beats.cupWon parks it in the profile module; the tap
// off the cup summary lands here (maybeRestart) before the title screen. The court tile IS the
// reward on screen; the name prompt, if the run also earned one, queues behind the ceremony.
let promoRun = null;
function buildPromo(pr, run) {
promoRun = run || null;
const lg = leagueOf(profile.prof.lg);
const c = COURTS.find(x => x.id === lg.court) || COURTS[0];
const champion = !!pr.champion;
document.getElementById('prTag').textContent = champion ? 'THE FINAL CUP IS YOURS' : 'THE CUP IS YOURS';
document.getElementById('prTitle').textContent = champion ? 'CHAMPION.' : 'PROMOTED.';
document.getElementById('prLeague').textContent = champion
? 'THE DEAD CLUB HAS NOBODY LEFT'
: `LEAGUE ${lg.id} OF ${LEAGUES.length} — ${lg.name}`;
document.getElementById('prCourt').src = `assets/${c.tile}.png?v=${SPRV}`;
document.getElementById('prCourtName').textContent = champion
? 'THE HONOURS BOARD REMEMBERS YOUR NAME'
: `NEW COURT UNLOCKED — ${c.name} · OPEN IN EVERY MODE`;
document.getElementById('prGo').textContent = champion ? 'TAKE A BOW' : 'WALK ON';
}
document.getElementById('prGo').addEventListener('click', () => {
const run = promoRun; promoRun = null;
if (run && run.newKillBest && run.score > 0) askName(run);
else go('splash');
});
// Which RECORD BOOK a run mode writes to. The four run modes share three books — the arcade ladder
// and the tournament are both zombie tennis and are counted together (see the record-shape note in
// stats.endRun). Lived inline in three places before the world board grew tabs of its own.
@@ -466,7 +530,9 @@ const boardFor = m => m === 'zombie' || m === 'cup' || m === 'train' ? stats.MOD
// 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];
const c = COURTS.find(x => x.id === picked) || COURTS[0];
const lg = leagueOf(profile.prof.lg);
const leagueMode = pickedMode === 'zombie' || pickedMode === 'cup';
const c = COURTS.find(x => x.id === (leagueMode ? lg.court : picked)) || COURTS[0];
const m = MODES.find(x => x.id === pickedMode) || MODES[0];
// the mark to beat is the PICKED MODE's own, because the three of them do not measure the same
// thing — see the record-shape note in stats.endRun. The cup banks through endRun as MODE_MATCH
@@ -477,7 +543,8 @@ function summary() {
const name = m.id === 'classic'
? m.name + ' · ' + (DIFFS.find(d => d.id === pickedDiff) || DIFFS[1]).name : m.name;
document.getElementById('sSum').innerHTML =
`<span>${name} · ${t.name.replace(' SINGLES', '')} · ${c.name}</span>`
`<span>${name} · ${t.name.replace(' SINGLES', '')} · ${c.name}${leagueMode
? ' · LEAGUE ' + lg.id : ''}</span>`
// training banks nothing, so there is no mark to beat and the footer must not invent one
+ `<span>${m.id === 'train' ? 'NOTHING AT STAKE — JUST REPS'
: best ? best + unit + ' TO BEAT' : 'FIRST RUN'}</span>`;
@@ -505,7 +572,9 @@ function start(modeId = pickedMode) {
if (!menuUp()) return;
selectTour(pickedTour);
selectKit(pickedKit); selectSkin(pickedSkin);
selectCourt(picked);
// THE LEAGUES: a league match is played on the league's own court — the pick stays yours in
// classic, gore, netplay and training (among the unlocked courts).
selectCourt(modeId === 'zombie' || modeId === 'cup' ? leagueOf(profile.prof.lg).court : picked);
menuDuck(true); // the theme belongs to the menus — hand the court over to the ambience
intro.hidden = true; ambient(); voice('quiet_please'); // low atmosphere bed starts with the first gesture
// ...and the mode the setup screen picked. THREE LINES, because that is genuinely all the modes
@@ -813,7 +882,8 @@ addEventListener('pagehide', () => {
if (G.match && G.match.train) return; // the training court banks nothing, walked out on or not
// ...tagged with the mode that was being played, and carrying THAT mode's two headline numbers —
// see the record-shape note in stats.endRun. A match abandoned mid-rally is a match run.
if (G.match) stats.endRun(G.match.opp - 1, score.games[0] + score.games[1], G.splatT, false, stats.MODE_MATCH);
if (G.match) stats.endRun(G.match.cup ? G.match.cup.round : (G.match.felled ?? Math.max(0, G.match.opp - 1)),
score.games[0] + score.games[1], G.splatT, false, stats.MODE_MATCH);
else stats.endRun(G.runGames, G.sets[0], G.splatT, false);
});
@@ -834,6 +904,10 @@ const maybeRestart = () => {
// stats.lastRun() survives restartRun on purpose — it lives in the stats module, not on G, which
// has just been restored from PRISTINE. Consumed on read, so the prompt appears once.
const run = stats.lastRun();
// THE PROMOTION plays first (the leagues): the ceremony the cup win parked, then the name
// prompt it may also have earned (prGo hands the run on), then the title.
const promo = profile.takePromo();
if (promo) { buildPromo(promo, run); go('promo'); return; }
// ...on the BOARD's number, not on kills: a classic match has none, so the gore-shaped test here
// silently barred two of the three modes from ever reaching the prompt
if (run && run.newKillBest && run.score > 0) askName(run);
+8
View File
@@ -88,6 +88,14 @@ export function award(kind, n = 1) {
return amt;
}
// ---------------------------------------------------------------- the promotion ----
// A cup win that moved the league parks its ceremony here (beats.cupWon writes, main.maybeRestart
// consumes once the summary is tapped away). Module-local and consumed-on-read, the same shape as
// stats.lastRun and for the same reason: it must survive restartRun's PRISTINE restore.
let promo = null;
export const setPromo = p => { promo = p; };
export function takePromo() { const p = promo; promo = null; return p; }
// ---------------------------------------------------------------- the mirror ----
// POST is fire-and-forget with keepalive so the pagehide path can use the same call. The server
// refuses to go backward on xp (see server.py), so a stale device cannot clobber the investment.
+17 -7
View File
@@ -1,7 +1,7 @@
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
import { cv, ctx } from './dom.js';
import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS, HEAD_CROP, CUP_ROUNDS, TINT, SHOT_DEPTH, matchFoe } from './config.js';
import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS, HEAD_CROP, TINT, SHOT_DEPTH, matchFoe } from './config.js';
import { P, O, ball, score, crowd, G, court, tour, kit, skin } from './state.js';
import { RAISERS, FRAMES } from './crowd.js';
import { predictLanding, previewServe, slotId, slotStock, serveApexY, rallyFeel, swayOf } from './gameplay.js';
@@ -236,7 +236,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 = 148;
export const SPRV = 149;
// 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".
@@ -2844,11 +2844,14 @@ export function render() {
// ...and in CLASSIC it says how far through the MATCH you are, which is the one thing the
// board itself cannot: which set this is, and how many it takes to win.
// the cup names the ROUND — its opp is a drawn rung, so the deathmatch arithmetic lied here
// clamped: past the final, cup.round sits one beyond the last row (that is how cupWon is
// reached) and the trophy screen still draws one frame of this panel over the held court
ctx.fillText(G.match && G.match.cup
? `${CUP_ROUNDS[G.match.cup.round].name} · ${G.match.cup.round} FELLED`
? `${G.match.cup.rounds[Math.min(G.match.cup.round, G.match.cup.rounds.length - 1)].name} · ${G.match.cup.round} FELLED`
// training: the ladder is parked, so "opponent N · felled" would lie — name the rep instead
: G.match && G.match.train ? `TRAINING · ${matchFoe(G.match.opp).short}`
: G.match ? `OPPONENT ${G.match.opp} · ${G.match.opp - 1} FELLED`
// the arcade names the RUNG and counts d.felled (THE LEAGUES: opp is a band position now)
: G.match ? `${matchFoe(G.match.opp).short} · ${G.match.felled || 0} FELLED`
: G.classic ? `SET ${G.sets[0] + G.sets[1] + 1} · FIRST TO ${TUNE.classicSets}`
: `GAME ${G.runGames + 1} · ${G.sets[0]} SET${G.sets[0] === 1 ? '' : 'S'}`, bW - 3, bH + 8);
}
@@ -2887,12 +2890,16 @@ export function render() {
ctx.fillStyle = won ? 'rgba(6,10,8,0.72)' : 'rgba(10,4,6,0.72)'; ctx.fillRect(0, 0, W, H);
// The announcement face, at 26 rather than the card's 30: this is the longest string in the
// game and at 30 it ran edge to edge on a 270px-wide portrait canvas.
// the big line is reserved for the TOP league's cup (THE LEAGUES): a lower cup is a step
// up the club, not the whole club — its ceremony (the promotion screen) follows anyway
const title = dead ? (m ? 'BLED OUT.' : 'GAME, SET, MASSACRE.')
: G.done.cup ? 'CHAMPION OF THE DEAD CLUB.' : won ? 'GAME, SET, MATCH.' : 'BEATEN.';
: G.done.cup ? (G.league >= 5 ? 'CHAMPION OF THE DEAD CLUB.' : 'THE CUP IS YOURS.')
: won ? 'GAME, SET, MATCH.' : 'BEATEN.';
const line = dead
? (m ? 'FELLED ' + m.felled + ' OPPONENT' + (m.felled === 1 ? '' : 'S') + ' · ' + m.games + ' GAMES'
: 'SURVIVED ' + G.runGames + ' GAMES · ' + G.sets[0] + ' SETS')
: G.done.cup ? 'THE CHAMPION FELL · ' + ((G.match && G.match.games) || G.runGames) + ' GAMES'
: G.done.cup ? (G.league >= 5 ? 'THE CHAMPION FELL · ' : 'THE FINAL FELL · ')
+ ((G.match && G.match.games) || G.runGames) + ' GAMES'
: G.sets[0] + '–' + G.sets[1] + ' IN SETS · ' + G.runGames + ' GAMES';
ctx.font = B(26); shadowText(title, W / 2, H / 2 - 44, won ? UI.pick : UI.bad);
ctx.font = F(9); shadowText(line, W / 2, H / 2 - 14, UI.cream);
@@ -2917,7 +2924,10 @@ export function render() {
W / 2, H / 2 + 18, xd.to > xd.from ? UI.pick : UI.cream);
const bw = 120, bx = Math.round(W / 2 - bw / 2), by = Math.round(H / 2 + 30);
ctx.fillStyle = 'rgba(255,255,255,0.14)'; ctx.fillRect(bx, by, bw, 4);
ctx.fillStyle = UI.warm; ctx.fillRect(bx, by, Math.round(bw * pr.frac), 4);
// the fill is a gradient (Oliver's call), green into gold — same ramp as the splash strip
const gr = ctx.createLinearGradient(bx, 0, bx + bw, 0);
gr.addColorStop(0, '#3f9560'); gr.addColorStop(0.55, UI.pick); gr.addColorStop(1, '#e8b23a');
ctx.fillStyle = gr; ctx.fillRect(bx, by, Math.round(bw * pr.frac), 4);
}
// gated well past the animation so a panic tap can't skip it (main.js gates the restart the same)
if (dead ? G.eatenT > EATEN_RESTART : G.done.t > DONE_RESTART) {
+3
View File
@@ -154,6 +154,9 @@ export const G = {
// One rule: always kill the body that would reach you first. `t` is time
// survived and drives the whole difficulty curve.
killCount: 0, // splatter: kills banked toward the next point (see TUNE.killsPerPoint)
league: 1, // THE LEAGUES: the profile's league, stamped by beats.startMatch for the
// match modes (world = pace tier + band + court). 1 everywhere else, so
// every non-league mode keeps playing the shipped game (leaguePace 1.0).
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
+5 -2
View File
@@ -310,8 +310,11 @@ export function killPlayer() {
// record's two headline slots (see the shape note there), so the best is derived from the record
// rather than stored a second time in its own key and left to drift.
const m = G.match;
// the cup's opp is a DRAWN RUNG, not a counter — bodies felled there is completed rounds
const felled = m ? (m.cup ? m.cup.round : m.opp - 1) : 0, gamesPlayed = score.games[0] + score.games[1];
// the cup's opp is a DRAWN RUNG, not a counter — bodies felled there is completed rounds. The
// arcade counts kills on d.felled now (THE LEAGUES: opp starts at the band's bottom rung and
// clamps at its top, so `opp - 1` stopped meaning anything); the fallback keeps old shapes alive.
const felled = m ? (m.cup ? m.cup.round : (m.felled ?? Math.max(0, m.opp - 1)))
: 0, gamesPlayed = score.games[0] + score.games[1];
const b = m ? endRun(felled, gamesPlayed, G.splatT, true, MODE_MATCH)
: endRun(G.runGames, G.sets[0], G.splatT);
G.best = b; G.newBest = b.newBest; // b is {games, sets, newBest}; render reads games/sets