Files
splatter-tennis/input.js
T
wuwiandClaude Opus 5 f4c6f408e7 A stolen finger no longer welds you to the edge, and the stack is yours
The right edge is where iOS keeps Control Center, and it is exactly where the
horde's absolute finger-steering parks your thumb. When the system takes that
gesture it delivers neither touchend nor touchcancel: `stick` survives forever,
so every later touchstart falls through to the extra-finger branch (swish, and
only swish) while touchmove ignores the new finger's id. steerPX stays frozen at
0.97 and the player is pinned to the screen edge, melee firing, for the rest of
the run. Any new touch event proves the old finger is gone if it is not in
e.touches — drop the stick then. Verified against the bug: pinned at x=6.11
before, steers to -5.20 after, and a genuine second finger still only swishes.

And wCap 9 -> 99, which is no cap in practice: game 70 wants a bigger stack, not
a rationed one. The barrel weapons move from "paid out only once all four tins
are full" (a branch an uncapped rack would never reach again) to rungs of the
same ladder, so a late duel still hands out a rocket instead of a fifth bomb.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 15:35:53 +02:00

186 lines
11 KiB
JavaScript

// Keyboard + one-handed touch. Exposes movement axes and the flick-aim for gameplay.
// Note: input <-> gameplay is an intentional import cycle; both only call each other's
// functions at runtime, never during module evaluation.
import { cv } from './dom.js';
import { audio, pock } from './audio.js';
import { TUNE, WEAPONS } from './config.js';
import { G, score } from './state.js';
import { keyPressed, dragServe, selectWeapon, serveToss, pullQuality, pullPerfect, dismissModal, slotId, dodge } from './gameplay.js';
import { rackHit } from './render.js';
import { togglePanel } from './panel.js';
const keys = {};
addEventListener('keydown', e => {
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(e.key)) e.preventDefault();
if (dismissModal()) return; // a modal eats the whole keystroke — dismissing must never also serve
if (e.key === 't' || e.key === 'T') togglePanel();
if (e.key >= '0' && e.key <= '9') selectWeapon((+e.key + 9) % 10); // pick a rack slot: 1..9 then 0 = the tenth
if (!keys[e.code]) keyPressed(e.code);
keys[e.code] = true;
});
addEventListener('keyup', e => keys[e.code] = false);
const touchAxis = { x: 0, z: 0 };
export const axisX = () => touchAxis.x || (keys.ArrowRight || keys.KeyD ? 1 : 0) - (keys.ArrowLeft || keys.KeyA ? 1 : 0);
export const axisZ = () => touchAxis.z || (keys.ArrowUp || keys.KeyW ? -1 : 0) + (keys.ArrowDown || keys.KeyS ? 1 : 0);
// One-handed touch: hold-drag = run, quick FLICK = swing aimed in the flick direction,
// plain tap = neutral swing / toss / serve. Extra finger tap still swings (two-thumb play).
let stick = null; // {id, x0, y0, t0, moved}
let flickAim = null; // {ax, az, t} aim override for the swing just triggered
const DEAD = 14;
// gameplay consumes the flick at contact; returns null if none or expired
export function takeFlickAim(maxAgeMs = 450) {
if (flickAim && performance.now() - flickAim.t < maxAgeMs) {
const aim = flickAim; // carries ax/az (lanes, tennis) AND fx/fy/mag (splatter free-aim)
flickAim = null;
return aim;
}
return null;
}
// the two-stage serve: a drag that STARTS at a serveable pretoss aims a visible reticle
// (G.aim, drawn by render); release fires the serve there, dragging back home cancels.
// EVERY phase of splatter play, so the drag never silently turns into something else. The old
// "drag walks the player" mode between shots was invisible state and read as a bug.
// a weapon you UNLOAD (burst or shot) rather than serve — it takes the tap directly, so the
// slingshot reticle must stay out of its way. Was `G.weapon !== 4` back when slot 4 was always GUN.
const isGunLike = () => WEAPONS[slotId(G.weapon)]?.kind === 'burst'; // a laser is SERVED, so it keeps the reticle
const canAimServe = () =>
!G.modal // the world is frozen behind a popup — the thumb that clears it must not draw a bow
&& G.mode === 'splatter' // every phase of it; 'eaten' is its own mode and never gets here
&& score.server === 'P' && !isGunLike() // a gun-like weapon pops on tap, no reticle
&& !G.interlude && !G.horde && !(G.mgT > 0) && !G.changeover; // the duel serves like tennis: no reticle
let steerPX = null; // live absolute finger x (-1..1 across the canvas) — machine-gun steering reads it
export const touchPX = () => steerPX;
// Restarting a run no longer reloads the page (see state.resetState), so this module's own state has
// to be put back by hand: a key still held, or a half-finished drag, would otherwise be inherited by
// the next run's very first frame.
export function resetInput() {
for (const k in keys) delete keys[k];
touchAxis.x = touchAxis.z = 0;
stick = flickAim = steerPX = null;
}
// A LOST TOUCHEND LOCKS THE GAME, part two. visibilitychange/blur (below) only catches the case
// where the app goes away. The other one: a system edge gesture (Control Center on the right edge —
// exactly where the horde's absolute finger-steering parks your thumb) steals the finger and iOS
// delivers neither touchend nor touchcancel. `stick` then survives forever, so every later
// touchstart falls through to the extra-finger branch (swish, and only swish) while touchmove
// ignores the new finger's id — leaving steerPX frozen and the player welded to the screen edge.
// Any new touch event proves the old finger is gone if it isn't in e.touches. Drop it then.
const dropStale = e => {
if (stick && ![...e.touches].some(t => t.identifier === stick.id)) {
stick = null; touchAxis.x = touchAxis.z = 0; steerPX = null; G.aim = null;
}
};
cv.addEventListener('touchstart', e => {
e.preventDefault(); audio(); dropStale(e);
const r = cv.getBoundingClientRect();
for (const t of e.changedTouches) {
if (dismissModal()) return; // the popup owns this gesture and nothing else sees it
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);
if (slot >= 0) { selectWeapon(slot); continue; }
}
if (!stick) {
// `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,
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');
}
}, { passive: false });
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 (t.identifier !== stick.id) continue;
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
// WHICH WAY YOU LEFT THE DEADZONE DECIDES WHAT THE GESTURE IS, once, and it never changes its
// mind after. Down-ish is the pull the game already teaches; SIDEWAYS is the sidestep — the
// only way to move in the serve stance, which is where a whole boss fight is spent. No timing
// window and no invisible mode: the direction you moved is the thing you asked for.
if (dist > DEAD && !stick.tossed && !stick.dodged && Math.abs(dx) > Math.abs(dy) * 1.6) {
stick.dodged = true; G.aim = null; dodge(Math.sign(dx));
}
if (stick.dodged) continue; // spent: no toss, no reticle, and nothing fires on release
if (dist > DEAD) {
if (!stick.tossed) { serveToss(); stick.tossed = true; } // the pull kicks off the toss animation
G.aim = { fx: -dx / dist, fy: -dy / dist, mag: dist };
// the moment the pull is strong enough to actually splatter, it clicks — you can feel
// where the threshold is without watching the reticle
const hot = pullQuality(dist) >= TUNE.zSplatQ;
if (hot !== !!stick.hot) { stick.hot = hot; if (hot) pock(0.45, 1250); }
// ...and a second, brighter click at the top of the draw, where the shot is praised. It
// goes SILENT again when you pull past the band, so overshooting is something you hear.
// Without this the perfect window would be invisible skill — a target nothing marks.
const sweet = pullPerfect(dist);
if (sweet !== !!stick.sweet) { stick.sweet = sweet; if (sweet) pock(0.5, 1950); }
} else G.aim = { fx: 0, fy: 0, mag: 0 };
continue;
}
const r = stick.r;
steerPX = (t.clientX - r.left) / r.width * 2 - 1;
touchAxis.x = Math.abs(dx) > DEAD ? Math.sign(dx) : 0;
touchAxis.z = Math.abs(dy) > DEAD ? Math.sign(dy) : 0;
}
}, { passive: false });
const endTouch = e => {
e.preventDefault();
for (const t of e.changedTouches) {
if (!stick || t.identifier !== stick.id) continue;
const dt = performance.now() - stick.t0;
const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0, dist = Math.hypot(dx, dy);
const aim = G.aim; G.aim = null; steerPX = null;
if (stick.aimable && stick.moved) { // slingshot release
// only a real pull fires — walked back under pullMin, it cancels and nothing is spent.
// (a bare hold used to fire at a free auto-target; that was the whole exploit)
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
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
t: performance.now() };
keyPressed('Space');
}
stick = null; touchAxis.x = 0; touchAxis.z = 0;
}
};
cv.addEventListener('touchend', endTouch, { passive: false });
cv.addEventListener('touchcancel', endTouch, { passive: false });
// A DRAG THAT NEVER ENDS LOCKS THE GAME. `stick` and `G.aim` are only cleared by the touchend for
// the matching identifier — and that event is not guaranteed: switch apps, pull the notification
// shade, take a call, and iOS tears the touch down without always delivering touchend OR
// touchcancel. What is left behind is a held slingshot: `G.aim` set forever, which keeps the
// uncaught-toss retoss from firing (see the `!G.aim` guard in update) and leaves the player in the
// toss phase with the ball on the floor, unable to serve or step. Dropping the gesture whenever the
// page stops being visible costs nothing — you cannot be mid-pull while looking at another app.
const dropGesture = () => { resetInput(); G.aim = null; };
addEventListener('visibilitychange', () => { if (document.hidden) dropGesture(); });
addEventListener('blur', dropGesture);
addEventListener('pagehide', dropGesture);
// Mouse/desktop dismissal. Registered ONLY on fine pointers: on a touch screen pointerdown
// fires before touchstart, so a shared handler would clear the modal and let the touchstart
// below it fall through into a serve — exactly the swallowed-gesture bug.
if (!matchMedia('(pointer: coarse)').matches)
cv.addEventListener('pointerdown', () => dismissModal());