Respect the notch and the home indicator

viewport-fit=cover was already on, so the canvas was full-bleed and the iPhone
status bar landed directly on the scoreboard. Keeping the canvas full-bleed —
the stands running to the screen edges is the look — and moving the HUD
instead: a hidden probe div carries env(safe-area-inset-*), which env() only
exposes to CSS, render.js converts it to logical canvas px at the same scale
fitViewport just applied, and the scoreboard shifts down while the rack and the
draw meter lift up. Body height also goes to 100dvh so iOS's dynamic toolbars
stop leaving a strip unused at the bottom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-30 17:53:36 +02:00
1 parent ae64daa0fb
commit 89667c4018
3 files changed
+34 -6

No files matched your search

+8
View File
@@ -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 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 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 - **`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. 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 - **`F(size, bold)` in render.js builds every canvas font string.** Bare `monospace` resolves to
+8 -2
View File
@@ -20,7 +20,12 @@
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%; 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; } 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; #hint { position: fixed; bottom: 8px; width: 100%; text-align: center;
font: 400 10px Silkscreen, monospace; color: #6b7688; } font: 400 10px Silkscreen, monospace; color: #6b7688; }
@@ -45,6 +50,7 @@
</head> </head>
<body> <body>
<canvas id="c" width="480" height="270"></canvas> <canvas id="c" width="480" height="270"></canvas>
<div id="safe"></div>
<div id="hint">move: WASD/arrows &nbsp;·&nbsp; serve/swing: SPACE &nbsp;·&nbsp; aim: hold a direction while swinging &nbsp;·&nbsp; T: tuning panel</div> <div id="hint">move: WASD/arrows &nbsp;·&nbsp; serve/swing: SPACE &nbsp;·&nbsp; aim: hold a direction while swinging &nbsp;·&nbsp; T: tuning panel</div>
<div id="intro"> <div id="intro">
<div class="card"> <div class="card">
@@ -60,6 +66,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=44"></script> <script type="module" src="main.js?v=45"></script>
</body> </body>
</html> </html>
+18 -4
View File
@@ -45,6 +45,19 @@ 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
// 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 export function fitViewport() { // landscape 480x270; portrait 270 wide and as TALL as the phone is
const portrait = innerHeight > innerWidth; const portrait = innerHeight > 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
@@ -79,6 +92,7 @@ export function fitViewport() { // landscape 480x270; portrait 270 wide and as T
ctx.imageSmoothingEnabled = false; // resets on canvas resize ctx.imageSmoothingEnabled = false; // resets on canvas resize
const sc = Math.min(innerWidth / w, innerHeight / h); const sc = Math.min(innerWidth / w, innerHeight / h);
cv.style.width = w * sc + 'px'; cv.style.height = h * sc + 'px'; 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);
@@ -94,7 +108,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 = 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 = {}; 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',
@@ -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. // 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_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) 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() { function rackGeom() {
const h = TUNE.rackH, n = 5, tw = Math.min(64, (W - 4) / n), rw = tw * n; 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 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 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 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') ? (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)]; : ['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) 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 if (SPR.scoreboard) ctx.drawImage(SPR.scoreboard, 0, 0, bW, bH); // generated ornate board backdrop
else { // code-drawn fallback until the PNG loads else { // code-drawn fallback until the PNG loads