The menu stage was the same logo on the same backdrop with two buttons, so it read as the title screen failing to advance. The buttons move onto the title screen in place of the control list, which is the tutorial's job now. Death no longer reloads the page. The reload was an honest full reset, but it also tore down the AudioContext — so the theme restarted from silence every time you died, when it should carry straight on from the death summary into the title screen. restartRun() puts G, P, O, ball and score back from a structuredClone taken at module load (all plain data, so the clone is the whole job and a field added later is covered for free), and the two modules with timers of their own reset themselves: every zombies array plus spawnT/moanT/ mgTick, and input's held keys and half-finished drags, which would otherwise be inherited by the next run's first frame. The overlay is hidden rather than removed so it can come straight back, which needs its own [hidden] rule to beat the display:grid on #intro. The theme starts on the first tap and never restarts: loopBed's beds.has guard already makes every later menuMusic() a no-op. Verified: one background-music fetch across launch -> run -> death -> title -> second run, and a full second run through the transition after the in-place reset. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
142 lines
7.1 KiB
JavaScript
142 lines
7.1 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 } from './config.js';
|
|
import { G, score } from './state.js';
|
|
import { keyPressed, dragServe, selectWeapon, serveToss, pullQuality, dismissModal } 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.
|
|
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' && G.weapon !== 4 // GUN 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());
|