// 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; // ...timed, like the tap, from the moment the finger LANDED (gameplay.keyPressed's `lead`): the // timed gold ring means "touch now", and a flick only swings once it has travelled flickMin. // Capped at flickLead, so a slow drag still has a window to swing into (the left-flick lesson). if (G.swipe && !stick.swung && G.phase === 'rally') { stick.swung = true; keyPressed('Space', Math.min(TUNE.flickLead, (performance.now() - stick.t0) / 1000), true); } } }, { 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', Math.min(TUNE.flickLead, dt / 1000), true); // 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());