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
@@ -116,6 +116,21 @@ export function boom(vol = 0.9) { // bomb ball — sub-bass thump under a filter
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shriek(vol = 0.35) { // a runner announces itself — harsh rising cry, the fairness contract
|
||||||
|
try {
|
||||||
|
const ac = audio(), t = ac.currentTime, dur = 0.7;
|
||||||
|
const o = ac.createOscillator(); o.type = 'sawtooth';
|
||||||
|
o.frequency.setValueAtTime(300, t);
|
||||||
|
o.frequency.exponentialRampToValueAtTime(950, t + 0.25);
|
||||||
|
o.frequency.exponentialRampToValueAtTime(500, t + dur);
|
||||||
|
const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 1400; bp.Q.value = 0.8;
|
||||||
|
const g = ac.createGain();
|
||||||
|
g.gain.setValueAtTime(0.001, t); g.gain.exponentialRampToValueAtTime(vol, t + 0.06);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
|
||||||
|
o.connect(bp).connect(g).connect(ac.destination); o.start(t); o.stop(t + dur + 0.05);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
export function moan(vol = 0.1) { // distant undead ambience
|
export function moan(vol = 0.1) { // distant undead ambience
|
||||||
try {
|
try {
|
||||||
const ac = audio(), t = ac.currentTime, dur = 1.2 + Math.random() * 0.8;
|
const ac = audio(), t = ac.currentTime, dur = 1.2 + Math.random() * 0.8;
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ export const TUNE = {
|
|||||||
hordeLen: 12, // s a horde wave lasts
|
hordeLen: 12, // s a horde wave lasts
|
||||||
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
|
||||||
|
runnerSpeed: 3.2, // runner ('f') speed multiplier over the shamble
|
||||||
|
bruteHp: 2, // splat-quality hits (or pellets) a brute ('g') soaks before it dies
|
||||||
|
shieldDown: 1.5, // s a shield zombie's ('s') chair stays dropped after a block — the kill window
|
||||||
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
|
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
|
racketSpeed: 2.8, // m/s the racket zombie shambles to chase a ball
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ const SPR = {};
|
|||||||
for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_swing1','p_swing2',
|
for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_swing1','p_swing2',
|
||||||
'o_idle1','o_idle2','o_bounce','o_bounce2','o_toss','o_smash','o_run1','o_run2','o_swing1','o_swing2',
|
'o_idle1','o_idle2','o_bounce','o_bounce2','o_toss','o_smash','o_run1','o_run2','o_swing1','o_swing2',
|
||||||
'x_ump','x_boyrun1','x_boyrun2','x_boyrun3','x_boyrun4',
|
'x_ump','x_boyrun1','x_boyrun2','x_boyrun3','x_boyrun4',
|
||||||
'z_a1','z_a2','z_b1','z_b2','z_c1','z_c2']) {
|
'z_a1','z_a2','z_b1','z_b2','z_c1','z_c2',
|
||||||
|
'z_f1','z_f2','z_g1','z_g2','z_s1','z_s2','z_s3']) {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = () => SPR[n] = img;
|
img.onload = () => SPR[n] = img;
|
||||||
img.src = 'assets/' + n + '.png';
|
img.src = 'assets/' + n + '.png';
|
||||||
@@ -125,8 +126,10 @@ function drawBoy(b) {
|
|||||||
function drawZombie(zb) {
|
function drawZombie(zb) {
|
||||||
const rk = zb.variant === 'r';
|
const rk = zb.variant === 'r';
|
||||||
const v = rk ? 'c' : zb.variant; // the racket boss reuses the turned-CPU sprite (already holds a racket)
|
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;
|
let frame = rk && zb.pose === 'swing' ? 2 : Math.floor(zb.walkT * (zb.variant === 'f' ? 6 : 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 (zb.variant === 's' && zb.shieldT > 0) frame = 3; // chair dropped — hit it NOW
|
||||||
|
const h = zb.variant === 'g' ? 2.7 : rk ? 1.85 : 1.8; // the brute towers half again over the crowd
|
||||||
|
drawStatic('z_' + v + frame, zb.x, zb.z, h, Math.floor(zb.walkT * 5.2) % 2);
|
||||||
if (rk) { // HP bar floating above the mini-boss
|
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;
|
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 = '#300'; ctx.fillRect(x, p.y, w, 3);
|
||||||
|
|||||||
+52
-11
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { TUNE, COURT } from './config.js';
|
import { TUNE, COURT } from './config.js';
|
||||||
import { G, P, ball, sendBoy, setupPoint } from './state.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';
|
import { splatPoint, cheer, spentBall } from './gameplay.js';
|
||||||
|
|
||||||
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
|
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) {
|
export function spawnZombie(x, z, variant) {
|
||||||
const ramp = 1 + Math.min(1.5, G.splatT / 70); // they get faster the longer you survive
|
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 —
|
// 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.
|
// shared by ball hits, machine-gun pellets, melee swipes, and extra (multi-serve) balls.
|
||||||
function killZombie(i, quiet) {
|
function killZombie(i, quiet) {
|
||||||
const zb = zombies[i];
|
const zb = zombies[i];
|
||||||
|
const big = zb.variant === 'g'; // a brute goes down like a building — twice the gore
|
||||||
zombies.splice(i, 1);
|
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();
|
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
|
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);
|
if (pl) pl.size = Math.min(4, pl.size + (big ? 2 : 1));
|
||||||
else { piles.push({ x: zb.x, z: zb.z, size: 1, seed: Math.random() * 1e4, blobs: null, builtSize: 0 });
|
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(); }
|
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,
|
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,
|
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,
|
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
|
// 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);
|
const cap = Math.min(TUNE.zMaxCap, TUNE.zMax + G.runGames);
|
||||||
if (spawnT <= 0 && zombies.length < cap) {
|
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);
|
spawnT = Math.max(TUNE.zSpawnMin, TUNE.zSpawnEvery - G.runGames * TUNE.zSpawnStep);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,12 +150,13 @@ 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 (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 */ }
|
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
|
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: 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) < (zb.variant === 'g' ? 0.9 : 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({
|
||||||
x: P.x, y: 1.1, z: P.z,
|
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) {
|
if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) {
|
||||||
const d = Math.hypot(ball.x - zb.x, ball.z - zb.z);
|
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
|
if (ball.bomb) { // detonate on first contact, whatever the pace
|
||||||
explode(ball.x, ball.z); ball.bomb = false;
|
explode(ball.x, ball.z); ball.bomb = false;
|
||||||
spentBall(); break; // zombies mutated — stop this frame's loop
|
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
|
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; }
|
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);
|
||||||
@@ -183,8 +217,15 @@ export function updateZombies(dt) {
|
|||||||
const pe = pellets[i];
|
const pe = pellets[i];
|
||||||
pe.t -= dt; pe.x += pe.vx * dt; pe.z += pe.vz * dt;
|
pe.t -= dt; pe.x += pe.vx * dt; pe.z += pe.vz * dt;
|
||||||
let hit = false;
|
let hit = false;
|
||||||
for (let j = zombies.length - 1; j >= 0; j--)
|
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; }
|
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);
|
if (hit || pe.t <= 0) pellets.splice(i, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user