From 41a6d2a0340b2a3329469e460332ec4d7a6fd925 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sun, 12 Jul 2026 09:18:43 +0200 Subject: [PATCH] Phase 6: racket zombies (rally mini-boss) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- config.js | 4 ++++ gameplay.js | 35 +++++++++++++++++++++++++++++++++-- render.js | 11 +++++++++-- zombies.js | 18 ++++++++++++++++-- 4 files changed, 62 insertions(+), 6 deletions(-) diff --git a/config.js b/config.js index c872ac1..74a986f 100644 --- a/config.js +++ b/config.js @@ -31,6 +31,10 @@ export const TUNE = { hordeRate: 2.5, // zombies spawned 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 + 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) }; diff --git a/gameplay.js b/gameplay.js index e0a8e1e..9dc7758 100644 --- a/gameplay.js +++ b/gameplay.js @@ -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. diff --git a/render.js b/render.js index 2a11771..dc6ed1a 100644 --- a/render.js +++ b/render.js @@ -123,8 +123,15 @@ function drawBoy(b) { ctx.restore(); } function drawZombie(zb) { - const frame = Math.floor(zb.walkT * 2.6) % 2 + 1; - drawStatic('z_' + zb.variant + frame, zb.x, zb.z, 1.8, Math.floor(zb.walkT * 5.2) % 2); + const rk = zb.variant === 'r'; + 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) { const p = proj(x, 0, z); diff --git a/zombies.js b/zombies.js index 2f79bc8..84674a6 100644 --- a/zombies.js +++ b/zombies.js @@ -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 }); 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() { 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 voice('quiet_dark', 0.5, 0.85); } @@ -127,10 +132,11 @@ export function updateZombies(dt) { const zb = zombies[i]; zb.walkT += 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); 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 gore.push({ x: P.x, z: P.z, r: 0.9, a: 0.9 }); 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; 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; } zb.z -= TUNE.zPushback; // weak sitters only stagger them pock(0.6, 300);