Make every weapon hurt the boss, drop his HP bar, un-cap the director
Steps 1-3 of the late-game depth plan, after Oliver's 50+ game playtest. "Sometimes the racket zombie doesn't seem harmable" was real, not a feeling. Three of the five rack slots did nothing at all to him and said nothing about it: TRIPLE side balls skipped him, GUN and horde pellets skipped him, fire couldn't ignite him. BOMB meanwhile deleted him outright ignoring his hp. All of it now routes through one wound() with a TUNE.dmg* table, so damage is proportional to the body and a future 20 hp boss needs no new code. Fractional damage banks on zb.chip so whole points only come off hp — the wound skins switch on hp thresholds. His contact radius goes to 0.7 m, because part of "unharmable" was just missing him. Melee still skips him and that's correct: he's on the far side of the net and melee can't span it. The HP bar is gone. It was the only floating UI on a body, it was on screen for nearly the whole run, and nobody knew what it meant — while the wound skins already say the same thing in the language every other multi-hit enemy uses. A one-per-run modal explains what he is instead. Skins and bleed anchors now read a fraction of hpMax, since his hp scales with the run. And the plateau: every difficulty dial bottomed out by about game 12, so games 12-50 were arithmetically identical. Body cap, spawn gap, shamble speed, variant mix, boss hp and horde size all keep climbing now, calibrated to track the old curve through game 12 rather than replace it. One deliberate exception: shamble speed was time-based and maxed during game 2, so the early game is genuinely gentler now — zSpeedPerGame if that reads flabby. HANDOFF carries the full spec for steps 4 and 5 (ranged attacks with player damage, then chapter bosses) including the decisions we settled, so the next session can pick it up cold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4cf54603f3
commit
ffac9b5512
6 files changed
+339
-54
No files matched your search
+111
-37
@@ -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, COURT } from './config.js';
|
||||
import { TUNE, UI, COURT } 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 } from './gameplay.js';
|
||||
import { splatPoint, cheer, spentBall, resolveInterlude, showModal } 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
|
||||
@@ -18,7 +18,10 @@ export const extras = []; // triple-serve side balls {x, y, z, vx, vy, vz, pow,
|
||||
let spawnT = 1.5, moanT = 3;
|
||||
|
||||
export function spawnZombie(x, z, variant) {
|
||||
const ramp = 1 + Math.min(1.5, G.splatT / 70); // they get faster the longer you survive
|
||||
// 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
|
||||
@@ -28,17 +31,26 @@ export function spawnZombie(x, z, variant) {
|
||||
zombies.push(zb);
|
||||
}
|
||||
|
||||
// which shambler steps out next: the roster grows one type per set (runner: set 1 game 3+,
|
||||
// brute: set 2, shield: set 3). Fixed random slots so the classics stay the crowd.
|
||||
// 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 tier = G.sets[0] + G.sets[1], r = Math.random();
|
||||
if (tier >= 2 && r < 0.15) return 's';
|
||||
if (tier >= 1 && r >= 0.15 && r < 0.30) return 'g';
|
||||
if ((tier >= 1 || G.runGames >= 2) && r >= 0.30 && r < 0.42
|
||||
&& !zombies.some(z => z.variant === 'f')) return 'f'; // at most one runner alive
|
||||
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');
|
||||
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.
|
||||
const hitR = zb => zb.variant === 'g' ? 0.75 : zb.variant === 'r' ? 0.7 : 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
|
||||
@@ -76,23 +88,39 @@ function killZombie(i, quiet) {
|
||||
// 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.variant === 'r' || zb.burnT > 0) continue; // the duel opponent doesn't burn
|
||||
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);
|
||||
zb.burnD = (zb.burnD || 0) + TUNE.burnDps * dt;
|
||||
while (zb.burnD >= 1) { zb.burnD -= 1; zb.hp--; }
|
||||
// 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);
|
||||
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 (zb.hp > 0) return false;
|
||||
if (!dead) return false;
|
||||
killZombie(i, !!G.horde);
|
||||
return true;
|
||||
}
|
||||
@@ -124,7 +152,11 @@ function woundBurst(zb) {
|
||||
|
||||
// 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 racket-zombie boss shrugs melee off entirely)
|
||||
// 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--) {
|
||||
@@ -157,8 +189,18 @@ function firePellet() {
|
||||
pock(0.25, 1400); // rapid fire tick; if it clips on device, gate to every 2nd pellet
|
||||
}
|
||||
export function spawnRacketZombie() { // one takes up residence on the far court — an opponent, of sorts
|
||||
// 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: TUNE.racketHp });
|
||||
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;
|
||||
@@ -169,7 +211,12 @@ function endHorde() {
|
||||
function tickHorde(dt) {
|
||||
const h = G.horde;
|
||||
h.t -= dt;
|
||||
if ((h.spawnT -= dt) <= 0) { spawnHordeZombie(); h.spawnT = 1 / TUNE.hordeRate; }
|
||||
// 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 / TUNE.mgRate; }
|
||||
if (h.t <= 0) endHorde();
|
||||
}
|
||||
@@ -177,8 +224,14 @@ function tickHorde(dt) {
|
||||
// bomb ball: everything inside bombRadius splatters at once (each kill still ++ball.kills so
|
||||
// the Phase-2 fanfare escalates), plus a crater — big stain, size-3 pile, extra particles, boom.
|
||||
export function explode(x, z) {
|
||||
for (let i = zombies.length - 1; i >= 0; i--) // backwards: splatter() splices (and can sweep) the array
|
||||
if (zombies[i] && Math.hypot(zombies[i].x - x, zombies[i].z - z) < TUNE.bombRadius) splatter(i);
|
||||
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 (zb.variant === 'r') { 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
|
||||
@@ -218,8 +271,13 @@ function bleed(zb) {
|
||||
if (zb.hp <= TUNE.bruteHp - 3) wounds.push([0.05, 2.35, 2.0]);
|
||||
if (zb.hp <= 1) wounds.push([0.0, 1.2, 0.7]);
|
||||
}
|
||||
else if (zb.variant === 'r' && zb.hp <= 1) 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 === 2) wounds = [[0.4, 1.2, 1.0]]; // racket: torn-off arm
|
||||
// the boss reads as a FRACTION of full health, not against hardcoded hp numbers: his hp scales
|
||||
// with the run now (racketHpPerGame), so `hp === 2` would leave a late-run boss bleeding from
|
||||
// nothing until the very last hit. Must stay in step with drawZombie's skin thresholds.
|
||||
else if (zb.variant === 'r' && zb.hp / (zb.hpMax || TUNE.racketHp) <= 0.34)
|
||||
wounds = [[0.05, 1.5, 2.4], [0.4, 1.15, 0.9]]; // headless: neck spurt + torn arm
|
||||
else if (zb.variant === 'r' && zb.hp / (zb.hpMax || TUNE.racketHp) <= 0.67)
|
||||
wounds = [[0.4, 1.2, 1.0]]; // racket: torn-off arm
|
||||
else if ((zb.variant === 'a' || zb.variant === 'b') && zb.hp < TUNE.shamblerHp) wounds = [[0.28, 1.35, 0.85]]; // shambler: gashed shoulder
|
||||
else return;
|
||||
for (const w of wounds) {
|
||||
@@ -248,11 +306,14 @@ export function updateZombies(dt) {
|
||||
}
|
||||
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)
|
||||
spawnT -= dt;
|
||||
// the spawn director: the match's age (games finished) sets both crowd size and tempo
|
||||
const cap = Math.min(TUNE.zMaxCap, TUNE.zMax + G.runGames);
|
||||
// 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 (spawnT <= 0 && zombies.length < cap) {
|
||||
spawnZombie((Math.random() * 2 - 1) * 4.8, -1.5 - Math.random(), pickVariant());
|
||||
spawnT = Math.max(TUNE.zSpawnMin, TUNE.zSpawnEvery - G.runGames * TUNE.zSpawnStep);
|
||||
spawnT = Math.max(TUNE.zSpawnMin, TUNE.zSpawnEvery / (1 + G.runGames * TUNE.zSpawnAccel));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -314,15 +375,16 @@ export function updateZombies(dt) {
|
||||
}
|
||||
if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) {
|
||||
const d = Math.hypot(ball.x - zb.x, ball.z - zb.z);
|
||||
if (d < (zb.variant === 'g' ? 0.75 : 0.55) + ballSweep) { // the brute is a bigger target
|
||||
if (d < hitR(zb) + ballSweep) { // 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 (zb.variant === 'r') { // the boss only takes a wound
|
||||
zb.hp--; splat(0.5);
|
||||
if (zb.hp <= 0) { splatter(i); } continue;
|
||||
if (zb.variant === 'r') { // the boss takes a heavy wound AND catches light
|
||||
if (wound(zb, TUNE.dmgFlame)) splatter(i);
|
||||
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
|
||||
@@ -331,11 +393,13 @@ export function updateZombies(dt) {
|
||||
}
|
||||
if (zb.variant === 'r') { // mini-boss: splat-quality wounds it; soft balls it rallies (racketAI)
|
||||
if ((ball.pow || 0) >= TUNE.zSplatQ) {
|
||||
zb.hp--; splat(0.5); pock(0.6, 300);
|
||||
const dead = wound(zb, TUNE.dmgBall);
|
||||
pock(0.6, 300);
|
||||
ball.vx *= 0.3; ball.vz *= 0.3; ball.lastHit = 'X'; // spent on the hit
|
||||
if (zb.hp <= 0) { splatter(i); continue; } // killing blow: full gore + point
|
||||
if (dead) { splatter(i); continue; } // killing blow: full gore + point
|
||||
}
|
||||
continue; // soft balls pass through, for the AI to return
|
||||
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) {
|
||||
@@ -377,9 +441,13 @@ export function updateZombies(dt) {
|
||||
let hit = false;
|
||||
for (let j = zombies.length - 1; j >= 0; j--) {
|
||||
const zb = zombies[j];
|
||||
if (!zb || zb.variant === 'r') continue;
|
||||
if (Math.hypot(zb.x - pe.x, zb.z - pe.z) < (zb.variant === 'g' ? 0.75 : 0.55)) {
|
||||
if (zb.hp > 1) { zb.hp--; pock(0.5, 260); } // brutes AND shamblers soak pellets — wound, don't kill
|
||||
if (!zb) continue;
|
||||
if (Math.hypot(zb.x - pe.x, zb.z - pe.z) < hitR(zb)) {
|
||||
// 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); }
|
||||
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;
|
||||
}
|
||||
@@ -395,10 +463,16 @@ export function updateZombies(dt) {
|
||||
let dead = e.t <= 0 || Math.abs(e.x) > 7.5 || e.z < -2 || e.z > 25.5;
|
||||
if (!dead && e.y < 1.9) for (let j = zombies.length - 1; j >= 0; j--) {
|
||||
const zb = zombies[j];
|
||||
if (!zb || zb.variant === 'r') continue; // side balls pass the boss; !zb: kills can sweep mid-loop
|
||||
if (Math.hypot(zb.x - e.x, zb.z - e.z) < (zb.variant === 'g' ? 0.75 : 0.55)
|
||||
if (!zb) 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
|
||||
if (zb.variant === 's' && e.vz < 0 && !(zb.shieldT > 0)) { zb.shieldT = TUNE.shieldDown; pock(0.9, 1800); } // chair blocks these too
|
||||
// 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 (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);
|
||||
|
||||
Reference in new issue
Block a user