Researched it instead of guessing again. Launched from the homescreen, WebKit resolves the DYNAMIC viewport and every env() inset once against a provisional geometry and does not revisit them until the geometry is "exercised" — a rotation. That is the whole bug, and it also explains why listening for a signal could never fix it: nothing fires, so there is nothing to observe. Two known workarounds, both applied. Standalone has no toolbars, so 100vh is correct AND stable from the first frame — dvh only exists for the browser's disappearing toolbars, so it now applies only there. And toggling viewport-fit off and back on at boot forces WebKit to recalculate, without the user rotating anything. Countdown moved up off the ad boards — the strip's own projected line is its ceiling now, so it stays in the stands whatever the phone does to the layout, with a 1px shadow to carry it over the crowd. Sources: gist.github.com/fozzedout/5e77925381991a9570151550992baf14 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
82 lines
5.3 KiB
JavaScript
82 lines
5.3 KiB
JavaScript
// Splatter Tennis — boot, main loop, intro overlay, and the window.T test hooks.
|
|
// World units: meters. z axis = court depth, 0 at FAR baseline, 23.77 at NEAR baseline.
|
|
// Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update)
|
|
// render (camera/drawing) · input (keys/touch) · audio (sfx/umpire) · panel (T tuning UI)
|
|
|
|
import { TUNE, COURT, UMP_H, UMP_X, EATEN_RESTART } from './config.js';
|
|
import { P, O, ball, score, G, setupPoint } from './state.js';
|
|
import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight } from './gameplay.js';
|
|
import { fitViewport, render, proj, RES } from './render.js';
|
|
import { voice, ambient } from './audio.js';
|
|
import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, explode, hurtPlayer, killPlayer } from './zombies.js';
|
|
import { cv } from './dom.js';
|
|
import './input.js';
|
|
|
|
let last = performance.now();
|
|
function frame(now) {
|
|
requestAnimationFrame(frame); // schedule FIRST: one bad frame must never kill the loop (a thrown error froze the game for good)
|
|
if (TUNE.fps && now - last < 1000 / TUNE.fps - 2) return; // frame cap (battery): skip RAF ticks until it's our beat
|
|
const dt = Math.min(0.04, (now - last) / 1000); last = now;
|
|
update(dt); render();
|
|
}
|
|
|
|
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'); }
|
|
|
|
// intro overlay: first tap dismisses it, unlocks audio (iOS needs the gesture) and starts play
|
|
const intro = document.getElementById('intro');
|
|
function dismissIntro() {
|
|
if (!intro.parentNode) return;
|
|
intro.remove(); ambient(); voice('quiet_please'); // low atmosphere bed starts with the first gesture
|
|
}
|
|
intro.addEventListener('pointerdown', e => { e.preventDefault(); dismissIntro(); });
|
|
// which build am I looking at? Push = deploy and the browser cannot see git, so the served file's
|
|
// own timestamp IS the version marker — it changes on every deploy, with nothing to remember to bump.
|
|
// Match it against `git log -1 --format=%cd` when a phone looks suspiciously out of date.
|
|
fetch('main.js', { method: 'HEAD' })
|
|
.then(r => { const t = r.headers.get('last-modified'); if (!t) return;
|
|
document.getElementById('build').textContent = 'BUILD ' + new Date(t).toLocaleString('en-GB').replace(',', ''); })
|
|
.catch(() => {}); // no header, no line — never a broken start screen
|
|
addEventListener('keydown', () => dismissIntro(), { once: true });
|
|
|
|
// eaten: any tap or key restarts — but only once the death animation has played out, so a
|
|
// panic tap during the gore burst can't skip it. A modal owns its own gesture (see input.js).
|
|
const maybeRestart = () => { if (!G.modal && G.mode === 'eaten' && G.eatenT > EATEN_RESTART) location.reload(); }; // ponytail: reload = honest full reset
|
|
cv.addEventListener('pointerdown', maybeRestart);
|
|
addEventListener('keydown', maybeRestart);
|
|
|
|
// hidden shortcut: tap the umpire three times to summon zombie mode early
|
|
let umpTaps = [];
|
|
cv.addEventListener('pointerdown', e => {
|
|
const r = cv.getBoundingClientRect();
|
|
const x = (e.clientX - r.left) / r.width * (cv.width / RES); // logical coords
|
|
const y = (e.clientY - r.top) / r.height * (cv.height / RES);
|
|
if (x >= 6 && x <= 138 && y >= 6 && y <= 36) { G.logShow = !G.logShow; return; } // scoreboard tap = miss-log overlay
|
|
const u = proj(UMP_X, 0, COURT.netZ);
|
|
if (Math.abs(x - u.x) > 16 || y > u.y + 6 || y < u.y - UMP_H * u.s - 8) return;
|
|
const now = performance.now();
|
|
umpTaps = umpTaps.filter(t => now - t < 1600);
|
|
umpTaps.push(now);
|
|
if (umpTaps.length >= 3) { umpTaps = []; forceZombies(); }
|
|
});
|
|
|
|
requestAnimationFrame(frame);
|
|
|
|
// exposed for in-browser sanity checks and simulated playtests
|
|
window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight,
|
|
step: dt => update(dt), render, press: keyPressed,
|
|
get phase() { return G.phase; }, set phase(v) { G.phase = v; } };
|