From fa6db7f77b2506b516eaf694df53ae0aa0c350f3 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sat, 29 Aug 2026 15:51:59 +0200 Subject: [PATCH] The leagues, the polish round: the way-up ribbon, upright chevrons, the breathing badges, the card counts up Co-Authored-By: Claude Fable 5 --- PROJECT.md | 22 +++++++++++++++++ index.html | 68 ++++++++++++++++++++++++++++++++------------------- src/config.js | 5 +++- src/main.js | 60 +++++++++++++++++++++++++++++++++------------ 4 files changed, 113 insertions(+), 42 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index a028221..355c2a6 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -491,6 +491,28 @@ 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 polish round: the ribbon, the glow, the count-up (2026-08-29, same day, STAGING ONLY) + +Oliver's retest ("schon viel besser") with five polish items, all built: + +- **Every chevron is UPRIGHT now.** Bangers is drawn slanted, so `‹`/`›` read as italic on every + screen (his "an vielen Stellen"). All `.chev` buttons and the plaque's arrow use Silkscreen's + straight ASCII `<`/`>` instead — the announcement face keeps its job, arrows stop leaning. +- **The plaque wears rounded corners (8px) and real padding** (12/14 against 8/10). +- **THE WAY-UP RIBBON**: the tag became a banderole — red field, thick white Silkscreen with a + light shadow, rotated -45° over the poster's top-left corner, ends clipped by the poster's own + `overflow: hidden` (that clip is what sells the wrap). And it shows on BOTH picker levels: the + top-level ZOMBIE TENNIS poster carries it too (`tag` on the MENU row), so the click path to the + promotion is marked from the first screen. +- **Poster captions grew**: titles 12 → 15 bold, sub lines 9 → 10 and a brighter grey — "viel + Text in sehr kleiner Schrift" was true of every banner. +- **The card performs its entrance**: earned badges and the current crest BREATHE a subtle gold + halo (`badgeGlow` keyframes, CSS-only — the parked menu loop is irrelevant to CSS animations; + the dark slots stay dead, which is their point), both bars sweep from empty (0.7s eased + transition, forced-reflow reset), and every number counts up fast — the XP line and all six + ledger figures through one `countUp` easer (setTimeout, the bracket's own no-rAF rule; timers + cancelled on rebuild so re-entry never races itself). + ## 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 diff --git a/index.html b/index.html index 3f094d3..2acacb1 100644 --- a/index.html +++ b/index.html @@ -209,8 +209,10 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat background: var(--solid); padding-top: max(10px, env(safe-area-inset-top)); } .hdr h2 { padding-left: 2px; } + /* UPRIGHT, not Bangers: the announcement face is drawn slanted, and a slanted arrow read as + italic on every screen. Silkscreen's ASCII < > are straight pixel arrows. */ .chev { width: 34px; height: 34px; flex: none; background: none; border: 2px solid var(--line); - color: var(--acid); font: 400 20px Bangers, cursive; line-height: 1; cursor: pointer; + color: var(--acid); font: 700 14px Silkscreen, monospace; line-height: 1; cursor: pointer; -webkit-tap-highlight-color: transparent; } /* the BODY COUNT headline row: three boxed figures sharing one frame */ .figs { display: flex; border: 2px solid var(--line); background: var(--panel); } @@ -291,6 +293,13 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat #cdBadges { display: flex; gap: 10px; align-items: flex-end; } #cdBadges img { height: 46px; image-rendering: pixelated; } #cdBadges img.dark { filter: grayscale(1) brightness(0.3); } + /* earned badges BREATHE — a subtle gold halo, CSS-only (the menu parks the game loop, CSS + animations do not care). The dark slots stay dead, which is the point of them. */ + #cdBadges img:not(.dark), #cdBadge { + animation: badgeGlow 2.6s ease-in-out infinite; } + @keyframes badgeGlow { + 0%, 100% { filter: drop-shadow(0 0 2px rgba(232, 178, 58, 0.25)); } + 50% { filter: drop-shadow(0 0 7px rgba(232, 178, 58, 0.75)); } } .rkbadge { height: 15px; margin-right: 6px; image-rendering: pixelated; flex: none; } /* THE LEVEL STRIP: the one meta number over a thin bar, riding the splash foot. The fill colour is the acid accent for now; the leagues will hand it the skill colour in phase 3. */ @@ -301,8 +310,9 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat 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); + #plaque { display: flex; align-items: center; gap: 12px; width: 100%; margin: 0 0 10px; + padding: 12px 14px; background: var(--solid); border: 2px solid var(--line); + border-radius: 8px; 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; } @@ -313,20 +323,27 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat 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); } + #plaque em { flex: none; font: 700 16px Silkscreen, monospace; 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; } #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; } + /* the WAY-UP RIBBON: the standing answer to "how do I advance", worn as a banderole wrapped + over the poster's corner (Oliver: bigger, red, thick white type, light shadow). The poster + clips its ends diagonally (overflow hidden + the -45deg rotation), which is what sells the + wrap. */ + .way { position: absolute; top: 20px; left: -42px; width: 150px; transform: rotate(-45deg); + padding: 6px 0; font: 700 11px Silkscreen, monospace; font-style: normal; + text-align: center; color: #fff; background: var(--red); + border-top: 1px solid rgba(255,255,255,0.35); border-bottom: 1px solid #69201c; + text-shadow: 1px 1px 0 rgba(0,0,0,0.55); box-shadow: 0 2px 6px rgba(0,0,0,0.4); + 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; } + #cdBar s i, #cdCareer s i { transition: width 0.7s cubic-bezier(0.2, 0.7, 0.3, 1); } /* KIT and SKIN: a row of colour squares, under a label that names whatever is selected. That is the whole UI — the pick has no meters and no art to show, and a labelled tile per colour would be five times the screen for a choice you make by looking at the colour. */ @@ -391,7 +408,8 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat a live answer to the header's question; tapping answers and advances. The only memory shown is the LAST tag — an indicator, not a selection. A border that means "SERVE UP will use this" exists solely on the final screen, where a confirm button actually follows. */ - #modeList button.mode, #modeSub button.mode { display: flex; align-items: flex-end; width: 100%; min-height: 96px; + #modeList button.mode, #modeSub button.mode { overflow: hidden; /* clips the WAY-UP ribbon's ends */ + display: flex; align-items: flex-end; width: 100%; min-height: 96px; margin: 0 0 8px; padding: 7px 9px; background-size: cover; background-position: center bottom; image-rendering: pixelated; border: 2px solid var(--line); color: inherit; font: inherit; text-align: left; cursor: pointer; -webkit-tap-highlight-color: transparent; @@ -425,8 +443,8 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat #courtList img { width: 92px; height: 66px; display: block; image-rendering: pixelated; flex: none; object-fit: cover; } #courtList .cb, #modeList .cb, #modeSub .cb { flex: 1; min-width: 0; } - #courtList b, #modeList b, #modeSub b { display: block; font: 400 12px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px; } - #courtList i, #modeList i, #modeSub i { display: block; margin: 3px 0 6px; font: 400 9px Silkscreen, monospace; color: var(--dim); font-style: normal; } + #courtList b, #modeList b, #modeSub b { display: block; font: 700 15px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px; } + #courtList i, #modeList i, #modeSub i { display: block; margin: 4px 0 6px; font: 400 10px Silkscreen, monospace; color: #c9c4b8; font-style: normal; } /* The two meters read straight off the court's own bounce numbers (see main.js), so they cannot drift away from what the ball actually does on it. */ .meter { display: flex; align-items: center; gap: 6px; margin-top: 4px; @@ -587,7 +605,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat LEVEL 1 - › + >
@@ -605,15 +623,15 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat who-and-where with SERVE UP. Every step comes up pre-selected from last time, so a returning player is three taps from serving. -->
-

