A miss at the rack is a miss, not a shot

Tapping a tile and landing a thumb-width off it returned -1 — free court — and
the tap became Space, which instantly armed whatever burst weapon was selected.
Reaching for the flamer and missing by 10px fired the gun: 22px tiles, a second
row the thumb hasn't learned, and a selection that moves under you (a won duel
re-equips the tin it tops up). rackHit now reports a dead margin around the
band (-2), and only the TAP dies there, decided at touchend — a drag or hold
starting in the same spot still steers and still serves, so burst steering
parked low keeps working.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-10 21:28:37 +02:00
1 parent 15cad26105
commit 1c793f59a4
2 files changed
+20 -5

No files matched your search

+10 -3
View File
@@ -101,21 +101,28 @@ cv.addEventListener('touchstart', e => {
const r = cv.getBoundingClientRect(); const r = cv.getBoundingClientRect();
for (const t of e.changedTouches) { for (const t of e.changedTouches) {
if (dismissModal()) return; // the popup owns this gesture and nothing else sees it if (dismissModal()) return; // the popup owns this gesture and nothing else sees it
// A TAP that lands NEAR the rack but on no tile is a miss at a tile, not a shot — Space here
// instantly armed whatever burst weapon was selected ("selecting the flamer activated the
// gun"). Only the TAP dies, decided at touchend: a drag or hold starting in the same spot is
// a deliberate gesture (burst steering parks the thumb low, serve pulls start anywhere) and
// must keep working, so the stick is still created.
let nearRack = false;
if (!G.horde) { // rack tap selects a tin — a burst can be aborted out of, a horde cannot (selectWeapon) if (!G.horde) { // rack tap selects a tin — a burst can be aborted out of, a horde cannot (selectWeapon)
const slot = rackHit((t.clientX - r.left) / r.width, (t.clientY - r.top) / r.height); const slot = rackHit((t.clientX - r.left) / r.width, (t.clientY - r.top) / r.height);
if (slot >= 0) { selectWeapon(slot); continue; } if (slot >= 0) { selectWeapon(slot); continue; }
nearRack = slot === -2;
} }
if (!stick) { if (!stick) {
// `r` rides along: the canvas is position: fixed, so its rect only moves on viewport changes // `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 // — 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, stick = { id: t.identifier, x0: t.clientX, y0: t.clientY, t0: performance.now(), r,
seen: performance.now(), seen: performance.now(), nearRack,
moved: false, aimable: canAimServe() }; // context locked at touch start — a movement drag never morphs into a serve 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 // 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 }; if (stick.aimable) G.aim = { fx: 0, fy: 0, mag: 0 };
else steerPX = (t.clientX - r.left) / r.width * 2 - 1; else steerPX = (t.clientX - r.left) / r.width * 2 - 1;
} }
else { spare = { id: t.identifier, x0: t.clientX, y0: t.clientY }; keyPressed('Space'); } else { spare = { id: t.identifier, x0: t.clientX, y0: t.clientY }; if (!nearRack) keyPressed('Space'); }
} }
}, { passive: false }); }, { passive: false });
@@ -173,7 +180,7 @@ const endTouch = e => {
// (a bare hold used to fire at a free auto-target; that was the whole exploit) // (a bare hold used to fire at a free auto-target; that was the whole exploit)
if (aim && dist >= TUNE.pullMin) dragServe(aim); if (aim && dist >= TUNE.pullMin) dragServe(aim);
} }
else if (!stick.moved && dt < 250) keyPressed('Space'); // tap else if (!stick.moved && dt < 250) { if (!stick.nearRack) keyPressed('Space'); } // tap — dead if it was a near-miss at the rack
else if (!stick.adopted && dt < 300 && dist > 28) { // flick = aimed swing — a rescue drag is never a 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, flickAim = { ax: Math.abs(dx) > dist * 0.38 ? Math.sign(dx) : 0,
az: Math.abs(dy) > dist * 0.38 ? Math.sign(dy) : 0, az: Math.abs(dy) > dist * 0.38 ? Math.sign(dy) : 0,
+10 -2
View File
@@ -1183,13 +1183,21 @@ function rackGeom() {
const rows = rackRows(); const rows = rackRows();
return { x0: Math.round((W - rw) / 2), y0: rackTop(), tw, h, rows, n: rows * RACK_ROW, rw }; return { x0: Math.round((W - rw) / 2), y0: rackTop(), tw, h, rows, n: rows * RACK_ROW, rw };
} }
export function rackHit(nx, ny) { // normalized [0,1] canvas coords -> slot index, or -1 // A NEAR-MISS AT THE RACK IS DEAD, NOT A SHOT. A tap that lands a thumb-width off a tile used to
// return -1 — free court — and input turned it into Space, which INSTANTLY armed whatever burst
// weapon was selected. So reaching for the flamer and missing by 10px fired the gun: the exact
// "selecting X activated Y" report, made likely by 22px tiles, a second row the thumb hasn't
// learned yet, and a selection that can move under you (a won duel re-equips the tin it tops up).
const RACK_MISS = 18; // px of dead zone around the band: no selection, no swing, no burst
export function rackHit(nx, ny) { // normalized [0,1] canvas coords -> slot index; -1 free court, -2 dead near-miss
if (G.mode !== 'splatter' || G.interlude) return -1; // no rack during the tennis-rules duel if (G.mode !== 'splatter' || G.interlude) return -1; // no rack during the tennis-rules duel
const g = rackGeom(), cx = nx * W, cy = ny * H; const g = rackGeom(), cx = nx * W, cy = ny * H;
// The touch band is deliberately BIGGER than the art: up by RACK_GRAB and all the way down to // The touch band is deliberately BIGGER than the art: up by RACK_GRAB and all the way down to
// the screen edge, reclaiming the dead margin below. At 22px tall the tiles were ~30 CSS px on a // the screen edge, reclaiming the dead margin below. At 22px tall the tiles were ~30 CSS px on a
// phone — well under the 44px everyone's thumb expects — which is why switching tins felt fiddly. // phone — well under the 44px everyone's thumb expects — which is why switching tins felt fiddly.
if (cy < g.y0 - RACK_GRAB || cx < g.x0 || cx >= g.x0 + g.rw) return -1; if (cy < g.y0 - RACK_GRAB || cx < g.x0 || cx >= g.x0 + g.rw)
return cy >= g.y0 - RACK_GRAB - RACK_MISS && cx >= g.x0 - RACK_MISS && cx < g.x0 + g.rw + RACK_MISS
? -2 : -1;
// The FIRST five never move: they stay welded to the bottom edge and the second five stack above, // The FIRST five never move: they stay welded to the bottom edge and the second five stack above,
// so growing the rack doesn't slide the ball out from under a thumb that already knows where it is. // so growing the rack doesn't slide the ball out from under a thumb that already knows where it is.
// Clamped rather than rejected — the RACK_GRAB overhang sits above the top row. // Clamped rather than rejected — the RACK_GRAB overhang sits above the top row.