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:
1 parent
ffac9b5512
commit
4a066e5cc6
14 files changed
+492
-131
No files matched your search
+186
-36
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user