From f352612de3a01caf40eb945f3e662b162165c277 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sun, 9 Aug 2026 12:55:04 +0200 Subject: [PATCH] The band became one axis: fitted plates, and cards that travel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- config.js | 8 ++- gameplay.js | 10 ++- render.js | 178 +++++++++++++++++++++++++++++++++++++++------------- 3 files changed, 144 insertions(+), 52 deletions(-) diff --git a/config.js b/config.js index 3763636..97e7338 100644 --- a/config.js +++ b/config.js @@ -184,7 +184,9 @@ export const TUNE = { zPushback: 1.4, // m a staggered zombie is knocked back splatterOverT: 0.1, // s the spent-ball 'over' beat lasts before the next serve is armed stuckT: 6, // s the deadlock watchdog waits before letting go of a frozen latch (see update) - tinCardT: 2.0, // s the won-duel weapon card holds (it does NOT wait for a tap) + tinCardT: 2.8, // s the won-duel weapon card holds (it does NOT wait for a tap). The swipe-in + // and swipe-out eat ~0.55 s of this, so every card number below is REAL time on + // screen minus that — 2.0 left the old static hold, and read as a flicker. // the slingshot: the pull's DIRECTION picks a lane, its LENGTH is power. Deliberately DECOUPLED — // reaching a far shambler must not cost a longer drag than the screen has room for. The old // mapping (pull length = reach) is why you had to drag off the bottom of the phone. @@ -342,8 +344,8 @@ export const TUNE = { burnBallR: 2.4, // m — the flame ball also lights anything it FLIES PAST. Without this the ball's // full pierce simply killed the whole lane outright and nothing was left to catch fire. burnPanic: 1.0, // walk-speed multiplier while burning (1 = off; crank it for panicked sprinting) - setCardT: 2.0, // s the centered SET card holds (the 'kurze unterbrechung' before the horde) - bossWinT: 3.6, // ...and s the EPIC! card holds after a chapter boss. Its OWN number: a boss is + setCardT: 2.8, // s the centered SET card holds (the 'kurze unterbrechung' before the horde) + bossWinT: 4.4, // ...and s the EPIC! card holds after a chapter boss. Its OWN number: a boss is // the longest fight in the game and the win was over before it registered, but // stretching setCardT would have made every routine set announcement drag too. modalIn: 0.34, // s the modal swooshes in — icon+title from the right, body cascading from the diff --git a/gameplay.js b/gameplay.js index 21f7085..8955b89 100644 --- a/gameplay.js +++ b/gameplay.js @@ -630,12 +630,10 @@ export function deliverTin() { if (wasPlain) G.weapon = 0; sendBoy(); voice('new_balls', 0.6); aside('ump_new_balls_dark', 2.0); // "...and a new opponent, apparently." - // THE PRIZE GETS ITS OWN CARD, with the tin's own icon on it — the announcement AND the whole - // explanation. The first-sight weapon MODAL is gone (with popBarrel's): the testteam never read - // them, and a popup that freezes a live court mid-run costs runs. The card carries the one desc - // line that matters and does not want a tap: you have just won a point, not been asked a question. - G.card = { title: WEAPONS[id].label, sub1: 'DELIVERED TO THE RACK', - sub2: (WEAPONS[id].desc && WEAPONS[id].desc[0]) || '', + // THE PRIZE GETS ITS OWN CARD: the tin's picture, its name, and how many. Nothing else — the + // first-sight MODAL is gone (a popup that freezes a live court mid-run costs runs) and so is the + // prose that replaced it. You have just won a point; you want to know what landed and how much. + G.card = { title: WEAPONS[id].label, sub1: '×' + WEAPONS[id].tin, icon: WEAPONS[id].icon, color: UI.warm, t: TUNE.tinCardT }; } diff --git a/render.js b/render.js index 689d3b0..faaa3eb 100644 --- a/render.js +++ b/render.js @@ -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);