Add chapter bosses and a second damage channel; demote the racket zombie

Steps 4 and 5 of the late-game depth plan.

The ranged channel. A rogue ball machine gets wheeled onto the far court every
four games: it never walks, never crosses your baseline and never scores, so
it is a standing threat you choose when to deal with — the target-priority
decision the game was missing. Its shot is a real arc solved like a serve, not
a flat bolt, so it reads coming in and the swing window has something to meet.
Three outcomes: a swing inside the volley window returns it as your own ball
flying up-court (an extras clone, so "a parry is a weapon" cost nothing to
build), the panic swish bats it away, and letting it reach you costs blood.
The swish path is load-bearing, not politeness: in pretoss the player is
rooted and cannot swing at all.

Contact still kills outright — that stays a separate channel, and killPlayer()
is now the single death path for both causes so the run summary and the best-run
write cannot drift. Health is drawn on the man: procedural spatter at fixed
anchors, a live trickle, and a red wash from the screen edges. No bar. Healing
is winning a duel, which finally gives the duel a reason to be wanted.

Chapter bosses every ten games, with their own sheet — a colossal zombie
groundskeeper hauling a ball hopper, coming apart through arm-off, ribs-out and
headless-still-throwing. Chapter 1 is 10 hp and 2.7 m, chapter 4 is 34 hp and
3.67 m. It outranks the set and duel beats at a clear, because a boss walking
into a standing wave is unreadable. And it fixes the economy: a chapter-4 boss
costs 12 bombs or 100 rounds or 34 side balls against a per-type cap of 9, so
no single weapon can take one and a banked mixed arsenal is mandatory.

The racket zombie is demoted to an occasional opponent, as asked — appearing
every won game is exactly why he read as the boss.

Five silent bugs found on the way, all in HANDOFF: a blast deleted a 34 hp boss
outright (isBoss() exists to stop that class recurring), the machine's hp did
nothing because it fell through to the one-shot fallback, the boss both scored
and fired its card, the machine gated the hand-clear, and a boss died with a
shambler-sized gore burst.

Tennis untouched: 30 tapped points still 50 hits / 0 whiffs. 45-game soak clean
with all four chapters firing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-30 12:55:35 +02:00
1 parent ffac9b5512
commit 4a066e5cc6
14 files changed
+492 -131

No files matched your search

