// The splatter phase: shamblers, gore decals, particles. The ball is the weapon; // tennis scoring keeps running via gameplay.splatPoint. Zombies advance toward // your baseline — one crossing it is a point for THEM. // (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.) import { TUNE, UI, COURT, CONTACT, BOSS_ART, bossArt, WEAPONS, BARREL_WEAPONS, TIN_LEAGUE, woundStage, zombieH, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R, storyPool } from './config.js'; import { G, P, O, ball, score, sendBoy, setupPoint } from './state.js'; import { splat, moan, pock, thud, zap, voice, boom, swish, shriek, aww, sfx , whoosh, flame, squeak, crackle, whump, gunshot, stomp, growl } from './audio.js'; import { haptic } from './haptics.js'; import { drillKill, drillCaught } from './drill.js'; import { splatPoint, cheer, spentBall, aside, grantWeapon, endBurst, sliceCarom } from './gameplay.js'; import { resolveInterlude, endBossFight, storyDeath } from './beats.js'; // runtime-only cycle, like the gameplay one import { tally, multiKill, endRun, MODE_MATCH } from './stats.js'; import { award, bonus, prof, recordCup } from './profile.js'; export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'} export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer export const piles = []; // 3D-ish gore mounds {x, z, size 1..4, seed} — grow where kills cluster export const parts = []; // splatter particles {x, y, z, vx, vy, vz, t, dark} export const pellets = []; // machine-gun rounds during a horde {x, y, z, vx, vz, t} export const extras = []; // triple-serve side balls {x, y, z, vx, vy, vz, pow, t} — ballistic clones // INCOMING hostile balls: the ball machine's shots and the chapter boss's volleys. Deliberately a // separate array from `ball` and `extras` rather than an extension of either — the main ball is the // one you serve and there can only be one of it, and extras are yours and kill zombies. These are // theirs and they hurt YOU. Swat one with a swing and it becomes an `extras` entry flying back // up-court, which is how a parry turns into a real weapon for free. export const shots = []; // {x, y, z, vx, vy, vz, g, t} // world-anchored reaction pops: DOUBLE KILL rises off the corpse that earned it, PERFECT lands on // the impact — the answer to "what did I just do" appears where you are already looking, not in // the announcement band. Render draws them, gameplay's update ticks them, killed in resetZombies. export const pops = []; // {x, y, z, text, col, t, T} export function popText(x, y, z, text, col, still = false) { pops.push({ x, y, z, text, col, still, t: 0.85, T: 0.85 }); // still: does not rise (the rally's praise, under the player) if (pops.length > 10) pops.shift(); } let spawnT = 1.5, moanT = 3, crackleT = 0, whumpT = 0, bossHurtT = 0; // the last three throttle the fire and boss sounds // Every body, decal and projectile on the court, plus this module's own timers. Called by // gameplay.restartRun — a restart is in-place now, not a page reload. export function resetZombies() { for (const a of [zombies, gore, piles, parts, pellets, extras, shots, pops]) a.length = 0; spawnT = 1.5; moanT = 3; crackleT = whumpT = bossHurtT = 0; mgTick = 0; lastBarrel = null; } export function spawnZombie(x, z, variant) { // Keyed on GAMES FINISHED, not on splatT: the old `1 + min(1.5, splatT / 70)` maxed out after // 105 seconds of splatter, so every shambler from roughly game 3 onward walked at the same top // speed for the rest of a 50-game run. Games are what the player experiences as progress. // test mode pins the shamble at a fixed MEDIUM speed instead of riding the per-game ramp const ramp = 1 + Math.min(TUNE.zSpeedMax, G.runGames * TUNE.zSpeedPerGame); const base = G.testMode ? TUNE.testZSpeed : TUNE.zSpeed * ramp; const zb = { x, z, v: base * (0.8 + Math.random() * 0.4), walkT: Math.random() * 9, homeOff: (Math.random() * 2 - 1) * 0.8, variant }; // personal lane offset so they don't stack into a column if (variant === 'f') { zb.v = Math.min(TUNE.runnerVMax, zb.v * TUNE.runnerSpeed); shriek(); } // shriek = the fairness contract; capped so late runners stay reactable if (variant === 'a' || variant === 'b') zb.hp = TUNE.shamblerHp; // classic shamblers take a wound before they drop — only the runner is one-shot glass if (variant === 'g') { zb.v *= 0.55; zb.hp = TUNE.bruteHp; } // slow, but soaks hits if (variant === 'd') { zb.v *= 0.8; zb.hp = TUNE.stewardHp; } // the steward: a head taller and several hits tougher than the crowd, nothing more exotic than that if (variant === 'e') zb.hp = TUNE.ballboyHp; // the ball boy: shambler-sized, three hits deep if (variant === 'h') { zb.v *= 0.75; zb.hp = TUNE.bouncerHp; } // the bouncer: between the steward and the brute if (variant === 'k') { zb.v *= 0.45; zb.hp = TUNE.rollerHp; } // the roller man: slower and heavier than the brute // Every body with hp remembers its FULL hp. The wound skins used to compare against the variant's // TUNE constant, which meant every new body needed its own branch to look hurt; one field here and // the rule is "hp below full = show the wound", for anything that has a wound skin. if (zb.hp) zb.hpMax = zb.hp; if (variant === 's') zb.shieldT = 0; // chair up; > 0 = seconds of stagger left if ('fgsdehk'.includes(variant) && !G.seen['v' + variant]) { G.seen['v' + variant] = true; aside('ump_dress_code', 0.8); } if (variant === 'o') { // the weapon barrel: a rolling pink target with a weapon inside it zb.v = 0; zb.hp = zb.hpMax = TUNE.barrelHp; zb.wid = pickBarrelWeapon(); zb.dir = Math.random() < 0.5 ? -1 : 1; zb.roll = 0; } if (variant === 'm') { // the rogue ball machine: furniture, not a body. It never SHAMBLES... zb.v = 0; zb.hp = zb.hpMax = TUNE.machHp; zb.fireT = TUNE.machCycle; zb.windT = 0; zb.repoT = TUNE.machRepo * (0.4 + Math.random()); // ...but it wheels itself somewhere else now and then } zombies.push(zb); } let lastBarrel = null; // WHICH weapon is in the barrel. A flat 1-in-4 gave Oliver the laser "almost every time" and the // beacon never — four samples is far too few for uniform to feel uniform. Weighted, gated on the // run so the rocket is genuinely a late-game find, and it never hands you the same thing twice in // a row, which is the part a player actually notices. function pickBarrelWeapon() { // THE STORY: a barrel holds what the act has earned (config.storyPool) — the act decides the rack, // so a barrel in act I is a bomb, a triple or fire, never the laser the story hands out in act III if (G.story) { const own = storyPool(G.story.act).filter(id => id !== lastBarrel); const id = own.length ? own[Math.floor(Math.random() * own.length)] : lastBarrel || 'bomb'; lastBarrel = id; return id; } const pool = []; for (const [id, b] of Object.entries(BARREL_WEAPONS)) { if (G.runGames < b.from) continue; // THE WEAPON LICENCE (the leagues): the league licenses, `from` paces — a barrel must not // hand a league-1 player the laser his duels rightly refuse (config.TIN_LEAGUE) if (prof.lg < (TIN_LEAGUE[id] || 0)) continue; if (id === lastBarrel) continue; // never twice running... for (let k = 0; k < b.weight; k++) pool.push(id); } // ...unless it is the only thing unlocked, in which case repeating beats handing out nothing if (!pool.length) return lastBarrel || 'beacon'; const id = pool[Math.floor(Math.random() * pool.length)]; lastBarrel = id; return id; } // A WEAPON BARREL, rolled across the far court. Shoot it and you get what is inside, armed on the // spot. It is the only source of the four weapons that are not on the duel ladder, and it is pure // upside — it never attacks, never scores, and rolls off on its own if you ignore it. export function spawnBarrel() { spawnZombie((Math.random() * 2 - 1) * 3.2, 4.5 + Math.random() * 3, 'o'); // a CARD, not a modal: it lands at a game-over beat that can have a standing wave on court, and // a world-stopping popup there is exactly the "popups get me killed" report. Same for the // machine's and the racket zombie's introductions below. if (!G.seen.barrel) { G.seen.barrel = true; G.card = { title: 'A GLOWING BARREL', sub1: 'A WEAPON IS INSIDE — SHOOT IT', color: UI.pink, t: 2.6 }; } } // the rogue ball machine, wheeled out onto the far court. It never moves and never scores; it just // shoots at you until you deal with it. export function spawnMachine() { spawnZombie((Math.random() * 2 - 1) * 3.6, 2.2 + Math.random() * 3, 'm'); aside('ump_machine', 0.4); // benched with the rest of Daniel's personality — see aside() if (!G.seen.mach) { G.seen.mach = true; // the card NAMES the thing it explains — an instruction without a subject read as noise // (2026-08-29 copy audit) G.card = { title: 'A BALL MACHINE', sub1: 'SWING ITS SHOTS BACK — OR SWISH THEM AWAY', color: UI.warm, t: 2.8 }; } } // ---- the chapter boss. One every TUNE.bossEvery games, bigger and tougher each time. It advances // slowly and volleys at you, so it threatens BOTH channels: its shots chip your hp, and letting it // reach you is the ordinary instant death. Reuses the whole existing damage model — it is just a // body with a lot of hp (see the wound() invariant in PROJECT.md), so no weapon needs special-casing. export function spawnChapterBoss(chapter, design = null) { // which of the boss designs this chapter is (every tenth = THE CHAMPION) — or, in THE STORY, the // act's own design at the act's strength (config.storyBossArt / storyBossChapter) const art = design || bossArt(chapter); // Every behaviour field on the design is OPTIONAL and falls back to the shared TUNE number, so a // design row that carries nothing but art behaves exactly the way a boss did before there were any. const hp = Math.round((TUNE.bossHp + (chapter - 1) * TUNE.bossHpPerChapter) * (art.hpMul ?? 1)); zombies.push({ x: 0, z: -1.2, v: TUNE.bossSpeed * (art.speedMul ?? 1), walkT: 0, homeOff: 0, variant: 'B', chapter, hp, hpMax: hp, scale: TUNE.bossScale + (chapter - 1) * TUNE.bossScalePer, fireT: art.cycle ?? TUNE.bossCycle, windT: 0, salvo: 0, salvoT: 0, dist: 0, dir: Math.random() < 0.5 ? -1 : 1, moveT: 0, // dist drives the stride: see drawZombie art }); } // ---- BOSS MOVEMENT. Before this the boss had no AI at all — it matched no branch in the variant // chain below and fell through to the crowd's aimless ±0.15 m/s wobble, so "the boss moves" was a // comment and nothing else. Each design owns a style (BOSS_ART.move) and they differ in the axis // they threaten on: pace/bounce make you lead a moving target, mirror takes your lane away, charge // trades its ranged game for closing the distance. Returns nothing; it moves zb.x/zb.z directly. // // Two rules hold for all of them: // - IT PLANTS ITS FEET TO THROW. While winding up or mid-volley it does not move, which is the same // fairness contract as the wind-up glow: the telegraph has to be worth reading. // - IT STAYS IN THE BAND (bossZMin..bossZMax) unless it is charging. Uncapped, a boss ended the // fight by simply arriving on top of you after about a minute — and every extra hp made that // outcome MORE likely, not less. Only 'charge' is allowed through, and that is its whole threat. // A NOTE THAT DECIDED THREE OF THESE FIVE: in splatter the player is rooted in `pretoss` for nearly // the whole of a boss fight (he only walks during `rally` — a settled decision). So any style whose // motion is "close on the player's x" arrives, stops, and is static for the rest of the fight, which // is the exact complaint this work exists to fix. Every style below therefore moves under its OWN // power and only BIASES toward the player. Measured: each of the six covers 5-14 m a minute. // WHICH PHASE a boss is in, or -1 for a design that has none. Health drives it, so it is derived // rather than stored and cannot desync from the hp it is meant to describe. const bossPhase = zb => { const n = zb.art?.phases?.length; return n ? Math.min(n - 1, Math.floor((1 - zb.hp / zb.hpMax) * n)) : -1; }; // The design a boss is FIGHTING as, this instant: its own row, with the current phase's fields laid // over it. Everything that reads a boss's behaviour goes through here, so a phase stays a data row // and never becomes a second code path. (There is one boss on court; the object per call is free.) export const bossPlan = zb => { const A = zb.art || {}, i = bossPhase(zb); return i < 0 ? A : { ...A, ...A.phases[i] }; }; function bossAI(zb, dt) { if (zb.windT > 0 || zb.salvo > 0) return; // planted: throwing is not a moving action const x0 = zb.x, z0 = zb.z, A = bossPlan(zb), style = A.move || 'advance'; const strafe = TUNE.bossStrafe * (A.paceMul ?? 1); if (style === 'charge') { zb.moveT -= dt; const v = TUNE.bossChargeV * (A.speedMul ?? 1); if (zb.charging) { // IT COMMITTED TO A LANE AND IT IS RUNNING DOWN THAT LANE, whatever you do next. Tracking your // x for the whole rush made the sidestep meaningless — it just followed you across and ran you // over ("they overrun you without a chance"), and stopping the rush ON you left it standing // there afterwards, where the 0.5 s dodge cooldown cannot outrun a 0.28 s i-frame. So: the // lane is locked when the rush begins, you get the entire run-up to leave it, and the rush // ends on a STRIKE LINE just in front of you, on the same frame the rush timer expires, so it // turns and withdraws the instant it arrives. A rush that misses is over. zb.x += Math.sign(zb.lane - zb.x) * Math.min(Math.abs(zb.lane - zb.x), TUNE.bossStrafe * 2 * dt); zb.z += zb.chargeV * dt; if (zb.moveT <= 0) { zb.charging = false; zb.moveT = TUNE.bossChargeRest; } // spent: it turns and walks back } else { // AND IT FALLS BACK. A rush that ended where it landed left the boss parked on top of you for // the rest of the fight: dodge or die every two seconds, with no window to hit it and nothing // but a hitscan laser fast enough to use what window there was — Oliver, "they come too close, // they seem unbeatable". So the retreat OUTRUNS the rush (retreat > 1) and stops at the front // of the band rather than crawling a fraction of the way back. A charge is a threat that // PASSES: you dodge it, and the ground it gives up again is your turn to hit it. const home = TUNE.bossZMax, sp = v * (A.retreat ?? 1.3) * dt; // toward its LINE, from either side: zb.z += Math.sign(home - zb.z) * Math.min(Math.abs(home - zb.z), sp); // backward off a rush, forward on the way in // THE RUSH IS TIMED, NOT PACED, and it always begins FROM THE LINE — never from wherever a // half-finished retreat happened to leave it, which is how a rush could start on your toes // with no run-up to read. It covers the whole distance in bossChargeT, so the approach looks // the same every time whatever the design's speed. if (zb.moveT <= 0 && Math.abs(zb.z - home) < 0.1) { zb.charging = true; zb.moveT = TUNE.bossChargeT; zb.lane = P.x; // the lane, locked. You have the run-up to leave it. zb.chargeV = (P.z - TUNE.bossStrike - zb.z) / TUNE.bossChargeT; } } } else if (style === 'shadow') { // paces — but the ground it paces over is centred on YOU, so it never stops moving and it takes // your lane away the moment you take one const c = Math.max(-TUNE.bossPaceX, Math.min(TUNE.bossPaceX, P.x)); zb.x += zb.dir * strafe * dt; if (zb.x > c + TUNE.bossShadowX) zb.dir = -1; else if (zb.x < c - TUNE.bossShadowX) zb.dir = 1; zb.z += zb.v * dt; } else if (style === 'bounce') { // hops, not a slide: it covers bossHopX per hop and is nearly still between them, so it reads as // a costume with someone inside it rather than a sprite being lerped across the court. It flips // every hop and only steers when it has drifted a long way off your lane. zb.moveT -= dt; if (zb.moveT <= 0) { zb.moveT = TUNE.bossHopT; // keep going the SAME way until it runs out of court or has overshot your lane, then turn. // Flipping every hop kept it oscillating inside one metre — motion with no travel in it. if (Math.abs(zb.x) > TUNE.bossPaceX) zb.dir = -Math.sign(zb.x); else if (Math.abs(P.x - zb.x) > 2.5) zb.dir = Math.sign(P.x - zb.x) || zb.dir; } const k = Math.max(0, Math.sin(Math.PI * (1 - zb.moveT / TUNE.bossHopT))); // fast at mid-hop zb.x += zb.dir * (TUNE.bossHopX / TUNE.bossHopT) * k * dt; zb.z += zb.v * dt; } else if (style === 'surge') { // rolls up the whole band and back down it, over and over, drifting slowly into your lane. Its // DEPTH is the threat, not its x — the one that simply keeps coming, and keeps coming back. zb.z += zb.dir * zb.v * 3 * dt; if (zb.z > TUNE.bossZMax) zb.dir = -1; else if (zb.z < TUNE.bossZMin) zb.dir = 1; const dx = P.x - zb.x; zb.x += Math.sign(dx) * Math.min(Math.abs(dx), strafe * 0.3 * dt); } else if (style === 'pace') { zb.x += zb.dir * strafe * dt; if (Math.abs(zb.x) > TUNE.bossPaceX) { zb.dir = -Math.sign(zb.x); zb.x = Math.sign(zb.x) * TUNE.bossPaceX; } zb.z += zb.v * dt; } else { // 'advance': the old crawl, with a slow drift. zb.x += Math.sin(zb.walkT * 0.6) * 0.4 * dt; // Unused by any design — it is what a row with zb.z += zb.v * dt; // no `move` field falls back to. } if (style !== 'charge') zb.z = Math.max(TUNE.bossZMin, Math.min(TUNE.bossZMax, zb.z)); zb.x = Math.max(-TUNE.bossPaceX - 0.6, Math.min(TUNE.bossPaceX + 0.6, zb.x)); // never off the sides // The stride steps off GROUND COVERED (the brute's rule). A strafing boss read off zb.z alone // would slide sideways with both feet frozen — the exact defect the distance rule exists to stop. const d0 = zb.dist || 0; zb.dist = d0 + Math.hypot(zb.x - x0, zb.z - z0); if (Math.floor(zb.dist / TUNE.bruteStride) !== Math.floor(d0 / TUNE.bruteStride)) stomp(0.35, bodySize(zb)); // one per drawn stride } // HE CALLS SECURITY. A design with `adds` puts bodies on the court while you fight it — the one // thing a boss fight could not do before, since startBossFight clears the stage and normal spawning // is off for the duration. `cap` counts the boss itself, so the court cannot silt up while a slow // player works on a big health bar. function bossAdds(zb, dt) { const A = bossPlan(zb); if (!A.adds || (zb.addT = (zb.addT ?? A.adds.every) - dt) > 0) return; zb.addT = A.adds.every; if (zombies.length < (A.adds.cap ?? 5)) spawnZombie((Math.random() * 2 - 1) * 5, -1.5 - Math.random(), A.adds.variant); } // ---- the player's second health channel. Only these two functions touch it. export function hurtPlayer(n) { // G.hurtT is the grace window: a volley must not delete you at once. G.dodgeI is the SIDESTEP — // it beats this channel and the eat test below, which is the only reason to spend a dodge. if (G.mode !== 'splatter' || G.hurtT > 0 || G.dodgeI > 0 || G.mode === 'eaten') return; G.hp -= n; G.hurtT = TUNE.hurtGrace * bonus().grace; G.hurtFlash = 0.3; // the grace rides the level (the leagues) haptic('heavy'); // taking one is rare (hurtGrace gates it) and is the loudest thing the hand gets G.shake = { t: 0.22, mag: 4 }; splat(0.7); aww(0.5); for (let k = 0; k < 14; k++) parts.push({ // he takes it in the chest x: P.x + (Math.random() * 2 - 1) * 0.2, y: 1.2, z: P.z, vx: (Math.random() * 2 - 1) * 2.2, vy: 0.8 + Math.random() * 2, vz: (Math.random() * 2 - 1) * 2.2, t: 0.35 + Math.random() * 0.35, dark: Math.random() < 0.5 }); if (G.hp <= 0) killPlayer(); else aside('ump_quiet_bleeding', 0.3); // benched with the rest of Daniel's personality — see aside() } // Death, from EITHER channel: a shambler reaching you, or your hp running out. Extracted so the two // causes cannot drift apart — the run summary, the best-run write and the death animation are all // here exactly once. export function killPlayer() { if (G.mode === 'eaten') return; // In the drill, being caught costs the POINT and not the run — a deliberate contract change, // see drillCaught. Permadeath stays where it was settled: the gore mode. if (G.drill) return drillCaught(); gore.push({ x: P.x, z: P.z, r: 0.9, a: 0.9 }); if (gore.length > 40) gore.shift(); for (let k = 0; k < 30; k++) parts.push({ x: P.x, y: 1.1, z: P.z, vx: (Math.random() * 2 - 1) * 4.5, vy: 1 + Math.random() * 4, vz: (Math.random() * 2 - 1) * 4.5, t: 0.5 + Math.random() * 0.5 }); splat(1); voice('game_set_massacre', 0.8); aww(1); // the run is over — the crowd groans // the death animation runs off G.eatenT: thrash here, burst at EATEN_BURST (playerGore), summary after haptic('dead'); // the notification pattern, once a run — the only place it is ever used G.eatenT = 0; G.eatenAt = { x: P.x, z: P.z }; G.mode = 'eaten'; ball.active = false; G.flash = null; G.modal = null; // zombies keep shambling for ambience G.horde = null; pellets.length = 0; extras.length = 0; shots.length = 0; G.mgT = 0; G.gunId = null; // everything stops // The run is over — bank it. endRun writes the record AND hands back the best run before this // one, which is what the summary compares against. It replaced a hand-rolled localStorage. // splatterBest read/write here: the best is now derived from the record rather than stored twice // and left to drift. Timing is safe — restartRun() only fires on the tap AFTER death, so // runGames/sets/splatT are all still intact at this point. // THE MATCH MEASURES SOMETHING ELSE. Blood-only scoring (model A) means a lost game costs nothing // but the blood already spent, so "games survived" is not the achievement — OPPONENTS FELLED is. // It goes through the SAME endRun with the mode tagged and its own two headline numbers in the // record's two headline slots (see the shape note there), so the best is derived from the record // rather than stored a second time in its own key and left to drift. // THE STORY HAS NO PERMADEATH: the death costs the attempt, not the story (prof.story keeps the // checkpoint), and it banks nothing on the boards — the story is not a high-score run, and its // chapter games are numbered on the arcade's scale (STORY.base), which would post act V's game // 85 as a gore best. The summary reads G.story and offers the retry. if (G.story) { storyDeath(); return; } const m = G.match; // the cup's opp is a DRAWN RUNG, not a counter — bodies felled there is completed rounds. The // arcade counts kills on d.felled now (THE LEAGUES: opp starts at the band's bottom rung and // clamps at its top, so `opp - 1` stopped meaning anything); the fallback keeps old shapes alive. const felled = m ? (m.cup ? m.cup.round : (m.felled ?? Math.max(0, m.opp - 1))) : 0, gamesPlayed = score.games[0] + score.games[1]; const b = m ? endRun(felled, gamesPlayed, G.splatT, true, MODE_MATCH) : endRun(G.runGames, G.sets[0], G.splatT); G.best = b; G.newBest = b.newBest; // b is {games, sets, newBest}; render reads games/sets if (m) { m.felled = felled; m.games = gamesPlayed; m.best = b.games; } // ...the death screen's words // THE CUP RECORD (THE SEASON): an attempt that ends here is still an attempt. The round you // were IN is the round you reached, and the rung holding the racket is the one the training // court will offer you next. Training reps are not attempts and never write it. if (m && m.cup && !m.train) recordCup(G.league, m.cup.round + 1, m.cup.rung || m.opp); } // One hostile ball, lobbed at where the player is standing. `spread` fans a volley sideways. // It is a real ARC (solved like a serve, not fired flat) so it is readable coming in and so the // swing window has something to meet — a flat bolt at head height is not a tennis shot. function fireAtPlayer(x, z, spread = 0, speed = TUNE.machSpeed) { const y0 = 1.1; const tx = P.x + spread, tz = P.z; const d = Math.hypot(tx - x, tz - z), Tf = Math.max(0.35, d / speed); const g = TUNE.gravity; shots.push({ x, y: y0, z, vx: (tx - x) / Tf, vz: (tz - z) / Tf, vy: (0.9 - y0 + 0.5 * g * Tf * Tf) / Tf, // land at racket height, not on the grass g, t: Tf + 1.6 }); pock(0.55, 190); // the thunk of the throw } // Which shambler steps out next. Unlocks are keyed on GAMES, not on sets won: a set is six games, // so the old top gate (`tier >= 2`) opened around game 12 and after that the mix was frozen forever // at 15% shield / 15% brute / 12% runner — a third of why a 50-game run stopped changing. // Now the advanced SHARE keeps climbing after everything is unlocked, which is what makes game 40 // feel different from game 20 rather than merely faster. function pickVariant() { const g = G.testMode ? TUNE.testLevel : G.runGames; // test mode unlocks the whole roster const pool = []; if (g >= 2 && !zombies.some(z => z.variant === 'f')) pool.push('f'); // at most one runner alive if (g >= 6) pool.push('g'); if (g >= 12) pool.push('s'); // The steward is the mildest of the pool on purpose — bigger and one hit tougher, no special rule // to learn — so he can arrive EARLY and thicken the ordinary crowd, which the shambler mix alone // never did. He is also the only advanced body that shows up in a horde (see spawnHordeZombie). if (g >= 4) pool.push('d'); // The late roster, spread WIDE on purpose. They were at 3/8/15 and Oliver had met the whole cast // by about game 10 — "let's introduce them more gradually". One new body roughly every seven // games now, and the ORIGINAL four keep their gates exactly (the curve through ~game 12 was // already tuned; see the calibration rule in PROJECT.md). if (g >= 5) pool.push('e'); if (g >= 12) pool.push('h'); if (g >= 20) pool.push('k'); const nasty = Math.min(TUNE.nastyMax, g * TUNE.nastyPerGame); if (pool.length && Math.random() < nasty) return pool[Math.floor(Math.random() * pool.length)]; return Math.random() < 0.5 ? 'a' : 'b'; } // How big a target a body is. Bosses are deliberately a little more generous than a shambler — // part of "the racket zombie doesn't seem harmable" was simply missing him. // A BOSS BODY: takes wound damage from everything instead of being deleted by it. The brute ('g') is // deliberately NOT one — melee/bomb/fire one-shotting a brute is a settled "power exception". const isBoss = zb => zb.variant === 'r' || zb.variant === 'B'; const hitR = zb => zb.variant === 'g' ? 0.75 : zb.variant === 'd' ? 0.64 : zb.variant === 'r' ? 0.7 : zb.variant === 'o' ? 0.7 // the barrel: a wide low drum, generous on purpose : zb.variant === 'k' ? 0.88 : zb.variant === 'h' ? 0.68 // the roller man is wider than the brute : zb.variant === 'B' ? 0.55 * (zb.scale || 1) * 1.4 // a chapter boss is a barn door, as it should be : zb.variant === 'm' ? 0.8 // the machine is a wide low box : 0.55; // How big it sounds. 1 = a shambler, and every impact sound scales its pitch and length off this, // so a ball landing on a brute is audibly a bigger event than one landing on a runner. const bodySize = zb => zombieH(zb) / 1.8; // THE LASER. Not a projectile: the serve gesture you already know, and instead of a ball you get a // widening plane of light down the lane you aimed at, killing everything standing inside the wedge // at once. Oliver asked for exactly this after a tap-per-round version read as broken — "the normal // ball logic, but the band is blue and gets way wider at the end, like a blue plane cutting through". // It goes through wound()/killZombie like everything else, so it cannot no-op on a boss. export function fireBeam(dirX, dirZ) { const d = Math.hypot(dirX, dirZ) || 1, ux = dirX / d, uz = dirZ / d; // out of the PLAYER at chest height — same point the cone is measured from, so what you see and // what it kills are one geometry CAUSE = 'laser'; G.beam = { x: P.x, y: TUNE.beamY + TUNE.beamOriginLift, z: P.z, ux, uz, t: TUNE.beamT }; // 0.22, was 0.55: zap() sums FOUR voices (crack, two detuned saws, a sub) straight into the // destination, so its `vol` is not one voice's level — it was coming out several times louder // than anything else in the game. Oliver: "laser sound is way too loud." zap(0.22); G.shake = { t: 0.18, mag: 3 }; G.boomFlash = 0.08; for (let i = zombies.length - 1; i >= 0; i--) { const zb = zombies[i]; if (!zb) continue; const rx = zb.x - P.x, rz = zb.z - P.z; const along = rx * ux + rz * uz; // how far down the lane it stands if (along < 0 || along > TUNE.beamLen) continue; // behind you, or past the end of the beam const off = Math.abs(rx * uz - rz * ux); // lateral offset from the lane // the kill follows whichever LOOK is on, or the A/B would be a lie: a narrow band that deletes a // cone-wide crowd tells you nothing about how the narrow band plays const halfW = TUNE.beamBand >= 0.5 ? TUNE.beamBandW * 2 : TUNE.beamW0 + (TUNE.beamW1 - TUNE.beamW0) * (along / TUNE.beamLen); if (off > halfW + hitR(zb)) continue; // THE BEAM DELETES ORDINARY BODIES. hp does not enter into it: a plane of light that left a // roller man standing was the thing that made it feel like a worse ball rather than a weapon. // A BOSS is the exception and takes a heavy wound instead — "they also must suffer severely". if (isBoss(zb)) { if (wound(zb, TUNE.dmgBeam)) killZombie(i, true); } else killZombie(i, !!G.horde); } } // 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. // how absurdly a variant dies: classics splash, the advanced roster DETONATES const GORE_MUL = { a: 1, b: 1, e: 1, f: 1.8, s: 2.5, h: 2.5, g: 4, k: 5, r: 3, m: 1.5, o: 0, B: 6 }; // a chapter boss comes apart like nothing else on the court; a barrel is not meat at all // WHAT IS KILLING THINGS RIGHT NOW. killZombie takes an index, not a cause, and eleven call sites // reach it — threading a weapon id through all of them (and through wound(), and through // splatter()) to feed one number on a menu is not worth the churn. Every damage loop in this file // instead stamps this before it can kill, and killZombie reads it. Safe because the whole thing is // one synchronous frame: nothing interleaves between the stamp and the kill it causes. let CAUSE = 'ball'; // A barrel bursts and hands you what was inside. Pink debris rather than gore — it is a container, // not a body. It does NOT equip itself: shooting a barrel mid-plan used to yank the gun into your // hand and the next serve fired the wrong thing — the current weapon stays live and the new slot // pulses pink instead (render.drawRack reads G.rackGlow). The announcement is a CARD, never a // modal: a barrel pops mid-combat, and a popup that stops the world there costs lives, not reads. function popBarrel(zb) { boom(0.7); G.shake = { t: 0.22, mag: 4 }; G.boomFlash = 0.08; for (let k = 0; k < 30; k++) parts.push({ x: zb.x, y: 0.7, z: zb.z, vx: (Math.random() * 2 - 1) * 4, vy: 1.2 + Math.random() * 3.5, vz: (Math.random() * 2 - 1) * 4, t: 0.35 + Math.random() * 0.5, pink: true }); const keep = G.weapon; const got = grantWeapon(zb.wid); G.weapon = keep; G.rackGlow = { slot: got, t: 2.2 }; const w = WEAPONS[zb.wid]; G.card = { title: w.label, sub1: (w.desc && w.desc[0]) || '', sub2: 'TAP ITS RACK SLOT TO ARM IT', icon: w.icon, color: UI.pink, t: TUNE.tinCardT }; } // dark smoke: one puff, drifting up and spreading. Not gravity-bound like gore (see the parts loop). function smokePuff(x, y, z, spread = 0.5) { parts.push({ x: x + (Math.random() * 2 - 1) * spread, y, z: z + (Math.random() * 2 - 1) * spread * 0.5, vx: (Math.random() * 2 - 1) * 0.35, vy: 0.5 + Math.random() * 0.5, vz: (Math.random() * 2 - 1) * 0.35, t: 0.9 + Math.random() * 0.9, smoke: true }); } // The machine is the one thing on court that is not made of meat, and it used to burst like a body: // blood decal, gore mound, the wet splat, a ball boy sent out to mop it. It comes apart as MACHINERY // now — a flash, sparks, torn metal, and a column of dark smoke over a scorch mark. function wreckMachine(zb) { boom(); G.shake = { t: 0.3, mag: 5 }; G.boomFlash = 0.09; gore.push({ x: zb.x, z: zb.z, r: 1.1, a: 0.95, scorch: true }); // burnt clay, not blood if (gore.length > 40) gore.shift(); for (let k = 0; k < 26; k++) parts.push({ // sparks and burning debris x: zb.x, y: 0.8, z: zb.z, vx: (Math.random() * 2 - 1) * 4, vy: 1.5 + Math.random() * 4, vz: (Math.random() * 2 - 1) * 4, t: 0.3 + Math.random() * 0.5, fire: true }); for (let k = 0; k < 26; k++) smokePuff(zb.x, 0.5 + Math.random() * 1.6, zb.z, 0.7); // a column, not a puff } // A chapter boss does not just die. It is the thing the whole fight was about, so it comes apart // in its own beat: the world drops into slow motion, the screen kicks, a ring of blood blows out // through the crowd's roar, and the card that announces it waits until the pieces have landed. function bossDeath(zb) { G.slowT = TUNE.bossSlowT; // real seconds; main.js scales dt while it runs // ...and the camera leans in on it: the transition's punch-in, reused at a gentler magnification, // riding the slow-mo so the longest fight in the game ends on a camera moment. T is GAME time // (update ticks zoom with the scaled dt), so the peak lands mid-slow-mo and eases out after. G.zoom = { x: zb.x, z: zb.z, k: 1.22, t: 0, T: 0.8 }; G.bossBurst = { x: zb.x, z: zb.z, t: 0.75 }; // the shockwave ring render.js draws G.shake = { t: 0.5, mag: 7 }; G.boomFlash = 0.12; boom(); splat(1, bodySize(zb)); growl(0.45, bodySize(zb), 1.4, 0.4); cheer(4, 1); voice('unbelievable', 0.5); // the biggest body in the game, at its lowest const h = 1.8 * (zb.scale || 1.5); for (let k = 0; k < 90; k++) parts.push({ // on top of killZombie's own burst: a full body's worth x: zb.x, y: h * (0.2 + Math.random() * 0.7), z: zb.z, vx: (Math.random() * 2 - 1) * 7, vy: 1 + Math.random() * 7, vz: (Math.random() * 2 - 1) * 7, t: 0.7 + Math.random() * 0.9, dark: Math.random() < 0.4 }); for (let k = 0; k < 6; k++) { // and it leaves a mess nothing else on the court leaves const a = Math.random() * 6.283, r = Math.random() * 2.4; gore.push({ x: zb.x + Math.cos(a) * r, z: zb.z + Math.sin(a) * r * 0.6, r: 0.6 + Math.random() * 0.7, a: 0.9 }); if (gore.length > 40) gore.shift(); } } function killZombie(i, quiet) { const zb = zombies[i]; haptic('medium'); // every weapon routes through here, so the kill is felt exactly once per kill // The drill's ONE rule, read at the single point every weapon routes through — hooking the ball // path instead would have the swish, the pellets and the blast silently exempt from the order. const mul = GORE_MUL[zb.variant] || 1; // BEFORE the early returns below: the ball machine is furniture but it is still a thing you put // down, so it earns a row. The barrel is loot and scores nothing anywhere else in the game — // keep that agreement here too. A chapter boss files under its DESIGN, so the screen can name // the one that has been killing you. // A MATCH OPPONENT FILES UNDER WHO IT IS, not under the generic body it wears: the screen is a // list of who you have felled, and 'r' is a body, not a name. zb.foe is set by matchOpponent. if (zb.variant !== 'o') { tally(zb.foe ? zb.foe.key : zb.variant === 'B' ? 'B' + Math.max(0, BOSS_ART.indexOf(zb.art)) : zb.variant, CAUSE); award('kill'); // the leagues' clock, at the same single funnel — award() mutes no-stakes modes } zombies.splice(i, 1); // THE DRILL RESOLVES HERE — after the splice, and WITHOUT returning. Both halves are load-bearing: // splice first, because drillKill can draw a new combination on the spot (a double fault // re-calls the survivors, an emptied batch brings in the next one) off whatever is standing in // `zombies` — resolving first let the new combination name the body that was in the middle of // dying, i.e. a round asking for a colour that is not on court; // and no early return, because everything below is the DEATH — the blood decal, the pile, the // particle burst, the splat and the headshot crack. The first cut returned here and bibbed // bodies died silently and cleanly, which is not what this game does to anybody. if (G.drill && zb.drill) drillKill(zb); // `drill` marks a batch member; eaters are never flagged if (zb.variant === 'B') bossDeath(zb); // before endBossFight below, so the card can wait on it if (zb.variant === 'o') { popBarrel(zb); return; } // a container: it pays out and scores NOTHING if (zb.variant === 'm') { // furniture, not a body: no blood, no mound, no mop boy wreckMachine(zb); if (!G.horde && !G.clearing) splatPoint('P', quiet); return; } if (G.interlude && zb.variant === 'r') resolveInterlude('P', 'body_destroyed'); // regicide ends the duel your way — any weapon counts if (zb.variant === 'B') endBossFight(); // the chapter is over // A BURNT BODY DOES NOT BURST. Fire kills by cooking, so there is nothing left to spray — it goes // to dark grey ash instead of blood: same decal, same mound, same particles, all recoloured and a // little smaller, plus a puff of smoke. Marked either by still burning or by `ashes`, which the // flame ball sets before it kills (that kill happens before the body ever catches). const ash = zb.burnT > 0 || zb.ashes; const stains = Math.round(Math.sqrt(mul)); // advanced kills splash a scatter of stains, not one puddle for (let k = 0; k < stains; k++) { gore.push({ x: zb.x + (k ? (Math.random() * 2 - 1) * 0.9 : 0), z: zb.z + (k ? (Math.random() * 2 - 1) * 0.9 : 0), r: (0.45 + Math.random() * 0.35) * Math.sqrt(mul) * (ash ? 0.8 : 1), a: ash ? 0.7 : 0.85, ash }); 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 + Math.round(mul / 2 + 0.5)); if (!ash) pl.ash = false; } else { piles.push({ x: zb.x, z: zb.z, size: Math.min(4, Math.round(mul / 2 + 0.5)), seed: Math.random() * 1e4, blobs: null, builtSize: 0, ash }); if (piles.length > 30) piles.shift(); } // A BURNT BODY COLLAPSING IS ITS OWN MOMENT. It used to be over faster than a wet kill — fewer // motes AND shorter ones — so the most spectacular way to die in the game was the least visible. // Ash still SETTLES rather than sprays (low, slow, no arc), but there is more of it and it hangs // roughly twice as long, which is what sells "he came apart into a heap" instead of "he blinked". const n = Math.round((ash ? 34 : 30) * mul); 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) * (ash ? 1.6 : 3.5), vy: (ash ? 0.6 : 1) + Math.random() * (ash ? 1.6 : 3.5), vz: (Math.random() * 2 - 1) * (ash ? 1.6 : 3.5), t: ash ? (dark ? 1.3 + Math.random() * 1.1 : 0.9 + Math.random() * 0.8) : (dark ? 0.6 + Math.random() * 0.6 : 0.4 + Math.random() * 0.4), dark, ash, }); } if (ash) { // the dust cloud: a low wide bloom at the feet where the body folded, and a thinner column // lifting off it. Five tight puffs read as a wisp; this is the body's own volume going up. for (let k = 0; k < 10; k++) smokePuff(zb.x, 0.25 + Math.random() * 0.5, zb.z, 1.0); // the ground bloom for (let k = 0; k < 8; k++) smokePuff(zb.x, 0.8 + Math.random() * 1.5, zb.z, 0.5); // the column for (let k = 0; k < 8; k++) parts.push({ // last embers off the collapse, dying as it settles x: zb.x + (Math.random() * 2 - 1) * 0.5, y: 0.3 + Math.random() * 1.0, z: zb.z, vx: (Math.random() * 2 - 1) * 1.1, vy: 1.0 + Math.random() * 1.4, vz: (Math.random() * 2 - 1) * 1.1, t: 0.3 + Math.random() * 0.35, fire: true }); pock(0.4, 130); } else { splat(1, bodySize(zb)); // the burst is pitched by the body too, so a boss comes apart LOW sfx('headshot-' + (Math.random() < 0.5 ? 1 : 2), 0.6); // random headshot crack layered on the gore burst } sendBoy(zb.x, zb.z, true); // a boy comes out with the mop // The chapter boss deliberately scores NOTHING: its reward is the chapter card and the parked game // beat resuming. Letting it bank a point meant splatPoint's game-over branch and endBossFight both // decided "what happens next" in the same frame and clobbered each other. if (!G.horde && !G.clearing && zb.variant !== 'B') splatPoint('P', quiet); // horde = survival, clearing = mopping up an already-won game — neither scores } // ---- fire. The flame ball doesn't just kill what it touches, it starts an epidemic: the kill // ignites its neighbours, and every burning body keeps igniting whatever it shambles past. // Read-only over zombies[] (it only sets burnT), so it is safe to call from inside a kill loop. function ignite(x, z, r) { for (const zb of zombies) { if (!zb || zb.burnT > 0) continue; // the boss DOES catch now — just at TUNE.bossBurnMul the rate, // so fire is a real answer to a big body instead of a no-op // remember WHAT lit it: a body that burns to death is credited to the flamer or the fire ball // that started it, not to whatever happens to be firing when it finally goes down if (Math.hypot(zb.x - x, zb.z - z) < r) { zb.burnT = TUNE.burnTime; zb.burnBy = CAUSE; if (zb.hp === undefined) zb.hp = 1; if (whumpT <= 0) { whump(); whumpT = 0.25; } // an epidemic lights many a frame: say it once } } } // Bank `amount` of damage on a multi-hit body and return true once it should DIE. Fractional amounts // are legal (a machine-gun pellet chips a third of a point): they accumulate on zb.chip and only // WHOLE points come off hp, because the wound skins and bleed() anchors switch on hp thresholds and // a fractional hp would never match one. The caller decides HOW it dies — splatter() for the // multi-kill fanfare, killZombie() for a quiet one — and must stop touching zb afterwards, because // killZombie splices the array. This is the one place damage is applied, so a future boss with 20 hp // needs no new code: every weapon is already proportional to it. function wound(zb, amount, fx = true) { zb.chip = (zb.chip || 0) + amount; const ph = bossPhase(zb); let took = 0; while (zb.chip >= 1 && zb.hp > 0) { zb.chip -= 1; zb.hp--; took++; } if (took && fx) { splat(0.5, bodySize(zb)); woundBurst(zb); } // the boss FEELS it — throttled, because the gun chips it a dozen times a second if (took && zb.variant === 'B' && bossHurtT <= 0) { growl(0.3, bodySize(zb), 0.45, 0.6); bossHurtT = 0.9; } // A PHASED BOSS TURNING IS AN EVENT, not a silent change of numbers. Same body, new fight: the // crowd comes up, the court shakes, and he says which set this is. Without the beat the champion's // second gear would arrive as "the boss suddenly got faster" and read as the game glitching. if (zb.hp > 0 && bossPhase(zb) > ph) { const cry = bossPlan(zb).cry; if (cry) G.flash = { text: cry, t: 1.6 }; G.shake = { t: 0.4, mag: 6 }; cheer(1.4, 0.7); moan(0.7); } return zb.hp <= 0; } // one burning body's frame: damage, spread, embers. Returns true if the fire killed it (the // caller must then stop touching zb — killZombie splices, and splatPoint can cascade further). function burnTick(zb, i, dt) { zb.burnT -= dt; CAUSE = zb.burnBy || 'fire'; ignite(zb.x, zb.z, TUNE.burnSpread); // fx off: the embers below already ARE the feedback, a wet pop per cooked hp point would be noise const dead = wound(zb, TUNE.burnDps * (isBoss(zb) ? TUNE.bossBurnMul : 1) * dt, false); // Embers off the body, and SMOKE off the top of them. Two short-lived sparks read as a body // sizzling; a burning man throws a column, so this is three embers with more spread and more // life, plus a puff every few frames that rises and goes grey. The cap is what keeps a lit crowd // from flooding the particle array — raised with the count so the last body still gets its fire. if (parts.length < 200) { for (let k = 0; k < 3; k++) parts.push({ x: zb.x + (Math.random() * 2 - 1) * 0.42, y: 0.4 + Math.random() * 1.6, z: zb.z + (Math.random() * 2 - 1) * 0.2, vx: (Math.random() * 2 - 1) * 0.8, vy: 1.5 + Math.random() * 1.2, vz: (Math.random() * 2 - 1) * 0.8, t: 0.34 + Math.random() * 0.3, fire: true }); if (Math.random() < 0.25) smokePuff(zb.x, 1.5 + Math.random() * 0.7, zb.z, 0.35); } if (!dead) return false; killZombie(i, !!G.horde); return true; } // Phase A, every frame: they are tearing at you and it shows. A thin constant spray out of the // pile plus a stain that keeps widening — "blood and gore flowing out" is a CONTINUOUS thing, not // one burst, and the burst at EATEN_BURST lands on top of it. export function eatenBleed(dt) { const at = G.eatenAt; if (!at) return; for (let k = 0; k < 2; k++) parts.push({ x: at.x + (Math.random() * 2 - 1) * 0.35, y: 0.5 + Math.random() * 0.8, z: at.z + (Math.random() * 2 - 1) * 0.3, vx: (Math.random() * 2 - 1) * 3.2, vy: 1.5 + Math.random() * 3.5, vz: (Math.random() * 2 - 1) * 3.2, t: 0.4 + Math.random() * 0.5, dark: Math.random() < 0.45 }); const g = gore.find(d => d.spreading); if (g) g.r = Math.min(2.1, g.r + dt * 0.7); else { gore.push({ x: at.x, z: at.z, r: 0.7, a: 0.9, spreading: true }); if (gore.length > 40) gore.shift(); } if ((G.eatSfxT = (G.eatSfxT || 0) - dt) <= 0) { splat(0.45); G.eatSfxT = 0.28; } // wet work, on a loop } // phase B of the death animation (EATEN_BURST, config.js): the player IS the gore now. Reuses killZombie's machinery — // the biggest mound on the court, and it stays visible under the summary scrim. That's the point. export function playerGore() { const at = G.eatenAt || { x: P.x, z: P.z }; gore.push({ x: at.x, z: at.z, r: 1.7, a: 1 }); if (gore.length > 40) gore.shift(); piles.push({ x: at.x, z: at.z, size: 4, seed: Math.random() * 1e4, blobs: null, builtSize: 0 }); if (piles.length > 30) piles.shift(); for (let k = 0; k < 90; k++) parts.push({ x: at.x, y: 1.0, z: at.z, vx: (Math.random() * 2 - 1) * 5.5, vy: 1 + Math.random() * 6, vz: (Math.random() * 2 - 1) * 5.5, t: 0.6 + Math.random() * 0.9, dark: k % 3 === 0 }); splat(1); boom(); G.shake = { t: 0.35, mag: 7 }; G.boomFlash = 0.08; } // a small dark spatter the instant a multi-hit shambler is wounded — subtle impact pop, NOT the // kill spray (the ongoing thin trickle is bleed()'s job). Kept low per the settled blood feel. // The player's own wound trickle. Same rule as the zombies': blood is NEVER painted into the player // sprite (settled feel decision) — it is live particles, sparse, thin, and the flow scales with how // far gone he is. Together with drawChar's procedural spatter this IS the health bar. function bleedPlayer() { if (parts.length > 95) return; const lost = G.hpMax - G.hp; for (let k = 0; k < lost; k++) { if (Math.random() > 0.22) continue; // sparse: most frames each wound emits nothing parts.push({ x: P.x + (k % 2 ? 0.22 : -0.2) + (Math.random() * 2 - 1) * 0.03, y: 1.45 - k * 0.13, z: P.z + (Math.random() * 2 - 1) * 0.03, vx: (Math.random() * 2 - 1) * 0.28, vy: 0.5 + Math.random() * 0.7, vz: (Math.random() * 2 - 1) * 0.26, t: 0.3 + Math.random() * 0.3, dark: Math.random() < 0.55 }); } } function woundBurst(zb) { for (let k = 0; k < 5; k++) parts.push({ x: zb.x + (Math.random() * 2 - 1) * 0.15, y: 1.4, z: zb.z, vx: (Math.random() * 2 - 1) * 1.6, vy: 0.6 + Math.random() * 1.4, vz: (Math.random() * 2 - 1) * 1.6, t: 0.35 + Math.random() * 0.3, dark: Math.random() < 0.6 }); } // swishing the racket cleaves every shambler in reach — it's the panic button for when they're // already on top of you, so it stays a full sweep. TUNE.meleeCool is the brake that stops it // from being the default attack. // The boss skip is NOT a gap in the damage model: it is unreachable by construction. The player is // bounded at P_BOUNDS.zMin (netZ + 1.2 = 13.1) and the boss holds z <= COURT.svcFar (5.5), so they // are always on opposite sides of the net and meleeRange 1.7 can never span it. Left as a skip // rather than "fixed", because a melee path to the boss would need him to cross the net first. export function meleeSwipe(x, z, range) { let hit = 0; CAUSE = 'ball'; // the racket. No w_racket icon exists, and the swish IS the ball stroke — same row. for (let i = zombies.length - 1; i >= 0; i--) { const zb = zombies[i]; if (!zb || zb.variant === 'r') continue; // !zb: killZombie can sweep the court mid-loop if (zb.variant === 'm' || isBoss(zb)) { // furniture and bosses don't cleave — they take a wound if (Math.hypot(zb.x - x, zb.z - z) < range && wound(zb, TUNE.dmgBall)) { splatter(i); hit++; } continue; } if (Math.hypot(zb.x - x, zb.z - z) < range) { killZombie(i, !!G.horde); hit++; } } // The swish deliberately does NOT touch incoming shots. It used to, as a pretoss fallback — but // meleeSwipe runs FIRST in keyPressed, so it swallowed the very shot the new tap-to-parry was about // to return. One answer to a thrown ball: swing at it. if (hit) swish(0.35); return hit; } function splatter(i, fanfare = true) { // the MAIN ball hit a zombie const zx = zombies[i].x, zy = zombieH(zombies[i]) * 0.9, zz = zombies[i].z; // for the pops — killZombie splices const sp0 = Math.hypot(ball.vx, ball.vz); // pace BEFORE the pierce tax, for a slice to keep killZombie(i, !!G.horde); ball.vx *= TUNE.zPierce; ball.vz *= TUNE.zPierce; // keep enough pace to plow through the next // SLICE: struck again off this body, onto the next one, at FULL pace — the pierce tax is what a // straight ball pays for plowing through meat, and a cut ball does not plow, it leaves. Only a // KILL reaches here, so a wound ends the chain by simply never extending it, and no brute, // steward or boss ever carries one on. See sliceCarom for why this is a single strike and not a // steer. The new arc is solved from the CORPSE's position, not the ball's, because at this point // in the frame the ball is still a hair short of the body it just burst. // NOT IN THE DRILL. A slice leaves the corpse and strikes whatever it finds next — in a mode where // hitting the wrong body IS the failure that is the game choosing your target for you, and choosing // it badly. Oliver: "remove the ball slicing in this mode.. this leads to unwanted hits". if (ball.chain > 0 && !G.drill) { const nx = nearestTo(zx, zz, TUNE.sliceR, ball, TUNE.sliceArc * Math.PI / 180); if (nx) { ball.chain--; sliceCarom(nx, Math.max(sp0, 14)); // floored: a ball that arrived slowly still leaves fast pock(0.5, 1100 + 450 * ball.kills); // the cut, a step higher up the scale per link } else ball.chain = 0; // nothing in reach: it plows on as a normal ball } if (!fanfare) return; // machine-gun pellets splatter but skip the streak fanfare / bomb meter ball.kills++; // this shot's running streak // splat micro-freeze: every ball kill stutters the world a touch, and the stutter grows with the // streak, so a triple kill physically hitches three times. max(), never assignment — a bomb's own // 0.18 s freeze must not be trimmed by the kills it causes. G.hitstop = Math.max(G.hitstop, Math.min(0.09, 0.03 + 0.012 * ball.kills)); // escalating multi-kill fanfare — one shot, ≥2 zombies // ...and the stat is banked on the TRANSITION to 2, so a massacre of six is one multi-kill if (ball.kills === 2) { multiKill(); popText(zx, zy, zz, 'DOUBLE KILL'); cheer(1.6, 0.45); voice('double_kill', 0.4); } else if (ball.kills === 3) { popText(zx, zy, zz, 'TRIPLE KILL'); cheer(2.2, 0.6); voice('triple_kill', 0.4); } else if (ball.kills >= 4) { popText(zx, zy, zz, `MASSACRE ×${ball.kills}`); cheer(2.8, 0.7); voice('unbelievable', 0.4); G.slowT = Math.max(G.slowT, 0.35); } // a massacre borrows the boss's slow-mo — the infra already runs every frame } // ---- horde wave: dense spawns + an auto-firing machine-gun racket (pellets, not the match ball) function spawnHordeZombie() { // all from the far baseline, across the court — never off-screen at the sides // a few stewards in the mix: the wave needs bodies that survive one pellet, or a grown horde is a // uniform wall of the same thing dying at the same range const v = Math.random() < TUNE.hordeSteward && G.runGames >= 4 ? 'd' : Math.random() < 0.5 ? 'a' : 'b'; spawnZombie((Math.random() * 2 - 1) * 5, -1.5 - Math.random(), v); } let mgTick = 0; // One round, for every barrel-fed weapon there is. The WEAPONS row is stamped onto the pellet, so // the loop below serves the machine gun, the beacon cannon, the flamethrower, the laser and the // rocket without a second array or a second update — the only things that ever differed were a // radius, a damage number, whether it stops at the first body, and what it does when it lands. // Called with no argument by the HORDE, which is the plain machine gun. export function firePellet(w = WEAPONS.gun) { const spread = w.spread ?? (w.life ? TUNE.mgSpread * 2.4 : TUNE.mgSpread); // a short-lived round is a CONE const a = (Math.random() * 2 - 1) * spread * Math.PI / 180, s = w.speed ?? 26; // straight up-court (-z) const pe = { x: P.x, y: 0.9, z: P.z, vx: Math.sin(a) * s, vz: -Math.cos(a) * s, t: w.life ?? 1.2, r: w.r ?? TUNE.mgPelletR, dmg: w.dmg ?? TUNE.dmgPellet, hp: w.hp ?? TUNE.dmgPelletHp, pierce: !!w.pierce, boom: !!w.boom, ignite: w.ignite ? (w.igniteR ?? 1.8) : 0, trail: w.trail || 0, trailR: w.trailR, jet: w.jet || 0, col: w.col, glow: w.glow, wid: w.id || 'gun' }; // the round remembers which weapon threw it — the horde's plain gun has no row // A LOBBED round: it is thrown, not sprayed. Solved so it comes down about where the crowd is, so // "it throws itself" reads as aiming rather than as a flat stream that happens to be fat. if (w.arc) { const tgt = nearestAhead(); // something to actually throw AT const dx = (tgt ? tgt.x : P.x + Math.sin(a) * 6) - P.x, dz = (tgt ? tgt.z : P.z - 12) - P.z; const dist = Math.max(3, Math.hypot(dx, dz)), T = dist / s; pe.vx = dx / T; pe.vz = dz / T; pe.g = TUNE.gravity; pe.vy = (0.35 - pe.y + 0.5 * pe.g * T * T) / T; // lands about knee height on the target pe.t = T + 0.6; pe.arc = 1; } // The BEACON changes colour every ball it throws. It is the showpiece: you should be able to tell // it is running from the corner of your eye. if (w.hue) pe.col = HUES[mgTick % HUES.length]; pellets.push(pe); // each weapon announces itself: a low THUMP as the beacon lobs, a SWOOSH as a rocket leaves, // and the gun's rapid tick every other round (at 12+/s every round buzzes the mixer) if (w.sfx === 'thump') boom(0.3); else if (w.sfx === 'swoosh') whoosh(0.3); // the flamer had no case of its own and fell through to the gun's dry tick. Every THIRD round at // rate 30 = 10 overlapping 0.26 s puffs a second, which sums to a continuous roar; firing one per // round just stacks 30 gains on top of each other and clips. else if (w.sfx === 'flame') { if (++mgTick % 3 === 0) flame(0.5); } else if (++mgTick % 2 === 0) gunshot(); // the one ignite weapon is the flamer, which has its own case } const HUES = ['#ff6b6b', '#ffd166', '#7bffa0', '#6bd5ff', '#c08bff', '#ff8bd5']; // The next body a slice is struck on to: nearest live thing to the kill it just made, and it may // well be off to the side or short of it — that deflection is the whole shot. `arc` keeps it inside // a believable angle off the ball's heading: a carom can physically go anywhere, but one that turns // round and heads back down-court reads as a stray bounce, not as a cut. // Furniture and barrels are not bodies and are never a target. function nearestTo(x, z, maxD, b, arc) { const head = Math.atan2(b.vx, b.vz); let best = null, bd = maxD; for (const zb of zombies) { if ('mo'.includes(zb.variant)) continue; const d = Math.hypot(zb.x - x, zb.z - z); if (d >= bd) continue; const da = ((Math.atan2(zb.x - b.x, zb.z - b.z) - head + Math.PI * 3) % (Math.PI * 2)) - Math.PI; if (Math.abs(da) > arc) continue; // behind the ball: no arc gets there, so the chain ends bd = d; best = zb; } return best; } // the best thing up-court to lob at: nearest body ahead of you, ignoring furniture you can walk past function nearestAhead() { let best = null, bd = 1e9; for (const zb of zombies) { if (!zb || zb.variant === 'o' || zb.z > P.z - 1.5) continue; const d = Math.hypot(zb.x - P.x, zb.z - P.z); if (d < bd) { bd = d; best = zb; } } return best; } export function spawnRacketZombie() { // one takes up residence on the far court — an opponent, of sorts // He stiffens as the run goes on. hpMax is stored because the wound skins and bleed() anchors read // a FRACTION of full health now — hardcoded `hp === 2` thresholds would leave a late-run boss // looking pristine right up to the killing blow. const hp = TUNE.racketHp + Math.floor(G.runGames * TUNE.racketHpPerGame); 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, hpMax: hp }); // (the IT HAS A RACKET card is gone — the racket in its hand is the announcement) } function endHorde() { G.horde = null; pellets.length = 0; if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // survive the wave → a racket zombie stays setupPoint(); // serve loop resumes; any zombies still standing are yours to mop up voice('quiet_dark', 0.5, 0.85); } function tickHorde(dt) { const h = G.horde; h.t -= dt; // h.rate is stamped by startHorde and grows per set banked — every horde used to be the same // twelve seconds at the same 2.5/s, so the fifth one was indistinguishable from the first. if ((h.spawnT -= dt) <= 0) { if (zombies.length < TUNE.zHordeCap) spawnHordeZombie(); // ...but bounded, see TUNE.zHordeCap h.spawnT = 1 / (h.rate || TUNE.hordeRate); } if ((h.mgT -= dt) <= 0) { firePellet(); h.mgT = 1 / (h.mgRate || TUNE.mgRate); } if (h.t <= 0) endHorde(); } // bomb ball: everything inside bombRadius splatters at once (each kill still ++ball.kills so // the Phase-2 fanfare escalates), plus a crater — big stain, size-3 pile, extra particles, boom. export function explode(x, z, why = 'bomb') { CAUSE = why; // the rocket detonates through here too, and it is its own row haptic('heavy'); // the blast, before the kills it causes — heavy and medium throttle separately for (let i = zombies.length - 1; i >= 0; i--) { // backwards: splatter() splices (and can sweep) the array const zb = zombies[i]; if (!zb || Math.hypot(zb.x - x, zb.z - z) >= TUNE.bombRadius) continue; // A blast used to delete the boss outright, ignoring his hp entirely — which made BOMB the only // real answer to him and would have made any bigger boss meaningless. It's heavy damage now. if (isBoss(zb)) { if (wound(zb, TUNE.dmgBomb)) splatter(i); } else splatter(i); } gore.push({ x, z, r: 2.2, a: 1, scorch: true }); if (gore.length > 40) gore.shift(); // the crater — near-permanent gore.push({ x, z, r: 2.0, a: 0.9 }); if (gore.length > 40) gore.shift(); // a ring of splatter thrown outward, so the blast paints the lawn instead of leaving one puddle for (let k = 0; k < 6; k++) { const a = Math.random() * 6.2832, r = TUNE.bombRadius * (0.35 + Math.random() * 0.6); gore.push({ x: x + Math.cos(a) * r, z: z + Math.sin(a) * r, r: 0.7 + Math.random() * 0.7, a: 0.85 }); if (gore.length > 40) gore.shift(); } const pl = piles.find(p => Math.hypot(p.x - x, p.z - z) < 0.6); if (pl) pl.size = Math.min(4, pl.size + 3); else { piles.push({ x, z, size: 4, seed: Math.random() * 1e4, blobs: null, builtSize: 0 }); if (piles.length > 30) piles.shift(); } // the eruption itself: a fat, slow, high-arcing column of gore rather than a camera flash for (let k = 0; k < 120; k++) parts.push({ x: x + (Math.random() * 2 - 1) * 0.4, y: 0.5, z: z + (Math.random() * 2 - 1) * 0.4, vx: (Math.random() * 2 - 1) * 11, vy: 2 + Math.random() * 9, vz: (Math.random() * 2 - 1) * 11, t: 0.7 + Math.random() * 1.1, dark: k % 3 === 0, }); G.hitstop = 0.18; boom(); G.shake = { t: 0.45, mag: 9 }; // camera kick // a warm, brief bloom instead of a white-out — the blinding frame was doing the work the gore // should be doing, and on a phone it just hurt (see render.js for the colour) G.boomFlash = 0.06; if (G.idleT - G.lastOrderT > 3) { voice('order', 0.35); G.lastOrderT = G.idleT; } // the umpire loses composure } // wounded zombies bleed from every open wound — a fine, sparse trickle (a small spurt from the // neck stump on the headless boss). Anchors are approximate body offsets [x, y, rise]; most // frames a wound drips nothing, and the tight spread keeps each thread thin. Bounded low. // Where each body opens up, one anchor per WOUND STAGE and cumulative: at stage n it bleeds from // the first n. Read through the same config.woundStage call the skins are, and deliberately the same // length as that variant's ZWOUND row, so the blood and the drawing it comes out of cannot drift — // the two used to be written by hand against different constants. (The brute's old cumulative // if-chain is what this replaces, and it reproduces it exactly. The steward gained a trickle here // that his wound skin had always implied and never had.) const BLEED = { a: [[0.28, 1.35, 0.85]], b: [[0.28, 1.35, 0.85]], d: [[0.30, 1.62, 0.90]], e: [[0.28, 1.35, 0.85], [0.05, 1.55, 1.60]], // gashed, then throat open h: [[0.32, 1.72, 0.95], [-0.30, 1.68, 0.90], [0.05, 2.05, 1.80]], g: [[-0.30, 1.95, 1.10], [0.42, 1.90, 1.00], [0.05, 2.35, 2.00], [0.00, 1.20, 0.70]], // arm, both, head, gut k: [[-0.36, 2.30, 1.15], [0.50, 2.24, 1.05], [0.06, 2.80, 2.10], [0.00, 1.50, 0.75]], // the same, a head higher }; // the kill fall's bleed-out: low anchors around the corpse itself, thin drops but many — the flow // reads as the body emptying, not as a heavier version of the standing trickle (blood stays thin, // the settled rule; this is more EMISSIONS from where the body actually is) function gushProne(zb) { if (parts.length > 150) return; for (let i = 0; i < TUNE.killGush; i++) { if (Math.random() > 0.75) continue; parts.push({ x: zb.x + (Math.random() * 2 - 1) * 0.55, y: 0.22 + Math.random() * 0.3, z: zb.z + (Math.random() * 2 - 1) * 0.3, vx: (Math.random() * 2 - 1) * 0.9, vy: 0.5 + Math.random() * 1.1, vz: (Math.random() * 2 - 1) * 0.5, t: 0.35 + Math.random() * 0.35, dark: Math.random() < 0.55 }); } } // NETPLAY DEATH MATCH: the opponent is the LIVING O — a human sprite with no wound skins to climb // (Oliver: "the normal opponent does not have wound states — this was the reason I said increased // dynamic bleeding"). So the score reads on him as BLOOD: each point taken off him this game opens // another anchor and thickens the flow, love → game point, and the burst (matchGameWon) resets it // with the body. Same thin-drop discipline as bleed() — more EMISSIONS, never heavier drops. const O_BLEED = [[0.12, 1.55, 0.9], [-0.22, 1.2, 0.8], [0.16, 0.9, 0.7]]; function bleedO() { // THE BLOOD IS THEIR BLOOD, not our scoreboard (Oliver, 2026-08-22). This read points-this-game, // which is a different clock from the one that actually kills them: it resets to zero every game, // it ignores the heal a won game pays them, and it said "unhurt" to a friend two points from // death. Their phone puts its real hp on the position stream (net.js), so the flow now tracks the // pool that ends the match. The old count survives as the fallback for a build that sends none. const o = G.net.opp; const k = o && o.hp !== undefined ? Math.min(3, Math.ceil(Math.max(0, G.hpMax - o.hp) * 3 / Math.max(1, G.hpMax))) : Math.min(3, score.pts[0]); if (!k || G.hideO || parts.length > 120) return; for (const w of O_BLEED.slice(0, k)) { if (Math.random() > 0.14 * k) continue; // ramps: one light nick → gushing at game point parts.push({ x: O.x + w[0] + (Math.random() * 2 - 1) * 0.04, y: w[1] + (Math.random() * 2 - 1) * 0.04, z: O.z + (Math.random() * 2 - 1) * 0.04, vx: (Math.random() * 2 - 1) * 0.35, vy: w[2] * (0.6 + Math.random() * 0.5), vz: (Math.random() * 2 - 1) * 0.3, t: 0.3 + Math.random() * 0.3, dark: Math.random() < 0.55 }); } } function bleed(zb) { if (parts.length > 90) return; let wounds; const anchors = BLEED[zb.variant]; // the boss reads as a FRACTION of full health, not against hardcoded hp numbers: his hp scales // with the run now (racketHpPerGame), so `hp === 2` would leave a late-run boss bleeding from // nothing until the very last hit. Must stay in step with drawZombie's skin thresholds. if (zb.variant === 'r' && zb.hp / (zb.hpMax || TUNE.racketHp) <= 0.34) wounds = [[0.05, 1.5, 2.4], [0.4, 1.15, 0.9]]; // headless: neck spurt + torn arm else if (zb.variant === 'r' && zb.hp / (zb.hpMax || TUNE.racketHp) <= 0.67) wounds = [[0.4, 1.2, 1.0]]; // racket: torn-off arm // Still sparse as the damage stacks — more ANCHORS, not a heavier flow (blood stays thin). else if (anchors && woundStage(zb, anchors.length)) wounds = anchors.slice(0, woundStage(zb, anchors.length)); else return; for (const w of wounds) { if (Math.random() > 0.4) continue; // sparse: most frames this wound emits nothing parts.push({ x: zb.x + w[0] + (Math.random() * 2 - 1) * 0.03, y: w[1] + (Math.random() * 2 - 1) * 0.03, z: zb.z + (Math.random() * 2 - 1) * 0.03, vx: (Math.random() * 2 - 1) * 0.3, vy: w[2] * (0.6 + Math.random() * 0.5), vz: (Math.random() * 2 - 1) * 0.28, t: 0.3 + Math.random() * 0.3, dark: Math.random() < 0.55 }); } } // Split out of updateZombies because particles are NOT a zombie feature: the ball's bounce dust // fires in TENNIS too, and while this loop only ran in splatter, tennis dust was born, never // ticked, and sat on the clay forever — the "marks where the ball hit" testers saw. export function updateParts(dt) { for (let i = parts.length - 1; i >= 0; i--) { const p = parts[i]; p.t -= dt; if (p.smoke) { p.vy = 0.55; p.vx *= 0.97; p.vz *= 0.97; } // smoke rises and spreads; it does not fall else p.vy -= 14 * dt; p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt; if (p.y < 0) { p.y = 0; p.vy = 0; p.vx *= 0.5; p.vz *= 0.5; } if (p.t <= 0) parts.splice(i, 1); } } export function updateZombies(dt) { moanT -= dt; whumpT -= dt; bossHurtT -= dt; // FIRE HAS A SOUND OF ITS OWN: one crackle clock for the whole court, faster and a little louder // the more is burning (sqrt, so a lit wave is dense, not deafening) — never one loop per body. let lit = 0; for (const zb of zombies) if (zb && zb.burnT > 0) lit++; if (lit && (crackleT -= dt) <= 0) { crackle(Math.min(0.26, 0.12 + 0.035 * lit)); crackleT = Math.max(0.07, 0.2 / Math.sqrt(lit)); } if (moanT <= 0) { moan(); moanT = 2.5 + Math.random() * 4; } if (G.net && G.net.death && G.match) bleedO(); // the friend's damage, read as blood (see bleedO) if (G.mode === 'splatter') { // transition spawns are scripted, not timed G.splatT += dt; if (G.horde) tickHorde(dt); // horde: dense spawns + machine gun, ignores the cap else if (G.mgT > 0) { // machine-gun tin: horde firepower, regular rules // The burst clock ticks in EVERY phase. It used to tick only during 'rally', so anything that // moved the phase mid-burst (a duel calling setupPoint) froze mgT above zero forever — and // `G.mgT > 0` blocks serving, so the game locked up with the gun racket stuck on screen. G.mgT -= dt; // whichever gun-like weapon is armed sets the rate and the round (see config.WEAPONS). A const gw = WEAPONS[G.gunId] || WEAPONS.gun; // NO PHASE GUARD. It used to require 'rally', and the beats that fire the moment a burst // clears the court all park the phase in 'over' (a point, a won game, the mop-up) — so the // weapon fell silent exactly when it had killed everything, which reads as the gun jamming // ("it should just fire on"). A burst is its own control mode for its whole `len`, like a // horde: the clock above is what ends it, never the beat playing behind it. if (gw.rate && (G.mgCool -= dt) <= 0) { firePellet(gw); G.mgCool = 1 / gw.rate; } if (G.mgT <= 0) { endBurst(); setupPoint(); } // window over: spend the tin, resume the serve loop } if (!G.horde && !G.changeover && !G.interlude && !G.clearing && !G.bossFight && !G.drill && !G.bench) { // breathers, duels, the mop-up after a won game, a boss fight, a drill round AND the sprite bench all get a quiet court (no new spawns) spawnT -= dt; // The spawn director: games finished sets both crowd size and tempo. Both curves used to hit a // wall — the cap pinned at 14 by game 11 and the gap floored at 1.0 s by game 10, after which // the game literally stopped changing. The gap is hyperbolic now so it keeps tightening on its // own instead of running into a floor; zSpawnMin is only a safety stop. // TEST MODE overrides the whole director with one fixed medium setting, so the sandbox is the // same wave whether you triple-tapped in game 1 or game 40. const cap = G.testMode ? TUNE.testZMax : Math.min(TUNE.zMaxCap, TUNE.zMax + Math.floor(G.runGames * TUNE.zMaxPerGame)); if (zombies.length >= cap - 1 && !G.testMode) aside('ump_hindrance', 0.6); // the court is genuinely full if (spawnT <= 0 && zombies.length < cap) { spawnZombie((Math.random() * 2 - 1) * 4.8, -1.5 - Math.random(), pickVariant()); spawnT = G.testMode ? TUNE.testSpawnEvery : Math.max(TUNE.zSpawnMin, TUNE.zSpawnEvery / (1 + G.runGames * TUNE.zSpawnAccel)); } } } // ponytail: crude swept collision — grow the contact window by half the distance the ball // covers this frame. A full-draw smash moves ~0.7 m per frame (1.6 m on a 25 fps device, where // main.js clamps dt), which is wider than a shambler, so a discrete point test tunnels straight // through bodies. Upgrade to a real segment-vs-circle test if the fudge ever reads as a phantom // hit; at these speeds and radii it doesn't. const ballSweep = ball.active ? Math.hypot(ball.vx, ball.vz) * dt * 0.5 : 0; // The flame ball lights everything it FLIES PAST, not just what it kills. Without this the // fire never actually spread: full pierce meant the ball killed its whole lane outright, and // the only bodies left to catch were the ones it had already removed. // The flame ball lights what it FLIES PAST, outside the body loop below — so it has to stamp the // cause itself. Without this the trail's burn kills inherited whatever CAUSE was left from the // previous frame and filed under 'ball'. if (ball.active && ball.flame && ball.y < 2.6) { CAUSE = ball.wid; ignite(ball.x, ball.z, TUNE.burnBallR); } // The umpire called time. The changeover is a MANDATORY breather for the player — no serving, no // walking — so it is one for the dead too, which is the joke: they wait, because it's still a // tennis match. They stand where they are, still bleeding, still burning, still lit by anything // in the air, but nothing advances and nothing can eat you. (The alternative — letting the player // walk freely during the beat — was tried and vetoed: he never walks freely during play either.) const held = !!G.changeover; if (G.hurtT > 0) G.hurtT -= dt; if (G.hurtFlash > 0) G.hurtFlash -= dt; if (G.hp < G.hpMax && G.mode === 'splatter') bleedPlayer(); for (let i = zombies.length - 1; i >= 0; i--) { const zb = zombies[i]; if (!zb) continue; // a bomb detonation splices several out mid-loop and can gut the array under us zb.walkT += dt; const faceX0 = zb.x; // which way he is walking, for the sprite mirror in drawZombie if (zb.burnT > 0 && burnTick(zb, i, dt)) continue; // the fire finished it — the array moved under us // a chapter boss is excluded: bossAI owns BOTH of its axes, and leaving this in advanced it twice if (!held && zb.variant !== 'B') zb.z += zb.v * (zb.burnT > 0 ? TUNE.burnPanic : 1) * dt; // ...AND A BODY ON THE GROUND IS NOT STANDING, which this line already said and did not enforce. // bleed()'s wound anchors are ABSOLUTE world heights — the headless neck spurt is pinned at // y = 1.5 m — so a corpse lying flat through the fall kept spraying from where its neck used to // be, two metres above itself. Oliver: "this looks like blood fountains midair." The falling body // is the one G.matchFall owns; every other body on court bleeds as before. if (!(G.matchFall && zb.variant === 'r')) bleed(zb); // open wounds bleed every frame they're standing // ...but a game-winning body BLEEDS OUT where it lies: once the drop has landed it, the prone // hold gushes from ground height until the burst (Oliver: "increased dynamic bleeding before // exploding"). Deliberately NOT bleed() — its anchors are standing heights, and a corpse // spraying from where its neck used to be is the "blood fountains midair" bug fixed above. else if (G.matchFall.kill && G.matchFall.t > 0.25) gushProne(zb); if (zb.shieldT > 0) zb.shieldT -= dt; // staggered chair comes back up when this runs out if (zb.hitT > 0) zb.hitT -= dt; // boss contact cooldown (see TUNE.bossHitCool) if (zb.retT > 0) zb.retT -= dt; // the champion's return stroke, held long enough to read // ---- the two RANGED attackers. Both wind up visibly before firing (TUNE.machWind / drawZombie's // charge glow) because a shot you cannot see coming is not a fairness contract, it is a gotcha. if (!held && !G.interlude && (zb.variant === 'm' || zb.variant === 'B') && G.mode === 'splatter' && zb.rollTo === undefined) { // a cart on the roll cannot aim — that is the window to close on it const boss = zb.variant === 'B', A = boss ? bossPlan(zb) : zb.art; if (zb.salvo > 0) { // mid-volley: the rest of the balls are still coming zb.salvoT -= dt; // A gap of 0 means the WHOLE volley leaves in this one frame — the mascot's wall of five, // which you dodge rather than parry. The loop still terminates: salvo counts down either way. const gap = A?.gap ?? TUNE.bossSalvoGap, spread = A?.spread ?? 0.35; while (zb.salvo > 0 && zb.salvoT <= 0) { zb.salvo--; zb.salvoT += gap; fireAtPlayer(zb.x, zb.z, (Math.random() * 2 - 1) * spread, A?.shot ?? TUNE.bossShotSpeed); // aimed at where } // you are NOW, with only a hand's width of scatter } else if (zb.windT > 0) { zb.windT -= dt; if (zb.windT <= 0) { if (boss) { zb.salvo = A?.volley ?? TUNE.bossVolley; zb.salvoT = 0; growl(0.32, bodySize(zb), 0.18, 0.6); } // the volley starts next frame, on a bark else fireAtPlayer(zb.x, zb.z, 0, TUNE.machSpeed); } } else if ((zb.fireT -= dt) <= 0) { zb.fireT = boss ? (A?.cycle ?? TUNE.bossCycle) : TUNE.machCycle; // windMax rides along for render: the telegraph glow fades in over the ACTUAL wind. It used // to divide by TUNE.bossWind whatever the shooter, so the machine (0.9 s) and any design // with wind > 0.5 spent the first stretch at an invalid negative alpha — full brightness, // no ramp, on the one effect documented as the fairness contract. zb.windT = zb.windMax = boss ? (A?.wind ?? TUNE.bossWind) : TUNE.machWind; if (boss) growl(0.32, bodySize(zb), zb.windT, 1.35); // the tell: he draws breath for the whole wind-up else pock(0.3, 1500); // the machine's tell: a mechanical click as it loads } } if (G.mode === 'eaten' && G.eatenAt && zb.variant !== 'm' && G.eatenT < EATEN_SUMMARY) { // You are down. Nothing else matters to them: every body on court converges on the spot and // packs into a ring around it, and the ones already there feed (the bob is in drawZombie). // This is the whole point of the death — you go under a CROWD, not under a puff of gore. // Each one claims a SPOT around the body, kept from wherever it happened to be standing, so // they form a ring and crowd in from all sides. Sending them all at the one point instead // stacked every body on the same pixel and read as one fat zombie, not a swarm. if (zb.eatA === undefined) { zb.eatA = Math.atan2(zb.z - G.eatenAt.z, zb.x - G.eatenAt.x) + (Math.random() * 2 - 1) * 0.4; zb.eatR = EATEN_FEED_R * (0.8 + Math.random() * 0.7); } const tx = G.eatenAt.x + Math.cos(zb.eatA) * zb.eatR; const tz = G.eatenAt.z + Math.sin(zb.eatA) * zb.eatR * 0.55; // squashed in depth: the camera is low const dx = tx - zb.x, dz = tz - zb.z, d = Math.hypot(dx, dz); zb.feeding = d < 0.12; if (!zb.feeding) { const sp = zb.v * 1.8 * dt; // they hurry for a kill zb.x += dx / d * Math.min(sp, d); zb.z += dz / d * Math.min(sp, d); } continue; // no shambling, no eating you twice, no crossing the baseline for a point } if (G.mode === 'eaten' && G.eatenAt) { // The summary is up. They have finished with you: the pack breaks up and shambles away up-court // instead of standing on the body twitching in a heap for as long as the screen is on. zb.feeding = false; const away = Math.sign(zb.x - G.eatenAt.x) || (zb.walkT % 2 < 1 ? 1 : -1); zb.x += away * zb.v * 0.5 * dt; zb.z -= zb.v * 0.7 * dt; // back the way they came zb.face = away; continue; } if (zb.variant === 'o') { // the barrel just rolls, turning at the sidelines. It never attacks. if (!held && G.mode === 'splatter' && !G.interlude) { zb.x += zb.dir * TUNE.barrelRoll * dt; zb.roll += TUNE.barrelRoll * dt; // drawn spin, so it reads as ROLLING if (Math.abs(zb.x) > 4.4) { zb.dir = -Math.sign(zb.x); zb.x = Math.sign(zb.x) * 4.4; } } } else if (zb.variant === 'm') { // the machine wheels itself around instead of being bolted down if (!held && G.mode === 'splatter' && !G.interlude) { if (zb.rollTo !== undefined) { const d = zb.rollTo - zb.x; if (Math.abs(d) < 0.06) { zb.rollTo = undefined; zb.repoT = TUNE.machRepo * (0.7 + Math.random() * 0.6); } else { zb.x += Math.sign(d) * Math.min(Math.abs(d), TUNE.machRoll * dt); if ((zb.rollT -= dt) <= 0) { pock(0.16, 150); if (Math.random() < 0.6) squeak(); zb.rollT = 0.4; } // castors grinding, and one of them squeaks: it announces itself } } else if ((zb.repoT -= dt) <= 0) { zb.rollTo = (Math.random() * 2 - 1) * 3.6; zb.rollT = 0; } } if (zb.hp < zb.hpMax * 0.5 && Math.random() < dt * 6) smokePuff(zb.x, 1.1, zb.z, 0.25); // wrecked: it smokes where it stands } else if (held || zb.variant === 'r') { /* nothing moves on a changeover; a duelling opponent owns his x */ } else if (zb.variant === 'B') { if (G.mode === 'splatter' && !G.interlude) { bossAI(zb, dt); bossAdds(zb, dt); } } // A DRILL BODY HOLDS ITS LANE. The homing below is a gore-mode behaviour — bodies converge on // your x as they cross the net — and in the drill it undoes the spacing at exactly the moment // spacing matters most: they arrive nicely spread and then bunch into an unreadable knot right in // front of you, which is the "space them not too close" note coming back downstream. Their lane // IS the puzzle here (it decides what screens what), so they walk it straight in with nothing but // the wobble. They still reach you — the lane runs past the baseline — so the threat is unchanged. else if (zb.drill) zb.x += Math.sin(zb.walkT * 1.7) * 0.12 * dt; else if (G.mode === 'splatter' && zb.z > COURT.netZ) { // on YOUR half they close in — each on its own lane, not a column const tx = P.x + zb.homeOff; zb.x += Math.sign(tx - zb.x) * Math.min(Math.abs(tx - zb.x), zb.v * 0.7 * dt) + Math.sin(zb.walkT * 2.3) * 0.25 * dt; // shamble wobble — the approach reads organic, not tracked } else zb.x += Math.sin(zb.walkT * 1.7) * 0.15 * dt; // far half: aimless shamble // CONTACT IS STILL INSTANT DEATH — settled decision, and the ball machine's chip damage does not // soften it. Two threats that demand two different responses is the point. The machine ('m') is // furniture and cannot eat you. if (!held && G.mode === 'splatter' && !'mo'.includes(zb.variant) // furniture and barrels cannot eat you && !(G.dodgeI > 0) // ...and a sidestep gets you out from under one. It is the ONE escape from this && Math.hypot(zb.x - P.x, (zb.z - P.z) * 0.75) < TUNE.eatenR + (zb.variant === 'g' ? 0.2 : 0)) // z weighted down: depth is foreshortened, so a head-on shambler eats you when it LOOKS on top of you killPlayer(); if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) { CAUSE = ball.wid; // re-stamped per body: burnTick above shares this loop and moves it const d = Math.hypot(ball.x - zb.x, ball.z - zb.z); if (d < hitR(zb) + ballSweep && !(zb.hitT > 0)) { // brute and boss are bigger targets // the serve's deferred praise pops on the FIRST body the ball meets — this is the impact // the word was waiting for (armed in hitServe, see praiseWord) if (ball.praise) { popText(zb.x, zombieH(zb) * 0.8, zb.z, ball.praise, UI.warm); ball.praise = null; } 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 } if (ball.flame) { // burns through anything it touches — no quality gate, no chair, no pace tax if (isBoss(zb)) { // a boss takes a heavy wound AND catches light zb.hitT = TUNE.bossHitCool; // ...and the ball is SPENT on it: a boss is not ball.lastHit = 'X'; ball.vx *= 0.3; ball.vz *= 0.3; // removed, so an unconsumed pierce ball if (wound(zb, TUNE.dmgFlame)) splatter(i); // re-wounded it every single frame else zb.burnT = TUNE.burnTime; continue; } const vx0 = ball.vx, vz0 = ball.vz, bx = zb.x, bz = zb.z; zb.ashes = true; // burnt, not burst — see killZombie splatter(i); ball.vx = vx0; ball.vz = vz0; // undo splatter's pierce tax: full pace through the lane ignite(bx, bz, TUNE.burnIgniteR); // ...and everyone standing near the body goes up continue; } if (isBoss(zb)) { // boss: splat-quality wounds it; soft balls the racket one rallies (racketAI) // NOT IN THE MATCH. Regicide made sense in the duel (one sudden-death point, any weapon // counts) but in a full game every hard serve that happened to meet the body was a free // kill nobody aimed — Oliver: "the killing by smashing occurs too often. i cant really // control it". A match ball NEVER damages the opponent; it passes through like any soft // ball and racketAI plays it. The score is the only way through — which also makes the // wound stages exactly the scoreboard, with no side-channel smearing them. if ((ball.pow || 0) >= TUNE.zSplatQ && !G.match) { zb.hitT = TUNE.bossHitCool; // belt-and-braces: this ball is spent below, but the cooldown const dead = wound(zb, TUNE.dmgBall); // is what stops ANY lingering contact multi-wounding pock(0.6, 300); ball.vx *= 0.3; ball.vz *= 0.3; ball.lastHit = 'X'; // spent on the hit if (dead) { splatter(i); continue; } // killing blow: full gore + point } // ...AND THE CHAMPION RETURNS THE REST. Every other body in the game can be worn down with // soft balls; the final boss sends anything under splatter quality straight back at your // head, which is what makes him the one you have to out-HIT. The ball is spent on his // racket and comes back as an incoming shot — parry it, swish it away, or step aside. else if (bossPlan(zb).rally) { fireAtPlayer(zb.x, zb.z, (Math.random() * 2 - 1) * 0.4, bossPlan(zb).shot ?? TUNE.bossShotSpeed); zb.retT = 0.3; // the throw frame, so the return is drawn ball.vx *= 0.2; ball.vz *= 0.2; ball.lastHit = 'X'; } continue; // soft balls pass through ON PURPOSE, for racketAI to return — that rally IS the // feedback that it wasn't hard enough, so no clank here; see PROJECT.md } 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; } if (zb.variant === 'f') { splatter(i); continue; } // the sprinter is GLASS — any touch, any quality, it bursts // ANY multi-hit body soaks, and hp is the ONLY thing that says how many. This used to be a // chain of per-variant branches — brute+machine, then shamblers — and every body NOT named // in it fell through to "splat-quality kills outright" below. That silently one-shot the // steward (5 hp, commented "a brute's worth of hp") from the day he shipped, and then the // ball boy, bouncer and roller man the moment they existed: Oliver's "the bigger guys were // way too easy to kill". Pellets and melee always read hp properly; only the BALL skipped it. if ((zb.hpMax || 0) > 1) { const meat = zb.variant !== 'm'; // the machine rings and does not bleed const heavy = 'gmhk'.includes(zb.variant); // a heavy body is not shoved back by a ball const sz = bodySize(zb); if ((ball.pow || 0) >= TUNE.zSplatQ) { if (--zb.hp <= 0) { splatter(i); continue; } // the killing blow if (meat) woundBurst(zb); // a bloody wound, not a death // meat THUDS (wet, and pitched by how big the body is), the machine still rings if (meat) { thud(0.65, sz); splat(0.3, sz); } else pock(0.6, 700); if (!heavy) zb.z -= TUNE.zPushback * 0.5; ball.vx *= 0.3; ball.vz *= 0.3; ball.lastHit = 'X'; continue; // ball spent on the wound } if (!heavy) zb.z -= TUNE.zPushback; // soft ball staggers the light ones, bounces off the rest if (meat) thud(0.4, sz); else pock(0.6, 500); // a soft ball off a body is a duller version of the same hit ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; 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 thud(0.4, bodySize(zb)); ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; // spent — one stagger per ball } } if (Math.abs(zb.x - faceX0) > 0.004) zb.face = Math.sign(zb.x - faceX0); // sticky: a body standing still keeps facing where it last walked if (zb.z >= COURT.L + 0.7) { // A BATCH BODY THAT GETS PAST YOU IS A FAILURE, not a free despawn. Drill bodies hold their // lane, so one in a lane you are not standing in walks the whole court and off the back // without ever touching you — and since the sequence names it, the round is left asking for a // body that no longer exists. That is the un-winnable state the whole mode is built to // prevent, and it only appeared once the roster became the alphabet: types move at different // speeds, so the runner leaves long before the roller. It counts as caught. if (G.drill && zb.drill) { zombies.splice(i, 1); drillCaught('GOT PAST YOU'); continue; } aside('ump_unsportsmanlike', 0.5); zombies.splice(i, 1); if (G.mode !== 'eaten' && !G.horde && !G.clearing) splatPoint('O'); } // it reached your baseline (during a mop-up, a straggler that reaches the line just despawns — no point for them) } for (let i = pellets.length - 1; i >= 0; i--) { // gun rounds flat and fast; lobbed ones ride an arc const pe = pellets[i]; // A rocket blast can bank the last kill of a game, and the beat that follows (a duel, a chapter // boss) empties this array — so every remaining index reads undefined and the frame throws. Same // guard, and the same reason, as the one in the shots loop and the body loop. if (!pe) continue; CAUSE = pe.wid; pe.t -= dt; pe.x += pe.vx * dt; pe.z += pe.vz * dt; let hit = false, landed = false; if (pe.arc) { // a thrown round: it falls, and landing is a hit pe.vy -= pe.g * dt; pe.y += pe.vy * dt; if (pe.y <= 0.05) { pe.y = 0.05; landed = true; } } // A JET PLUME, not a shower of sparks: the exhaust is laid down tight on the flight line, // barely moving, so it reads as a rocket motor drawing a line through the air. Scattered // sparks with their own velocities looked like a firework, which is not what a rocket does. if (pe.jet && parts.length < 240) for (let k = 0; k < pe.jet; k++) { const b = (k + Math.random()) / pe.jet; // spread ALONG this frame's step parts.push({ x: pe.x - pe.vx * dt * b + (Math.random() * 2 - 1) * 0.05, y: pe.y - (pe.vy || 0) * dt * b + (Math.random() * 2 - 1) * 0.05, z: pe.z - pe.vz * dt * b + (Math.random() * 2 - 1) * 0.05, vx: 0, vy: 0.25, vz: 0, // it hangs and lifts, it does not fly off t: 0.30 + Math.random() * 0.30, jet: 1, spark: '#fff2c0' }); } // ...and a FINE thread for the beacon: one small mote a frame, tight to the ball, so the ball // itself is the bright thing and the trail only says where it has been. // ...and a FINE thread for the beacon: one small mote a frame, tight to the ball. An IGNITE // round (the flamer) throws fat embers instead — same loop, but `fire` motes that are 2px and // warm rather than 1px and flat, they LIFT as they burn out, and they live long enough to leave // a body of flame hanging in the air behind the stream instead of a dotted line. if (pe.trail && parts.length < 320) for (let k = 0; k < pe.trail; k++) parts.push({ x: pe.x + (Math.random() * 2 - 1) * (pe.trailR ?? 0.12), y: pe.y + (Math.random() * 2 - 1) * (pe.trailR ?? 0.12), z: pe.z + (Math.random() * 2 - 1) * (pe.trailR ?? 0.12), vx: (Math.random() * 2 - 1) * (pe.ignite ? 0.5 : 0), vz: (Math.random() * 2 - 1) * (pe.ignite ? 0.5 : 0), vy: pe.ignite ? 0.9 + Math.random() * 0.8 : 0.12, t: pe.ignite ? 0.30 + Math.random() * 0.34 : 0.16 + Math.random() * 0.16, fine: pe.ignite ? 0 : 1, fire: pe.ignite ? 1 : 0, spark: pe.ignite ? 0 : (pe.col || '#fff') }); for (let j = zombies.length - 1; j >= 0; j--) { const zb = zombies[j]; if (!zb) continue; if (Math.hypot(zb.x - pe.x, zb.z - pe.z) < hitR(zb) + (pe.r ?? TUNE.mgPelletR)) { // Pellets used to skip the boss entirely, so a GUN burst (and every horde) did literally // nothing to him with no feedback at all. They CHIP him now: dmgPellet is fractional, so // three rounds take a point off and the gun is a slow, committed answer rather than a no-op. // EVERY new weapon comes through here, which is why none of them can silently no-op on a // boss the way three of the five once did. if (pe.ignite) ignite(pe.x, pe.z, pe.ignite); // the flamer lights the spot, and fire spreads itself // A round with no impact damage (the flamethrower) only LIGHTS what it touches — the burn // does the killing. Without this guard `hp -= 0` fell straight through to killZombie and the // flame cone deleted the crowd on contact, which is a machine gun that happens to be orange. const dmgHp = pe.hp ?? TUNE.dmgPelletHp, dmgBoss = pe.dmg ?? TUNE.dmgPellet; if (isBoss(zb)) { if (dmgBoss > 0 && wound(zb, dmgBoss, false)) killZombie(j, true); else pock(0.45, 240); } else if (dmgHp <= 0) { /* fire only: it is already alight from the ignite above */ } else if ((zb.hp -= dmgHp) > 0) pock(0.5, 260); // only brutes soak now; a shambler drops to one round else killZombie(j, true); if (pe.boom) { explode(pe.x, pe.z, pe.wid); hit = true; break; } // the rocket takes the crowd, not the body hit = !pe.pierce; // a laser keeps going down the whole lane if (hit) break; } } // A thrown round that reaches the ground goes off THERE, whether or not it touched a body — // "like a bomb, the projectile is a rocket". Without this a rocket that missed simply expired. if (!hit && landed) { if (pe.boom) explode(pe.x, pe.z, pe.wid); else if (pe.ignite) ignite(pe.x, pe.z, pe.ignite); hit = true; } if (hit || pe.t <= 0) pellets.splice(i, 1); } if (G.beam) { G.beam.t -= dt; if (G.beam.t <= 0) G.beam = null; } // the laser's wedge fades and is gone // ---- INCOMING hostile balls (ball machine, chapter boss volleys). Three outcomes, in this order: // a timed swing inside the volley window RETURNS it as your own ball flying up-court, the panic // swish (meleeSwipe, above) merely bats it away, and reaching you unanswered costs a point of hp. for (let i = shots.length - 1; i >= 0; i--) { const s = shots[i]; // A shot landing the KILLING blow routes through killPlayer, which empties this array from // under us — every remaining index then reads undefined and the frame throws. Same guard, and // the same reason, as the `if (!zb) continue` in the body loop above. if (!s) continue; s.t -= dt; s.vy -= s.g * dt; s.x += s.vx * dt; s.y += s.vy * dt; s.z += s.vz * dt; let done = s.t <= 0 || s.y < -0.25 || Math.abs(s.x) > 8 || s.z > COURT.L + 2; // The PARRY keeps the 0.8 depth weighting — a racket sweep really is wider than it is deep. // The HIT does not: weighting depth shrinks it, so contact registered while the ball was still // most of a metre up-court. That mismatch between what you saw and what hit you was the bug. const dP = Math.hypot(s.x - P.x, (s.z - P.z) * 0.8); const dHit = Math.hypot(s.x - P.x, s.z - P.z); if (!done && P.swingT >= 0 && P.swingT <= TUNE.parryWindow && dP < TUNE.parryReach * bonus().parry && s.y < CONTACT.maxY) { // the parry: it becomes an `extras` clone — that array is already ballistic and already kills // zombies, so "returned shot is a live weapon" costs nothing to build. // It is a GLANCING blow, not a mirror. Reversing the incoming line sent every return straight // back up the line it arrived on — which is where the thrower is standing — so a boss was a // free wound per throw. Where you meet the ball across the racket face now sets the angle: // hitting it back INTO him is a shot you have to place your feet for. const off = Math.max(-1, Math.min(1, (s.x - P.x) / (TUNE.parryReach * bonus().parry))); // -1..1 across the face const ang = (off * TUNE.parryDeflect + (Math.random() * 2 - 1) * TUNE.parryJitter) * Math.PI / 180; const sp = Math.hypot(s.vx, s.vz) * 1.15; extras.push({ x: s.x, y: s.y, z: s.z, g: TUNE.gravity, pow: TUNE.parryPow, t: 2.5, vx: Math.sin(ang) * sp, vy: 4.2, vz: -Math.cos(ang) * sp }); pock(0.85, 1300); popText(s.x, s.y + 0.4, s.z, 'RETURNED'); cheer(0.8, 0.3); done = true; } if (!done && dHit < TUNE.shotHitR && s.y < TUNE.shotHitY) { hurtPlayer(TUNE.machDmg); done = true; } if (done) shots.splice(i, 1); } for (let i = extras.length - 1; i >= 0; i--) { // triple-serve side balls: full ballistic clones const e = extras[i]; if (!e) continue; // same as pellets/shots: a kill here can start a beat that empties the array CAUSE = e.wid || 'ball'; // a triple's wings; a parried machine ball is just a ball e.t -= dt; e.vy -= (e.g || TUNE.gravity) * dt; // e.g: a clone flies under the SAME topspin as the ball it came from e.x += e.vx * dt; e.y += e.vy * dt; e.z += e.vz * dt; if (e.y <= 0 && e.vy < 0) { e.y = 0; e.vy = -e.vy * TUNE.bounceY; e.vx *= TUNE.bounceXZ; e.vz *= TUNE.bounceXZ; } let dead = e.t <= 0 || Math.abs(e.x) > 7.5 || e.z < -2 || e.z > 25.5; if (!dead && e.y < 1.9) for (let j = zombies.length - 1; j >= 0; j--) { const zb = zombies[j]; if (!zb) continue; // killZombie can sweep the court mid-loop if (Math.hypot(zb.x - e.x, zb.z - e.z) < hitR(zb) + Math.hypot(e.vx, e.vz) * dt * 0.5) { // same sweep: clones inherit the smash pace // Side balls used to pass THROUGH the boss doing nothing at all, which is most of why a // TRIPLE serve read as "he can't be hurt". They wound him like the main ball now. if (isBoss(zb)) { if (e.pow >= TUNE.zSplatQ) { if (wound(zb, TUNE.dmgSide)) killZombie(j, !!G.horde); } else thud(0.4, bodySize(zb)); } else if (zb.variant === 's' && e.vz < 0 && !(zb.shieldT > 0)) { zb.shieldT = TUNE.shieldDown; pock(0.9, 1800); } // chair blocks these too else if (e.pow >= TUNE.zSplatQ || zb.variant === 'f') { if (zb.hp > 1) { zb.hp--; thud(0.55, bodySize(zb)); splat(0.25, bodySize(zb)); } // wounds brutes AND shamblers else killZombie(j, !!G.horde); } else { zb.z -= TUNE.zPushback; thud(0.4, bodySize(zb)); } dead = true; break; // a side ball is spent on its first touch } } if (dead) extras.splice(i, 1); } updateParts(dt); const boy = G.boy; // the boy mops any gore he passes over for (let i = gore.length - 1; i >= 0; i--) { const d = gore[i]; // He scrubs HARD only while stopped and working the mop; walking past barely touches it, so // the stain visibly dies under him rather than fading as he drifts by. if (boy && Math.abs(boy.x - d.x) < 0.8 && Math.abs(boy.z - d.z) < 1.3) d.a -= dt * (d.scorch ? 0.16 : boy.mopT > 0 ? 0.7 : 0.2); // craters scar the lawn far longer if (d.a <= 0) gore.splice(i, 1); } for (let i = piles.length - 1; i >= 0; i--) { // piles shave far slower than stains — net growth wins const pl = piles[i]; if (boy && Math.abs(boy.x - pl.x) < 0.7 && Math.abs(boy.z - pl.z) < 1.2) pl.size -= dt * 0.25; if (pl.size <= 0.3) piles.splice(i, 1); } }