The rack had five hardcoded slots where the index WAS the weapon, spelled out in
five places: SLOTS, slotStock, WDESC, drawRack's three parallel arrays,
deliverTin's w+1, and bare wsel === 1|2|3 / G.weapon === 4 tests across five
files. config.WEAPONS is the one table now, G.rack holds up to four {id, n} in
the order you GATHERED them, and slot 0 stays BALL. Everything reads through
slotId/slotStock/slotEntry, so nothing else knows how the rack is stored.
A fifth weapon is always allowed, per Oliver: it rides slot 0 as G.temp until
spent, then slot 0 goes back to the infinite ball. A barrel arms on the spot and
is spent quickly, so refusing it would mean a barrel you shot did nothing. And a
spent weapon LEAVES its slot -- with gathered slots, an exhausted weapon holding
one is a quarter of the rack you can never pick up into. (The old rack kept its
empties dimmed, which was right when a slot meant "BOMB goes here".)
Four new weapons, and they are all the ONE existing pellet loop. firePellet(w)
stamps the WEAPONS row onto the round -- r, dmg, hp, pierce, boom, ignite, col,
speed, life -- so the beacon cannon, flamethrower, laser and rocket needed no new
array and no new update; the break after the first body was the only thing making
a pellet single-target. All four go through the same isBoss branch, which is why
none can silently no-op on a boss the way three of five once did. Measured:
gun/beacon/laser/rocket reach past 16 m, the flamer is a ~6 m cone that ignites.
G.mgT stays the mode timer for all of them, so the eighteen !(G.mgT > 0) guards
across five files did not have to change. kind:'burst' auto-fires at its own rate
for len seconds; kind:'shot' holds the same braced stance and fires one per tap.
The barrels reuse the ball machine's shape: a rolling, shootable non-body. All
three of the registrations that fail silently are done -- blocksClear (loot must
not gate the hand-clear), the contact guard (furniture cannot eat you), and
hitR/GORE_MUL/zombieH. It scores nothing.
The pink had to become procedural. The chroma key deletes every pixel where red
and blue both beat green by 40, so a magenta drum and a magenta laser pistol both
came back as hollow outlines -- 19% and 15% solid against 50-56% for the icons in
other colours. The drum is grey steel with hazard chevrons and every bit of its
pink is the halo drawBarrel paints; the laser went electric blue. Both are solid
now (92% and 58%), and the rule is written down in HANDOFF.
tinTriples 3 -> 10 as asked, and the four dead TUNE ammo constants are gone --
nothing read them once the table landed, which is a panel value you can tune all
day for no effect.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
978 lines
62 KiB
JavaScript
978 lines
62 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, WEAPONS, BARREL_WEAPONS, woundStage, EATEN_BURST, EATEN_SUMMARY, 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, grantWeapon, weaponModal } 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;
|
||
|
||
// Every body, decal and projectile on the court, plus this module's own timers. Called by
|
||
// gameplay.restartRun — a restart is in-place now, not a page reload.
|
||
export function resetZombies() {
|
||
for (const a of [zombies, gore, piles, parts, pellets, extras, shots]) a.length = 0;
|
||
spawnT = 1.5; moanT = 3; mgTick = 0;
|
||
}
|
||
|
||
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
|
||
if (variant === 'e') zb.hp = TUNE.ballboyHp; // the ball boy: shambler-sized, three hits deep
|
||
if (variant === 'h') { zb.v *= 0.75; zb.hp = TUNE.bouncerHp; } // the bouncer: between the steward and the brute
|
||
if (variant === 'k') { zb.v *= 0.45; zb.hp = TUNE.rollerHp; } // the roller man: slower and heavier than the brute
|
||
// 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 ('fgsdehk'.includes(variant) && !G.seen['v' + variant]) { G.seen['v' + variant] = true; aside('ump_dress_code', 0.8); }
|
||
if (variant === 'o') { // the weapon barrel: a rolling pink target with a weapon inside it
|
||
zb.v = 0; zb.hp = zb.hpMax = TUNE.barrelHp;
|
||
zb.wid = BARREL_WEAPONS[Math.floor(Math.random() * BARREL_WEAPONS.length)];
|
||
zb.dir = Math.random() < 0.5 ? -1 : 1; zb.roll = 0;
|
||
}
|
||
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);
|
||
}
|
||
|
||
// A WEAPON BARREL, rolled across the far court. Shoot it and you get what is inside, armed on the
|
||
// spot. It is the only source of the four weapons that are not on the duel ladder, and it is pure
|
||
// upside — it never attacks, never scores, and rolls off on its own if you ignore it.
|
||
export function spawnBarrel() {
|
||
spawnZombie((Math.random() * 2 - 1) * 3.2, 4.5 + Math.random() * 3, 'o');
|
||
if (!G.seen.barrel) {
|
||
G.seen.barrel = true;
|
||
showModal({ title: 'A GLOWING BARREL', color: UI.pink,
|
||
body: ['THERE IS A WEAPON INSIDE IT', 'SHOOT IT AND IT IS YOURS', 'PINK MEANS TAKE THE SHOT'] });
|
||
}
|
||
}
|
||
|
||
// 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 art = BOSS_ART[(chapter - 1) % BOSS_ART.length]; // which of the boss designs this chapter is
|
||
// Every behaviour field on the design is OPTIONAL and falls back to the shared TUNE number, so a
|
||
// design row that carries nothing but art behaves exactly the way a boss did before there were any.
|
||
const hp = Math.round((TUNE.bossHp + (chapter - 1) * TUNE.bossHpPerChapter) * (art.hpMul ?? 1));
|
||
zombies.push({ x: 0, z: -1.2, v: TUNE.bossSpeed * (art.speedMul ?? 1), walkT: 0, homeOff: 0, variant: 'B',
|
||
chapter, hp, hpMax: hp, scale: TUNE.bossScale + (chapter - 1) * TUNE.bossScalePer,
|
||
fireT: art.cycle ?? TUNE.bossCycle, windT: 0, salvo: 0, salvoT: 0,
|
||
dist: 0, dir: Math.random() < 0.5 ? -1 : 1, moveT: 0, // dist drives the stride: see drawZombie
|
||
art });
|
||
}
|
||
|
||
// ---- BOSS MOVEMENT. Before this the boss had no AI at all — it matched no branch in the variant
|
||
// chain below and fell through to the crowd's aimless ±0.15 m/s wobble, so "the boss moves" was a
|
||
// comment and nothing else. Each design owns a style (BOSS_ART.move) and they differ in the axis
|
||
// they threaten on: pace/bounce make you lead a moving target, mirror takes your lane away, charge
|
||
// trades its ranged game for closing the distance. Returns nothing; it moves zb.x/zb.z directly.
|
||
//
|
||
// Two rules hold for all of them:
|
||
// - IT PLANTS ITS FEET TO THROW. While winding up or mid-volley it does not move, which is the same
|
||
// fairness contract as the wind-up glow: the telegraph has to be worth reading.
|
||
// - IT STAYS IN THE BAND (bossZMin..bossZMax) unless it is charging. Uncapped, a boss ended the
|
||
// fight by simply arriving on top of you after about a minute — and every extra hp made that
|
||
// outcome MORE likely, not less. Only 'charge' is allowed through, and that is its whole threat.
|
||
// A NOTE THAT DECIDED THREE OF THESE FIVE: in splatter the player is rooted in `pretoss` for nearly
|
||
// the whole of a boss fight (he only walks during `rally` — a settled decision). So any style whose
|
||
// motion is "close on the player's x" arrives, stops, and is static for the rest of the fight, which
|
||
// is the exact complaint this work exists to fix. Every style below therefore moves under its OWN
|
||
// power and only BIASES toward the player. Measured: each of the six covers 5-14 m a minute.
|
||
function bossAI(zb, dt) {
|
||
if (zb.windT > 0 || zb.salvo > 0) return; // planted: throwing is not a moving action
|
||
const x0 = zb.x, z0 = zb.z, style = zb.art?.move || 'advance';
|
||
const strafe = TUNE.bossStrafe * (zb.art?.paceMul ?? 1);
|
||
if (style === 'charge') {
|
||
zb.moveT -= dt;
|
||
if (zb.charging) {
|
||
const dx = P.x - zb.x;
|
||
zb.x += Math.sign(dx) * Math.min(Math.abs(dx), TUNE.bossStrafe * dt);
|
||
zb.z = Math.min(P.z, zb.z + TUNE.bossChargeV * dt); // AT you and NO FURTHER: unclamped it ran
|
||
if (zb.moveT <= 0) { zb.charging = false; zb.moveT = TUNE.bossChargeRest; } // off the back of the court
|
||
} else {
|
||
// It withdraws LESS than it charged, so the fight has a visible clock: it is a couple of metres
|
||
// nearer after every rush and reaches you in about half a minute. A withdrawal that undid the
|
||
// whole charge made "the body is the weapon" untrue — it never once got near you.
|
||
zb.z = Math.max(TUNE.bossZMin, zb.z - TUNE.bossChargeV * 0.45 * dt);
|
||
if (zb.moveT <= 0) { zb.charging = true; zb.moveT = TUNE.bossChargeT; }
|
||
}
|
||
} else if (style === 'shadow') {
|
||
// paces — but the ground it paces over is centred on YOU, so it never stops moving and it takes
|
||
// your lane away the moment you take one
|
||
const c = Math.max(-TUNE.bossPaceX, Math.min(TUNE.bossPaceX, P.x));
|
||
zb.x += zb.dir * strafe * dt;
|
||
if (zb.x > c + TUNE.bossShadowX) zb.dir = -1; else if (zb.x < c - TUNE.bossShadowX) zb.dir = 1;
|
||
zb.z += zb.v * dt;
|
||
} else if (style === 'bounce') {
|
||
// hops, not a slide: it covers bossHopX per hop and is nearly still between them, so it reads as
|
||
// a costume with someone inside it rather than a sprite being lerped across the court. It flips
|
||
// every hop and only steers when it has drifted a long way off your lane.
|
||
zb.moveT -= dt;
|
||
if (zb.moveT <= 0) {
|
||
zb.moveT = TUNE.bossHopT;
|
||
// keep going the SAME way until it runs out of court or has overshot your lane, then turn.
|
||
// Flipping every hop kept it oscillating inside one metre — motion with no travel in it.
|
||
if (Math.abs(zb.x) > TUNE.bossPaceX) zb.dir = -Math.sign(zb.x);
|
||
else if (Math.abs(P.x - zb.x) > 2.5) zb.dir = Math.sign(P.x - zb.x) || zb.dir;
|
||
}
|
||
const k = Math.max(0, Math.sin(Math.PI * (1 - zb.moveT / TUNE.bossHopT))); // fast at mid-hop
|
||
zb.x += zb.dir * (TUNE.bossHopX / TUNE.bossHopT) * k * dt;
|
||
zb.z += zb.v * dt;
|
||
} else if (style === 'surge') {
|
||
// rolls up the whole band and back down it, over and over, drifting slowly into your lane. Its
|
||
// DEPTH is the threat, not its x — the one that simply keeps coming, and keeps coming back.
|
||
zb.z += zb.dir * zb.v * 3 * dt;
|
||
if (zb.z > TUNE.bossZMax) zb.dir = -1; else if (zb.z < TUNE.bossZMin) zb.dir = 1;
|
||
const dx = P.x - zb.x;
|
||
zb.x += Math.sign(dx) * Math.min(Math.abs(dx), strafe * 0.3 * dt);
|
||
} else if (style === 'pace') {
|
||
zb.x += zb.dir * strafe * dt;
|
||
if (Math.abs(zb.x) > TUNE.bossPaceX) { zb.dir = -Math.sign(zb.x); zb.x = Math.sign(zb.x) * TUNE.bossPaceX; }
|
||
zb.z += zb.v * dt;
|
||
} else { // 'advance': the old crawl, with a slow drift.
|
||
zb.x += Math.sin(zb.walkT * 0.6) * 0.4 * dt; // Unused by any design — it is what a row with
|
||
zb.z += zb.v * dt; // no `move` field falls back to.
|
||
}
|
||
if (style !== 'charge') zb.z = Math.max(TUNE.bossZMin, Math.min(TUNE.bossZMax, zb.z));
|
||
zb.x = Math.max(-TUNE.bossPaceX - 0.6, Math.min(TUNE.bossPaceX + 0.6, zb.x)); // never off the sides
|
||
// The stride steps off GROUND COVERED (the brute's rule). A strafing boss read off zb.z alone
|
||
// would slide sideways with both feet frozen — the exact defect the distance rule exists to stop.
|
||
zb.dist = (zb.dist || 0) + Math.hypot(zb.x - x0, zb.z - z0);
|
||
}
|
||
|
||
// ---- 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');
|
||
// The late roster, in the order the crowd should learn it: a stock-sized body that no longer dies
|
||
// to two (game 3), then a bigger one (game 8), then something heavier than the brute (game 15).
|
||
if (g >= 3) pool.push('e');
|
||
if (g >= 8) pool.push('h');
|
||
if (g >= 15) pool.push('k');
|
||
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 === 'o' ? 0.7 // the barrel: a wide low drum, generous on purpose
|
||
: zb.variant === 'k' ? 0.88 : zb.variant === 'h' ? 0.68 // the roller man is wider than the brute
|
||
: 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, e: 1, f: 1.8, s: 2.5, h: 2.5, g: 4, k: 5, r: 3, m: 1.5, o: 0, B: 6 }; // a chapter boss comes apart like nothing else on the court; a barrel is not meat at all
|
||
|
||
// A barrel bursts and hands you what was inside, armed. Pink debris rather than gore — it is a
|
||
// container, not a body — and the modal only fires the FIRST time you see each weapon, so the tenth
|
||
// rocket does not stop the world to explain itself.
|
||
function popBarrel(zb) {
|
||
boom(0.7); G.shake = { t: 0.22, mag: 4 }; G.boomFlash = 0.08;
|
||
for (let k = 0; k < 30; k++) parts.push({
|
||
x: zb.x, y: 0.7, z: zb.z,
|
||
vx: (Math.random() * 2 - 1) * 4, vy: 1.2 + Math.random() * 3.5, vz: (Math.random() * 2 - 1) * 4,
|
||
t: 0.35 + Math.random() * 0.5, pink: true });
|
||
grantWeapon(zb.wid);
|
||
const w = WEAPONS[zb.wid];
|
||
G.flash = { text: w.label, t: 1.1 };
|
||
if (!G.seen['w' + zb.wid]) { G.seen['w' + zb.wid] = true; weaponModal(zb.wid); }
|
||
}
|
||
|
||
// 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 === 'o') { popBarrel(zb); return; } // a container: it pays out and scores NOTHING
|
||
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;
|
||
// One round, for every barrel-fed weapon there is. The WEAPONS row is stamped onto the pellet, so
|
||
// the loop below serves the machine gun, the beacon cannon, the flamethrower, the laser and the
|
||
// rocket without a second array or a second update — the only things that ever differed were a
|
||
// radius, a damage number, whether it stops at the first body, and what it does when it lands.
|
||
// Called with no argument by the HORDE, which is the plain machine gun.
|
||
export function firePellet(w = WEAPONS.gun) {
|
||
const spread = w.life ? TUNE.mgSpread * 2.4 : TUNE.mgSpread; // a short-lived round is a CONE, not a line
|
||
const a = (Math.random() * 2 - 1) * spread * Math.PI / 180, s = w.speed ?? 26; // 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: w.life ?? 1.2, r: w.r ?? TUNE.mgPelletR, dmg: w.dmg ?? TUNE.dmgPellet, hp: w.hp ?? TUNE.dmgPelletHp,
|
||
pierce: !!w.pierce, boom: !!w.boom, ignite: w.ignite ? (w.igniteR ?? 1.8) : 0, col: w.col, glow: w.glow });
|
||
if (++mgTick % 2 === 0) pock(0.25, w.boom ? 420 : w.pierce ? 2100 : 1400); // 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.
|
||
// Where each body opens up, one anchor per WOUND STAGE and cumulative: at stage n it bleeds from
|
||
// the first n. Read through the same config.woundStage call the skins are, and deliberately the same
|
||
// length as that variant's ZWOUND row, so the blood and the drawing it comes out of cannot drift —
|
||
// the two used to be written by hand against different constants. (The brute's old cumulative
|
||
// if-chain is what this replaces, and it reproduces it exactly. The steward gained a trickle here
|
||
// that his wound skin had always implied and never had.)
|
||
const BLEED = {
|
||
a: [[0.28, 1.35, 0.85]],
|
||
b: [[0.28, 1.35, 0.85]],
|
||
d: [[0.30, 1.62, 0.90]],
|
||
e: [[0.28, 1.35, 0.85], [0.05, 1.55, 1.60]], // gashed, then throat open
|
||
h: [[0.32, 1.72, 0.95], [-0.30, 1.68, 0.90], [0.05, 2.05, 1.80]],
|
||
g: [[-0.30, 1.95, 1.10], [0.42, 1.90, 1.00], [0.05, 2.35, 2.00], [0.00, 1.20, 0.70]], // arm, both, head, gut
|
||
k: [[-0.36, 2.30, 1.15], [0.50, 2.24, 1.05], [0.06, 2.80, 2.10], [0.00, 1.50, 0.75]], // the same, a head higher
|
||
};
|
||
function bleed(zb) {
|
||
if (parts.length > 90) return;
|
||
let wounds;
|
||
const anchors = BLEED[zb.variant];
|
||
// 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.
|
||
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
|
||
// Still sparse as the damage stacks — more ANCHORS, not a heavier flow (blood stays thin).
|
||
else if (anchors && woundStage(zb, anchors.length)) wounds = anchors.slice(0, woundStage(zb, anchors.length));
|
||
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;
|
||
// whichever gun-like weapon is armed sets the rate and the round (see config.WEAPONS). A
|
||
// kind:'shot' weapon has no rate — it fires on the TAP instead (gameplay.tryGunShot).
|
||
const gw = WEAPONS[G.gunId] || WEAPONS.gun;
|
||
if (G.phase === 'rally' && gw.rate && (G.mgCool -= dt) <= 0) { firePellet(gw); G.mgCool = 1 / gw.rate; }
|
||
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;
|
||
const faceX0 = zb.x; // which way he is walking, for the sprite mirror in drawZombie
|
||
if (zb.burnT > 0 && burnTick(zb, i, dt)) continue; // the fire finished it — the array moved under us
|
||
// a chapter boss is excluded: bossAI owns BOTH of its axes, and leaving this in advanced it twice
|
||
if (!held && zb.variant !== 'B') 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', A = zb.art;
|
||
if (zb.salvo > 0) { // mid-volley: the rest of the balls are still coming
|
||
zb.salvoT -= dt;
|
||
// A gap of 0 means the WHOLE volley leaves in this one frame — the mascot's wall of five,
|
||
// which you dodge rather than parry. The loop still terminates: salvo counts down either way.
|
||
const gap = A?.gap ?? TUNE.bossSalvoGap, spread = A?.spread ?? 0.35;
|
||
while (zb.salvo > 0 && zb.salvoT <= 0) {
|
||
zb.salvo--; zb.salvoT += gap;
|
||
fireAtPlayer(zb.x, zb.z, (Math.random() * 2 - 1) * spread, A?.shot ?? 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 = A?.volley ?? 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 ? (A?.cycle ?? TUNE.bossCycle) : TUNE.machCycle;
|
||
zb.windT = boss ? (A?.wind ?? 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' && G.eatenT < EATEN_SUMMARY) {
|
||
// 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 (G.mode === 'eaten' && G.eatenAt) {
|
||
// The summary is up. They have finished with you: the pack breaks up and shambles away up-court
|
||
// instead of standing on the body twitching in a heap for as long as the screen is on.
|
||
zb.feeding = false;
|
||
const away = Math.sign(zb.x - G.eatenAt.x) || (zb.walkT % 2 < 1 ? 1 : -1);
|
||
zb.x += away * zb.v * 0.5 * dt;
|
||
zb.z -= zb.v * 0.7 * dt; // back the way they came
|
||
zb.face = away;
|
||
continue;
|
||
}
|
||
if (zb.variant === 'o') { // the barrel just rolls, turning at the sidelines. It never attacks.
|
||
if (!held && G.mode === 'splatter' && !G.interlude) {
|
||
zb.x += zb.dir * TUNE.barrelRoll * dt;
|
||
zb.roll += TUNE.barrelRoll * dt; // drawn spin, so it reads as ROLLING
|
||
if (Math.abs(zb.x) > 4.4) { zb.dir = -Math.sign(zb.x); zb.x = Math.sign(zb.x) * 4.4; }
|
||
}
|
||
}
|
||
else 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') { /* nothing moves on a changeover; a duelling opponent owns his x */ }
|
||
else if (zb.variant === 'B') { if (G.mode === 'splatter' && !G.interlude) bossAI(zb, dt); }
|
||
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' && !'mo'.includes(zb.variant) // furniture and barrels cannot eat you
|
||
&& 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 (Math.abs(zb.x - faceX0) > 0.004) zb.face = Math.sign(zb.x - faceX0); // sticky: a body standing still keeps facing where it last walked
|
||
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) + (pe.r ?? 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.
|
||
// EVERY new weapon comes through here, which is why none of them can silently no-op on a
|
||
// boss the way three of the five once did.
|
||
if (pe.ignite) ignite(pe.x, pe.z, pe.ignite); // the flamer lights the spot, and fire spreads itself
|
||
if (isBoss(zb)) { if (wound(zb, pe.dmg ?? TUNE.dmgPellet, false)) killZombie(j, true); else pock(0.45, 240); }
|
||
else if ((zb.hp -= (pe.hp ?? TUNE.dmgPelletHp)) > 0) pock(0.5, 260); // only brutes soak now; a shambler drops to one round
|
||
else killZombie(j, true);
|
||
if (pe.boom) { explode(pe.x, pe.z); hit = true; break; } // the rocket takes the crowd, not the body
|
||
hit = !pe.pierce; // a laser keeps going down the whole lane
|
||
if (hit) 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];
|
||
// A shot landing the KILLING blow routes through killPlayer, which empties this array from
|
||
// under us — every remaining index then reads undefined and the frame throws. Same guard, and
|
||
// the same reason, as the `if (!zb) continue` in the body loop above.
|
||
if (!s) continue;
|
||
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);
|
||
}
|
||
}
|