diff --git a/index.html b/index.html index 5ecf3ef..90c8121 100644 --- a/index.html +++ b/index.html @@ -46,6 +46,14 @@ width: calc(100vw - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px)); height: calc(100vh - env(safe-area-inset-top, 0px)); height: calc(100dvh - env(safe-area-inset-top, 0px)); } + /* ...but NOT installed. Launched from the homescreen, WebKit does not initialise the DYNAMIC + viewport until the geometry is "exercised" — 100dvh stays provisional until you rotate the + phone, which is exactly the bug. Standalone has no toolbars, so 100vh is both correct and + stable from the first frame; dvh only exists for the browser's disappearing toolbars. */ + @media (display-mode: standalone) { + html { height: 100vh; } + canvas { height: calc(100vh - env(safe-area-inset-top, 0px)); } + } /* desktop key hint — up in the empty top-right corner now that the rack owns the bottom edge */ #hint { position: fixed; top: 6px; right: 10px; text-align: right; font: 400 10px Silkscreen, monospace; color: #6b7688; } @@ -89,6 +97,6 @@

- + diff --git a/main.js b/main.js index 950ea98..e405d4d 100644 --- a/main.js +++ b/main.js @@ -21,6 +21,17 @@ function frame(now) { } fitViewport(); +// iOS PWA cold start. Launched from the homescreen, WebKit resolves the viewport AND every env() +// safe-area inset once, against a provisional geometry, and never revisits them until the geometry +// is "exercised" — which is why the game only filled the screen after a rotate and back, and why +// waiting for an event could never work: nothing fires. Toggling viewport-fit off and on is the +// known WebKit workaround: it forces that recalculation without the user rotating anything. +// Standalone only, because that is the only mode with the bug. +if (navigator.standalone || matchMedia('(display-mode: standalone)').matches) { + const meta = document.querySelector('meta[name=viewport]'), real = meta.content; + meta.content = real.replace('viewport-fit=cover', 'viewport-fit=auto'); + requestAnimationFrame(() => { meta.content = real; requestAnimationFrame(fitViewport); }); +} setupPoint(); // warm the pixel HUD font so the canvas never paints a system-serif fallback frame if (document.fonts) { document.fonts.load('700 8px Silkscreen'); document.fonts.load('400 8px Silkscreen'); } diff --git a/render.js b/render.js index d90bc85..93936f6 100644 --- a/render.js +++ b/render.js @@ -633,6 +633,7 @@ function drawModal(m) { } // ------------------------------------------------------------------ frame ---- +const AD_H = 14; // px tall the courtside ad strip is drawn — also the ceiling for centred HUD text export function render() { ctx.setTransform(RES, 0, 0, RES, 0, 0); // logical W x H coordinates at RES density // ease the aim camera FIRST, before anything projects — one camera per frame (see proj) @@ -655,7 +656,7 @@ export function render() { // pinning to that projected line keeps the boards clear of the lawn in BOTH orientations // (a fixed baseline offset over-/under-shoots because camF differs portrait vs landscape). const standsBottom = proj(0, 0, -6).y; - const adH = 14, crowdH = standsBottom - adH + 2; + const crowdH = standsBottom - AD_H + 2; const crowdImg = SPR['x_crowd']; if (crowdImg) { // Applause is a real TWO-FRAME animation now: x_crowd is the seated sheet, x_crowd2 the same @@ -684,9 +685,9 @@ export function render() { // courtside advertising: tiled sponsor panels at native aspect (never half-cut text) const ads = SPR['x_ads']; if (ads) { - const tw = ads.width * (adH / ads.height); - for (let x = 0; x < W; x += tw) ctx.drawImage(ads, x, standsBottom - adH, tw, adH); - } else { ctx.fillStyle = pal.board; ctx.fillRect(0, standsBottom - adH, W, adH); } + const tw = ads.width * (AD_H / ads.height); + for (let x = 0; x < W; x += tw) ctx.drawImage(ads, x, standsBottom - AD_H, tw, AD_H); + } else { ctx.fillStyle = pal.board; ctx.fillRect(0, standsBottom - AD_H, W, AD_H); } // the rot dims the stadium: one tint over crowd + boards, fading home during duels // the rot dims the stadium — but 0.5 on top of an already-dark crowd sheet buried the stands in // splatter mode, where you spend most of the game. 0.26 still reads as "the lights went wrong". @@ -943,8 +944,14 @@ export function render() { G.phase === 'over' ? G.overT : 0, G.interlude ? G.interlude.introT : 0); if (waitT > 0.5) { - ctx.font = F(10); ctx.fillStyle = hexA(UI.warm, 0.85); - ctx.fillText('RESUMING IN ' + Math.ceil(waitT), W / 2, Math.round(H * 0.4)); // out in the open, not tucked under the HUD + // Kept clear of the ad strip — centred on H alone it landed right on the boards, which is the + // busiest band on the screen. The strip's own projected line is the ceiling, so it stays legible + // whatever the phone's height does to the layout. A 1px shadow, not a box, carries it over the crowd. + const txt = 'RESUMING IN ' + Math.ceil(waitT); + const y = Math.min(Math.round(H * 0.34), Math.round(proj(0, 0, -6).y) - AD_H - 16); + ctx.font = F(10); + ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillText(txt, W / 2 + 1, y + 1); + ctx.fillStyle = hexA(UI.warm, 0.92); ctx.fillText(txt, W / 2, y); } if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !(G.mgT > 0) && !G.aim && waitT <= 0.5) { // while pulling, the draw meter owns this slot ctx.fillStyle = UI.warm; ctx.font = F(8);