diff --git a/HANDOFF.md b/HANDOFF.md index 65ed409..6f47318 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -5,9 +5,10 @@ everything they specified is shipped, and what still matters from them is record long history, `~/.claude/projects/.../memory/splatter-tennis-history.md` is auto-loaded; for the detail of any individual change, read the git log. -Last updated 2026-07-30 after Oliver playtested the fixes. All eight retest issues are closed and -recorded at the bottom with what the fix was, because each one is a trap worth not re-digging. -Issue 1's first fix was **vetoed on feel and replaced** — read that entry before touching movement. +Last updated 2026-07-30, mid-way through the **late-game depth work** after Oliver's 50+ game +playtest. Steps 1-3 of that plan are DONE; **steps 4 and 5 are the next job and are specified in +full at the bottom of this file** — start there. Everything from the earlier retest rounds is closed +and recorded below it. ## What this is SNES-style pseudo-3D tennis (Super Tennis reference) that transitions into a zombie splatter game @@ -61,6 +62,33 @@ zombie. Getting caught = `G.mode='eaten'` = permadeath, and the run summary is t While `m.in < 1`, `dismissModal` **returns true without dismissing** — it swallows the gesture but won't let a panic tap that was already mid-air skip a popup nobody has read. Returning false there would drop that tap through into a serve instead. +- **The difficulty director lives in `updateZombies`'s spawn block + `spawnZombie` + `pickVariant`, + and every dial is keyed on `G.runGames`.** It is the thing that broke first: as shipped, ALL of it + bottomed out by about game 12 (body cap pinned at 14, spawn gap floored at 1.0 s, variant mix frozen + at exactly 0.42 advanced, shamble speed maxed after 105 *seconds* of splatter, boss stuck at 3 hp, + every horde identical). Oliver played 50+ games and reported the plateau; games 12-50 were + arithmetically the same game. All six now keep climbing — see the TUNE block marked + THE DIFFICULTY DIRECTOR. Calibration rule: the curve must **track the old values through ~game 12** + and only then extend, or you have silently nerfed the part of the game that was already fine. + The raw dials do run out around game 25 by design; bosses and ranged attacks (steps 4-5) are what + carry escalation past that, not more bodies. +- **`wound(zb, amount, fx)` in zombies.js is the ONE place damage is applied to a multi-hit body.** + Fractional amounts are legal (a pellet chips `TUNE.dmgPellet` = 0.34 of a point); they bank on + `zb.chip` and only WHOLE points come off `zb.hp`, because the wound skins and `bleed()` anchors + switch on hp thresholds and a fractional hp would never match one. It returns true when the body + should die and the CALLER kills it — `splatter()` when the kill should get the multi-kill fanfare, + `killZombie()` for a quiet one — after which the caller must stop touching `zb`, because + `killZombie` splices the array. A boss with 20 hp therefore needs no new damage code at all. +- **Boss damage state is read as a FRACTION of `zb.hpMax`, never against literal hp numbers.** + `spawnRacketZombie` scales hp with `TUNE.racketHpPerGame` and stores `hpMax`. Two places switch on + it and they MUST stay in step: `drawZombie`'s skins (`<= 0.34` headless `z_cw2`, `<= 0.67` torn-arm + `z_cw1`) and `bleed()`'s wound anchors. The old hardcoded `hp === 2` left a scaled boss looking + pristine until the killing blow. +- **The racket boss has NO health bar, deliberately.** It was the only floating UI on a body anywhere, + it was on screen for nearly the whole run (a boss respawns every game you win), and players did not + know what it meant. The wound skins already encode the same information in the language every other + multi-hit enemy uses. A one-per-run modal (`G.seen.racket`, fired from `spawnRacketZombie`) explains + what he *is* instead. Do not re-add a bar; give a new boss more wound stages. - **`UI` in config.js is the accent palette** (hot/warm/pick/cream/dim/bad/ball). Every HUD colour goes through it. Gore reds are deliberately NOT in it — the splatter stays loud. - **`F(size, bold)` in render.js builds every canvas font string.** Bare `monospace` resolves to @@ -203,6 +231,152 @@ Known generator quirks, all still true: --- +# NEXT UP — late-game depth (agreed with Oliver 2026-07-30) + +He has played 50+ games. Verdict: **nothing new happens after about game 30.** The measured cause is +in the invariants above — every difficulty dial bottomed out by game 12. Steps 1-3 removed the flat +line; **steps 4 and 5 are the actual content and are NOT built yet.** Step 6 is parked by his +decision. Read this whole section before starting: the decisions in it were argued out and settled, +and re-litigating them wastes his time. + +## Settled design decisions (do not reopen) + +- **Contact still kills outright. This was explicitly decided.** Player HP is a SEPARATE channel that + only ranged attacks touch. A shambler reaching you is still `G.mode = 'eaten'`, still permadeath, + still the whole run over. Oliver: *"keep the instant death separate"*. The reason: two threats that + demand two different responses is the depth; making contact chip instead of kill would make the game + easier and lose the dread that carries it. +- **Player health is his SHIRT, not a bar.** Tennis whites going progressively bloodier, plus + `bleed()` on the player once wounded. No HUD box, no meter, no number — same reasoning that removed + the boss's HP bar (see invariants). This is the aesthetic rule for this game: state is expressed in + the art or in tennis furniture, never in floating UI. +- **Healing happens by winning a sudden-death duel.** That finally gives the duel a reason to be + WANTED rather than just being a weapon vending machine. +- **Step 6 is parked.** The crowd turning into zombies and the ball-boy-zombie-who-catches-your-balls + were both discussed; Oliver: *"for 6 that's my least favourite one and we should park it"*. Do not + build them. They are recorded at the bottom only so the idea isn't lost. + +## Step 4 — ranged attacks and player damage (NOT BUILT) + +The point of this step: it is the only idea on the list that adds a **new verb**. Every other option +adds more of what the player already does. The player's whole vocabulary today is pull-aim-fire, +panic-swish, pick a tin, walk during a rally. + +**The cheap insight to build on: a thrown projectile IS just a ball hit by the other side.** +`trySwing` already implements "an incoming ball, a generous volley window, time it or eat it", and +`VOLLEY` already widens reach ×1.4 / window ×1.8 for a pre-bounce contact. A thrower is therefore +much closer to "a racket zombie that serves at you" than to a new system. Reuse that path rather than +writing a projectile-dodging mechanic from scratch. + +Deliver it as the **rogue ball machine** — Oliver's own idea and the most on-brand thing in the list, +because a ball machine is *real tennis furniture*. Suggested shape: +- A new variant (`'m'`?) that does NOT shamble: it parks on the far court and fires at you on a cycle. +- It must **telegraph**: a wind-up pose plus a sound, far enough ahead that a phone player with one + thumb can react. Reuse the `shriek()` fairness-contract pattern the runner already uses. +- Returning the shot is a normal swing/volley. Missing it costs one point of player HP and starts the + bleed. It must never be an instant kill — that is what contact is for. +- Because it doesn't walk, it never reaches your baseline, so it can't score for THEM by crossing — + it has to be a standing threat you choose when to deal with. That makes it a target-priority + decision, which is new. + +Player-state plumbing this needs (none of it exists yet): +- `G.hp` / `G.hpMax` on the `G` object in state.js, reset per run. +- The bloodied-shirt skins: new `p_*` sprites per wound stage, resolved in `drawChar` exactly the way + `drawZombie` resolves boss skins — **as a fraction of hpMax, not against literal hp numbers** + (see invariants; this trap has already been hit once). +- `bleed()` currently takes a zombie; it will need to work for the player, or get a sibling. +- Healing in `resolveInterlude('P')`, alongside `deliverTin()`. +- The eaten/run-summary path must still work when death comes from HP reaching zero rather than from + contact — check `G.eatenAt`, `playerGore()`, and the `localStorage` best-run write. + +## Step 5 — chapter bosses every 10 games (NOT BUILT — Oliver's favourite) + +*"my favourite - we need spectacular new sprites for the bosses and his wounds"* + +Why this is worth the most: it gives the run **chapters**, and it fixes a real economy problem — +tins arrive every 3 games forever (`duelFirst` 2, `duelEvery` 3), so there is currently no reason to +bank anything. A rare wall you must save weapons for is what turns the rack into a decision. + +- Every 10 games. Escalating each time: more hp, bigger, more wound stages, and — once step 4 exists + — it uses ranged attacks too, so escalation becomes numbers rather than new mechanics. +- **The damage model is already ready for this.** `wound()` + `hitR()` + the `TUNE.dmg*` table mean a + boss is just a bigger `hp` number and every weapon stays proportional. Verified: a 9 hp body takes + 3 off per bomb, ~3 pellets per point, 1 per side ball. Do not add per-boss damage special cases. +- **Bigger is free**: the brute already renders at 2.7 m against a shambler's 1.8 m, so sprite scale + is an existing, proven lever. +- **The always-present racket zombie must stop being "the boss" first**, or a real boss will not read + as special. Today `splatPoint` respawns one every single game you win. Decide: does the mini-boss + become rarer, or get renamed/re-skinned as an ordinary "opponent" while the chapter boss is a + distinct thing? +- Sprite work is the bulk of this step and it is Oliver's stated priority. Follow the asset pipeline + section above exactly: `generate-image` in EDIT mode with the existing sheet as `--input` is what + makes a new sheet match, magenta background, then `_slice.py` (frames) or `_key.py` (standalone). + Wound stages must be generated as a SET so they colour-match — and remember a wound stage's height + must always come from its A frame or it pops. + +## Parked (step 6) — do not build without asking + +- **The crowd turning.** Mechanically it is "spawn from the stands instead of the far baseline", i.e. + attacks arriving from behind and beside you, which breaks the one assumption the whole game rests + on. Highest spectacle-per-line available, and `x_crowd`/`x_crowd2` already exist. Oliver's least + favourite; parked. +- **The ball-boy zombie that catches your balls.** Denies your best lane, forcing target priority. + Parked with the above. + +--- + +# CLOSED — the 50-game plateau, steps 1-3 (2026-07-30) + +### Racket zombie's HP bar removed +See invariants. Replaced by a one-per-run "IT HAS A RACKET / IT RETURNS YOUR WEAK SHOTS / ONLY A FULL +PULL WOUNDS IT" modal from `spawnRacketZombie`, gated on `G.seen.racket`. + +### "Sometimes the racket zombie doesn't seem harmable" — it was real, not a feeling +Three of the five rack slots did **nothing at all** to him, silently: +`TRIPLE` side balls skipped him (`variant === 'r'` → continue), `GUN`/horde pellets skipped him, and +fire spread could not ignite him. Meanwhile `BOMB` deleted him outright ignoring hp. All fixed via the +new `TUNE.dmg*` table and `wound()`. Verified numerically: 9 pellets kill a 3 hp boss, one side ball +takes 1 hp and is consumed, a weak side ball does nothing (correct — the `zSplatQ` gate), a bomb takes +3 off, and he now catches fire from a burning neighbour and cooks to death in 3.6 s at +`bossBurnMul` 0.4. +- **`hitR(zb)` gives him a 0.7 m contact radius** (shambler 0.55, brute 0.75) — part of "unharmable" + was simply missing him. +- **Melee is still skipped, and that is correct, not an oversight.** The player is bounded at + `P_BOUNDS.zMin` = 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. Don't "fix" an unreachable path. +- **A sub-threshold ball still passes through him on purpose** so `racketAI` can rally it back. That + rally IS the feedback that the pull wasn't hard enough, so there is deliberately no clank there. + +### The director un-capped +Old vs new, verified in-browser (gap s / body cap / speed m/s / advanced share / boss hp): + +``` + gap cap speed nasty bossHp +g 1 2.59/2.78 3/4 0.79/1.75 0.03/— 3/3 +g10 1.15/1.00 12/13 1.61/1.75 0.30/0.27 4/3 +g12 1.03/1.00 13/14 1.79/1.75 0.36/0.42 4/3 +g20 0.71/1.00 21/14 2.52/1.75 0.60/0.42 5/3 +g30 0.52/1.00 24/14 2.73/1.75 0.75/0.42 6/3 +g50 0.35/1.00 24/14 2.73/1.75 0.75/0.42 9/3 +``` +- The spawn gap is **hyperbolic** now (`zSpawnEvery / (1 + games * zSpawnAccel)`) so it keeps + tightening instead of hitting a floor; `zSpawnMin` is only a safety stop. +- **Known, deliberate early-game change:** shamble speed used to be TIME-based (`splatT / 70`) and + maxed out after 105 seconds of splatter — i.e. during game 2 — so it was never a progression lever + and every early shambler already walked at top speed. It is games-based now, which makes games 1-10 + genuinely gentler than before. Oliver has not playtested that yet; if the on-ramp feels flabby the + knob is `zSpeedPerGame`. +- Hordes scale with sets banked (`hordeGrow`) — length AND spawn rate. **`TUNE.zHordeCap` = 34 is + required**: a horde deliberately ignores the director cap, and with growth a late wave asks for + ~120 spawns where the old fixed 12 s x 2.5/s could only ask for 30. A 41-body court showed up in the + soak before the cap went in. + +### Regression net after all of it +30 tapped tennis points = 50 hits / 0 whiffs (unchanged). 40-game splatter soak: no exceptions, +`mgT` drains to 0, gore 40 / piles 30 / bodies capped at exactly 34. + +--- + # CLOSED — Oliver's 35-game retest (2026-07-27), all fixed 2026-07-30 Kept because each one is a trap worth not re-digging. Every fix was verified in the browser via `window.T`; the numbers quoted are from those runs. diff --git a/config.js b/config.js index 5d5e747..5dede7d 100644 --- a/config.js +++ b/config.js @@ -25,11 +25,42 @@ export const TUNE = { aiReaction: 0.45, // s before the CPU reads your shot — well-aimed corners are winners autoAssist: matchMedia('(pointer: coarse)').matches ? 1.15 : 0, // auto-run speed factor (mobile) zSpeed: 0.7, // zombie shamble speed m/s - zSpawnEvery: 3.0, // s between spawns in game 1 — shrinks by zSpawnStep per finished game - zSpawnMin: 1.0, // spawn-gap floor late in a run - zSpawnStep: 0.22, // spawn-gap reduction per finished game - zMax: 3, // shamblers on court in game 1 (cap grows +1 per game to zMaxCap; 0 = spawns off, for tests) - zMaxCap: 14, // absolute shambler cap + // ---- THE DIFFICULTY DIRECTOR. Every dial below used to bottom out by about game 12, which is why + // a 50-game run stopped changing (games 12..50 were arithmetically identical). They are all keyed + // on G.runGames now and all keep moving; the ceilings that remain exist for perf or fairness, not + // because the curve ran out. See HANDOFF "The difficulty director". + // Calibrated to TRACK the old curve through roughly game 12 and then keep going, rather than to + // replace it: the first pass at this made games 1-30 easier, which is the opposite of the ask. + zSpawnEvery: 3.0, // s between spawns in game 1 + zSpawnAccel: 0.16, // hyperbolic tightening: gap = zSpawnEvery / (1 + games * this). g5 1.67s, + // g11 1.09s (old floored at 1.0 here and stopped dead), g20 0.71s, g30 0.52s + zSpawnMin: 0.35, // hard spawn-gap floor, safety only — reached around game 41 + zMax: 3, // shamblers on court in game 1 + zMaxPerGame: 0.9, // cap growth per finished game (was a flat +1 into a wall at game 11) + zMaxCap: 24, // absolute shambler cap — readability ceiling, reached ~game 24 + zSpeedPerGame: 0.13, // shamble-speed ramp per finished game. Was TIME-based (splatT/70) and hit its + // ceiling after 105 seconds of splatter, i.e. during game 2 — so shamble speed + // was never a progression lever at all. g12 matches the old top speed (1.75 + // m/s), and it climbs to 2.7 m/s by game 22. + zSpeedMax: 2.9, // cap on that ramp (top speed = zSpeed * 3.9). runnerVMax still caps runners. + nastyPerGame: 0.03, // share of spawns drawn from the advanced roster, per finished game + nastyMax: 0.75, // ...capped here (g14 matches the old 0.42, g25 hits the cap). The old mix was + // frozen at exactly 0.42 from game 12 to the end of the run. + hordeGrow: 0.2, // horde length AND spawn rate both scale by this per set already banked + zHordeCap: 34, // hard body ceiling DURING a horde. A horde ignores the director cap on + // purpose (that is the point of it), but hordeGrow means a late wave now tries + // for ~120 spawns where the old fixed 12 s x 2.5/s could only ever ask for 30, + // and a 40-body court on a 270px-wide portrait screen is mush, not menace. + racketHpPerGame: 0.12, // the racket zombie stiffens as the run goes on (floored into whole hp) + // ---- damage. Bodies with hp (racket boss 'r', brute 'g') take damage in these units, so a big + // boss is just a bigger hp number and every weapon stays proportional. Fractions are legal: they + // bank on zb.chip and only whole points come off hp (the wound SKINS switch on hp thresholds). + dmgBall: 1, // a splat-quality ball or serve + dmgSide: 1, // one TRIPLE side ball — used to pass straight through the boss doing nothing + dmgPellet: 0.34, // one machine-gun round — three chip a point off. Also used to no-op on the boss + dmgBomb: 3, // caught in a blast. Was an instant kill that ignored hp entirely + dmgFlame: 2, // a direct flame-ball touch (and it sets the boss alight, which it didn't before) + bossBurnMul: 0.4, // burnDps multiplier while a boss body cooks — it burns, just slower than a body changeoverT: 5.0, // s the changeover breather lasts (after odd games, as the rules demand) boyMopSpeed: 2.2, // m/s a MOPPING ball boy crosses at (a fetching one still sprints at 7.5) boyMopDwell: 1.8, // s he stands on a stain pushing the mop before moving to the next one diff --git a/gameplay.js b/gameplay.js index d6956bb..12326a8 100644 --- a/gameplay.js +++ b/gameplay.js @@ -442,7 +442,8 @@ function startHorde() { if (G.horde) return; G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked G.pendingGame = false; // the horde IS the announcement - G.horde = { t: TUNE.hordeLen, spawnT: 0, mgT: 0 }; + const grow = 1 + (G.sets[0] + G.sets[1]) * TUNE.hordeGrow; // each set banked makes the next wave worse + G.horde = { t: TUNE.hordeLen * grow, spawnT: 0, mgT: 0, rate: TUNE.hordeRate * grow }; ball.active = false; G.phase = 'rally'; // ball gone; 'rally' lets the movement block steer P P.pose = 'idle'; P.poseT = 0; P.swingT = -1; // clear the triggering shot's serve/swing pose P.z = HOME.P.z; // brace at the baseline — the horde is a hold-the-line stand (finger steers x only) diff --git a/index.html b/index.html index 0462428..7582349 100644 --- a/index.html +++ b/index.html @@ -60,6 +60,6 @@

