Pin the canvas height to what is actually visible, and three fixes
Viewport, again — the homescreen app was the case that broke it. Watching for a signal cannot work there: launched standalone, iOS resolves the CSS viewport once against a provisional box and never re-resolves it, so no resize, no layout change, nothing for a ResizeObserver to observe. Only a rotation forced the issue. So the CSS box is now a proposal: visualViewport says what is really visible and the height gets pinned in px to match, re-read on every fit — plus a short settle salvo after launch, since that launch announces nothing. - The countdown skipped the transition, where overT is parked at 9999 to hold the point flow. It read as RESUMING IN 9999 after the first game you ever win. - Countdown moved off the HUD line into the open, at a readable size. - A returned shot is a glancing blow now, not a mirror. Reversing the incoming line sent every parry straight back up it — into the thrower — so a boss handed you a free wound per volley. Contact across the racket face sets the angle: dead centre still goes back at him, which is a placement you earn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e8116c6abb
commit
9a4d921706
4 files changed
+44
-15
No files matched your search
@@ -84,6 +84,12 @@ export const TUNE = {
|
|||||||
parryArm: 3.4, // m — an incoming shot this close makes the next tap a SWING instead of a serve
|
parryArm: 3.4, // m — an incoming shot this close makes the next tap a SWING instead of a serve
|
||||||
parryReach: 1.7, // m — how far off the racket a return still connects (normal volley reach is 1.82)
|
parryReach: 1.7, // m — how far off the racket a return still connects (normal volley reach is 1.82)
|
||||||
parryWindow: 0.2, // s the return window stays open after the tap
|
parryWindow: 0.2, // s the return window stays open after the tap
|
||||||
|
// A return is a GLANCING blow: contact off the middle of the racket face throws it that many
|
||||||
|
// degrees off straight up-court. Returns used to mirror the incoming line — straight back at the
|
||||||
|
// thrower — so a boss handed you a free wound with every volley. Meeting it dead centre still
|
||||||
|
// sends it back at him; that is now a placement you earn by moving your feet.
|
||||||
|
parryDeflect: 55, // ° of deflection at the edge of the reach
|
||||||
|
parryJitter: 8, // ° of scatter on top — a bat-away is never surgical
|
||||||
// ---- chapter bosses: one every bossEvery games, escalating. The racket zombie is demoted to an
|
// ---- chapter bosses: one every bossEvery games, escalating. The racket zombie is demoted to an
|
||||||
// occasional opponent (racketEvery) so the boss is the only thing that reads as a wall.
|
// occasional opponent (racketEvery) so the boss is the only thing that reads as a wall.
|
||||||
bossEvery: 10, // games between chapter bosses
|
bossEvery: 10, // games between chapter bosses
|
||||||
|
|||||||
+1
-1
@@ -89,6 +89,6 @@
|
|||||||
<p id="build"></p>
|
<p id="build"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="main.js?v=52"></script>
|
<script type="module" src="main.js?v=53"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -78,7 +78,20 @@ function pageBackground(out) {
|
|||||||
document.documentElement.style.background = css;
|
document.documentElement.style.background = css;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The CSS box is only a PROPOSAL. Added to the homescreen, iOS resolves 100dvh once at launch against
|
||||||
|
// a provisional viewport and never re-resolves it — no resize, no layout change, so nothing (not even
|
||||||
|
// a ResizeObserver) fires and the canvas stays a strip short of the bottom until you rotate the phone
|
||||||
|
// and back. visualViewport is the authority on what is actually visible, so measure that and pin the
|
||||||
|
// height ourselves. Re-read on every fit, so a late safe-area inset or a rotation just re-pins it.
|
||||||
|
function fitBox() {
|
||||||
|
const el = document.getElementById('safe');
|
||||||
|
const top = el ? parseFloat(getComputedStyle(el).paddingTop) || 0 : 0; // the inset the canvas starts below
|
||||||
|
const want = Math.round((window.visualViewport?.height || innerHeight) - top);
|
||||||
|
if (want > 120 && Math.abs(cv.clientHeight - want) > 1) cv.style.height = want + 'px'; // sane, and only when it differs
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
fitBox();
|
||||||
const portrait = (cv.clientHeight || innerHeight) > (cv.clientWidth || 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.
|
||||||
@@ -119,15 +132,16 @@ export function fitViewport() { // landscape 480x270; portrait 270 wide and as T
|
|||||||
// inset in logical px so the rack can lift clear of the home indicator.
|
// inset in logical px so the rack can lift clear of the home indicator.
|
||||||
measureSafe((cv.clientWidth || innerWidth) / w);
|
measureSafe((cv.clientWidth || innerWidth) / w);
|
||||||
}
|
}
|
||||||
// The fit was correct but it ran exactly once, too early. iOS Safari lays the page out BEFORE its
|
// Every signal that the visible area may have changed, plus a short settle salvo — because the case
|
||||||
// toolbars settle, so the box the first fit measured was ~150 CSS px short — and no window `resize`
|
// that broke it (a homescreen launch) fires NO signal at all: the viewport iOS hands you at launch is
|
||||||
// follows, because the page never scrolls. The height only came right after a rotate and back, which
|
// simply wrong and is never corrected. fitViewport is idempotent (fitBox no-ops within 1px, and the
|
||||||
// IS a resize. Watching the canvas box itself covers every cause (bars settling, rotation, split
|
// logical size early-outs unless it really changed), so re-running it costs nothing.
|
||||||
// view, zoom) instead of guessing which event or delay to listen for — and the observer's initial
|
addEventListener('resize', fitViewport);
|
||||||
// callback is the real initialisation. fitViewport is idempotent, and it only changes the backing
|
addEventListener('orientationchange', fitViewport);
|
||||||
// store, never the CSS box, so this cannot feed itself.
|
addEventListener('pageshow', fitViewport); // the app was resumed from the bfcache
|
||||||
addEventListener('resize', fitViewport); // fallback for browsers without the observer
|
window.visualViewport?.addEventListener('resize', fitViewport); // the one iOS does fire when the bars move
|
||||||
new ResizeObserver(fitViewport).observe(cv); // the box itself is the signal, whatever moved it
|
new ResizeObserver(fitViewport).observe(cv); // the box moved, whatever moved it
|
||||||
|
for (const ms of [0, 120, 400, 1200]) setTimeout(fitViewport, ms); // ...and the launch that says nothing
|
||||||
|
|
||||||
// The aim camera: while the slingshot is drawn the view rises and leans back a little, which
|
// The aim camera: while the slingshot is drawn the view rises and leans back a little, which
|
||||||
// steepens the look down the court. That extra depth is what makes a shambler standing right
|
// steepens the look down the court. That extra depth is what makes a shambler standing right
|
||||||
@@ -922,13 +936,15 @@ export function render() {
|
|||||||
// Timed freezes swallow every input until they run out, which reads as the game having hung
|
// Timed freezes swallow every input until they run out, which reads as the game having hung
|
||||||
// ("sometimes the controls just feel frozen"). Show the clock instead of hiding it. A modal is
|
// ("sometimes the controls just feel frozen"). Show the clock instead of hiding it. A modal is
|
||||||
// excluded on purpose — that one waits for YOU, and says so.
|
// excluded on purpose — that one waits for YOU, and says so.
|
||||||
const waitT = G.modal || G.mode === 'eaten' ? 0
|
// 'transition' is excluded: startTransition parks overT at 9999 to hold the point flow while its
|
||||||
|
// own scripted timeline runs, and counting that down read as "resuming in 9999".
|
||||||
|
const waitT = G.modal || G.mode === 'eaten' || G.mode === 'transition' ? 0
|
||||||
: Math.max(G.changeover > 0 ? G.changeover : 0,
|
: Math.max(G.changeover > 0 ? G.changeover : 0,
|
||||||
G.phase === 'over' ? G.overT : 0,
|
G.phase === 'over' ? G.overT : 0,
|
||||||
G.interlude ? G.interlude.introT : 0);
|
G.interlude ? G.interlude.introT : 0);
|
||||||
if (waitT > 0.5) {
|
if (waitT > 0.5) {
|
||||||
ctx.font = F(7, 0); ctx.fillStyle = hexA(UI.warm, 0.8);
|
ctx.font = F(10); ctx.fillStyle = hexA(UI.warm, 0.85);
|
||||||
ctx.fillText('RESUMING IN ' + Math.ceil(waitT), W / 2, 78); // under the flash line, clear of the court and the man
|
ctx.fillText('RESUMING IN ' + Math.ceil(waitT), W / 2, Math.round(H * 0.4)); // out in the open, not tucked under the HUD
|
||||||
}
|
}
|
||||||
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
|
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);
|
ctx.fillStyle = UI.warm; ctx.font = F(8);
|
||||||
|
|||||||
+9
-2
@@ -608,9 +608,16 @@ export function updateZombies(dt) {
|
|||||||
if (!done && P.swingT >= 0 && P.swingT <= TUNE.parryWindow
|
if (!done && P.swingT >= 0 && P.swingT <= TUNE.parryWindow
|
||||||
&& dP < TUNE.parryReach && s.y < CONTACT.maxY) {
|
&& dP < TUNE.parryReach && s.y < CONTACT.maxY) {
|
||||||
// the parry: it becomes an `extras` clone — that array is already ballistic and already kills
|
// the parry: it becomes an `extras` clone — that array is already ballistic and already kills
|
||||||
// zombies, so "returned shot is a live weapon" costs nothing to build
|
// zombies, so "returned shot is a live weapon" costs nothing to build.
|
||||||
|
// It is a GLANCING blow, not a mirror. Reversing the incoming line sent every return straight
|
||||||
|
// back up the line it arrived on — which is where the thrower is standing — so a boss was a
|
||||||
|
// free wound per throw. Where you meet the ball across the racket face now sets the angle:
|
||||||
|
// hitting it back INTO him is a shot you have to place your feet for.
|
||||||
|
const off = Math.max(-1, Math.min(1, (s.x - P.x) / TUNE.parryReach)); // -1..1 across the face
|
||||||
|
const ang = (off * TUNE.parryDeflect + (Math.random() * 2 - 1) * TUNE.parryJitter) * Math.PI / 180;
|
||||||
|
const sp = Math.hypot(s.vx, s.vz) * 1.15;
|
||||||
extras.push({ x: s.x, y: s.y, z: s.z, g: TUNE.gravity, pow: TUNE.parryPow, t: 2.5,
|
extras.push({ x: s.x, y: s.y, z: s.z, g: TUNE.gravity, pow: TUNE.parryPow, t: 2.5,
|
||||||
vx: s.vx * 0.3, vy: 4.2, vz: -Math.abs(s.vz) * 1.15 });
|
vx: Math.sin(ang) * sp, vy: 4.2, vz: -Math.cos(ang) * sp });
|
||||||
pock(0.85, 1300); G.flash = { text: 'RETURNED', t: 0.6 }; cheer(0.8, 0.3);
|
pock(0.85, 1300); G.flash = { text: 'RETURNED', t: 0.6 }; cheer(0.8, 0.3);
|
||||||
done = true;
|
done = true;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user