diff --git a/PROJECT.md b/PROJECT.md index 05260b4..408a09a 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -491,7 +491,73 @@ 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. -## THE SEASON — the tournament is the story (2026-08-31, AGREED in discussion; NOT BUILT YET) +## Session log: THE SEASON, PHASES 1-4 — the front door, the record, the rooms, the season beat (2026-08-31, STAGING ONLY) + +All four phases of the plan below, plus the overlay consolidation Oliver asked for in the same +breath ("die modals haben manchmal 2 boxen manchmal eine manchmal keine... der tap wird zu einem +echten button im footer der box... zudem noch das run-ende, hier bitte auch mehr struktur"). + +**THE MODAL IS ONE BOX** (`render.drawModal`). Three drawers — the plain announcement, the rules +card, the walk-on — became one renderer with slots: a MASTHEAD (art beside the title, with the +body's strap under it), a stack of ROWS separated by hairlines (statement over consequence, the +split figure of his level against yours, the blood pool as pips), and a FOOTER BUTTON. The payload +is untouched: `title/body/icon/rules/foe` are the same fields beats.js has always sent, so the +suite and every call site are unaffected — this is a layout. Two consequences worth knowing: +- The entrance is now ONE object flying in from the right. The old per-element stagger would tear + a box apart, and a box that assembles itself out of flying pieces is not a box. +- The button says what HAPPENS (`PLAY ON` / `GOT IT` / `WALK ON`, `m.btn` overrides) instead of + what gesture to make, and it appears only once the box has landed. **A tap anywhere still + dismisses** — the button is the affordance, not a hit target. A modal you could only leave + through one 26px strip would be a worse wall than the one we had. + +**THE RUN-END WEARS THE SAME BOX, AND HANDS OVER.** Headline and the run's line, then the xp it +paid, then the mark, then THE FIXTURE with its buttons — `ENTER THE DRAW` beside `PLAY AGAIN` +(or `THE CLUBHOUSE` when the run just played was the cup). `render.endHit(nx, ny)` gives the +rects back to `main.maybeRestart`, which is where the tap already lived; a tap anywhere else is +the old behaviour exactly, so nothing that worked before stopped working. Netplay gets ONE door +(the clubhouse): dealing a solo cup into a session whose socket is still tearing down is not a +handover, it is a bug waiting for a phone verdict. + +**THE CUP'S OWN EXIT IS A CEREMONY.** `OUT IN THE QUARTER-FINAL.` over `YOUR BEST STANDS: THE +SEMI-FINAL` (or `THE BOARD NOTES IT` when the attempt IS the new record). The board's felled count +is suppressed there — two bests answering different questions in one box was the wall of text this +pass exists to remove. + +**THE RECORD** (`profile.cups` / `profile.out`, `recordCup`, `cupBest`). Two small fields: rounds +ever reached per league, and the rung of the body that ended the last attempt. Written by the two +places a cup run can end (`zombies.killPlayer`, `beats.cupWon`), mirrored server-side beside the +xp (`server.clean_profile` re-derives them like every other field), merged FORWARD on pull. Shown +on the plaque (which stopped repeating "win the cup to advance" — the fixture button below says +that), in the league panel, and in the summary. Training reps write nothing: a rep is not an +attempt. + +**THE FRONT DOOR IS THE FIXTURE.** `PLAY MATCH` became `LEAGUE 3 · THE CUP / 4 ROUNDS · ROUND ONE: +THE STRAWBERRY SELLER` and enters the draw directly — the cup has no court to pick and no opponent +to choose, so the setup screen there was a confirmation page with nothing on it to confirm. The +poster wall moved one tap down behind `OTHER GAMES`. `config.cupFixture` is the single descriptor +all three screens read, so the splash, the summary and the promotion cannot drift apart on its +wording. + +**THE ROOMS.** The wall deals three named groups now — THE SEASON (the cup, alone), THE TRAINING +(the climb, the training court), OUT OF COMPETITION (gore, classic, a friend) — and the cup left +the ZOMBIE TENNIS pocket for the top. That also emptied the second step of everything except +CLASSIC's difficulty chips. `MENU` rows with a `group` key are headings, not posters. + +**THE REVANCHE.** The training list puts the body that knocked you out of the cup FIRST, in a red +tile that says `PUT YOU OUT OF THE CUP`. One remembered rung turns a shelf of eighteen equal tiles +into the obvious answer to the last defeat — the cheapest coupling of Beiwerk to canon we have. + +**THE SEASON BEAT** rides inside the promotion (no new screen): `NEXT: LEAGUE 4 · THE CUP` over +its rounds and its first body — or, for the champion, the title he now has to hold. + +Tests: two added (127 total) — the fixture descriptor against every league (including the +champion's defence), and the record's forward-only write with the eliminator it names. `freshSplatter` +resets `cups`/`out` for the same reason it resets the league: a cup ending writes them for real. + +Open for the phone: whether the fixture button should ever go to the setup screen (it does not +now), and whether the promotion's WALK ON should enter the draw instead of the clubhouse. + +## THE SEASON — the tournament is the story (2026-08-31, AGREED in discussion; PHASES 1-4 BUILT ON STAGING — see the log above) Oliver's diagnosis, and it is right: "alles ist irgendwie beliebig... die Menüführung und die Start- und Endscreens enforcen keine Story-Line". The cause is not a missing story — THE LEAGUES already diff --git a/index.html b/index.html index fa44680..1d3f10a 100644 --- a/index.html +++ b/index.html @@ -237,6 +237,12 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat hierarchy is carried by colour rather than by art. */ .btn.big { padding: 13px 10px 11px; background: var(--red); border-color: #69201c; color: #f6e7dc; font: 400 26px Bangers, cursive; letter-spacing: 2px; line-height: 1.15; } + /* THE FIXTURE BUTTON (THE SEASON): the shout stays the shout, and the fixture itself is the + quiet line under it — the same two-register rule the cards follow (Bangers announces, Silkscreen + informs). `b` is not bold here, it IS the announcement face. */ + .btn.big b { display: block; font: inherit; } + .btn.big i { display: block; margin-top: 3px; font: 400 9px Silkscreen, monospace; + font-style: normal; letter-spacing: 0.5px; color: #f0cfc4; } .two { display: flex; gap: 8px; } .two .btn { margin-bottom: 0; } .btn.sec i { display: block; margin-top: 4px; font: 400 9px Silkscreen, monospace; @@ -413,11 +419,21 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat text-align: center; line-height: 1.35; } #bossList i, #trainList i { font: 400 8px Silkscreen, monospace; color: var(--dim); font-style: normal; letter-spacing: 1px; } + /* THE REVANCHE TILE (THE SEASON): the one that put you out of the cup, marked and standing + first. Red border and a red line, because it is the only tile on this screen with a reason. */ + #trainList button.put { border-color: var(--red); } + #trainList button.put i { color: #e08a7c; } /* the training picker is 18 rungs deep — the tile carries the HEAD (the same z_*head crop the draw and the walk-on wear), which reads at a third of the boss portrait's height */ #trainList img { height: 44px; width: auto; display: block; image-rendering: pixelated; } /* START is genuinely unreachable until the draw has landed — the wait IS the ceremony */ .btn[disabled] { opacity: 0.32; cursor: default; } + /* the fixture line under the new court: the same two registers the plaque uses — the name in + the league's own acid, what it costs you in the quiet face */ + #prNext { margin: 12px 0 0; font: 400 10px Silkscreen, monospace; color: var(--acid); + letter-spacing: 1px; line-height: 1.6; } + #prNext u { display: block; margin-top: 3px; font-size: 9px; color: var(--dim); + text-decoration: none; letter-spacing: 0.5px; } /* a row, not a stacked tile: three 3:2 tiles stacked fill a phone twice over, and the art still reads at this size. -webkit-tap-highlight-color kills iOS's blue flash over the pixel art. */ /* MODE posters are pure BUTTONS, not selectors (Oliver: the remembered highlight made each one @@ -439,6 +455,15 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat at cream-on-crowd was the report from the first live look */ #modeList i, #modeSub i { color: #fff; font-size: 10px; margin-bottom: 0; text-shadow: 0 1px 0 #000, 1px 0 0 #000, -1px 0 0 #000, 0 -1px 0 #000, 0 0 7px #000; } + /* THE GROUP HEADINGS (THE SEASON): what the posters under them are FOR — one path up, a room to + practise in, a room where nothing counts. Deliberately quiet: the posters are the loud objects + on this screen, and a heading competing with them would just be a fourth poster. */ + #modeList .mgroup { margin: 16px 0 7px; } + #modeList .mgroup:first-child { margin-top: 2px; } + #modeList .mgroup b { display: block; font: 400 12px Silkscreen, monospace; letter-spacing: 2px; + color: var(--acid); text-shadow: none; } + #modeList .mgroup i { display: block; margin: 3px 0 0; font: 400 9px Silkscreen, monospace; + font-style: normal; font-size: 9px; color: var(--dim); text-shadow: none; } /* THE SECOND STEP (config.MENU) has its own SCREEN in the wizard: sub-mode cards are full-height posters centred on their scene, and the difficulty chips are plain acid-outline buttons stacked tall — art for EASY/MEDIUM/HARD would be forced, and the chip reads faster. */ @@ -632,7 +657,12 @@ 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
- + + +
@@ -730,6 +760,10 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat

