From 15cad26105c50a4df5cecf5644ca09853d1f8e87 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Mon, 10 Aug 2026 21:06:45 +0200 Subject: [PATCH] The zombie finger iOS never buries: a drag now digs the stick out from under it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dropStale trusted e.touches — but a finger stolen for a system edge gesture can stay listed there forever, so the stick survived and the player stayed welded to the edge (field-proven: escape taps still swished, meaning dropStale ran and found the id). The zombie is indistinguishable from a parked thumb by data, so it's told apart by intent: in the absolute-steering modes, a new finger that drags past the deadzone while the stick has been silent over a second inherits the stick. Taps still swish and steal nothing. Co-Authored-By: Claude Fable 5 --- input.js | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/input.js b/input.js index e6b9ec2..ba32e7f 100644 --- a/input.js +++ b/input.js @@ -64,7 +64,7 @@ export const touchPX = () => steerPX; export function resetInput() { for (const k in keys) delete keys[k]; touchAxis.x = touchAxis.z = 0; - stick = flickAim = steerPX = null; + stick = flickAim = steerPX = spare = null; } // A LOST TOUCHEND LOCKS THE GAME, part two. visibilitychange/blur (below) only catches the case @@ -80,6 +80,22 @@ const dropStale = e => { } }; +// A LOST TOUCHEND LOCKS THE GAME, part three. dropStale above trusts e.touches — but iOS can +// steal a finger for an edge gesture WITHOUT tearing the touch down, and then WebKit lists the +// dead touch in e.touches forever, so dropStale never fires. (Proven in the field: the player's +// escape taps still swished, so touchstart ran dropStale and still found the id listed.) By data +// the zombie is indistinguishable from a real thumb parked still at the edge; by INTENT it isn't: +// a welded player puts a new finger down and DRAGS. So remember the newest extra finger, and if +// it drags past the deadzone while the stick has been silent over a second in an auto-fire mode +// (the only modes that steer by absolute finger x), the drag takes the stick. A parked thumb +// plus a melee tap stays legit two-thumb play — a tap doesn't drag, so it steals nothing. +let spare = null; // {id, x0, y0} — the newest extra finger, candidate to inherit a zombie stick +const STALE_MS = 1000; +const stickStolen = t => + stick && spare && t.identifier === spare.id && (G.horde || G.mgT > 0) + && performance.now() - stick.seen > STALE_MS + && Math.hypot(t.clientX - spare.x0, t.clientY - spare.y0) > DEAD; + cv.addEventListener('touchstart', e => { e.preventDefault(); audio(); dropStale(e); const r = cv.getBoundingClientRect(); @@ -93,12 +109,13 @@ cv.addEventListener('touchstart', e => { // `r` rides along: the canvas is position: fixed, so its rect only moves on viewport changes // — re-measuring it per touchmove (60-120 Hz on iOS) was layout work for a constant answer stick = { id: t.identifier, x0: t.clientX, y0: t.clientY, t0: performance.now(), r, + seen: performance.now(), moved: false, aimable: canAimServe() }; // context locked at touch start — a movement drag never morphs into a serve // the reticle appears the moment the thumb lands, but mag 0 aims at nothing — you have to pull if (stick.aimable) G.aim = { fx: 0, fy: 0, mag: 0 }; else steerPX = (t.clientX - r.left) / r.width * 2 - 1; } - else keyPressed('Space'); + else { spare = { id: t.identifier, x0: t.clientX, y0: t.clientY }; keyPressed('Space'); } } }, { passive: false }); @@ -106,7 +123,11 @@ cv.addEventListener('touchmove', e => { e.preventDefault(); dropStale(e); // unpins the player as soon as any finger moves; the next touchstart takes the stick if (!stick) return; for (const t of e.changedTouches) { + if (stickStolen(t)) // the zombie stick loses to a finger that provably wants to steer + stick = { id: t.identifier, x0: t.clientX, y0: t.clientY, t0: performance.now(), + r: stick.r, seen: performance.now(), moved: true, adopted: true, aimable: false }; if (t.identifier !== stick.id) continue; + stick.seen = performance.now(); const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0, dist = Math.hypot(dx, dy); if (dist > DEAD) stick.moved = true; if (stick.aimable) { // slingshot: PULL BACK, launch forward — the shot is the inverse of the drag @@ -153,7 +174,7 @@ const endTouch = e => { if (aim && dist >= TUNE.pullMin) dragServe(aim); } else if (!stick.moved && dt < 250) keyPressed('Space'); // tap - else if (dt < 300 && dist > 28) { // flick = aimed swing + else if (!stick.adopted && dt < 300 && dist > 28) { // flick = aimed swing — a rescue drag is never a swing flickAim = { ax: Math.abs(dx) > dist * 0.38 ? Math.sign(dx) : 0, az: Math.abs(dy) > dist * 0.38 ? Math.sign(dy) : 0, fx: dx / dist, fy: dy / dist, mag: dist, // raw vector: splatter free-aim