The band work from f352612 rides along; everything else is this round's list.
- proj returns a NEGATIVE scale behind the camera, so four ellipse call sites
could be handed negative radii — ctx.ellipse throws on those, and the throw
killed the rest of the frame (no HUD, no rack, no band). All four now skip
points behind the camera.
- a burst tin is no longer a commitment: tapping another slot aborts it, spends
it and hands back the serve loop. Lives in selectWeapon, so the rack tap and
the 1-0 keys both get it. A horde still locks the rack — that is the one place
your finger is already spoken for. Spending splices the rack, so the tap
resolves to a weapon id and re-finds it rather than trusting the index.
- beacon 1.6 -> 2.4 rounds/s: a lob cannon you could not leave AND could not
feel working was two problems.
- FIRE. Flamer throws fat embers instead of a one-pixel thread; burning bodies
get nine tongues over a heat bloom plus smoke; the fire ball gets a halo and a
real tail; an ash death gets twice the motes at twice the life over a ground
bloom and a column. The flamer had no sfx case at all and was using the gun's
dry tick — it has its own roar now.
- chapter bosses carry an HP bar over their head. The no-HP-bar rule stands for
everything else and the comment says why a boss is the exception.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
173 lines
9.6 KiB
JavaScript
173 lines
9.6 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) { // 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();
|
|
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());
|