The band became one axis: fitted plates, and cards that travel

Every transient message — milestone card, RESUMING clock, one-line flash — now
centres its ink on CARD_MID and only one is ever up. Three shared pieces do all
of it: inkSpan measures, plate() sizes the fill to what it holds, slideDraw
swipes it in from the left and off to the right with a four-ghost smear sampled
back along its own easing curve.

- weapon card is icon + name + count, one centred row, no prose
- plain cards centre as a block instead of hanging off the band's top edge
- the clock left the net for the band; it takes over when a card or flash clears
- band moved up 20px into the flash's old row, which it can now that they never
  draw together
- hold times +0.8s: the swipe eats ~0.55s that the old numbers didn't budget for

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-09 12:55:04 +02:00
1 parent 8658a93dc7
commit f352612de3
3 files changed
+144 -52

No files matched your search

+135 -43
View File
@@ -1181,40 +1181,122 @@ function drawRack() {
// the centered milestone 'block': a real pause, front and center. Shared by the sudden-death duel
// and the SET announcement so both read as the same interruption. Assumes textAlign is already set.
// ---- THE MESSAGE BAND. Every transient centred message — the milestone card, the RESUMING
// countdown, the one-line flash (FLASH_Y already lived here) — has ONE definite home: over the
// CROWD, below the scoreboard block, above the ad strip. Nothing transient sits on the court
// anymore — a card at centre court covered the very play it was announcing, non-blocking or not.
// countdown, the one-line flash — has ONE definite home: over the CROWD, below the scoreboard
// block, above the ad strip. Nothing transient sits on the court anymore — a card at centre court
// covered the very play it was announcing, non-blocking or not.
const AD_TOP = () => Math.round(proj(0, 0, -6).y) - AD_H; // top of the courtside ad strip
// The band has ROWS: the flash line owns 68..90 (FLASH_Y, Bangers 18), the countdown panel the
// strip under it, and the card a FIXED slot below both. The card is ONE compact height now, icon
// or not — the icon sits LEFT of the text instead of stacking above it. Stacked, an icon card ran
// 102 px tall and its lower third hung over live court; side-by-side it is 62 px flat, sits fully
// over the crowd, and the art actually draws BIGGER than the stacked layout gave it.
const CARD_TOP = 88, CARD_H = 62, CARD_PAD = 20; // px of side padding inside the band; top sits
// flush under the flash row (ends ~88)
// The band no longer has ROWS — it has an AXIS. Card, countdown and flash all centre their ink on
// CARD_MID; only one of the three is ever up (see cardUp/flashUp), so they cannot collide, and the
// eye learns one place to look instead of three stacked slots. The icon sits LEFT of the text
// rather than above it: stacked, an icon card ran 102 px tall and its lower third hung over live
// court; side-by-side it is flat, sits fully over the crowd, and the art draws BIGGER than the
// stacked layout gave it. CARD_H is the MAXIMUM plate height — the plate itself is sized to what
// it actually holds (see plate()), so a one-line countdown gets a slim strip instead of a
// three-line box with nothing in two thirds of it.
const CARD_TOP = 68, CARD_H = 62, CARD_PADY = 9; // px of breathing room above and below the ink
const CARD_MID = CARD_TOP + CARD_H / 2; // the axis everything in the band sits on
function drawCard(title, sub1, sub2, color, icon) {
// THE CARD IS A PHYSICAL THING that gets carried on and carried off, courtside-sign style: it
// swipes in from the left, holds, and leaves to the right. Entry overshoots and settles back
// (easeOutBack) so it arrives with weight; the exit accelerates away (easeInCubic) so it reads as
// leaving rather than fading. The whole card moves, plate included — a sign travels, its backing
// does not stay bolted across the screen while the type slides out from under it.
const CARD_IN = 0.44, CARD_OUT = 0.24; // seconds; clamped below so a short card still gets both.
// The entry is the SLOWER of the two on purpose: at 0.3 the overshoot covered the whole width in
// about four frames, so the smear happened off-screen and only the exit ever showed a trail.
// x offset for something that lives for T0 seconds and has t left. Anything with a countdown can
// ride the band this way — the card does, and so does the RESUMING clock.
function slideX(T0, t) {
const tIn = Math.min(CARD_IN, T0 / 3), tOut = Math.min(CARD_OUT, T0 / 3);
const el = T0 - t;
if (el < tIn) { const k = Math.max(0, el) / tIn - 1, s = 1 + 2.7 * k * k * k + 1.7 * k * k; return -W * (1 - s); }
if (t < tOut) { const k = 1 - t / tOut; return W * k * k * k; }
return 0;
}
// MOTION BLUR: ghost copies sampled BACK IN TIME along the card's own easing curve, so the smear
// stretches exactly as far as the card actually travelled — long through the entry snap, short as
// it settles, and nothing at all while it holds. Sampling the curve beats differencing frames:
// it looks identical at 60 fps and at the calm-state 20, and needs no per-card state.
const CARD_GHOSTS = 4, CARD_GHOST_DT = 0.026; // 4 copies over ~100 ms of history — the trail length
// The RESUMING clock's own T0, latched on the frame it takes the band and cleared when it leaves.
// A card has a fixed life to slide against; the clock's is whatever was left when the band freed up.
let cdT0 = 0;
function slideDraw(T0, t, body) {
const dx = slideX(T0, t);
for (let i = CARD_GHOSTS; i >= 1; i--) {
const gx = slideX(T0, t + i * CARD_GHOST_DT);
if (Math.abs(gx - dx) < 1.5) continue; // holding: no trail to draw
ctx.globalAlpha = 0.42 / i;
body(gx);
}
ctx.globalAlpha = 1;
body(dx);
}
function drawCard(c) {
const T0 = c.T0 || (c.T0 = c.t); // recorded on the first drawn frame — `wait` holds
slideDraw(T0, c.t, dx => cardBody(c, dx)); // `t` frozen until then, so this is exact
}
function cardBody(c, dx) {
const { title, sub1, sub2, color, icon } = c;
const img = icon && SPR[icon];
ctx.fillStyle = 'rgba(10,6,8,0.72)'; ctx.fillRect(0, CARD_TOP, W, CARD_H);
// TWO layouts, by whether there is art: a plain card (SET, CHAPTER, the intros) stays fully
// CENTRED, the classic announcement; an icon card goes ragged-left beside its inset art —
// centred type next to a left icon read as two unrelated things sharing a band.
let tx = W / 2; ctx.textAlign = 'center';
if (img) { // vertically centred, boxed small — the art is a bullet point, not the message
const k = Math.min(36 / img.height, 38 / img.width);
ctx.save(); ctx.translate(Math.round(dx), 0);
// TWO layouts, by whether there is art. The WEAPON card is one centred row — art, name, amount,
// measured as a single group and centred as one. No prose: the tin's own picture says what it is
// and the count says how much, and the sentence explaining it was a line nobody read mid-run.
ctx.textAlign = 'center';
if (img) {
const k = Math.min(44 / img.height, 46 / img.width);
const iw = Math.round(img.width * k), ih = Math.round(img.height * k);
ctx.font = B(26); const nameW = ctx.measureText(title).width;
ctx.font = B(20); const amtW = sub1 ? 8 + ctx.measureText(sub1).width : 0;
let x = Math.round((W - (iw + 10 + nameW + amtW)) / 2);
const s = inkSpan([[title, B(26), 0]]);
plate(Math.max(ih, s.bot - s.top)); // the art is usually the taller of the two
ctx.imageSmoothingEnabled = false;
ctx.drawImage(img, CARD_PAD, Math.round(CARD_TOP + (CARD_H - ih) / 2), iw, ih);
tx = CARD_PAD + iw + 12; ctx.textAlign = 'left';
ctx.drawImage(img, x, Math.round(CARD_MID - ih / 2), iw, ih);
ctx.textAlign = 'left'; x += iw + 10;
ctx.font = B(26); shadowText(title, x, inkCentre([[title, B(26), 0]]), color);
if (sub1) { ctx.font = B(20); shadowText(sub1, x + nameW + 8, inkCentre([[sub1, B(20), 0]]), UI.cream); }
ctx.textAlign = 'center'; ctx.restore();
return;
}
// the title still SHOUTS (Bangers) but at 22, not 30 — up here it shares the screen with the
// scoreboard and the ads, and 30 read as oversized the moment it left centre court
ctx.font = B(22); shadowText(title, tx, CARD_TOP + 4, color);
ctx.font = F(8);
if (sub1) shadowText(sub1, tx, CARD_TOP + 34, UI.cream);
if (sub2) shadowText(sub2, tx, CARD_TOP + 48, UI.cream);
ctx.textAlign = 'center'; // the band's neighbours (flash, countdown) assume centred
// scoreboard and the ads, and 30 read as oversized the moment it left centre court. The stack
// keeps its own line rhythm (0/30/44) and is centred as ONE block, so a one-line card and a
// three-line card sit on the same axis instead of both hanging off the band's top edge.
const lines = [[title, B(22), 0, color]];
if (sub1) lines.push([sub1, F(8), 30, UI.cream]);
if (sub2) lines.push([sub2, F(8), 44, UI.cream]);
const s = inkSpan(lines);
plate(s.bot - s.top);
const y = Math.round(CARD_MID - (s.top + s.bot) / 2);
for (const [t, f, rel, col] of lines) { ctx.font = f; shadowText(t, W / 2, y + rel, col); }
ctx.textAlign = 'center'; ctx.restore(); // the band's neighbours (flash, countdown) assume centred
}
// The plate, sized to the `h` px of content it carries and centred on the axis. Capped at CARD_H:
// the three-line SET card is what that number was tuned for and it still fills it exactly.
function plate(h) {
const ph = Math.min(CARD_H, Math.round(h) + CARD_PADY * 2);
ctx.fillStyle = 'rgba(10,6,8,0.72)';
ctx.fillRect(0, Math.round(CARD_MID - ph / 2), W, ph);
}
// The measured INK extent of a stack of [text, font, relativeY]. Off ink, not off the font box:
// Bangers carries a lot of leading, so a nominal 'middle' baseline (or a fixed offset tuned on one
// string) hangs the caps visibly high. textBaseline is 'top' here, so ascent is negative — ink runs
// rel-ascent .. rel+descent. Everything in the band sizes and centres itself off this.
function inkSpan(lines) {
let top = Infinity, bot = -Infinity;
for (const [t, f, rel] of lines) {
ctx.font = f; const m = ctx.measureText(t);
top = Math.min(top, rel - m.actualBoundingBoxAscent);
bot = Math.max(bot, rel + m.actualBoundingBoxDescent);
}
return { top, bot };
}
function inkCentre(lines) { const s = inkSpan(lines); return Math.round(CARD_MID - (s.top + s.bot) / 2); }
// A GHOST HAND, fingertip at (x, y) — the tutorial finger every gesture hint shares. A drawn
// pointing hand rather than the old abstract dot: testers did not read a sliding circle as "your
@@ -1416,9 +1498,7 @@ function drawModal(m) {
// ------------------------------------------------------------------ frame ----
const ballHist = []; // hot-ball ghost trail positions (render-owned, see the streak block in render)
const AD_H = 14; // px tall the courtside ad strip is drawn — also the ceiling for centred HUD text
const FLASH_Y = 68; // the ONE slot every free-floating message uses. Clear of the scoreboard block
// (6 + 42 + 15 = 63 at its tallest), which the old 60 was not in portrait —
// there the board is over half the width, so a centred flash landed on it.
// (FLASH_Y is gone — the flash centres on CARD_MID with everything else in the band now.)
export function render() {
ctx.setTransform(RES, 0, 0, RES, 0, 0); // logical W x H coordinates at RES density
// ease the aim camera FIRST, before anything projects — one camera per frame (see proj)
@@ -1785,16 +1865,6 @@ export function render() {
: Math.max(G.changeover > 0 ? G.changeover : 0,
G.phase === 'over' ? G.overT : 0,
G.interlude ? G.interlude.introT : 0);
if (waitT > 0.5) {
// OVER THE NET, in the shout face, no box — Oliver's call after trying it panelled in the
// band: the countdown belongs to the COURT the pause is holding, and Bangers + shadowText is
// the register that survives any backdrop (it is what every flash already rides).
// The changeover names itself here — its old separate flash was the same word, twice.
const txt = (G.changeover > 0 ? 'CHANGEOVER · ' : '') + 'RESUMING IN ' + Math.ceil(waitT);
ctx.font = B(16);
ctx.textBaseline = 'top';
shadowText(txt, W / 2, Math.round(proj(0, COURT.netH, COURT.netZ).y) - 26, '#ffffff');
}
if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !(G.mgT > 0) && !G.aim && waitT <= 0.5) { // while pulling, the draw meter owns this slot
ctx.font = F(8);
const touch = IS_TOUCH, switchHint = touch ? 'TAP THE RACK TO SWITCH' : '1-0 TO SWITCH';
@@ -1833,16 +1903,38 @@ export function render() {
// ...and neither the flash nor the card draws UNDER a modal: the popup owns the screen, and a
// band message half-visible through its dim was clutter saying nothing. Their clocks are frozen
// behind the freeze too (see update), so both play out in full once the popup is dismissed.
if (G.flash && !G.modal) {
// ONE MESSAGE AT A TIME. A flash stacked above a card was two announcements shouting over each
// other in the same band; the CARD wins because it is the bigger event (a set, a chapter, a
// weapon won) and the flash under it is always a footnote to something you already watched.
// The flash's clock keeps running, so it expires quietly rather than surfacing late.
const cardUp = G.card && !G.modal && G.card.t > 0 && !(G.card.wait > 0);
const flashUp = G.flash && !G.modal && !cardUp;
if (flashUp) {
// 18, not 24: at the card's near-size a transient one-liner read as a milestone announcement,
// and half of what lands in this slot (PERFECT, RETURNED, DOUBLE KILL) is only ever a footnote
// to something you already watched happen. The register is right; the volume was not.
// On the band's axis, not on its top edge: the flash is the only band citizen with no plate,
// so hanging it where a plate's first line would start left it sitting visibly high with
// nothing under it. Same ink-centring every card uses — one axis for all three registers.
ctx.font = B(18);
shadowText(G.flash.text, W / 2, FLASH_Y + Math.sin(G.flash.t * 20) * 1, '#ffffff');
shadowText(G.flash.text, W / 2, inkCentre([[G.flash.text, B(18), 0]]) + Math.sin(G.flash.t * 20) * 1, '#ffffff');
}
// the SET milestone card (the duel's own announcement is a real modal now, not a timed card)
if (G.card && !G.modal && G.card.t > 0 && !(G.card.wait > 0)) // `wait` holds it OFF the screen (a boss dying under it)
drawCard(G.card.title, G.card.sub1, G.card.sub2, G.card.color, G.card.icon);
if (cardUp) drawCard(G.card); // `wait` holds it OFF the screen (a boss dying under it)
// THE RESUMING CLOCK is a band citizen too — same plate, same swipe, same smear. It used to
// float bare over the net in its own face and position, which was the last message in the game
// that looked like it came from a different game. It is the LOWEST priority of the three: a card
// outranks it (the card explains the very pause it is counting) and so does a flash (short, and
// always about something that just happened). It swipes in the moment the band frees up.
// The 0.5 gate is on STARTING only — once it is on the band it plays down to zero, or the
// swipe-out (0.24 s) would be cut off before it ever ran.
if (waitT > 0 && (cdT0 || waitT > 0.5) && !cardUp && !flashUp) {
cdT0 = cdT0 || waitT; // its own T0: the clock left when the band came free
const n = Math.ceil(waitT);
slideDraw(cdT0, waitT, dx => cardBody(
G.changeover > 0 ? { title: 'CHANGEOVER', sub1: 'RESUMING IN ' + n, color: '#ffffff' }
: { title: 'RESUMING IN ' + n, color: '#ffffff' }, dx));
} else cdT0 = 0;
if (TUNE.missHud && G.lastMiss && G.idleT - G.lastMiss.at < 3.5) { // last miss reason, live
ctx.font = F(8); ctx.fillStyle = '#ff9060';
ctx.fillText(G.lastMiss.text.toUpperCase(), W / 2, H - 28);