The climb: the arcade runs the whole earned ladder, the tempo follows the territory, the court is yours

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-29 16:24:43 +02:00
1 parent 041827c4dc
commit 689550d2d5
7 files changed
+104 -52

No files matched your search

+4 -4
View File
@@ -368,10 +368,10 @@ export function startMatch(cup = false, draw = null, train = 0) {
// 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 CLIMB: the arcade opens at the very BOTTOM of the ladder and runs the whole earned club,
// clamping at your league's top (matchGameWon) — the marathon whose length the promotions buy.
// The tempo shifts per band border (gameplay.leaguePace reads the rung's own league).
G.match = { opp: train || 1, 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
+6 -1
View File
@@ -825,7 +825,9 @@ export const COURTS = [
// baked in — the button's own name/sub render over a scrim, so the caption stays crisp.
export const MODES = [
{ id: 'classic', name: 'CLASSIC TENNIS', sub: 'REAL TENNIS · BEST OF THREE SETS', art: 'mode_classic' },
{ id: 'zombie', name: 'ARCADE', sub: 'ONE GAME PER OPPONENT · BLOOD ONLY', art: 'mode_arcade' },
// THE CLIMB (2026-08-29): the arcade is the survival marathon through the WHOLE earned ladder,
// bottom up — its length grows with the career; the cup stays the league fixture. See beats.
{ id: 'zombie', name: 'ARCADE', sub: 'THE WHOLE CLUB, BOTTOM UP · BLOOD ONLY', art: 'mode_arcade' },
// `tag` is the poster's corner chip (main.banner): the cup is THE WAY UP — winning your
// league's tournament is the one route to promotion, and the picker should say so standing.
{ id: 'cup', name: 'THE TOURNAMENT', sub: 'YOUR LEAGUE\'S ROUNDS · WIN IT TO RISE', art: 'mode_cup',
@@ -910,6 +912,9 @@ export const LEAGUES = [
{ id: 5, name: 'THE HONOURS BOARD', court: 'members', band: [16, 18], color: '#e8b23a' },
];
export const leagueOf = lg => LEAGUES[Math.min(LEAGUES.length, Math.max(1, lg | 0 || 1)) - 1];
// which league a LADDER POSITION belongs to — THE CLIMB's tempo reads this (the world speeds up
// as the run crosses each band border), and it is what makes the club read as five territories
export const leagueOfRung = n => LEAGUES.find(l => n <= l.band[1]) || LEAGUES[LEAGUES.length - 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`
+10 -6
View File
@@ -1,6 +1,6 @@
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
import { TUNE, UI, bossArt, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS, racketStage, matchWound, LADDER, LEAGUES, TIN_LEAGUE, TIN_DOUBLE_LEAGUE } 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, LEAGUES, leagueOfRung, TIN_LEAGUE, TIN_DOUBLE_LEAGUE } 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';
@@ -163,11 +163,15 @@ 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;
// THE LEAGUES' pace tier, PER TERRITORY (2026-08-29, THE CLIMB): the tempo hangs on the band of
// the CURRENT OPPONENT, not on the player's league — the low ladder plays at its own shipped
// tempo and the world audibly pulls away as a climb crosses each band border. In the cup the two
// readings coincide (the pool IS your band); the training court now practises a body at the
// tempo you would really meet it at. 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 + (leagueOfRung(G.match.opp).id - 1) * TUNE.leaguePace : 1;
// THE DUEL LEARNS (the leagues, phase 4): 0 fresh, 1 at TUNE.duelRampGames — gore's own clock,
// so game 0 is exactly the shipped duel and the league never rekeys the director. Exported for
+40 -26
View File
@@ -303,9 +303,24 @@ swatches('kitRow', 'kitName', KITS, 'kit', () => pickedKit, id => pickedKit = id
swatches('skinRow', 'skinName', SKINS, 'skin', () => pickedSkin, id => pickedSkin = id);
refreshLook();
// THE LEAGUE PANEL (the leagues): the setup's league-mode face — your league writ large and the
// band's roster with your all-time felled counts (stats' per-foe tallies, key 'L'+index). The
// identity block this replaces still shows for every non-league mode; the card owns it anyway.
// THE CLIMB PANEL: the arcade's setup face — the marathon's length (grown by every promotion)
// and your best run. Reuses the league panel's furniture; the foes row stays empty (up to 18
// tiles is a wall, and the training picker already shows the roster face-up).
function buildClimbPanel() {
const lg = leagueOf(profile.prof.lg);
document.getElementById('lgBadge').src = `assets/badge_l${lg.id}.png?v=${SPRV}`;
const name = document.getElementById('lgName');
name.textContent = 'THE CLIMB';
name.style.color = '';
const best = stats.read('all', undefined, stats.MODE_MATCH).longest;
document.getElementById('lgSub').textContent =
`THE CLUB FROM THE BOTTOM — ${lg.band[1]} STAND` + (best ? ` · BEST RUN: ${best} FELLED` : '');
document.getElementById('lgFoes').innerHTML = '';
}
// THE LEAGUE PANEL (the leagues): the cup's setup face — your league writ large and the band's
// roster with your all-time felled counts (stats' per-foe tallies, key 'L'+index). The identity
// block these panels replace still shows for every other mode; the card owns it anyway.
function buildLeaguePanel() {
const lg = leagueOf(profile.prof.lg);
const badge = document.getElementById('lgBadge');
@@ -336,27 +351,26 @@ function buildLeaguePanel() {
// On the LEAGUE MODES the player block steps aside for the league panel (Oliver: the identity is
// on the card now — the setup should show the league and who of it you have already beaten).
function syncSetup() {
const leagueMode = pickedMode === 'zombie' || pickedMode === 'cup';
document.getElementById('who').style.display = leagueMode ? 'none' : 'flex';
document.getElementById('lgPanel').style.display = leagueMode ? 'block' : 'none';
document.getElementById('s01').textContent = leagueMode ? '01 · YOUR LEAGUE' : '01 · PLAYER';
if (leagueMode) buildLeaguePanel();
const cupMode = pickedMode === 'cup', climbMode = pickedMode === 'zombie';
document.getElementById('who').style.display = cupMode || climbMode ? 'none' : 'flex';
document.getElementById('lgPanel').style.display = cupMode || climbMode ? 'block' : 'none';
document.getElementById('s01').textContent =
cupMode ? '01 · YOUR LEAGUE' : climbMode ? '01 · THE CLIMB' : '01 · PLAYER';
if (cupMode) buildLeaguePanel(); else if (climbMode) buildClimbPanel();
if (!courtUnlocked(picked)) picked = leagueOf(profile.prof.lg).court;
// ...and the COURT section stops pretending to be a choice on the league modes (Oliver: a
// league HAS its court — start() has forced it all along): only the league's own row shows,
// ringed, as the venue. Every other mode gets the full pickable list. This runs AFTER the
// generic repaint below would, so it has the last word on .on/.lock.
// the COURT section: the CUP is the league fixture — one ringed row, the home venue, no choice
// (Oliver: a league HAS its court). The climb and every other mode keep the full pickable list.
const homeCourt = leagueOf(profile.prof.lg).court;
document.getElementById('s02').innerHTML = leagueMode
document.getElementById('s02').innerHTML = cupMode
? '02 · HOME COURT<span>SET BY YOUR LEAGUE</span>' : '02 · COURT';
courtList.classList.toggle('fixed', leagueMode);
courtList.classList.toggle('fixed', cupMode);
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 && !leagueMode);
el.classList.toggle('on', leagueMode ? el.dataset.id === homeCourt
: open && el.dataset.id === picked);
el.style.display = leagueMode && el.dataset.id !== homeCourt ? 'none' : '';
el.classList.toggle('lock', !open && !cupMode);
el.classList.toggle('on', cupMode ? el.dataset.id === homeCourt
: open && el.dataset.id === picked);
el.style.display = cupMode && el.dataset.id !== homeCourt ? 'none' : '';
// 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;
}
@@ -440,7 +454,7 @@ 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 (pickedMode === 'zombie' || pickedMode === 'cup') return; // the league picks the venue
if (pickedMode === 'cup') return; // the cup is the league fixture — the league picks
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);
@@ -541,7 +555,7 @@ function buildTrain() {
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.innerHTML = `<img src="${foeArt(f)}" alt=""><b>${f.name}</b><i>No. ${n}</i>`;
b.addEventListener('click', () => { trainRung = n; start('train'); });
trainList.appendChild(b);
}
@@ -669,8 +683,8 @@ const boardFor = m => m === 'zombie' || m === 'cup' || m === 'train' ? stats.MOD
function summary() {
const t = TOURS.find(x => x.id === pickedTour) || TOURS[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 cupMode = pickedMode === 'cup';
const c = COURTS.find(x => x.id === (cupMode ? 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
@@ -681,7 +695,7 @@ 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}${leagueMode
`<span>${name} · ${t.name.replace(' SINGLES', '')} · ${c.name}${cupMode
? ' · 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'
@@ -718,9 +732,9 @@ function start(modeId = pickedMode) {
if (!menuUp()) return;
selectTour(pickedTour);
selectKit(pickedKit); selectSkin(pickedSkin);
// 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);
// THE LEAGUES: the CUP is the league fixture and plays the league's own court; every other
// mode — the climb included, it is an exhibition — plays your pick (among the unlocked).
selectCourt(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