Make the parry reachable, and stop a boss dying to one flame ball

Six reports from Oliver's first playtest of the boss build. Two mattered.

A boss sprite turned up in the shambler crowd because z_B1.png and z_b1.png
are the same file on a case-insensitive filesystem — writing the boss frames
silently overwrote the 'b' shambler's first two frames, and `ls` only ever
showed the original lowercase name. Restored from HEAD~1, boss frames renamed
z_boss*, and the rule is now in the asset pipeline notes.

The parry was unreachable, which killed the entire new mechanic: P.swingT is
only set in keyPressed's `phase === 'rally'` branch, and splatter sits in
pretoss almost all the time, so every tap aimed at an incoming shot tossed the
serve ball instead of swinging. An incoming shot inside parryArm now owns the
tap in every phase, with its own generous reach and window — this is a phone,
one thumb, against something aimed at you. The three fail cases (too early,
too late, no tap) are what keep it a timing skill. The swish no longer eats
shots: it runs first in keyPressed and was swallowing the very ball the parry
was about to return.

One flame ball killed a 16 hp boss, and that was a bug rather than tuning. A
flame ball is not consumed on contact (full pierce, so it can cut a lane) but a
boss is not removed when hit, so the same ball re-wounded it every frame. Boss
bodies now take a contact cooldown and flame spends itself on them. Chapter 1
costs 4 flame balls or 16 clean smashes, and bossHp goes 10 to 16.

The boss had no wounded state while throwing — correct, the wound skins were
standing poses only. New sheet gives all four damage states their own overarm
pitch. The ball machine's four painted rects are replaced with a real sheet,
idle and firing, keeping only the muzzle glow procedural because the telegraph
has to read at 20px tall. And the player's blood no longer sits on him as a
painted layer: live drip and the screen-edge wash, nothing else.

Tennis untouched: 30 tapped points, 50 hits / 0 whiffs. 45-game soak clean,
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 13:50:47 +02:00
1 parent 4a066e5cc6
commit 295470b73c
21 files changed
+137 -85

No files matched your search

+12 -14
View File
@@ -279,14 +279,9 @@ export function meleeSwipe(x, z, range) {
}
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++; }
}
// The swish deliberately does NOT touch incoming shots. It used to, as a pretoss fallback — but
// meleeSwipe runs FIRST in keyPressed, so it swallowed the very shot the new tap-to-parry was about
// to return. One answer to a thrown ball: swing at it.
if (hit) swish(0.35);
return hit;
}
@@ -472,6 +467,7 @@ 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 (zb.hitT > 0) zb.hitT -= dt; // boss contact cooldown (see TUNE.bossHitCool)
// ---- 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') {
@@ -502,14 +498,16 @@ export function updateZombies(dt) {
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
if (d < hitR(zb) + ballSweep && !(zb.hitT > 0)) { // 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 (isBoss(zb)) { // a boss takes a heavy wound AND catches light
if (wound(zb, TUNE.dmgFlame)) splatter(i);
zb.hitT = TUNE.bossHitCool; // ...and the ball is SPENT on it: a boss is not
ball.lastHit = 'X'; ball.vx *= 0.3; ball.vz *= 0.3; // removed, so an unconsumed pierce ball
if (wound(zb, TUNE.dmgFlame)) splatter(i); // re-wounded it every single frame
else zb.burnT = TUNE.burnTime;
continue;
}
@@ -520,7 +518,8 @@ export function updateZombies(dt) {
}
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);
zb.hitT = TUNE.bossHitCool; // belt-and-braces: this ball is spent below, but the cooldown
const dead = wound(zb, TUNE.dmgBall); // is what stops ANY lingering contact multi-wounding
pock(0.6, 300);
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
@@ -591,9 +590,8 @@ export function updateZombies(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) {
if (!done && P.swingT >= 0 && P.swingT <= TUNE.parryWindow
&& dP < TUNE.parryReach && 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,