From c601035eab915b2f40ba001637cabb00569b8c1e Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Thu, 30 Jul 2026 18:37:09 +0200 Subject: [PATCH] Run the art to the bottom edge, lift only the rack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remaining gap was the home-indicator inset being reserved on the canvas element. Unlike the status bar that is a slim translucent bar, not opaque UI, so the canvas now runs to the physical bottom edge and the court art fills it — only the weapon rack and the draw meter lift clear of it, through SAFE.bottom. The top keeps its element inset, since the scoreboard genuinely has to clear the status bar. Co-Authored-By: Claude Opus 5 --- HANDOFF.md | 12 +++++++----- index.html | 11 ++++++----- render.js | 23 ++++++++++++++++------- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index fec5899..01efb01 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -182,11 +182,13 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu because the wave grows with `hordeGrow` while the panic button was a flat 1 s; he chose scaling the melee over shrinking the horde. **Measure the crowd BEFORE calling `meleeSwipe`** — it kills everything in reach, so counting after counts the survivors and the cooldown barely moves. -- **The safe area is handled in ONE place: index.html's canvas rule.** The canvas is `position: fixed` - and inset by `env(safe-area-inset-*)`, so the whole game sits inside the safe region — nothing ever - renders under the notch, the status bar or the home indicator. `render.js` does NOT offset the HUD as - well (`SAFE` stays at zero); doing both subtracts the notch twice. Full-bleed with an inset HUD was - tried first and Oliver rejected it twice. +- **The safe area is handled ASYMMETRICALLY, on purpose.** TOP: index.html insets the canvas element + below the status bar, because that is opaque UI the scoreboard must clear - `SAFE.top` stays 0, and + adding a HUD offset there as well subtracts the notch twice. BOTTOM: the canvas deliberately runs to + the PHYSICAL bottom edge so the court art fills the screen (the home indicator is a slim translucent + bar, not opaque UI); only the interactive RACK and the draw meter lift clear of it, via + `SAFE.bottom`, which `measureSafe` reads from the hidden `#safe` probe (env() is CSS-only). + Reserving the bottom inset on the element instead just left a dead band. - **The canvas is STRETCHED by CSS (100vw x 100dvh minus insets) and `fitViewport` derives the logical grid from the box it was actually given** — `cv.clientWidth/clientHeight`, never `innerWidth/innerHeight`. Sizing it the other way round (compute a scale, then set explicit px) laid it out against whatever diff --git a/index.html b/index.html index 92ef847..7978b89 100644 --- a/index.html +++ b/index.html @@ -31,15 +31,16 @@ explicit px) meant it was laid out against whatever innerHeight reported at load — before iOS Safari's toolbars had settled — and it stayed a strip short of the bottom forever after. 100dvh is the dynamic viewport height, so it tracks those toolbars. */ - /* The canvas lives ENTIRELY INSIDE the safe area — never under the notch, the status bar or the - home indicator. Doing the inset here rather than offsetting the HUD in canvas space keeps it in - ONE place: render.js then just matches its logical grid to whatever box it was given. + /* TOP inset only. The status bar is opaque UI and the scoreboard must clear it, so the canvas starts + below it — but the home indicator is a slim translucent bar, so the canvas runs all the way to the + PHYSICAL bottom edge and the court art fills it. Only the weapon RACK lifts clear of the indicator, + via SAFE.bottom in render.js. Reserving the bottom inset here instead just left a dead band. 100dvh is the dynamic viewport height, so it tracks iOS Safari's toolbars appearing/hiding. */ canvas { image-rendering: pixelated; display: block; position: fixed; touch-action: none; top: env(safe-area-inset-top, 0px); left: env(safe-area-inset-left, 0px); width: calc(100vw - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px)); - height: calc(100dvh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)); } + height: calc(100dvh - env(safe-area-inset-top, 0px)); } #hint { position: fixed; bottom: 8px; width: 100%; text-align: center; font: 400 10px Silkscreen, monospace; color: #6b7688; } @media (pointer: coarse) { #hint, #intro .kb { display: none; } } @@ -79,6 +80,6 @@

TAP TO START

- + diff --git a/render.js b/render.js index 6117689..87f45ba 100644 --- a/render.js +++ b/render.js @@ -45,11 +45,18 @@ const IS_TOUCH = matchMedia('(pointer: coarse)').matches; // hoisted: a matchMed export const RES = 4; export let W = 480, H = 270; -// Safe-area handling lives ENTIRELY in index.html's canvas rule: the element is inset by -// env(safe-area-inset-*) so the whole game sits inside the safe region. SAFE stays at zero and is kept -// only so nothing has to change if that decision is ever revisited — do NOT re-add offsets here as -// well, or the notch gets subtracted twice. +// Split responsibility, and the split is deliberate: +// TOP — index.html insets the canvas element below the status bar. SAFE.top stays 0; adding an +// offset here as well would subtract the notch twice. +// BOTTOM — the canvas deliberately runs to the PHYSICAL bottom edge so the court art fills the +// screen (the home indicator is a slim bar, not opaque UI). Only the interactive RACK is +// lifted clear of it, by SAFE.bottom below. export const SAFE = { top: 0, bottom: 0 }; +function measureSafe(scale) { + const el = document.getElementById('safe'); + if (!el) return; + SAFE.bottom = Math.round((parseFloat(getComputedStyle(el).paddingBottom) || 0) / scale); +} export function fitViewport() { // landscape 480x270; portrait 270 wide and as TALL as the phone is const portrait = (cv.clientHeight || innerHeight) > (cv.clientWidth || innerWidth); @@ -88,7 +95,9 @@ export function fitViewport() { // landscape 480x270; portrait 270 wide and as T buildCrowd(); } ctx.imageSmoothingEnabled = false; // resets on canvas resize - // CSS owns both the canvas's on-screen size AND the safe-area inset; we only match the logical grid. + // CSS owns the on-screen size and the TOP inset; we match the logical grid and measure the bottom + // inset in logical px so the rack can lift clear of the home indicator. + measureSafe((cv.clientWidth || innerWidth) / w); } addEventListener('resize', fitViewport); @@ -104,7 +113,7 @@ export function proj(x, y, z) { // ---------------------------------------------------------------- sprites ---- // assets/.png; game falls back to code-drawn figures until sprites load. -const SPRV = 46; // asset cache-buster — bump with main.js?v=N (see the img.src note below) +const SPRV = 47; // asset cache-buster — bump with main.js?v=N (see the img.src note below) const SPR = {}; for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_swing1','p_swing2', 'o_idle1','o_idle2','o_bounce','o_bounce2','o_toss','o_smash','o_run1','o_run2','o_swing1','o_swing2', @@ -807,7 +816,7 @@ export function render() { const ptTxt = i => score.pts[0] >= 3 && score.pts[1] >= 3 ? (score.pts[i] === score.pts[1 - i] ? '40' : score.pts[i] > score.pts[1 - i] ? 'AD' : '40') : ['0', '15', '30', '40'][Math.min(score.pts[i], 3)]; - ctx.save(); ctx.translate(6, 6 + SAFE.top); + ctx.save(); ctx.translate(6, 6); const bW = 150, bH = 42; // fixed: keeps the ornate PNG undistorted (art is 300x84 = 150x42) if (SPR.scoreboard) ctx.drawImage(SPR.scoreboard, 0, 0, bW, bH); // generated ornate board backdrop else { // code-drawn fallback until the PNG loads