The zombie finger iOS never buries: a drag now digs the stick out from under it

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 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-10 21:06:45 +02:00
1 parent 482c7cdce4
commit 15cad26105
1 file changed
+24 -3
+24 -3
View File
@@ -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