The leagues, the polish round: the way-up ribbon, upright chevrons, the breathing badges, the card counts up

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-29 15:51:59 +02:00
1 parent 87ac3294bc
commit fa6db7f77b
4 files changed
+113 -42

No files matched your search

+22
View File
@@ -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
+43 -25
View File
@@ -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
<span id="plRow"><u id="plLv">LEVEL 1</u><s><i id="plFill"></i></s></span>
<i id="plGoal"></i>
</span>
<em>&rsaquo;</em>
<em>&gt;</em>
</button>
<button class="btn big" id="mStart">PLAY MATCH</button>
<div class="two">
@@ -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. -->
<div id="stmode">
<div class="hdr"><button class="chev" id="mBack">&lsaquo;</button><h2>PICK A GAME</h2></div>
<div class="hdr"><button class="chev" id="mBack">&lt;</button><h2>PICK A GAME</h2></div>
<div id="modeList"></div>
</div>
<div id="stsub">
<div class="hdr"><button class="chev" id="subBack">&lsaquo;</button><h2 id="subTitle">DIFFICULTY</h2></div>
<div class="hdr"><button class="chev" id="subBack">&lt;</button><h2 id="subTitle">DIFFICULTY</h2></div>
<div id="modeSub"></div>
</div>
<div id="setup">
<div class="hdr"><button class="chev" id="sBack">&lsaquo;</button><h2>MATCH SETUP</h2></div>
<div class="hdr"><button class="chev" id="sBack">&lt;</button><h2>MATCH SETUP</h2></div>
<!-- ONE PLAYER BLOCK. It was four stacked sections — two big portrait tiles, then the live
player, then a kit row, then a skin row — which said the same thing twice (the tiles were
a second, FRONT-FACING drawing of a man who does not react to any of the colours) and ran
@@ -644,7 +662,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
keys), so the two screens cannot disagree about who you are. Rebuilt on every visit by
main.buildPlayer, so the .on states track the setup screen's. -->
<div id="player">
<div class="hdr"><button class="chev" id="pBack">&lsaquo;</button><h2>YOUR PLAYER</h2></div>
<div class="hdr"><button class="chev" id="pBack">&lt;</button><h2>YOUR PLAYER</h2></div>
<h3>NAME<span>YOURS ON EVERY BOARD</span></h3>
<div id="nameRow">
<input id="pName" class="tin" maxlength="12" autocomplete="off" spellcheck="false">
@@ -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. -->
<div id="card">
<div class="hdr"><button class="chev" id="cdBack">&lsaquo;</button><h2 id="cdName">YOUR PLAYER</h2></div>
<div class="hdr"><button class="chev" id="cdBack">&lt;</button><h2 id="cdName">YOUR PLAYER</h2></div>
<div id="cdTop">
<canvas id="cdLook" width="96" height="224"></canvas>
<div id="cdMeta">
@@ -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. -->
<div id="bracket">
<div class="hdr"><button class="chev" id="brBack">&lsaquo;</button><h2>THE DRAW</h2></div>
<div class="hdr"><button class="chev" id="brBack">&lt;</button><h2>THE DRAW</h2></div>
<h3>FOUR ROUNDS<span id="brState">DRAWING…</span></h3>
<div id="cupRows"></div>
<div class="foot">
@@ -721,7 +739,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
<!-- THE TRAINING COURT's opponent picker: the whole ladder face-up, tap a body to drill it.
Built in main.js from config's roster (matchFoe/LADDER), so a new rung needs no markup. -->
<div id="train">
<div class="hdr"><button class="chev" id="trBack">&lsaquo;</button><h2>TRAINING</h2></div>
<div class="hdr"><button class="chev" id="trBack">&lt;</button><h2>TRAINING</h2></div>
<h3>PICK YOUR OPPONENT<span>ONE GAME · GO AGAIN</span></h3>
<div id="trainList"></div>
</div>
@@ -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). -->
<div id="vs">
<div class="hdr"><button class="chev" id="vsBack">&lsaquo;</button><h2>VS A FRIEND</h2></div>
<div class="hdr"><button class="chev" id="vsBack">&lt;</button><h2>VS A FRIEND</h2></div>
<div id="vsPick">
<h3>01 · YOUR NAME<span>ON THEIR SCOREBOARD</span></h3>
<input id="vsName" class="tin" maxlength="12" placeholder="YOUR NAME" autocomplete="off"
@@ -758,7 +776,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div>
<!-- BODY COUNT. Every row in here is built by stats.js from the run records in localStorage. -->
<div id="stats">
<div class="hdr"><button class="chev" id="stBack">&lsaquo;</button><h2 id="stTitle">BODY COUNT</h2></div>
<div class="hdr"><button class="chev" id="stBack">&lt;</button><h2 id="stTitle">BODY COUNT</h2></div>
<!-- WHICH MODE, then which period. The three modes do not measure the same thing (see the
record-shape note in stats.endRun), so this strip changes the whole screen under it —
the title, the three figures, the roster's heading and the footer. -->
@@ -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. -->
<div id="ranks">
<div class="hdr"><button class="chev" id="rkBack">&lsaquo;</button><h2>WORLD BOARD</h2></div>
<div class="hdr"><button class="chev" id="rkBack">&lt;</button><h2>WORLD BOARD</h2></div>
<div class="tabs" id="rkModes">
<button class="on" data-m="g">SPLATTER</button>
<button data-m="z">TENNIS</button>
@@ -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. -->
<div id="secret">
<div class="hdr"><button class="chev" id="scBack">&lsaquo;</button><h2>BACK ROOM</h2></div>
<div class="hdr"><button class="chev" id="scBack">&lt;</button><h2>BACK ROOM</h2></div>
<div class="foot bare">
<button class="btn big" id="scWeapons">WEAPON TEST</button>
<button class="btn big" id="scBosses">BOSS TEST</button>
@@ -808,7 +826,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
<!-- one tile per chapter boss, built from config.bossArt in main.js: adding a design to the
roster puts it on this screen and never touches this markup -->
<div id="bosses">
<div class="hdr"><button class="chev" id="bsBack">&lsaquo;</button><h2>BOSS TEST</h2></div>
<div class="hdr"><button class="chev" id="bsBack">&lt;</button><h2>BOSS TEST</h2></div>
<h3>PICK A CHAPTER<span>FULL ARSENAL</span></h3>
<div id="bossList"></div>
</div>
@@ -828,6 +846,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div>
</div>
</div>
<script type="module" src="src/main.js?v=316"></script>
<script type="module" src="src/main.js?v=317"></script>
</body>
</html>
+4 -1
View File
@@ -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)
+44 -16
View File
@@ -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) => `<div class="fig"><b>${label}</b><i>${val}</i></div>`;
// 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'); });