From b34a9159a793a88ed141c50405dbeb315de51578 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Fri, 21 Aug 2026 14:21:12 +0200 Subject: [PATCH] The HUD lifts off the crowd, and the frame is dark at both ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A drop shadow under the scoreboard, the game/sets panel, the BLOOD panel and every rack tile — and the same numbers in CSS on the quit chip, because half the overlay lifting off the backdrop while the other half sits flat on it is worse than neither. Canvas shadow offsets are device pixels and are not scaled by the transform, so hudShadow multiplies by RES; without that a 3px shadow is three quarters of a logical pixel on a 4x phone. And the top of the frame now fades out of black the way the bottom already fades into it (TUNE.standTop) — Oliver: "the bottom of the screen falls off into a black tone.. please make the top come out of a black tone". The stand comes up out of the dark instead of being cut off at the top edge, which also gives the scoreboard something to sit on. Co-Authored-By: Claude Opus 5 --- PROJECT.md | 11 +++++++++++ index.html | 7 +++++-- src/config.js | 4 ++++ src/render.js | 39 +++++++++++++++++++++++++++++++++++++-- 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 333cdc8..5d0ffdc 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -388,6 +388,17 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu `assets/_sheet_crowd1.png` is the pre-brightening original of the FIRST crowd, kept for history. ## Settled feel decisions — do not regress silently +- **The frame is dark at BOTH ends.** The run-off apron already took the bottom of the screen down + to near-black below the near baseline; `TUNE.standTop` does the same at the top, so the stand fades + UP out of the dark instead of being cut off at y = 0. Oliver, 2026-08-21: "the bottom of the screen + falls off into a black tone.. please make the top come out of a black tone." It also gives the + scoreboard something to sit on. +- **Every HUD element on top of the picture carries a drop shadow** (`render.hudShadow`, and the same + numbers in CSS on the quit chip): the board, the game/sets panel, the BLOOD panel and each rack + tile. They sit on a crowd, and half the overlay lifting off the backdrop while the other half does + not is worse than neither. Canvas shadow offsets are NOT scaled by the transform — they are device + pixels — so `hudShadow` multiplies by `RES`, or a 3 px shadow reads as three quarters of a logical + pixel on a 4x phone. It is sticky state: turn it off the moment the element is drawn. - **Far bodies draw off a flattened scale curve; the camera holds still.** `TUNE.farK` 0.25 (render.js `farB`): the court projects true, bodies grow toward the far baseline (~20% at the back), anchored so the near player never changes. Oliver: *"new perspective is really good — diff --git a/index.html b/index.html index 741a4c9..c4a1609 100644 --- a/index.html +++ b/index.html @@ -99,7 +99,10 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat #quit button { width: calc(var(--hud, 8px) * 4); height: calc(var(--hud, 8px) * 4); border-radius: 3px; display: grid; place-items: center; background: rgba(8,26,18,0.82); border: 1px solid rgba(63,149,96,0.35); - box-shadow: inset 0 1px 0 rgba(242,234,214,0.10), 0 1px 2px rgba(0,0,0,0.45); + /* the same drop shadow the canvas HUD carries (render.hudShadow): the chip sits on + the crowd like the scoreboard does, and half the overlay lifting off the + backdrop while the other half does not is worse than neither. */ + box-shadow: inset 0 1px 0 rgba(242,234,214,0.10), 3px 3px 3px rgba(0,0,0,0.68); /* a plain X, not ✕: Silkscreen has no U+2715, so the dingbat fell back to the system font and drew a smooth glyph inside a pixel HUD */ color: var(--cream); font: 700 14px Silkscreen, monospace; line-height: 1; @@ -661,6 +664,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/config.js b/src/config.js index f2c0750..8944a28 100644 --- a/src/config.js +++ b/src/config.js @@ -305,6 +305,10 @@ export const TUNE = { // the night court) replaces it rather than adding to it — stacking the two is what made those two // unreadable: "it is too dim to see anything on the centre court and the dark court". standBack: 0.22, + // ...and how dark the TOP of the frame is before the stand fades up out of it — the mirror of the + // run-off apron that already takes the bottom of the screen down to black. Oliver: "the bottom of + // the screen falls off into a black tone.. please make the top come out of a black tone." + standTop: 0.78, // ---- damage. Bodies with hp (racket boss 'r', brute 'g') take damage in these units, so a big // boss is just a bigger hp number and every weapon stays proportional. Fractions are legal: they // bank on zb.chip and only whole points come off hp (the wound SKINS switch on hp thresholds). diff --git a/src/render.js b/src/render.js index ee781d8..97067f0 100644 --- a/src/render.js +++ b/src/render.js @@ -509,6 +509,19 @@ function paintBackdrop(c, pal, pt) { // went wrong". const dim = standDim(pt); if (dim > 0) { c.fillStyle = `rgba(12,8,20,${dim})`; c.fillRect(0, 0, W, standsBottom); } + // THE PICTURE COMES OUT OF THE DARK AT THE TOP, the way it falls into it at the bottom. Same + // colour and the same idea as the run-off apron below the near baseline (further down this + // function): the stand is the far edge of the frame, and a hard cut at y = 0 reads as a + // photograph that has been cropped rather than as a stadium with a roof somewhere above it. + // It also happens to be where the scoreboard sits, which is the second reason it earns its keep. + if (TUNE.standTop > 0) { + const deep = standsBottom * 0.42; + const g = c.createLinearGradient(0, -8, 0, deep); + g.addColorStop(0, `rgba(6,10,6,${TUNE.standTop})`); + g.addColorStop(1, 'rgba(6,10,6,0)'); + c.fillStyle = g; + c.fillRect(-8, -8, W + 16, deep + 8); + } // ground + court (Super Tennis lawn); everything outside the doubles court is OUT const dw = COURT.dblW / 2; quadXZ(-14, -6, 14, 30, pal.out); // out area. NEVER let z approach @@ -617,6 +630,17 @@ function panel(x, y, w, h, r, fill, stroke) { if (fill) { ctx.fillStyle = fill; ctx.fill(); } if (stroke) { ctx.strokeStyle = stroke; ctx.stroke(); } } +// A DROP SHADOW UNDER THE HUD. The board, the blood panel and the rack sit on a crowd now, and an +// overlay lifts off a busy backdrop with a shadow — the same thing shadowText already does for loose +// text, one pixel at a time. Canvas shadow offsets are NOT scaled by the transform (they are device +// pixels), so they are multiplied by RES here, or a 2 px shadow reads as half a logical pixel on a +// 4x phone. Call hudShadow(false) as soon as the element is drawn: the state is sticky and would +// otherwise land under every glyph that follows. +function hudShadow(on, off = 3, blur = 3, a = 0.68) { + ctx.shadowColor = on ? `rgba(0,0,0,${a})` : 'transparent'; + ctx.shadowBlur = on ? blur * RES : 0; + ctx.shadowOffsetX = ctx.shadowOffsetY = on ? off * RES : 0; +} function drawShadow(px, py, rx, ry, alpha = 0.28) { ctx.fillStyle = `rgba(0,0,0,${alpha})`; ctx.beginPath(); ctx.ellipse(px, py, rx, Math.max(1, ry), 0, 0, 7); ctx.fill(); @@ -1183,7 +1207,9 @@ function drawBlood() { // different coordinate systems and only the CSS one is fixed. const chip = Math.round(44 * W / (cv.clientWidth || innerWidth)); const w = Math.max(46, pw + 10), h = 24, x = W - 6 - chip - w, y = 6; + hudShadow(true); panel(x + 0.5, y + 0.5, w - 1, h - 1, 3, 'rgba(26,6,8,0.82)', 'rgba(163,74,66,0.45)'); + hudShadow(false); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = F(7, 0); ctx.fillStyle = hp <= 2 ? UI.bad : '#89a08f'; ctx.fillText('BLOOD', x + w / 2, y + 7); @@ -1902,9 +1928,11 @@ function drawRack() { // The LIVE one has to read at a glance ("the active weapon should be more pronounced"): // a visibly brighter well and a 2px gold frame, not the same hairline every other tile gets. if (sel) ctx.lineWidth = 2; + hudShadow(true, 2, 0, 0.5); // hard, no blur: this runs once per slot per frame panel(x + 1.5, y0 + 1.5, g.tw - 3, g.h - 3, 4, sel ? '#1c5230' : '#0a2418', // sunk slot — the live one sits visibly lit sel ? UI.pick : '#05160e'); // gold frame when live + hudShadow(false); if (sel) ctx.lineWidth = 1; // THE BURST COUNTDOWN IS THE TILE ITSELF. It was a 1.5 px bar along the tile's bottom edge — // which is both the rounded frame AND the very bottom of a phone screen, so it was invisible @@ -2706,9 +2734,14 @@ export function render() { const bW = 150, bH = 42; // fixed: keeps the ornate PNG undistorted (art is 300x84 = 150x42) const plate = zBoard || SPR.scoreboard; if (benchOn) { /* an art test has no score — not even the empty plate */ } - else if (plate) ctx.drawImage(plate, 0, 0, bW, bH); // generated ornate board backdrop - else { // code-drawn fallback until the PNG loads + else if (plate) { + hudShadow(true); + ctx.drawImage(plate, 0, 0, bW, bH); // generated ornate board backdrop + hudShadow(false); + } else { // code-drawn fallback until the PNG loads + hudShadow(true); R(0, 0, bW, bH, bd.face); + hudShadow(false); R(0, 0, bW, 1, bd.hi); R(0, 0, 1, bH, bd.hi); R(0, bH - 1, bW, 1, bd.lo); R(bW - 1, 0, 1, bH, bd.lo); R(1, 1, 5, bH - 2, bd.spine); R(1, 1, 5, 1, bd.spineHi); R(6, 1, 1, bH - 2, bd.lo); @@ -2747,7 +2780,9 @@ export function render() { // 6px was the smallest type in the game and it read as texture rather than as words. Both // strings are 7 and the panel grew to carry them — they are the only permanent readout of how // far into a run you are. + hudShadow(true); panel(0.5, bH + 1.5, bW - 1, 14, 3, 'rgba(8,26,18,0.82)', hexA(bd.hi, 0.35)); + hudShadow(false); // which game you're in, and the sets banked, permanently — the 1.1s GAME N flash and the // scoreboard's own per-row sets column were the only places either showed. ctx.textAlign = 'right'; ctx.font = F(7, 0); ctx.fillStyle = bd.dim;