The leagues, phase 3: the character card, the honours collection, the skill trail

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-29 12:48:34 +02:00
1 parent 16bf5b9fed
commit 76d88030e5
11 files changed
+152 -17

No files matched your search

+28
View File
@@ -491,6 +491,34 @@ 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, PHASE 3 — the character card, badges, the skill trail (2026-08-29, STAGING ONLY)
Phase 3. STAGING ONLY, like 1 and 2.
- **THE CHARACTER CARD** (`#card`, main.buildCard): the splash's LEVEL STRIP is the door (tap it —
it wears `cursor: pointer` and nothing else changed on the splash). Name as the screen title,
the live sprite (drawLook, third canvas on refreshLook), the current league's crest + name in
the league colour, LEVEL in Bangers over the gradient bar with "N XP · M TO LEVEL X", an
HONOURS row, and THE LEDGER (two .figs rows off stats.read per mode: kills / multi-kills /
deaths, best run / most felled / runs). EDIT PLAYER borrows the identity screen and returns to
the card (`playerReturn`). Deviation from the plan: no death-screen entry — the death screen
already carries the XP moment inline, and its tap is the restart.
- **The honours row is a COLLECTION**: all five league shields plus the champion's laurel always
visible, earned bright, unearned dark (`filter: grayscale brightness`) — the empty slots are
the incentive. A shield is earned by FINISHING its league (being past it); league five's lights
with the champion crown, since there is no league six to be past it.
- **Badges rolled via OpenRouter** (one sheet on #FF00FF, cut by connected components + chroma
key, 4x NEAREST downscale): `badge_l1..l5.png` (terracotta / slate / grass / navy-ice /
marble-gold shields, each with the crossed-rackets motif and a chip of decay) + `badge_champ.png`
(gold laurel + cup). DOM `<img>` only — not in SPR, nothing draws them on canvas yet.
- **THE SKILL TRAIL**: `LEAGUES` rows carry `color` (each riding its court's palette), and the
player's OWN sprint dust wears it — `dustPuff` grew an optional colour, the mover's emit passes
`skillDust()`. League 1 keeps the shipped court dust: the tint itself is a progression reward.
Read off the profile (not `G.league`), so the gore mode shows your colour too. The aura stays
made of MOTION, never paint — the full-body glow idea was dropped against the painted-layer veto.
- Pane-verified: card at league 5 (four bright shields, dark L5 + laurel, ledger numbers, edit
round trip). Suite unchanged at 121 — the card is DOM, the dust is a colour.
## Session log: THE LEAGUES, PHASE 2 — bands, pace tiers, courts, the promotion (2026-08-29, STAGING ONLY)
Phase 2 of the plan. STAGING ONLY, like phase 1.
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+46 -4
View File
@@ -174,7 +174,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
/* ONE overlay, four panels, swapped by #intro[data-stage]. Keeping the backdrop and the card frame
alive across every screen is the point — a screen that unmounts the whole overlay flashes the
court through between menus. */
#splash, #setup, #stats, #ranks, #newbest, #secret, #bosses, #stmode, #stsub, #bracket, #vs, #train, #player, #promo { display: none; }
#splash, #setup, #stats, #ranks, #newbest, #secret, #bosses, #stmode, #stsub, #bracket, #vs, #train, #player, #promo, #card { display: none; }
#intro[data-stage="splash"] #splash { display: flex; flex-direction: column; flex: 1; }
/* flex COLUMN, not block: it is what lets the footer's margin-top:auto reach the bottom of a
screen whose content does not fill it */
@@ -190,6 +190,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
#intro[data-stage="vs"] #vs,
#intro[data-stage="player"] #player,
#intro[data-stage="promo"] #promo,
#intro[data-stage="card"] #card,
#intro[data-stage="newbest"] #newbest { display: flex; flex-direction: column; flex: 1;
animation: drop 0.3s ease-out; }
/* the overlay is HIDDEN during play, not removed: a restart puts it straight back, and the
@@ -274,14 +275,31 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
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; }
/* THE CHARACTER CARD (the leagues, phase 3): the player writ large — sprite, crest, level,
honours, ledger. The honours row is a collection with visible empty slots: the unearned
badges sit dark, which is the whole incentive. */
#cdTop { display: flex; gap: 14px; align-items: flex-start; margin-top: 14px; }
#cdLook { position: static; display: block; flex: none; margin: 0; width: auto; height: 200px;
touch-action: auto; image-rendering: pixelated; }
#cdMeta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
#cdBadge { width: 56px; image-rendering: pixelated; }
#cdLeague { font: 700 10px Silkscreen, monospace; text-decoration: none; letter-spacing: 1.5px; }
#cdLevel { font: 400 30px Bangers, cursive; color: var(--cream); letter-spacing: 1.5px; }
#cdXp { display: block; margin-top: 5px; font: 400 9px Silkscreen, monospace; color: var(--dim);
text-decoration: none; letter-spacing: 1px; }
#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); }
/* 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. */
#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 s { flex: 1; height: 6px; background: rgba(255,255,255,0.10); border: 1px solid var(--line);
#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; }
#xpRow s i { display: block; height: 100%; width: 0%;
#cdBar s { height: 8px; }
#xpRow s i, #cdBar 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
@@ -629,6 +647,30 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
<button class="btn big" id="prGo">WALK ON</button>
</div>
</div>
<!-- THE CHARACTER CARD (the leagues): tap the splash's level strip to open. Built by
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 id="cdTop">
<canvas id="cdLook" width="96" height="224"></canvas>
<div id="cdMeta">
<img id="cdBadge" alt="">
<u id="cdLeague"></u>
<b id="cdLevel">LEVEL 1</b>
<div id="cdBar"><s><i id="cdFill"></i></s><u id="cdXp"></u></div>
</div>
</div>
<h3>HONOURS<span>WIN THE CUP TO CLIMB</span></h3>
<div id="cdBadges"></div>
<h3>THE LEDGER<span>ALL TIME</span></h3>
<div class="figs" id="cdFigs1"></div>
<div style="height:8px"></div>
<div class="figs" id="cdFigs2"></div>
<div class="foot bare">
<button class="btn sec" id="cdEdit">EDIT PLAYER<i>NAME · TOUR · COLOURS</i></button>
</div>
</div>
<!-- THE DRAW (cup mode only). SERVE UP lands here instead of on a court: the four rounds roll
through their own band of the ladder and stop, and what you watched land is what walks on
(main.js hands the settled rungs to startMatch). The rows are built in main.js from
@@ -752,6 +794,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div>
</div>
</div>
<script type="module" src="src/main.js?v=313"></script>
<script type="module" src="src/main.js?v=314"></script>
</body>
</html>
+8 -5
View File
@@ -875,12 +875,15 @@ export const DIFFS = [
// `band` is [lo, hi] inclusive rungs. Court order deliberately rides the bounce table: clay is
// the slowest surface for the slowest league, grass the first prestige, and the top of the club
// plays on THE MEMBERS' GROUND. The pace tier is TUNE.leaguePace (gameplay.leaguePace()).
// `color` is the league's SKILL COLOUR — the identity tint the meta game hands the player:
// the sprint-dust trail (league 2 up; league 1 keeps the shipped court dust), the character card's
// accent, the badge row. Each rides its court's own palette so the colour reads as belonging.
export const LEAGUES = [
{ id: 1, name: 'THE GRAVEYARD SHIFT', court: 'clay', band: [1, 4] },
{ id: 2, name: 'THE CLUB STAFF', court: 'hard', band: [5, 8] },
{ id: 3, name: 'THE OFFICIALS', court: 'grass', band: [9, 12] },
{ id: 4, name: 'THE COACHING STAFF', court: 'gloom', band: [13, 15] },
{ id: 5, name: 'THE HONOURS BOARD', court: 'members', band: [16, 18] },
{ id: 1, name: 'THE GRAVEYARD SHIFT', court: 'clay', band: [1, 4], color: '#c4652f' },
{ id: 2, name: 'THE CLUB STAFF', court: 'hard', band: [5, 8], color: '#4f7fc9' },
{ id: 3, name: 'THE OFFICIALS', court: 'grass', band: [9, 12], color: '#4cae52' },
{ id: 4, name: 'THE COACHING STAFF', court: 'gloom', band: [13, 15], color: '#8fd8ff' },
{ 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];
+10 -5
View File
@@ -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 } 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 } 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';
@@ -8,7 +8,7 @@ import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js';
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
import { updateDrill } from './drill.js';
import { endRun, beginRun, MODE_CLASSIC } from './stats.js';
import { award, beginRunXp } from './profile.js';
import { award, beginRunXp, prof } from './profile.js';
import { updateZombies, updateParts, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam , pops, popText } from './zombies.js';
import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog, updateBench, benchKey, tickMatchFall, placeReceiver } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing
import { netSendShot, netSendToss, netTick, netJudging, netSendPoint, netSendFault, netClose } from './net.js'; // runtime-only cycle: net calls back into pointTo/fault/tossBall/showModal
@@ -81,13 +81,18 @@ function praiseWord() {
// grass, grit on the slab. Squarely inside the "blood, fire, smoke" procedural exemption; the ball's
// bounce and the sidestep both kick it, which is what pins fast feet and fast balls to the ground.
const DUST_COL = { grass: '#8fa06a', clay: '#c9895a', hard: '#8494a4' };
function dustPuff(x, z, n = 5, sp = 1) {
function dustPuff(x, z, n = 5, sp = 1, col = null) {
for (let k = 0; k < n; k++) parts.push({
x: x + (Math.random() * 2 - 1) * 0.15, y: 0.05, z: z + (Math.random() * 2 - 1) * 0.1,
vx: (Math.random() * 2 - 1) * 1.2 * sp, vy: 0.5 + Math.random() * 0.9 * sp,
vz: (Math.random() * 2 - 1) * 1.2 * sp,
t: 0.25 + Math.random() * 0.2, spark: DUST_COL[court.id] || '#98948a' });
t: 0.25 + Math.random() * 0.2, spark: col || DUST_COL[court.id] || '#98948a' });
}
// THE SKILL TRAIL (the leagues, phase 3): the player's OWN sprint dust wears the league colour —
// the aura made of motion, never paint (the painted-layer veto). League 1 keeps the shipped court
// dust: the tint itself is a progression reward. Read off the profile, not G.league, so the gore
// mode shows your colour too.
const skillDust = () => prof.lg > 1 ? (LEAGUES[prof.lg - 1] || LEAGUES[0]).color : null;
export function flickTarget(ch, aim) { // exported: render draws the live reticle with it
const q = pullQuality(aim.mag), f = pullFrac(aim.mag);
@@ -1930,7 +1935,7 @@ export function update(dt) {
const pSpd = Math.hypot(P.vx, P.vz);
if (TUNE.trailMin && pSpd > TUNE.trailMin
&& (P.dustAcc = (P.dustAcc || 0) + pSpd * dt) > TUNE.runDustEvery) {
P.dustAcc = 0; dustPuff(P.x, P.z, 2, 0.6);
P.dustAcc = 0; dustPuff(P.x, P.z, 2, 0.6, skillDust());
}
}
// ...and out of 'rally' he STANDS. 'run' is the one pose nothing else expires (expirePose only
+60 -3
View File
@@ -257,7 +257,8 @@ for (const t of TOURS) {
// way this can show a wrong colour is if the court would show it too.
// The sprite sheets load asynchronously and the menu deliberately parks the game loop, so the first
// draw can land before the art does: retry on a slow poll that stops itself the moment it succeeds.
const lookCv = document.getElementById('look'), pLookCv = document.getElementById('pLook');
const lookCv = document.getElementById('look'), pLookCv = document.getElementById('pLook'),
cdLookCv = document.getElementById('cdLook');
let lookPoll = 0;
function refreshLook() {
const t = TOURS.find(x => x.id === pickedTour) || TOURS[0];
@@ -265,7 +266,7 @@ function refreshLook() {
const s = SKINS.find(e => e.id === pickedSkin) || SKINS[0];
// both live players — the setup screen's and the identity screen's — are the same man and must
// move together; drawing to whichever is hidden is a no-op-cheap blit
const ok = drawLook(lookCv, t, k, s) & drawLook(pLookCv, t, k, s);
const ok = drawLook(lookCv, t, k, s) & drawLook(pLookCv, t, k, s) & drawLook(cdLookCv, t, k, s);
clearTimeout(lookPoll);
if (!ok) lookPoll = setTimeout(refreshLook, 200); // sprites still coming; ask again shortly
}
@@ -350,12 +351,16 @@ function buildPlayer() {
refreshLook();
}
document.getElementById('pDice').addEventListener('click', () => { pName.value = profile.zombieName(); });
document.getElementById('pBack').addEventListener('click', () => go('splash')); // setup stays open — it re-asks
document.getElementById('pBack').addEventListener('click', () => {
if (playerReturn === 'card') { playerReturn = null; buildCard(); go('card'); return; }
go('splash'); // setup stays unset — it re-asks
});
document.getElementById('pGo').addEventListener('click', () => {
// an emptied field is not a refusal to have a name — the die decides, like the prefill did
stats.saveName(stats.tidyName(pName.value) || profile.zombieName());
try { localStorage.setItem('tour', pickedTour); } catch {}
profile.prof.setup = 1; profile.save(); profile.push();
if (playerReturn === 'card') { playerReturn = null; buildCard(); go('card'); return; }
buildTops(); go('mode');
});
pName.addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('pGo').click(); });
@@ -495,6 +500,58 @@ function buildTrain() {
}
document.getElementById('trBack').addEventListener('click', () => go('setup'));
// --- THE CHARACTER CARD (the leagues, phase 3). The splash's level strip is the door. The
// 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>`;
function buildCard() {
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}`;
const el = document.getElementById('cdLeague');
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');
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) : '');
const row = document.getElementById('cdBadges');
row.innerHTML = '';
for (const l of LEAGUES) {
const img = document.createElement('img');
img.src = `assets/badge_l${l.id}.png?v=${SPRV}`;
img.title = l.name;
// a league's badge is EARNED by finishing it — winning its cup. Being past it means exactly
// that; league five has no "past", so its shield lights with the champion's crown.
img.className = l.id < profile.prof.lg || (l.id === LEAGUES.length && profile.prof.ch) ? '' : 'dark';
row.appendChild(img);
}
const champ = document.createElement('img');
champ.src = `assets/badge_champ.png?v=${SPRV}`;
champ.title = 'CHAMPION OF THE DEAD CLUB';
champ.className = profile.prof.ch ? '' : 'dark';
row.appendChild(champ);
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'));
refreshLook();
}
document.getElementById('xpRow').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', () => {
playerReturn = 'card'; buildPlayer(); go('player');
});
// --- THE PROMOTION CEREMONY (the leagues). beats.cupWon parks it in the profile module; the tap
// off the cup summary lands here (maybeRestart) before the title screen. The court tile IS the
// reward on screen; the name prompt, if the run also earned one, queues behind the ceremony.