Phase 6: racket zombies (rally mini-boss)

Survive a horde and a racket zombie takes up residence on the far court
(variant 'r', one per horde survived) — the tennis rally skill lives on
inside zombie mode. It reuses the turned-CPU z_c sprite (already holds a
racket) and a slimmed copy of the tennis AI: holds station deep, chases a
ball laterally at TUNE.racketSpeed (2.8), and returns your SOFT shots
(pow < zSplatQ) as live lastHit:'O' balls with quality capped at
racketQMax (0.55) and racketFlub (0.2) whiffs. trySwing gained an
optional quality-cap arg (defaulted, so tennis is untouched).

It's a mini-boss with TUNE.racketHp (3): only splat-quality balls hurt it
(-1 hp, staggers the ball; the killing blow runs the full splatter). Soft
balls pass through for it to rally; melee and pellets don't touch it. Its
return that gets past your baseline is a point for THEM; you can rally it
back with the existing player hit. A red HP bar floats over its head.

Verified via window.T: endHorde spawns exactly one (hp 3, z 3.5); three
splat hits kill it (2→1→dead); a pow-0.3 ball is returned toward you; an
'O' ball past your baseline scores for THEM; melee spares the boss but
cleaves the shambler beside it; tennis still 12/11/12 and a normal
shambler is still one-shot; renders with its HP bar; no console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 4.8 committed 2026-07-12 09:18:43 +02:00
1 parent bfaafe6727
commit 41a6d2a034
4 files changed
+62 -6

No files matched your search

+33 -2
View File
@@ -341,7 +341,7 @@ function logWhiff() {
ballZ: r2(best.bz), bounces: best.bounces, px: r2(P.x), pz: r2(P.z) });
}
function trySwing(ch, isAI) {
function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
// Contact happens if the ball is inside reach while the swing window is open.
// Player volleys (pre-bounce) get wider reach + window: full-speed ball, no bounce cue.
const volley = !isAI && ball.bounces === 0;
@@ -364,7 +364,7 @@ function trySwing(ch, isAI) {
const aim = isAI
? { ax: [ -1, 0, 1 ][Math.floor(Math.random() * 3)], az: Math.random() < 0.25 ? -1 : 0 }
: flick || { ax: axisX(), az: axisZ() * (ch.side === 'P' ? 1 : -1) };
if (isAI) q = Math.min(q, TUNE.aiQualityMin + Math.random() * (TUNE.aiQualityMax - TUNE.aiQualityMin));
if (isAI) q = Math.min(q, TUNE.aiQualityMin + Math.random() * (qCap - TUNE.aiQualityMin));
if (!isAI && G.mode === 'tennis') {
G.swingSnaps = null; G.rallyNear = null;
logSwing({ result: 'HIT', reason: `contact q=${Math.round(q * 100) / 100}`, d: Math.round(Math.hypot(dx, dz * 0.8) * 100) / 100, y: Math.round(ball.y * 100) / 100 });
@@ -373,6 +373,29 @@ function trySwing(ch, isAI) {
ch.swingT = -1;
}
// racket zombie (splatter mini-boss): a slimmed tennis CPU that holds the far court and returns
// your SOFT shots as live balls. Splat-quality balls it won't rally — those wound its HP instead.
function racketAI(rz, dt) {
expirePose(rz, dt);
rz.swingCool -= dt;
const incoming = ball.active && ball.lastHit === 'P' && (ball.pow || 0) < TUNE.zSplatQ && ball.vz < 0;
if (!incoming) { if (rz.pose === 'run') rz.pose = 'idle'; return; }
const tz = Math.max(1.5, Math.min(COURT.svcFar, ball.z)); // chase laterally, hold deep on the far court
const dx = ball.x - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz);
if (d > 0.15) {
const step = Math.min(TUNE.racketSpeed * dt, d);
rz.x += dx / d * step; rz.z += dz / d * step; rz.runT += dt;
if (rz.pose !== 'swing') rz.pose = 'run';
if (Math.abs(dx) > 0.2) rz.facing = dx > 0 ? 1 : -1;
} else if (rz.pose === 'run') rz.pose = 'idle';
if (rz.swingCool <= 0 && rz.swingT < 0
&& Math.hypot(ball.x - rz.x, (ball.z - rz.z) * 0.8) < TUNE.aiReach
&& ball.y < CONTACT.maxY && ball.y > CONTACT.minY) {
if (Math.random() < TUNE.racketFlub) rz.swingCool = 0.5; // whiff — it hesitates
else { rz.swingT = TUNE.swingWindup + TUNE.swingWindow / 2; trySwing(rz, true, TUNE.racketQMax); rz.swingT = -1; rz.swingCool = 0.3; }
}
}
export function keyPressed(code) {
if (code !== 'Space') return;
audio(); // unlock on first gesture
@@ -509,6 +532,10 @@ export function update(dt) {
}
expirePose(O, dt);
// --- racket zombies rally from the far court (splatter only, between hordes)
if (G.mode === 'splatter' && !G.horde)
for (const rz of zombies) if (rz.variant === 'r') racketAI(rz, dt);
// --- ball physics
if (ball.active) {
ball.vy -= (ball.g || TUNE.gravity) * dt;
@@ -536,6 +563,10 @@ export function update(dt) {
} else if (ball.bounces === 1) pointTo(ball.lastHit, null);
}
}
// a racket zombie's return that gets past you (crosses your baseline) is a point for THEM
if (G.mode === 'splatter' && ball.active && ball.lastHit === 'O' && ball.z > COURT.L + 0.3) {
ball.active = false; if (!G.horde) splatPoint('O'); G.phase = 'over'; G.overT = TUNE.splatterOverT;
}
// splatter: retire spent or hopeless balls fast so the next serve comes right up.
// z-bounds gate on travel direction — a serve born behind the baseline (P.z up to 26)
// heads to the far court (vz<0), so it must not read as "out the back" on frame one.