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
+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);
|
||||
|
||||
Reference in new issue
Block a user