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

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

No files matched your search

+90 -16
View File
@@ -3,7 +3,7 @@
// Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update)
// render (camera/drawing) · input (keys/touch) · audio (sfx/umpire) · panel (T tuning UI)
import { TUNE, COURTS, TOURS, KITS, SKINS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, CUP_ROUNDS, matchFoe, LADDER } from './config.js';
import { TUNE, COURTS, TOURS, KITS, SKINS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, LEAGUES, leagueOf, cupRoundsFor, matchFoe, LADDER } from './config.js';
import { P, O, ball, score, G, setupPoint, sendBoy, selectCourt, selectTour, selectKit, selectSkin } from './state.js';
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, startGore, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.js';
import { startDuel, resolveInterlude, startHorde, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs, endNetVs } from './beats.js';
@@ -84,7 +84,7 @@ let pickedTour = remembered('tour') || TOURS[0].id;
// every stage change is a gesture, so it is also a safe place to (re)start the theme. The setup
// screen syncs its picker rows on entry: the identity screen writes the same picked* state, and
// rows that only ever toggle on their OWN taps would keep showing the boot-time selection.
const go = stage => { if (stage === 'setup') syncSetup(); intro.dataset.stage = stage; menuMusic(); };
const go = stage => { if (stage === 'setup') syncSetup(); if (stage === 'train') buildTrain(); intro.dataset.stage = stage; menuMusic(); };
// --- MODE selection in TWO STEPS (config.MENU + config.MODES): three posters on top, and the
// picked one deals its own second step underneath — classic deals the difficulty chips, zombie
@@ -303,6 +303,15 @@ refreshLook();
// The setup screen's rows, brought up to date with picks made elsewhere (the identity screen).
// Selection state lives in picked*; the rows are just its picture and must be repainted on entry.
function syncSetup() {
if (!courtUnlocked(picked)) picked = leagueOf(profile.prof.lg).court;
for (const el of courtList.children) {
const c = COURTS.find(x => x.id === el.dataset.id), l = courtLeague(el.dataset.id);
const open = courtUnlocked(el.dataset.id);
el.classList.toggle('lock', !open);
el.classList.toggle('on', open && el.dataset.id === picked);
// the sub line carries the lock and names the price — the tile stays visible, as a goal should
el.querySelector('i').textContent = open ? c.sub : 'LOCKED — REACH ' + l.name;
}
for (const el of tourList.children) el.classList.toggle('on', el.dataset.id === pickedTour);
for (const [rowId, nameId, table, cur] of [['kitRow', 'kitName', KITS, pickedKit],
['skinRow', 'skinName', SKINS, pickedSkin]]) {
@@ -362,9 +371,15 @@ const meter = (label, v, cls = '') =>
`<div class="meter ${cls}"><u>${label}</u><s><i style="width:${
Math.round(Math.min(1, Math.max(0.08, v)) * 100)}%"></i></s></div>`;
const courtList = document.getElementById('courtList');
// THE LEAGUES lock the courts: a court belongs to its league and unlocks for EVERY mode the
// moment you reach that league (settled: the unlock is the reward formula). A court no league
// names stays free forever.
const courtLeague = id => LEAGUES.find(l => l.court === id);
const courtUnlocked = id => { const l = courtLeague(id); return !l || l.id <= profile.prof.lg; };
for (const c of COURTS) {
const b = document.createElement('button');
b.className = 'court' + (c.id === picked ? ' on' : ''); // your last court comes up pre-picked
b.dataset.id = c.id;
b.innerHTML = `<img src="assets/${c.tile}.png?v=${SPRV}" alt=""><span class="cb">`
+ `<b>${c.name}</b><i>${c.sub}</i>`
+ meter('SPEED', (c.bounceXZ - 0.62) / 0.26)
@@ -373,12 +388,16 @@ for (const c of COURTS) {
// Tapping a tile SELECTS it and nothing more. It used to start the match on the spot, which gave
// you no way to look at the three of them and no way back out of the screen.
b.addEventListener('click', () => {
if (!courtUnlocked(c.id)) return; // locked: the row itself says which league opens it
picked = c.id;
for (const el of courtList.children) el.classList.toggle('on', el === b);
summary();
});
courtList.appendChild(b);
}
// a remembered pick that is locked for THIS profile (fresh install, adopted mirror) falls back
// to the league's own court rather than serving up a court the player has not earned
if (!courtUnlocked(picked)) picked = leagueOf(profile.prof.lg).court;
// ---- THE DRAW (Oliver, 2026-08-15: "when starting the tournament there should be a view of the
// tournament hierarchy but only the finale is settled — the game against the champion... random
@@ -402,14 +421,27 @@ const foeArt = f => `assets/z_${f.fam}head.png?v=${SPRV}`;
function buildBracket() {
for (const t of cupSpins) clearTimeout(t);
cupSpins = [];
cupPicks = CUP_ROUNDS.map(r => r.band[Math.floor(Math.random() * r.band.length)]);
const R = cupRoundsFor(profile.prof.lg);
// WITHOUT REPLACEMENT, like cupDraw: a league band is 3-4 rungs, so the non-final rounds are a
// shuffle of the sub-band — every tournament a new order, no body dealt twice, the final settled
cupPicks = [];
const used = new Set();
for (const r of R) {
const pool = r.band.filter(x => !used.has(x));
const pick = (pool.length ? pool : r.band)[Math.floor(Math.random() * (pool.length ? pool : r.band).length)];
used.add(pick); cupPicks.push(pick);
}
// the header counts the league's own rounds (a 3-rung league plays three)
document.querySelector('#bracket h3').firstChild.nodeValue =
R.length === 4 ? 'FOUR ROUNDS' : 'THREE ROUNDS';
cupRows.innerHTML = '';
cupGo.disabled = true;
document.getElementById('brState').textContent = 'DRAWING…';
const lg = leagueOf(profile.prof.lg);
document.getElementById('brSum').innerHTML =
`<span>ONLY THE FINAL WAS EVER SETTLED</span><span>${
(COURTS.find(c => c.id === picked) || COURTS[0]).name}</span>`;
CUP_ROUNDS.forEach((r, i) => {
`<span>ONLY THE FINAL WAS EVER SETTLED</span><span>${lg.name} · ${
(COURTS.find(c => c.id === lg.court) || COURTS[0]).name}</span>`;
R.forEach((r, i) => {
const fixed = r.band.length === 1; // the final: one rung, nothing to roll
const row = document.createElement('div');
row.className = 'cupRow' + (fixed ? ' fin set' : '');
@@ -425,7 +457,7 @@ function buildBracket() {
if (p >= 1) {
show(cupPicks[i]);
row.classList.add('set');
if (cupRows.querySelectorAll('.set').length === CUP_ROUNDS.length) {
if (cupRows.querySelectorAll('.set').length === R.length) {
cupGo.disabled = false;
document.getElementById('brState').textContent = 'THE DRAW IS MADE';
}
@@ -449,14 +481,46 @@ document.getElementById('cupGo').addEventListener('click', () => start('cup'));
// tap-advances rule; the SERVE UP button routed here instead of starting (see cStart below).
let trainRung = 1;
const trainList = document.getElementById('trainList');
for (let n = 1; n <= LADDER; n++) {
const f = matchFoe(n), b = document.createElement('button');
b.innerHTML = `<img src="${foeArt(f)}" alt=""><b>${f.name}</b><i>RUNG ${n}</i>`;
b.addEventListener('click', () => { trainRung = n; start('train'); });
trainList.appendChild(b);
// rebuilt on entry (go('train')): you can only train who the club has let you meet — every rung
// up to the top of YOUR league's band. LADDER stays the cap for a maxed profile.
function buildTrain() {
trainList.innerHTML = '';
const top = Math.min(LADDER, leagueOf(profile.prof.lg).band[1]);
for (let n = 1; n <= top; n++) {
const f = matchFoe(n), b = document.createElement('button');
b.innerHTML = `<img src="${foeArt(f)}" alt=""><b>${f.name}</b><i>RUNG ${n}</i>`;
b.addEventListener('click', () => { trainRung = n; start('train'); });
trainList.appendChild(b);
}
}
document.getElementById('trBack').addEventListener('click', () => go('setup'));
// --- THE PROMOTION CEREMONY (the leagues). beats.cupWon parks it in the profile module; the tap
// off the cup summary lands here (maybeRestart) before the title screen. The court tile IS the
// reward on screen; the name prompt, if the run also earned one, queues behind the ceremony.
let promoRun = null;
function buildPromo(pr, run) {
promoRun = run || null;
const lg = leagueOf(profile.prof.lg);
const c = COURTS.find(x => x.id === lg.court) || COURTS[0];
const champion = !!pr.champion;
document.getElementById('prTag').textContent = champion ? 'THE FINAL CUP IS YOURS' : 'THE CUP IS YOURS';
document.getElementById('prTitle').textContent = champion ? 'CHAMPION.' : 'PROMOTED.';
document.getElementById('prLeague').textContent = champion
? 'THE DEAD CLUB HAS NOBODY LEFT'
: `LEAGUE ${lg.id} OF ${LEAGUES.length} — ${lg.name}`;
document.getElementById('prCourt').src = `assets/${c.tile}.png?v=${SPRV}`;
document.getElementById('prCourtName').textContent = champion
? 'THE HONOURS BOARD REMEMBERS YOUR NAME'
: `NEW COURT UNLOCKED — ${c.name} · OPEN IN EVERY MODE`;
document.getElementById('prGo').textContent = champion ? 'TAKE A BOW' : 'WALK ON';
}
document.getElementById('prGo').addEventListener('click', () => {
const run = promoRun; promoRun = null;
if (run && run.newKillBest && run.score > 0) askName(run);
else go('splash');
});
// Which RECORD BOOK a run mode writes to. The four run modes share three books — the arcade ladder
// and the tournament are both zombie tennis and are counted together (see the record-shape note in
// stats.endRun). Lived inline in three places before the world board grew tabs of its own.
@@ -466,7 +530,9 @@ const boardFor = m => m === 'zombie' || m === 'cup' || m === 'train' ? stats.MOD
// what the setup footer says you are about to play, and what it is worth beating
function summary() {
const t = TOURS.find(x => x.id === pickedTour) || TOURS[0];
const c = COURTS.find(x => x.id === picked) || COURTS[0];
const lg = leagueOf(profile.prof.lg);
const leagueMode = pickedMode === 'zombie' || pickedMode === 'cup';
const c = COURTS.find(x => x.id === (leagueMode ? lg.court : picked)) || COURTS[0];
const m = MODES.find(x => x.id === pickedMode) || MODES[0];
// the mark to beat is the PICKED MODE's own, because the three of them do not measure the same
// thing — see the record-shape note in stats.endRun. The cup banks through endRun as MODE_MATCH
@@ -477,7 +543,8 @@ function summary() {
const name = m.id === 'classic'
? m.name + ' · ' + (DIFFS.find(d => d.id === pickedDiff) || DIFFS[1]).name : m.name;
document.getElementById('sSum').innerHTML =
`<span>${name} · ${t.name.replace(' SINGLES', '')} · ${c.name}</span>`
`<span>${name} · ${t.name.replace(' SINGLES', '')} · ${c.name}${leagueMode
? ' · LEAGUE ' + lg.id : ''}</span>`
// training banks nothing, so there is no mark to beat and the footer must not invent one
+ `<span>${m.id === 'train' ? 'NOTHING AT STAKE — JUST REPS'
: best ? best + unit + ' TO BEAT' : 'FIRST RUN'}</span>`;
@@ -505,7 +572,9 @@ function start(modeId = pickedMode) {
if (!menuUp()) return;
selectTour(pickedTour);
selectKit(pickedKit); selectSkin(pickedSkin);
selectCourt(picked);
// THE LEAGUES: a league match is played on the league's own court — the pick stays yours in
// classic, gore, netplay and training (among the unlocked courts).
selectCourt(modeId === 'zombie' || modeId === 'cup' ? leagueOf(profile.prof.lg).court : picked);
menuDuck(true); // the theme belongs to the menus — hand the court over to the ambience
intro.hidden = true; ambient(); voice('quiet_please'); // low atmosphere bed starts with the first gesture
// ...and the mode the setup screen picked. THREE LINES, because that is genuinely all the modes
@@ -813,7 +882,8 @@ addEventListener('pagehide', () => {
if (G.match && G.match.train) return; // the training court banks nothing, walked out on or not
// ...tagged with the mode that was being played, and carrying THAT mode's two headline numbers —
// see the record-shape note in stats.endRun. A match abandoned mid-rally is a match run.
if (G.match) stats.endRun(G.match.opp - 1, score.games[0] + score.games[1], G.splatT, false, stats.MODE_MATCH);
if (G.match) stats.endRun(G.match.cup ? G.match.cup.round : (G.match.felled ?? Math.max(0, G.match.opp - 1)),
score.games[0] + score.games[1], G.splatT, false, stats.MODE_MATCH);
else stats.endRun(G.runGames, G.sets[0], G.splatT, false);
});
@@ -834,6 +904,10 @@ const maybeRestart = () => {
// stats.lastRun() survives restartRun on purpose — it lives in the stats module, not on G, which
// has just been restored from PRISTINE. Consumed on read, so the prompt appears once.
const run = stats.lastRun();
// THE PROMOTION plays first (the leagues): the ceremony the cup win parked, then the name
// prompt it may also have earned (prGo hands the run on), then the title.
const promo = profile.takePromo();
if (promo) { buildPromo(promo, run); go('promo'); return; }
// ...on the BOARD's number, not on kills: a classic match has none, so the gore-shaped test here
// silently barred two of the three modes from ever reaching the prompt
if (run && run.newKillBest && run.score > 0) askName(run);