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
@@ -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)
|
||||
};
|
||||
|
||||
|
||||
+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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
+16
-2
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user