THE FREEZE (root cause found): the game-winning kill fires INSIDE the zombie update loop — killZombie -> splatPoint -> gameOver -> sweepCourt guts the zombies array mid-iteration, the loop reads zombies[i] === undefined, throws, and the un-rescheduled RAF chain dies: a permanent hard freeze. Crowded courts (later games, brutes) make the mid-loop game-winner likelier — hence 'freezes around game 3 when the fat zombies arrive'. Fixed at the root (null guards in every loop whose body can cascade into a sweep: zombie walk loop, meleeSwipe, explode, pellet + extras inner loops) AND made frame() reschedule RAF FIRST so no single bad frame can ever kill the loop again. Aim awareness: the reticle now appears the moment the thumb lands (mag 0 = auto-target the most advanced zombie, red lock immediately). A pure HOLD (>250ms, no drag) releases into the auto-target serve; dragging aims as before; the serve prompt switches to 'RELEASE TO SERVE / RELEASE FIRES THE BOMB - DRAG BACK TO CANCEL' while aiming. Duel presentation: the interlude now opens with a 2.4s intro beat — serving blocked, centered card (SUDDEN DEATH / ONE POINT - THE LINES ARE LIVE / WIN IT FOR THE NEXT TIN), and the palette lerps back to the TENNIS colors for the duration of the duel (duelLerp, 0.8s each way) — order visibly restored, then the rot returns. Machine-gun steering: absolute now — the finger x IS the player x, zero lag (was sign-quantized velocity). Clamped to what the camera can see ((W/2-14)/proj-scale — portrait's world bounds are wider than its viewport), keyboard steering clamped the same during auto-fire. Verified: the exact pre-fix crash scenario (game-winning kill mid-loop, 9 zombies + brute) survives and sweeps clean; intro blocks taps then the duel plays; hold-release fired at the auto-lock and won a tin end-to-end; screenshot shows tennis-green court + centered card + GAME 2; MG jumps P.x to the finger in one frame, clamped; tennis 12/12; no console errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
106 lines
4.9 KiB
JavaScript
106 lines
4.9 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 } from './audio.js';
|
|
import { G, score } from './state.js';
|
|
import { keyPressed, dragServe } from './gameplay.js';
|
|
import { togglePanel } from './panel.js';
|
|
|
|
const keys = {};
|
|
addEventListener('keydown', e => {
|
|
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(e.key)) e.preventDefault();
|
|
if (e.key === 't' || e.key === 'T') togglePanel();
|
|
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.
|
|
const canAimServe = () =>
|
|
G.mode === 'splatter' && G.phase === 'pretoss' && score.server === 'P'
|
|
&& !G.horde && !(G.mgT > 0) && !G.changeover && !(G.interlude && G.interlude.introT > 0);
|
|
|
|
let steerPX = null; // live absolute finger x (-1..1 across the canvas) — machine-gun steering reads it
|
|
export const touchPX = () => steerPX;
|
|
|
|
cv.addEventListener('touchstart', e => {
|
|
e.preventDefault(); audio();
|
|
for (const t of e.changedTouches) {
|
|
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: mag 0 = auto-target the most advanced
|
|
if (stick.aimable) G.aim = { fx: 0, fy: 0, mag: 0 };
|
|
else { const r = cv.getBoundingClientRect(); 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) { // aiming, not steering: the reticle tracks the thumb (home = auto-target)
|
|
G.aim = dist > DEAD ? { fx: dx / dist, fy: dy / dist, mag: dist } : { 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 || dt >= 250)) { // two-stage release
|
|
// a pure HOLD (never moved) fires at the auto-target; a drag fires at the reticle;
|
|
// a drag walked back home cancels — the tin stays in stock
|
|
if (aim && (!stick.moved || dist > DEAD * 1.5)) 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 });
|