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

+4
View File
@@ -31,6 +31,10 @@ export const TUNE = {
hordeRate: 2.5, // zombies spawned per second during a horde hordeRate: 2.5, // zombies spawned per second during a horde
mgRate: 6, // machine-gun pellets fired per second during a horde mgRate: 6, // machine-gun pellets fired per second during a horde
meleeRange: 1.7, // m — a racket swish cleaves shamblers this close to you meleeRange: 1.7, // m — a racket swish cleaves shamblers this close to you
racketHp: 3, // splat-quality hits a racket zombie (mini-boss) takes before it dies
racketSpeed: 2.8, // m/s the racket zombie shambles to chase a ball
racketQMax: 0.55, // its return quality is capped here (slower, sloppier than the tennis CPU)
racketFlub: 0.2, // chance the racket zombie whiffs a return
missHud: 1, // show the last miss reason on screen (full log: T.G.missLog) missHud: 1, // show the last miss reason on screen (full log: T.G.missLog)
}; };
+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) }); 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. // 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. // Player volleys (pre-bounce) get wider reach + window: full-speed ball, no bounce cue.
const volley = !isAI && ball.bounces === 0; const volley = !isAI && ball.bounces === 0;
@@ -364,7 +364,7 @@ function trySwing(ch, isAI) {
const aim = isAI const aim = isAI
? { ax: [ -1, 0, 1 ][Math.floor(Math.random() * 3)], az: Math.random() < 0.25 ? -1 : 0 } ? { 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) }; : 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') { if (!isAI && G.mode === 'tennis') {
G.swingSnaps = null; G.rallyNear = null; 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 }); 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; 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) { export function keyPressed(code) {
if (code !== 'Space') return; if (code !== 'Space') return;
audio(); // unlock on first gesture audio(); // unlock on first gesture
@@ -509,6 +532,10 @@ export function update(dt) {
} }
expirePose(O, 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 // --- ball physics
if (ball.active) { if (ball.active) {
ball.vy -= (ball.g || TUNE.gravity) * dt; ball.vy -= (ball.g || TUNE.gravity) * dt;
@@ -536,6 +563,10 @@ export function update(dt) {
} else if (ball.bounces === 1) pointTo(ball.lastHit, null); } 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. // 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) // 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. // heads to the far court (vz<0), so it must not read as "out the back" on frame one.
+9 -2
View File
@@ -123,8 +123,15 @@ function drawBoy(b) {
ctx.restore(); ctx.restore();
} }
function drawZombie(zb) { function drawZombie(zb) {
const frame = Math.floor(zb.walkT * 2.6) % 2 + 1; const rk = zb.variant === 'r';
drawStatic('z_' + zb.variant + frame, zb.x, zb.z, 1.8, Math.floor(zb.walkT * 5.2) % 2); const v = rk ? 'c' : zb.variant; // the racket boss reuses the turned-CPU sprite (already holds a racket)
const frame = rk && zb.pose === 'swing' ? 2 : Math.floor(zb.walkT * 2.6) % 2 + 1;
drawStatic('z_' + v + frame, zb.x, zb.z, rk ? 1.85 : 1.8, Math.floor(zb.walkT * 5.2) % 2);
if (rk) { // HP bar floating above the mini-boss
const p = proj(zb.x, 2.05, zb.z), w = Math.max(10, p.s * 0.5), x = p.x - w / 2;
ctx.fillStyle = '#300'; ctx.fillRect(x, p.y, w, 3);
ctx.fillStyle = '#e03030'; ctx.fillRect(x, p.y, w * Math.max(0, zb.hp) / TUNE.racketHp, 3);
}
} }
function drawRing(x, z, color) { function drawRing(x, z, color) {
const p = proj(x, 0, z); const p = proj(x, 0, z);
+16 -2
View File
@@ -78,8 +78,13 @@ function firePellet() {
pellets.push({ x: P.x, y: 0.9, z: P.z, vx: Math.sin(a) * s, vz: -Math.cos(a) * s, t: 1.2 }); pellets.push({ x: P.x, y: 0.9, z: P.z, vx: Math.sin(a) * s, vz: -Math.cos(a) * s, t: 1.2 });
pock(0.25, 1400); // rapid fire tick; if it clips on device, gate to every 2nd pellet pock(0.25, 1400); // rapid fire tick; if it clips on device, gate to every 2nd pellet
} }
function spawnRacketZombie() { // one takes up residence on the far court each horde you survive
zombies.push({ x: (Math.random() * 2 - 1) * 2.5, z: 3.5, v: 0, walkT: Math.random() * 9,
variant: 'r', side: 'O', facing: 1, pose: 'idle', poseT: 0, swingT: -1, swingCool: 0, runT: 0, hp: TUNE.racketHp });
}
function endHorde() { function endHorde() {
G.horde = null; pellets.length = 0; G.horde = null; pellets.length = 0;
spawnRacketZombie(); // survive the wave → a racket zombie stays to rally you
setupPoint(); // serve loop resumes; any zombies still standing are yours to mop up setupPoint(); // serve loop resumes; any zombies still standing are yours to mop up
voice('quiet_dark', 0.5, 0.85); voice('quiet_dark', 0.5, 0.85);
} }
@@ -127,10 +132,11 @@ export function updateZombies(dt) {
const zb = zombies[i]; const zb = zombies[i];
zb.walkT += dt; zb.walkT += dt;
zb.z += zb.v * dt; zb.z += zb.v * dt;
if (G.mode === 'splatter' && zb.variant !== 'r' && zb.z > COURT.netZ) // on YOUR half they home in on you if (zb.variant === 'r') { /* the racket boss holds station — its AI owns its x */ }
else if (G.mode === 'splatter' && zb.z > COURT.netZ) // on YOUR half they home in on you
zb.x += Math.sign(P.x - zb.x) * Math.min(Math.abs(P.x - zb.x), zb.v * 0.7 * dt); zb.x += Math.sign(P.x - zb.x) * Math.min(Math.abs(P.x - zb.x), zb.v * 0.7 * dt);
else else
zb.x += Math.sin(zb.walkT * 1.7) * 0.15 * dt; // far half / racket boss: aimless shamble zb.x += Math.sin(zb.walkT * 1.7) * 0.15 * dt; // far half: aimless shamble
if (G.mode === 'splatter' && Math.hypot(zb.x - P.x, zb.z - P.z) < 0.7) { // it got you if (G.mode === 'splatter' && Math.hypot(zb.x - P.x, zb.z - P.z) < 0.7) { // it got you
gore.push({ x: P.x, z: P.z, r: 0.9, a: 0.9 }); gore.push({ x: P.x, z: P.z, r: 0.9, a: 0.9 });
for (let k = 0; k < 30; k++) parts.push({ for (let k = 0; k < 30; k++) parts.push({
@@ -150,6 +156,14 @@ export function updateZombies(dt) {
explode(ball.x, ball.z); ball.active = false; ball.bomb = false; explode(ball.x, ball.z); ball.active = false; ball.bomb = false;
G.phase = 'over'; G.overT = TUNE.splatterOverT; break; // zombies mutated — stop this frame's loop G.phase = 'over'; G.overT = TUNE.splatterOverT; break; // zombies mutated — stop this frame's loop
} }
if (zb.variant === 'r') { // mini-boss: splat-quality wounds it; soft balls it rallies (racketAI)
if ((ball.pow || 0) >= TUNE.zSplatQ) {
zb.hp--; splat(0.5); pock(0.6, 300);
ball.vx *= 0.3; ball.vz *= 0.3; ball.lastHit = 'X'; // spent on the hit
if (zb.hp <= 0) { splatter(i); continue; } // killing blow: full gore + point
}
continue; // soft balls pass through, for the AI to return
}
if ((ball.pow || 0) >= TUNE.zSplatQ) { splatter(i); continue; } if ((ball.pow || 0) >= TUNE.zSplatQ) { splatter(i); continue; }
zb.z -= TUNE.zPushback; // weak sitters only stagger them zb.z -= TUNE.zPushback; // weak sitters only stagger them
pock(0.6, 300); pock(0.6, 300);