From 7bc26aec1cdc06a085b0b9528d6e04f59a0121dd Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sun, 12 Jul 2026 10:39:10 +0200 Subject: [PATCH] =?UTF-8?q?Phase=2011:=20enemy=20roster=20wired=20?= =?UTF-8?q?=E2=80=94=20runner,=20brute,=20shield=20zombie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- audio.js | 15 +++++++++++++ config.js | 3 +++ render.js | 9 +++++--- zombies.js | 63 ++++++++++++++++++++++++++++++++++++++++++++---------- 4 files changed, 76 insertions(+), 14 deletions(-) diff --git a/audio.js b/audio.js index 00cb1bd..b0688e8 100644 --- a/audio.js +++ b/audio.js @@ -116,6 +116,21 @@ export function boom(vol = 0.9) { // bomb ball — sub-bass thump under a filter } 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 try { const ac = audio(), t = ac.currentTime, dur = 1.2 + Math.random() * 0.8; diff --git a/config.js b/config.js index 1c2a654..2cd565e 100644 --- a/config.js +++ b/config.js @@ -35,6 +35,9 @@ export const TUNE = { hordeLen: 12, // s a horde wave lasts hordeRate: 2.5, // zombies spawned 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 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 diff --git a/render.js b/render.js index f7dcf5c..0b75626 100644 --- a/render.js +++ b/render.js @@ -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', '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', - '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(); img.onload = () => SPR[n] = img; img.src = 'assets/' + n + '.png'; @@ -125,8 +126,10 @@ function drawBoy(b) { function drawZombie(zb) { 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); + let frame = rk && zb.pose === 'swing' ? 2 : Math.floor(zb.walkT * (zb.variant === 'f' ? 6 : 2.6)) % 2 + 1; + 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 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); diff --git a/zombies.js b/zombies.js index 0aa909c..69e8ccf 100644 --- a/zombies.js +++ b/zombies.js @@ -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); }