Make the bosses move, and put six of them and three new bodies on court
A chapter boss had no AI at all: it matched no branch in the variant chain and fell through to the crowd's aimless wobble, so "the boss AI owns its x" was a comment describing code that did not exist. Movement is now data on the BOSS_ART row -- pace, shadow, surge, charge, bounce -- and bossAI is the one place it happens. Every behaviour field is optional, so a row carrying only art behaves exactly as a boss did before. Three earlier drafts of those styles failed the same way: written as "walk toward the player's x", which in splatter means arrive in four seconds and stand still, because the player is rooted in pretoss for nearly all of a boss fight. Every style now moves under its own power and only biases toward him. Measured over a simulated minute each: 50-136 m travelled, none static. Three new designs -- THE HEAD COACH (a 5-shot drill 0.14s apart), THE PHYSIO (charges, and withdraws less than it charges, so it reaches you at 38s and that is the fight's clock), THE SPONSOR MASCOT (gap 0, so the whole wall of five leaves in ONE frame and is dodged rather than parried). Harder to kill: 16 -> 20 hp, 9 -> 10 per chapter, plus per-design multipliers. Three new bodies: the ball boy (3 hp, 2 skins, from game 3), the bouncer (6/3, game 8) and the roller man (9/4, game 15), each with a 4-frame walk and a drawn two-frame lurch for every wound stage. Three wound rules became one. The brute had a hardcoded stage array, everything else a single skin behind 'abd'.includes(...), and the boss a fraction of hpMax. The boss's rule reproduces the other two exactly -- verified: the brute still draws z_gw/gw2/gw3/gw4 at hp 4/3/2/1 -- and keeps working when hp is not one-per-skin, which all three new bodies need. So a multi-hit body is now a row in config.ZWOUND and no code. bleed() reads the same call, which also gave the steward the trickle his wound skin had always implied. Fixes a real crash on the way past: killPlayer empties shots[] while the shots loop is mid-iteration, so every remaining index read undefined and the frame threw. The mascot's five-at-once volley makes it easy to hit; the shipped 3-shot volley could always have hit it. _regen.py gains two style blocks and a check tier. One constant used to say "keep every colour exactly as the reference" -- right for a remaster, and it fights every word of the subject otherwise. newart is for a new character, state for the same character wounded or mid-throw; using newart for the wound sheets licensed a repaint and got one, five came back with living tan skin. And the detail gate ratchets against the file it replaces, so check: any exists for re-rolling already-fine art to fix something the gate cannot see, such as colour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0774a2a31d
commit
4cda6bde76
64 files changed
+694
-78
No files matched your search
+137
-26
@@ -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, VOLLEY, CONTACT, BOSS_ART, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js';
|
||||
import { TUNE, UI, COURT, VOLLEY, CONTACT, BOSS_ART, 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 } from './gameplay.js';
|
||||
@@ -41,12 +41,15 @@ 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 === '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 ('fgsd'.includes(variant) && !G.seen['v' + variant]) { G.seen['v' + variant] = true; aside('ump_dress_code', 0.8); }
|
||||
if ('fgsdehk'.includes(variant) && !G.seen['v' + variant]) { G.seen['v' + variant] = true; aside('ump_dress_code', 0.8); }
|
||||
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
|
||||
@@ -71,11 +74,94 @@ 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 hp = TUNE.bossHp + (chapter - 1) * TUNE.bossHpPerChapter;
|
||||
zombies.push({ x: 0, z: -1.2, v: TUNE.bossSpeed, walkT: 0, homeOff: 0, variant: 'B',
|
||||
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: TUNE.bossCycle, windT: 0, salvo: 0, salvoT: 0,
|
||||
art: BOSS_ART[(chapter - 1) % BOSS_ART.length] }); // which of the boss designs this chapter is
|
||||
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.
|
||||
@@ -147,6 +233,11 @@ function pickVariant() {
|
||||
// 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';
|
||||
@@ -158,6 +249,7 @@ function pickVariant() {
|
||||
// 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 === '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;
|
||||
@@ -165,7 +257,7 @@ const hitR = zb => zb.variant === 'g' ? 0.75 : zb.variant === 'd' ? 0.64 : zb.va
|
||||
// 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, m: 1.5, B: 6 }; // a chapter boss comes apart like nothing else on the court
|
||||
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, B: 6 }; // a chapter boss comes apart like nothing else on the court
|
||||
|
||||
// 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) {
|
||||
@@ -464,25 +556,34 @@ export function explode(x, z) {
|
||||
// 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;
|
||||
if (zb.variant === 'g' && zb.hp < TUNE.bruteHp) {
|
||||
// the brute opens up stage by stage: one arm, then both, then the head. Still sparse —
|
||||
// more ANCHORS, not a heavier flow (the settled feel note: blood stays thin).
|
||||
wounds = [[-0.3, 1.95, 1.1]];
|
||||
if (zb.hp <= TUNE.bruteHp - 2) wounds.push([0.42, 1.9, 1.0]);
|
||||
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]);
|
||||
}
|
||||
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.
|
||||
else if (zb.variant === 'r' && zb.hp / (zb.hpMax || TUNE.racketHp) <= 0.34)
|
||||
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
|
||||
// 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
|
||||
@@ -551,7 +652,8 @@ export function updateZombies(dt) {
|
||||
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
|
||||
if (!held) zb.z += zb.v * (zb.burnT > 0 ? TUNE.burnPanic : 1) * dt;
|
||||
// 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
|
||||
@@ -560,21 +662,25 @@ export function updateZombies(dt) {
|
||||
// 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';
|
||||
const boss = zb.variant === 'B', A = zb.art;
|
||||
if (zb.salvo > 0) { // mid-volley: the rest of the balls are still coming
|
||||
if ((zb.salvoT -= dt) <= 0) {
|
||||
zb.salvo--; zb.salvoT = TUNE.bossSalvoGap;
|
||||
fireAtPlayer(zb.x, zb.z, (Math.random() * 2 - 1) * 0.35, TUNE.bossShotSpeed); // aimed at where
|
||||
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 = TUNE.bossVolley; zb.salvoT = 0; } // the volley starts next frame
|
||||
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 ? TUNE.bossCycle : TUNE.machCycle;
|
||||
zb.windT = boss ? TUNE.bossWind : TUNE.machWind;
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -622,7 +728,8 @@ 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') { /* boss AI owns its x; a duelling opponent owns his */ }
|
||||
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)
|
||||
@@ -726,6 +833,10 @@ export function updateZombies(dt) {
|
||||
// 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;
|
||||
|
||||
Reference in new issue
Block a user