The menu text reduction: the design handoff, built
Sixty words on the wall became a dozen. The handoff's own summary of the rule:
Jersey 15 NAMES (screen titles, rooms, cards), Bangers CALLS exactly once per
screen (the fixture, the primary button, a modal headline), Silkscreen INFORMS
(numbers, labels, chips) — and a sentence survives only where it carries a
decision. Everything else moved into a bar, a pip or the modal that opens with
the mode.
· Jersey 15 bundled subset (9 KB) and Bungee retired after one afternoon
· SPLASH: the plaque is one row, the goal line is gone (the fixture button
below said it already), the two doors carry a number each
· PICK A GAME: rooms are one word, posters are their names, the fixture is a
hero with the only second line — the whole wall fits one screen now
· MATCH SETUP: unnumbered blocks, the meters' legend once instead of a
description per court, no summary footer
· DIFFICULTY: three pips instead of three sentences
· TRAINING: the revanche is a full-width row, rungs are numbers, the list
ends on the league that opens the rest of the ladder
· VS: three paragraphs became YOU / HOST / JOIN
· MODALS: one rule per row (the sub was the lead again in other words), the
blood pool as pips on that same line, and the walk-on's two levels in ONE
face at ONE size — a comparison drawn at two sizes is a headline with a
footnote (Oliver)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4c0ede33db
commit
a331a1ae37
44 files changed
+3840
-187
No files matched your search
+11
-15
@@ -7,7 +7,7 @@
|
||||
// (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, leagueOf, cupRoundsFor, foeLevel } from './config.js';
|
||||
import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME, racketStage, leagueOf, cupRoundsFor, cupFixture, foeLevel } from './config.js';
|
||||
import { endRun, MODE_MATCH } from './stats.js';
|
||||
import { award, prof, save as profSave, push as profPush, setPromo, levelAt, recordCup } from './profile.js';
|
||||
import { G, P, O, ball, score, setupPoint, sendBoy, other } from './state.js';
|
||||
@@ -85,8 +85,8 @@ export function startDuel() {
|
||||
showModal({ title: 'SUDDEN DEATH', color: UI.hot,
|
||||
body: ["IT'S TENNIS AGAIN — ONE POINT", 'WIN IT AND THEY HAND YOU A WEAPON'],
|
||||
// both stakes named (the old card only sold the win): a lost duel is a real cost
|
||||
rules: [{ lead: "IT'S TENNIS AGAIN — ONE POINT", sub: 'WIN: THEY HAND YOU A WEAPON' },
|
||||
{ lead: 'LOSE: THE DEAD TAKE A GAME', sub: 'AND FIVE MORE CLIMB THE FENCE', hot: true }] });
|
||||
rules: ['WIN IT AND THEY HAND YOU A WEAPON',
|
||||
{ lead: 'LOSE IT AND FIVE MORE CLIMB THE FENCE', hot: true }] });
|
||||
} else G.flash = { text: 'SUDDEN DEATH', t: 1.6 };
|
||||
}
|
||||
|
||||
@@ -394,17 +394,15 @@ export function startMatch(cup = false, draw = null, train = 0) {
|
||||
showModal({ title: 'THE TRAINING COURT', color: UI.hot,
|
||||
body: ['ONE BODY, ONE GAME AT A TIME',
|
||||
'FELL IT — OR BLEED OUT — AND IT COMES BACK', 'NO RUN IS BANKED — REPS PAY QUARTER XP'],
|
||||
rules: [{ lead: 'ONE BODY, ONE GAME AT A TIME', sub: 'FELL IT OR BLEED OUT — IT COMES BACK' },
|
||||
{ lead: 'NO RUN IS BANKED', sub: 'REPS PAY QUARTER XP' }] });
|
||||
rules: ['FELL IT OR BLEED OUT — IT COMES BACK', 'NOTHING IS BANKED · REPS PAY ¼ XP'] });
|
||||
} else if (cup && !G.seen.cup) {
|
||||
G.seen.cup = true;
|
||||
showModal({ title: 'THE TOURNAMENT', color: UI.hot,
|
||||
showModal({ title: 'THE TOURNAMENT', color: UI.hot, strap: cupFixture(G.league, prof.ch).sub,
|
||||
// plain words (Oliver: "dead hierarchy" said nothing) — what you play, what winning buys
|
||||
body: ['YOUR LEAGUE, ROUND BY ROUND — WIN IT ALL, MOVE UP A LEAGUE',
|
||||
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'],
|
||||
rules: [{ lead: 'YOUR LEAGUE, ROUND BY ROUND', sub: 'WIN IT ALL — MOVE UP A LEAGUE' },
|
||||
{ lead: 'ITS POINTS DRAW BLOOD', sub: 'AT ZERO YOU ARE EATEN', hot: true,
|
||||
pips: TUNE.matchPlayerHp }] });
|
||||
rules: ['WIN IT ALL — MOVE UP A LEAGUE',
|
||||
{ lead: 'POINTS DRAW BLOOD', hot: true, pips: TUNE.matchPlayerHp }] });
|
||||
} else if (!cup && !train && !G.seen.match) {
|
||||
G.seen.match = true;
|
||||
// Oliver's copy direction: name the stake, count the blood. TUNE.matchPlayerHp is the real
|
||||
@@ -416,15 +414,13 @@ export function startMatch(cup = false, draw = null, train = 0) {
|
||||
? { title: 'DEATH MATCH', color: UI.hot,
|
||||
body: ['WIN THE POINT — WOUND YOUR FRIEND',
|
||||
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'],
|
||||
rules: [{ lead: 'WIN THE POINT', sub: 'WOUND YOUR FRIEND' },
|
||||
{ lead: 'ITS POINTS DRAW BLOOD', sub: 'AT ZERO YOU ARE EATEN', hot: true,
|
||||
pips: TUNE.matchPlayerHp }] }
|
||||
rules: ['WIN THE POINT — WOUND YOUR FRIEND',
|
||||
{ lead: 'POINTS DRAW BLOOD', hot: true, pips: TUNE.matchPlayerHp }] }
|
||||
: { title: 'THE CLIMB', color: UI.hot,
|
||||
body: ['THE WHOLE CLUB, BOTTOM UP — ONE GAME PER BODY',
|
||||
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'],
|
||||
rules: [{ lead: 'THE WHOLE CLUB, BOTTOM UP', sub: 'ONE GAME PER BODY — HOW FAR CAN YOU GET?' },
|
||||
{ lead: 'ITS POINTS DRAW BLOOD', sub: 'AT ZERO YOU ARE EATEN', hot: true,
|
||||
pips: TUNE.matchPlayerHp }] });
|
||||
rules: ['ONE GAME PER BODY — HOW FAR CAN YOU GET?',
|
||||
{ lead: 'POINTS DRAW BLOOD', hot: true, pips: TUNE.matchPlayerHp }] });
|
||||
}
|
||||
matchOpponent();
|
||||
matchReady();
|
||||
|
||||
+11
-4
@@ -856,13 +856,17 @@ export const MODES = [
|
||||
// canonical path does not live behind a fan-out. Its two former siblings (the climb, the training
|
||||
// court) stand on their own in the training group, which also cost the second step its only other
|
||||
// job: CLASSIC's difficulty chips are all that still fan out.
|
||||
// THE TEXT REDUCTION (2026-08-31, design handoff): a room is ONE WORD and the posters carry only
|
||||
// their names. The straps went with the sentences — a shelf that has to explain itself is a shelf
|
||||
// nobody reads. `layout` is how the room deals its posters: the fixture is a HERO with the one
|
||||
// call on the screen, the training pair stands side by side, the casual three stack shorter.
|
||||
export const MENU = [
|
||||
{ group: 'THE SEASON', sub: 'THE ONLY WAY UP THE CLUB' },
|
||||
{ group: 'SEASON', layout: 'hero' },
|
||||
{ mode: 'cup' },
|
||||
{ group: 'THE TRAINING', sub: 'SHARPEN UP · NOTHING HERE MOVES YOUR LEAGUE' },
|
||||
{ group: 'TRAINING', layout: 'pair' },
|
||||
{ mode: 'zombie' },
|
||||
{ mode: 'train' },
|
||||
{ group: 'OUT OF COMPETITION', sub: 'NO LEAGUE, NO RECORD, NO STAKES' },
|
||||
{ group: 'CASUAL', layout: 'stack' },
|
||||
{ mode: 'gore' },
|
||||
{ mode: 'classic', diffs: true },
|
||||
// NETPLAY (prototype): a poster with no run mode of its own — its second step is HOST / JOIN,
|
||||
@@ -963,7 +967,10 @@ export const cupFixture = (lg, champion = 0) => {
|
||||
const held = champion && lg >= LEAGUES.length; // the champion has nothing above him left
|
||||
return { lg, league: leagueOf(lg), rounds, rung: first.band[0], foe,
|
||||
title: held ? 'DEFEND THE TITLE' : 'LEAGUE ' + lg + ' · THE CUP',
|
||||
line: rounds.length + ' ROUNDS · ' + first.name + ': ' + foe.name };
|
||||
// the ROUND COUNT moved to `sub` (the text reduction): it belongs in the modal that
|
||||
// explains the tournament, not on the button you press to enter it
|
||||
line: first.name + ': ' + foe.name,
|
||||
sub: 'LEAGUE ' + lg + ' · ' + rounds.length + ' ROUNDS' };
|
||||
};
|
||||
|
||||
// How far a cup record reads, in the rounds' own words: 3 of 4 rounds reached is THE SEMI-FINAL.
|
||||
|
||||
+84
-72
@@ -124,15 +124,21 @@ const modeSub = document.getElementById('modeSub');
|
||||
// checkbox and a button combined"). Every poster is full-bright and equal — all of them are live
|
||||
// answers — and the only memory shown is the LAST tag, an indicator rather than a selection. The
|
||||
// select-and-confirm border lives solely on the final screen, where SERVE UP actually confirms.
|
||||
const banner = (card, cls, last) => {
|
||||
// `opts.line` is the ONE second line the wall still carries (the fixture, under the hero's name);
|
||||
// `opts.chip` is a fact that changes what a mode IS rather than describing it (18 RUNGS).
|
||||
const banner = (card, cls, last, opts = {}) => {
|
||||
const b = document.createElement('button');
|
||||
b.className = cls;
|
||||
// JUST THE ART. The scrim used to ride in this background stack as a hard linear-gradient; the
|
||||
// caption sits on a BLURRED veil now (index.html, button.mode::after) that fades in out of
|
||||
// nothing from above, and stacking the old gradient under it only doubled the darkening.
|
||||
// caption sits on a veil now (index.html, button.mode::after) that rises out of the bottom edge,
|
||||
// and stacking the old gradient under it only doubled the darkening.
|
||||
b.style.backgroundImage = `url(assets/${card.art}.png?v=${SPRV})`;
|
||||
b.innerHTML = `<span class="cb"><b>${card.name}</b><i>${card.sub}</i></span>`
|
||||
// THE POSTER IS ITS NAME (2026-08-31, the text reduction). The mode's one-line rule used to sit
|
||||
// under every name on the wall — sixty words to make one choice. The rules live in the modal
|
||||
// that opens with the mode; MODES.sub is still their source, it just no longer prints here.
|
||||
b.innerHTML = `<span class="cb"><b>${card.name}</b>${opts.line ? `<i>${opts.line}</i>` : ''}</span>`
|
||||
+ (last ? '<u class="last">LAST</u>' : '')
|
||||
+ (opts.chip ? `<u class="last chip">${opts.chip}</u>` : '')
|
||||
// the standing answer to "how do I advance", worn by the poster it is true of (config MODES)
|
||||
+ (card.tag ? `<u class="way">${card.tag}</u>` : '');
|
||||
return b;
|
||||
@@ -145,23 +151,26 @@ function buildSub(top) {
|
||||
modeSub.innerHTML = '';
|
||||
const t = top || topOf(pickedMode);
|
||||
document.getElementById('subTitle').textContent = t.diffs ? 'DIFFICULTY' : (t.name || '');
|
||||
if (t.diffs) for (const d of DIFFS) {
|
||||
// THE DIFFICULTY IS A COUNT, not a paragraph (2026-08-31, the text reduction): the name, and
|
||||
// three pips of which as many are lit as the chip is hard. Nothing else — the CPU explains
|
||||
// itself in the first rally, and it always did.
|
||||
if (t.diffs) DIFFS.forEach((d, i) => {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'chip';
|
||||
b.innerHTML = d.name + (d.id === pickedDiff ? '<u class="last">LAST</u>' : '');
|
||||
b.className = 'chip' + (i === DIFFS.length - 1 ? ' hot' : '');
|
||||
b.innerHTML = `<b>${d.name}</b><span class="pips">`
|
||||
+ [0, 1, 2].map(k => `<i${k <= i ? ' class="on"' : ''}></i>`).join('') + '</span>'
|
||||
+ (d.id === pickedDiff ? '<u class="last">LAST</u>' : '');
|
||||
b.addEventListener('click', () => {
|
||||
pickedDiff = d.id;
|
||||
try { localStorage.setItem('diff', d.id); } catch {}
|
||||
summary();
|
||||
go('setup');
|
||||
});
|
||||
modeSub.appendChild(b);
|
||||
}
|
||||
});
|
||||
else if (t.kids) for (const id of t.kids) {
|
||||
const b = banner(modeOf(id), 'mode', id === pickedMode);
|
||||
b.addEventListener('click', () => {
|
||||
pickMode(id);
|
||||
summary();
|
||||
go('setup');
|
||||
});
|
||||
modeSub.appendChild(b);
|
||||
@@ -177,12 +186,18 @@ function buildTops() {
|
||||
// merely floats above a stack is a label, and the wall needs three rooms, not three labels.
|
||||
if (t.group) {
|
||||
shelf = document.createElement('section');
|
||||
shelf.className = 'mgroup';
|
||||
shelf.innerHTML = `<header><b>${t.group}</b><i>${t.sub}</i></header>`;
|
||||
shelf.className = 'mgroup ' + (t.layout || '');
|
||||
shelf.innerHTML = `<header><b>${t.group}</b></header><div class="shelf"></div>`;
|
||||
modeList.appendChild(shelf);
|
||||
shelf = shelf.querySelector('.shelf'); // the posters live in the row, not the section
|
||||
continue;
|
||||
}
|
||||
const b = banner(t.mode ? modeOf(t.mode) : t, 'mode', topOf(pickedMode) === t);
|
||||
const fx = cupFixture(profile.prof.lg, profile.prof.ch);
|
||||
const b = banner(t.mode ? modeOf(t.mode) : t, 'mode', topOf(pickedMode) === t,
|
||||
// the fixture is the hero and carries the only second line on the wall; the training court
|
||||
// carries the one fact that is not a description — how much of the club it holds
|
||||
{ line: t.mode === 'cup' ? 'LEAGUE ' + fx.lg + ' · ' + fx.line : null,
|
||||
chip: t.mode === 'train' ? LADDER + ' RUNGS' : null });
|
||||
b.addEventListener('click', () => {
|
||||
// the netplay poster opens THE LOBBY and never touches pickedMode — it is not a run mode
|
||||
// the stats screen or the summary could name, and the lobby starts the game itself
|
||||
@@ -190,7 +205,6 @@ function buildTops() {
|
||||
// a group remembers which child you were on; entering it fresh lands on its first card
|
||||
pickMode(t.mode || (t.kids.includes(pickedMode) ? pickedMode : t.kids[0]));
|
||||
buildSub();
|
||||
summary();
|
||||
go(t.diffs || t.kids ? 'modesub' : 'setup');
|
||||
});
|
||||
shelf.appendChild(b);
|
||||
@@ -286,7 +300,6 @@ for (const t of TOURS) {
|
||||
pickedTour = t.id;
|
||||
for (const el of tourList.children) el.classList.toggle('on', el === b);
|
||||
refreshLook();
|
||||
summary();
|
||||
});
|
||||
tourList.appendChild(b);
|
||||
}
|
||||
@@ -398,15 +411,17 @@ function syncSetup() {
|
||||
document.getElementById('cStart').textContent = cupMode ? 'ENTER THE CUP' : 'SERVE UP';
|
||||
document.getElementById('who').style.display = cupMode || climbMode ? 'none' : 'flex';
|
||||
document.getElementById('lgPanel').style.display = cupMode || climbMode ? 'block' : 'none';
|
||||
// THE TEXT REDUCTION: two blocks on one screen, not two numbered steps
|
||||
document.getElementById('s01').textContent =
|
||||
cupMode ? '01 · YOUR LEAGUE' : climbMode ? '01 · THE CLIMB' : '01 · PLAYER';
|
||||
cupMode ? 'YOUR LEAGUE' : climbMode ? 'THE CLIMB' : 'PLAYER';
|
||||
if (cupMode) buildLeaguePanel(); else if (climbMode) buildClimbPanel();
|
||||
if (!courtUnlocked(picked)) picked = leagueOf(profile.prof.lg).court;
|
||||
// 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;
|
||||
// ...and the meters get their legend ONCE, in the heading, instead of a line under every court
|
||||
document.getElementById('s02').innerHTML = cupMode
|
||||
? '02 · HOME COURT<span>SET BY YOUR LEAGUE</span>' : '02 · COURT';
|
||||
? 'HOME COURT<span>SET BY YOUR LEAGUE</span>' : 'COURT<span>PACE · BOUNCE</span>';
|
||||
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);
|
||||
@@ -415,8 +430,11 @@ function syncSetup() {
|
||||
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;
|
||||
// an OPEN court says nothing — its two meters say it wordlessly (the text reduction). A locked
|
||||
// one names the league that opens it, and nothing else: the tile stays visible, as a goal should
|
||||
const sub = el.querySelector('i');
|
||||
sub.textContent = open ? '' : 'LEAGUE ' + l.id;
|
||||
sub.hidden = open;
|
||||
}
|
||||
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],
|
||||
@@ -425,8 +443,7 @@ function syncSetup() {
|
||||
el.classList.toggle('on', el.dataset.id === cur);
|
||||
document.getElementById(nameId).textContent = (table.find(e => e.id === cur) || table[0]).name;
|
||||
}
|
||||
refreshLook(); summary();
|
||||
}
|
||||
refreshLook();}
|
||||
|
||||
// --- THE IDENTITY SCREEN (the leagues, phase 1). First boot only: name + tour + colours, then
|
||||
// ENTER THE CLUB. It writes the SAME stores the setup screen reads (pickedTour/Kit/Skin, their
|
||||
@@ -477,8 +494,10 @@ pName.addEventListener('keydown', e => { if (e.key === 'Enter') document.getElem
|
||||
// SPEED how much pace survives the bounce — bounceXZ, grass .84 fastest, clay .70 slowest
|
||||
// BOUNCE how much of it comes back UP — bounceY, clay .66 highest, grass .50 lowest
|
||||
// The two divisors just stretch those narrow real ranges across a full bar; they carry no physics.
|
||||
// the label is a TITLE, not a printed word (the text reduction): the legend stands once over the
|
||||
// list — two bars in a fixed order need naming once, not on every row
|
||||
const meter = (label, v, cls = '') =>
|
||||
`<div class="meter ${cls}"><u>${label}</u><s><i style="width:${
|
||||
`<div class="meter ${cls}" title="${label}"><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
|
||||
@@ -491,7 +510,7 @@ for (const c of COURTS) {
|
||||
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>`
|
||||
+ `<b>${c.name}</b><i hidden></i>` // the sub only ever carries a LOCK now (syncSetup)
|
||||
+ meter('SPEED', (c.bounceXZ - 0.62) / 0.26)
|
||||
+ meter('BOUNCE', (c.bounceY - 0.44) / 0.28, 'b')
|
||||
+ '</span>';
|
||||
@@ -502,7 +521,6 @@ for (const c of COURTS) {
|
||||
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);
|
||||
}
|
||||
@@ -538,18 +556,38 @@ function buildTrain() {
|
||||
// in its own right — a practice list where every rung looks the same is a shelf, and this one
|
||||
// room in the game has an obvious reason to exist the moment you have just been put out.
|
||||
const out = profile.prof.out;
|
||||
const rungs = [];
|
||||
for (let n = 1; n <= top; n++) rungs.push(n);
|
||||
if (out >= 1 && out <= top) rungs.splice(rungs.indexOf(out), 1), rungs.unshift(out);
|
||||
for (const n of rungs) {
|
||||
const rung = n => {
|
||||
const f = matchFoe(n), b = document.createElement('button');
|
||||
const put = n === out;
|
||||
if (put) b.className = 'put';
|
||||
b.innerHTML = `<img src="${foeArt(f)}" alt=""><b>${f.name}</b>`
|
||||
+ `<i>${put ? 'PUT YOU OUT OF THE CUP' : 'No. ' + n}</i>`;
|
||||
b.addEventListener('click', () => { trainRung = n; start('train'); });
|
||||
return [f, b];
|
||||
};
|
||||
// THE REVANCHE IS A ROW, not a tile in the grid (the text reduction): it is one body out of
|
||||
// eighteen and the only one with a reason, so it gets the width to say so — and says it in
|
||||
// three words, because the screen it sits on is already called TRAINING.
|
||||
if (out >= 1 && out <= top) {
|
||||
const [f, b] = rung(out);
|
||||
b.className = 'put';
|
||||
b.innerHTML = `<img src="${foeArt(f)}" alt=""><span><b>${f.name}</b><i>PUT YOU OUT</i></span>`
|
||||
+ '<em>></em>';
|
||||
trainList.appendChild(b);
|
||||
}
|
||||
for (let n = 1; n <= top; n++) {
|
||||
if (n === out) continue;
|
||||
const [f, b] = rung(n);
|
||||
// the rung is a NUMBER on a list of numbers — two digits, no "No."
|
||||
b.innerHTML = `<img src="${foeArt(f)}" alt=""><b>${f.name}</b>`
|
||||
+ `<i>${String(n).padStart(2, '0')}</i>`;
|
||||
trainList.appendChild(b);
|
||||
}
|
||||
// ...and the list ENDS somewhere: the next league's band is drawn as the goal it is, not left
|
||||
// to stop dead at the cap with no explanation (the handoff's "Ziel, keine Liste")
|
||||
if (top < LADDER) {
|
||||
const next = LEAGUES.find(l => l.band[0] > top);
|
||||
const goal = document.createElement('div');
|
||||
goal.className = 'goal';
|
||||
if (next) goal.innerHTML = `<b>LEAGUE ${next.id}</b><i>${next.band[0]} — ${next.band[1]}</i>`;
|
||||
trainList.appendChild(goal);
|
||||
}
|
||||
}
|
||||
document.getElementById('trBack').addEventListener('click', () => go('setup'));
|
||||
|
||||
@@ -679,62 +717,36 @@ const boardFor = m => m === 'zombie' || m === 'cup' || m === 'train' ? stats.MOD
|
||||
: m === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
|
||||
|
||||
// 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 lg = leagueOf(profile.prof.lg);
|
||||
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
|
||||
// (see cupWon), so its summary reads the same book it writes.
|
||||
const best = stats.read('all', undefined, boardFor(m.id)).longest;
|
||||
const unit = m.id === 'zombie' || m.id === 'cup' ? ' FELLED'
|
||||
: best === 1 ? ' GAME' : ' GAMES';
|
||||
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}${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' ? 'NO RUN, NO DEATH — REPS PAY ¼ XP'
|
||||
: best ? best + unit + ' TO BEAT' : 'FIRST RUN'}</span>`;
|
||||
}
|
||||
|
||||
// The splash carries ONE number, on the STATS button. A boxed BEST RUN / WORLD RANK bar across the
|
||||
// top came out of the mockup and went again: it sat on the best part of the art and said twice what
|
||||
// the two buttons underneath already say. Re-read whenever the splash comes back up, because a run
|
||||
// has just ended and the count has moved.
|
||||
function refreshSplash() {
|
||||
document.getElementById('mKills').textContent = stats.total().kills.toLocaleString('en-GB') + ' KILLS';
|
||||
// THE TEXT REDUCTION (2026-08-31): the two secondary doors carry a NUMBER each instead of a
|
||||
// description — what you have done, and your own best on the board they open. The board figure
|
||||
// is read locally on purpose: the splash does not fetch the world board.
|
||||
document.getElementById('mKills').textContent = stats.total().kills.toLocaleString('en-GB');
|
||||
document.getElementById('mBest').textContent =
|
||||
'BEST ' + stats.bestScore(boardFor(pickedMode)).toLocaleString('en-GB');
|
||||
// THE PLAQUE — the meta identity, re-read whenever the splash comes back because a run has just
|
||||
// moved it (same rule as the kill count above). The goal line is the standing answer to "what
|
||||
// do I do to advance": win the CURRENT league's cup, become champion, or rest on the crown.
|
||||
// moved it (same rule as the kill count above). One row now: crest, name and level, and the bar
|
||||
// under them wearing the level it fills toward. The goal LINE is gone — the fixture button below
|
||||
// says what to do, and it said it twice.
|
||||
const pr = profile.progress(), lg = leagueOf(profile.prof.lg);
|
||||
document.getElementById('plBadge').src = `assets/badge_l${lg.id}.png?v=${SPRV}`;
|
||||
document.getElementById('plName').textContent = stats.savedName() || 'YOUR PLAYER';
|
||||
document.getElementById('plLv').textContent = 'LEVEL ' + pr.level + ' · LEAGUE ' + lg.id;
|
||||
document.getElementById('plFrom').textContent = pr.level;
|
||||
document.getElementById('plLv').textContent = 'LEVEL ' + pr.level;
|
||||
document.getElementById('plTo').textContent = pr.need ? pr.level + 1 : 'MAX';
|
||||
document.getElementById('plFill').style.width = Math.round(pr.frac * 100) + '%';
|
||||
// ...and the goal line carries THE RECORD now (THE SEASON): the fixture button below already
|
||||
// says what to do, so repeating "win the cup to advance" over it was the same sentence twice.
|
||||
// What the plaque can say that nothing else does is how far you have ever got.
|
||||
const bestR = profile.cupBest(profile.prof.lg);
|
||||
document.getElementById('plGoal').textContent = profile.prof.ch
|
||||
? 'CHAMPION OF THE DEAD CLUB — DEFEND THE NAME'
|
||||
: bestR
|
||||
? 'THIS CUP · BEST SO FAR: ' + cupRoundName(profile.prof.lg, bestR)
|
||||
: 'THE CUP IS THE WAY UP — NEVER ENTERED';
|
||||
// THE FRONT DOOR (THE SEASON): the fixture, in the same words the summary and the promotion use
|
||||
// (config.cupFixture). The record rides the sub line when there is one — a lost cup is the only
|
||||
// thing the club has to say about your last attempt, and it belongs where the next one starts.
|
||||
// (config.cupFixture) — minus the round count, which lives in the tournament's own modal now.
|
||||
const fx = cupFixture(profile.prof.lg, profile.prof.ch);
|
||||
document.getElementById('msTitle').textContent = fx.title;
|
||||
document.getElementById('msLine').textContent = fx.line;
|
||||
summary();
|
||||
}
|
||||
|
||||
|
||||
// `modeId` is the mode to drop into. It defaults to what the setup screen picked — but the BACK
|
||||
// ROOM passes null, because those entries bring their own mode and a remembered ZOMBIE TENNIS was
|
||||
// taking the court before startDrill/startBench/toggleTestMode could, so every one of them silently
|
||||
@@ -785,7 +797,7 @@ const goMode = () => {
|
||||
// confirmation page with nothing on it to confirm
|
||||
document.getElementById('mStart').addEventListener('click', () => {
|
||||
if (!profile.prof.setup) { buildPlayer(); go('player'); return; } // the club wants a name first
|
||||
pickMode('cup'); summary(); start('cup');
|
||||
pickMode('cup'); start('cup');
|
||||
});
|
||||
document.getElementById('mOther').addEventListener('click', goMode);
|
||||
document.getElementById('mStats').addEventListener('click', openStats);
|
||||
@@ -1106,7 +1118,7 @@ const maybeRestart = e => {
|
||||
if (run && run.newKillBest && run.score > 0) { askName(run); return; }
|
||||
// the fixture button restarts the canonical path without a detour through the menus; PLAY AGAIN
|
||||
// re-deals the mode that just ended (pickedMode is what started it, cup included)
|
||||
if (door === 'next') { pickMode('cup'); summary(); start('cup'); return; }
|
||||
if (door === 'next') { pickMode('cup'); start('cup'); return; }
|
||||
if (door === 'again') { start(pickedMode); return; }
|
||||
go('splash');
|
||||
};
|
||||
|
||||
+32
-29
@@ -2368,16 +2368,21 @@ function modalRows(m, inner, skipBody) {
|
||||
const rows = [];
|
||||
const push = r => { rows.push(r); return r; };
|
||||
if (m.foe) {
|
||||
push({ kind: 'split', h: 32 });
|
||||
push({ kind: 'split', h: 26 });
|
||||
const sub = [];
|
||||
for (const l of m.foe.sub || []) sub.push(...wrapText(l, F(8, 0), inner));
|
||||
if (sub.length) push({ kind: 'text', lines: sub, dim: true, h: sub.length * 12 + 2 });
|
||||
} else if (m.rules) {
|
||||
for (const r of m.rules) {
|
||||
const lead = wrapText(r.lead, F(8, 700), inner);
|
||||
const sub = r.sub ? wrapText(r.sub, F(8, 0), inner) : [];
|
||||
push({ kind: 'rule', lead, sub, hot: r.hot, pips: r.pips,
|
||||
h: lead.length * 12 + sub.length * 12 + (r.pips ? 12 : 0) + 2 });
|
||||
// ONE RULE, ONE LINE (2026-08-31, the menu-text-reduction handoff). Every row used to carry a
|
||||
// statement and a consequence, and the consequence was usually the statement again in other
|
||||
// words ("YOUR LEAGUE, ROUND BY ROUND" / "WIN IT ALL — MOVE UP A LEAGUE"). The line that
|
||||
// names the RESULT is the one that survives. A rule is a string, or an object when it needs
|
||||
// the threat colour or the blood pool. `pips` ride the SAME line, hard right.
|
||||
for (const raw of m.rules) {
|
||||
const r = typeof raw === 'string' ? { lead: raw } : raw;
|
||||
const room = inner - (r.pips ? r.pips * 9 + 12 : 0);
|
||||
const lead = wrapText(r.lead, F(8, 700), room);
|
||||
push({ kind: 'rule', lead, hot: r.hot, pips: r.pips, h: lead.length * 12 + 4 });
|
||||
}
|
||||
} else if (!skipBody) {
|
||||
const lines = [];
|
||||
@@ -2415,7 +2420,11 @@ function modalLayout(m) {
|
||||
// ...and only when it actually FITS there: a line that wraps in the narrow column belongs under
|
||||
// the head as an ordinary row, not as a ragged column beside a picture
|
||||
const beside = pairs && wrapped.length <= bodyLines.length + 1;
|
||||
const strap = m.foe ? wrapText(m.foe.line, F(8, 0), headW) : beside ? wrapped : [];
|
||||
// ONE line under the title: the body's slogan on a walk-on, the mode's own fact on a rules card
|
||||
// (`m.strap` — LEAGUE 3 · 4 ROUNDS, the round count the fixture button gave up), or the two
|
||||
// lines of an illustrated announcement.
|
||||
const strap = m.foe ? wrapText(m.foe.line, F(8, 0), headW)
|
||||
: m.strap ? wrapText(m.strap, F(8, 0), headW) : beside ? wrapped : [];
|
||||
const headH = Math.max(artH, 22 + strap.length * 12);
|
||||
const rows = modalRows(m, boxW - pad * 2, beside);
|
||||
let h = pad + headH + MODAL_GAP + MODAL_FOOT + MODAL_BTN + pad;
|
||||
@@ -2464,34 +2473,28 @@ function drawModal(m) {
|
||||
} else if (r.kind === 'rule') {
|
||||
ctx.textAlign = 'left';
|
||||
ctx.font = F(8, 700);
|
||||
r.lead.forEach((l, i) => shadowText(l, inX, y + 7 + i * 12, UI.cream));
|
||||
let ry = y + 7 + r.lead.length * 12;
|
||||
// the consequence line: DIM for information, the modal's hot colour only when it threatens
|
||||
ctx.font = F(8, 0);
|
||||
r.sub.forEach((l, i) => shadowText(l, inX, ry + i * 12, r.hot ? col : UI.dim));
|
||||
if (r.pips) { // the pool, in the HUD's own pip language
|
||||
const py = ry + r.sub.length * 12 + 4;
|
||||
// the rule wears the modal's hot colour only when it threatens; otherwise it is a statement
|
||||
r.lead.forEach((l, i) => shadowText(l, inX, y + 8 + i * 12, r.hot ? col : UI.cream));
|
||||
if (r.pips) { // the pool, in the HUD's own pip language, ON THE SAME LINE
|
||||
const px = x + L.boxW - pad - (r.pips - 1) * 9;
|
||||
for (let k = 0; k < r.pips; k++) {
|
||||
ctx.beginPath(); ctx.arc(inX + 3 + k * 9, py, 2.6, 0, 7);
|
||||
ctx.beginPath(); ctx.arc(px + k * 9, y + 8, 2.6, 0, 7);
|
||||
ctx.fillStyle = '#8a1a12'; ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1; ctx.stroke();
|
||||
}
|
||||
}
|
||||
} else if (r.kind === 'split') {
|
||||
// HIS LEVEL AGAINST YOURS — one frame, two figures, a hairline between them. The number
|
||||
// wears the modal's hot colour while the body outranks you and the pick green once it
|
||||
// does not, which is the whole point of showing it.
|
||||
const half = Math.round((L.boxW - pad * 2) / 2), f = m.foe;
|
||||
ctx.strokeStyle = MODAL_RULE; ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(inX + half + 0.5, y + 4); ctx.lineTo(inX + half + 0.5, y + r.h - 4);
|
||||
ctx.stroke();
|
||||
ctx.textAlign = 'center';
|
||||
ctx.font = F(7, 0);
|
||||
shadowText('ITS LEVEL', inX + half / 2, y + 8, UI.dim);
|
||||
shadowText('YOURS', inX + half * 1.5, y + 8, UI.dim);
|
||||
ctx.font = F(13, 700);
|
||||
shadowText(String(f.lvl), inX + half / 2, y + 23, f.lvl > f.mine ? col : UI.pick);
|
||||
shadowText(String(f.mine), inX + half * 1.5, y + 23, UI.cream);
|
||||
// HIS LEVEL AGAINST YOURS, on ONE line: his on the left, yours hard right. SAME FACE, SAME
|
||||
// SIZE for both (Oliver) — a comparison drawn at two sizes is not a comparison, it is a
|
||||
// headline with a footnote. Only the COLOUR differs, and only where it means something: his
|
||||
// number wears the modal's hot colour while he outranks you, the pick green once he does not.
|
||||
const f = m.foe;
|
||||
ctx.font = F(10, 700);
|
||||
ctx.textAlign = 'left';
|
||||
shadowText('LEVEL ' + f.lvl, inX, y + 9, f.lvl > f.mine ? col : UI.pick);
|
||||
ctx.textAlign = 'right';
|
||||
shadowText('YOURS ' + f.mine, x + L.boxW - pad, y + 9, UI.dim);
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
y += r.h + MODAL_GAP;
|
||||
}
|
||||
|
||||
@@ -165,6 +165,13 @@ export function read(period, runs = load(), mode = MODE_GORE) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// YOUR best number on a board, from your own runs. The splash shows it beside the world board's
|
||||
// door (the text reduction) — a local read on purpose: the splash does not fetch the board.
|
||||
export function bestScore(mode = MODE_GORE) {
|
||||
const B = boardOf(mode);
|
||||
return load().filter(r => modeOf(r) === mode).reduce((m, r) => Math.max(m, B.score(r)), 0);
|
||||
}
|
||||
|
||||
// the two figures the splash carries
|
||||
export function total() {
|
||||
const a = read('all');
|
||||
|
||||
Reference in new issue
Block a user