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:
wuwiandClaude Opus 5 committed 2026-07-30 12:55:35 +02:00
1 parent ffac9b5512
commit 4a066e5cc6
14 files changed
+492 -131

No files matched your search

+88 -2
View File
@@ -4,7 +4,7 @@ import { cv, ctx } from './dom.js';
import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, PLAYER_H, BOY_H, UMP_H, UMP_X, BOUNCE_IDLE, SPRITE_H } from './config.js';
import { P, O, ball, score, crowd, G } from './state.js';
import { predictLanding, flickTarget } from './gameplay.js';
import { zombies, gore, piles, parts, pellets, extras } from './zombies.js';
import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js';
// ---------------------------------------------------------------- palette ----
// tennis palette -> drained splatter palette; the transition lerps between them
@@ -79,6 +79,7 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','
'x_boymop1','x_boymop2','x_boymop3','x_boymop4', // the push cycle, used only while he is stopped and working
'x_boycarry1','x_boycarry2','x_boycarry3','x_boycarry4','x_howto', // walking with the mop shouldered, and the slingshot tutorial panel
'z_gw2','z_gw3','z_gw4','z_gwb','z_gw2b','z_gw3b','z_gw4b', // the brute comes apart in stages (4/5 hp = z_gw .. 1/5 = z_gw4), each with a 2-frame lurch (…b)
'z_B1','z_B2','z_Bw1','z_Bw2','z_Bw3', // the CHAPTER BOSS: 2-frame stride + three wound stages (arm off -> ribs out -> headless)
'z_a1','z_a2','z_a3','z_a4','z_b1','z_b2','z_b3','z_b4','z_c1','z_c2',
'z_f1','z_f2','z_f3','z_f4','z_g1','z_g2','z_gw','z_s1','z_s2','z_s3',
'z_cw1','z_cw2','z_aw','z_bw', // wounded skins for multi-hit enemies (racket boss, brute, and now the classic shamblers)
@@ -164,15 +165,41 @@ function drawBoy(b) {
ctx.restore();
}
const ZFRAMES = { a: 4, b: 4, f: 4 }; // 4-frame walk cycles where sheets exist; the rest stay 2
// The rogue ball machine: real tennis furniture, so it is drawn as furniture — a squat hopper on
// wheels with a barrel pointed down-court. Procedural like drawFlames, and it stays that way unless
// a sheet turns up: a box is four rects, and the readable part is the BARREL and the charge glow.
function drawMachine(zb) {
const p = proj(zb.x, 0, zb.z), u = p.s * 0.16; // u = one "pixel" at this depth
const R = (x, y, w, h, c) => { ctx.fillStyle = c; ctx.fillRect(Math.round(p.x + x * u), Math.round(p.y + y * u), Math.max(1, w * u), Math.max(1, h * u)); };
drawShadow(p.x, p.y, u * 4.4, u * 1.3);
const dead = zb.hp / TUNE.machHp;
R(-4, -7, 8, 7, dead > 0.5 ? '#4a5460' : '#3a3038'); // hopper, scorched as it takes damage
R(-4, -7, 8, 1, '#6b7684'); // top light
R(-4, -1, 8, 1, '#20262e'); // bottom shadow
R(-2.2, -9.6, 4.4, 2.6, '#2b3138'); // ball basket
R(-1.6, -9.2, 3.6, 1.8, UI.ball); // ...full of tennis balls
R(1.2, -6.2, 4.6, 2.2, '#565f6b'); // the BARREL, pointed at you
R(4.4, -6.2, 1.4, 2.2, '#161a1f'); // muzzle
R(-3.4, -1.2, 2, 1.6, '#15181c'); R(1.4, -1.2, 2, 1.6, '#15181c'); // wheels
if (zb.windT > 0) { // the TELEGRAPH: the muzzle glows hotter the closer the shot is
const k = 1 - zb.windT / TUNE.machWind;
ctx.globalAlpha = 0.35 + k * 0.65;
R(4.2, -6.4, 2 + k * 1.6, 2.6, k > 0.6 ? '#fff2c0' : UI.warm);
ctx.globalAlpha = 1;
}
}
function drawZombie(zb) {
if (zb.variant === 'm') return drawMachine(zb);
const rk = zb.variant === 'r';
const v = rk ? 'c' : zb.variant; // the racket boss reuses the turned-CPU sprite (already holds a racket)
const v = rk ? 'c' : zb.variant; // the racket zombie reuses the turned-CPU sprite (already holds a racket)
let N = ZFRAMES[v] || 2;
if (!SPR['z_' + v + N]) N = 2; // frames still loading (or sheet rejected): 2-frame fallback
// same CYCLE frequency as before — more frames subdivide the stride, they don't speed it up
let frame = rk && zb.pose === 'swing' ? 2 : Math.floor(zb.walkT * (zb.variant === 'f' ? 6 : 2.6) * N / 2) % N + 1;
if (zb.variant === 's' && zb.shieldT > 0) frame = 3; // chair dropped — hit it NOW
let h = zb.variant === 'g' ? 2.7 : rk ? 1.85 : 1.8; // the brute towers half again over the crowd
if (zb.variant === 'B') h = 1.8 * (zb.scale || 1.5); // ...and a chapter boss towers over the brute
// wounded skins: multi-hit enemies show their damage as hp drops (arm off → head gone, still
// shambling). The blood is NOT painted in — open wounds gush live particles (see bleed() in
// zombies.js). Fall back to the healthy sprite until the wounded PNG has loaded.
@@ -198,8 +225,25 @@ function drawZombie(zb) {
if (f <= 0.34 && SPR.z_cw2) { name = 'z_cw2'; h = 1.52; } // headless: a head shorter, so the shoulders land where a whole zombie's do
else if (f <= 0.67 && SPR.z_cw1) name = 'z_cw1';
}
// The chapter boss comes apart through the SAME fraction-of-hpMax rule as the racket zombie, over
// three stages, so a 34 hp chapter-4 boss visibly degrades all the way down: arm off, then ribs
// out, then headless and still throwing. Its 2-frame stride steps off DISTANCE COVERED (the same
// rule the brute uses) — it is the slowest thing on court and a time-driven cycle would foot-slide.
if (zb.variant === 'B') {
const f = zb.hp / (zb.hpMax || 1);
const stage = f <= 0.25 ? 'z_Bw3' : f <= 0.5 ? 'z_Bw2' : f <= 0.75 ? 'z_Bw1'
: (Math.floor(zb.z / TUNE.bruteStride) % 2 ? 'z_B2' : 'z_B1');
if (SPR[stage]) { name = stage; h *= SPR[stage].height / SPRITE_H; } // height ALWAYS off the frame, or the size pops
}
drawStatic(name, zb.x, zb.z, h, Math.floor(zb.walkT * 5.2) % 2);
if (zb.burnT > 0) drawFlames(zb, h);
if (zb.windT > 0 && zb.variant === 'B') { // the boss telegraphs too: it rears up, glowing, then throws
const k = 1 - zb.windT / TUNE.machWind, p = proj(zb.x, h * 0.62, zb.z);
ctx.globalAlpha = 0.25 + k * 0.55; ctx.fillStyle = k > 0.6 ? '#fff2c0' : UI.bad;
const r = Math.max(2, p.s * 0.1 * (0.5 + k));
ctx.beginPath(); ctx.arc(Math.round(p.x), Math.round(p.y), r, 0, 7); ctx.fill();
ctx.globalAlpha = 1;
}
// No HP bar. It was the only floating UI on a body anywhere in the game, it was on screen for
// almost the whole run (a boss respawns every game you win), and nobody knew what it meant —
// while the wound SKINS above already encode exactly the same information in the language every
@@ -303,6 +347,29 @@ function drawChar(ch) {
ctx.drawImage(img, -w / 2, -h, w, h);
ctx.restore();
} else drawFallbackChar(ch, p, hpx);
if (ch.side === 'P') drawPlayerWounds(p, hpx);
}
// The player's health, drawn ON HIM and nowhere else — there is no bar and there will not be one
// (same rule that removed the boss's). Blood is never painted into the sprite (settled feel
// decision), so this is a procedural overlay plus bleedPlayer()'s live trickle: fixed anchors, so it
// does not crawl frame to frame, and it grows with every hit. The white kit is the meter.
const WOUND_AT = [[-0.18, -0.62], [0.22, -0.55], [-0.05, -0.72], [0.14, -0.42], [-0.26, -0.48], [0.06, -0.33]];
function drawPlayerWounds(p, hpx) {
if (G.hp >= G.hpMax) return;
const lost = G.hpMax - G.hp, n = Math.min(WOUND_AT.length, lost * 2);
for (let i = 0; i < n; i++) {
const [ox, oy] = WOUND_AT[i];
const s = Math.max(1, hpx * (0.055 + (i % 2) * 0.02));
ctx.fillStyle = i % 3 ? 'rgba(150,16,16,0.92)' : 'rgba(86,10,10,0.95)';
ctx.fillRect(Math.round(p.x + ox * hpx * 0.5), Math.round(p.y + oy * hpx), s, s);
}
if (G.hurtFlash > 0) { // the impact itself: he goes red for a moment
ctx.save(); ctx.globalAlpha = Math.min(1, G.hurtFlash / 0.3) * 0.5;
ctx.fillStyle = UI.bad;
ctx.fillRect(Math.round(p.x - hpx * 0.2), Math.round(p.y - hpx * 0.82), hpx * 0.4, hpx * 0.6);
ctx.restore();
}
}
function drawFallbackChar(ch, p, h) { // shown only until sprites load
const you = ch.side === 'P';
@@ -645,6 +712,17 @@ export function render() {
// the match ball (and any triple-serve side balls in flight)
if ((ball.active || G.phase === 'over') && !ball.collected) drawBall(ball.x, ball.y, ball.z, true, ball.bomb, ball.flame);
for (const e of extras) drawBall(e.x, e.y, e.z);
// INCOMING hostile balls, marked so they can never be mistaken for one of yours: a red ring and a
// hard ground shadow, so you can read WHERE it will land while it is still in the air.
for (const s of shots) {
const sh = proj(s.x, 0, s.z);
drawShadow(sh.x, sh.y, Math.max(1.5, sh.s * 0.05), Math.max(1, sh.s * 0.025), 0.4);
const sp = proj(s.x, s.y, s.z), r = Math.max(2, sp.s * 0.045);
ctx.fillStyle = UI.bad;
ctx.beginPath(); ctx.arc(Math.round(sp.x), Math.round(sp.y), r + 1.5, 0, 7); ctx.fill();
ctx.fillStyle = UI.ball;
ctx.beginPath(); ctx.arc(Math.round(sp.x), Math.round(sp.y), r, 0, 7); ctx.fill();
}
// splatter particles (fire embers ride the same array)
for (const pt of parts) {
const pp = proj(pt.x, pt.y, pt.z);
@@ -677,6 +755,14 @@ export function render() {
// bomb bloom: a warm blood-orange wash, not a white-out. At 0.85 white it blinded you on a phone
// and hid the very gore eruption it was announcing.
if (G.boomFlash > 0) { ctx.fillStyle = `rgba(255,150,70,${Math.min(1, G.boomFlash / 0.06) * 0.3})`; ctx.fillRect(0, 0, W, H); }
// took a ranged hit: a red wash from the screen EDGES inward. Deliberately not a full-screen fill —
// this channel must read as "you are bleeding", not as "something exploded" (that is boomFlash).
if (G.hurtFlash > 0) {
const a = Math.min(1, G.hurtFlash / 0.3) * 0.55, b = 26;
ctx.fillStyle = `rgba(150,10,14,${a.toFixed(3)})`;
ctx.fillRect(0, 0, W, b); ctx.fillRect(0, H - b, W, b);
ctx.fillRect(0, 0, b, H); ctx.fillRect(W - b, 0, b, H);
}
// HUD — Wimbledon manual scoreboard as a 16-bit panel: bevelled bottle-green box (light
// top/left, dark bottom/right), a purple club spine, cream serif names, and the LIVE game