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:
1 parent
89667c4018
commit
90aefdce27
3 files changed
+46
-21
No files matched your search
+16
@@ -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
@@ -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>
|
||||||
@@ -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',
|
||||||
|
|||||||
Reference in new issue
Block a user