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:
1 parent
9133c0d351
commit
16bf5b9fed
12 files changed
+406
-71
No files matched your search
+48
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
Reference in new issue
Block a user