PICK A GAME

+

PICK A GAME

-

DIFFICULTY

+

DIFFICULTY

-

MATCH SETUP

+

MATCH SETUP

-

YOUR PLAYER

+

YOUR PLAYER

NAMEYOURS ON EVERY BOARD

@@ -684,7 +702,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat main.buildCard from the profile + stats; the honours row shows every badge slot, earned bright and unearned dark. -->
-

YOUR PLAYER

+

YOUR PLAYER

@@ -710,7 +728,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat (main.js hands the settled rungs to startMatch). The rows are built in main.js from config.CUP_ROUNDS, so a round added there needs no markup. -->
-

THE DRAW

+

THE DRAW

FOUR ROUNDSDRAWING…

@@ -721,7 +739,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
-

TRAINING

+

TRAINING

PICK YOUR OPPONENTONE GAME · GO AGAIN

@@ -730,7 +748,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat a socket is open, so the room code and its SHARE button stand where the buttons were and the game stays parked behind this overlay until the room fills (main.js, THE LOBBY). -->
-

VS A FRIEND

+

VS A FRIEND

01 · YOUR NAMEON THEIR SCOREBOARD

-

BODY COUNT

+

BODY COUNT

@@ -783,7 +801,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat a ranking. Same tab strip as the stats screen's, and stats.drawRanks rewrites the column heading with the tab, because the number means something different on each one. -->
-

WORLD BOARD

+

WORLD BOARD

@@ -796,7 +814,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat at it, which is the whole point: it replaced a triple-tap on the in-play scoreboard that players kept tripping by accident. -->
-

BACK ROOM

+

BACK ROOM

@@ -808,7 +826,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
-

BOSS TEST

+

BOSS TEST

PICK A CHAPTERFULL ARSENAL

