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:
1 parent
bfaafe6727
commit
41a6d2a034
4 files changed
+62
-6
No files matched your search
+33
-2
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user