The old death was a red tint and a shake for half a second, then a puff of gore. That reads as the sprite having a moment, not as being eaten. Now it takes four and a half seconds and it is a crowd doing it. You are dragged to your knees fighting it (new frames, p_die1/p_die2, alternating), blood coming out of you the whole time — a constant spray and a stain that keeps widening, with the wet work on a loop underneath. Every body on court drops what it was doing and converges: each claims a spot in a ring around you, kept from wherever it was standing, so they crowd in from all sides instead of stacking on one pixel. Then you are pressed flat into the ground while the ring closes over you — you are drawn INSIDE the crowd's z order, so the ones between you and the camera hide you exactly as they arrive — and what is left is the mound, with them still feeding over it when the summary finally fades in. Also: the wounded shamblers were stepping their two frames at half the cadence of the healthy four, which reads as frozen next to the crowd they walk in. Same clock now. (They were animating — verified frame by frame — just too slowly.) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
791 lines
49 KiB
JavaScript
791 lines
49 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, UI, COURT, VOLLEY, CONTACT, BOSS_ART, EATEN_BURST, EATEN_FEED_R } from './config.js';
|
||
import { G, P, ball, sendBoy, setupPoint } from './state.js';
|
||
import { splat, moan, pock, voice, boom, swish, shriek, aww, sfx } from './audio.js';
|
||
import { splatPoint, cheer, spentBall, resolveInterlude, showModal, endBossFight, aside } 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
|
||
// INCOMING hostile balls: the ball machine's shots and the chapter boss's volleys. Deliberately a
|
||
// separate array from `ball` and `extras` rather than an extension of either — the main ball is the
|
||
// one you serve and there can only be one of it, and extras are yours and kill zombies. These are
|
||
// theirs and they hurt YOU. Swat one with a swing and it becomes an `extras` entry flying back
|
||
// up-court, which is how a parry turns into a real weapon for free.
|
||
export const shots = []; // {x, y, z, vx, vy, vz, g, t}
|
||
|
||
let spawnT = 1.5, moanT = 3;
|
||
|
||
export function spawnZombie(x, z, variant) {
|
||
// Keyed on GAMES FINISHED, not on splatT: the old `1 + min(1.5, splatT / 70)` maxed out after
|
||
// 105 seconds of splatter, so every shambler from roughly game 3 onward walked at the same top
|
||
// speed for the rest of a 50-game run. Games are what the player experiences as progress.
|
||
const ramp = 1 + Math.min(TUNE.zSpeedMax, G.runGames * TUNE.zSpeedPerGame);
|
||
const zb = { x, z, v: TUNE.zSpeed * ramp * (0.8 + Math.random() * 0.4), walkT: Math.random() * 9,
|
||
homeOff: (Math.random() * 2 - 1) * 0.8, variant }; // personal lane offset so they don't stack into a column
|
||
if (variant === 'f') { zb.v = Math.min(TUNE.runnerVMax, zb.v * TUNE.runnerSpeed); shriek(); } // shriek = the fairness contract; capped so late runners stay reactable
|
||
if (variant === 'a' || variant === 'b') zb.hp = TUNE.shamblerHp; // classic shamblers take a wound before they drop — only the runner is one-shot glass
|
||
if (variant === 'g') { zb.v *= 0.55; zb.hp = TUNE.bruteHp; } // slow, but soaks hits
|
||
if (variant === 'd') { zb.v *= 0.8; zb.hp = TUNE.stewardHp; } // the steward: a head taller and several hits tougher than the crowd, nothing more exotic than that
|
||
// Every body with hp remembers its FULL hp. The wound skins used to compare against the variant's
|
||
// TUNE constant, which meant every new body needed its own branch to look hurt; one field here and
|
||
// the rule is "hp below full = show the wound", for anything that has a wound skin.
|
||
if (zb.hp) zb.hpMax = zb.hp;
|
||
if (variant === 's') zb.shieldT = 0; // chair up; > 0 = seconds of stagger left
|
||
if ('fgsd'.includes(variant) && !G.seen['v' + variant]) { G.seen['v' + variant] = true; aside('ump_dress_code', 0.8); }
|
||
if (variant === 'm') { // the rogue ball machine: furniture, not a body. It never SHAMBLES...
|
||
zb.v = 0; zb.hp = zb.hpMax = TUNE.machHp; zb.fireT = TUNE.machCycle; zb.windT = 0;
|
||
zb.repoT = TUNE.machRepo * (0.4 + Math.random()); // ...but it wheels itself somewhere else now and then
|
||
}
|
||
zombies.push(zb);
|
||
}
|
||
|
||
// the rogue ball machine, wheeled out onto the far court. It never moves and never scores; it just
|
||
// shoots at you until you deal with it.
|
||
export function spawnMachine() {
|
||
spawnZombie((Math.random() * 2 - 1) * 3.6, 2.2 + Math.random() * 3, 'm');
|
||
voice('ump_machine', 0.4, 0.95);
|
||
if (!G.seen.mach) {
|
||
G.seen.mach = true;
|
||
showModal({ title: 'A BALL MACHINE', color: UI.warm,
|
||
body: ['IT FIRES AT YOU ON A TIMER', 'SWING TO RETURN THE SHOT', 'OR SWISH TO BAT IT AWAY'] });
|
||
}
|
||
}
|
||
|
||
// ---- the chapter boss. One every TUNE.bossEvery games, bigger and tougher each time. It advances
|
||
// slowly and volleys at you, so it threatens BOTH channels: its shots chip your hp, and letting it
|
||
// reach you is the ordinary instant death. Reuses the whole existing damage model — it is just a
|
||
// body with a lot of hp (see the wound() invariant in HANDOFF), so no weapon needs special-casing.
|
||
export function spawnChapterBoss(chapter) {
|
||
const hp = TUNE.bossHp + (chapter - 1) * TUNE.bossHpPerChapter;
|
||
zombies.push({ x: 0, z: -1.2, v: TUNE.bossSpeed, walkT: 0, homeOff: 0, variant: 'B',
|
||
chapter, hp, hpMax: hp, scale: TUNE.bossScale + (chapter - 1) * TUNE.bossScalePer,
|
||
fireT: TUNE.bossCycle, windT: 0, salvo: 0, salvoT: 0,
|
||
art: BOSS_ART[(chapter - 1) % BOSS_ART.length] }); // which of the boss designs this chapter is
|
||
}
|
||
|
||
// ---- the player's second health channel. Only these two functions touch it.
|
||
export function hurtPlayer(n) {
|
||
if (G.mode !== 'splatter' || G.hurtT > 0 || G.mode === 'eaten') return; // grace window: a volley
|
||
G.hp -= n; // must not delete you at once
|
||
G.hurtT = TUNE.hurtGrace; G.hurtFlash = 0.3;
|
||
G.shake = { t: 0.22, mag: 4 };
|
||
splat(0.7); aww(0.5);
|
||
for (let k = 0; k < 14; k++) parts.push({ // he takes it in the chest
|
||
x: P.x + (Math.random() * 2 - 1) * 0.2, y: 1.2, z: P.z,
|
||
vx: (Math.random() * 2 - 1) * 2.2, vy: 0.8 + Math.random() * 2, vz: (Math.random() * 2 - 1) * 2.2,
|
||
t: 0.35 + Math.random() * 0.35, dark: Math.random() < 0.5 });
|
||
if (G.hp <= 0) killPlayer();
|
||
else voice('ump_quiet_bleeding', 0.3, 0.95); // the umpire objects on your behalf. He is still watching.
|
||
}
|
||
|
||
// Death, from EITHER channel: a shambler reaching you, or your hp running out. Extracted so the two
|
||
// causes cannot drift apart — the run summary, the best-run write and the death animation are all
|
||
// here exactly once.
|
||
export function killPlayer() {
|
||
if (G.mode === 'eaten') return;
|
||
gore.push({ x: P.x, z: P.z, r: 0.9, a: 0.9 }); if (gore.length > 40) gore.shift();
|
||
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); aww(1); // the run is over — the crowd groans
|
||
// the death animation runs off G.eatenT: thrash here, burst at EATEN_BURST (playerGore), summary after
|
||
G.eatenT = 0; G.eatenAt = { x: P.x, z: P.z };
|
||
G.mode = 'eaten'; ball.active = false; G.flash = null; G.modal = null; // zombies keep shambling for ambience
|
||
G.horde = null; pellets.length = 0; extras.length = 0; shots.length = 0; G.mgT = 0; // everything stops
|
||
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 };
|
||
G.newBest = G.runGames > b.games; // beat it → the summary crows about it
|
||
if (G.newBest) { 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
|
||
}
|
||
|
||
// One hostile ball, lobbed at where the player is standing. `spread` fans a volley sideways.
|
||
// It is a real ARC (solved like a serve, not fired flat) so it is readable coming in and so the
|
||
// swing window has something to meet — a flat bolt at head height is not a tennis shot.
|
||
function fireAtPlayer(x, z, spread = 0, speed = TUNE.machSpeed) {
|
||
const y0 = 1.1;
|
||
const tx = P.x + spread, tz = P.z;
|
||
const d = Math.hypot(tx - x, tz - z), Tf = Math.max(0.35, d / speed);
|
||
const g = TUNE.gravity;
|
||
shots.push({ x, y: y0, z,
|
||
vx: (tx - x) / Tf, vz: (tz - z) / Tf,
|
||
vy: (0.9 - y0 + 0.5 * g * Tf * Tf) / Tf, // land at racket height, not on the grass
|
||
g, t: Tf + 1.6 });
|
||
pock(0.55, 190); // the thunk of the throw
|
||
}
|
||
|
||
// Which shambler steps out next. Unlocks are keyed on GAMES, not on sets won: a set is six games,
|
||
// so the old top gate (`tier >= 2`) opened around game 12 and after that the mix was frozen forever
|
||
// at 15% shield / 15% brute / 12% runner — a third of why a 50-game run stopped changing.
|
||
// Now the advanced SHARE keeps climbing after everything is unlocked, which is what makes game 40
|
||
// feel different from game 20 rather than merely faster.
|
||
function pickVariant() {
|
||
const g = G.runGames;
|
||
const pool = [];
|
||
if (g >= 2 && !zombies.some(z => z.variant === 'f')) pool.push('f'); // at most one runner alive
|
||
if (g >= 6) pool.push('g');
|
||
if (g >= 12) pool.push('s');
|
||
// The steward is the mildest of the pool on purpose — bigger and one hit tougher, no special rule
|
||
// to learn — so he can arrive EARLY and thicken the ordinary crowd, which the shambler mix alone
|
||
// never did. He is also the only advanced body that shows up in a horde (see spawnHordeZombie).
|
||
if (g >= 4) pool.push('d');
|
||
const nasty = Math.min(TUNE.nastyMax, g * TUNE.nastyPerGame);
|
||
if (pool.length && Math.random() < nasty) return pool[Math.floor(Math.random() * pool.length)];
|
||
return Math.random() < 0.5 ? 'a' : 'b';
|
||
}
|
||
|
||
// How big a target a body is. Bosses are deliberately a little more generous than a shambler —
|
||
// part of "the racket zombie doesn't seem harmable" was simply missing him.
|
||
// A BOSS BODY: takes wound damage from everything instead of being deleted by it. The brute ('g') is
|
||
// deliberately NOT one — melee/bomb/fire one-shotting a brute is a settled "power exception".
|
||
const isBoss = zb => zb.variant === 'r' || zb.variant === 'B';
|
||
const hitR = zb => zb.variant === 'g' ? 0.75 : zb.variant === 'd' ? 0.64 : zb.variant === 'r' ? 0.7
|
||
: zb.variant === 'B' ? 0.55 * (zb.scale || 1) * 1.4 // a chapter boss is a barn door, as it should be
|
||
: zb.variant === 'm' ? 0.8 // the machine is a wide low box
|
||
: 0.55;
|
||
|
||
// 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.
|
||
// how absurdly a variant dies: classics splash, the advanced roster DETONATES
|
||
const GORE_MUL = { a: 1, b: 1, f: 1.8, s: 2.5, g: 4, r: 3, m: 1.5, B: 6 }; // a chapter boss comes apart like nothing else on the court
|
||
|
||
// dark smoke: one puff, drifting up and spreading. Not gravity-bound like gore (see the parts loop).
|
||
function smokePuff(x, y, z, spread = 0.5) {
|
||
parts.push({ x: x + (Math.random() * 2 - 1) * spread, y, z: z + (Math.random() * 2 - 1) * spread * 0.5,
|
||
vx: (Math.random() * 2 - 1) * 0.35, vy: 0.5 + Math.random() * 0.5, vz: (Math.random() * 2 - 1) * 0.35,
|
||
t: 0.9 + Math.random() * 0.9, smoke: true });
|
||
}
|
||
|
||
// The machine is the one thing on court that is not made of meat, and it used to burst like a body:
|
||
// blood decal, gore mound, the wet splat, a ball boy sent out to mop it. It comes apart as MACHINERY
|
||
// now — a flash, sparks, torn metal, and a column of dark smoke over a scorch mark.
|
||
function wreckMachine(zb) {
|
||
boom(); G.shake = { t: 0.3, mag: 5 }; G.boomFlash = 0.09;
|
||
gore.push({ x: zb.x, z: zb.z, r: 1.1, a: 0.95, scorch: true }); // burnt clay, not blood
|
||
if (gore.length > 40) gore.shift();
|
||
for (let k = 0; k < 26; k++) parts.push({ // sparks and burning debris
|
||
x: zb.x, y: 0.8, z: zb.z,
|
||
vx: (Math.random() * 2 - 1) * 4, vy: 1.5 + Math.random() * 4, vz: (Math.random() * 2 - 1) * 4,
|
||
t: 0.3 + Math.random() * 0.5, fire: true });
|
||
for (let k = 0; k < 26; k++) smokePuff(zb.x, 0.5 + Math.random() * 1.6, zb.z, 0.7); // a column, not a puff
|
||
}
|
||
|
||
// A chapter boss does not just die. It is the thing the whole fight was about, so it comes apart
|
||
// in its own beat: the world drops into slow motion, the screen kicks, a ring of blood blows out
|
||
// through the crowd's roar, and the card that announces it waits until the pieces have landed.
|
||
function bossDeath(zb) {
|
||
G.slowT = TUNE.bossSlowT; // real seconds; main.js scales dt while it runs
|
||
G.bossBurst = { x: zb.x, z: zb.z, t: 0.75 }; // the shockwave ring render.js draws
|
||
G.shake = { t: 0.5, mag: 7 }; G.boomFlash = 0.12;
|
||
boom(); splat(1); cheer(4, 1); voice('unbelievable', 0.5);
|
||
const h = 1.8 * (zb.scale || 1.5);
|
||
for (let k = 0; k < 90; k++) parts.push({ // on top of killZombie's own burst: a full body's worth
|
||
x: zb.x, y: h * (0.2 + Math.random() * 0.7), z: zb.z,
|
||
vx: (Math.random() * 2 - 1) * 7, vy: 1 + Math.random() * 7, vz: (Math.random() * 2 - 1) * 7,
|
||
t: 0.7 + Math.random() * 0.9, dark: Math.random() < 0.4 });
|
||
for (let k = 0; k < 6; k++) { // and it leaves a mess nothing else on the court leaves
|
||
const a = Math.random() * 6.283, r = Math.random() * 2.4;
|
||
gore.push({ x: zb.x + Math.cos(a) * r, z: zb.z + Math.sin(a) * r * 0.6, r: 0.6 + Math.random() * 0.7, a: 0.9 });
|
||
if (gore.length > 40) gore.shift();
|
||
}
|
||
}
|
||
|
||
function killZombie(i, quiet) {
|
||
const zb = zombies[i];
|
||
const mul = GORE_MUL[zb.variant] || 1;
|
||
zombies.splice(i, 1);
|
||
if (zb.variant === 'B') bossDeath(zb); // before endBossFight below, so the card can wait on it
|
||
if (zb.variant === 'm') { // furniture, not a body: no blood, no mound, no mop boy
|
||
wreckMachine(zb);
|
||
if (!G.horde && !G.clearing) splatPoint('P', quiet);
|
||
return;
|
||
}
|
||
if (G.interlude && zb.variant === 'r') resolveInterlude('P'); // regicide ends the duel your way — any weapon counts
|
||
if (zb.variant === 'B') endBossFight(); // the chapter is over
|
||
const stains = Math.round(Math.sqrt(mul)); // advanced kills splash a scatter of stains, not one puddle
|
||
for (let k = 0; k < stains; k++) {
|
||
gore.push({ x: zb.x + (k ? (Math.random() * 2 - 1) * 0.9 : 0), z: zb.z + (k ? (Math.random() * 2 - 1) * 0.9 : 0),
|
||
r: (0.45 + Math.random() * 0.35) * Math.sqrt(mul), 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 + Math.round(mul / 2 + 0.5));
|
||
else { piles.push({ x: zb.x, z: zb.z, size: Math.min(4, Math.round(mul / 2 + 0.5)), seed: Math.random() * 1e4, blobs: null, builtSize: 0 });
|
||
if (piles.length > 30) piles.shift(); }
|
||
const n = Math.round(30 * mul); // base bumped 24 -> 30, then the multiplier goes to work
|
||
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();
|
||
sfx('headshot-' + (Math.random() < 0.5 ? 1 : 2), 0.6); // random headshot crack layered on the gore burst
|
||
sendBoy(zb.x, zb.z, true); // a boy comes out with the mop
|
||
// The chapter boss deliberately scores NOTHING: its reward is the chapter card and the parked game
|
||
// beat resuming. Letting it bank a point meant splatPoint's game-over branch and endBossFight both
|
||
// decided "what happens next" in the same frame and clobbered each other.
|
||
if (!G.horde && !G.clearing && zb.variant !== 'B') splatPoint('P', quiet); // horde = survival, clearing = mopping up an already-won game — neither scores
|
||
}
|
||
|
||
// ---- fire. The flame ball doesn't just kill what it touches, it starts an epidemic: the kill
|
||
// ignites its neighbours, and every burning body keeps igniting whatever it shambles past.
|
||
// Read-only over zombies[] (it only sets burnT), so it is safe to call from inside a kill loop.
|
||
export function ignite(x, z, r) {
|
||
for (const zb of zombies) {
|
||
if (!zb || zb.burnT > 0) continue; // the boss DOES catch now — just at TUNE.bossBurnMul the rate,
|
||
// so fire is a real answer to a big body instead of a no-op
|
||
if (Math.hypot(zb.x - x, zb.z - z) < r) { zb.burnT = TUNE.burnTime; if (zb.hp === undefined) zb.hp = 1; }
|
||
}
|
||
}
|
||
|
||
// Bank `amount` of damage on a multi-hit body and return true once it should DIE. Fractional amounts
|
||
// are legal (a machine-gun pellet chips a third of a point): they accumulate on zb.chip and only
|
||
// WHOLE points come off hp, because the wound skins and bleed() anchors switch on hp thresholds and
|
||
// a fractional hp would never match one. The caller decides HOW it dies — splatter() for the
|
||
// multi-kill fanfare, killZombie() for a quiet one — and must stop touching zb afterwards, because
|
||
// killZombie splices the array. This is the one place damage is applied, so a future boss with 20 hp
|
||
// needs no new code: every weapon is already proportional to it.
|
||
function wound(zb, amount, fx = true) {
|
||
zb.chip = (zb.chip || 0) + amount;
|
||
let took = 0;
|
||
while (zb.chip >= 1 && zb.hp > 0) { zb.chip -= 1; zb.hp--; took++; }
|
||
if (took && fx) { splat(0.5); woundBurst(zb); }
|
||
return zb.hp <= 0;
|
||
}
|
||
|
||
// one burning body's frame: damage, spread, embers. Returns true if the fire killed it (the
|
||
// caller must then stop touching zb — killZombie splices, and splatPoint can cascade further).
|
||
function burnTick(zb, i, dt) {
|
||
zb.burnT -= dt;
|
||
ignite(zb.x, zb.z, TUNE.burnSpread);
|
||
// fx off: the embers below already ARE the feedback, a wet pop per cooked hp point would be noise
|
||
const dead = wound(zb, TUNE.burnDps * (isBoss(zb) ? TUNE.bossBurnMul : 1) * dt, false);
|
||
if (parts.length < 110) for (let k = 0; k < 2; k++) parts.push({ // embers off the body
|
||
x: zb.x + (Math.random() * 2 - 1) * 0.3, y: 0.5 + Math.random() * 1.4, z: zb.z,
|
||
vx: (Math.random() * 2 - 1) * 0.5, vy: 1.2 + Math.random(), vz: (Math.random() * 2 - 1) * 0.5,
|
||
t: 0.25 + Math.random() * 0.2, fire: true });
|
||
if (!dead) return false;
|
||
killZombie(i, !!G.horde);
|
||
return true;
|
||
}
|
||
|
||
// Phase A, every frame: they are tearing at you and it shows. A thin constant spray out of the
|
||
// pile plus a stain that keeps widening — "blood and gore flowing out" is a CONTINUOUS thing, not
|
||
// one burst, and the burst at EATEN_BURST lands on top of it.
|
||
export function eatenBleed(dt) {
|
||
const at = G.eatenAt; if (!at) return;
|
||
for (let k = 0; k < 2; k++) parts.push({
|
||
x: at.x + (Math.random() * 2 - 1) * 0.35, y: 0.5 + Math.random() * 0.8, z: at.z + (Math.random() * 2 - 1) * 0.3,
|
||
vx: (Math.random() * 2 - 1) * 3.2, vy: 1.5 + Math.random() * 3.5, vz: (Math.random() * 2 - 1) * 3.2,
|
||
t: 0.4 + Math.random() * 0.5, dark: Math.random() < 0.45 });
|
||
const g = gore.find(d => d.spreading);
|
||
if (g) g.r = Math.min(2.1, g.r + dt * 0.7);
|
||
else { gore.push({ x: at.x, z: at.z, r: 0.7, a: 0.9, spreading: true }); if (gore.length > 40) gore.shift(); }
|
||
if ((G.eatSfxT = (G.eatSfxT || 0) - dt) <= 0) { splat(0.45); G.eatSfxT = 0.28; } // wet work, on a loop
|
||
}
|
||
|
||
// phase B of the death animation (EATEN_BURST, config.js): the player IS the gore now. Reuses killZombie's machinery —
|
||
// the biggest mound on the court, and it stays visible under the summary scrim. That's the point.
|
||
export function playerGore() {
|
||
const at = G.eatenAt || { x: P.x, z: P.z };
|
||
gore.push({ x: at.x, z: at.z, r: 1.7, a: 1 }); if (gore.length > 40) gore.shift();
|
||
piles.push({ x: at.x, z: at.z, size: 4, seed: Math.random() * 1e4, blobs: null, builtSize: 0 });
|
||
if (piles.length > 30) piles.shift();
|
||
for (let k = 0; k < 90; k++) parts.push({
|
||
x: at.x, y: 1.0, z: at.z,
|
||
vx: (Math.random() * 2 - 1) * 5.5, vy: 1 + Math.random() * 6, vz: (Math.random() * 2 - 1) * 5.5,
|
||
t: 0.6 + Math.random() * 0.9, dark: k % 3 === 0 });
|
||
splat(1); boom();
|
||
G.shake = { t: 0.35, mag: 7 };
|
||
G.boomFlash = 0.08;
|
||
}
|
||
|
||
// a small dark spatter the instant a multi-hit shambler is wounded — subtle impact pop, NOT the
|
||
// kill spray (the ongoing thin trickle is bleed()'s job). Kept low per the settled blood feel.
|
||
// The player's own wound trickle. Same rule as the zombies': blood is NEVER painted into the player
|
||
// sprite (settled feel decision) — it is live particles, sparse, thin, and the flow scales with how
|
||
// far gone he is. Together with drawChar's procedural spatter this IS the health bar.
|
||
function bleedPlayer() {
|
||
if (parts.length > 95) return;
|
||
const lost = G.hpMax - G.hp;
|
||
for (let k = 0; k < lost; k++) {
|
||
if (Math.random() > 0.22) continue; // sparse: most frames each wound emits nothing
|
||
parts.push({
|
||
x: P.x + (k % 2 ? 0.22 : -0.2) + (Math.random() * 2 - 1) * 0.03,
|
||
y: 1.45 - k * 0.13, z: P.z + (Math.random() * 2 - 1) * 0.03,
|
||
vx: (Math.random() * 2 - 1) * 0.28, vy: 0.5 + Math.random() * 0.7, vz: (Math.random() * 2 - 1) * 0.26,
|
||
t: 0.3 + Math.random() * 0.3, dark: Math.random() < 0.55 });
|
||
}
|
||
}
|
||
|
||
function woundBurst(zb) {
|
||
for (let k = 0; k < 5; k++) parts.push({
|
||
x: zb.x + (Math.random() * 2 - 1) * 0.15, y: 1.4, z: zb.z,
|
||
vx: (Math.random() * 2 - 1) * 1.6, vy: 0.6 + Math.random() * 1.4, vz: (Math.random() * 2 - 1) * 1.6,
|
||
t: 0.35 + Math.random() * 0.3, dark: Math.random() < 0.6 });
|
||
}
|
||
|
||
// swishing the racket cleaves every shambler in reach — it's the panic button for when they're
|
||
// already on top of you, so it stays a full sweep. TUNE.meleeCool is the brake that stops it
|
||
// from being the default attack.
|
||
// The boss skip is NOT a gap in the damage model: it is unreachable by construction. The player is
|
||
// bounded at P_BOUNDS.zMin (netZ + 1.2 = 13.1) and the boss holds z <= COURT.svcFar (5.5), so they
|
||
// are always on opposite sides of the net and meleeRange 1.7 can never span it. Left as a skip
|
||
// rather than "fixed", because a melee path to the boss would need him to cross the net first.
|
||
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 (zb.variant === 'm' || isBoss(zb)) { // furniture and bosses don't cleave — they take a wound
|
||
if (Math.hypot(zb.x - x, zb.z - z) < range && wound(zb, TUNE.dmgBall)) { splatter(i); hit++; }
|
||
continue;
|
||
}
|
||
if (Math.hypot(zb.x - x, zb.z - z) < range) { killZombie(i, !!G.horde); hit++; }
|
||
}
|
||
// The swish deliberately does NOT touch incoming shots. It used to, as a pretoss fallback — but
|
||
// meleeSwipe runs FIRST in keyPressed, so it swallowed the very shot the new tap-to-parry was about
|
||
// to return. One answer to a thrown ball: swing at it.
|
||
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
|
||
// a few stewards in the mix: the wave needs bodies that survive one pellet, or a grown horde is a
|
||
// uniform wall of the same thing dying at the same range
|
||
const v = Math.random() < TUNE.hordeSteward && G.runGames >= 4 ? 'd' : Math.random() < 0.5 ? 'a' : 'b';
|
||
spawnZombie((Math.random() * 2 - 1) * 5, -1.5 - Math.random(), v);
|
||
}
|
||
let mgTick = 0;
|
||
function firePellet() {
|
||
const a = (Math.random() * 2 - 1) * TUNE.mgSpread * Math.PI / 180, s = 26; // spread cone, 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 });
|
||
if (++mgTick % 2 === 0) pock(0.25, 1400); // rapid fire tick, every 2nd round — at 12+/s every round buzzes
|
||
}
|
||
export function spawnRacketZombie() { // one takes up residence on the far court — an opponent, of sorts
|
||
// He stiffens as the run goes on. hpMax is stored because the wound skins and bleed() anchors read
|
||
// a FRACTION of full health now — hardcoded `hp === 2` thresholds would leave a late-run boss
|
||
// looking pristine right up to the killing blow.
|
||
const hp = TUNE.racketHp + Math.floor(G.runGames * TUNE.racketHpPerGame);
|
||
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, hpMax: hp });
|
||
if (!G.seen.racket) { // it is not a shambler and it does not behave like one — say so, once
|
||
G.seen.racket = true;
|
||
showModal({ title: 'IT HAS A RACKET', color: UI.hot,
|
||
body: ['IT RETURNS YOUR WEAK SHOTS', 'ONLY A FULL PULL WOUNDS IT'] });
|
||
}
|
||
}
|
||
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;
|
||
// h.rate is stamped by startHorde and grows per set banked — every horde used to be the same
|
||
// twelve seconds at the same 2.5/s, so the fifth one was indistinguishable from the first.
|
||
if ((h.spawnT -= dt) <= 0) {
|
||
if (zombies.length < TUNE.zHordeCap) spawnHordeZombie(); // ...but bounded, see TUNE.zHordeCap
|
||
h.spawnT = 1 / (h.rate || TUNE.hordeRate);
|
||
}
|
||
if ((h.mgT -= dt) <= 0) { firePellet(); h.mgT = 1 / (h.mgRate || 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
|
||
const zb = zombies[i];
|
||
if (!zb || Math.hypot(zb.x - x, zb.z - z) >= TUNE.bombRadius) continue;
|
||
// A blast used to delete the boss outright, ignoring his hp entirely — which made BOMB the only
|
||
// real answer to him and would have made any bigger boss meaningless. It's heavy damage now.
|
||
if (isBoss(zb)) { if (wound(zb, TUNE.dmgBomb)) splatter(i); }
|
||
else splatter(i);
|
||
}
|
||
gore.push({ x, z, r: 2.2, a: 1, scorch: true }); if (gore.length > 40) gore.shift(); // the crater — near-permanent
|
||
gore.push({ x, z, r: 2.0, a: 0.9 }); if (gore.length > 40) gore.shift();
|
||
// a ring of splatter thrown outward, so the blast paints the lawn instead of leaving one puddle
|
||
for (let k = 0; k < 6; k++) {
|
||
const a = Math.random() * 6.2832, r = TUNE.bombRadius * (0.35 + Math.random() * 0.6);
|
||
gore.push({ x: x + Math.cos(a) * r, z: z + Math.sin(a) * r, r: 0.7 + Math.random() * 0.7, a: 0.85 });
|
||
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: 4, seed: Math.random() * 1e4, blobs: null, builtSize: 0 }); if (piles.length > 30) piles.shift(); }
|
||
// the eruption itself: a fat, slow, high-arcing column of gore rather than a camera flash
|
||
for (let k = 0; k < 120; k++) parts.push({
|
||
x: x + (Math.random() * 2 - 1) * 0.4, y: 0.5, z: z + (Math.random() * 2 - 1) * 0.4,
|
||
vx: (Math.random() * 2 - 1) * 11, vy: 2 + Math.random() * 9, vz: (Math.random() * 2 - 1) * 11,
|
||
t: 0.7 + Math.random() * 1.1, dark: k % 3 === 0,
|
||
});
|
||
G.hitstop = 0.18; boom();
|
||
G.shake = { t: 0.45, mag: 9 }; // camera kick
|
||
// a warm, brief bloom instead of a white-out — the blinding frame was doing the work the gore
|
||
// should be doing, and on a phone it just hurt (see render.js for the colour)
|
||
G.boomFlash = 0.06;
|
||
if (G.idleT - G.lastOrderT > 3) { voice('order', 0.35); G.lastOrderT = G.idleT; } // the umpire loses composure
|
||
}
|
||
|
||
// wounded zombies bleed from every open wound — a fine, sparse trickle (a small spurt from the
|
||
// neck stump on the headless boss). Anchors are approximate body offsets [x, y, rise]; most
|
||
// frames a wound drips nothing, and the tight spread keeps each thread thin. Bounded low.
|
||
function bleed(zb) {
|
||
if (parts.length > 90) return;
|
||
let wounds;
|
||
if (zb.variant === 'g' && zb.hp < TUNE.bruteHp) {
|
||
// the brute opens up stage by stage: one arm, then both, then the head. Still sparse —
|
||
// more ANCHORS, not a heavier flow (the settled feel note: blood stays thin).
|
||
wounds = [[-0.3, 1.95, 1.1]];
|
||
if (zb.hp <= TUNE.bruteHp - 2) wounds.push([0.42, 1.9, 1.0]);
|
||
if (zb.hp <= TUNE.bruteHp - 3) wounds.push([0.05, 2.35, 2.0]);
|
||
if (zb.hp <= 1) wounds.push([0.0, 1.2, 0.7]);
|
||
}
|
||
// the boss reads as a FRACTION of full health, not against hardcoded hp numbers: his hp scales
|
||
// with the run now (racketHpPerGame), so `hp === 2` would leave a late-run boss bleeding from
|
||
// nothing until the very last hit. Must stay in step with drawZombie's skin thresholds.
|
||
else if (zb.variant === 'r' && zb.hp / (zb.hpMax || TUNE.racketHp) <= 0.34)
|
||
wounds = [[0.05, 1.5, 2.4], [0.4, 1.15, 0.9]]; // headless: neck spurt + torn arm
|
||
else if (zb.variant === 'r' && zb.hp / (zb.hpMax || TUNE.racketHp) <= 0.67)
|
||
wounds = [[0.4, 1.2, 1.0]]; // racket: torn-off arm
|
||
else if ((zb.variant === 'a' || zb.variant === 'b') && zb.hp < TUNE.shamblerHp) wounds = [[0.28, 1.35, 0.85]]; // shambler: gashed shoulder
|
||
else return;
|
||
for (const w of wounds) {
|
||
if (Math.random() > 0.4) continue; // sparse: most frames this wound emits nothing
|
||
parts.push({
|
||
x: zb.x + w[0] + (Math.random() * 2 - 1) * 0.03, y: w[1] + (Math.random() * 2 - 1) * 0.03, z: zb.z + (Math.random() * 2 - 1) * 0.03,
|
||
vx: (Math.random() * 2 - 1) * 0.3, vy: w[2] * (0.6 + Math.random() * 0.5), vz: (Math.random() * 2 - 1) * 0.28,
|
||
t: 0.3 + Math.random() * 0.3, dark: Math.random() < 0.55 });
|
||
}
|
||
}
|
||
|
||
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) { // machine-gun tin: horde firepower, regular rules
|
||
// The burst clock ticks in EVERY phase. It used to tick only during 'rally', so anything that
|
||
// moved the phase mid-burst (a duel calling setupPoint) froze mgT above zero forever — and
|
||
// `G.mgT > 0` blocks serving, so the game locked up with the gun racket stuck on screen.
|
||
G.mgT -= dt;
|
||
if (G.phase === 'rally' && (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 && !G.clearing && !G.bossFight) { // breathers, duels, the mop-up after a won game AND a boss fight all get a quiet court (no new spawns)
|
||
spawnT -= dt;
|
||
// The spawn director: games finished sets both crowd size and tempo. Both curves used to hit a
|
||
// wall — the cap pinned at 14 by game 11 and the gap floored at 1.0 s by game 10, after which
|
||
// the game literally stopped changing. The gap is hyperbolic now so it keeps tightening on its
|
||
// own instead of running into a floor; zSpawnMin is only a safety stop.
|
||
const cap = Math.min(TUNE.zMaxCap, TUNE.zMax + Math.floor(G.runGames * TUNE.zMaxPerGame));
|
||
if (zombies.length >= cap - 1) aside('ump_hindrance', 0.6); // the court is genuinely full
|
||
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 / (1 + G.runGames * TUNE.zSpawnAccel));
|
||
}
|
||
}
|
||
}
|
||
|
||
// ponytail: crude swept collision — grow the contact window by half the distance the ball
|
||
// covers this frame. A full-draw smash moves ~0.7 m per frame (1.6 m on a 25 fps device, where
|
||
// main.js clamps dt), which is wider than a shambler, so a discrete point test tunnels straight
|
||
// through bodies. Upgrade to a real segment-vs-circle test if the fudge ever reads as a phantom
|
||
// hit; at these speeds and radii it doesn't.
|
||
const ballSweep = ball.active ? Math.hypot(ball.vx, ball.vz) * dt * 0.5 : 0;
|
||
|
||
// The flame ball lights everything it FLIES PAST, not just what it kills. Without this the
|
||
// fire never actually spread: full pierce meant the ball killed its whole lane outright, and
|
||
// the only bodies left to catch were the ones it had already removed.
|
||
if (ball.active && ball.flame && ball.y < 2.6) ignite(ball.x, ball.z, TUNE.burnBallR);
|
||
|
||
// The umpire called time. The changeover is a MANDATORY breather for the player — no serving, no
|
||
// walking — so it is one for the dead too, which is the joke: they wait, because it's still a
|
||
// tennis match. They stand where they are, still bleeding, still burning, still lit by anything
|
||
// in the air, but nothing advances and nothing can eat you. (The alternative — letting the player
|
||
// walk freely during the beat — was tried and vetoed: he never walks freely during play either.)
|
||
const held = !!G.changeover;
|
||
if (G.hurtT > 0) G.hurtT -= dt;
|
||
if (G.hurtFlash > 0) G.hurtFlash -= dt;
|
||
if (G.hp < G.hpMax && G.mode === 'splatter') bleedPlayer();
|
||
|
||
for (let i = zombies.length - 1; i >= 0; i--) {
|
||
const zb = zombies[i];
|
||
if (!zb) continue; // a bomb detonation splices several out mid-loop and can gut the array under us
|
||
zb.walkT += dt;
|
||
if (zb.burnT > 0 && burnTick(zb, i, dt)) continue; // the fire finished it — the array moved under us
|
||
if (!held) zb.z += zb.v * (zb.burnT > 0 ? TUNE.burnPanic : 1) * dt;
|
||
bleed(zb); // open wounds bleed every frame they're standing
|
||
|
||
if (zb.shieldT > 0) zb.shieldT -= dt; // staggered chair comes back up when this runs out
|
||
if (zb.hitT > 0) zb.hitT -= dt; // boss contact cooldown (see TUNE.bossHitCool)
|
||
// ---- the two RANGED attackers. Both wind up visibly before firing (TUNE.machWind / drawZombie's
|
||
// charge glow) because a shot you cannot see coming is not a fairness contract, it is a gotcha.
|
||
if (!held && !G.interlude && (zb.variant === 'm' || zb.variant === 'B') && G.mode === 'splatter'
|
||
&& zb.rollTo === undefined) { // a cart on the roll cannot aim — that is the window to close on it
|
||
const boss = zb.variant === 'B';
|
||
if (zb.salvo > 0) { // mid-volley: the rest of the balls are still coming
|
||
if ((zb.salvoT -= dt) <= 0) {
|
||
zb.salvo--; zb.salvoT = TUNE.bossSalvoGap;
|
||
fireAtPlayer(zb.x, zb.z, (Math.random() * 2 - 1) * 0.35, TUNE.bossShotSpeed); // aimed at where
|
||
} // you are NOW, with only a hand's width of scatter
|
||
} else if (zb.windT > 0) {
|
||
zb.windT -= dt;
|
||
if (zb.windT <= 0) {
|
||
if (boss) { zb.salvo = TUNE.bossVolley; zb.salvoT = 0; } // the volley starts next frame
|
||
else fireAtPlayer(zb.x, zb.z, 0, TUNE.machSpeed);
|
||
}
|
||
} else if ((zb.fireT -= dt) <= 0) {
|
||
zb.fireT = boss ? TUNE.bossCycle : TUNE.machCycle;
|
||
zb.windT = boss ? TUNE.bossWind : TUNE.machWind;
|
||
pock(0.3, 1500); // the tell: a mechanical click as it loads
|
||
}
|
||
}
|
||
if (G.mode === 'eaten' && G.eatenAt && zb.variant !== 'm') {
|
||
// You are down. Nothing else matters to them: every body on court converges on the spot and
|
||
// packs into a ring around it, and the ones already there feed (the bob is in drawZombie).
|
||
// This is the whole point of the death — you go under a CROWD, not under a puff of gore.
|
||
// Each one claims a SPOT around the body, kept from wherever it happened to be standing, so
|
||
// they form a ring and crowd in from all sides. Sending them all at the one point instead
|
||
// stacked every body on the same pixel and read as one fat zombie, not a swarm.
|
||
if (zb.eatA === undefined) {
|
||
zb.eatA = Math.atan2(zb.z - G.eatenAt.z, zb.x - G.eatenAt.x) + (Math.random() * 2 - 1) * 0.4;
|
||
zb.eatR = EATEN_FEED_R * (0.8 + Math.random() * 0.7);
|
||
}
|
||
const tx = G.eatenAt.x + Math.cos(zb.eatA) * zb.eatR;
|
||
const tz = G.eatenAt.z + Math.sin(zb.eatA) * zb.eatR * 0.55; // squashed in depth: the camera is low
|
||
const dx = tx - zb.x, dz = tz - zb.z, d = Math.hypot(dx, dz);
|
||
zb.feeding = d < 0.12;
|
||
if (!zb.feeding) {
|
||
const sp = zb.v * 1.8 * dt; // they hurry for a kill
|
||
zb.x += dx / d * Math.min(sp, d); zb.z += dz / d * Math.min(sp, d);
|
||
}
|
||
continue; // no shambling, no eating you twice, no crossing the baseline for a point
|
||
}
|
||
if (zb.variant === 'm') { // the machine wheels itself around instead of being bolted down
|
||
if (!held && G.mode === 'splatter' && !G.interlude) {
|
||
if (zb.rollTo !== undefined) {
|
||
const d = zb.rollTo - zb.x;
|
||
if (Math.abs(d) < 0.06) { zb.rollTo = undefined; zb.repoT = TUNE.machRepo * (0.7 + Math.random() * 0.6); }
|
||
else {
|
||
zb.x += Math.sign(d) * Math.min(Math.abs(d), TUNE.machRoll * dt);
|
||
if ((zb.rollT -= dt) <= 0) { pock(0.16, 150); zb.rollT = 0.4; } // castors grinding on the clay
|
||
}
|
||
} else if ((zb.repoT -= dt) <= 0) { zb.rollTo = (Math.random() * 2 - 1) * 3.6; zb.rollT = 0; }
|
||
}
|
||
if (zb.hp < zb.hpMax * 0.5 && Math.random() < dt * 6) smokePuff(zb.x, 1.1, zb.z, 0.25); // wrecked: it smokes where it stands
|
||
}
|
||
else if (held || zb.variant === 'r') { /* boss AI owns its x; a duelling opponent owns his */ }
|
||
else if (G.mode === 'splatter' && zb.z > COURT.netZ) { // on YOUR half they close in — each on its own lane, not a column
|
||
const tx = P.x + zb.homeOff;
|
||
zb.x += Math.sign(tx - zb.x) * Math.min(Math.abs(tx - zb.x), zb.v * 0.7 * dt)
|
||
+ Math.sin(zb.walkT * 2.3) * 0.25 * dt; // shamble wobble — the approach reads organic, not tracked
|
||
} else
|
||
zb.x += Math.sin(zb.walkT * 1.7) * 0.15 * dt; // far half: aimless shamble
|
||
// CONTACT IS STILL INSTANT DEATH — settled decision, and the ball machine's chip damage does not
|
||
// soften it. Two threats that demand two different responses is the point. The machine ('m') is
|
||
// furniture and cannot eat you.
|
||
if (!held && G.mode === 'splatter' && zb.variant !== 'm'
|
||
&& Math.hypot(zb.x - P.x, (zb.z - P.z) * 0.75) < TUNE.eatenR + (zb.variant === 'g' ? 0.2 : 0)) // z weighted down: depth is foreshortened, so a head-on shambler eats you when it LOOKS on top of you
|
||
killPlayer();
|
||
if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) {
|
||
const d = Math.hypot(ball.x - zb.x, ball.z - zb.z);
|
||
if (d < hitR(zb) + ballSweep && !(zb.hitT > 0)) { // brute and boss are bigger targets
|
||
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 (isBoss(zb)) { // a boss takes a heavy wound AND catches light
|
||
zb.hitT = TUNE.bossHitCool; // ...and the ball is SPENT on it: a boss is not
|
||
ball.lastHit = 'X'; ball.vx *= 0.3; ball.vz *= 0.3; // removed, so an unconsumed pierce ball
|
||
if (wound(zb, TUNE.dmgFlame)) splatter(i); // re-wounded it every single frame
|
||
else zb.burnT = TUNE.burnTime;
|
||
continue;
|
||
}
|
||
const vx0 = ball.vx, vz0 = ball.vz, bx = zb.x, bz = zb.z;
|
||
splatter(i); ball.vx = vx0; ball.vz = vz0; // undo splatter's pierce tax: full pace through the lane
|
||
ignite(bx, bz, TUNE.burnIgniteR); // ...and everyone standing near the body goes up
|
||
continue;
|
||
}
|
||
if (isBoss(zb)) { // boss: splat-quality wounds it; soft balls the racket one rallies (racketAI)
|
||
if ((ball.pow || 0) >= TUNE.zSplatQ) {
|
||
zb.hitT = TUNE.bossHitCool; // belt-and-braces: this ball is spent below, but the cooldown
|
||
const dead = wound(zb, TUNE.dmgBall); // is what stops ANY lingering contact multi-wounding
|
||
pock(0.6, 300);
|
||
ball.vx *= 0.3; ball.vz *= 0.3; ball.lastHit = 'X'; // spent on the hit
|
||
if (dead) { splatter(i); continue; } // killing blow: full gore + point
|
||
}
|
||
continue; // soft balls pass through ON PURPOSE, for racketAI to return — that rally IS the
|
||
// feedback that it wasn't hard enough, so no clank here; see HANDOFF
|
||
}
|
||
if (zb.variant === 'g' || zb.variant === 'm') { // brute AND the bolted-down machine: soak
|
||
if ((ball.pow || 0) >= TUNE.zSplatQ) { // splat-quality hits, never stagger back
|
||
zb.hp--; splat(0.5); pock(0.6, zb.variant === 'm' ? 700 : 220); // machine rings, meat thuds
|
||
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;
|
||
}
|
||
if (zb.variant === 'f') { splatter(i); continue; } // the sprinter is GLASS — any touch, any quality, it bursts
|
||
if (zb.variant === 'a' || zb.variant === 'b') { // classic shamblers soak a splat-quality wound before they drop
|
||
if ((ball.pow || 0) >= TUNE.zSplatQ) {
|
||
if (--zb.hp <= 0) { splatter(i); continue; } // the second splat is the killing blow
|
||
woundBurst(zb); splat(0.5); pock(0.6, 280); // first splat: a bloody wound, not a death
|
||
zb.z -= TUNE.zPushback * 0.5; ball.vx *= 0.3; ball.vz *= 0.3; ball.lastHit = 'X'; continue; // ball spent on the wound
|
||
}
|
||
zb.z -= TUNE.zPushback; pock(0.6, 300); ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; continue; // soft ball just staggers
|
||
}
|
||
// 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) { aside('ump_unsportsmanlike', 0.5); zombies.splice(i, 1); if (G.mode !== 'eaten' && !G.horde && !G.clearing) splatPoint('O'); } // it reached your baseline (during a mop-up, a straggler that reaches the line just despawns — no point for them)
|
||
}
|
||
|
||
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) continue;
|
||
if (Math.hypot(zb.x - pe.x, zb.z - pe.z) < hitR(zb) + TUNE.mgPelletR) {
|
||
// Pellets used to skip the boss entirely, so a GUN burst (and every horde) did literally
|
||
// nothing to him with no feedback at all. They CHIP him now: dmgPellet is fractional, so
|
||
// three rounds take a point off and the gun is a slow, committed answer rather than a no-op.
|
||
if (isBoss(zb)) { if (wound(zb, TUNE.dmgPellet, false)) killZombie(j, true); else pock(0.45, 240); }
|
||
else if ((zb.hp -= TUNE.dmgPelletHp) > 0) pock(0.5, 260); // only brutes soak now; a shambler drops to one round
|
||
else killZombie(j, true);
|
||
hit = true; break;
|
||
}
|
||
}
|
||
if (hit || pe.t <= 0) pellets.splice(i, 1);
|
||
}
|
||
|
||
// ---- INCOMING hostile balls (ball machine, chapter boss volleys). Three outcomes, in this order:
|
||
// a timed swing inside the volley window RETURNS it as your own ball flying up-court, the panic
|
||
// swish (meleeSwipe, above) merely bats it away, and reaching you unanswered costs a point of hp.
|
||
for (let i = shots.length - 1; i >= 0; i--) {
|
||
const s = shots[i];
|
||
s.t -= dt; s.vy -= s.g * dt;
|
||
s.x += s.vx * dt; s.y += s.vy * dt; s.z += s.vz * dt;
|
||
let done = s.t <= 0 || s.y < -0.25 || Math.abs(s.x) > 8 || s.z > COURT.L + 2;
|
||
// The PARRY keeps the 0.8 depth weighting — a racket sweep really is wider than it is deep.
|
||
// The HIT does not: weighting depth shrinks it, so contact registered while the ball was still
|
||
// most of a metre up-court. That mismatch between what you saw and what hit you was the bug.
|
||
const dP = Math.hypot(s.x - P.x, (s.z - P.z) * 0.8);
|
||
const dHit = Math.hypot(s.x - P.x, s.z - P.z);
|
||
if (!done && P.swingT >= 0 && P.swingT <= TUNE.parryWindow
|
||
&& dP < TUNE.parryReach && s.y < CONTACT.maxY) {
|
||
// the parry: it becomes an `extras` clone — that array is already ballistic and already kills
|
||
// zombies, so "returned shot is a live weapon" costs nothing to build.
|
||
// It is a GLANCING blow, not a mirror. Reversing the incoming line sent every return straight
|
||
// back up the line it arrived on — which is where the thrower is standing — so a boss was a
|
||
// free wound per throw. Where you meet the ball across the racket face now sets the angle:
|
||
// hitting it back INTO him is a shot you have to place your feet for.
|
||
const off = Math.max(-1, Math.min(1, (s.x - P.x) / TUNE.parryReach)); // -1..1 across the face
|
||
const ang = (off * TUNE.parryDeflect + (Math.random() * 2 - 1) * TUNE.parryJitter) * Math.PI / 180;
|
||
const sp = Math.hypot(s.vx, s.vz) * 1.15;
|
||
extras.push({ x: s.x, y: s.y, z: s.z, g: TUNE.gravity, pow: TUNE.parryPow, t: 2.5,
|
||
vx: Math.sin(ang) * sp, vy: 4.2, vz: -Math.cos(ang) * sp });
|
||
pock(0.85, 1300); G.flash = { text: 'RETURNED', t: 0.6 }; cheer(0.8, 0.3);
|
||
done = true;
|
||
}
|
||
if (!done && dHit < TUNE.shotHitR && s.y < TUNE.shotHitY) { hurtPlayer(TUNE.machDmg); done = true; }
|
||
if (done) shots.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 -= (e.g || TUNE.gravity) * dt; // e.g: a clone flies under the SAME topspin as the ball it came from
|
||
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) continue; // killZombie can sweep the court mid-loop
|
||
if (Math.hypot(zb.x - e.x, zb.z - e.z) < hitR(zb)
|
||
+ Math.hypot(e.vx, e.vz) * dt * 0.5) { // same sweep: clones inherit the smash pace
|
||
// Side balls used to pass THROUGH the boss doing nothing at all, which is most of why a
|
||
// TRIPLE serve read as "he can't be hurt". They wound him like the main ball now.
|
||
if (isBoss(zb)) {
|
||
if (e.pow >= TUNE.zSplatQ) { if (wound(zb, TUNE.dmgSide)) killZombie(j, !!G.horde); }
|
||
else pock(0.5, 300);
|
||
}
|
||
else 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 || zb.variant === 'f') {
|
||
if (zb.hp > 1) { zb.hp--; splat(0.4); } // wounds brutes AND shamblers
|
||
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;
|
||
if (p.smoke) { p.vy = 0.55; p.vx *= 0.97; p.vz *= 0.97; } // smoke rises and spreads; it does not fall
|
||
else 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];
|
||
// He scrubs HARD only while stopped and working the mop; walking past barely touches it, so
|
||
// the stain visibly dies under him rather than fading as he drifts by.
|
||
if (boy && Math.abs(boy.x - d.x) < 0.8 && Math.abs(boy.z - d.z) < 1.3)
|
||
d.a -= dt * (d.scorch ? 0.16 : boy.mopT > 0 ? 0.7 : 0.2); // craters scar the lawn far 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);
|
||
}
|
||
}
|