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>
306 lines
16 KiB
JavaScript
306 lines
16 KiB
JavaScript
// The splatter phase: shamblers, gore decals, particles. The ball is the weapon;
|
||
// tennis scoring keeps running via gameplay.splatPoint. Zombies advance toward
|
||
// your baseline — one crossing it is a point for THEM.
|
||
// (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.)
|
||
|
||
import { TUNE, COURT } from './config.js';
|
||
import { G, P, ball, sendBoy, setupPoint } from './state.js';
|
||
import { splat, moan, pock, voice, boom, swish, shriek } from './audio.js';
|
||
import { splatPoint, cheer, spentBall, resolveInterlude } from './gameplay.js';
|
||
|
||
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
|
||
export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer
|
||
export const piles = []; // 3D-ish gore mounds {x, z, size 1..4, seed} — grow where kills cluster
|
||
export const parts = []; // splatter particles {x, y, z, vx, vy, vz, t, dark}
|
||
export const pellets = []; // machine-gun rounds during a horde {x, y, z, vx, vz, t}
|
||
export const extras = []; // triple-serve side balls {x, y, z, vx, vy, vz, pow, t} — ballistic clones
|
||
|
||
let spawnT = 1.5, moanT = 3;
|
||
|
||
export function spawnZombie(x, z, variant) {
|
||
const ramp = 1 + Math.min(1.5, G.splatT / 70); // they get faster the longer you survive
|
||
const zb = { x, z, v: TUNE.zSpeed * ramp * (0.8 + Math.random() * 0.4), walkT: Math.random() * 9, variant };
|
||
if (variant === 'f') { zb.v *= TUNE.runnerSpeed; shriek(); } // the shriek is the fairness contract
|
||
if (variant === 'g') { zb.v *= 0.55; zb.hp = TUNE.bruteHp; } // slow, but soaks hits
|
||
if (variant === 's') zb.shieldT = 0; // chair up; > 0 = seconds of stagger left
|
||
zombies.push(zb);
|
||
}
|
||
|
||
// which shambler steps out next: the roster grows one type per set (runner: set 1 game 3+,
|
||
// brute: set 2, shield: set 3). Fixed random slots so the classics stay the crowd.
|
||
function pickVariant() {
|
||
const tier = G.sets[0] + G.sets[1], r = Math.random();
|
||
if (tier >= 2 && r < 0.15) return 's';
|
||
if (tier >= 1 && r >= 0.15 && r < 0.30) return 'g';
|
||
if ((tier >= 1 || G.runGames >= 2) && r >= 0.30 && r < 0.42
|
||
&& !zombies.some(z => z.variant === 'f')) return 'f'; // at most one runner alive
|
||
return Math.random() < 0.5 ? 'a' : 'b';
|
||
}
|
||
|
||
// core kill: remove the zombie, spawn its gore/pile/particles, score. No ball coupling —
|
||
// shared by ball hits, machine-gun pellets, melee swipes, and extra (multi-serve) balls.
|
||
function killZombie(i, quiet) {
|
||
const zb = zombies[i];
|
||
const big = zb.variant === 'g'; // a brute goes down like a building — twice the gore
|
||
zombies.splice(i, 1);
|
||
if (G.interlude && zb.variant === 'r') resolveInterlude('P'); // regicide ends the duel your way — any weapon counts
|
||
gore.push({ x: zb.x, z: zb.z, r: (big ? 0.8 : 0.45) + Math.random() * 0.35, a: 0.85 });
|
||
if (gore.length > 40) gore.shift();
|
||
const pl = piles.find(p => Math.hypot(p.x - zb.x, p.z - zb.z) < 0.6); // stack up where kills cluster
|
||
if (pl) pl.size = Math.min(4, pl.size + (big ? 2 : 1));
|
||
else { piles.push({ x: zb.x, z: zb.z, size: big ? 2 : 1, seed: Math.random() * 1e4, blobs: null, builtSize: 0 });
|
||
if (piles.length > 30) piles.shift(); }
|
||
const n = big ? 48 : 24;
|
||
for (let k = 0; k < n; k++) { const dark = k >= n * 2 / 3; parts.push({ // ~1/3 dark & long-lived: settling gore
|
||
x: zb.x, y: 0.9, z: zb.z,
|
||
vx: (Math.random() * 2 - 1) * 3.5, vy: 1 + Math.random() * 3.5, vz: (Math.random() * 2 - 1) * 3.5,
|
||
t: dark ? 0.6 + Math.random() * 0.6 : 0.4 + Math.random() * 0.4, dark,
|
||
}); }
|
||
splat();
|
||
sendBoy(zb.x, zb.z); // a boy sprints out to mop
|
||
if (!G.horde) splatPoint('P', quiet); // a horde is survival — it doesn't score, so it can't chain into itself
|
||
}
|
||
|
||
// game over: the surviving wave collapses where it stands — no points, no piles, just bodies.
|
||
// This is what makes a game END visibly instead of bleeding into the next one.
|
||
export function sweepCourt() {
|
||
let n = 0;
|
||
for (let i = zombies.length - 1; i >= 0; i--) {
|
||
const zb = zombies[i];
|
||
if (zb.variant === 'r') continue; // the opponent keeps its residence
|
||
zombies.splice(i, 1); n++;
|
||
for (let k = 0; k < 6; k++) parts.push({
|
||
x: zb.x, y: 0.5, z: zb.z, t: 0.4, dark: k % 2 === 0,
|
||
vx: (Math.random() * 2 - 1) * 1.5, vy: 0.4 + Math.random(), vz: (Math.random() * 2 - 1) * 1.5 });
|
||
}
|
||
if (n) splat(0.4);
|
||
}
|
||
|
||
// swishing the racket cleaves any shambler in reach (the racket-zombie boss shrugs melee off)
|
||
export function meleeSwipe(x, z, range) {
|
||
let hit = 0;
|
||
for (let i = zombies.length - 1; i >= 0; i--) {
|
||
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;
|
||
}
|
||
|
||
function splatter(i, fanfare = true) { // the MAIN ball hit a zombie
|
||
killZombie(i, !!G.horde);
|
||
ball.vx *= TUNE.zPierce; ball.vz *= TUNE.zPierce; // keep enough pace to plow through the next
|
||
if (!fanfare) return; // machine-gun pellets splatter but skip the streak fanfare / bomb meter
|
||
ball.kills++; // this shot's running streak
|
||
// escalating multi-kill fanfare — one shot, ≥2 zombies
|
||
if (ball.kills === 2) { G.flash = { text: 'DOUBLE KILL', t: 0.9 }; cheer(1.6, 0.45); voice('double_kill', 0.4); }
|
||
else if (ball.kills === 3) { G.flash = { text: 'TRIPLE KILL', t: 0.9 }; cheer(2.2, 0.6); voice('triple_kill', 0.4); }
|
||
else if (ball.kills >= 4) { G.flash = { text: `MASSACRE ×${ball.kills}`, t: 0.9 }; cheer(2.8, 0.7); voice('unbelievable', 0.4); }
|
||
}
|
||
|
||
// ---- horde wave: dense spawns + an auto-firing machine-gun racket (pellets, not the match ball)
|
||
function spawnHordeZombie() { // all from the far baseline, across the court — never off-screen at the sides
|
||
spawnZombie((Math.random() * 2 - 1) * 5, -1.5 - Math.random(), Math.random() < 0.5 ? 'a' : 'b');
|
||
}
|
||
function firePellet() {
|
||
const a = (Math.random() * 2 - 1) * 4 * Math.PI / 180, s = 26; // ±4° spread, straight up-court (-z)
|
||
pellets.push({ x: P.x, y: 0.9, z: P.z, vx: Math.sin(a) * s, vz: -Math.cos(a) * s, t: 1.2 });
|
||
pock(0.25, 1400); // rapid fire tick; if it clips on device, gate to every 2nd pellet
|
||
}
|
||
export function spawnRacketZombie() { // one takes up residence on the far court — an opponent, of sorts
|
||
zombies.push({ x: (Math.random() * 2 - 1) * 2.5, z: 3.5, v: 0, walkT: Math.random() * 9,
|
||
variant: 'r', side: 'O', facing: 1, pose: 'idle', poseT: 0, swingT: -1, swingCool: 0, runT: 0, hp: TUNE.racketHp });
|
||
}
|
||
function endHorde() {
|
||
G.horde = null; pellets.length = 0;
|
||
if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // survive the wave → a racket zombie stays
|
||
setupPoint(); // serve loop resumes; any zombies still standing are yours to mop up
|
||
voice('quiet_dark', 0.5, 0.85);
|
||
}
|
||
function tickHorde(dt) {
|
||
const h = G.horde;
|
||
h.t -= dt;
|
||
if ((h.spawnT -= dt) <= 0) { spawnHordeZombie(); h.spawnT = 1 / TUNE.hordeRate; }
|
||
if ((h.mgT -= dt) <= 0) { firePellet(); h.mgT = 1 / TUNE.mgRate; }
|
||
if (h.t <= 0) endHorde();
|
||
}
|
||
|
||
// 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 (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);
|
||
if (pl) pl.size = Math.min(4, pl.size + 3);
|
||
else { piles.push({ x, z, size: 3, seed: Math.random() * 1e4, blobs: null, builtSize: 0 }); if (piles.length > 30) piles.shift(); }
|
||
for (let k = 0; k < 40; k++) parts.push({
|
||
x, y: 0.6, z, vx: (Math.random() * 2 - 1) * 6, vy: 1 + Math.random() * 5, vz: (Math.random() * 2 - 1) * 6,
|
||
t: 0.5 + Math.random() * 0.7, dark: k % 2 === 0,
|
||
});
|
||
G.hitstop = 0.18; boom();
|
||
G.shake = { t: 0.35, mag: 6 }; // camera kick
|
||
G.boomFlash = 0.08; // one white frame
|
||
if (G.idleT - G.lastOrderT > 3) { voice('order', 0.35); G.lastOrderT = G.idleT; } // the umpire loses composure
|
||
}
|
||
|
||
export function updateZombies(dt) {
|
||
moanT -= dt;
|
||
if (moanT <= 0) { moan(); moanT = 2.5 + Math.random() * 4; }
|
||
|
||
if (G.mode === 'splatter') { // transition spawns are scripted, not timed
|
||
G.splatT += dt;
|
||
if (G.horde) tickHorde(dt); // horde: dense spawns + machine gun, ignores the cap
|
||
else if (G.mgT > 0 && G.phase === 'rally') { // machine-gun tin: horde firepower, regular rules
|
||
G.mgT -= dt;
|
||
if ((G.mgCool -= dt) <= 0) { firePellet(); G.mgCool = 1 / TUNE.mgRate; }
|
||
if (G.mgT <= 0) setupPoint(); // ammo dry — the serve loop resumes
|
||
}
|
||
if (!G.horde && !G.changeover && !G.interlude) { // breathers and duels get a quiet court
|
||
spawnT -= dt;
|
||
// the spawn director: the match's age (games finished) sets both crowd size and tempo
|
||
const cap = Math.min(TUNE.zMaxCap, TUNE.zMax + G.runGames);
|
||
if (spawnT <= 0 && zombies.length < cap) {
|
||
spawnZombie((Math.random() * 2 - 1) * 4.8, -1.5 - Math.random(), pickVariant());
|
||
spawnT = Math.max(TUNE.zSpawnMin, TUNE.zSpawnEvery - G.runGames * TUNE.zSpawnStep);
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
if (zb.variant === 'r') { /* the racket boss holds station — its AI owns its x */ }
|
||
else if (G.mode === 'splatter' && zb.z > COURT.netZ) // on YOUR half they home in on you
|
||
zb.x += Math.sign(P.x - zb.x) * Math.min(Math.abs(P.x - zb.x), zb.v * 0.7 * dt);
|
||
else
|
||
zb.x += Math.sin(zb.walkT * 1.7) * 0.15 * dt; // far half: aimless shamble
|
||
if (G.mode === 'splatter' && Math.hypot(zb.x - P.x, zb.z - P.z) < TUNE.eatenR + (zb.variant === 'g' ? 0.2 : 0)) { // it got you — touch is HONEST now: sprite overlap = eaten
|
||
gore.push({ x: P.x, z: P.z, r: 0.9, a: 0.9 });
|
||
for (let k = 0; k < 30; k++) parts.push({
|
||
x: P.x, y: 1.1, z: P.z,
|
||
vx: (Math.random() * 2 - 1) * 4.5, vy: 1 + Math.random() * 4, vz: (Math.random() * 2 - 1) * 4.5,
|
||
t: 0.5 + Math.random() * 0.5,
|
||
});
|
||
splat(1);
|
||
voice('game_set_massacre', 0.8);
|
||
G.mode = 'eaten'; ball.active = false; G.flash = null; // zombies keep shambling for ambience
|
||
G.horde = null; pellets.length = 0; extras.length = 0; G.mgT = 0; // everything stops; the eaten screen takes over
|
||
try { // the run is over — settle it against the best (games survived, sets as tiebreak)
|
||
const b = JSON.parse(localStorage.splatterBest || 'null') || { games: 0, sets: 0 };
|
||
if (G.runGames > b.games) { b.games = G.runGames; b.sets = G.sets[0]; localStorage.splatterBest = JSON.stringify(b); }
|
||
G.best = b;
|
||
} catch (e) { G.best = null; } // private mode etc. — the summary just skips the best line
|
||
}
|
||
if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) {
|
||
const d = Math.hypot(ball.x - zb.x, ball.z - zb.z);
|
||
if (d < (zb.variant === 'g' ? 0.75 : 0.55)) { // the brute is a bigger target
|
||
if (ball.bomb) { // detonate on first contact, whatever the pace
|
||
explode(ball.x, ball.z); ball.bomb = false;
|
||
spentBall(); break; // zombies mutated — stop this frame's loop
|
||
}
|
||
if (ball.flame) { // burns through anything it touches — no quality gate, no chair, no pace tax
|
||
if (zb.variant === 'r') { // the boss only takes a wound
|
||
zb.hp--; splat(0.5);
|
||
if (zb.hp <= 0) { splatter(i); } continue;
|
||
}
|
||
const vx0 = ball.vx, vz0 = ball.vz;
|
||
splatter(i); ball.vx = vx0; ball.vz = vz0; // undo splatter's pierce tax: full pace through the lane
|
||
continue;
|
||
}
|
||
if (zb.variant === 'r') { // mini-boss: splat-quality wounds it; soft balls it rallies (racketAI)
|
||
if ((ball.pow || 0) >= TUNE.zSplatQ) {
|
||
zb.hp--; splat(0.5); pock(0.6, 300);
|
||
ball.vx *= 0.3; ball.vz *= 0.3; ball.lastHit = 'X'; // spent on the hit
|
||
if (zb.hp <= 0) { splatter(i); continue; } // killing blow: full gore + point
|
||
}
|
||
continue; // soft balls pass through, for the AI to return
|
||
}
|
||
if (zb.variant === 'g') { // brute: soaks splat-quality hits, never staggers back
|
||
if ((ball.pow || 0) >= TUNE.zSplatQ) {
|
||
zb.hp--; splat(0.5); pock(0.6, 220);
|
||
ball.vx *= 0.3; ball.vz *= 0.3; ball.lastHit = 'X';
|
||
if (zb.hp <= 0) { splatter(i); continue; } // timber
|
||
} else { pock(0.6, 300); ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; } // soft ball bounces off
|
||
continue;
|
||
}
|
||
if (zb.variant === 's' && ball.vz < 0 && !(zb.shieldT > 0)) { // chair up, ball at its face: BLOCKED
|
||
zb.shieldT = TUNE.shieldDown; // ...but the block staggers it — the chair drops, kill window opens
|
||
zb.z -= TUNE.zPushback * 0.5;
|
||
pock(0.9, 1800); // wooden clank
|
||
ball.vx *= 0.4; ball.vz *= -0.2; ball.lastHit = 'X'; // ball caroms off the chair, spent
|
||
continue;
|
||
}
|
||
// chair-down shield zombies (and anything hit from behind) obey the normal rules:
|
||
if ((ball.pow || 0) >= TUNE.zSplatQ) { splatter(i); continue; }
|
||
zb.z -= TUNE.zPushback; // weak sitters only stagger them
|
||
pock(0.6, 300);
|
||
ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; // spent — one stagger per ball
|
||
}
|
||
}
|
||
if (zb.z >= COURT.L + 0.7) { zombies.splice(i, 1); if (G.mode !== 'eaten' && !G.horde) splatPoint('O'); } // it reached your baseline
|
||
}
|
||
|
||
for (let i = pellets.length - 1; i >= 0; i--) { // machine-gun rounds: flat, fast, one-shot kills
|
||
const pe = pellets[i];
|
||
pe.t -= dt; pe.x += pe.vx * dt; pe.z += pe.vz * dt;
|
||
let hit = false;
|
||
for (let j = zombies.length - 1; j >= 0; j--) {
|
||
const zb = zombies[j];
|
||
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);
|
||
hit = true; break;
|
||
}
|
||
}
|
||
if (hit || pe.t <= 0) pellets.splice(i, 1);
|
||
}
|
||
|
||
for (let i = extras.length - 1; i >= 0; i--) { // triple-serve side balls: full ballistic clones
|
||
const e = extras[i];
|
||
e.t -= dt; e.vy -= TUNE.gravity * dt;
|
||
e.x += e.vx * dt; e.y += e.vy * dt; e.z += e.vz * dt;
|
||
if (e.y <= 0 && e.vy < 0) { e.y = 0; e.vy = -e.vy * TUNE.bounceY; e.vx *= TUNE.bounceXZ; e.vz *= TUNE.bounceXZ; }
|
||
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 || 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) {
|
||
if (zb.variant === 'g' && --zb.hp > 0) splat(0.4); // wounds the brute
|
||
else killZombie(j, !!G.horde);
|
||
} else { zb.z -= TUNE.zPushback; pock(0.5, 300); }
|
||
dead = true; break; // a side ball is spent on its first touch
|
||
}
|
||
}
|
||
if (dead) extras.splice(i, 1);
|
||
}
|
||
|
||
for (let i = parts.length - 1; i >= 0; i--) {
|
||
const p = parts[i];
|
||
p.t -= dt; p.vy -= 14 * dt;
|
||
p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt;
|
||
if (p.y < 0) { p.y = 0; p.vy = 0; p.vx *= 0.5; p.vz *= 0.5; }
|
||
if (p.t <= 0) parts.splice(i, 1);
|
||
}
|
||
|
||
const boy = G.boy; // the boy mops any gore he passes over
|
||
for (let i = gore.length - 1; i >= 0; i--) {
|
||
const d = gore[i];
|
||
if (boy && Math.abs(boy.x - d.x) < 0.7 && Math.abs(boy.z - d.z) < 1.2) d.a -= dt * (d.scorch ? 0.16 : 1.6); // craters scar the lawn 10× longer
|
||
if (d.a <= 0) gore.splice(i, 1);
|
||
}
|
||
for (let i = piles.length - 1; i >= 0; i--) { // piles shave far slower than stains — net growth wins
|
||
const pl = piles[i];
|
||
if (boy && Math.abs(boy.x - pl.x) < 0.7 && Math.abs(boy.z - pl.z) < 1.2) pl.size -= dt * 0.25;
|
||
if (pl.size <= 0.3) piles.splice(i, 1);
|
||
}
|
||
}
|