Put the whole game inside the safe area

Insetting only the HUD while the canvas stayed full-bleed wasn't what was
asked for. The canvas itself is position:fixed and inset by
env(safe-area-inset-*) now, so nothing renders under the notch, the status bar
or the home indicator, and render.js no longer offsets the HUD as well — doing
both would subtract the notch twice.

The bottom strip was a second bug: the canvas was sized by computing a scale
from innerWidth/innerHeight and setting explicit pixels, so it was laid out
against whatever innerHeight reported at load, before Safari's toolbars had
settled, and stayed short of the bottom forever after. CSS stretches it now
(100vw x 100dvh minus the insets) and fitViewport derives the logical grid from
the box it was actually given, so the aspect always matches exactly — no
letterbox, no distortion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-30 17:59:43 +02:00
1 parent 89667c4018
commit 90aefdce27
3 files changed
+46 -21

No files matched your search

+16
View File
@@ -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 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 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. 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 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 — 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 but an iPhone's clock was landing directly on the scoreboard. A hidden `#safe` probe div carries
+16 -3
View File
@@ -20,13 +20,26 @@
src: url('assets/fonts/Silkscreen-Regular.ttf') format('truetype'); } src: url('assets/fonts/Silkscreen-Regular.ttf') format('truetype'); }
@font-face { font-family: 'Silkscreen'; font-weight: 700; font-display: block; @font-face { font-family: 'Silkscreen'; font-weight: 700; font-display: block;
src: url('assets/fonts/Silkscreen-Bold.ttf') format('truetype'); } 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 /* 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) — 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. */ 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; #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); } 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; #hint { position: fixed; bottom: 8px; width: 100%; text-align: center;
font: 400 10px Silkscreen, monospace; color: #6b7688; } font: 400 10px Silkscreen, monospace; color: #6b7688; }
@media (pointer: coarse) { #hint, #intro .kb { display: none; } } @media (pointer: coarse) { #hint, #intro .kb { display: none; } }
@@ -66,6 +79,6 @@
<p class="start">TAP TO START</p> <p class="start">TAP TO START</p>
</div> </div>
</div> </div>
<script type="module" src="main.js?v=45"></script> <script type="module" src="main.js?v=46"></script>
</body> </body>
</html> </html>
+14 -18
View File
@@ -45,28 +45,26 @@ const IS_TOUCH = matchMedia('(pointer: coarse)').matches; // hoisted: a matchMed
export const RES = 4; export const RES = 4;
export let W = 480, H = 270; export let W = 480, H = 270;
// Device safe-area insets, in LOGICAL canvas px. The canvas is deliberately full-bleed under the // Safe-area handling lives ENTIRELY in index.html's canvas rule: the element is inset by
// notch and the home indicator (viewport-fit=cover) — the crowd looks better running to the edges — // env(safe-area-inset-*) so the whole game sits inside the safe region. SAFE stays at zero and is kept
// so the HUD is what has to respect them: the scoreboard shifts down by SAFE.top and the rack lifts // only so nothing has to change if that decision is ever revisited — do NOT re-add offsets here as
// by SAFE.bottom. Without this an iPhone's clock sat directly on top of the scoreboard. // well, or the notch gets subtracted twice.
export const SAFE = { top: 0, bottom: 0 }; 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 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 // 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. // (~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 // 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 // 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. // 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)))); // Derive the logical size from the box CSS actually gave the canvas, not from innerWidth/innerHeight.
const [w, h] = portrait ? [270, tall] : [480, 270]; // 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) { if (cv.width !== w * RES || cv.height !== h * RES) {
W = w; H = h; W = w; H = h;
cv.width = w * RES; cv.height = h * RES; cv.width = w * RES; cv.height = h * RES;
@@ -90,9 +88,7 @@ export function fitViewport() { // landscape 480x270; portrait 270 wide and as T
buildCrowd(); buildCrowd();
} }
ctx.imageSmoothingEnabled = false; // resets on canvas resize ctx.imageSmoothingEnabled = false; // resets on canvas resize
const sc = Math.min(innerWidth / w, innerHeight / h); // CSS owns both the canvas's on-screen size AND the safe-area inset; we only match the logical grid.
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); addEventListener('resize', fitViewport);
@@ -108,7 +104,7 @@ export function proj(x, y, z) {
// ---------------------------------------------------------------- sprites ---- // ---------------------------------------------------------------- sprites ----
// assets/<name>.png; game falls back to code-drawn figures until sprites load. // assets/<name>.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 = {}; 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', 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', 'o_idle1','o_idle2','o_bounce','o_bounce2','o_toss','o_smash','o_run1','o_run2','o_swing1','o_swing2',