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:
1 parent
8cfe9fdbcd
commit
e75b3888de
6 files changed
+68
-26
No files matched your search
+25
-7
@@ -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') {
|
||||
|
||||
@@ -41,13 +41,21 @@ export function takeFlickAim(maxAgeMs = 450) {
|
||||
// (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.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(),
|
||||
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 });
|
||||
@@ -59,10 +67,12 @@ cv.addEventListener('touchmove', e => {
|
||||
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
|
||||
G.aim = dist > DEAD ? { fx: dx / dist, fy: dy / dist, mag: dist } : null;
|
||||
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;
|
||||
}
|
||||
@@ -74,10 +84,11 @@ const endTouch = e => {
|
||||
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);
|
||||
if (stick.aimable && stick.moved) { // two-stage release
|
||||
const aim = G.aim; G.aim = null;
|
||||
if (aim && dist > DEAD * 1.5) dragServe(aim); // on target — serve
|
||||
// else: dragged back home — cancel, the tin stays in stock
|
||||
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
|
||||
|
||||
@@ -14,9 +14,9 @@ import './input.js';
|
||||
|
||||
let last = performance.now();
|
||||
function frame(now) {
|
||||
requestAnimationFrame(frame); // schedule FIRST: one bad frame must never kill the loop (a thrown error froze the game for good)
|
||||
const dt = Math.min(0.033, (now - last) / 1000); last = now;
|
||||
update(dt); render();
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
fitViewport();
|
||||
|
||||
@@ -20,7 +20,8 @@ const hexLerp = (a, b, t) => {
|
||||
return `rgb(${Math.round(ar + (br - ar) * t)},${Math.round(ag + (bg - ag) * t)},${Math.round(ab + (bb - ab) * t)})`;
|
||||
};
|
||||
function palette() {
|
||||
const t = G.mode === 'tennis' ? 0 : G.mode === 'splatter' ? 1 : Math.min(1, G.transT / 6);
|
||||
// splatter lerps back toward the tennis palette while a duel runs — order, restored
|
||||
const t = G.mode === 'tennis' ? 0 : G.mode === 'splatter' ? 1 - G.duelLerp : Math.min(1, G.transT / 6);
|
||||
if (t === 0) return PAL.tennis;
|
||||
if (t === 1) return PAL.splatter;
|
||||
const m = {};
|
||||
@@ -302,7 +303,7 @@ export function render() {
|
||||
drawRing(ft.zt.x, ft.zt.z, '#ff5050');
|
||||
const hp = proj(ft.zt.x, 2.2, ft.zt.z);
|
||||
ctx.fillStyle = '#ff5050'; ctx.fillRect(hp.x - 1, hp.y - 4, 3, 3);
|
||||
} else drawRing(ft.tx, ft.tz, '#f8e048');
|
||||
} else if (ft.tx !== undefined) drawRing(ft.tx, ft.tz, '#f8e048');
|
||||
}
|
||||
|
||||
// far-side drawables in z order (far → near), then the net, then the near side
|
||||
@@ -401,7 +402,8 @@ export function render() {
|
||||
const touch = matchMedia('(pointer: coarse)').matches;
|
||||
const stock = G.bombs ? 'BOMB' : G.triples ? 'TRIPLE' : G.flames ? 'FIRE' : null;
|
||||
const txt = G.mode === 'splatter'
|
||||
? (stock ? `AIMED SERVES FIRE THE ${stock} · TAP KEEPS IT`
|
||||
? (G.aim ? (stock ? `RELEASE FIRES THE ${stock} · DRAG BACK TO CANCEL` : 'RELEASE TO SERVE · DRAG BACK TO CANCEL')
|
||||
: stock ? `AIMED SERVES FIRE THE ${stock} · TAP KEEPS IT`
|
||||
: touch ? 'TAP TO SERVE · HOLD + DRAG TO AIM' : 'SPACE SERVES · ARROWS AIM')
|
||||
: G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE')
|
||||
: (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE');
|
||||
@@ -411,6 +413,14 @@ export function render() {
|
||||
ctx.font = 'bold 14px monospace'; ctx.fillStyle = '#ffffff';
|
||||
ctx.fillText(G.flash.text, W / 2, 60 + Math.sin(G.flash.t * 20) * 1);
|
||||
}
|
||||
if (G.interlude && G.interlude.introT > 0) { // the duel card: a real pause, front and center
|
||||
ctx.fillStyle = 'rgba(10,6,8,0.66)'; ctx.fillRect(0, H / 2 - 36, W, 64);
|
||||
ctx.fillStyle = '#ff5050'; ctx.font = 'bold 18px monospace';
|
||||
ctx.fillText('SUDDEN DEATH', W / 2, H / 2 - 26);
|
||||
ctx.fillStyle = '#e8e8dc'; ctx.font = 'bold 8px monospace';
|
||||
ctx.fillText('ONE POINT · THE LINES ARE LIVE', W / 2, H / 2 - 2);
|
||||
ctx.fillText('WIN IT FOR THE NEXT TIN', W / 2, H / 2 + 12);
|
||||
}
|
||||
if (TUNE.missHud && G.lastMiss && G.idleT - G.lastMiss.at < 3.5) { // last miss reason, live
|
||||
ctx.font = 'bold 8px monospace'; ctx.fillStyle = '#ff9060';
|
||||
ctx.fillText(G.lastMiss.text.toUpperCase(), W / 2, H - 28);
|
||||
|
||||
@@ -41,7 +41,8 @@ export const G = {
|
||||
tinDue: false, // the rules called for new balls — deliver at the next changeover
|
||||
pendingGame: false, // a game just ended — flash 'GAME N' when the next one arms
|
||||
aim: null, // live two-stage serve aim {fx, fy, mag} while the thumb is down (splatter pretoss)
|
||||
interlude: false, // sudden-death point vs the racket zombie: tennis rules apply, shamblers spectate
|
||||
interlude: false, // sudden-death duel vs the racket zombie: falsy, or {introT} while announcing
|
||||
duelLerp: 0, // 0..1 — how far the court has returned to tennis colors for the duel
|
||||
horde: null, // active horde wave {t, spawnT, mgT} — machine-gun racket, no serving
|
||||
runGames: 0, // games finished this run (either side) — the difficulty director
|
||||
sets: [0, 0], // splatter sets won [you, them]; 6 games = a set = a horde
|
||||
|
||||
+8
-6
@@ -80,8 +80,9 @@ export function sweepCourt() {
|
||||
export function meleeSwipe(x, z, range) {
|
||||
let hit = 0;
|
||||
for (let i = zombies.length - 1; i >= 0; i--) {
|
||||
if (zombies[i].variant === 'r') continue;
|
||||
if (Math.hypot(zombies[i].x - x, zombies[i].z - z) < range) { killZombie(i, !!G.horde); hit++; }
|
||||
const zb = zombies[i];
|
||||
if (!zb || zb.variant === 'r') continue; // !zb: killZombie can sweep the court mid-loop
|
||||
if (Math.hypot(zb.x - x, zb.z - z) < range) { killZombie(i, !!G.horde); hit++; }
|
||||
}
|
||||
if (hit) swish(0.35);
|
||||
return hit;
|
||||
@@ -128,8 +129,8 @@ function tickHorde(dt) {
|
||||
// bomb ball: everything inside bombRadius splatters at once (each kill still ++ball.kills so
|
||||
// the Phase-2 fanfare escalates), plus a crater — big stain, size-3 pile, extra particles, boom.
|
||||
export function explode(x, z) {
|
||||
for (let i = zombies.length - 1; i >= 0; i--) // backwards: splatter() splices the array
|
||||
if (Math.hypot(zombies[i].x - x, zombies[i].z - z) < TUNE.bombRadius) splatter(i);
|
||||
for (let i = zombies.length - 1; i >= 0; i--) // backwards: splatter() splices (and can sweep) the array
|
||||
if (zombies[i] && Math.hypot(zombies[i].x - x, zombies[i].z - z) < TUNE.bombRadius) splatter(i);
|
||||
gore.push({ x, z, r: 1.6, a: 1, scorch: true }); if (gore.length > 40) gore.shift(); // the crater — near-permanent
|
||||
gore.push({ x, z, r: 1.4, a: 0.9 }); if (gore.length > 40) gore.shift();
|
||||
const pl = piles.find(p => Math.hypot(p.x - x, p.z - z) < 0.6);
|
||||
@@ -170,6 +171,7 @@ export function updateZombies(dt) {
|
||||
|
||||
for (let i = zombies.length - 1; i >= 0; i--) {
|
||||
const zb = zombies[i];
|
||||
if (!zb) continue; // a kill can cascade into sweepCourt (game won mid-loop) and gut the array under us
|
||||
zb.walkT += dt;
|
||||
zb.z += zb.v * dt;
|
||||
if (zb.shieldT > 0) zb.shieldT -= dt; // staggered chair comes back up when this runs out
|
||||
@@ -250,7 +252,7 @@ export function updateZombies(dt) {
|
||||
let hit = false;
|
||||
for (let j = zombies.length - 1; j >= 0; j--) {
|
||||
const zb = zombies[j];
|
||||
if (zb.variant === 'r') continue;
|
||||
if (!zb || zb.variant === 'r') continue;
|
||||
if (Math.hypot(zb.x - pe.x, zb.z - pe.z) < (zb.variant === 'g' ? 0.75 : 0.55)) {
|
||||
if (zb.variant === 'g' && --zb.hp > 0) pock(0.5, 260); // the brute soaks pellets too
|
||||
else killZombie(j, true);
|
||||
@@ -268,7 +270,7 @@ export function updateZombies(dt) {
|
||||
let dead = e.t <= 0 || Math.abs(e.x) > 7.5 || e.z < -2 || e.z > 25.5;
|
||||
if (!dead && e.y < 1.9) for (let j = zombies.length - 1; j >= 0; j--) {
|
||||
const zb = zombies[j];
|
||||
if (zb.variant === 'r') continue; // side balls pass the boss — only the match ball duels it
|
||||
if (!zb || zb.variant === 'r') continue; // side balls pass the boss; !zb: kills can sweep mid-loop
|
||||
if (Math.hypot(zb.x - e.x, zb.z - e.z) < (zb.variant === 'g' ? 0.75 : 0.55)) {
|
||||
if (zb.variant === 's' && e.vz < 0 && !(zb.shieldT > 0)) { zb.shieldT = TUNE.shieldDown; pock(0.9, 1800); } // chair blocks these too
|
||||
else if (e.pow >= TUNE.zSplatQ) {
|
||||
|
||||
Reference in new issue
Block a user