+ +

@@ -871,6 +905,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
- + diff --git a/server.py b/server.py index 79479e4..d3735a3 100644 --- a/server.py +++ b/server.py @@ -74,12 +74,26 @@ def clean_profile(payload): return None p = payload['p'] word = lambda k, n=12: re.sub(r'[^a-z0-9_]', '', str(p.get(k, '')))[:n] + # THE CUP RECORD (THE SEASON): league -> rounds ever reached. Re-derived key by key like every + # other field — five small ints, nothing else survives the trip. + cups = {} + raw = p.get('cups') + if isinstance(raw, dict): + for lg in range(1, 6): + try: + v = int(raw.get(str(lg), raw.get(lg, 0))) + except (TypeError, ValueError): + v = 0 + if v > 0: + cups[str(lg)] = min(9, v) try: out = {'v': 1, 'xp': max(0, min(int(p.get('xp', 0)), 1_000_000_000)), 'lg': max(1, min(int(p.get('lg', 1)), 5)), 'ch': 1 if p.get('ch') else 0, 'setup': 1 if p.get('setup') else 0, + 'cups': cups, + 'out': max(0, min(int(p.get('out', 0)), 99)), 'n': re.sub(r'[^A-Z0-9 ._-]', '', str(p.get('n', '')).upper()).strip()[:NAME_MAX], 'tour': word('tour'), 'kit': word('kit'), 'skin': word('skin')} except (TypeError, ValueError): diff --git a/src/beats.js b/src/beats.js index 4b7d0a9..e9e06ef 100644 --- a/src/beats.js +++ b/src/beats.js @@ -9,7 +9,7 @@ import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME, racketStage, leagueOf, cupRoundsFor, foeLevel } from './config.js'; import { endRun, MODE_MATCH } from './stats.js'; -import { award, prof, save as profSave, push as profPush, setPromo, levelAt } from './profile.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'; import { voice, zombieDuck, aww } from './audio.js'; import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.js'; @@ -928,6 +928,7 @@ function cupWon() { else if (!prof.ch) { prof.ch = 1; setPromo({ champion: true }); } profSave(); profPush(); } + if (!d.train) recordCup(G.league, d.cup.rounds.length); // the full record: you took the final const games = score.games[0] + score.games[1]; const b = endRun(d.cup.rounds.length, games, G.splatT, false, MODE_MATCH); G.best = b; G.newBest = b.newBest; diff --git a/src/config.js b/src/config.js index 6580cac..f24408f 100644 --- a/src/config.js +++ b/src/config.js @@ -846,13 +846,25 @@ export const MODES = [ // opens its own second step — classic deals the difficulty chips, zombie tennis deals its two // sub-mode cards (arcade / the cup), gore just IS gore. A row either points at a run mode in MODES // (`mode`) or carries its own card and the MODES ids it fans out to (`kids`). +// THE ROOMS, NOT A SHELF (2026-08-31, THE SEASON). The wall used to deal five posters as equals +// with the career hidden two taps inside ZOMBIE TENNIS, so nothing on screen said what any of them +// was FOR — Oliver: "alles ist irgendwie beliebig". Three named groups answer that before a single +// caption is read: one path up, a room to practise in, and a room where nothing counts. A `group` +// row is a heading (main.buildTops), not a poster. +// +// The cup left the ZOMBIE TENNIS pocket for the top of the wall — it is the canonical path, and a +// 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. export const MENU = [ - { mode: 'classic', diffs: true }, - // 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' }, + { group: 'THE SEASON', sub: 'THE ONLY WAY UP THE CLUB' }, + { mode: 'cup' }, + { group: 'THE TRAINING', sub: 'SHARPEN UP · NOTHING HERE MOVES YOUR LEAGUE' }, + { mode: 'zombie' }, + { mode: 'train' }, + { group: 'OUT OF COMPETITION', sub: 'NO LEAGUE, NO RECORD, NO STAKES' }, { mode: 'gore' }, + { mode: 'classic', diffs: true }, // 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) { name: 'VS A FRIEND', sub: 'LIVE 1V1 · REAL TENNIS · TWO PHONES', art: 'mode_vs', net: true }, @@ -941,6 +953,25 @@ export const cupRoundsFor = lg => { return rounds.concat([{ name: 'THE FINAL', band: [hi], need: 2 }]); }; +// THE SEASON (2026-08-31, PROJECT.md "THE SEASON"): what the club has you down for next. The +// tournament is the canonical path and every screen that points at it — the splash's front door, +// the run-end handover, the promotion's second beat — says the SAME sentence, because three +// screens drifting apart on its wording is exactly how the story stopped reading as one. Pure +// descriptor: it starts nothing, it only names the fixture. +export const cupFixture = (lg, champion = 0) => { + const rounds = cupRoundsFor(lg), first = rounds[0], foe = matchFoe(first.band[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 }; +}; + +// How far a cup record reads, in the rounds' own words: 3 of 4 rounds reached is THE SEMI-FINAL. +export const cupRoundName = (lg, reached) => { + const rounds = cupRoundsFor(lg); + return reached >= rounds.length + 1 ? 'WON' : (rounds[reached - 1] || rounds[0]).name; +}; + export const TOURS = [ // `kit` — HOW this tour's art takes a colour, because the two sets are dressed differently and the // same operation cannot serve both. diff --git a/src/main.js b/src/main.js index f97cc3d..bcad381 100644 --- a/src/main.js +++ b/src/main.js @@ -3,12 +3,12 @@ // Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update) // render (camera/drawing) · input (keys/touch) · audio (sfx/umpire) · panel (T tuning UI) -import { TUNE, COURTS, TOURS, KITS, SKINS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, LEAGUES, leagueOf, cupRoundsFor, matchFoe, LADDER } from './config.js'; +import { TUNE, COURTS, TOURS, KITS, SKINS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, LEAGUES, leagueOf, cupRoundsFor, cupFixture, cupRoundName, matchFoe, LADDER } from './config.js'; import { P, O, ball, score, G, setupPoint, sendBoy, selectCourt, selectTour, selectKit, selectSkin } from './state.js'; import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, startGore, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.js'; import { startDuel, resolveInterlude, startHorde, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs, endNetVs } from './beats.js'; import { netConnect, netHooks } from './net.js'; -import { fitViewport, render, proj, RES, SPRV, LOAD, SPR, drawLook } from './render.js'; +import { fitViewport, render, proj, RES, SPRV, LOAD, SPR, drawLook, endHit } from './render.js'; import { voice, ambient, menuMusic, menuDuck } from './audio.js'; import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js'; import { cv } from './dom.js'; @@ -146,6 +146,14 @@ function buildSub(top) { function buildTops() { modeList.innerHTML = ''; for (const t of MENU) { + // a GROUP row is the heading over the posters under it — what that shelf is for (config MENU) + if (t.group) { + const h = document.createElement('div'); + h.className = 'mgroup'; + h.innerHTML = `${t.group}${t.sub}`; + modeList.appendChild(h); + continue; + } const b = banner(t.mode ? modeOf(t.mode) : t, 'mode', topOf(pickedMode) === t); b.addEventListener('click', () => { // the netplay poster opens THE LOBBY and never touches pickedMode — it is not a run mode @@ -342,9 +350,15 @@ function buildLeaguePanel() { foes.appendChild(d); } const size = lg.band[1] - lg.band[0] + 1; - document.getElementById('lgSub').textContent = felled >= size - ? 'ALL ' + size + ' FELLED — TAKE THE CUP' - : felled + ' OF ' + size + ' FELLED · THE CUP IS THE WAY UP'; + // ...and THE RECORD (THE SEASON): how far this league's cup has ever let you get. It is the one + // line that makes a lost attempt count for something, so it takes the second half of the sub + // whenever there is one to show. + const best = profile.cupBest(lg.id); + document.getElementById('lgSub').innerHTML = (felled >= size + ? 'ALL ' + size + ' FELLED' + : felled + ' OF ' + size + ' FELLED') + + (best ? '
BEST IN THE CUP: ' + cupRoundName(lg.id, best) + : '
THE CUP IS THE WAY UP — NEVER ENTERED'); } // The setup screen's rows, brought up to date with picks made elsewhere (the identity screen). @@ -490,9 +504,19 @@ const trainList = document.getElementById('trainList'); function buildTrain() { trainList.innerHTML = ''; const top = Math.min(LADDER, leagueOf(profile.prof.lg).band[1]); - for (let n = 1; n <= top; n++) { + // THE REVANCHE (THE SEASON): the body that ended your last cup attempt is marked and sits FIRST + // 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 f = matchFoe(n), b = document.createElement('button'); - b.innerHTML = `${f.name}No. ${n}`; + const put = n === out; + if (put) b.className = 'put'; + b.innerHTML = `${f.name}` + + `${put ? 'PUT YOU OUT OF THE CUP' : 'No. ' + n}`; b.addEventListener('click', () => { trainRung = n; start('train'); }); trainList.appendChild(b); } @@ -605,6 +629,12 @@ function buildPromo(pr, run) { ? 'THE HONOURS BOARD REMEMBERS YOUR NAME' : `NEW COURT UNLOCKED — ${c.name} · OPEN IN EVERY MODE`; document.getElementById('prGo').textContent = champion ? 'TAKE A BOW' : 'WALK ON'; + // ...and what the new league has you down for. The champion has nothing above him, so his beat + // is the title he now has to hold instead of a fixture he has to win. + const fx = cupFixture(profile.prof.lg, profile.prof.ch); + document.getElementById('prNext').innerHTML = champion + ? `THE TITLE IS YOURS TO HOLDEVERY CUP FROM HERE IS A DEFENCE` + : `NEXT: ${fx.title}${fx.line}`; } document.getElementById('prGo').addEventListener('click', () => { const run = promoRun; promoRun = null; @@ -657,11 +687,21 @@ function refreshSplash() { document.getElementById('plFrom').textContent = 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' - : profile.prof.lg >= LEAGUES.length - ? 'WIN THE ' + lg.name.replace('THE ', '') + ' CUP — BECOME CHAMPION' - : 'WIN THE ' + lg.name.replace('THE ', '') + ' CUP TO ADVANCE'; + : 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. + const fx = cupFixture(profile.prof.lg, profile.prof.ch); + document.getElementById('msTitle').textContent = fx.title; + document.getElementById('msLine').textContent = fx.line; summary(); } @@ -710,7 +750,14 @@ const goMode = () => { if (!profile.prof.setup) { buildPlayer(); go('player'); return; } buildTops(); go('mode'); }; -document.getElementById('mStart').addEventListener('click', goMode); +// the fixture button IS the tournament — it skips the setup screen on purpose: the cup has no +// court to pick (it plays the league's own) and no opponent to choose, so that screen was a +// 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'); +}); +document.getElementById('mOther').addEventListener('click', goMode); document.getElementById('mStats').addEventListener('click', openStats); // the world board opens on the mode you last PICKED, like the stats screen does — that is the one // whose names you came to look at @@ -997,10 +1044,18 @@ addEventListener('pagehide', () => { // NOT a page reload any more: that reset everything honestly but also tore down the AudioContext, so // the theme restarted from silence. restartRun puts every bag back from a pristine copy, and the // bed that has been playing under the death summary simply carries on into the title screen. -const maybeRestart = () => { +const maybeRestart = e => { // ...and the classic ending taps out the same way, on its own shorter clock (G.done, no corpse) const ready = G.done ? G.done.t > DONE_RESTART : G.mode === 'eaten' && G.eatenT > EATEN_RESTART; if (G.modal || !ready) return; + // WHICH DOOR (THE SEASON: "das ende reicht weiter"). The summary lays its own buttons out as it + // draws — it measures its box, so only it knows where they landed — and hands the rects back + // through endHit. A tap anywhere ELSE is the old behaviour exactly: back to the club. + let door = null; + if (e && e.clientX !== undefined) { + const r = cv.getBoundingClientRect(); + door = endHit((e.clientX - r.left) / r.width, (e.clientY - r.top) / r.height); + } restartRun(); profile.push(); // the run's xp is banked — mirror it while the moment is quiet refreshSplash(); // the run just ended: the splash count has moved @@ -1015,8 +1070,12 @@ const maybeRestart = () => { if (promo) { buildPromo(promo, run); go('promo'); return; } // ...on the BOARD's number, not on kills: a classic match has none, so the gore-shaped test here // silently barred two of the three modes from ever reaching the prompt - if (run && run.newKillBest && run.score > 0) askName(run); - else go('splash'); + 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 === 'again') { start(pickedMode); return; } + go('splash'); }; cv.addEventListener('pointerdown', maybeRestart); addEventListener('keydown', maybeRestart); diff --git a/src/profile.js b/src/profile.js index 6f99785..647c0f5 100644 --- a/src/profile.js +++ b/src/profile.js @@ -21,15 +21,29 @@ import { myId, savedName, saveName } from './stats.js'; const KEY = 'splatterProfile'; +// THE CLUB REMEMBERS THE ATTEMPTS (2026-08-31, THE SEASON). Two small fields carry the whole +// record: `cups` is league -> how many rounds you have ever reached in its cup (1 = round one, +// rounds.length = you took the final), and `out` is the rung of the body that put you out last. +// That is what turns a lost cup from a null event into a line on the board — and what lets the +// training court offer the one that beat you. Bounded on load like everything else that comes +// back off a device. +const cleanCups = c => { + const out = {}; + if (c && typeof c === 'object') + for (let l = 1; l <= 5; l++) { const v = c[l] | 0; if (v > 0) out[l] = Math.min(9, v); } + return out; +}; + function load() { try { const p = JSON.parse(localStorage.getItem(KEY) || 'null'); if (p && typeof p === 'object') { return { v: 1, xp: Math.max(0, p.xp | 0), lg: Math.min(5, Math.max(1, p.lg | 0 || 1)), - ch: p.ch ? 1 : 0, setup: p.setup ? 1 : 0 }; + ch: p.ch ? 1 : 0, setup: p.setup ? 1 : 0, + cups: cleanCups(p.cups), out: Math.max(0, Math.min(99, p.out | 0)) }; } } catch {} - return { v: 1, xp: 0, lg: 1, ch: 0, setup: 0 }; + return { v: 1, xp: 0, lg: 1, ch: 0, setup: 0, cups: {}, out: 0 }; } export const prof = load(); @@ -111,6 +125,19 @@ export function bonus() { // quality is centre-normalised, so the same ms error scores higher } +// ---------------------------------------------------------------- the cup record ---- +// How far you have ever got in a league's cup, and who ended the last attempt. Written by the two +// places a cup run can end (zombies.killPlayer, beats.cupWon) and read by the summary, the league +// panel and the training court. A record only ever moves FORWARD — an early exit after a deep run +// does not erase the deep run, it just is not news. +export const cupBest = (lg = prof.lg) => prof.cups[lg] | 0; +export function recordCup(lg, reached, rung = 0) { + if (!(lg >= 1 && lg <= 5) || reached < 1) return; + if (reached > cupBest(lg)) prof.cups[lg] = Math.min(9, reached); + if (rung) prof.out = rung; // the LAST one to put you out, deep run or not + save(); +} + // ---------------------------------------------------------------- the promotion ---- // A cup win that moved the league parks its ceremony here (beats.cupWon writes, main.maybeRestart // consumes once the summary is tapped away). Module-local and consumed-on-read, the same shape as @@ -118,6 +145,9 @@ export function bonus() { let promo = null; export const setPromo = p => { promo = p; }; export function takePromo() { const p = promo; promo = null; return p; } +// ...and a PEEK, for the summary: a won cup parks a ceremony, and the handover has to point at +// that instead of at the next draw. Never consumes — main.maybeRestart is the only consumer. +export const hasPromo = () => !!promo; // ---------------------------------------------------------------- the mirror ---- // POST is fire-and-forget with keepalive so the pagehide path can use the same call. The server @@ -149,6 +179,12 @@ export async function pull() { prof.lg = Math.min(5, Math.max(prof.lg, p.lg | 0 || 1)); prof.ch = prof.ch || (p.ch ? 1 : 0); prof.setup = prof.setup || (p.setup ? 1 : 0); + // the cup record comes back with the xp it was earned beside — merged forward, never down + for (let l = 1; l <= 5; l++) { + const v = p.cups && p.cups[l] | 0; + if (v > cupBest(l)) prof.cups[l] = Math.min(9, v); + } + prof.out = prof.out || Math.max(0, Math.min(99, p.out | 0)); runFrom = levelAt(prof.xp); // the adopted xp is history, not this run's delta save(); } diff --git a/src/render.js b/src/render.js index 50579f6..a712ba3 100644 --- a/src/render.js +++ b/src/render.js @@ -1,13 +1,13 @@ // Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state). import { cv, ctx } from './dom.js'; -import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS, HEAD_CROP, TINT, SHOT_DEPTH, matchFoe } from './config.js'; +import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS, HEAD_CROP, TINT, SHOT_DEPTH, matchFoe, cupFixture, cupRoundName } from './config.js'; import { P, O, ball, score, crowd, G, court, tour, kit, skin } from './state.js'; import { RAISERS, FRAMES } from './crowd.js'; import { predictLanding, previewServe, slotId, slotStock, serveApexY, rallyFeel, swayOf } from './gameplay.js'; import { drillQueue, drillLegal, drillMult } from './drill.js'; import { zombies, gore, piles, parts, pellets, extras, shots, pops } from './zombies.js'; -import { runDelta, progress } from './profile.js'; +import { runDelta, progress, prof, cupBest, hasPromo } from './profile.js'; // ---------------------------------------------------------------- palette ---- // tennis palette -> drained splatter palette; the transition lerps between them. @@ -2341,155 +2341,179 @@ function drawFlickHint() { const BACK_C = 1.70158; const easeBack = p => 1 + (BACK_C + 1) * (p - 1) ** 3 + BACK_C * (p - 1) ** 2; -// THE RULES CARD (2026-08-29, Oliver: the mode modals were walls of text too). Each rule is its -// own boxed row — the statement in cream over its consequence in the modal's colour — and a rule -// about BLOOD shows the pool as the game's own pips instead of a naked number. Driven by -// m.rules: [{ lead, sub, pips }]; title/body keep carrying the words for the suite. -function drawRulesModal(m, t, dx) { - const boxW = Math.min(W - 28, 240), bx = Math.round(W / 2 - boxW / 2); - const rowH = r => (r.sub ? 34 : 22) + (r.pips ? 12 : 0); - const gap = 8; - const blockH = 30 + m.rules.reduce((a, r) => a + rowH(r) + gap, 0) - gap; - let y = Math.round(H / 2 - blockH / 2); - const hx = dx(0, 1); - ctx.font = B(24); ctx.fillStyle = m.color || UI.warm; - if (hx) for (const [f, a] of [[0.7, 0.12], [0.35, 0.26]]) { - ctx.save(); ctx.globalAlpha = a; - ctx.fillText(m.title, W / 2 + Math.round(hx * (1 + f)), y + 8); - ctx.restore(); - } - ctx.fillText(m.title, W / 2 + hx, y + 8); - y += 30; - m.rules.forEach((r, i) => { - const h = rowH(r), rx = dx(i + 1, -1); - panel(bx + rx, y, boxW, h, 3, 'rgba(8,26,18,0.85)', 'rgba(120,150,130,0.4)'); - ctx.font = F(8, 700); - shadowText(r.lead, W / 2 + rx, y + 12, UI.cream); - // the consequence line: DIM for information, the modal's hot colour only when it threatens - if (r.sub) { ctx.font = F(8, 0); shadowText(r.sub, W / 2 + rx, y + 25, r.hot ? (m.color || UI.warm) : UI.dim); } - if (r.pips) { // the pool, in the HUD's own pip language - const pg = 9, pw = (r.pips - 1) * pg; - for (let k = 0; k < r.pips; k++) { - ctx.beginPath(); ctx.arc(W / 2 + rx - pw / 2 + k * pg, y + h - 9, 2.6, 0, 7); - ctx.fillStyle = '#8a1a12'; ctx.fill(); - ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1; ctx.stroke(); - } +// THE MODAL IS ONE BOX (2026-08-31, Oliver: "manchmal 2 boxen, manchmal eine, manchmal keine — +// vielleicht immer in einer box, und der tap / press any key wird zu einem echten button im +// footer der box"). Three drawers had grown here — the plain announcement, the rules card, the +// walk-on — each with its own geometry, its own alignment and its own idea of what a box was. +// They are ONE renderer now, with slots: +// +// THE MASTHEAD art (an icon or the body's mugshot) beside the title, with an optional strap +// THE ROWS a statement over its consequence · a split figure (his level against yours) +// · a blood pool drawn as the HUD's own pips — hairlines between them +// THE FOOTER a real button. The prompt used to be a blinking line of text that said what +// gesture to make; it says what HAPPENS now, and looks pressable. +// +// The PAYLOAD is unchanged — title/body/icon/rules/foe are the same fields beats.js has always +// sent and the suite still reads. This is a layout, not new state. Any tap still dismisses (the +// button is the affordance, not a hit target — a modal you can only leave through one 26px strip +// would be a worse wall than the one we had). +const MODAL_PAD = 12, MODAL_GAP = 9, MODAL_BTN = 26; +const MODAL_FILL = 'rgba(8,22,16,0.93)', MODAL_EDGE = 'rgba(120,150,130,0.45)'; +const MODAL_RULE = 'rgba(120,150,130,0.22)'; + +// One row of the stack, measured. `inner` is the text width inside the box. +function modalRows(m, inner) { + const rows = []; + const push = r => { rows.push(r); return r; }; + if (m.foe) { + push({ kind: 'split', h: 32 }); + 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 }); } - y += h + gap; - }); - if (t >= 1 && Math.floor(G.idleT * 2) % 2) { - ctx.font = F(8); - shadowText(IS_TOUCH ? 'TAP TO CONTINUE' : 'PRESS ANY KEY', W / 2, - Math.round(H / 2 + blockH / 2 + 24), UI.warm); + } else { + const lines = []; + for (const l of m.body || []) { + if (l === '') { lines.push(''); continue; } // the weapon test's deliberate blank + lines.push(...wrapText(l, F(8, 0), inner)); + } + if (lines.length) push({ kind: 'text', lines, h: lines.length * 12 + 2 }); } - ctx.textBaseline = 'top'; + return rows; } -// THE WALK-ON'S OWN FACE (2026-08-29, Oliver: the opponent card was "a wall of text" — six kinds -// of information in one line stack). Designated zones instead: the MASTHEAD (mugshot beside the -// name with the slogan under it), THE MEASURE (its level boxed against yours — the number wears -// the modal's hot red while the body outranks you, the pick green once it does not), and the -// round/lesson strip. Same machinery as every modal otherwise: the entrance lanes, the frozen -// court, the blinking tap (drawModal below). m.title/m.body still carry the same words for the -// suite and any legacy reader — this is a LAYOUT, not new state. -function drawFoeModal(m, t, dx) { - const f = m.foe, img = f.head && SPR[f.head]; - const boxW = Math.min(W - 24, 250), bx = Math.round(W / 2 - boxW / 2); - const mugH = Math.round(Math.min(56, H * 0.22)); - const mugW = img ? Math.round(mugH * img.width / img.height) : 0; - const slogan = wrapText(f.line, F(8, 0), boxW - (mugW ? mugW + 12 : 0)); // the shared wrapper sets the font - const headH = Math.max(mugH, 26 + slogan.length * 12); - const measureH = 36, gap = 14; - const subH = f.sub.length ? f.sub.length * 12 + 4 : 0; - const blockH = headH + gap + measureH + (subH ? gap - 4 + subH : 0); - let y = Math.round(H / 2 - blockH / 2); - // THE MASTHEAD flies from the right as one piece - const hx = dx(0, 1); - const nx = bx + (mugW ? mugW + 12 : 0) + hx; - if (img) { ctx.imageSmoothingEnabled = false; ctx.drawImage(img, bx + hx, y, mugW, mugH); } - ctx.textAlign = 'left'; - ctx.font = B(20); - ctx.fillStyle = m.color || UI.hot; - ctx.fillText(f.name, nx, y + 10); - ctx.font = F(8, 0); - slogan.forEach((l, i) => shadowText(l, nx, y + 28 + i * 12, UI.cream)); - y += headH + gap; - // THE MEASURE flies from the left: two boxed figures sharing one frame (the .figs language) - const mx = dx(1, -1); - const half = Math.round(boxW / 2); - panel(bx + mx, y, boxW, measureH, 3, 'rgba(8,26,18,0.85)', 'rgba(120,150,130,0.4)'); - ctx.strokeStyle = 'rgba(120,150,130,0.4)'; ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(bx + mx + half + 0.5, y + 4); ctx.lineTo(bx + mx + half + 0.5, y + measureH - 4); - ctx.stroke(); - ctx.textAlign = 'center'; - ctx.font = F(7, 0); - shadowText('ITS LEVEL', bx + mx + half / 2, y + 10, UI.dim); - shadowText('YOURS', bx + mx + half * 1.5, y + 10, UI.dim); - ctx.font = F(13, 700); - shadowText(String(f.lvl), bx + mx + half / 2, y + 25, f.lvl > f.mine ? (m.color || UI.hot) : UI.pick); - shadowText(String(f.mine), bx + mx + half * 1.5, y + 25, UI.cream); - y += measureH; - // the round / the lesson, from the left a beat later - if (f.sub.length) { - y += gap - 4; - ctx.font = F(8, 0); - f.sub.forEach((l, i) => shadowText(l, W / 2 + dx(2, -1), y + 6 + i * 12, UI.dim)); - } - if (t >= 1 && Math.floor(G.idleT * 2) % 2) { - ctx.font = F(8); - shadowText(IS_TOUCH ? 'TAP TO CONTINUE' : 'PRESS ANY KEY', W / 2, - Math.round(H / 2 + blockH / 2 + 24), UI.warm); - } - ctx.textBaseline = 'top'; +// Everything measured before anything is drawn: the box has to know its own height, and the title +// has to know whether it fits beside the art before either is placed. +function modalLayout(m) { + const boxW = Math.min(W - 20, 268), pad = MODAL_PAD; + const art = m.foe ? (m.foe.head && SPR[m.foe.head]) : (m.icon && SPR[m.icon]); + const artH = art ? Math.round(Math.min(m.iconH || 46, H * 0.2)) : 0; + const artW = art ? Math.round(artH * art.width / art.height) : 0; + const headW = boxW - pad * 2 - (art ? artW + 10 : 0); + // the title SHRINKS to its lane rather than running off the box — boss names and THE TRAINING + // COURT are both titles, and one of them is twice as long as the other + ctx.font = B(22); + const tw = ctx.measureText(m.title).width; + const tSize = tw > headW ? Math.max(13, Math.floor(22 * headW / tw)) : 22; + const strap = m.foe ? wrapText(m.foe.line, F(8, 0), headW) : []; + const headH = Math.max(artH, 22 + strap.length * 12); + const rows = modalRows(m, boxW - pad * 2); + let h = pad + headH + MODAL_GAP + MODAL_BTN + pad; + for (const r of rows) h += r.h + MODAL_GAP; + return { boxW, pad, art, artW, artH, tSize, strap, headH, rows, h, + x: Math.round(W / 2 - boxW / 2), y: Math.round(H / 2 - h / 2) }; } function drawModal(m) { - // The entrance (m.in, 0..1, ticked in gameplay's update behind the world freeze): the icon and - // title fly in from the RIGHT, the body lines cascade in from the LEFT one after another, each - // on its own staggered easeOutBack. Every x is ROUNDED — a fractional x on a nearest-neighbour - // scaled image re-snaps its pixel columns every frame, which is the same boiling that once read - // as HUD flicker. Legacy/test modals with no `in` field draw fully landed. + // The entrance (m.in, 0..1, ticked in gameplay's update behind the world freeze). The box flies + // in from the RIGHT as ONE object — the old per-line stagger would tear a box apart — and the + // dim washes in first. Every x is ROUNDED: a fractional x on nearest-neighbour art re-snaps its + // pixel columns every frame, which is the boiling that once read as HUD flicker. const t = m.in === undefined ? 1 : m.in; - const STAG = 0.08, RUN = 0.62; // per-element delay, and travel time - const lane = k => { // 0 = off screen, 1 = home (may overshoot) - const p = Math.max(0, Math.min(1, (t - k * STAG) / RUN)); - return p >= 1 ? 1 : easeBack(p); - }; - const dx = (k, from) => Math.round(from * (1 - lane(k)) * W * 0.85); // from: -1 left, 1 right - ctx.fillStyle = `rgba(8,6,10,${(0.82 * Math.min(1, t / 0.35)).toFixed(3)})`; // the dim washes in first + const p = Math.max(0, Math.min(1, t / 0.62)); + const dx = Math.round((1 - (p >= 1 ? 1 : easeBack(p))) * W * 0.9); + ctx.fillStyle = `rgba(8,6,10,${(0.82 * Math.min(1, t / 0.35)).toFixed(3)})`; ctx.fillRect(0, 0, W, H); - ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - if (m.foe) return drawFoeModal(m, t, dx); - if (m.rules) return drawRulesModal(m, t, dx); - const body = m.body || [], img = m.icon && SPR[m.icon]; - // cap the illustration against the viewport, not just the asset: at 270px tall (landscape) a - // portrait-sized icon pushed the TAP prompt down onto the weapon rack - const iconH = img ? Math.min(m.iconH || 40, H * 0.28) : 0; - const blockH = (iconH ? iconH + 12 : 0) + 26 + body.length * 13; - let y = Math.round(H / 2 - blockH / 2); - const headX = dx(0, 1); // icon and title travel together, as one masthead - if (img) { - const iw = Math.min(W - 24, iconH * img.width / img.height); - ctx.drawImage(img, Math.round(W / 2 - iw / 2) + headX, y, Math.round(iw), iconH); - y += iconH + 12; + const L = modalLayout(m); + const col = m.color || UI.warm; + const x = L.x + dx, pad = L.pad, inX = x + pad; + let y = L.y + pad; + ctx.textBaseline = 'middle'; + panel(x, L.y, L.boxW, L.h, 4, MODAL_FILL, MODAL_EDGE); + // THE MASTHEAD + if (L.art) { + ctx.imageSmoothingEnabled = false; + ctx.drawImage(L.art, inX, y + Math.round((L.headH - L.artH) / 2), L.artW, L.artH); } - ctx.font = B(24); ctx.fillStyle = m.color || UI.warm; // an announcement, so the announcement face - if (headX) for (const [f, a] of [[0.7, 0.12], [0.35, 0.26]]) { // speed streak: ghosts strung out behind it - ctx.save(); ctx.globalAlpha = a; - ctx.fillText(m.title, W / 2 + Math.round(headX * (1 + f)), y + 8); - ctx.restore(); - } - ctx.fillText(m.title, W / 2 + headX, y + 8); y += 26; + const tx = inX + (L.art ? L.artW + 10 : 0); + ctx.textAlign = 'left'; + ctx.font = B(L.tSize); + ctx.fillStyle = col; + ctx.fillText(m.title, tx, y + 11); ctx.font = F(8, 0); - body.forEach((l, i) => { shadowText(l, W / 2 + dx(i + 1, -1), y + 6, UI.cream); y += 13; }); - // the prompt only exists once everything has landed — offering the tap mid-flight would be a lie, - // dismissModal swallows it until then - if (t >= 1 && Math.floor(G.idleT * 2) % 2) { // the prompt blinks so it never reads as part of the copy - ctx.font = F(8); - shadowText(IS_TOUCH ? 'TAP TO CONTINUE' : 'PRESS ANY KEY', W / 2, Math.round(H / 2 + blockH / 2 + 22), UI.warm); + L.strap.forEach((l, i) => shadowText(l, tx, y + 26 + i * 12, UI.cream)); + y += L.headH + MODAL_GAP; + // THE ROWS + for (const r of L.rows) { + ctx.strokeStyle = MODAL_RULE; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(x + 6, y - Math.round(MODAL_GAP / 2) + 0.5); + ctx.lineTo(x + L.boxW - 6, y - Math.round(MODAL_GAP / 2) + 0.5); ctx.stroke(); + if (r.kind === 'text') { + ctx.font = F(8, 0); ctx.textAlign = 'left'; + r.lines.forEach((l, i) => l && shadowText(l, inX, y + 7 + i * 12, r.dim ? UI.dim : UI.cream)); + } 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; + for (let k = 0; k < r.pips; k++) { + ctx.beginPath(); ctx.arc(inX + 3 + k * 9, py, 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); + } + y += r.h + MODAL_GAP; } - ctx.textBaseline = 'top'; + // THE FOOTER BUTTON — only once the box has landed. Offering the press mid-flight would be a + // lie: dismissModal swallows the gesture until then. + if (t >= 1) { + const label = m.btn || (m.foe ? 'WALK ON' : m.rules ? 'GOT IT' : 'PLAY ON'); + const bw = Math.min(L.boxW - pad * 2, 168), bx = Math.round(x + L.boxW / 2 - bw / 2); + ctx.strokeStyle = MODAL_RULE; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(x + 6, y - Math.round(MODAL_GAP / 2) + 0.5); + ctx.lineTo(x + L.boxW - 6, y - Math.round(MODAL_GAP / 2) + 0.5); ctx.stroke(); + drawButton(bx, y, bw, MODAL_BTN, label, col, true); + } + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; +} + +// THE HANDOVER'S BUTTONS. Laid out by the summary as it draws (the box measures itself, so the +// rects are only known then) and hit-tested by main.maybeRestart. A tap ANYWHERE else still ends +// the run the way it always did — the buttons add a door, they never take the old one away. +const endBtns = []; +export function endHit(nx, ny) { // normalized [0,1] canvas coords -> button id or null + const cx = nx * W, cy = ny * H; + for (const b of endBtns) + if (cx >= b.x - 4 && cx <= b.x + b.w + 4 && cy >= b.y - 6 && cy <= b.y + b.h + 6) return b.id; + return null; +} + +// The one button face the overlays share — filled when it is the thing to do, outlined when it is +// the way out. Used by the modal footer and by the run-end summary, so the two screens cannot +// drift into two button languages. +function drawButton(x, y, w, h, label, col, primary) { + panel(x, y, w, h, 3, primary ? col : 'rgba(8,22,16,0.9)', primary ? null : col); + ctx.font = F(9, 700); + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillStyle = primary ? '#0b120e' : col; + ctx.fillText(label, x + w / 2, y + h / 2 + 1); } // ------------------------------------------------------------------ frame ---- @@ -2981,69 +3005,145 @@ export function render() { drawDrillHud(); // the triage readout — no-op unless a drill is running drawBenchHud(); // ...and the sprite bench's, same rule drawApproachRing(); drawApexBand(); // the match's timing, drawn — no-ops unless a match is running - // THE END OF A RUN, whichever way it ended. Three modes end three different ways and they share - // one screen — a wash, a headline, the run's own two numbers, the mark to beat and the gated tap - // — because only the WORDS differ, and three copies of this drift apart the first time one moves. - // eaten + gore GAME, SET, MASSACRE. n games, n sets - // eaten + match BLED OUT. n opponents felled, n games - // done + classic MATCH. / BEATEN. the set score - // phase C for the death: the summary fades UP over the animation instead of cutting on top of it. + // THE END OF A RUN, whichever way it ended — and the one screen in the game that has to HAND + // SOMETHING OVER (2026-08-31, THE SEASON: "das ende reicht weiter"). It wears the modal's box + // language for the same reason the modals got one: a wash with five free-floating lines on it + // was a wall of text at the exact moment the player is deciding whether to open the game again. + // THE HEADLINE how it ended, in the mode's own words + // eaten + gore GAME, SET, MASSACRE. n games, n sets + // eaten + match BLED OUT. n felled, n games + // eaten + cup OUT IN THE SEMI-FINAL. the board notes it, and your best + // done + classic GAME, SET, MATCH. / BEATEN. + // THE RUN the xp it paid and the level it moved — meta absorbing permadeath + // THE MARK your best on this board, or the record it just became + // THE FIXTURE what the club has you down for next, and the button that goes there { const m = G.match && G.match.felled !== undefined ? G.match : null; const dead = G.mode === 'eaten' && G.eatenT > EATEN_SUMMARY, done = G.done && G.done.t > 0.5; + endBtns.length = 0; if (dead || done) { const clk = dead ? G.eatenT - EATEN_SUMMARY : G.done.t - 0.5; const fade = Math.min(1, clk / 0.6); const won = !dead && G.done.won; + const cup = m && m.cup ? m.cup : null; ctx.save(); ctx.globalAlpha = fade; ctx.fillStyle = won ? 'rgba(6,10,8,0.72)' : 'rgba(10,4,6,0.72)'; ctx.fillRect(0, 0, W, H); - // The announcement face, at 26 rather than the card's 30: this is the longest string in the - // game and at 30 it ran edge to edge on a 270px-wide portrait canvas. - // the big line is reserved for the TOP league's cup (THE LEAGUES): a lower cup is a step - // up the club, not the whole club — its ceremony (the promotion screen) follows anyway - const title = dead ? (m ? 'BLED OUT.' : 'GAME, SET, MASSACRE.') + // THE WORDS. The cup's own exit is a ceremony, not a death: it says which round took you. + // "OUT IN SEMI-FINAL" reads like a telegram: the round names carry their own article when + // they have one (THE FINAL, ROUND ONE) and want one when they do not. + const theRound = n => /^(THE |ROUND )/.test(n) ? n : 'THE ' + n; + const title = dead + ? (cup ? 'OUT IN ' + theRound(cupRoundName(G.league, cup.round + 1)) + '.' + : m ? 'BLED OUT.' : 'GAME, SET, MASSACRE.') : G.done.cup ? (G.league >= 5 ? 'CHAMPION OF THE DEAD CLUB.' : 'THE CUP IS YOURS.') : won ? 'GAME, SET, MATCH.' : 'BEATEN.'; const line = dead - ? (m ? 'FELLED ' + m.felled + ' OPPONENT' + (m.felled === 1 ? '' : 'S') + ' · ' + m.games + ' GAMES' - : 'SURVIVED ' + G.runGames + ' GAMES · ' + G.sets[0] + ' SETS') + ? (cup ? (cup.round + 1 >= cupBest(G.league) ? 'THE BOARD NOTES IT' + : 'YOUR BEST STANDS: ' + theRound(cupRoundName(G.league, cupBest(G.league)))) + : m ? 'FELLED ' + m.felled + ' OPPONENT' + (m.felled === 1 ? '' : 'S') + ' · ' + m.games + ' GAMES' + : 'SURVIVED ' + G.runGames + ' GAMES · ' + G.sets[0] + ' SETS') : G.done.cup ? (G.league >= 5 ? 'THE CHAMPION FELL · ' : 'THE FINAL FELL · ') + ((G.match && G.match.games) || G.runGames) + ' GAMES' : G.sets[0] + '–' + G.sets[1] + ' IN SETS · ' + G.runGames + ' GAMES'; - ctx.font = B(26); shadowText(title, W / 2, H / 2 - 44, won ? UI.pick : UI.bad); - ctx.font = F(9); shadowText(line, W / 2, H / 2 - 14, UI.cream); - if (G.newBest) { // a fresh record crows; otherwise show the mark to beat - const pulse = 0.55 + 0.45 * Math.abs(Math.sin(clk * 4)); - ctx.font = B(20); - shadowText('★ NEW BEST ★', W / 2, H / 2 + 2, hexA(UI.warm, pulse)); - } else if (G.best && G.best.games > 0) { - ctx.font = F(8); - shadowText(m ? 'BEST ' + G.best.games + ' FELLED' - : G.classic ? 'LONGEST MATCH ' + G.best.games + ' GAMES' - : 'BEST ' + G.best.games + ' GAMES · ' + G.best.sets + ' SETS', W / 2, H / 2 + 4, UI.dim); - } - // THE RUN'S XP — the moment meta-progress absorbs the permadeath (the leagues, phase 1). - // Muted modes never award, so a training rep or a drill shows nothing here by construction. - const xd = runDelta(); + // THE FIXTURE. A won cup parks a ceremony (the promotion) — that IS the handover, so the + // summary points at it instead of at the next draw. + const promo = hasPromo(); + const fx = cupFixture(prof.lg, prof.ch); + const xd = runDelta(), pr = progress(); + const ready = dead ? G.eatenT > EATEN_RESTART : G.done.t > DONE_RESTART; + + const boxW = Math.min(W - 20, 288), pad = MODAL_PAD, inner = boxW - pad * 2; + const headLines = wrapText(title, B(22), inner); + const lineLines = wrapText(line, F(9), inner); + // the cup keeps ITS OWN mark (the round reached, in the headline pair above) — the board's + // felled count beside it was two bests answering different questions in the same box + const mark = G.newBest || cup ? null + : G.best && G.best.games > 0 + ? (m ? 'BEST ' + G.best.games + ' FELLED' + : G.classic ? 'LONGEST MATCH ' + G.best.games + ' GAMES' + : 'BEST ' + G.best.games + ' GAMES · ' + G.best.sets + ' SETS') + : null; + const fxLines = ready ? wrapText(promo ? 'THE CLUB WANTS A WORD' + : G.net ? 'THE CLUB IS WAITING' : fx.line, F(8, 0), inner) : []; + let h = pad + headLines.length * 24 + lineLines.length * 13 + 4; + if (xd.xp > 0) h += MODAL_GAP + 26; + if (G.newBest || mark) h += MODAL_GAP + (G.newBest ? 20 : 13); + if (ready) h += MODAL_GAP + 14 + fxLines.length * 12 + 6 + MODAL_BTN; + h += pad; + const x = Math.round(W / 2 - boxW / 2), y0 = Math.round(H / 2 - h / 2), inX = x + pad; + panel(x, y0, boxW, h, 4, MODAL_FILL, MODAL_EDGE); + let y = y0 + pad; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.font = B(22); + headLines.forEach((l, i) => shadowText(l, inX, y + 12 + i * 24, won ? UI.pick : UI.bad)); + y += headLines.length * 24; + ctx.font = F(9); + lineLines.forEach((l, i) => shadowText(l, inX, y + 7 + i * 13, UI.cream)); + y += lineLines.length * 13 + 4; + const rule = () => { + ctx.strokeStyle = MODAL_RULE; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(x + 6, y + Math.round(MODAL_GAP / 2) + 0.5); + ctx.lineTo(x + boxW - 6, y + Math.round(MODAL_GAP / 2) + 0.5); ctx.stroke(); + y += MODAL_GAP; + }; + // THE RUN'S XP — meta-progress absorbing permadeath. Muted modes never award, so a drill or + // a training rep shows nothing here by construction. if (xd.xp > 0) { - const pr = progress(); + rule(); 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 + 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); + inX, y + 7, xd.to > xd.from ? UI.pick : UI.cream); + const bw = inner, by = y + 18; + ctx.fillStyle = 'rgba(255,255,255,0.14)'; ctx.fillRect(inX, 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); + const gr = ctx.createLinearGradient(inX, 0, inX + bw, 0); gr.addColorStop(0, '#3f9560'); gr.addColorStop(0.55, UI.pick); gr.addColorStop(1, '#e8b23a'); - ctx.fillStyle = gr; ctx.fillRect(bx, by, Math.round(bw * pr.frac), 4); + ctx.fillStyle = gr; ctx.fillRect(inX, by, Math.round(bw * pr.frac), 4); + y += 26; } - // 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) { + if (G.newBest) { // a fresh record crows; otherwise show the mark to beat + rule(); + const pulse = 0.55 + 0.45 * Math.abs(Math.sin(clk * 4)); + ctx.font = B(18); + shadowText('★ NEW BEST ★', inX, y + 10, hexA(UI.warm, pulse)); + y += 20; + } else if (mark) { + rule(); ctx.font = F(8); - shadowText(IS_TOUCH ? 'TAP TO PLAY AGAIN' : 'PRESS ANY KEY TO PLAY AGAIN', W / 2, H / 2 + 50, UI.warm); + shadowText(mark, inX, y + 6, UI.dim); + y += 13; } + // THE HANDOVER, gated well past the animation so a panic tap can't skip the summary + // (main.js gates the restart on the same clock). + if (ready) { + rule(); + ctx.font = F(7, 0); + shadowText('NEXT', inX, y + 5, UI.dim); + ctx.font = F(9, 700); + shadowText(promo ? 'THE PROMOTION' : G.net ? 'BACK TO THE CLUBHOUSE' : fx.title, + inX + 34, y + 5, UI.warm); + y += 14; + ctx.font = F(8, 0); + fxLines.forEach((l, i) => shadowText(l, inX, y + 6 + i * 12, UI.dim)); + y += fxLines.length * 12 + 6; + // one button when the ceremony owns the tap, two when there is a real choice: the fixture + // (the canonical path) and the way back to what you were just doing + // NETPLAY ends on one door only: the other phone's socket is still being torn down, and + // "enter the draw" would deal a solo cup into a live session. + const solo = !promo && !G.net; + const second = solo ? (cup || (G.done && G.done.cup) ? ['club', 'THE CLUBHOUSE'] + : ['again', 'PLAY AGAIN']) : null; + const bw = second ? Math.round((inner - 8) / 2) : inner; + const label = promo ? 'TAKE THE FLOOR' : solo ? 'ENTER THE DRAW' : 'THE CLUBHOUSE'; + drawButton(inX, y, bw, MODAL_BTN, label, UI.pick, true); + endBtns.push({ id: solo ? 'next' : 'club', x: inX, y, w: bw, h: MODAL_BTN }); + if (second) { + drawButton(inX + bw + 8, y, bw, MODAL_BTN, second[1], UI.warm, false); + endBtns.push({ id: second[0], x: inX + bw + 8, y, w: bw, h: MODAL_BTN }); + } + } + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.restore(); } } diff --git a/src/zombies.js b/src/zombies.js index a267199..a065ea9 100644 --- a/src/zombies.js +++ b/src/zombies.js @@ -11,7 +11,7 @@ import { drillKill, drillCaught } from './drill.js'; import { splatPoint, cheer, spentBall, aside, grantWeapon, endBurst, sliceCarom } from './gameplay.js'; import { resolveInterlude, endBossFight } from './beats.js'; // runtime-only cycle, like the gameplay one import { tally, multiKill, endRun, MODE_MATCH } from './stats.js'; -import { award, bonus, prof } from './profile.js'; +import { award, bonus, prof, recordCup } from './profile.js'; export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'} export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer @@ -325,6 +325,10 @@ export function killPlayer() { : endRun(G.runGames, G.sets[0], G.splatT); G.best = b; G.newBest = b.newBest; // b is {games, sets, newBest}; render reads games/sets if (m) { m.felled = felled; m.games = gamesPlayed; m.best = b.games; } // ...the death screen's words + // THE CUP RECORD (THE SEASON): an attempt that ends here is still an attempt. The round you + // were IN is the round you reached, and the rung holding the racket is the one the training + // court will offer you next. Training reps are not attempts and never write it. + if (m && m.cup && !m.train) recordCup(G.league, m.cup.round + 1, m.cup.rung || m.opp); } // One hostile ball, lobbed at where the player is standing. `spread` fans a volley sideways. diff --git a/test.js b/test.js index ecd5ea3..a84ce98 100644 --- a/test.js +++ b/test.js @@ -115,6 +115,9 @@ function freshSplatter() { // XP is zeroed too: the level feeds real dials now (legs, window), and accumulated awards from // earlier tests would silently buff every later one. prof.prof.lg = 1; prof.prof.ch = 0; prof.prof.xp = 0; prof.takePromo(); + // THE SEASON: the cup record is written by a real cup ending, so it leaks between tests exactly + // the way the promotion did — the training court reads `out`, and a stale one reorders its list + prof.prof.cups = {}; prof.prof.out = 0; G.mode = 'splatter'; G.everTransitioned = true; setupPoint(); @@ -1235,6 +1238,57 @@ test('THE TOURNAMENT: bands draw, bodies deepen, rounds hold their body, the cha assert.ok(pr && pr.to === 2, 'the ceremony is parked for the menu to play'); }); +// ---- THE SEASON: the tournament is the story, so the club has to remember the attempts -------- + +test('THE SEASON: the fixture names the league, its rounds and the body that opens it', () => { + for (const l of cfg.LEAGUES) { + const fx = cfg.cupFixture(l.id), R = cfg.cupRoundsFor(l.id); + assert.equal(fx.rung, R[0].band[0], l.name + ': the fixture opens on round one\'s rung'); + assert.equal(fx.foe.name, cfg.matchFoe(R[0].band[0]).name, l.name + ': and names that body'); + assert.ok(fx.title.includes(String(l.id)) || fx.title === 'DEFEND THE TITLE', + l.name + ': the title carries the league'); + assert.ok(fx.line.includes(R[0].name) && fx.line.startsWith(String(R.length)), + l.name + ': the line counts the rounds and names the first'); + } + // the champion has nothing above him: his fixture is a defence, not a promotion + assert.equal(cfg.cupFixture(cfg.LEAGUES.length, 1).title, 'DEFEND THE TITLE'); + // ...and a record reads in the rounds' own words + const R3 = cfg.cupRoundsFor(3); + assert.equal(cfg.cupRoundName(3, 1), R3[0].name, 'one round reached is round one'); + assert.equal(cfg.cupRoundName(3, R3.length), 'THE FINAL', 'the last round is the final'); +}); + +test('THE SEASON: a cup exit is recorded, forward only, and names who put you out', () => { + prof.prof.lg = 3; + _startMatch(true); // league 3's cup, round one + G.modal = null; G.modalQ.length = 0; + G.match.cup.round = 2; // deep: the semi-final + const rung = G.match.cup.rung; + killPlayer(); + assert.equal(prof.cupBest(3), 3, 'the round you were in is the round you reached'); + assert.equal(prof.prof.out, rung, 'the body holding the racket is the one that put you out'); + assert.equal(prof.cupBest(1), 0, 'and it is that league\'s record, not the club\'s'); + // a shallower attempt afterwards leaves the record standing — and still updates the eliminator + restartRun(); + G.mode = 'splatter'; G.everTransitioned = true; setupPoint(); + prof.prof.lg = 3; + _startMatch(true); + G.modal = null; G.modalQ.length = 0; + G.match.cup.round = 0; + const rung2 = G.match.cup.rung; + killPlayer(); + assert.equal(prof.cupBest(3), 3, 'a worse run never erases a better one'); + assert.equal(prof.prof.out, rung2, 'but the LAST body to end an attempt is always the offer'); + // the training court is not an attempt and writes nothing + const out = prof.prof.out; + restartRun(); + G.mode = 'splatter'; G.everTransitioned = true; setupPoint(); + _startMatch(false, null, 2); + G.modal = null; G.modalQ.length = 0; + killPlayer(); + assert.equal(prof.prof.out, out, 'a training rep is a rep, not a cup attempt'); +}); + test('THE CUP CLIMBS: rounds run easy to hard, deterministic, the final the top of the band', () => { // the shuffle retired with the draw screen (Oliver: "warum sollten wir noch mischen?") — a bad // roll paired the strongest challenger with the shortest round, inverting the tuned curve