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