diff --git a/HANDOFF.md b/HANDOFF.md index ee2eabd..01a0beb 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -182,6 +182,14 @@ 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 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 + `env(safe-area-inset-*)` (env() is only readable from CSS), `measureSafe()` converts it to LOGICAL + canvas px using the same scale `fitViewport` just applied, and `SAFE.top`/`SAFE.bottom` shift the + scoreboard down and lift the rack + `METER_Y` up. Anything new that anchors to the very top or very + bottom of the screen must offset by these too — and the rack's hit test shares `rackGeom`, so taps + follow automatically. - **`UI` in config.js is the accent palette** (hot/warm/pick/cream/dim/bad/ball). Every HUD colour goes through it. Gore reds are deliberately NOT in it — the splatter stays loud. - **`F(size, bold)` in render.js builds every canvas font string.** Bare `monospace` resolves to diff --git a/index.html b/index.html index 49bcd1b..a6d46a1 100644 --- a/index.html +++ b/index.html @@ -20,7 +20,12 @@ 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%; background: #10141c; display: grid; place-items: center; } + html, body { margin: 0; height: 100%; height: 100dvh; background: #10141c; display: grid; place-items: center; } + /* 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; } #hint { position: fixed; bottom: 8px; width: 100%; text-align: center; font: 400 10px Silkscreen, monospace; color: #6b7688; } @@ -45,6 +50,7 @@ +
move: WASD/arrows  ·  serve/swing: SPACE  ·  aim: hold a direction while swinging  ·  T: tuning panel
@@ -60,6 +66,6 @@

TAP TO START

- + diff --git a/render.js b/render.js index f3b26f6..800607b 100644 --- a/render.js +++ b/render.js @@ -45,6 +45,19 @@ 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. +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; // Portrait height ADAPTS to the device. A fixed 270x480 is 9:16, but a modern iPhone is taller @@ -79,6 +92,7 @@ export function fitViewport() { // landscape 480x270; portrait 270 wide and as T 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 } addEventListener('resize', fitViewport); @@ -94,7 +108,7 @@ export function proj(x, y, z) { // ---------------------------------------------------------------- sprites ---- // assets/.png; game falls back to code-drawn figures until sprites load. -const SPRV = 44; // asset cache-buster — bump with main.js?v=N (see the img.src note below) +const SPRV = 45; // 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', @@ -421,10 +435,10 @@ function drawMgRacket() { // horde: the player braces the racket forward and up- // via rackHit so a tap lands on the tile you see. const RACK_BOT = 8; // px of breathing room below the rack (floats it up off the screen edge) const RACK_GRAB = 7; // px the TOUCH band extends above the drawn rack (below, it runs to the screen edge) -export const METER_Y = () => H - TUNE.rackH - RACK_BOT - 17; // the draw meter's fixed bottom slot +export const METER_Y = () => H - TUNE.rackH - RACK_BOT - 17 - SAFE.bottom; // the draw meter's fixed bottom slot function rackGeom() { const h = TUNE.rackH, n = 5, tw = Math.min(64, (W - 4) / n), rw = tw * n; - return { x0: Math.round((W - rw) / 2), y0: H - h - RACK_BOT, tw, h, n, rw }; + return { x0: Math.round((W - rw) / 2), y0: H - h - RACK_BOT - SAFE.bottom, tw, h, n, rw }; } export function rackHit(nx, ny) { // normalized [0,1] canvas coords -> slot index, or -1 if (G.mode !== 'splatter' || G.interlude) return -1; // no rack during the tennis-rules duel @@ -797,7 +811,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); + ctx.save(); ctx.translate(6, 6 + SAFE.top); 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