@@ -828,6 +846,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
- + diff --git a/src/config.js b/src/config.js index 339b5f8..294bb5d 100644 --- a/src/config.js +++ b/src/config.js @@ -844,7 +844,10 @@ export const MODES = [ // (`mode`) or carries its own card and the MODES ids it fans out to (`kids`). export const MENU = [ { mode: 'classic', diffs: true }, - { name: 'ZOMBIE TENNIS', sub: 'THE DEAD CLUB PLAYS YOU', art: 'mode_zombie', kids: ['zombie', 'cup', 'train'] }, + // the WAY-UP ribbon shows on THIS level too, so the click path to the promotion is marked + // from the first screen of the picker (main.banner reads `tag`) + { name: 'ZOMBIE TENNIS', sub: 'THE DEAD CLUB PLAYS YOU', art: 'mode_zombie', kids: ['zombie', 'cup', 'train'], + tag: 'THE WAY UP' }, { mode: 'gore' }, // NETPLAY (prototype): a poster with no run mode of its own — its second step is HOST / JOIN, // and either chip starts classic tennis against the other phone (beats.startNetVs + src/net.js) diff --git a/src/main.js b/src/main.js index 10dd3d5..fe191ed 100644 --- a/src/main.js +++ b/src/main.js @@ -506,7 +506,21 @@ document.getElementById('trBack').addEventListener('click', () => go('setup')); // honours row is a COLLECTION: every slot always visible, earned badges bright, the rest dark — // the empty slots are the incentive. The ledger reads the same stats module the BODY COUNT does. const fig = (label, val) => `
${label}${val}
`; +// THE CARD COUNTS UP (Oliver): bars sweep in and every number climbs fast on entry. setTimeout, +// not rAF — the menu parks the game loop (the bracket's own rule). Timers are cancelled on the +// next build so a re-entry never races itself. +let cardTimers = []; +const countUp = (write, target, dur = 700) => { + const t0 = performance.now(); + (function tick() { + const p = Math.min(1, (performance.now() - t0) / dur); + write(Math.round(target * (1 - Math.pow(1 - p, 3)))); // easeOutCubic: fast, then settles + if (p < 1) cardTimers.push(setTimeout(tick, 30)); + })(); +}; function buildCard() { + for (const t of cardTimers) clearTimeout(t); + cardTimers = []; const lg = leagueOf(profile.prof.lg), pr = profile.progress(); document.getElementById('cdName').textContent = stats.savedName() || 'THE WALK-ON'; document.getElementById('cdBadge').src = `assets/badge_l${lg.id}.png?v=${SPRV}`; @@ -515,14 +529,20 @@ function buildCard() { el.style.color = lg.color; document.getElementById('cdLevel').textContent = '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) - : ' · 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)) + '%'; + // both bars sweep from empty to their mark (the 0.7s eased transition in the CSS) + for (const [id, w] of [['cdFill', Math.round(pr.frac * 100)], + ['cdCareerFill', Math.min(100, Math.round(profile.prof.xp / profile.xpFor(profile.LEVEL_CAP) * 100))]]) { + const bar = document.getElementById(id); + bar.style.transition = 'none'; bar.style.width = '0%'; + void bar.offsetWidth; // commit the empty bar before the sweep + bar.style.transition = ''; + cardTimers.push(setTimeout(() => { bar.style.width = w + '%'; }, 40)); + } + const xpTail = pr.need + ? ' · ' + (pr.need - pr.into).toLocaleString('en-GB') + ' TO LEVEL ' + (pr.level + 1) + : ' · THE CAP'; + const cdXp = document.getElementById('cdXp'); + countUp(v => cdXp.textContent = v.toLocaleString('en-GB') + ' XP' + xpTail, profile.prof.xp); const row = document.getElementById('cdBadges'); row.innerHTML = ''; for (const l of LEAGUES) { @@ -542,14 +562,22 @@ function buildCard() { const g = stats.read('all', undefined, stats.MODE_GORE), z = stats.read('all', undefined, stats.MODE_MATCH), c = stats.read('all', undefined, stats.MODE_CLASSIC); - document.getElementById('cdFigs1').innerHTML = - fig('KILLS', (g.kills + z.kills + c.kills).toLocaleString('en-GB')) - + fig('MULTI-KILLS', (g.multi + z.multi + c.multi).toLocaleString('en-GB')) - + fig('DEATHS', (g.deaths + z.deaths + c.deaths).toLocaleString('en-GB')); - document.getElementById('cdFigs2').innerHTML = - fig('BEST RUN', g.longest + ' G') - + fig('MOST FELLED', z.longest) - + fig('RUNS', (g.matches + z.matches + c.matches).toLocaleString('en-GB')); + // the ledger climbs: rows are [box id, [label, target, format]...]; every value starts at 0 and + // counts up through the same easing the bars sweep with + for (const [boxId, rows] of [ + ['cdFigs1', [['KILLS', g.kills + z.kills + c.kills], + ['MULTI-KILLS', g.multi + z.multi + c.multi], + ['DEATHS', g.deaths + z.deaths + c.deaths]]], + ['cdFigs2', [['BEST RUN', g.longest, v => v + ' G'], + ['MOST FELLED', z.longest], + ['RUNS', g.matches + z.matches + c.matches]]]]) { + const box = document.getElementById(boxId); + box.innerHTML = rows.map(([label]) => fig(label, '0')).join(''); + [...box.querySelectorAll('.fig i')].forEach((cell, i) => { + const fmt = rows[i][2] || (v => v.toLocaleString('en-GB')); + countUp(v => cell.textContent = fmt(v), rows[i][1]); + }); + } refreshLook(); } document.getElementById('plaque').addEventListener('click', () => { buildCard(); go('card'); });