THE SEASON, phases 1-4: the front door is the fixture, and the club remembers

The tournament is the canonical path, so the game finally says so. The splash's
primary button names the next cup fixture and enters the draw; the poster wall
moved one tap down and deals three named rooms (the season, the training, out of
competition) with the cup at the top. Two profile fields remember every attempt
— rounds ever reached per league, and the body that put you out — so a lost cup
is a line on the board instead of a null event, and the training court offers
that body first, marked. The promotion names what waits in the new league.

Overlays consolidated in the same pass: three modal drawers became one box with
a masthead, hairline-separated rows and a real footer button (any tap still
dismisses), and the run-end summary wears the same box — headline, xp, mark, and
the fixture with ENTER THE DRAW beside PLAY AGAIN. The cup's own exit is a
ceremony now: OUT IN THE QUARTER-FINAL, your best standing beside it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-31 11:14:25 +02:00
1 parent 063b01d2a5
commit 5c7521f867
10 files changed
+603 -204

No files matched your search

+67 -1
View File
@@ -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 `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. 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- 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 und Endscreens enforcen keine Story-Line". The cause is not a missing story — THE LEAGUES already
+36 -2
View File
@@ -237,6 +237,12 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
hierarchy is carried by colour rather than by art. */ hierarchy is carried by colour rather than by art. */
.btn.big { padding: 13px 10px 11px; background: var(--red); border-color: #69201c; color: #f6e7dc; .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; } 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 { display: flex; gap: 8px; }
.two .btn { margin-bottom: 0; } .two .btn { margin-bottom: 0; }
.btn.sec i { display: block; margin-top: 4px; font: 400 9px Silkscreen, monospace; .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; } text-align: center; line-height: 1.35; }
#bossList i, #trainList i { font: 400 8px Silkscreen, monospace; color: var(--dim); font-style: normal; #bossList i, #trainList i { font: 400 8px Silkscreen, monospace; color: var(--dim); font-style: normal;
letter-spacing: 1px; } 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 /* 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 */ 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; } #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 */ /* START is genuinely unreachable until the draw has landed — the wait IS the ceremony */
.btn[disabled] { opacity: 0.32; cursor: default; } .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 /* 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. */ 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 /* 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 */ at cream-on-crowd was the report from the first live look */
#modeList i, #modeSub i { color: #fff; font-size: 10px; margin-bottom: 0; #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; } 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 /* 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 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. */ 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 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. --> the zombies missing. Swapped by #splash.busy, see main.js. -->
<div id="boot"><s><i></i></s><u>LOADING</u></div> <div id="boot"><s><i></i></s><u>LOADING</u></div>
<button class="btn big" id="mStart">PLAY MATCH</button> <!-- THE FRONT DOOR IS THE FIXTURE (2026-08-31, THE SEASON). The primary button used to say
PLAY MATCH and open a wall of posters, which asked the player a question the game
already knows the answer to. It names the next cup fixture now and enters the draw;
everything else lives one tap down, behind OTHER GAMES. -->
<button class="btn big" id="mStart"><b id="msTitle">THE CUP</b><i id="msLine"></i></button>
<button class="btn sec" id="mOther">OTHER GAMES<i>CLIMB · TRAINING · GORE · A FRIEND</i></button>
<div class="two"> <div class="two">
<button class="btn sec" id="mStats">STATS<i id="mKills">0 KILLS</i></button> <button class="btn sec" id="mStats">STATS<i id="mKills">0 KILLS</i></button>
<button class="btn sec" id="mRanks">RANKINGS<i>WORLD BOARD</i></button> <button class="btn sec" id="mRanks">RANKINGS<i>WORLD BOARD</i></button>
@@ -730,6 +760,10 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
<p id="prLeague"></p> <p id="prLeague"></p>
<img id="prCourt" alt=""> <img id="prCourt" alt="">
<p id="prCourtName"></p> <p id="prCourtName"></p>
<!-- THE SEASON'S SECOND BEAT (2026-08-31): the promotion is also the season opener, so it
names the fixture waiting in the new league rather than ending on a court tile. No
extra screen — a tap between the player and the court is what we were avoiding. -->
<p id="prNext"></p>
</div> </div>
<div class="foot bare"> <div class="foot bare">
<button class="btn big" id="prGo">WALK ON</button> <button class="btn big" id="prGo">WALK ON</button>
@@ -871,6 +905,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div> </div>
</div> </div>
</div> </div>
<script type="module" src="src/main.js?v=340"></script> <script type="module" src="src/main.js?v=341"></script>
</body> </body>
</html> </html>
+14
View File
@@ -74,12 +74,26 @@ def clean_profile(payload):
return None return None
p = payload['p'] p = payload['p']
word = lambda k, n=12: re.sub(r'[^a-z0-9_]', '', str(p.get(k, '')))[:n] 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: try:
out = {'v': 1, out = {'v': 1,
'xp': max(0, min(int(p.get('xp', 0)), 1_000_000_000)), 'xp': max(0, min(int(p.get('xp', 0)), 1_000_000_000)),
'lg': max(1, min(int(p.get('lg', 1)), 5)), 'lg': max(1, min(int(p.get('lg', 1)), 5)),
'ch': 1 if p.get('ch') else 0, 'ch': 1 if p.get('ch') else 0,
'setup': 1 if p.get('setup') 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], 'n': re.sub(r'[^A-Z0-9 ._-]', '', str(p.get('n', '')).upper()).strip()[:NAME_MAX],
'tour': word('tour'), 'kit': word('kit'), 'skin': word('skin')} 'tour': word('tour'), 'kit': word('kit'), 'skin': word('skin')}
except (TypeError, ValueError): except (TypeError, ValueError):
+2 -1
View File
@@ -9,7 +9,7 @@
import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME, racketStage, leagueOf, cupRoundsFor, foeLevel } from './config.js'; import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME, racketStage, leagueOf, cupRoundsFor, foeLevel } from './config.js';
import { endRun, MODE_MATCH } from './stats.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 { G, P, O, ball, score, setupPoint, sendBoy, other } from './state.js';
import { voice, zombieDuck, aww } from './audio.js'; import { voice, zombieDuck, aww } from './audio.js';
import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.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 }); } else if (!prof.ch) { prof.ch = 1; setPromo({ champion: true }); }
profSave(); profPush(); 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 games = score.games[0] + score.games[1];
const b = endRun(d.cup.rounds.length, games, G.splatT, false, MODE_MATCH); const b = endRun(d.cup.rounds.length, games, G.splatT, false, MODE_MATCH);
G.best = b; G.newBest = b.newBest; G.best = b; G.newBest = b.newBest;
+36 -5
View File
@@ -846,13 +846,25 @@ export const MODES = [
// opens its own second step — classic deals the difficulty chips, zombie tennis deals its two // 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 // 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`). // (`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 = [ export const MENU = [
{ mode: 'classic', diffs: true }, { group: 'THE SEASON', sub: 'THE ONLY WAY UP THE CLUB' },
// the WAY-UP ribbon shows on THIS level too, so the click path to the promotion is marked { mode: 'cup' },
// from the first screen of the picker (main.banner reads `tag`) { group: 'THE TRAINING', sub: 'SHARPEN UP · NOTHING HERE MOVES YOUR LEAGUE' },
{ name: 'ZOMBIE TENNIS', sub: 'THE DEAD CLUB PLAYS YOU', art: 'mode_zombie', kids: ['zombie', 'cup', 'train'], { mode: 'zombie' },
tag: 'THE WAY UP' }, { mode: 'train' },
{ group: 'OUT OF COMPETITION', sub: 'NO LEAGUE, NO RECORD, NO STAKES' },
{ mode: 'gore' }, { mode: 'gore' },
{ mode: 'classic', diffs: true },
// NETPLAY (prototype): a poster with no run mode of its own — its second step is HOST / JOIN, // 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) // 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 }, { 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 }]); 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 = [ export const TOURS = [
// `kit` — HOW this tour's art takes a colour, because the two sets are dressed differently and the // `kit` — HOW this tour's art takes a colour, because the two sets are dressed differently and the
// same operation cannot serve both. // same operation cannot serve both.
+73 -14
View File
@@ -3,12 +3,12 @@
// Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update) // 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) // 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 { 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 { 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 { 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 { 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 { 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 { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js';
import { cv } from './dom.js'; import { cv } from './dom.js';
@@ -146,6 +146,14 @@ function buildSub(top) {
function buildTops() { function buildTops() {
modeList.innerHTML = ''; modeList.innerHTML = '';
for (const t of MENU) { 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 = `<b>${t.group}</b><i>${t.sub}</i>`;
modeList.appendChild(h);
continue;
}
const b = banner(t.mode ? modeOf(t.mode) : t, 'mode', topOf(pickedMode) === t); const b = banner(t.mode ? modeOf(t.mode) : t, 'mode', topOf(pickedMode) === t);
b.addEventListener('click', () => { b.addEventListener('click', () => {
// the netplay poster opens THE LOBBY and never touches pickedMode — it is not a run mode // 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); foes.appendChild(d);
} }
const size = lg.band[1] - lg.band[0] + 1; const size = lg.band[1] - lg.band[0] + 1;
document.getElementById('lgSub').textContent = felled >= size // ...and THE RECORD (THE SEASON): how far this league's cup has ever let you get. It is the one
? 'ALL ' + size + ' FELLED — TAKE THE CUP' // line that makes a lost attempt count for something, so it takes the second half of the sub
: felled + ' OF ' + size + ' FELLED · THE CUP IS THE WAY UP'; // 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 ? '<br>BEST IN THE CUP: ' + cupRoundName(lg.id, best)
: '<br>THE CUP IS THE WAY UP — NEVER ENTERED');
} }
// The setup screen's rows, brought up to date with picks made elsewhere (the identity screen). // 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() { function buildTrain() {
trainList.innerHTML = ''; trainList.innerHTML = '';
const top = Math.min(LADDER, leagueOf(profile.prof.lg).band[1]); 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'); const f = matchFoe(n), b = document.createElement('button');
b.innerHTML = `<img src="${foeArt(f)}" alt=""><b>${f.name}</b><i>No. ${n}</i>`; const put = n === out;
if (put) b.className = 'put';
b.innerHTML = `<img src="${foeArt(f)}" alt=""><b>${f.name}</b>`
+ `<i>${put ? 'PUT YOU OUT OF THE CUP' : 'No. ' + n}</i>`;
b.addEventListener('click', () => { trainRung = n; start('train'); }); b.addEventListener('click', () => { trainRung = n; start('train'); });
trainList.appendChild(b); trainList.appendChild(b);
} }
@@ -605,6 +629,12 @@ function buildPromo(pr, run) {
? 'THE HONOURS BOARD REMEMBERS YOUR NAME' ? 'THE HONOURS BOARD REMEMBERS YOUR NAME'
: `NEW COURT UNLOCKED — ${c.name} · OPEN IN EVERY MODE`; : `NEW COURT UNLOCKED — ${c.name} · OPEN IN EVERY MODE`;
document.getElementById('prGo').textContent = champion ? 'TAKE A BOW' : 'WALK ON'; 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 HOLD<u>EVERY CUP FROM HERE IS A DEFENCE</u>`
: `NEXT: ${fx.title}<u>${fx.line}</u>`;
} }
document.getElementById('prGo').addEventListener('click', () => { document.getElementById('prGo').addEventListener('click', () => {
const run = promoRun; promoRun = null; const run = promoRun; promoRun = null;
@@ -657,11 +687,21 @@ function refreshSplash() {
document.getElementById('plFrom').textContent = pr.level; document.getElementById('plFrom').textContent = pr.level;
document.getElementById('plTo').textContent = pr.need ? pr.level + 1 : 'MAX'; document.getElementById('plTo').textContent = pr.need ? pr.level + 1 : 'MAX';
document.getElementById('plFill').style.width = Math.round(pr.frac * 100) + '%'; 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 document.getElementById('plGoal').textContent = profile.prof.ch
? 'CHAMPION OF THE DEAD CLUB — DEFEND THE NAME' ? 'CHAMPION OF THE DEAD CLUB — DEFEND THE NAME'
: profile.prof.lg >= LEAGUES.length : bestR
? 'WIN THE ' + lg.name.replace('THE ', '') + ' CUP — BECOME CHAMPION' ? 'THIS CUP · BEST SO FAR: ' + cupRoundName(profile.prof.lg, bestR)
: 'WIN THE ' + lg.name.replace('THE ', '') + ' CUP TO ADVANCE'; : '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(); summary();
} }
@@ -710,7 +750,14 @@ const goMode = () => {
if (!profile.prof.setup) { buildPlayer(); go('player'); return; } if (!profile.prof.setup) { buildPlayer(); go('player'); return; }
buildTops(); go('mode'); 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); 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 // 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 // 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 // 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 // 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. // 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) // ...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; const ready = G.done ? G.done.t > DONE_RESTART : G.mode === 'eaten' && G.eatenT > EATEN_RESTART;
if (G.modal || !ready) return; 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(); restartRun();
profile.push(); // the run's xp is banked — mirror it while the moment is quiet 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 refreshSplash(); // the run just ended: the splash count has moved
@@ -1015,8 +1070,12 @@ const maybeRestart = () => {
if (promo) { buildPromo(promo, run); go('promo'); return; } 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 // ...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 // silently barred two of the three modes from ever reaching the prompt
if (run && run.newKillBest && run.score > 0) askName(run); if (run && run.newKillBest && run.score > 0) { askName(run); return; }
else go('splash'); // 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); cv.addEventListener('pointerdown', maybeRestart);
addEventListener('keydown', maybeRestart); addEventListener('keydown', maybeRestart);
+38 -2
View File
@@ -21,15 +21,29 @@ import { myId, savedName, saveName } from './stats.js';
const KEY = 'splatterProfile'; 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() { function load() {
try { try {
const p = JSON.parse(localStorage.getItem(KEY) || 'null'); const p = JSON.parse(localStorage.getItem(KEY) || 'null');
if (p && typeof p === 'object') { 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)), 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 {} } 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(); export const prof = load();
@@ -111,6 +125,19 @@ export function bonus() {
// quality is centre-normalised, so the same ms error scores higher // 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 ---- // ---------------------------------------------------------------- the promotion ----
// A cup win that moved the league parks its ceremony here (beats.cupWon writes, main.maybeRestart // 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 // 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; let promo = null;
export const setPromo = p => { promo = p; }; export const setPromo = p => { promo = p; };
export function takePromo() { const p = promo; promo = null; return 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 ---- // ---------------------------------------------------------------- the mirror ----
// POST is fire-and-forget with keepalive so the pagehide path can use the same call. The server // 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.lg = Math.min(5, Math.max(prof.lg, p.lg | 0 || 1));
prof.ch = prof.ch || (p.ch ? 1 : 0); prof.ch = prof.ch || (p.ch ? 1 : 0);
prof.setup = prof.setup || (p.setup ? 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 runFrom = levelAt(prof.xp); // the adopted xp is history, not this run's delta
save(); save();
} }
+278 -178
View File
@@ -1,13 +1,13 @@
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state). // Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
import { cv, ctx } from './dom.js'; 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 { P, O, ball, score, crowd, G, court, tour, kit, skin } from './state.js';
import { RAISERS, FRAMES } from './crowd.js'; import { RAISERS, FRAMES } from './crowd.js';
import { predictLanding, previewServe, slotId, slotStock, serveApexY, rallyFeel, swayOf } from './gameplay.js'; import { predictLanding, previewServe, slotId, slotStock, serveApexY, rallyFeel, swayOf } from './gameplay.js';
import { drillQueue, drillLegal, drillMult } from './drill.js'; import { drillQueue, drillLegal, drillMult } from './drill.js';
import { zombies, gore, piles, parts, pellets, extras, shots, pops } from './zombies.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 ---- // ---------------------------------------------------------------- palette ----
// tennis palette -> drained splatter palette; the transition lerps between them. // tennis palette -> drained splatter palette; the transition lerps between them.
@@ -2341,155 +2341,179 @@ function drawFlickHint() {
const BACK_C = 1.70158; const BACK_C = 1.70158;
const easeBack = p => 1 + (BACK_C + 1) * (p - 1) ** 3 + BACK_C * (p - 1) ** 2; 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 // THE MODAL IS ONE BOX (2026-08-31, Oliver: "manchmal 2 boxen, manchmal eine, manchmal keine —
// own boxed row — the statement in cream over its consequence in the modal's colour — and a rule // vielleicht immer in einer box, und der tap / press any key wird zu einem echten button im
// about BLOOD shows the pool as the game's own pips instead of a naked number. Driven by // footer der box"). Three drawers had grown here — the plain announcement, the rules card, the
// m.rules: [{ lead, sub, pips }]; title/body keep carrying the words for the suite. // walk-on — each with its own geometry, its own alignment and its own idea of what a box was.
function drawRulesModal(m, t, dx) { // They are ONE renderer now, with slots:
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); // THE MASTHEAD art (an icon or the body's mugshot) beside the title, with an optional strap
const gap = 8; // THE ROWS a statement over its consequence · a split figure (his level against yours)
const blockH = 30 + m.rules.reduce((a, r) => a + rowH(r) + gap, 0) - gap; // · a blood pool drawn as the HUD's own pips — hairlines between them
let y = Math.round(H / 2 - blockH / 2); // THE FOOTER a real button. The prompt used to be a blinking line of text that said what
const hx = dx(0, 1); // gesture to make; it says what HAPPENS now, and looks pressable.
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]]) { // The PAYLOAD is unchanged — title/body/icon/rules/foe are the same fields beats.js has always
ctx.save(); ctx.globalAlpha = a; // sent and the suite still reads. This is a layout, not new state. Any tap still dismisses (the
ctx.fillText(m.title, W / 2 + Math.round(hx * (1 + f)), y + 8); // button is the affordance, not a hit target — a modal you can only leave through one 26px strip
ctx.restore(); // would be a worse wall than the one we had).
} const MODAL_PAD = 12, MODAL_GAP = 9, MODAL_BTN = 26;
ctx.fillText(m.title, W / 2 + hx, y + 8); const MODAL_FILL = 'rgba(8,22,16,0.93)', MODAL_EDGE = 'rgba(120,150,130,0.45)';
y += 30; const MODAL_RULE = 'rgba(120,150,130,0.22)';
m.rules.forEach((r, i) => {
const h = rowH(r), rx = dx(i + 1, -1); // One row of the stack, measured. `inner` is the text width inside the box.
panel(bx + rx, y, boxW, h, 3, 'rgba(8,26,18,0.85)', 'rgba(120,150,130,0.4)'); function modalRows(m, inner) {
ctx.font = F(8, 700); const rows = [];
shadowText(r.lead, W / 2 + rx, y + 12, UI.cream); const push = r => { rows.push(r); return r; };
// the consequence line: DIM for information, the modal's hot colour only when it threatens if (m.foe) {
if (r.sub) { ctx.font = F(8, 0); shadowText(r.sub, W / 2 + rx, y + 25, r.hot ? (m.color || UI.warm) : UI.dim); } push({ kind: 'split', h: 32 });
if (r.pips) { // the pool, in the HUD's own pip language const sub = [];
const pg = 9, pw = (r.pips - 1) * pg; for (const l of m.foe.sub || []) sub.push(...wrapText(l, F(8, 0), inner));
for (let k = 0; k < r.pips; k++) { if (sub.length) push({ kind: 'text', lines: sub, dim: true, h: sub.length * 12 + 2 });
ctx.beginPath(); ctx.arc(W / 2 + rx - pw / 2 + k * pg, y + h - 9, 2.6, 0, 7); } else if (m.rules) {
ctx.fillStyle = '#8a1a12'; ctx.fill(); for (const r of m.rules) {
ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1; ctx.stroke(); 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; } else {
}); const lines = [];
if (t >= 1 && Math.floor(G.idleT * 2) % 2) { for (const l of m.body || []) {
ctx.font = F(8); if (l === '') { lines.push(''); continue; } // the weapon test's deliberate blank
shadowText(IS_TOUCH ? 'TAP TO CONTINUE' : 'PRESS ANY KEY', W / 2, lines.push(...wrapText(l, F(8, 0), inner));
Math.round(H / 2 + blockH / 2 + 24), UI.warm); }
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 // Everything measured before anything is drawn: the box has to know its own height, and the title
// of information in one line stack). Designated zones instead: the MASTHEAD (mugshot beside the // has to know whether it fits beside the art before either is placed.
// name with the slogan under it), THE MEASURE (its level boxed against yours — the number wears function modalLayout(m) {
// the modal's hot red while the body outranks you, the pick green once it does not), and the const boxW = Math.min(W - 20, 268), pad = MODAL_PAD;
// round/lesson strip. Same machinery as every modal otherwise: the entrance lanes, the frozen const art = m.foe ? (m.foe.head && SPR[m.foe.head]) : (m.icon && SPR[m.icon]);
// court, the blinking tap (drawModal below). m.title/m.body still carry the same words for the const artH = art ? Math.round(Math.min(m.iconH || 46, H * 0.2)) : 0;
// suite and any legacy reader — this is a LAYOUT, not new state. const artW = art ? Math.round(artH * art.width / art.height) : 0;
function drawFoeModal(m, t, dx) { const headW = boxW - pad * 2 - (art ? artW + 10 : 0);
const f = m.foe, img = f.head && SPR[f.head]; // the title SHRINKS to its lane rather than running off the box — boss names and THE TRAINING
const boxW = Math.min(W - 24, 250), bx = Math.round(W / 2 - boxW / 2); // COURT are both titles, and one of them is twice as long as the other
const mugH = Math.round(Math.min(56, H * 0.22)); ctx.font = B(22);
const mugW = img ? Math.round(mugH * img.width / img.height) : 0; const tw = ctx.measureText(m.title).width;
const slogan = wrapText(f.line, F(8, 0), boxW - (mugW ? mugW + 12 : 0)); // the shared wrapper sets the font const tSize = tw > headW ? Math.max(13, Math.floor(22 * headW / tw)) : 22;
const headH = Math.max(mugH, 26 + slogan.length * 12); const strap = m.foe ? wrapText(m.foe.line, F(8, 0), headW) : [];
const measureH = 36, gap = 14; const headH = Math.max(artH, 22 + strap.length * 12);
const subH = f.sub.length ? f.sub.length * 12 + 4 : 0; const rows = modalRows(m, boxW - pad * 2);
const blockH = headH + gap + measureH + (subH ? gap - 4 + subH : 0); let h = pad + headH + MODAL_GAP + MODAL_BTN + pad;
let y = Math.round(H / 2 - blockH / 2); for (const r of rows) h += r.h + MODAL_GAP;
// THE MASTHEAD flies from the right as one piece return { boxW, pad, art, artW, artH, tSize, strap, headH, rows, h,
const hx = dx(0, 1); x: Math.round(W / 2 - boxW / 2), y: Math.round(H / 2 - h / 2) };
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';
} }
function drawModal(m) { function drawModal(m) {
// The entrance (m.in, 0..1, ticked in gameplay's update behind the world freeze): the icon and // The entrance (m.in, 0..1, ticked in gameplay's update behind the world freeze). The box flies
// title fly in from the RIGHT, the body lines cascade in from the LEFT one after another, each // in from the RIGHT as ONE object — the old per-line stagger would tear a box apart — and the
// on its own staggered easeOutBack. Every x is ROUNDED — a fractional x on a nearest-neighbour // dim washes in first. Every x is ROUNDED: a fractional x on nearest-neighbour art re-snaps its
// scaled image re-snaps its pixel columns every frame, which is the same boiling that once read // pixel columns every frame, which is the boiling that once read as HUD flicker.
// as HUD flicker. Legacy/test modals with no `in` field draw fully landed.
const t = m.in === undefined ? 1 : m.in; const t = m.in === undefined ? 1 : m.in;
const STAG = 0.08, RUN = 0.62; // per-element delay, and travel time const p = Math.max(0, Math.min(1, t / 0.62));
const lane = k => { // 0 = off screen, 1 = home (may overshoot) const dx = Math.round((1 - (p >= 1 ? 1 : easeBack(p))) * W * 0.9);
const p = Math.max(0, Math.min(1, (t - k * STAG) / RUN)); ctx.fillStyle = `rgba(8,6,10,${(0.82 * Math.min(1, t / 0.35)).toFixed(3)})`;
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
ctx.fillRect(0, 0, W, H); ctx.fillRect(0, 0, W, H);
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const L = modalLayout(m);
if (m.foe) return drawFoeModal(m, t, dx); const col = m.color || UI.warm;
if (m.rules) return drawRulesModal(m, t, dx); const x = L.x + dx, pad = L.pad, inX = x + pad;
const body = m.body || [], img = m.icon && SPR[m.icon]; let y = L.y + pad;
// cap the illustration against the viewport, not just the asset: at 270px tall (landscape) a ctx.textBaseline = 'middle';
// portrait-sized icon pushed the TAP prompt down onto the weapon rack panel(x, L.y, L.boxW, L.h, 4, MODAL_FILL, MODAL_EDGE);
const iconH = img ? Math.min(m.iconH || 40, H * 0.28) : 0; // THE MASTHEAD
const blockH = (iconH ? iconH + 12 : 0) + 26 + body.length * 13; if (L.art) {
let y = Math.round(H / 2 - blockH / 2); ctx.imageSmoothingEnabled = false;
const headX = dx(0, 1); // icon and title travel together, as one masthead ctx.drawImage(L.art, inX, y + Math.round((L.headH - L.artH) / 2), L.artW, L.artH);
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;
} }
ctx.font = B(24); ctx.fillStyle = m.color || UI.warm; // an announcement, so the announcement face const tx = inX + (L.art ? L.artW + 10 : 0);
if (headX) for (const [f, a] of [[0.7, 0.12], [0.35, 0.26]]) { // speed streak: ghosts strung out behind it ctx.textAlign = 'left';
ctx.save(); ctx.globalAlpha = a; ctx.font = B(L.tSize);
ctx.fillText(m.title, W / 2 + Math.round(headX * (1 + f)), y + 8); ctx.fillStyle = col;
ctx.restore(); ctx.fillText(m.title, tx, y + 11);
}
ctx.fillText(m.title, W / 2 + headX, y + 8); y += 26;
ctx.font = F(8, 0); ctx.font = F(8, 0);
body.forEach((l, i) => { shadowText(l, W / 2 + dx(i + 1, -1), y + 6, UI.cream); y += 13; }); L.strap.forEach((l, i) => shadowText(l, tx, y + 26 + i * 12, UI.cream));
// the prompt only exists once everything has landed — offering the tap mid-flight would be a lie, y += L.headH + MODAL_GAP;
// dismissModal swallows it until then // THE ROWS
if (t >= 1 && Math.floor(G.idleT * 2) % 2) { // the prompt blinks so it never reads as part of the copy for (const r of L.rows) {
ctx.font = F(8); ctx.strokeStyle = MODAL_RULE; ctx.lineWidth = 1;
shadowText(IS_TOUCH ? 'TAP TO CONTINUE' : 'PRESS ANY KEY', W / 2, Math.round(H / 2 + blockH / 2 + 22), UI.warm); 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 ---- // ------------------------------------------------------------------ frame ----
@@ -2981,69 +3005,145 @@ export function render() {
drawDrillHud(); // the triage readout — no-op unless a drill is running drawDrillHud(); // the triage readout — no-op unless a drill is running
drawBenchHud(); // ...and the sprite bench's, same rule drawBenchHud(); // ...and the sprite bench's, same rule
drawApproachRing(); drawApexBand(); // the match's timing, drawn — no-ops unless a match is running 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 // THE END OF A RUN, whichever way it ended — and the one screen in the game that has to HAND
// one screen — a wash, a headline, the run's own two numbers, the mark to beat and the gated tap // SOMETHING OVER (2026-08-31, THE SEASON: "das ende reicht weiter"). It wears the modal's box
// — because only the WORDS differ, and three copies of this drift apart the first time one moves. // language for the same reason the modals got one: a wash with five free-floating lines on it
// eaten + gore GAME, SET, MASSACRE. n games, n sets // was a wall of text at the exact moment the player is deciding whether to open the game again.
// eaten + match BLED OUT. n opponents felled, n games // THE HEADLINE how it ended, in the mode's own words
// done + classic MATCH. / BEATEN. the set score // eaten + gore GAME, SET, MASSACRE. n games, n sets
// phase C for the death: the summary fades UP over the animation instead of cutting on top of it. // 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 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; const dead = G.mode === 'eaten' && G.eatenT > EATEN_SUMMARY, done = G.done && G.done.t > 0.5;
endBtns.length = 0;
if (dead || done) { if (dead || done) {
const clk = dead ? G.eatenT - EATEN_SUMMARY : G.done.t - 0.5; const clk = dead ? G.eatenT - EATEN_SUMMARY : G.done.t - 0.5;
const fade = Math.min(1, clk / 0.6); const fade = Math.min(1, clk / 0.6);
const won = !dead && G.done.won; const won = !dead && G.done.won;
const cup = m && m.cup ? m.cup : null;
ctx.save(); ctx.globalAlpha = fade; 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); 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 // THE WORDS. The cup's own exit is a ceremony, not a death: it says which round took you.
// game and at 30 it ran edge to edge on a 270px-wide portrait canvas. // "OUT IN SEMI-FINAL" reads like a telegram: the round names carry their own article when
// the big line is reserved for the TOP league's cup (THE LEAGUES): a lower cup is a step // they have one (THE FINAL, ROUND ONE) and want one when they do not.
// up the club, not the whole club — its ceremony (the promotion screen) follows anyway const theRound = n => /^(THE |ROUND )/.test(n) ? n : 'THE ' + n;
const title = dead ? (m ? 'BLED OUT.' : 'GAME, SET, MASSACRE.') 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.') : G.done.cup ? (G.league >= 5 ? 'CHAMPION OF THE DEAD CLUB.' : 'THE CUP IS YOURS.')
: won ? 'GAME, SET, MATCH.' : 'BEATEN.'; : won ? 'GAME, SET, MATCH.' : 'BEATEN.';
const line = dead const line = dead
? (m ? 'FELLED ' + m.felled + ' OPPONENT' + (m.felled === 1 ? '' : 'S') + ' · ' + m.games + ' GAMES' ? (cup ? (cup.round + 1 >= cupBest(G.league) ? 'THE BOARD NOTES IT'
: 'SURVIVED ' + G.runGames + ' GAMES · ' + G.sets[0] + ' SETS') : '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.done.cup ? (G.league >= 5 ? 'THE CHAMPION FELL · ' : 'THE FINAL FELL · ')
+ ((G.match && G.match.games) || G.runGames) + ' GAMES' + ((G.match && G.match.games) || G.runGames) + ' GAMES'
: G.sets[0] + '–' + G.sets[1] + ' IN SETS · ' + 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); // THE FIXTURE. A won cup parks a ceremony (the promotion) — that IS the handover, so the
ctx.font = F(9); shadowText(line, W / 2, H / 2 - 14, UI.cream); // summary points at it instead of at the next draw.
if (G.newBest) { // a fresh record crows; otherwise show the mark to beat const promo = hasPromo();
const pulse = 0.55 + 0.45 * Math.abs(Math.sin(clk * 4)); const fx = cupFixture(prof.lg, prof.ch);
ctx.font = B(20); const xd = runDelta(), pr = progress();
shadowText('★ NEW BEST ★', W / 2, H / 2 + 2, hexA(UI.warm, pulse)); const ready = dead ? G.eatenT > EATEN_RESTART : G.done.t > DONE_RESTART;
} else if (G.best && G.best.games > 0) {
ctx.font = F(8); const boxW = Math.min(W - 20, 288), pad = MODAL_PAD, inner = boxW - pad * 2;
shadowText(m ? 'BEST ' + G.best.games + ' FELLED' const headLines = wrapText(title, B(22), inner);
: G.classic ? 'LONGEST MATCH ' + G.best.games + ' GAMES' const lineLines = wrapText(line, F(9), inner);
: 'BEST ' + G.best.games + ' GAMES · ' + G.best.sets + ' SETS', W / 2, H / 2 + 4, UI.dim); // 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
// THE RUN'S XP — the moment meta-progress absorbs the permadeath (the leagues, phase 1). const mark = G.newBest || cup ? null
// Muted modes never award, so a training rep or a drill shows nothing here by construction. : G.best && G.best.games > 0
const xd = runDelta(); ? (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) { if (xd.xp > 0) {
const pr = progress(); rule();
ctx.font = F(8); 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 shadowText('+' + xd.xp + ' XP' + (xd.to > xd.from ? ' · LEVEL ' + xd.from + ' → ' + xd.to
: ' · LEVEL ' + pr.level), : ' · LEVEL ' + pr.level),
W / 2, H / 2 + 24, xd.to > xd.from ? UI.pick : UI.cream); inX, y + 7, xd.to > xd.from ? UI.pick : UI.cream);
const bw = 120, bx = Math.round(W / 2 - bw / 2), by = Math.round(H / 2 + 36); const bw = inner, by = y + 18;
ctx.fillStyle = 'rgba(255,255,255,0.14)'; ctx.fillRect(bx, by, bw, 4); 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 // 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'); 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 (G.newBest) { // a fresh record crows; otherwise show the mark to beat
if (dead ? G.eatenT > EATEN_RESTART : G.done.t > DONE_RESTART) { 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); 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(); ctx.restore();
} }
} }
+5 -1
View File
@@ -11,7 +11,7 @@ import { drillKill, drillCaught } from './drill.js';
import { splatPoint, cheer, spentBall, aside, grantWeapon, endBurst, sliceCarom } from './gameplay.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 { resolveInterlude, endBossFight } from './beats.js'; // runtime-only cycle, like the gameplay one
import { tally, multiKill, endRun, MODE_MATCH } from './stats.js'; 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 zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer 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); : endRun(G.runGames, G.sets[0], G.splatT);
G.best = b; G.newBest = b.newBest; // b is {games, sets, newBest}; render reads games/sets 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 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. // One hostile ball, lobbed at where the player is standing. `spread` fans a volley sideways.
+54
View File
@@ -115,6 +115,9 @@ function freshSplatter() {
// XP is zeroed too: the level feeds real dials now (legs, window), and accumulated awards from // XP is zeroed too: the level feeds real dials now (legs, window), and accumulated awards from
// earlier tests would silently buff every later one. // earlier tests would silently buff every later one.
prof.prof.lg = 1; prof.prof.ch = 0; prof.prof.xp = 0; prof.takePromo(); 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.mode = 'splatter';
G.everTransitioned = true; G.everTransitioned = true;
setupPoint(); 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'); 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', () => { 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 // 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 // roll paired the strongest challenger with the shortest round, inverting the tuned curve