TAP TO START

- + diff --git a/render.js b/render.js index 7bf83ac..8b56ba4 100644 --- a/render.js +++ b/render.js @@ -190,15 +190,20 @@ function drawZombie(zb) { } else if (SPR.z_gw) name = 'z_gw'; // a missing asset degrades to the last skin that loaded } else if ((zb.variant === 'a' || zb.variant === 'b') && zb.hp < TUNE.shamblerHp && SPR['z_' + zb.variant + 'w']) name = 'z_' + zb.variant + 'w'; // gashed shambler - else if (rk && zb.hp <= 1 && SPR.z_cw2) { name = 'z_cw2'; h = 1.52; } // headless: a head shorter, so the shoulders land where a whole zombie's do - else if (rk && zb.hp === 2 && SPR.z_cw1) name = 'z_cw1'; + // The boss's damage is read as a FRACTION of full health — his hp scales with the run + // (TUNE.racketHpPerGame), so the old `hp === 2` / `hp <= 1` thresholds would have left a late-run + // boss looking untouched until the killing blow. Keep these in step with bleed()'s anchors. + else if (rk) { + const f = zb.hp / (zb.hpMax || TUNE.racketHp); + if (f <= 0.34 && SPR.z_cw2) { name = 'z_cw2'; h = 1.52; } // headless: a head shorter, so the shoulders land where a whole zombie's do + else if (f <= 0.67 && SPR.z_cw1) name = 'z_cw1'; + } drawStatic(name, zb.x, zb.z, h, Math.floor(zb.walkT * 5.2) % 2); if (zb.burnT > 0) drawFlames(zb, h); - if (rk) { // HP bar floating above the mini-boss - const p = proj(zb.x, 2.05, zb.z), w = Math.max(10, p.s * 0.5), x = p.x - w / 2; - ctx.fillStyle = '#300'; ctx.fillRect(x, p.y, w, 3); - ctx.fillStyle = '#e03030'; ctx.fillRect(x, p.y, w * Math.max(0, zb.hp) / TUNE.racketHp, 3); - } + // No HP bar. It was the only floating UI on a body anywhere in the game, it was on screen for + // almost the whole run (a boss respawns every game you win), and nobody knew what it meant — + // while the wound SKINS above already encode exactly the same information in the language every + // other multi-hit enemy uses. The first-sight modal in spawnRacketZombie explains him instead. } // Fire, drawn procedurally instead of as a sprite sheet: a few vertically-stretched quads whose // offsets ride each body's own phase (walkT), so no two burning zombies flicker in sync — and it diff --git a/zombies.js b/zombies.js index f478722..7c59910 100644 --- a/zombies.js +++ b/zombies.js @@ -3,10 +3,10 @@ // your baseline — one crossing it is a point for THEM. // (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.) -import { TUNE, COURT } from './config.js'; +import { TUNE, UI, COURT } from './config.js'; import { G, P, ball, sendBoy, setupPoint } from './state.js'; import { splat, moan, pock, voice, boom, swish, shriek, aww, sfx } from './audio.js'; -import { splatPoint, cheer, spentBall, resolveInterlude } from './gameplay.js'; +import { splatPoint, cheer, spentBall, resolveInterlude, showModal } from './gameplay.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 @@ -18,7 +18,10 @@ export const extras = []; // triple-serve side balls {x, y, z, vx, vy, vz, pow, let spawnT = 1.5, moanT = 3; export function spawnZombie(x, z, variant) { - const ramp = 1 + Math.min(1.5, G.splatT / 70); // they get faster the longer you survive + // 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. + const ramp = 1 + Math.min(TUNE.zSpeedMax, G.runGames * TUNE.zSpeedPerGame); const zb = { x, z, v: TUNE.zSpeed * ramp * (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 @@ -28,17 +31,26 @@ export function spawnZombie(x, z, variant) { zombies.push(zb); } -// which shambler steps out next: the roster grows one type per set (runner: set 1 game 3+, -// brute: set 2, shield: set 3). Fixed random slots so the classics stay the crowd. +// 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 tier = G.sets[0] + G.sets[1], r = Math.random(); - if (tier >= 2 && r < 0.15) return 's'; - if (tier >= 1 && r >= 0.15 && r < 0.30) return 'g'; - if ((tier >= 1 || G.runGames >= 2) && r >= 0.30 && r < 0.42 - && !zombies.some(z => z.variant === 'f')) return 'f'; // at most one runner alive + const g = G.runGames; + 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'); + 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. +const hitR = zb => zb.variant === 'g' ? 0.75 : zb.variant === 'r' ? 0.7 : 0.55; + // 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 @@ -76,23 +88,39 @@ function killZombie(i, quiet) { // Read-only over zombies[] (it only sets burnT), so it is safe to call from inside a kill loop. export function ignite(x, z, r) { for (const zb of zombies) { - if (!zb || zb.variant === 'r' || zb.burnT > 0) continue; // the duel opponent doesn't burn + 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 if (Math.hypot(zb.x - x, zb.z - z) < r) { zb.burnT = TUNE.burnTime; if (zb.hp === undefined) zb.hp = 1; } } } +// 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; + let took = 0; + while (zb.chip >= 1 && zb.hp > 0) { zb.chip -= 1; zb.hp--; took++; } + if (took && fx) { splat(0.5); woundBurst(zb); } + 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; ignite(zb.x, zb.z, TUNE.burnSpread); - zb.burnD = (zb.burnD || 0) + TUNE.burnDps * dt; - while (zb.burnD >= 1) { zb.burnD -= 1; zb.hp--; } + // 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 * (zb.variant === 'r' ? TUNE.bossBurnMul : 1) * dt, false); if (parts.length < 110) for (let k = 0; k < 2; k++) parts.push({ // embers off the body x: zb.x + (Math.random() * 2 - 1) * 0.3, y: 0.5 + Math.random() * 1.4, z: zb.z, vx: (Math.random() * 2 - 1) * 0.5, vy: 1.2 + Math.random(), vz: (Math.random() * 2 - 1) * 0.5, t: 0.25 + Math.random() * 0.2, fire: true }); - if (zb.hp > 0) return false; + if (!dead) return false; killZombie(i, !!G.horde); return true; } @@ -124,7 +152,11 @@ function woundBurst(zb) { // 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 racket-zombie boss shrugs melee off entirely) +// 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; for (let i = zombies.length - 1; i >= 0; i--) { @@ -157,8 +189,18 @@ function firePellet() { pock(0.25, 1400); // rapid fire tick; if it clips on device, gate to every 2nd pellet } 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: TUNE.racketHp }); + variant: 'r', side: 'O', facing: 1, pose: 'idle', poseT: 0, swingT: -1, swingCool: 0, runT: 0, + hp, hpMax: hp }); + if (!G.seen.racket) { // it is not a shambler and it does not behave like one — say so, once + G.seen.racket = true; + showModal({ title: 'IT HAS A RACKET', color: UI.hot, + body: ['IT RETURNS YOUR WEAK SHOTS', 'ONLY A FULL PULL WOUNDS IT'] }); + } } function endHorde() { G.horde = null; pellets.length = 0; @@ -169,7 +211,12 @@ function endHorde() { function tickHorde(dt) { const h = G.horde; h.t -= dt; - if ((h.spawnT -= dt) <= 0) { spawnHordeZombie(); h.spawnT = 1 / TUNE.hordeRate; } + // 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 / TUNE.mgRate; } if (h.t <= 0) endHorde(); } @@ -177,8 +224,14 @@ function tickHorde(dt) { // 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) { - for (let i = zombies.length - 1; i >= 0; i--) // backwards: splatter() splices (and can sweep) the array - if (zombies[i] && Math.hypot(zombies[i].x - x, zombies[i].z - z) < TUNE.bombRadius) splatter(i); + 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 (zb.variant === 'r') { 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 @@ -218,8 +271,13 @@ function bleed(zb) { if (zb.hp <= TUNE.bruteHp - 3) wounds.push([0.05, 2.35, 2.0]); if (zb.hp <= 1) wounds.push([0.0, 1.2, 0.7]); } - else if (zb.variant === 'r' && zb.hp <= 1) 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 === 2) wounds = [[0.4, 1.2, 1.0]]; // racket: torn-off arm + // 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. + else 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 else if ((zb.variant === 'a' || zb.variant === 'b') && zb.hp < TUNE.shamblerHp) wounds = [[0.28, 1.35, 0.85]]; // shambler: gashed shoulder else return; for (const w of wounds) { @@ -248,11 +306,14 @@ export function updateZombies(dt) { } if (!G.horde && !G.changeover && !G.interlude && !G.clearing) { // breathers, duels AND the mop-up after a won game get a quiet court (no new spawns) spawnT -= dt; - // the spawn director: the match's age (games finished) sets both crowd size and tempo - const cap = Math.min(TUNE.zMaxCap, TUNE.zMax + G.runGames); + // 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. + const cap = Math.min(TUNE.zMaxCap, TUNE.zMax + Math.floor(G.runGames * TUNE.zMaxPerGame)); if (spawnT <= 0 && zombies.length < cap) { spawnZombie((Math.random() * 2 - 1) * 4.8, -1.5 - Math.random(), pickVariant()); - spawnT = Math.max(TUNE.zSpawnMin, TUNE.zSpawnEvery - G.runGames * TUNE.zSpawnStep); + spawnT = Math.max(TUNE.zSpawnMin, TUNE.zSpawnEvery / (1 + G.runGames * TUNE.zSpawnAccel)); } } } @@ -314,15 +375,16 @@ export function updateZombies(dt) { } if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) { const d = Math.hypot(ball.x - zb.x, ball.z - zb.z); - if (d < (zb.variant === 'g' ? 0.75 : 0.55) + ballSweep) { // the brute is a bigger target + if (d < hitR(zb) + ballSweep) { // brute and boss are bigger targets 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 (zb.variant === 'r') { // the boss only takes a wound - zb.hp--; splat(0.5); - if (zb.hp <= 0) { splatter(i); } continue; + if (zb.variant === 'r') { // the boss takes a heavy wound AND catches light + if (wound(zb, TUNE.dmgFlame)) splatter(i); + else zb.burnT = TUNE.burnTime; + continue; } const vx0 = ball.vx, vz0 = ball.vz, bx = zb.x, bz = zb.z; splatter(i); ball.vx = vx0; ball.vz = vz0; // undo splatter's pierce tax: full pace through the lane @@ -331,11 +393,13 @@ export function updateZombies(dt) { } if (zb.variant === 'r') { // mini-boss: splat-quality wounds it; soft balls it rallies (racketAI) if ((ball.pow || 0) >= TUNE.zSplatQ) { - zb.hp--; splat(0.5); pock(0.6, 300); + const dead = wound(zb, TUNE.dmgBall); + pock(0.6, 300); ball.vx *= 0.3; ball.vz *= 0.3; ball.lastHit = 'X'; // spent on the hit - if (zb.hp <= 0) { splatter(i); continue; } // killing blow: full gore + point + if (dead) { splatter(i); continue; } // killing blow: full gore + point } - continue; // soft balls pass through, for the AI to return + 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 HANDOFF } if (zb.variant === 'g') { // brute: soaks splat-quality hits, never staggers back if ((ball.pow || 0) >= TUNE.zSplatQ) { @@ -377,9 +441,13 @@ export function updateZombies(dt) { let hit = false; for (let j = zombies.length - 1; j >= 0; j--) { const zb = zombies[j]; - if (!zb || zb.variant === 'r') continue; - if (Math.hypot(zb.x - pe.x, zb.z - pe.z) < (zb.variant === 'g' ? 0.75 : 0.55)) { - if (zb.hp > 1) { zb.hp--; pock(0.5, 260); } // brutes AND shamblers soak pellets — wound, don't kill + if (!zb) continue; + if (Math.hypot(zb.x - pe.x, zb.z - pe.z) < hitR(zb)) { + // 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. + if (zb.variant === 'r') { if (wound(zb, TUNE.dmgPellet, false)) killZombie(j, true); else pock(0.45, 240); } + else if (zb.hp > 1) { zb.hp--; pock(0.5, 260); } // brutes AND shamblers soak pellets — wound, don't kill else killZombie(j, true); hit = true; break; } @@ -395,10 +463,16 @@ export function updateZombies(dt) { 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 || zb.variant === 'r') continue; // side balls pass the boss; !zb: kills can sweep mid-loop - if (Math.hypot(zb.x - e.x, zb.z - e.z) < (zb.variant === 'g' ? 0.75 : 0.55) + 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 - if (zb.variant === 's' && e.vz < 0 && !(zb.shieldT > 0)) { zb.shieldT = TUNE.shieldDown; pock(0.9, 1800); } // chair blocks these too + // 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 (zb.variant === 'r') { + if (e.pow >= TUNE.zSplatQ) { if (wound(zb, TUNE.dmgSide)) killZombie(j, !!G.horde); } + else pock(0.5, 300); + } + 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--; splat(0.4); } // wounds brutes AND shamblers else killZombie(j, !!G.horde);