Files
splatter-tennis/src/input.js
T
wuwiandClaude Opus 5.5 f9b0dd2efb Time a tap from touch-down, and give the opponents' legs a knee
Oliver after step 1: much harder, stuck at the Steward, "trotz gutem
Timing nicht mehr bekommen". A tap only proves it is a tap when the
finger lifts, so the swing started 100-150 ms after he meant it; the old
volley window hid that, step 1's groundstrokes did not. A tap made on the
gold ring fell from q 0.82 to 0.09 (and a third were missed at 150 ms),
murky balls landed short and the opponent came in. The swing clock now
starts at the touch's age (rally modes only); flicks keep their crossing.

The opponents' legs had no ceiling: 7.3 m/s at the Runner, 15.7 at the
Champion against the player's ~6.2. Past legKnee (6 m/s) only 15% of the
growth survives, monotone, so the ladder order holds; champion ~7.5. v362.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:06:08 +02:00

263 lines
17 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 { benchButtons, W, H } from './render.js'; // W/H are live bindings: fitViewport rewrites them
import { G, score } from './state.js';
import { keyPressed, dragServe, selectWeapon, serveToss, pullQuality, pullPerfect, dismissModal, slotId, dodge, rallyFeel } 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;
const FLICK_MS = 300; // a release later than this is a rescue drag, never a swing (see endTouch)
// 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 = spare = null;
G.swipe = 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; G.swipe = null;
}
};
// 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();
for (const t of e.changedTouches) {
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.
// THE BENCH'S OWN BUTTONS, tested before anything else — the bench is a back-room art screen and
// its taps mean only one thing. Arrow keys are not a control scheme on a phone, which is exactly
// the device the walk cycles have to be judged on. Geometry comes from render (benchButtons), so
// a moved button cannot stop responding — the rack learned that lesson first.
if (G.bench) {
const bx = (t.clientX - r.left) / r.width * W, by = (t.clientY - r.top) / r.height * H;
let hit = false;
for (const b of benchButtons()) {
if (bx >= b.x && bx <= b.x + b.w && by >= b.y && by <= b.y + b.h) {
keyPressed(bx < b.x + b.w / 2 ? b.k : b.k2); // left half steps back, right half forward
hit = true; break;
}
}
if (hit) continue;
}
let nearRack = false;
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; }
nearRack = slot === -2;
}
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,
seen: performance.now(), nearRack,
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 { spare = { id: t.identifier, x0: t.clientX, y0: t.clientY }; if (!nearRack) 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 (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
// 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;
// THE AUTO-RUN OWNS THE LEGS IN A RALLY, so the drag does not. Walking here was strictly worse
// than standing still: a manual axis SUPPRESSES the assist (the `!mx && !mz` gate in update),
// so holding a drag traded a full-speed run to the intercept point for a 0.35x shuffle that
// goes wherever your thumb went rather than where the ball will be — which is most of "i tend
// to not be able to reach the balls". The gesture keeps its one rally meaning: let go quickly
// and it is the aimed swing. Keys are untouched; a held arrow on a desktop is still an override.
// Gore keeps its drag-walk — there the assist only recovers depth and you hold your own lane.
const legs = !rallyFeel();
touchAxis.x = legs && Math.abs(dx) > DEAD ? Math.sign(dx) : 0;
touchAxis.z = legs && Math.abs(dy) > DEAD ? Math.sign(dy) : 0;
// THE FLICK FIRES THE MOMENT IT IS A FLICK (match + classic). The swing used to wait for the
// release, so the whole gesture ran as dead time on top of the 60 ms windup — and ~120 ms of
// lateness is all it takes to turn a perfectly aimed return into low-quality scatter. A LEFT
// flick is the slowest direction a right thumb has, and at the service line the opponent's
// return leaves only ~700-870 ms of budget, so "flick left" arrived at the window's tail or
// not at all (Oliver: "almost impossible"). Crossing flickMin already commits the gesture:
// the swing launches HERE, and the vector stays LIVE — trySwing reads G.swipe at contact, so
// the aim keeps following the thumb after the swing is airborne. The release deadline
// (FLICK_MS) is gone with it: a committed swipe that took 320 ms is a swing, not a rescue
// drag — that exception belongs to the finger-steering modes, which never reach this line.
// ax/az ride along so the live vector is drop-in for endTouch's flickAim shape.
// Rally modes only: gore's flick free-aims at a body, not at a lane.
G.swipe = rallyFeel() && dist > TUNE.flickMin
? { 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 } : null;
if (G.swipe && !stick.swung && G.phase === 'rally') { stick.swung = true; keyPressed('Space'); }
}
}, { 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) { if (!stick.nearRack) keyPressed('Space', dt / 1000); } // tap — dead if it was a near-miss at the rack; timed from touch-down (keyPressed)
else if (!stick.adopted && dist > TUNE.flickMin
&& (stick.swung || dt < FLICK_MS || (rallyFeel() && G.phase === 'rally'))) {
// flick = aimed swing. The FLICK_MS deadline only still applies where the swing could NOT
// fire at the crossing (gore's free-aim flick, the serve-phase strike) — there a late
// release stays a rescue drag, never a swing. The aim is recorded even when the crossing
// already swung: the ball may arrive AFTER the finger lifts, and takeFlickAim at contact
// is what carries the released vector to it.
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() };
if (!stick.swung) keyPressed('Space'); // the crossing already swung — never twice per gesture
}
stick = null; touchAxis.x = 0; touchAxis.z = 0; G.swipe = null;
}
};
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());