// 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, dismissModal, slotId } 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 >= '1' && e.key <= '5') selectWeapon(+e.key - 1); // pick a rack slot 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 = () => { const w = WEAPONS[slotId(G.weapon)]; return !!w && (w.kind === 'burst' || w.kind === 'shot'); }; 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; } cv.addEventListener('touchstart', e => { e.preventDefault(); audio(); 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 && !(G.mgT > 0)) { // rack tap selects a tin — but never during finger-steer modes const slot = rackHit((t.clientX - r.left) / r.width, (t.clientY - r.top) / r.height); if (slot >= 0) { selectWeapon(slot); continue; } } if (!stick) { stick = { id: t.identifier, x0: t.clientX, y0: t.clientY, t0: 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'); } }, { passive: false }); cv.addEventListener('touchmove', e => { e.preventDefault(); 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 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); } } else G.aim = { fx: 0, fy: 0, mag: 0 }; continue; } const r = cv.getBoundingClientRect(); 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 }); // 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());