diff --git a/PROJECT.md b/PROJECT.md index cdf3f14..a028221 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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 LEAGUES — the plaque round: the meta speaks (2026-08-29, same day, STAGING ONLY) + +Oliver's first screenshot review, four items, all built. The theme: the meta layer existed but +did not TALK to the player. + +- **No second bar** (his question, settled in discussion): a career bar moves ~2% a session late + and demotivates more than it informs. Total progress reads as the LEVEL NUMBER + LEAGUE + instead; the card carries the long form — "LEVEL 12 OF 50" and a thin CAREER bar (whole-curve + xp, honest about how far the cap is). +- **THE PLAYER PLAQUE** replaces the bare level strip on the splash: ONE button-styled panel — + league wappen, name, "LEVEL X · LEAGUE Y" over the bar, a chevron that says it opens (the bare + strip's tap was undiscoverable), and **the standing GOAL LINE**: "WIN THE GRAVEYARD SHIFT CUP + TO ADVANCE" / "…BECOME CHAMPION" / "CHAMPION OF THE DEAD CLUB — DEFEND THE NAME". The badge + COLLECTION deliberately stays on the card (splash real estate: the boxed-bar lesson) — now one + DISCOVERABLE tap away. +- **"How do I advance?" is answered in three standing places** (the promotion ceremony taught it + exactly once, and only AFTER the first promotion — too late): (1) the plaque's goal line; + (2) the arcade's own "what now?" moment — when the band's top body walks BACK on (`d.again`, + set by the clamp), its walk-on modal carries "NOBODY STRONGER IS LEFT / THE CUP IS THE WAY UP" + (pinned by the band-clamp test); (3) the tournament poster wears a corner tag **THE WAY UP** + (`tag` on the MODES row, rendered by banner()) and its blurb says "YOUR LEAGUE'S ROUNDS · WIN + IT TO RISE". +- **The promotion ceremony's court tile is centred** (`margin auto` + `align-self` — the .mid + column stretches block children, so the 74%-wide img sat left), and the death screen's XP line + moved to +24/+36/+50 — the pulsing ★NEW BEST★ is B(20) drawn top-baseline at +2 and overlapped + the +18 line whenever both showed. + ## Session log: THE LEAGUES, PHASE 4 — the duel learns, tin tiers, the level is you (2026-08-29, STAGING ONLY) Phase 4, the last of the built plan. STAGING ONLY — the whole feature waits on Oliver's own diff --git a/index.html b/index.html index 66b05b3..3f094d3 100644 --- a/index.html +++ b/index.html @@ -271,7 +271,8 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat animation: drop 0.5s ease-out 0.15s backwards; } #prLeague { margin: 0; font: 700 12px Silkscreen, monospace; color: var(--cream); letter-spacing: 2px; animation: drop 0.5s ease-out 0.3s backwards; } - #prCourt { width: 74%; max-width: 300px; margin-top: 10px; border: 2px solid var(--line); + #prCourt { width: 74%; max-width: 300px; margin: 10px auto 0; align-self: center; + border: 2px solid var(--line); image-rendering: pixelated; animation: drop 0.5s ease-out 0.5s backwards; } #prCourtName { margin: 0; font: 400 10px Silkscreen, monospace; color: var(--acid); letter-spacing: 1.5px; animation: drop 0.5s ease-out 0.62s backwards; } @@ -296,11 +297,34 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat #xpRow { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; } #xpRow u { font: 700 9px Silkscreen, monospace; color: var(--cream); text-decoration: none; letter-spacing: 1px; white-space: nowrap; } - #xpRow { cursor: pointer; } /* the strip IS the door to the character card */ - #xpRow s, #cdBar s { flex: 1; height: 6px; background: rgba(255,255,255,0.10); border: 1px solid var(--line); - text-decoration: none; display: block; } + /* THE PLAYER PLAQUE (replaces the bare level strip): ONE button-styled panel — wappen, name, + level+league over the bar, and the standing GOAL LINE ("win the X cup to advance"), with a + chevron that says it opens. The splash stays two buttons + one plaque; the badge COLLECTION + stays on the card, one (now discoverable) tap away. */ + #plaque { display: flex; align-items: center; gap: 10px; width: 100%; margin: 0 0 10px; + padding: 8px 10px; background: var(--solid); border: 2px solid var(--line); + cursor: pointer; text-align: left; -webkit-tap-highlight-color: transparent; } + #plBadge { height: 34px; flex: none; image-rendering: pixelated; } + #plBody { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; } + #plName { font: 700 10px Silkscreen, monospace; color: var(--cream); letter-spacing: 1.5px; + white-space: nowrap; overflow: hidden; } + #plRow { display: flex; align-items: center; gap: 8px; } + #plRow u { font: 700 8px Silkscreen, monospace; color: var(--dim); text-decoration: none; + letter-spacing: 1px; white-space: nowrap; } + #plGoal { font: 400 8px Silkscreen, monospace; font-style: normal; color: var(--acid); + letter-spacing: 1px; } + #plaque em { flex: none; font: 400 22px Bangers, cursive; font-style: normal; color: var(--acid); } + #plRow s, #cdBar s, #cdCareer s { flex: 1; height: 6px; background: rgba(255,255,255,0.10); + border: 1px solid var(--line); text-decoration: none; display: block; } #cdBar s { height: 8px; } - #xpRow s i, #cdBar s i { display: block; height: 100%; width: 0%; + #cdCareer { display: flex; align-items: center; gap: 8px; } + #cdCareer u { font: 700 8px Silkscreen, monospace; color: var(--dim); text-decoration: none; + letter-spacing: 1px; } + /* the mode picker's WAY-UP tag: the standing answer to "how do I advance", worn by the cup */ + .way { position: absolute; top: 8px; left: 8px; padding: 3px 6px; font: 700 8px Silkscreen, monospace; + font-style: normal; color: var(--ink); background: var(--acid); letter-spacing: 1px; + text-decoration: none; } + #plRow s i, #cdBar s i, #cdCareer s i { display: block; height: 100%; width: 0%; background: linear-gradient(90deg, #3f9560, var(--acid) 55%, #e8b23a); transition: width 0.4s ease; } /* KIT and SKIN: a row of colour squares, under a label that names whatever is selected. That is @@ -554,9 +578,17 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat you can press before the sheets have landed is how you reach the stats screen with half the zombies missing. Swapped by #splash.busy, see main.js. -->
LOADING
- -
LEVEL 1
+ +
@@ -660,6 +692,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat LEVEL 1
+
CAREER