+186 -36
View File
@@ -3,10 +3,10 @@
// 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 } from './config.js';
import { TUNE, UI, COURT, VOLLEY, CONTACT } 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 } from './gameplay.js';
import { splatPoint, cheer, spentBall, resolveInterlude, showModal, endBossFight } 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
@@ -14,6 +14,12 @@ export const piles = []; // 3D-ish gore mounds {x, z, size 1..4, seed} — gro
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;
@@ -28,9 +34,88 @@ export function spawnZombie(x, z, variant) {
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 === 's') zb.shieldT = 0; // chair up; > 0 = seconds of stagger left
if (variant === 'm') { // the rogue ball machine: furniture, not a body. It does not walk at all.
zb.v = 0; zb.hp = TUNE.machHp; zb.fireT = TUNE.machCycle; zb.windT = 0;
}
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');
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 });
}
// ---- 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('order', 0.3); // 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) {
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 / TUNE.machSpeed);
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.
@@ -49,18 +134,25 @@ function pickVariant() {
// 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.
const hitR = zb => zb.variant === 'g' ? 0.75 : zb.variant === 'r' ? 0.7 : 0.55;
// 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 === '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 };
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
function killZombie(i, quiet) {
const zb = zombies[i];
const mul = GORE_MUL[zb.variant] || 1;
zombies.splice(i, 1);
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),
@@ -80,7 +172,10 @@ function killZombie(i, quiet) {
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
if (!G.horde && !G.clearing) splatPoint('P', quiet); // horde = survival, clearing = mopping up an already-won game — neither scores
// 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
@@ -115,7 +210,7 @@ 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 * (zb.variant === 'r' ? TUNE.bossBurnMul : 1) * dt, false);
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,
@@ -143,6 +238,22 @@ export function playerGore() {
// 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,
@@ -162,8 +273,20 @@ export function meleeSwipe(x, z, range) {
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 also BATS AWAY incoming shots. This is what gives you a defence in `pretoss`, where you
// are rooted to the spot and cannot swing at all — without it a machine winding up while you were
// lining up a serve was an unanswerable hit. It only destroys the shot; the timed rally parry below
// is the skilled option that sends it back as a weapon.
for (let i = shots.length - 1; i >= 0; i--) {
const s = shots[i];
if (Math.hypot(s.x - x, s.z - z) < range && s.y < 2.4) { shots.splice(i, 1); pock(0.7, 900); hit++; }
}
if (hit) swish(0.35);
return hit;
}
@@ -229,7 +352,7 @@ export function explode(x, z) {
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 (zb.variant === 'r') { if (wound(zb, TUNE.dmgBomb)) splatter(i); }
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
@@ -304,7 +427,7 @@ export function updateZombies(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) { // breathers, duels AND the mop-up after a won game get a quiet court (no new spawns)
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
@@ -336,6 +459,9 @@ export function updateZombies(dt) {
// 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];
@@ -346,33 +472,34 @@ export function updateZombies(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 (held || zb.variant === 'r') { /* the racket boss holds station — its AI owns its x */ }
// ---- 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') {
if (zb.windT > 0) {
zb.windT -= dt;
if (zb.windT <= 0) {
const n = zb.variant === 'B' ? TUNE.bossVolley : 1;
for (let k = 0; k < n; k++) fireAtPlayer(zb.x, zb.z, n === 1 ? 0 : (k - (n - 1) / 2) * 1.9);
}
} else if ((zb.fireT -= dt) <= 0) {
zb.fireT = zb.variant === 'B' ? TUNE.bossCycle : TUNE.machCycle;
zb.windT = TUNE.machWind;
pock(0.3, 1500); // the tell: a mechanical click as it loads
}
}
if (held || zb.variant === 'r' || zb.variant === 'm') { /* boss AI owns its x; the machine is bolted down */ }
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
if (!held && G.mode === 'splatter' && 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
gore.push({ x: P.x, z: P.z, r: 0.9, a: 0.9 });
for (let k = 0; k < 30; k++) parts.push({
x: P.x, y: 1.1, z: P.z,
vx: (Math.random() * 2 - 1) * 4.5, vy: 1 + Math.random() * 4, vz: (Math.random() * 2 - 1) * 4.5,
t: 0.5 + Math.random() * 0.5,
});
splat(1);
voice('game_set_massacre', 0.8); 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; G.mgT = 0; // everything stops; the eaten screen takes over
try { // the run is over — settle it against the best (games survived, sets as tiebreak)
const b = JSON.parse(localStorage.splatterBest || 'null') || { games: 0, sets: 0 };
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
}
// 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) { // brute and boss are bigger targets
@@ -381,7 +508,7 @@ export function updateZombies(dt) {
spentBall(); break; // zombies mutated — stop this frame's loop
}
if (ball.flame) { // burns through anything it touches — no quality gate, no chair, no pace tax
if (zb.variant === 'r') { // the boss takes a heavy wound AND catches light
if (isBoss(zb)) { // a boss takes a heavy wound AND catches light
if (wound(zb, TUNE.dmgFlame)) splatter(i);
else zb.burnT = TUNE.burnTime;
continue;
@@ -391,7 +518,7 @@ export function updateZombies(dt) {
ignite(bx, bz, TUNE.burnIgniteR); // ...and everyone standing near the body goes up
continue;
}
if (zb.variant === 'r') { // mini-boss: splat-quality wounds it; soft balls it rallies (racketAI)
if (isBoss(zb)) { // boss: splat-quality wounds it; soft balls the racket one rallies (racketAI)
if ((ball.pow || 0) >= TUNE.zSplatQ) {
const dead = wound(zb, TUNE.dmgBall);
pock(0.6, 300);
@@ -401,9 +528,9 @@ export function updateZombies(dt) {
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') { // brute: soaks splat-quality hits, never staggers back
if ((ball.pow || 0) >= TUNE.zSplatQ) {
zb.hp--; splat(0.5); pock(0.6, 220);
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
@@ -446,7 +573,7 @@ export function updateZombies(dt) {
// 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 (zb.variant === 'r') { if (wound(zb, TUNE.dmgPellet, false)) killZombie(j, true); else pock(0.45, 240); }
if (isBoss(zb)) { if (wound(zb, TUNE.dmgPellet, false)) killZombie(j, true); else pock(0.45, 240); }
else if (zb.hp > 1) { zb.hp--; pock(0.5, 260); } // brutes AND shamblers soak pellets — wound, don't kill
else killZombie(j, true);
hit = true; break;
@@ -455,6 +582,29 @@ export function updateZombies(dt) {
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;
const dP = Math.hypot(s.x - P.x, (s.z - P.z) * 0.8);
if (!done && P.swingT >= TUNE.swingWindup
&& P.swingT <= TUNE.swingWindup + TUNE.swingWindow * VOLLEY.window
&& dP < TUNE.reach * VOLLEY.reach && 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
extras.push({ x: s.x, y: s.y, z: s.z, g: TUNE.gravity, pow: TUNE.parryPow, t: 2.5,
vx: s.vx * 0.3, vy: 4.2, vz: -Math.abs(s.vz) * 1.15 });
pock(0.85, 1300); G.flash = { text: 'RETURNED', t: 0.6 }; cheer(0.8, 0.3);
done = true;
}
if (!done && dP < 0.55 && s.y < 2.0) { 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
@@ -468,7 +618,7 @@ export function updateZombies(dt) {
+ 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 (zb.variant === 'r') {
if (isBoss(zb)) {
if (e.pow >= TUNE.zSplatQ) { if (wound(zb, TUNE.dmgSide)) killZombie(j, !!G.horde); }
else pock(0.5, 300);
}