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

+278 -178
View File
@@ -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();
}
}