Phase 11: enemy roster wired — runner, brute, shield zombie
- Spawn mix (pickVariant): roster grows one type per set — runner from set 1
game 3+ (12% slot, at most one alive, shriek on spawn = the fairness contract),
brute from set 2 (15%), shield from set 3 (15%); classics fill the rest
- Runner ('f'): 3.2x shamble speed, faster leg cycle; new shriek() synth in audio
- Brute ('g'): hp 2 vs splat-quality balls AND pellets, 0.55x speed, 0.75m target,
pushback-immune, 0.9m eaten radius, dies to double gore + size-2 pile, towers
at 1.5x height
- Shield ('s'): blocks front balls with the chair (clank, ball spent), the block
drops the chair for shieldDown 1.5s — any splat-quality ball kills in the
window (z_s3 staggered pose renders); chair recovers after; always dies to
bombs, melee, pellets
- TUNE: runnerSpeed 3.2, bruteHp 2, shieldDown 1.5
Verified in browser: per-variant stats; brute soft-ball immunity, 2-hit kill,
2-pellet kill, size-2 pile; shield block->window kill->chair recovery->blocks
again; melee kills f/s but not the racket boss; tier-2 spawn mix over 1500
spawns = 14/15/8/63%; all five sprites render with correct scale+occlusion;
tennis regression 11/12; no console errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
811fe3f91f
commit
7bc26aec1c
4 files changed
+76
-14
No files matched your search
+52
-11
@@ -5,7 +5,7 @@
|
||||
|
||||
import { TUNE, COURT } from './config.js';
|
||||
import { G, P, ball, sendBoy, setupPoint } from './state.js';
|
||||
import { splat, moan, pock, voice, boom, swish } from './audio.js';
|
||||
import { splat, moan, pock, voice, boom, swish, shriek } from './audio.js';
|
||||
import { splatPoint, cheer, spentBall } from './gameplay.js';
|
||||
|
||||
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
|
||||
@@ -18,21 +18,38 @@ let spawnT = 1.5, moanT = 3;
|
||||
|
||||
export function spawnZombie(x, z, variant) {
|
||||
const ramp = 1 + Math.min(1.5, G.splatT / 70); // they get faster the longer you survive
|
||||
zombies.push({ x, z, v: TUNE.zSpeed * ramp * (0.8 + Math.random() * 0.4), walkT: Math.random() * 9, variant });
|
||||
const zb = { x, z, v: TUNE.zSpeed * ramp * (0.8 + Math.random() * 0.4), walkT: Math.random() * 9, variant };
|
||||
if (variant === 'f') { zb.v *= TUNE.runnerSpeed; shriek(); } // the shriek is the fairness contract
|
||||
if (variant === 'g') { zb.v *= 0.55; zb.hp = TUNE.bruteHp; } // slow, but soaks hits
|
||||
if (variant === 's') zb.shieldT = 0; // chair up; > 0 = seconds of stagger left
|
||||
zombies.push(zb);
|
||||
}
|
||||
|
||||
// which shambler steps out next: the roster grows one type per set (runner: set 1 game 3+,
|
||||
// brute: set 2, shield: set 3). Fixed random slots so the classics stay the crowd.
|
||||
function pickVariant() {
|
||||
const tier = G.sets[0] + G.sets[1], r = Math.random();
|
||||
if (tier >= 2 && r < 0.15) return 's';
|
||||
if (tier >= 1 && r >= 0.15 && r < 0.30) return 'g';
|
||||
if ((tier >= 1 || G.runGames >= 2) && r >= 0.30 && r < 0.42
|
||||
&& !zombies.some(z => z.variant === 'f')) return 'f'; // at most one runner alive
|
||||
return Math.random() < 0.5 ? 'a' : 'b';
|
||||
}
|
||||
|
||||
// core kill: remove the zombie, spawn its gore/pile/particles, score. No ball coupling —
|
||||
// shared by ball hits, machine-gun pellets, melee swipes, and extra (multi-serve) balls.
|
||||
function killZombie(i, quiet) {
|
||||
const zb = zombies[i];
|
||||
const big = zb.variant === 'g'; // a brute goes down like a building — twice the gore
|
||||
zombies.splice(i, 1);
|
||||
gore.push({ x: zb.x, z: zb.z, r: 0.45 + Math.random() * 0.35, a: 0.85 });
|
||||
gore.push({ x: zb.x, z: zb.z, r: (big ? 0.8 : 0.45) + Math.random() * 0.35, a: 0.85 });
|
||||
if (gore.length > 40) gore.shift();
|
||||
const pl = piles.find(p => Math.hypot(p.x - zb.x, p.z - zb.z) < 0.6); // stack up where kills cluster
|
||||
if (pl) pl.size = Math.min(4, pl.size + 1);
|
||||
else { piles.push({ x: zb.x, z: zb.z, size: 1, seed: Math.random() * 1e4, blobs: null, builtSize: 0 });
|
||||
if (pl) pl.size = Math.min(4, pl.size + (big ? 2 : 1));
|
||||
else { piles.push({ x: zb.x, z: zb.z, size: big ? 2 : 1, seed: Math.random() * 1e4, blobs: null, builtSize: 0 });
|
||||
if (piles.length > 30) piles.shift(); }
|
||||
for (let k = 0; k < 24; k++) { const dark = k >= 16; parts.push({ // ~1/3 dark & long-lived: settling gore
|
||||
const n = big ? 48 : 24;
|
||||
for (let k = 0; k < n; k++) { const dark = k >= n * 2 / 3; parts.push({ // ~1/3 dark & long-lived: settling gore
|
||||
x: zb.x, y: 0.9, z: zb.z,
|
||||
vx: (Math.random() * 2 - 1) * 3.5, vy: 1 + Math.random() * 3.5, vz: (Math.random() * 2 - 1) * 3.5,
|
||||
t: dark ? 0.6 + Math.random() * 0.6 : 0.4 + Math.random() * 0.4, dark,
|
||||
@@ -123,7 +140,7 @@ export function updateZombies(dt) {
|
||||
// the spawn director: the match's age (games finished) sets both crowd size and tempo
|
||||
const cap = Math.min(TUNE.zMaxCap, TUNE.zMax + G.runGames);
|
||||
if (spawnT <= 0 && zombies.length < cap) {
|
||||
spawnZombie((Math.random() * 2 - 1) * 4.8, -1.5 - Math.random(), Math.random() < 0.5 ? 'a' : 'b');
|
||||
spawnZombie((Math.random() * 2 - 1) * 4.8, -1.5 - Math.random(), pickVariant());
|
||||
spawnT = Math.max(TUNE.zSpawnMin, TUNE.zSpawnEvery - G.runGames * TUNE.zSpawnStep);
|
||||
}
|
||||
}
|
||||
@@ -133,12 +150,13 @@ export function updateZombies(dt) {
|
||||
const zb = zombies[i];
|
||||
zb.walkT += dt;
|
||||
zb.z += zb.v * dt;
|
||||
if (zb.shieldT > 0) zb.shieldT -= dt; // staggered chair comes back up when this runs out
|
||||
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: 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) < (zb.variant === 'g' ? 0.9 : 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({
|
||||
x: P.x, y: 1.1, z: P.z,
|
||||
@@ -157,7 +175,7 @@ export function updateZombies(dt) {
|
||||
}
|
||||
if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) {
|
||||
const d = Math.hypot(ball.x - zb.x, ball.z - zb.z);
|
||||
if (d < 0.55) {
|
||||
if (d < (zb.variant === 'g' ? 0.75 : 0.55)) { // the brute is a bigger target
|
||||
if (ball.bomb) { // detonate on first contact, whatever the pace
|
||||
explode(ball.x, ball.z); ball.bomb = false;
|
||||
spentBall(); break; // zombies mutated — stop this frame's loop
|
||||
@@ -170,6 +188,22 @@ export function updateZombies(dt) {
|
||||
}
|
||||
continue; // soft balls pass through, for the AI to return
|
||||
}
|
||||
if (zb.variant === 'g') { // brute: soaks splat-quality hits, never staggers back
|
||||
if ((ball.pow || 0) >= TUNE.zSplatQ) {
|
||||
zb.hp--; splat(0.5); pock(0.6, 220);
|
||||
ball.vx *= 0.3; ball.vz *= 0.3; ball.lastHit = 'X';
|
||||
if (zb.hp <= 0) { splatter(i); continue; } // timber
|
||||
} else { pock(0.6, 300); ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; } // soft ball bounces off
|
||||
continue;
|
||||
}
|
||||
if (zb.variant === 's' && ball.vz < 0 && !(zb.shieldT > 0)) { // chair up, ball at its face: BLOCKED
|
||||
zb.shieldT = TUNE.shieldDown; // ...but the block staggers it — the chair drops, kill window opens
|
||||
zb.z -= TUNE.zPushback * 0.5;
|
||||
pock(0.9, 1800); // wooden clank
|
||||
ball.vx *= 0.4; ball.vz *= -0.2; ball.lastHit = 'X'; // ball caroms off the chair, spent
|
||||
continue;
|
||||
}
|
||||
// chair-down shield zombies (and anything hit from behind) obey the normal rules:
|
||||
if ((ball.pow || 0) >= TUNE.zSplatQ) { splatter(i); continue; }
|
||||
zb.z -= TUNE.zPushback; // weak sitters only stagger them
|
||||
pock(0.6, 300);
|
||||
@@ -183,8 +217,15 @@ export function updateZombies(dt) {
|
||||
const pe = pellets[i];
|
||||
pe.t -= dt; pe.x += pe.vx * dt; pe.z += pe.vz * dt;
|
||||
let hit = false;
|
||||
for (let j = zombies.length - 1; j >= 0; j--)
|
||||
if (zombies[j].variant !== 'r' && Math.hypot(zombies[j].x - pe.x, zombies[j].z - pe.z) < 0.55) { killZombie(j, true); hit = true; break; }
|
||||
for (let j = zombies.length - 1; j >= 0; j--) {
|
||||
const zb = zombies[j];
|
||||
if (zb.variant === 'r') continue;
|
||||
if (Math.hypot(zb.x - pe.x, zb.z - pe.z) < (zb.variant === 'g' ? 0.75 : 0.55)) {
|
||||
if (zb.variant === 'g' && --zb.hp > 0) pock(0.5, 260); // the brute soaks pellets too
|
||||
else killZombie(j, true);
|
||||
hit = true; break;
|
||||
}
|
||||
}
|
||||
if (hit || pe.t <= 0) pellets.splice(i, 1);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user