Three more chapters, and at game 100 the champion returns everything
The roster ran out at chapter 6 and then repeated itself; game 70 got the
groundskeeper again, bigger. bossArt() now walks nine designs, one per chapter,
and hands out THE CHAMPION on every tenth — the floor(ch/10) term takes his
chapters back out of the count so chapter 11 opens the second lap on the
groundskeeper rather than skipping him.
70 THE STRINGER — leather apron, bandoliers of gut string, racket frames on
his back. He takes your lane and puts two dead-straight ones on it.
80 THE TOURNAMENT DIRECTOR — blazer, lanyard, radio. `adds` is new: he calls
stewards onto the court while you fight him, which is the one thing a boss
fight could not do before, since the stage is cleared for it.
90 THE STRAWBERRY SELLER — tray of cream, apron soaked red, ladle. A tank
that sprays four at once and then comes for you.
100 THE CHAMPION — torn whites, a laurel of strapping tape, the trophy still
in one fist. `rally`: anything under splatter quality comes straight back
at your head, so he is the one body you have to out-HIT rather than
out-shoot. `phases` lays three rows over his own as his health falls — he
holds the baseline and rallies, then runs the line drilling volleys, then
drops to a charge — and each turn is an event: cry, shake, the stand up.
Thirty-six frames through assets/_regen.py, the mascot's three-batch recipe
(2 stride, 3 wounds, 4 throws). The trap that cost the re-rolls: a throw batch
references its own wound frames, so a wounds batch that fails leaves the throw
sheet drawn against the SEED — two of the four came back half groundskeeper.
Wounds first, verify, then throws.
And the physio, who Oliver reported "does basically nothing but move towards
the net": he kept 4.2 of every 6.3 m he charged, so the fight was always over
before he arrived. `withdraw` is his own number now (0.2) and he charges half
again as fast — measured, he is on top of you in 11 s instead of never — and he
drills three balls a cycle on the way in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f4c6f408e7
commit
04ee0174ce
42 files changed
+353
-72
No files matched your search
+57
-11
@@ -3,7 +3,7 @@
|
||||
// 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, CONTACT, BOSS_ART, WEAPONS, BARREL_WEAPONS, woundStage, zombieH, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js';
|
||||
import { TUNE, UI, COURT, CONTACT, BOSS_ART, bossArt, WEAPONS, BARREL_WEAPONS, woundStage, zombieH, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js';
|
||||
import { G, P, ball, sendBoy, setupPoint } from './state.js';
|
||||
import { splat, moan, pock, thud, zap, voice, boom, swish, shriek, aww, sfx , whoosh, flame } from './audio.js';
|
||||
import { splatPoint, cheer, spentBall, resolveInterlude, endBossFight, aside, grantWeapon, endBurst, sliceCarom } from './gameplay.js';
|
||||
@@ -124,7 +124,7 @@ export function spawnMachine() {
|
||||
// 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
|
||||
const art = bossArt(chapter); // which of the boss designs this chapter is (every tenth = THE CHAMPION)
|
||||
// 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));
|
||||
@@ -152,22 +152,38 @@ export function spawnChapterBoss(chapter) {
|
||||
// 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.
|
||||
// WHICH PHASE a boss is in, or -1 for a design that has none. Health drives it, so it is derived
|
||||
// rather than stored and cannot desync from the hp it is meant to describe.
|
||||
const bossPhase = zb => {
|
||||
const n = zb.art?.phases?.length;
|
||||
return n ? Math.min(n - 1, Math.floor((1 - zb.hp / zb.hpMax) * n)) : -1;
|
||||
};
|
||||
// The design a boss is FIGHTING as, this instant: its own row, with the current phase's fields laid
|
||||
// over it. Everything that reads a boss's behaviour goes through here, so a phase stays a data row
|
||||
// and never becomes a second code path. (There is one boss on court; the object per call is free.)
|
||||
export const bossPlan = zb => {
|
||||
const A = zb.art || {}, i = bossPhase(zb);
|
||||
return i < 0 ? A : { ...A, ...A.phases[i] };
|
||||
};
|
||||
|
||||
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);
|
||||
const x0 = zb.x, z0 = zb.z, A = bossPlan(zb), style = A.move || 'advance';
|
||||
const strafe = TUNE.bossStrafe * (A.paceMul ?? 1);
|
||||
if (style === 'charge') {
|
||||
zb.moveT -= dt;
|
||||
const v = TUNE.bossChargeV * (A.speedMul ?? 1);
|
||||
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
|
||||
zb.z = Math.min(P.z, zb.z + v * 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);
|
||||
// It withdraws LESS than it charged, so the fight has a visible clock: it is nearer after every
|
||||
// rush and arrives while you are still fighting it. A withdrawal that undid the whole charge
|
||||
// made "the body is the weapon" untrue — it never once got near you, which is exactly what the
|
||||
// physio's old 0.45 still did over a whole fight (`withdraw` is per design now).
|
||||
zb.z = Math.max(TUNE.bossZMin, zb.z - v * (A.withdraw ?? 0.45) * dt);
|
||||
if (zb.moveT <= 0) { zb.charging = true; zb.moveT = TUNE.bossChargeT; }
|
||||
}
|
||||
} else if (style === 'shadow') {
|
||||
@@ -214,6 +230,17 @@ function bossAI(zb, dt) {
|
||||
zb.dist = (zb.dist || 0) + Math.hypot(zb.x - x0, zb.z - z0);
|
||||
}
|
||||
|
||||
// HE CALLS SECURITY. A design with `adds` puts bodies on the court while you fight it — the one
|
||||
// thing a boss fight could not do before, since startBossFight clears the stage and normal spawning
|
||||
// is off for the duration. `cap` counts the boss itself, so the court cannot silt up while a slow
|
||||
// player works on a big health bar.
|
||||
function bossAdds(zb, dt) {
|
||||
const A = bossPlan(zb);
|
||||
if (!A.adds || (zb.addT = (zb.addT ?? A.adds.every) - dt) > 0) return;
|
||||
zb.addT = A.adds.every;
|
||||
if (zombies.length < (A.adds.cap ?? 5)) spawnZombie((Math.random() * 2 - 1) * 5, -1.5 - Math.random(), A.adds.variant);
|
||||
}
|
||||
|
||||
// ---- the player's second health channel. Only these two functions touch it.
|
||||
export function hurtPlayer(n) {
|
||||
// G.hurtT is the grace window: a volley must not delete you at once. G.dodgeI is the SIDESTEP —
|
||||
@@ -515,9 +542,18 @@ function ignite(x, z, r) {
|
||||
// needs no new code: every weapon is already proportional to it.
|
||||
function wound(zb, amount, fx = true) {
|
||||
zb.chip = (zb.chip || 0) + amount;
|
||||
const ph = bossPhase(zb);
|
||||
let took = 0;
|
||||
while (zb.chip >= 1 && zb.hp > 0) { zb.chip -= 1; zb.hp--; took++; }
|
||||
if (took && fx) { splat(0.5, bodySize(zb)); woundBurst(zb); }
|
||||
// A PHASED BOSS TURNING IS AN EVENT, not a silent change of numbers. Same body, new fight: the
|
||||
// crowd comes up, the court shakes, and he says which set this is. Without the beat the champion's
|
||||
// second gear would arrive as "the boss suddenly got faster" and read as the game glitching.
|
||||
if (zb.hp > 0 && bossPhase(zb) > ph) {
|
||||
const cry = bossPlan(zb).cry;
|
||||
if (cry) G.flash = { text: cry, t: 1.6 };
|
||||
G.shake = { t: 0.4, mag: 6 }; cheer(1.4, 0.7); moan(0.7);
|
||||
}
|
||||
return zb.hp <= 0;
|
||||
}
|
||||
|
||||
@@ -922,11 +958,12 @@ export function updateZombies(dt) {
|
||||
|
||||
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)
|
||||
if (zb.retT > 0) zb.retT -= dt; // the champion's return stroke, held long enough to read
|
||||
// ---- 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;
|
||||
const boss = zb.variant === 'B', A = boss ? bossPlan(zb) : 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,
|
||||
@@ -1004,7 +1041,7 @@ export function updateZombies(dt) {
|
||||
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 (zb.variant === 'B') { if (G.mode === 'splatter' && !G.interlude) { bossAI(zb, dt); bossAdds(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)
|
||||
@@ -1051,6 +1088,15 @@ export function updateZombies(dt) {
|
||||
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
|
||||
}
|
||||
// ...AND THE CHAMPION RETURNS THE REST. Every other body in the game can be worn down with
|
||||
// soft balls; the final boss sends anything under splatter quality straight back at your
|
||||
// head, which is what makes him the one you have to out-HIT. The ball is spent on his
|
||||
// racket and comes back as an incoming shot — parry it, swish it away, or step aside.
|
||||
else if (bossPlan(zb).rally) {
|
||||
fireAtPlayer(zb.x, zb.z, (Math.random() * 2 - 1) * 0.4, bossPlan(zb).shot ?? TUNE.bossShotSpeed);
|
||||
zb.retT = 0.3; // the throw frame, so the return is drawn
|
||||
ball.vx *= 0.2; ball.vz *= 0.2; ball.lastHit = 'X';
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user