diff --git a/HANDOFF.md b/HANDOFF.md index 01a0beb..fec5899 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -182,6 +182,22 @@ 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 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 + `innerHeight` reported at load, before iOS Safari's toolbars had settled, and it stayed a strip short + of the bottom forever after. `100dvh` is what tracks those toolbars. +- **The swish cooldown scales with how swarmed you are** (`meleeCooldown()` in gameplay.js): + `meleeCool / (1 + (near-1) * meleeCrowd)`, floored at `meleeCoolMin`, counting bodies within + `meleeCrowdR`. 1 body 1.00 s, 4 → 0.47, 8 → 0.27, 14+ → 0.22. Oliver died on every game-20 horde + 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 canvas is deliberately FULL-BLEED under the notch and home indicator; the HUD is what respects the safe area.** `viewport-fit=cover` is set, and the stands running to the screen edges look right — but an iPhone's clock was landing directly on the scoreboard. A hidden `#safe` probe div carries diff --git a/index.html b/index.html index a6d46a1..92ef847 100644 --- a/index.html +++ b/index.html @@ -20,13 +20,26 @@ src: url('assets/fonts/Silkscreen-Regular.ttf') format('truetype'); } @font-face { font-family: 'Silkscreen'; font-weight: 700; font-display: block; src: url('assets/fonts/Silkscreen-Bold.ttf') format('truetype'); } - html, body { margin: 0; height: 100%; height: 100dvh; background: #10141c; display: grid; place-items: center; } + html, body { margin: 0; padding: 0; background: #10141c; overflow: hidden; } /* invisible probe: env() is only readable from CSS, so park the insets on an element and let render.js measure them. The canvas stays FULL-BLEED (the stands under the notch look good) — it is the HUD that has to move, see SAFE in render.js. */ #safe { position: fixed; top: 0; left: 0; width: 0; height: 0; pointer-events: none; visibility: hidden; padding-top: env(safe-area-inset-top, 0px); padding-bottom: env(safe-area-inset-bottom, 0px); } - canvas { image-rendering: pixelated; width: min(96vw, calc(96vh * 16 / 9)); aspect-ratio: 16/9; touch-action: none; } + /* The canvas STRETCHES to the viewport and render.js derives its logical height from the box it + actually got. Sizing it the other way round (compute a scale from innerWidth/innerHeight, then set + 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. + 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)); } #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; } } @@ -66,6 +79,6 @@

TAP TO START

- + diff --git a/render.js b/render.js index 800607b..6117689 100644 --- a/render.js +++ b/render.js @@ -45,28 +45,26 @@ const IS_TOUCH = matchMedia('(pointer: coarse)').matches; // hoisted: a matchMed export const RES = 4; export let W = 480, H = 270; -// Device safe-area insets, in LOGICAL canvas px. The canvas is deliberately full-bleed under the -// notch and the home indicator (viewport-fit=cover) — the crowd looks better running to the edges — -// so the HUD is what has to respect them: the scoreboard shifts down by SAFE.top and the rack lifts -// by SAFE.bottom. Without this an iPhone's clock sat directly on top of the scoreboard. +// 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. export const SAFE = { top: 0, bottom: 0 }; -function measureSafe(scale) { - const el = document.getElementById('safe'); - if (!el) return; - const cs = getComputedStyle(el); - SAFE.top = Math.round((parseFloat(cs.paddingTop) || 0) / scale); - SAFE.bottom = Math.round((parseFloat(cs.paddingBottom) || 0) / scale); -} export function fitViewport() { // landscape 480x270; portrait 270 wide and as TALL as the phone is - const portrait = innerHeight > innerWidth; + const portrait = (cv.clientHeight || innerHeight) > (cv.clientWidth || innerWidth); // Portrait height ADAPTS to the device. A fixed 270x480 is 9:16, but a modern iPhone is taller // (~390x844 = 0.46), so width bound the fit and ~150 CSS px of screen went unused at the bottom. // The court is positioned by proj() off TUNE.horizon in ABSOLUTE logical px, and the rack/meter hang // off `H - ...`, so extra height simply widens the apron between the court and the rack — nothing // has to be re-laid-out. Clamped so a freak aspect can't stretch the apron absurdly. - const tall = Math.round(270 * Math.max(1.6, Math.min(2.3, innerHeight / Math.max(1, innerWidth)))); - const [w, h] = portrait ? [270, tall] : [480, 270]; + // Derive the logical size from the box CSS actually gave the canvas, not from innerWidth/innerHeight. + // The canvas is stretched to 100vw x 100dvh, so matching its logical aspect to that box exactly is + // what guarantees no letterbox and no distortion, however the phone's toolbars behave. + const bw = cv.clientWidth || innerWidth, bh = cv.clientHeight || innerHeight; + const base = portrait ? 270 : 480; + const other = Math.round(base * (portrait ? bh / Math.max(1, bw) : bh / Math.max(1, bw))); + const [w, h] = portrait ? [base, Math.max(430, Math.min(680, other))] : [base, Math.max(230, Math.min(340, other))]; if (cv.width !== w * RES || cv.height !== h * RES) { W = w; H = h; cv.width = w * RES; cv.height = h * RES; @@ -90,9 +88,7 @@ export function fitViewport() { // landscape 480x270; portrait 270 wide and as T buildCrowd(); } ctx.imageSmoothingEnabled = false; // resets on canvas resize - const sc = Math.min(innerWidth / w, innerHeight / h); - cv.style.width = w * sc + 'px'; cv.style.height = h * sc + 'px'; - measureSafe(sc); // CSS px -> logical px, so the HUD insets track however the canvas got scaled + // CSS owns both the canvas's on-screen size AND the safe-area inset; we only match the logical grid. } addEventListener('resize', fitViewport); @@ -108,7 +104,7 @@ export function proj(x, y, z) { // ---------------------------------------------------------------- sprites ---- // assets/.png; game falls back to code-drawn figures until sprites load. -const SPRV = 45; // asset cache-buster — bump with main.js?v=N (see the img.src note below) +const SPRV = 46; // 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',