PERFECT fired on almost every serve, and it was not a threshold that had drifted — it was the wrong test entirely. pullFrac CLAMPS at pullFull, so pullQuality is exactly 1.0 for any pull of 130 px or more, and 130 px is an ordinary comfortable thumb drag. "quality > 0.999" therefore meant "you pulled far enough", which is not an achievement and is why it read as noise. So hitServe takes `perfect` as its own argument now. The toss ritual keeps earning it the honest way, on the 45 ms perfectMs window. The slingshot earns it by STOPPING in a band at the top of the draw (pullFull..pullFull*perfectBand, 130-166 px) — a target you can miss, rather than a ceiling you cannot. The pull already clicks when it crosses the splatter threshold; it clicks brighter when it enters the band and goes silent when you pull past, so the window is learnable by feel instead of being invisible skill. And the word rotates: PERFECT, TEXTBOOK, IMMACULATE, OH LOVELY, UNPLAYABLE, SHOT OF THE DAY. Cycled rather than random — the same word twice running is what makes one read as a stuck HUD element rather than as praise. The umpire has still never acknowledged the massacre, so a clean shot gets the line it would get at Wimbledon. Also: the card band grew 72 -> 80 px, because the second sub line ended 4 px off the bottom edge and looked like it had fallen out. And the quiet face floats as much as the loud one does, so it gets the same 1 px shadow — the control hints, the gesture-demo labels, the card subs, the modal body and prompt, the death summary lines. The scoreboard and the rack keep theirs off: both already sit on dark panels, and a shadow under 6 px type on a flat fill only smears it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
159 lines
8.5 KiB
JavaScript
159 lines
8.5 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;
|
|
}
|
|
|
|
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
|
|
// 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 = 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());
|