Fix the freeze; duel intro card + tennis lights; hold-to-aim; instant gun steering

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>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-07-12 16:59:48 +02:00
1 parent 8cfe9fdbcd
commit e75b3888de
6 files changed
+69 -27

No files matched your search

+25 -7
View File
@@ -3,7 +3,8 @@
import { TUNE, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE } from './config.js';
import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } from './state.js';
import { audio, pock, swish, applause, voice } from './audio.js';
import { axisX, axisZ, takeFlickAim } from './input.js';
import { axisX, axisZ, takeFlickAim, touchPX } from './input.js';
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
import { updateZombies, spawnZombie, spawnRacketZombie, sweepCourt, zombies, explode, meleeSwipe, extras, parts } from './zombies.js';
// ------------------------------------------------------------- ballistics ----
@@ -25,6 +26,7 @@ export function predictLanding() { // first-bounce point, closed form — exact,
// flick drops it close, a long one reaches the far baseline. A zombie within zSnap of that
// point magnets the shot (with walk-lead); otherwise the ball lands exactly where you flicked.
export function flickTarget(ch, aim) { // exported: render draws the live reticle with it
if (!aim.mag) return { zt: zombieTarget(null) }; // pure hold, no direction yet: auto-target the most advanced
const R = Math.min(1, Math.max(0.12, aim.mag / 160)) * 22; // px flick length -> meters of reach
const tx = Math.max(-6.5, Math.min(6.5, ch.x + aim.fx * R));
const tz = Math.max(1, Math.min(24.5, ch.z + aim.fy * R)); // screen up = up-court (-z)
@@ -517,7 +519,7 @@ function racketAI(rz, dt) {
// is this serve's power; the toss ritual keeps the PERFECT-timing power ceiling)
export function dragServe(aim) {
if (G.mode !== 'splatter' || G.phase !== 'pretoss' || score.server !== 'P') return;
if (G.horde || G.mgT > 0 || G.changeover) return;
if (G.horde || G.mgT > 0 || G.changeover || (G.interlude && G.interlude.introT > 0)) return;
ball.active = true; ball.collected = false; ball.bounces = 0; ball.firstLand = null;
ball.x = P.x + 0.35; ball.z = P.z; ball.y = 2.2; ball.g = TUNE.gravity;
hitServe(P, 0.62, aim);
@@ -529,6 +531,7 @@ export function keyPressed(code) {
if (G.mode === 'splatter') meleeSwipe(P.x, P.z, TUNE.meleeRange); // a swish cleaves adjacent shamblers
if (G.horde || G.mgT > 0) return; // auto-fire modes: a tap only swishes, never serves
if (G.changeover) return; // the breather is mandatory — the melee swish above still works
if (G.interlude && G.interlude.introT > 0) return; // let the umpire finish announcing the duel
// splatter: a tap/flick during the brief 'over' beat re-arms the next serve immediately
if (G.mode === 'splatter' && G.phase === 'over') { ball.active = false; setupPoint(); }
if (G.phase === 'pretoss' && score.server === 'P') {
@@ -586,8 +589,20 @@ export function update(dt) {
const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8);
if (!G.rallyNear || d < G.rallyNear.d) G.rallyNear = { d, y: ball.y, bz: ball.z, px: P.x, pz: P.z };
}
// auto-fire steering is ABSOLUTE: the finger x IS the player x — zero lag, and clamped
// to what the camera can see (portrait's world bounds are wider than its viewport)
const mgMode = G.horde || G.mgT > 0;
const xVis = Math.min(P_BOUNDS.x, (W / 2 - 14) / (TUNE.camF / (TUNE.camZ - P.z)));
let mgSteer = false;
if (mgMode && touchPX() !== null) {
mgSteer = true;
const tx = Math.max(-xVis, Math.min(xVis, touchPX() * xVis));
if (Math.abs(tx - P.x) > 0.04) { P.facing = tx > P.x ? 1 : -1; P.runT += dt; P.pose = 'run'; }
else P.pose = 'idle';
P.x = tx;
}
const slow = P.swingT >= 0 ? 0.35 : 1;
const mx = axisX(), mz = axisZ();
const mx = mgSteer ? 0 : axisX(), mz = mgSteer ? 0 : axisZ();
P.x += mx * TUNE.playerSpeed * slow * dt;
P.z += mz * TUNE.playerSpeed * slow * dt;
let assist = 0;
@@ -603,11 +618,12 @@ export function update(dt) {
assist = dx;
}
}
P.x = Math.max(-P_BOUNDS.x, Math.min(P_BOUNDS.x, P.x));
const xLim = mgMode ? xVis : P_BOUNDS.x; // auto-fire keeps even keyboard players on screen
P.x = Math.max(-xLim, Math.min(xLim, P.x));
P.z = Math.max(P_BOUNDS.zMin, Math.min(P_BOUNDS.zMax, P.z));
const moving = mx || mz || assist !== 0;
P.runT += moving ? dt : 0;
if (P.pose !== 'swing' && P.pose !== 'serve') P.pose = moving ? 'run' : 'idle';
if (!mgSteer && P.pose !== 'swing' && P.pose !== 'serve') P.pose = moving ? 'run' : 'idle';
if (mx) P.facing = mx > 0 ? 1 : -1;
else if (Math.abs(assist) > 0.25) P.facing = assist > 0 ? 1 : -1; // hysteresis: small corrections don't flip facing
}
@@ -634,10 +650,12 @@ export function update(dt) {
if (G.mode === 'splatter' && !G.interlude && !G.horde && !G.changeover && !(G.mgT > 0)
&& G.phase === 'pretoss' && zombies.length === 1 && zombies[0].variant === 'r' && !zombies[0].dueled) {
zombies[0].dueled = true; // one duel per opponent — after that it just rallies
G.interlude = true;
G.interlude = { introT: 2.4 }; // the intro beat: serving blocked, centered card, colors return
voice('play_resume', 0.2); voice('sudden_death', 2.4);
G.flash = { text: 'SUDDEN DEATH', t: 1.8 };
}
if (G.interlude && G.interlude.introT > 0) G.interlude.introT -= dt;
// the duel is played under tennis lights: lerp the palette home while it runs, back after
G.duelLerp = Math.max(0, Math.min(1, G.duelLerp + (G.interlude ? dt : -dt) / 0.8));
// --- AI (the opponent only exists in tennis mode)
if (G.mode === 'tennis' && score.server === 'O' && G.phase === 'pretoss') {