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
+402 -67

No files matched your search

+48
View File
@@ -491,6 +491,54 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
`RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud
FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone.
## Session log: THE LEAGUES, PHASE 2 — bands, pace tiers, courts, the promotion (2026-08-29, STAGING ONLY)
Phase 2 of the plan. STAGING ONLY, like phase 1.
- **`LEAGUES` in config.js** — five bands tiling the 18-rung ladder exactly (1-4 / 5-8 / 9-12 /
13-15 / 16-18, the club's own hierarchy), each with its home court: clay → hard → grass →
gloom → **members** (new). `leagueOf(lg)`, and the ladder-shape test now pins the tiling.
- **`CUP_ROUNDS` is GONE; the cup carries its own rounds** (`cup.rounds`, stamped by startMatch
from `cupRoundsFor(G.league)`). A league band is 3-4 rungs, so the draw is WITHOUT REPLACEMENT:
non-final rounds carry the whole sub-band, `cup.played` (and buildBracket's `used` set) stop a
body being dealt twice, the final is always the band's top rung — "only the finale is settled"
survives the narrow bands. 4-rung leagues play four rounds, 3-rung leagues three.
- **`G.league`** (state, PRISTINE 1) is stamped by startMatch — netplay and training stay league-1
worlds. **`gameplay.leaguePace()`** = `1 + (league-1) * TUNE.leaguePace` (0.06) multiplies ball
pace on BOTH rackets (doHit, both serve paths) and `rzChaseSpeed`, so the world speeds up as one
piece and the reach tests keep believing the legs. League 1 IS the shipped game (factor 1.0 —
the calibration rule); league 5 plays ~24% faster. Everything non-match is factor 1: gore keeps
its own director.
- **The arcade opens at its band's bottom rung and CLAMPS at its top** — past it the league has
nobody stronger and the top body walks back on; the cup is the only way up. `d.felled` counts
bodies now (opp is a band position, `opp - 1` stopped meaning anything — killPlayer, pagehide
and the scoreboard slot all read felled).
- **Courts lock to the leagues**: a court unlocks for EVERY mode when you reach its league
(settled reward formula). Rows keep their tile visible but answer no tap and say the price
("LOCKED — REACH THE OFFICIALS"); syncSetup repaints locks on entry; a remembered-but-locked
pick falls back to the league court. League matches force the league court in `start()`; the
footer says so plus "LEAGUE N". The training picker only offers rungs up to your band's top.
- **THE MEMBERS' GROUND** (Oliver's pick): new COURTS row — brightly decayed prestige, dead
grey-green lawn (fastest bounce in the table, finishing the pace ladder), pale marble, ivy,
faded gold; borrows clay's crowd/ads with `dim` 0.15. Tile rolled via the famroll OpenRouter
route (style ref = court_grass tile, 4x NEAREST downscale + quantise), SPRV 148 → 149.
- **THE PROMOTION CEREMONY**: cupWon (non-train, cup of your CURRENT league) moves `prof.lg`,
parks a ceremony in the profile module (consumed-on-read, survives restartRun), and
maybeRestart plays it before the name prompt: PROMOTED. / league name / the new court's tile /
"OPEN IN EVERY MODE" / WALK ON. League 5 crowns instead (`prof.ch`, "CHAMPION."). The cup
summary's big line is league-aware now: 'CHAMPION OF THE DEAD CLUB.' belongs to league 5 only,
lower cups say 'THE CUP IS YOURS.'
- **Fix found live**: the scoreboard's cup slot indexed `cup.rounds[cup.round]` one past the end
on the trophy frame (cup.round is incremented before cupWon is reached) — clamped.
- **XP bar wears a gradient** (Oliver, mid-session): green → acid → gold, splash strip and the
canvas bar on the death screen.
- Tests 118 → 121 (band tiling + per-league cup shape, promotion + ceremony parked, champion
crown, arcade band clamp + felled, pace-tier scaling and its exemptions; the tournament
walkthrough now also asserts no body is dealt twice and the promotion fires; freshSplatter
resets the profile league so a won cup cannot leak league 2 into later tests). Seeds fuzzed.
Pane-verified: locks, forced court + league footer, league-2 draw, full cup → trophy (+1512 XP
· LEVEL 6 → 8) → PROMOTED ceremony → league-5 arcade opening vs THE BRUTE on the members court.
## Session log: THE LEAGUES, PHASE 1 — profile, xp, the identity screen (2026-08-29, STAGING ONLY)
Phase 1 of the plan below, built exactly to it. **STAGING ONLY for now** (Oliver: he tests himself
Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

+35 -3
View File
@@ -174,7 +174,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
/* ONE overlay, four panels, swapped by #intro[data-stage]. Keeping the backdrop and the card frame
alive across every screen is the point — a screen that unmounts the whole overlay flashes the
court through between menus. */
#splash, #setup, #stats, #ranks, #newbest, #secret, #bosses, #stmode, #stsub, #bracket, #vs, #train, #player { display: none; }
#splash, #setup, #stats, #ranks, #newbest, #secret, #bosses, #stmode, #stsub, #bracket, #vs, #train, #player, #promo { display: none; }
#intro[data-stage="splash"] #splash { display: flex; flex-direction: column; flex: 1; }
/* flex COLUMN, not block: it is what lets the footer's margin-top:auto reach the bottom of a
screen whose content does not fill it */
@@ -189,6 +189,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
#intro[data-stage="train"] #train,
#intro[data-stage="vs"] #vs,
#intro[data-stage="player"] #player,
#intro[data-stage="promo"] #promo,
#intro[data-stage="newbest"] #newbest { display: flex; flex-direction: column; flex: 1;
animation: drop 0.3s ease-out; }
/* the overlay is HIDDEN during play, not removed: a restart puts it straight back, and the
@@ -257,6 +258,22 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
#nameRow { display: flex; gap: 8px; }
#nameRow .tin { flex: 1; margin: 0; }
#pDice { width: 52px; flex: none; margin: 0; padding: 0; font-size: 20px; line-height: 1; }
/* a LOCKED court: the tile stays visible — it is a goal — but it answers no tap and sits back */
.court.lock { opacity: 0.45; filter: saturate(0.4); cursor: default; }
.court.lock i { color: var(--bad); }
/* THE PROMOTION CEREMONY: one centred column, the walk-on moment of the meta game. The court
tile is the reward made visible; the staggered drops are cheap CSS, no rAF (menu parks it). */
#promo .mid { gap: 10px; }
#prTag { margin: 0; font: 700 10px Silkscreen, monospace; color: var(--dim); letter-spacing: 3px;
animation: drop 0.5s ease-out; }
#prTitle { font: 400 44px Bangers, cursive; color: var(--acid); letter-spacing: 2px; margin: 0;
animation: drop 0.5s ease-out 0.15s backwards; }
#prLeague { margin: 0; font: 700 12px Silkscreen, monospace; color: var(--cream);
letter-spacing: 2px; animation: drop 0.5s ease-out 0.3s backwards; }
#prCourt { width: 74%; max-width: 300px; margin-top: 10px; border: 2px solid var(--line);
image-rendering: pixelated; animation: drop 0.5s ease-out 0.5s backwards; }
#prCourtName { margin: 0; font: 400 10px Silkscreen, monospace; color: var(--acid);
letter-spacing: 1.5px; animation: drop 0.5s ease-out 0.62s backwards; }
/* THE LEVEL STRIP: the one meta number over a thin bar, riding the splash foot. The fill colour
is the acid accent for now; the leagues will hand it the skill colour in phase 3. */
#xpRow { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; }
@@ -264,7 +281,8 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
letter-spacing: 1px; white-space: nowrap; }
#xpRow s { flex: 1; height: 6px; background: rgba(255,255,255,0.10); border: 1px solid var(--line);
text-decoration: none; display: block; }
#xpRow s i { display: block; height: 100%; width: 0%; background: var(--acid);
#xpRow s i { display: block; height: 100%; width: 0%;
background: linear-gradient(90deg, #3f9560, var(--acid) 55%, #e8b23a);
transition: width 0.4s ease; }
/* KIT and SKIN: a row of colour squares, under a label that names whatever is selected. That is
the whole UI — the pick has no meters and no art to show, and a labelled tile per colour would
@@ -597,6 +615,20 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
<button class="btn big" id="pGo">ENTER THE CLUB</button>
</div>
</div>
<!-- THE PROMOTION (the leagues): the ceremony between the cup summary and the title screen.
Built by main.buildPromo from the profile's fresh league; the court tile IS the reward. -->
<div id="promo">
<div class="mid cent">
<p id="prTag">THE CUP IS YOURS</p>
<h2 id="prTitle">PROMOTED.</h2>
<p id="prLeague"></p>
<img id="prCourt" alt="">
<p id="prCourtName"></p>
</div>
<div class="foot bare">
<button class="btn big" id="prGo">WALK ON</button>
</div>
</div>
<!-- THE DRAW (cup mode only). SERVE UP lands here instead of on a court: the four rounds roll
through their own band of the ladder and stop, and what you watched land is what walks on
(main.js hands the settled rungs to startMatch). The rows are built in main.js from
@@ -720,6 +752,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div>
</div>
</div>
<script type="module" src="src/main.js?v=312"></script>
<script type="module" src="src/main.js?v=313"></script>
</body>
</html>
+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
+86 -12
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++) {
// 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
+89 -14
View File
@@ -89,7 +89,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' });
// ---- game modules (real code) ----------------------------------------------
const state = await import('./src/state.js');
const { G, P, O, score, ball, setupPoint } = state;
const { update, forceZombies, startGore, splatPoint, restartRun, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt } = await import('./src/gameplay.js');
const { update, forceZombies, startGore, splatPoint, restartRun, leaguePace, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt } = await import('./src/gameplay.js');
const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN, startNetVs, endNetVs } = await import('./src/beats.js');
const { mirror, netMsg, netTest, netTick } = await import('./src/net.js');
// the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a
@@ -110,6 +110,9 @@ const { read: readStats, check: statsCheck } = statsMod;
const DT = 1 / 60;
function freshSplatter() {
restartRun();
// THE LEAGUES: a won cup PROMOTES the profile (by design), which would leak league 2 into every
// test after the tournament walkthrough — each test opens in league 1 with no ceremony pending
prof.prof.lg = 1; prof.prof.ch = 0; prof.takePromo();
G.mode = 'splatter';
G.everTransitioned = true;
setupPoint();
@@ -877,12 +880,23 @@ test('the opponent card is a RAMP: it starts easy and only gets harder', () => {
for (let n = 1; n <= LADDER; n++)
assert.ok(cfg.ZFAMS.includes(matchFoe(n).fam), `rung ${n}'s family is registered in ZFAMS`);
assert.ok(matchFoe(LADDER).h > 1.85, 'the champion stands taller than a stock body');
// the cup's final draws the top rung and nothing else: a band pointing past the ladder would
// wrap through matchFoe's modulo and quietly deal the shambler as THE CHAMPION
const fin = cfg.CUP_ROUNDS[cfg.CUP_ROUNDS.length - 1];
assert.deepEqual(fin.band, [LADDER], 'the final is the top of the ladder');
for (const r of cfg.CUP_ROUNDS) for (const rung of r.band)
assert.ok(rung >= 1 && rung <= LADDER, `${r.name} draws a rung that exists (${rung})`);
// THE LEAGUES: every league's cup draws only from its own band, its final is the band's top
// rung and nothing else, and the five bands tile the whole ladder with no gap and no overlap
let expectLo = 1;
for (const l of cfg.LEAGUES) {
const [lo, hi] = l.band;
assert.equal(lo, expectLo, `${l.name} starts where the league below ended`);
expectLo = hi + 1;
const R = cfg.cupRoundsFor(l.id);
assert.deepEqual(R[R.length - 1].band, [hi], `${l.name}'s final is its top rung`);
assert.equal(R.length, hi - lo + 1, `${l.name} plays one round per rung`);
for (const r of R) for (const rung of r.band)
assert.ok(rung >= lo && rung <= hi, `${l.name} ${r.name} stays in its band (${rung})`);
for (const r of R.slice(0, -1))
assert.ok(!r.band.includes(hi), `${l.name}'s top rung is only ever the final`);
assert.ok(cfg.COURTS.some(c => c.id === l.court), `${l.name}'s court exists`);
}
assert.equal(expectLo, LADDER + 1, 'the five bands cover the ladder exactly');
});
test('the first opponent cannot paint lines: its returns are width-clamped', () => {
@@ -1185,13 +1199,16 @@ test('the gore economy: barrels stop being income, and the late court keeps gett
});
test('THE TOURNAMENT: bands draw, bodies deepen, rounds hold their body, the champion ends it', () => {
const { CUP_ROUNDS } = cfg;
_startMatch(true); G.modal = null; G.modalQ.length = 0;
G.mode = 'splatter';
const d = G.match;
assert.ok(d.cup, 'the cup rides the match machinery');
const CUP_ROUNDS = d.cup.rounds; // THE LEAGUES: the run carries its own rounds
const dealt = [];
for (let round = 0; round < CUP_ROUNDS.length; round++) {
const R = CUP_ROUNDS[round];
assert.ok(!dealt.includes(d.opp), R.name + ' never re-deals a fallen body');
dealt.push(d.opp);
assert.equal(d.cup.round, round, 'on ' + R.name);
assert.ok(R.band.includes(d.opp), R.name + ' drew from its band (rung ' + d.opp + ')');
const zb = oppOf();
@@ -1209,7 +1226,11 @@ test('THE TOURNAMENT: bands draw, bodies deepen, rounds hold their body, the cha
if (round < CUP_ROUNDS.length - 1) passMopBeat(); // the boy, then the next draw walks on
}
assert.ok(G.done && G.done.cup && G.done.won, 'the champion fell: the trophy screen holds');
assert.equal(G.match.felled, CUP_ROUNDS.length, 'four bodies felled, banked as a match run');
assert.equal(G.match.felled, CUP_ROUNDS.length, 'every round felled a body, banked as a match run');
// THE LEAGUES: this cup was league one's, so winning it IS the promotion
assert.equal(prof.prof.lg, 2, 'the cup is the promotion gate');
const pr = prof.takePromo();
assert.ok(pr && pr.to === 2, 'the ceremony is parked for the menu to play');
});
test('THE DRAW: a bracket settled on screen is the bracket that is played', () => {
@@ -1217,20 +1238,21 @@ test('THE DRAW: a bracket settled on screen is the bracket that is played', () =
// showed you settling (main.buildBracket hands its rungs straight to startMatch). The guard is
// the other half: a rung that is not in the round's band falls back to an honest roll rather
// than dealing THE CHAMPION in round one.
const { CUP_ROUNDS, LADDER } = cfg;
const drawn = CUP_ROUNDS.map(r => r.band[r.band.length - 1]);
const { LADDER } = cfg;
const R = cfg.cupRoundsFor(1); // a fresh profile's cup is league one's
const drawn = [3, 1, 2, 4]; // a legal shuffle of the sub-band, the final settled
_startMatch(true, drawn); G.modal = null; G.modalQ.length = 0;
assert.equal(G.match.opp, drawn[0], 'round one walks on the body the screen landed on');
assert.deepEqual(G.match.cup.draw, drawn, 'and the rest of the draw rides with the run');
endMatch();
// a nonsense draw is refused per round, not trusted
_startMatch(true, [LADDER, LADDER, LADDER, LADDER]); G.modal = null; G.modalQ.length = 0;
assert.ok(CUP_ROUNDS[0].band.includes(G.match.opp),
assert.ok(R[0].band.includes(G.match.opp),
'a rung round one cannot contain is rolled instead of dealt');
endMatch();
// ...and no draw at all is the old behaviour, which the #cup back door still uses
_startMatch(true); G.modal = null; G.modalQ.length = 0;
assert.ok(CUP_ROUNDS[0].band.includes(G.match.opp), 'no draw: the round rolls its own');
assert.ok(R[0].band.includes(G.match.opp), 'no draw: the round rolls its own');
endMatch();
});
@@ -1240,7 +1262,6 @@ test('cup rallies never strand the latch: no-crossing points, the fall, and the
// no-fall path re-armed synchronously, and the after-set latch went stale ("basically like
// freeze from before"). The latch is set BEFORE the resolve now (the swallow exempts the
// resolution in progress via G.matchEarly), and willFall mirrors the cup divisor exactly.
const { CUP_ROUNDS } = cfg;
_startMatch(true); G.modal = null; G.modalQ.length = 0;
G.mode = 'splatter';
const d = G.match;
@@ -2193,5 +2214,59 @@ test('PROFILE: zombie names fit the board and its charset, and the profile round
prof.prof.lg = 1; prof.save();
});
test('THE LEAGUES: the arcade opens at its band, climbs inside it, and counts felled bodies', () => {
prof.prof.lg = 2;
assert.ok(_startMatch(), 'the arcade starts');
G.modal = null; G.modalQ.length = 0;
G.mode = 'splatter';
assert.equal(G.league, 2, 'the run wears the profile league');
const d = G.match;
assert.equal(d.opp, 5, "league two opens at its band's bottom rung");
for (let b = 0; b < 6; b++) {
G.interlude.introT = 0;
for (let k = 0; k < 4; k++) { resolveInterlude('P'); G.modal = null; G.modalQ.length = 0; }
for (let i = 0; i < 6 * 60 && G.matchFall; i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
passMopBeat();
}
assert.equal(d.opp, 8, 'the ladder clamps at the top of the band — the cup is the only way up');
assert.equal(d.felled, 6, 'every felled body counts, clamp or no clamp');
endMatch();
});
test('THE LEAGUES: the top league crowns a champion instead of promoting', () => {
prof.prof.lg = 5;
_startMatch(true); G.modal = null; G.modalQ.length = 0;
G.mode = 'splatter';
const d = G.match;
assert.equal(d.cup.rounds.length, 3, 'a three-rung league plays three rounds');
for (let round = 0; round < d.cup.rounds.length; round++) {
const R = d.cup.rounds[round];
G.interlude.introT = 0;
for (let g = 1; g <= R.need; g++)
for (let k = 0; k < 4; k++) { resolveInterlude('P'); G.modal = null; G.modalQ.length = 0; }
for (let i = 0; i < 6 * 60 && G.matchFall; i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
if (round < d.cup.rounds.length - 1) passMopBeat();
}
assert.ok(G.done && G.done.cup, 'the trophy screen holds');
assert.equal(prof.prof.lg, 5, 'there is no league six');
assert.equal(prof.prof.ch, 1, 'the profile is crowned');
const pr = prof.takePromo();
assert.ok(pr && pr.champion, 'the ceremony parked is the champion one');
});
test('THE LEAGUES: the pace tier scales the solo match world and nothing else', () => {
G.match = { opp: 1 }; G.league = 1;
assert.equal(leaguePace(), 1, 'league one IS the shipped game');
G.league = 5;
assert.ok(leaguePace() > 1.2, 'the top league is a visibly faster world');
const climbs = [1, 2, 3, 4, 5].map(l => { G.league = l; return leaguePace(); });
for (let i = 1; i < climbs.length; i++) assert.ok(climbs[i] > climbs[i - 1], 'each league is faster');
G.net = { on: true };
assert.equal(leaguePace(), 1, 'netplay is a human and exempt');
G.net = null; G.match = null; G.league = 5;
assert.equal(leaguePace(), 1, 'outside the match modes the factor is 1 — gore keeps its own director');
G.league = 1;
});
console.log(`\n${passed} tests passed`);
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report