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:
1 parent
041827c4dc
commit
689550d2d5
7 files changed
+104
-52
No files matched your search
+27
@@ -491,6 +491,33 @@ 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 CLIMB — the arcade gets its identity back (2026-08-29, same day, STAGING ONLY)
|
||||
|
||||
Oliver: "arcade verhält sich genau so [wie der cup].. die grenzen werden unscharf." Right — the
|
||||
phase-2 band clamp had reduced the arcade to the cup's own 3-4 bodies, loose. The two modes have
|
||||
sharp identities again:
|
||||
|
||||
- **THE ARCADE IS THE CLIMB**: it opens at the very BOTTOM of the ladder (opp 1) and runs the
|
||||
whole EARNED club, clamping at your league's top (the clamp, its felled counter and its
|
||||
"NOBODY STRONGER / THE CUP IS THE WAY UP" walk-on lesson all stay). The marathon's length
|
||||
grows with every promotion — league 2 climbs 8 bodies, league 5 all 18 — and the FELLED board
|
||||
means something again. Poster sub: "THE WHOLE CLUB, BOTTOM UP · BLOOD ONLY".
|
||||
- **The tempo hangs on the OPPONENT'S territory now** (`config.leagueOfRung`, read by
|
||||
leaguePace): the low ladder plays its own shipped tempo and the world pulls away at each band
|
||||
border — the climb makes the five leagues legible as five territories. Cup unchanged in effect
|
||||
(its pool IS your band); the training court now practises a body at the tempo you would really
|
||||
meet it at (a deliberate revision of phase 2's flat league-1 training world). Netplay exempt.
|
||||
- **The climb picks its court freely** (among the unlocked): the CUP is the league fixture with
|
||||
the fixed home venue, the climb is the exhibition. start()'s forcing, the setup's single-row
|
||||
venue face and the summary's LEAGUE suffix are all cup-only again.
|
||||
- **The climb's setup face** (replaces the league panel there): THE CLIMB writ large, "THE CLUB
|
||||
FROM THE BOTTOM — N STAND · BEST RUN: M FELLED" (stats' own longest), no roster wall — the
|
||||
training picker already shows the club face-up.
|
||||
- **"RUNG" is gone from the game's copy** (Oliver: too exotic) — the training tiles say "No. 7",
|
||||
the club roster number. It only ever appeared there; everywhere else the word lives in comments.
|
||||
- Tests reworked: the climb walkthrough (opens at 1, clamps at 8 for league 2, felled honest) and
|
||||
the pace test (flat inside a band, steps at each border, netplay/gore exempt). Suite 124.
|
||||
|
||||
## Session log: THE LEAGUES — the setup grows a league face (2026-08-29, same day, STAGING ONLY)
|
||||
|
||||
Two more from Oliver's screenshots:
|
||||
|
||||
+1
-1
@@ -920,6 +920,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="src/main.js?v=324"></script>
|
||||
<script type="module" src="src/main.js?v=325"></script>
|
||||
</body>
|
||||
</html>
|
||||
+4
-4
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -2214,22 +2214,22 @@ 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', () => {
|
||||
test('THE CLIMB: the arcade runs the whole earned ladder from the bottom and clamps at your top', () => {
|
||||
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++) {
|
||||
assert.equal(d.opp, 1, 'the climb opens at the very bottom of the ladder');
|
||||
for (let b = 0; b < 8; 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');
|
||||
assert.equal(d.opp, 8, "the ladder clamps at your league's top — the cup is the only way up");
|
||||
assert.equal(d.felled, 8, 'every felled body counts, clamp or no clamp');
|
||||
// ...and the repeat walk-on ANSWERS the "what now?" it poses: fell the top body once more and
|
||||
// read its intro modal instead of wiping it
|
||||
G.interlude.introT = 0;
|
||||
@@ -2267,18 +2267,20 @@ test('THE LEAGUES: the top league crowns a champion instead of promoting', () =>
|
||||
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');
|
||||
test("THE LEAGUES: the pace tier follows the OPPONENT's territory, and nothing else", () => {
|
||||
G.match = { opp: 1 };
|
||||
assert.equal(leaguePace(), 1, 'the bottom of the ladder IS the shipped game');
|
||||
G.match.opp = 4;
|
||||
assert.equal(leaguePace(), 1, '...flat inside a band: league one all plays league-one tempo');
|
||||
G.match.opp = 18;
|
||||
assert.ok(leaguePace() > 1.2, "the champion's territory is a visibly faster world");
|
||||
const climbs = [1, 5, 9, 13, 16].map(o => { G.match.opp = o; return leaguePace(); });
|
||||
for (let i = 1; i < climbs.length; i++)
|
||||
assert.ok(climbs[i] > climbs[i - 1], 'each band border pulls the tempo up');
|
||||
G.net = { on: true };
|
||||
assert.equal(leaguePace(), 1, 'netplay is a human and exempt');
|
||||
G.net = null; G.match = null; G.league = 5;
|
||||
G.net = null; G.match = null;
|
||||
assert.equal(leaguePace(), 1, 'outside the match modes the factor is 1 — gore keeps its own director');
|
||||
G.league = 1;
|
||||
});
|
||||
|
||||
test('THE DUEL LEARNS: its skill ramps with the run, and game zero is the shipped duel', () => {
|
||||
|
||||
Reference in new issue
Block a user