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;