HONOURSWIN THE CUP TO CLIMB

@@ -795,6 +828,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/beats.js b/src/beats.js index 3255958..e19015b 100644 --- a/src/beats.js +++ b/src/beats.js @@ -666,7 +666,10 @@ function matchOpponent() { const cup = G.match.cup; showModal({ title: zb.foe.name, color: UI.hot, icon: 'z_' + zb.fam + 'head', iconH: 64, body: [zb.foe.line].concat(cup ? [cup.rounds[cup.round].name + (cup.rounds[cup.round].need > 1 - ? ' · FIRST TO ' + cup.rounds[cup.round].need + ' GAMES' : '')] : []) }); + ? ' · FIRST TO ' + cup.rounds[cup.round].need + ' GAMES' : '')] + // the top of the band walking BACK on is the arcade's natural "what now?" moment — the + // intro answers it in place (THE LEAGUES: the cup is the only promotion) + : G.match.again ? ['NOBODY STRONGER IS LEFT', 'THE CUP IS THE WAY UP'] : []) }); // ...and a fresh body has read NOTHING yet: the habit tracker starts clean (see THE READ, config) G.match.laneRun = 0; G.match.lastLane = undefined; G.match.surprised = G.match.expected = false; // a FLAT pool per body: with regicide gone the body is pure scoreboard (matchPoint takes a @@ -793,8 +796,12 @@ function matchGameWon(zb, banked = false) { if (!G.net && !d.train) { d.felled = (d.felled || 0) + 1; // bodies actually felled (the board's number) // ...and the ladder climbs only inside the league's band: past its top rung the league has - // nobody stronger, so the top body walks back on (THE LEAGUES — the cup is the way up) - d.opp = Math.min(d.opp + 1, leagueOf(G.league).band[1]); + // nobody stronger, so the top body walks back on (THE LEAGUES — the cup is the way up). + // `d.again` marks exactly that moment — the felled body WAS the top — so its walk-on modal + // can answer the "what now?" the repeat would otherwise pose (see matchOpponent). + const top = leagueOf(G.league).band[1]; + d.again = d.opp >= top; + d.opp = Math.min(d.opp + 1, top); } // ALTERNATING SERVICE, as the sport has it. The match calls advancePoint directly, which banks // the game but never touches the server — that flip lives in tennis's own pointTo, which the diff --git a/src/config.js b/src/config.js index 79b7e6f..339b5f8 100644 --- a/src/config.js +++ b/src/config.js @@ -826,7 +826,10 @@ export const COURTS = [ 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' }, - { id: 'cup', name: 'THE TOURNAMENT', sub: 'FOUR ROUNDS · THE CHAMPION WAITS', art: 'mode_cup' }, + // `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', + tag: 'THE WAY UP' }, // TRAINING (2026-08-28): pick any rung, play it one game at a time, nothing is banked. The same // match machinery with the ladder parked — see startMatch's train parameter. { id: 'train', name: 'TRAINING', sub: 'PICK A BODY · ONE GAME · GO AGAIN', art: 'mode_train' }, diff --git a/src/main.js b/src/main.js index 776d366..10dd3d5 100644 --- a/src/main.js +++ b/src/main.js @@ -107,7 +107,9 @@ const banner = (card, cls, last) => { b.style.backgroundImage = `linear-gradient(180deg, rgba(0,0,0,0) 34%, rgba(0,0,0,0.78)), url(assets/${card.art}.png?v=${SPRV})`; b.innerHTML = `${card.name}${card.sub}` - + (last ? 'LAST' : ''); + + (last ? 'LAST' : '') + // the standing answer to "how do I advance", worn by the poster it is true of (config MODES) + + (card.tag ? `${card.tag}` : ''); return b; }; const pickMode = id => { pickedMode = id; try { localStorage.setItem('mode', id); } catch {} }; @@ -512,10 +514,15 @@ function buildCard() { el.textContent = `LEAGUE ${lg.id} OF ${LEAGUES.length} — ${lg.name}`; el.style.color = lg.color; document.getElementById('cdLevel').textContent = - 'LEVEL ' + pr.level + (pr.need ? '' : ' · THE CAP'); + 'LEVEL ' + pr.level + ' OF ' + profile.LEVEL_CAP; document.getElementById('cdFill').style.width = Math.round(pr.frac * 100) + '%'; document.getElementById('cdXp').textContent = profile.prof.xp.toLocaleString('en-GB') + ' XP' - + (pr.need ? ' · ' + (pr.need - pr.into).toLocaleString('en-GB') + ' TO LEVEL ' + (pr.level + 1) : ''); + + (pr.need ? ' · ' + (pr.need - pr.into).toLocaleString('en-GB') + ' TO LEVEL ' + (pr.level + 1) + : ' · THE CAP'); + // the CAREER bar is the long form the splash deliberately does not carry: whole-curve progress, + // honest about how far level 50 is + document.getElementById('cdCareerFill').style.width = + Math.min(100, Math.round(profile.prof.xp / profile.xpFor(profile.LEVEL_CAP) * 100)) + '%'; const row = document.getElementById('cdBadges'); row.innerHTML = ''; for (const l of LEAGUES) { @@ -545,7 +552,7 @@ function buildCard() { + fig('RUNS', (g.matches + z.matches + c.matches).toLocaleString('en-GB')); refreshLook(); } -document.getElementById('xpRow').addEventListener('click', () => { buildCard(); go('card'); }); +document.getElementById('plaque').addEventListener('click', () => { buildCard(); go('card'); }); document.getElementById('cdBack').addEventListener('click', () => go('splash')); let playerReturn = null; // EDIT PLAYER borrows the identity screen and comes back to the card document.getElementById('cdEdit').addEventListener('click', () => { @@ -613,11 +620,19 @@ function summary() { // has just ended and the count has moved. function refreshSplash() { document.getElementById('mKills').textContent = stats.total().kills.toLocaleString('en-GB') + ' KILLS'; - // the level strip — the meta clock, re-read whenever the splash comes back because a run has - // just moved it (same rule as the kill count above) - const pr = profile.progress(); - document.getElementById('xpLv').textContent = 'LEVEL ' + pr.level; - document.getElementById('xpFill').style.width = Math.round(pr.frac * 100) + '%'; + // 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. + 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('plFill').style.width = Math.round(pr.frac * 100) + '%'; + document.getElementById('plGoal').textContent = profile.prof.ch + ? 'CHAMPION OF THE DEAD CLUB — DEFEND THE NAME' + : profile.prof.lg >= LEAGUES.length + ? 'WIN THE ' + lg.name.replace('THE ', '') + ' CUP — BECOME CHAMPION' + : 'WIN THE ' + lg.name.replace('THE ', '') + ' CUP TO ADVANCE'; summary(); } diff --git a/src/render.js b/src/render.js index e967a45..93fa37b 100644 --- a/src/render.js +++ b/src/render.js @@ -2919,10 +2919,11 @@ export function render() { if (xd.xp > 0) { const pr = progress(); ctx.font = F(8); + // +24/+34: clear of the NEW BEST line above, which is B(20) drawn top-baseline at +2 shadowText('+' + xd.xp + ' XP' + (xd.to > xd.from ? ' · LEVEL ' + xd.from + ' → ' + xd.to : ' · LEVEL ' + pr.level), - W / 2, H / 2 + 18, xd.to > xd.from ? UI.pick : UI.cream); - const bw = 120, bx = Math.round(W / 2 - bw / 2), by = Math.round(H / 2 + 30); + W / 2, H / 2 + 24, xd.to > xd.from ? UI.pick : UI.cream); + const bw = 120, bx = Math.round(W / 2 - bw / 2), by = Math.round(H / 2 + 36); ctx.fillStyle = 'rgba(255,255,255,0.14)'; ctx.fillRect(bx, by, bw, 4); // the fill is a gradient (Oliver's call), green into gold — same ramp as the splash strip const gr = ctx.createLinearGradient(bx, 0, bx + bw, 0); @@ -2932,7 +2933,7 @@ export function render() { // gated well past the animation so a panic tap can't skip it (main.js gates the restart the same) if (dead ? G.eatenT > EATEN_RESTART : G.done.t > DONE_RESTART) { ctx.font = F(8); - shadowText(IS_TOUCH ? 'TAP TO PLAY AGAIN' : 'PRESS ANY KEY TO PLAY AGAIN', W / 2, H / 2 + 44, UI.warm); + shadowText(IS_TOUCH ? 'TAP TO PLAY AGAIN' : 'PRESS ANY KEY TO PLAY AGAIN', W / 2, H / 2 + 50, UI.warm); } ctx.restore(); } diff --git a/test.js b/test.js index c45fa01..cad210d 100644 --- a/test.js +++ b/test.js @@ -2230,6 +2230,19 @@ test('THE LEAGUES: the arcade opens at its band, climbs inside it, and counts fe } 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'); + // ...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; + 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); } + let saw = null; + for (let i = 0; i < (TUNE.matchMopT + 8) * 60 && !saw; i++) { + update(DT); + if (G.modal && G.modal.body) saw = G.modal.body.join(' | '); + } + assert.ok(saw && saw.includes('THE CUP IS THE WAY UP'), + 'the repeat walk-on carries the promotion lesson: ' + saw); + G.modal = null; G.modalQ.length = 0; endMatch(); });