diff --git a/HANDOFF.md b/HANDOFF.md index 6f47318..140e84a 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -5,10 +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, 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. +Last updated 2026-07-30. The **late-game depth work is complete**: steps 1-5 of the plan agreed after +Oliver's 50+ game playtest are all built and verified in-browser. Step 6 (crowd turning, ball-boy +catcher) is PARKED by his decision. **Awaiting his phone playtest of the boss/ranged build.** +What is still owed is art, not mechanics — see "Art still owed" at the bottom. ## What this is SNES-style pseudo-3D tennis (Super Tennis reference) that transitions into a zombie splatter game @@ -41,8 +41,11 @@ Tennis scoring keeps running. `TUNE.killsPerPoint` kills = one point; games/sets 6 games = a set = a **horde** (racket auto-fires, finger steers you absolutely). Odd games end in a **changeover** breather. A won game leaves the wave standing — you must **clear it by hand** (`G.clearing`) before play advances (`proceedAfterClear`). +Every **10 games a CHAPTER BOSS** walks on (`G.bossFight`) — it clears the stage, blocks all spawns, +and volleys at you. Every 4 games a **rogue ball machine** is wheeled out. Both attack the second +damage channel: `shots[]` chip `G.hp`, which only a won duel heals. Contact is still instant death. Weapons are **only ever won**, in the **sudden-death duel** (`G.interlude`) against the racket -zombie. Getting caught = `G.mode='eaten'` = permadeath, and the run summary is the whole game. +zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixture he used to be). Getting caught = `G.mode='eaten'` = permadeath, and the run summary is the whole game. ## Architecture & invariants — know these before touching anything @@ -89,6 +92,36 @@ zombie. Getting caught = `G.mode='eaten'` = permadeath, and the run summary is t 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. +- **There are TWO damage channels and they must never be merged.** Contact with a body is still + instant permadeath (`killPlayer`, `G.mode='eaten'`); ranged attacks chip `G.hp` (`hurtPlayer`). + Oliver settled this explicitly — two threats that demand two different responses is the depth, and + making contact chip instead of kill would lose the dread the whole game runs on. `killPlayer()` is + the single death path for BOTH causes (extracted for exactly that reason: the run summary, the + best-run localStorage write and the death animation exist once). `TUNE.hurtGrace` is what stops a + three-shot volley deleting you in one frame. +- **The player's health is drawn ON HIM and nowhere else.** No bar, no meter, no HUD number — same + rule that removed the boss's bar. `drawPlayerWounds` paints procedural spatter at FIXED anchors + (fixed so it cannot crawl frame to frame), `bleedPlayer()` adds the live trickle, and `G.hurtFlash` + washes the screen edges inward on impact. Blood is never painted into the player sprite: that is a + settled feel decision, which is why this is an overlay and not a set of bloodied-kit PNGs. +- **`shots[]` in zombies.js is the HOSTILE ball array** — the ball machine's shots and the chapter + boss's volleys. Deliberately separate from `ball` (there is only ever one of those and it is yours) + and from `extras` (also yours, and they kill zombies). Three outcomes, in this order: a swing inside + the volley window RETURNS it — as an `extras` clone at `TUNE.parryPow`, so "a parry is a real + weapon" costs nothing to build; `meleeSwipe` merely bats it away; reaching you unanswered costs hp. + **The swish path is not optional politeness** — in `pretoss` the player is rooted and cannot swing + at all, so without it a machine winding up while you lined up a serve was an unanswerable hit. +- **`isBoss(zb)`** (`'r'` or `'B'`) marks bodies that take `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". Every weapon path checks it — miss one and that weapon silently + no-ops or one-shots a 34 hp boss. This has already been the bug twice. +- **The chapter boss scores NOTHING when it dies.** Its reward is the chapter card plus the parked + game beat resuming. Letting it bank a kill meant `splatPoint`'s game-over branch and + `endBossFight` both decided "what happens next" in the same frame and clobbered each other. +- **`blocksClear`** decides which bodies gate the hand-clear after a won game: NOT the racket zombie + (it keeps its residence) and NOT the ball machine (furniture — a standing threat you deal with when + you choose). Making the machine gate progression turned "decide what to prioritise" into a chore + every four games. - **`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 @@ -231,97 +264,83 @@ Known generator quirks, all still true: --- -# NEXT UP — late-game depth (agreed with Oliver 2026-07-30) +# ART STILL OWED (mechanics are done) -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. +The only outstanding work on the boss/ranged build is art. Everything below is playable now. -## Settled design decisions (do not reopen) +- **The chapter boss has no B-frames for its wound stages.** `assets/_sheet_boss.png` is a 5-frame + sheet: `z_B1`/`z_B2` (a real 2-frame stride, driven off `zb.z / TUNE.bruteStride` per the + distance-not-time invariant) plus three static wound skins `z_Bw1` (arm torn off) / `z_Bw2` (ribs + out) / `z_Bw3` (headless, still throwing). The brute precedent has an A **and** B frame per wound + stage (`z_gw` + `z_gwb`, etc.), so a wounded boss currently stops striding mid-fight. Fix by + generating a second sheet of the three wound poses with the stride swapped, named `z_Bw1b`..`z_Bw3b`, + and extending drawZombie's stage pick the way the brute's does. + **Generating it: the model laid the first sheet out in TWO ROWS, which `_slice.py` cannot cut (it + segments on column gaps).** Don't fight the prompt — detect the blobs and re-flow them into one + baseline-aligned row before slicing. The reflow script is in this session's git history. +- **The ball machine is drawn procedurally** (`drawMachine` in render.js) — a hopper, a barrel and two + wheels, with the muzzle glowing hotter through the wind-up. That was a choice, not a stopgap: it is + furniture, a box is four rects, and the readable parts are the barrel and the charge. Replace with a + sheet only if it reads as a crate on a phone. +- **Chapter cards reuse `drawCard`,** which still cuts in with no entrance while modals now swoosh + (`TUNE.modalIn`). Giving the card a matching entrance was offered and not yet asked for. -- **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. +# CLOSED — chapter bosses + ranged attacks, steps 4-5 (2026-07-30) -## Step 4 — ranged attacks and player damage (NOT BUILT) +## Step 4 — the ranged channel -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 ball machine (`'m'`)**, Oliver's own idea and the most on-brand thing on the list, because a ball +machine is real tennis furniture. It never walks, so it never crosses your baseline and never scores: +it is a standing threat you choose when to deal with, which is the target-priority decision the game +was missing. Measured: 3.4 s cycle, **0.9 s telegraph** (`TUNE.machWind`, muzzle glow + a mechanical +click — the same fairness contract the runner's `shriek()` is), 3 hp, exempt from the clear gate. -**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. +**The projectile is a real ARC**, solved like a serve rather than fired flat, so it reads coming in and +so the swing window has something to meet. `fireAtPlayer` aims where you *stand*, which means moving +is a partial answer during a rally — and is exactly why the swish path exists for `pretoss`, where you +cannot move or swing. -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 damage.** 4 hp, `hurtGrace` 1.1 s, +2 on a won duel (the only healing in the game, and now +THE reason to want a duel rather than tolerate it). Verified: 4 hits kill you and land in the same +`eaten` path as being caught, with `eatenAt`, the death animation and the best-run write all intact; +three hits in one frame cost only one point. -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, and the racket zombie demoted -## Step 5 — chapter bosses every 10 games (NOT BUILT — Oliver's favourite) +**Demoted, as Oliver asked.** The racket zombie used to respawn on *every* game you won, which is +precisely why he read as "the boss" and why a real boss could not have stood out next to him. He is an +occasional opponent now (`TUNE.racketEvery` = 3 games). -*"my favourite - we need spectacular new sprites for the bosses and his wounds"* +**The chapter boss (`'B'`)** arrives every `TUNE.bossEvery` = 10 games, fired from `proceedAfterClear` +— the one beat where the court is guaranteed empty — and it OUTRANKS the set/horde and duel beats +there, because a boss walking on into a standing wave would be unreadable. It clears the stage, +blocks all other spawns while it lives, and threatens both channels: volleys of `bossVolley` = 3 +chip your hp, and letting it reach you is the ordinary instant death. Chapter 1 is 10 hp and 2.7 m; +chapter 4 is 34 hp and 3.67 m. -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. +**The economy fix this was really for:** tins arrive every 3 games forever, so nothing was ever worth +banking. Measured cost of a chapter-4 boss — 12 bombs, or 100 machine-gun rounds, or 34 side balls. +`TUNE.wCap` is 9 per type, so **no single weapon can kill a late boss** and a mixed, banked arsenal is +mandatory. That is the strategic pressure Oliver was asking for. -- 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. +`G.bossDue` never moves backwards (`Math.max`): going a long stretch without ever clearing the court +skips a chapter rather than queueing a backlog. Deliberate — you face the boss the run has earned. -## Parked (step 6) — do not build without asking +## Bugs found and fixed while building this (all were silent) -- **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. +- A blast **deleted** a 34 hp chapter boss outright: `explode` only wounded `'r'`. This is what + `isBoss()` now exists to prevent, and it is the second time the same class of bug has shipped. +- The machine's `machHp` did nothing — `'m'` fell through every variant branch to the generic + one-shot fallback. It soaks like the brute now (3 balls, verified). +- The boss both scored a kill and fired its chapter card, so two code paths set "what happens next" + in the same frame. +- The machine gated the hand-clear, turning a priority decision into a chore. +- A boss death produced a shambler-sized gore burst (`GORE_MUL` had no `'B'`; now 6 → ~305 particles). + +## Regression net after steps 4-5 +30 tapped tennis points = **50 hits / 0 whiffs** (unchanged — tennis is untouched by any of this). +45-game splatter soak: no exceptions, **all four chapters fired [1,2,3,4]**, machines appeared, ranged +hits landed, `mgT` drains, gore 40 / piles 30 / bodies capped at 34. --- diff --git a/assets/_sheet_boss.png b/assets/_sheet_boss.png new file mode 100644 index 0000000..dc3773a Binary files /dev/null and b/assets/_sheet_boss.png differ diff --git a/assets/z_Bw1.png b/assets/z_Bw1.png new file mode 100644 index 0000000..7871ad3 Binary files /dev/null and b/assets/z_Bw1.png differ diff --git a/assets/z_Bw2.png b/assets/z_Bw2.png new file mode 100644 index 0000000..8efb835 Binary files /dev/null and b/assets/z_Bw2.png differ diff --git a/assets/z_Bw3.png b/assets/z_Bw3.png new file mode 100644 index 0000000..280b539 Binary files /dev/null and b/assets/z_Bw3.png differ diff --git a/assets/z_b1.png b/assets/z_b1.png index def6fdf..9f34ac4 100644 Binary files a/assets/z_b1.png and b/assets/z_b1.png differ diff --git a/assets/z_b2.png b/assets/z_b2.png index 8acf96c..e8b8603 100644 Binary files a/assets/z_b2.png and b/assets/z_b2.png differ diff --git a/config.js b/config.js index 5dede7d..ae83a67 100644 --- a/config.js +++ b/config.js @@ -51,6 +51,35 @@ export const TUNE = { // 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. + // ---- PLAYER HEALTH. A SECOND, SEPARATE damage channel: only ranged attacks touch it. A shambler + // reaching you is still instant permadeath (G.mode='eaten') — that decision is settled, see HANDOFF. + // There is no HP bar and there never will be: the state shows as blood on the man. + playerHp: 4, // hits from ranged attacks you can absorb before you go down + hurtGrace: 1.1, // s of invulnerability after a hit — a burst must not delete you in one volley + healPerDuel: 2, // hp back for winning a sudden-death duel. THE reason to want the duel. + // ---- the rogue ball machine ('m'): real tennis furniture that shoots at you. It never shambles, + // so it never crosses your baseline and never scores — it is a standing threat you choose when to + // deal with, which is the target-priority decision the game was missing. + machEvery: 4, // games before the first machine can appear, and games between them after + machMax: 2, // machines allowed on court at once + machHp: 3, // splat-quality hits to wreck one + machCycle: 3.4, // s between its shots + machWind: 0.9, // s of TELEGRAPH before each shot — the fairness contract, same idea as shriek() + machSpeed: 13, // m/s it fires at + machDmg: 1, // player hp a shot costs if you don't return it + parryPow: 0.85, // shot quality of a RETURNED machine ball — a parry is a real weapon + // ---- chapter bosses: one every bossEvery games, escalating. The racket zombie is demoted to an + // occasional opponent (racketEvery) so the boss is the only thing that reads as a wall. + bossEvery: 10, // games between chapter bosses + bossHp: 10, // hp of the FIRST one; each later chapter adds bossHpPerChapter + bossHpPerChapter: 8, + bossScale: 1.5, // body height multiplier at chapter 1 (a shambler is 1.8 m) + bossScalePer: 0.18, // ...growing per chapter, so chapter 4 towers + bossSpeed: 0.42, // m/s it advances — slow, deliberate, unstoppable + bossCycle: 2.6, // s between its ranged attacks (it uses the machine's projectile) + bossVolley: 3, // shots per attack, fanned + racketEvery: 3, // games between racket-zombie (plain opponent) spawns — was EVERY won game, which + // is why he read as "the boss" and why a real boss would not have stood out 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 diff --git a/gameplay.js b/gameplay.js index 12326a8..384a3f8 100644 --- a/gameplay.js +++ b/gameplay.js @@ -5,7 +5,7 @@ import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } fr import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck } from './audio.js'; import { axisX, axisZ, takeFlickAim, touchPX } from './input.js'; import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies -import { updateZombies, spawnZombie, spawnRacketZombie, zombies, gore, explode, meleeSwipe, extras, parts, pellets, playerGore } from './zombies.js'; +import { updateZombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore } from './zombies.js'; // ------------------------------------------------------------- ballistics ---- // Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g. @@ -284,6 +284,11 @@ function pointTo(side, reason) { // quiet (during a horde) rate-limits the umpire so he doesn't call every rapid-fire point. // The scoreboard IS the progression: games drive spawn pressure, 6 games = a set = a horde, // odd games end in a changeover breather. +// Which bodies gate the hand-clear after a won game. The racket zombie keeps its residence, and the +// ball machine is furniture — a standing threat you deal with WHEN YOU CHOOSE. Making either block +// progression would turn "decide what to prioritise" into "clear the chore list". +const blocksClear = z => z.variant !== 'r' && z.variant !== 'm'; + export function splatPoint(side, quiet = false) { if (side === 'P' && ++G.killCount < TUNE.killsPerPoint) return; // K kills bank one point; the rest just splatter if (side === 'P') G.killCount = 0; @@ -295,6 +300,13 @@ export function splatPoint(side, quiet = false) { if (G.runGames === TUNE.duelFirst || (G.runGames > TUNE.duelFirst && (G.runGames - TUNE.duelFirst) % TUNE.duelEvery === 0)) G.duelDue = true; + // ...and every bossEvery games the schedule calls a CHAPTER BOSS. Like the duel it fires from + // proceedAfterClear(), the one beat where the court is guaranteed empty, and it outranks + // everything else there — a boss must not walk on into a standing wave. + if (G.runGames % TUNE.bossEvery === 0) + G.bossDue = Math.max(G.bossDue, Math.floor(G.runGames / TUNE.bossEvery)); // never backwards; a + // long stretch with no clear skips a chapter rather than queueing one, which is intended + voice(side === 'P' ? 'game_set_massacre' : 'game_opponent', 0.5); // game-win lines always speak if (side === 'P') cheer(2.2, 0.5); else aww(0.85); // your game = applause, theirs = a groan const i = side === 'P' ? 0 : 1; @@ -302,12 +314,20 @@ export function splatPoint(side, quiet = false) { else if ((score.games[0] + score.games[1]) % 2 === 1) G.afterClear = 'changeover'; // real rule: after odd games else G.afterClear = 'game'; // even game: a beat, then the next wave G.clearWinner = side; - if (side === 'P' && !zombies.some(z => z.variant === 'r')) - spawnRacketZombie(); // every game you win, an opponent takes the far court (rally it, or splat it) + // The racket zombie is DEMOTED: he used to appear on every single game you won, which is exactly + // why he read as "the boss" and why a real boss would not have stood out next to him. He is an + // occasional opponent now — every racketEvery games — and the chapter boss is the wall. + if (side === 'P' && G.runGames % TUNE.racketEvery === 0 && !zombies.some(z => z.variant === 'r') + && !G.bossFight) + spawnRacketZombie(); + // the rogue ball machine gets wheeled out on the same kind of schedule, capped on court + if (side === 'P' && G.runGames >= TUNE.machEvery && G.runGames % TUNE.machEvery === 0 + && zombies.filter(z => z.variant === 'm').length < TUNE.machMax && !G.bossFight) + spawnMachine(); // The wave does NOT auto-collapse anymore: the survivors stay and must be cleared BY HAND before // play advances — only no NEW shamblers spawn. When the last one is down, proceedAfterClear() // fires (see update()). If the winning shot already emptied the court, proceed at once. - if (zombies.some(z => z.variant !== 'r')) { + if (zombies.some(blocksClear)) { G.clearing = true; ball.active = false; G.flash = { text: 'CLEAR THE COURT', t: 1.8 }; G.phase = 'over'; G.overT = TUNE.gameBeatT; // a short beat to register the game, then the serve loop resumes for the mop-up @@ -326,7 +346,10 @@ export function proceedAfterClear() { G.afterClear = null; // A clear is the ONE moment the court is guaranteed empty — the natural slot for the scheduled // duel. (A wall-clock timer would need its own "wait for a safe moment" and could fire mid-swarm.) - // The set/horde beat outranks it; the duel just waits for the next clear. + // The chapter boss outranks everything here, including a set: a boss walking on into a standing + // wave (or into a horde) would be unreadable. Like the duel it parks the interrupted beat. + if (G.bossDue) { const c = G.bossDue; G.bossDue = 0; G.afterDuel = mode || 'game'; startBossFight(c); return; } + // The set/horde beat outranks the duel; the duel just waits for the next clear. if (mode !== 'set' && G.duelDue) { G.duelDue = false; G.afterDuel = mode || 'game'; startDuel(); return; } if (mode === 'set') { G.card = { title: 'SET ' + (G.sets[0] + G.sets[1]), // the set just banked (sets already ++'d at game over) @@ -408,6 +431,45 @@ export function startDuel() { } else G.flash = { text: 'SUDDEN DEATH', t: 1.6 }; } +// ---- the chapter boss: every TUNE.bossEvery games, the court is cleared and ONE thing walks on. +// No other spawns until it is down, so the fight is legible and so banking a bomb for it is a real +// decision (which is the point — tins arrive every 3 games, so there was never a reason to save one). +// It threatens BOTH channels: its volleys chip your hp, and letting it reach you is the ordinary +// instant death. G.bossFight holds the chapter number while it runs. +export function startBossFight(chapter) { + if (G.bossFight) return; + G.bossFight = chapter; + zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // a clean stage + G.horde = null; G.mgT = 0; G.clearing = false; + ball.active = false; setupPoint(); + spawnChapterBoss(chapter); + zombieDuck(true); // the music drops out for it + voice('quiet_please', 0, 0.8); voice('sudden_death', 1.6, 0.85); + G.card = { title: 'CHAPTER ' + chapter, sub1: 'SOMETHING BIG IS COMING', sub2: 'IT THROWS. RETURN IT.', + color: UI.bad, t: TUNE.setCardT }; + G.flash = null; + if (!G.seen.boss) { // the rules change: a wall, and a thing that shoots back + G.seen.boss = true; + showModal({ title: 'IT BRINGS A MACHINE', color: UI.bad, + body: ['IT THROWS BALLS AT YOU', 'SWING TO RETURN THEM — OR SWISH TO BAT THEM AWAY', + 'A HIT COSTS BLOOD. WIN A DUEL TO HEAL.'] }); + } +} + +// the chapter boss is down: the parked game beat resumes and normal spawning comes back +export function endBossFight() { + if (!G.bossFight) return; + const c = G.bossFight; G.bossFight = 0; + zombieDuck(false); + shots.length = 0; + cheer(3.4, 0.9); voice('unbelievable', 0.3); + G.card = { title: 'CHAPTER ' + c + ' DOWN', sub1: 'THE STANDS ARE ON THEIR FEET', + sub2: 'GAME ' + (G.runGames + 1) + ' NEXT', color: UI.pick, t: TUNE.setCardT }; + ball.active = false; G.phase = 'over'; G.overT = TUNE.setCardT; + // hand back the game beat the boss interrupted — same contract resolveInterlude uses + if (G.afterDuel) { G.afterClear = G.afterDuel; G.afterDuel = null; proceedAfterClear(); } +} + export function resolveInterlude(side) { if (!G.interlude) return; G.interlude = false; @@ -415,6 +477,12 @@ export function resolveInterlude(side) { ball.active = false; G.phase = 'over'; G.overT = 2.2; if (side === 'P') { cheer(3, 0.7); + // winning the duel patches you up. THE reason to want a duel rather than merely tolerate it — + // it is the only healing in the game, and blood is the only thing you cannot buy back otherwise. + if (G.hp < G.hpMax) { + G.hp = Math.min(G.hpMax, G.hp + TUNE.healPerDuel); + G.flash = { text: 'PATCHED UP', t: 1.4 }; + } deliverTin(); // win the point, win the next tin — on the spot } else { voice('game_opponent', 0.4); aww(0.85); // lost the sudden-death duel @@ -904,14 +972,14 @@ export function update(dt) { // --- zombies + transition timeline if (G.mode !== 'tennis') updateZombies(dt); if (G.meleeT > 0) G.meleeT -= dt; - if (G.clearing && !zombies.some(z => z.variant !== 'r')) proceedAfterClear(); // the won-game wave is mopped up — advance + if (G.clearing && !zombies.some(blocksClear)) proceedAfterClear(); // the won-game wave is mopped up — advance if (G.mode === 'transition') transition(dt); // --- interlude trigger: the court is clear, an opponent holds the far court, you're about // to serve — the umpire recognizes a legal match and restores order. One point. Sudden death. // The schedule is a FLOOR, not a ceiling: this lucky-clear trigger stays, and a bonus duel is // pure upside for weapon supply. - if (G.mode === 'splatter' && !G.interlude && !G.horde && !G.changeover && !(G.mgT > 0) + if (G.mode === 'splatter' && !G.interlude && !G.horde && !G.changeover && !(G.mgT > 0) && !G.bossFight && G.phase === 'pretoss' && zombies.length === 1 && zombies[0].variant === 'r' && !zombies[0].dueled) startDuel(); if (G.interlude && G.interlude.introT > 0) G.interlude.introT -= dt; diff --git a/index.html b/index.html index 7582349..8807c81 100644 --- a/index.html +++ b/index.html @@ -60,6 +60,6 @@

TAP TO START

- + diff --git a/main.js b/main.js index ea9fbdd..5ae3acf 100644 --- a/main.js +++ b/main.js @@ -5,10 +5,10 @@ import { TUNE, COURT, UMP_H, UMP_X, EATEN_RESTART } from './config.js'; import { P, O, ball, score, G, setupPoint } from './state.js'; -import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget } from './gameplay.js'; +import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight } from './gameplay.js'; import { fitViewport, render, proj, RES } from './render.js'; import { voice, ambient } from './audio.js'; -import { zombies, spawnZombie, piles, gore, parts, pellets, extras, explode } from './zombies.js'; +import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, explode, hurtPlayer, killPlayer } from './zombies.js'; import { cv } from './dom.js'; import './input.js'; @@ -58,6 +58,6 @@ cv.addEventListener('pointerdown', e => { requestAnimationFrame(frame); // exposed for in-browser sanity checks and simulated playtests -window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, piles, gore, parts, pellets, extras, explode, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, +window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight, step: dt => update(dt), render, press: keyPressed, get phase() { return G.phase; }, set phase(v) { G.phase = v; } }; diff --git a/render.js b/render.js index 8b56ba4..4629496 100644 --- a/render.js +++ b/render.js @@ -4,7 +4,7 @@ import { cv, ctx } from './dom.js'; import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, PLAYER_H, BOY_H, UMP_H, UMP_X, BOUNCE_IDLE, SPRITE_H } from './config.js'; import { P, O, ball, score, crowd, G } from './state.js'; import { predictLanding, flickTarget } from './gameplay.js'; -import { zombies, gore, piles, parts, pellets, extras } from './zombies.js'; +import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js'; // ---------------------------------------------------------------- palette ---- // tennis palette -> drained splatter palette; the transition lerps between them @@ -79,6 +79,7 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash',' 'x_boymop1','x_boymop2','x_boymop3','x_boymop4', // the push cycle, used only while he is stopped and working 'x_boycarry1','x_boycarry2','x_boycarry3','x_boycarry4','x_howto', // walking with the mop shouldered, and the slingshot tutorial panel 'z_gw2','z_gw3','z_gw4','z_gwb','z_gw2b','z_gw3b','z_gw4b', // the brute comes apart in stages (4/5 hp = z_gw .. 1/5 = z_gw4), each with a 2-frame lurch (…b) + 'z_B1','z_B2','z_Bw1','z_Bw2','z_Bw3', // the CHAPTER BOSS: 2-frame stride + three wound stages (arm off -> ribs out -> headless) 'z_a1','z_a2','z_a3','z_a4','z_b1','z_b2','z_b3','z_b4','z_c1','z_c2', 'z_f1','z_f2','z_f3','z_f4','z_g1','z_g2','z_gw','z_s1','z_s2','z_s3', 'z_cw1','z_cw2','z_aw','z_bw', // wounded skins for multi-hit enemies (racket boss, brute, and now the classic shamblers) @@ -164,15 +165,41 @@ function drawBoy(b) { ctx.restore(); } const ZFRAMES = { a: 4, b: 4, f: 4 }; // 4-frame walk cycles where sheets exist; the rest stay 2 +// The rogue ball machine: real tennis furniture, so it is drawn as furniture — a squat hopper on +// wheels with a barrel pointed down-court. Procedural like drawFlames, and it stays that way unless +// a sheet turns up: a box is four rects, and the readable part is the BARREL and the charge glow. +function drawMachine(zb) { + const p = proj(zb.x, 0, zb.z), u = p.s * 0.16; // u = one "pixel" at this depth + const R = (x, y, w, h, c) => { ctx.fillStyle = c; ctx.fillRect(Math.round(p.x + x * u), Math.round(p.y + y * u), Math.max(1, w * u), Math.max(1, h * u)); }; + drawShadow(p.x, p.y, u * 4.4, u * 1.3); + const dead = zb.hp / TUNE.machHp; + R(-4, -7, 8, 7, dead > 0.5 ? '#4a5460' : '#3a3038'); // hopper, scorched as it takes damage + R(-4, -7, 8, 1, '#6b7684'); // top light + R(-4, -1, 8, 1, '#20262e'); // bottom shadow + R(-2.2, -9.6, 4.4, 2.6, '#2b3138'); // ball basket + R(-1.6, -9.2, 3.6, 1.8, UI.ball); // ...full of tennis balls + R(1.2, -6.2, 4.6, 2.2, '#565f6b'); // the BARREL, pointed at you + R(4.4, -6.2, 1.4, 2.2, '#161a1f'); // muzzle + R(-3.4, -1.2, 2, 1.6, '#15181c'); R(1.4, -1.2, 2, 1.6, '#15181c'); // wheels + if (zb.windT > 0) { // the TELEGRAPH: the muzzle glows hotter the closer the shot is + const k = 1 - zb.windT / TUNE.machWind; + ctx.globalAlpha = 0.35 + k * 0.65; + R(4.2, -6.4, 2 + k * 1.6, 2.6, k > 0.6 ? '#fff2c0' : UI.warm); + ctx.globalAlpha = 1; + } +} + function drawZombie(zb) { + if (zb.variant === 'm') return drawMachine(zb); const rk = zb.variant === 'r'; - const v = rk ? 'c' : zb.variant; // the racket boss reuses the turned-CPU sprite (already holds a racket) + const v = rk ? 'c' : zb.variant; // the racket zombie reuses the turned-CPU sprite (already holds a racket) let N = ZFRAMES[v] || 2; if (!SPR['z_' + v + N]) N = 2; // frames still loading (or sheet rejected): 2-frame fallback // same CYCLE frequency as before — more frames subdivide the stride, they don't speed it up let frame = rk && zb.pose === 'swing' ? 2 : Math.floor(zb.walkT * (zb.variant === 'f' ? 6 : 2.6) * N / 2) % N + 1; if (zb.variant === 's' && zb.shieldT > 0) frame = 3; // chair dropped — hit it NOW let h = zb.variant === 'g' ? 2.7 : rk ? 1.85 : 1.8; // the brute towers half again over the crowd + if (zb.variant === 'B') h = 1.8 * (zb.scale || 1.5); // ...and a chapter boss towers over the brute // wounded skins: multi-hit enemies show their damage as hp drops (arm off → head gone, still // shambling). The blood is NOT painted in — open wounds gush live particles (see bleed() in // zombies.js). Fall back to the healthy sprite until the wounded PNG has loaded. @@ -198,8 +225,25 @@ function drawZombie(zb) { 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'; } + // The chapter boss comes apart through the SAME fraction-of-hpMax rule as the racket zombie, over + // three stages, so a 34 hp chapter-4 boss visibly degrades all the way down: arm off, then ribs + // out, then headless and still throwing. Its 2-frame stride steps off DISTANCE COVERED (the same + // rule the brute uses) — it is the slowest thing on court and a time-driven cycle would foot-slide. + if (zb.variant === 'B') { + const f = zb.hp / (zb.hpMax || 1); + const stage = f <= 0.25 ? 'z_Bw3' : f <= 0.5 ? 'z_Bw2' : f <= 0.75 ? 'z_Bw1' + : (Math.floor(zb.z / TUNE.bruteStride) % 2 ? 'z_B2' : 'z_B1'); + if (SPR[stage]) { name = stage; h *= SPR[stage].height / SPRITE_H; } // height ALWAYS off the frame, or the size pops + } drawStatic(name, zb.x, zb.z, h, Math.floor(zb.walkT * 5.2) % 2); if (zb.burnT > 0) drawFlames(zb, h); + if (zb.windT > 0 && zb.variant === 'B') { // the boss telegraphs too: it rears up, glowing, then throws + const k = 1 - zb.windT / TUNE.machWind, p = proj(zb.x, h * 0.62, zb.z); + ctx.globalAlpha = 0.25 + k * 0.55; ctx.fillStyle = k > 0.6 ? '#fff2c0' : UI.bad; + const r = Math.max(2, p.s * 0.1 * (0.5 + k)); + ctx.beginPath(); ctx.arc(Math.round(p.x), Math.round(p.y), r, 0, 7); ctx.fill(); + ctx.globalAlpha = 1; + } // 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 @@ -303,6 +347,29 @@ function drawChar(ch) { ctx.drawImage(img, -w / 2, -h, w, h); ctx.restore(); } else drawFallbackChar(ch, p, hpx); + if (ch.side === 'P') drawPlayerWounds(p, hpx); +} + +// The player's health, drawn ON HIM and nowhere else — there is no bar and there will not be one +// (same rule that removed the boss's). Blood is never painted into the sprite (settled feel +// decision), so this is a procedural overlay plus bleedPlayer()'s live trickle: fixed anchors, so it +// does not crawl frame to frame, and it grows with every hit. The white kit is the meter. +const WOUND_AT = [[-0.18, -0.62], [0.22, -0.55], [-0.05, -0.72], [0.14, -0.42], [-0.26, -0.48], [0.06, -0.33]]; +function drawPlayerWounds(p, hpx) { + if (G.hp >= G.hpMax) return; + const lost = G.hpMax - G.hp, n = Math.min(WOUND_AT.length, lost * 2); + for (let i = 0; i < n; i++) { + const [ox, oy] = WOUND_AT[i]; + const s = Math.max(1, hpx * (0.055 + (i % 2) * 0.02)); + ctx.fillStyle = i % 3 ? 'rgba(150,16,16,0.92)' : 'rgba(86,10,10,0.95)'; + ctx.fillRect(Math.round(p.x + ox * hpx * 0.5), Math.round(p.y + oy * hpx), s, s); + } + if (G.hurtFlash > 0) { // the impact itself: he goes red for a moment + ctx.save(); ctx.globalAlpha = Math.min(1, G.hurtFlash / 0.3) * 0.5; + ctx.fillStyle = UI.bad; + ctx.fillRect(Math.round(p.x - hpx * 0.2), Math.round(p.y - hpx * 0.82), hpx * 0.4, hpx * 0.6); + ctx.restore(); + } } function drawFallbackChar(ch, p, h) { // shown only until sprites load const you = ch.side === 'P'; @@ -645,6 +712,17 @@ export function render() { // the match ball (and any triple-serve side balls in flight) if ((ball.active || G.phase === 'over') && !ball.collected) drawBall(ball.x, ball.y, ball.z, true, ball.bomb, ball.flame); for (const e of extras) drawBall(e.x, e.y, e.z); + // INCOMING hostile balls, marked so they can never be mistaken for one of yours: a red ring and a + // hard ground shadow, so you can read WHERE it will land while it is still in the air. + for (const s of shots) { + const sh = proj(s.x, 0, s.z); + drawShadow(sh.x, sh.y, Math.max(1.5, sh.s * 0.05), Math.max(1, sh.s * 0.025), 0.4); + const sp = proj(s.x, s.y, s.z), r = Math.max(2, sp.s * 0.045); + ctx.fillStyle = UI.bad; + ctx.beginPath(); ctx.arc(Math.round(sp.x), Math.round(sp.y), r + 1.5, 0, 7); ctx.fill(); + ctx.fillStyle = UI.ball; + ctx.beginPath(); ctx.arc(Math.round(sp.x), Math.round(sp.y), r, 0, 7); ctx.fill(); + } // splatter particles (fire embers ride the same array) for (const pt of parts) { const pp = proj(pt.x, pt.y, pt.z); @@ -677,6 +755,14 @@ export function render() { // bomb bloom: a warm blood-orange wash, not a white-out. At 0.85 white it blinded you on a phone // and hid the very gore eruption it was announcing. if (G.boomFlash > 0) { ctx.fillStyle = `rgba(255,150,70,${Math.min(1, G.boomFlash / 0.06) * 0.3})`; ctx.fillRect(0, 0, W, H); } + // took a ranged hit: a red wash from the screen EDGES inward. Deliberately not a full-screen fill — + // this channel must read as "you are bleeding", not as "something exploded" (that is boomFlash). + if (G.hurtFlash > 0) { + const a = Math.min(1, G.hurtFlash / 0.3) * 0.55, b = 26; + ctx.fillStyle = `rgba(150,10,14,${a.toFixed(3)})`; + ctx.fillRect(0, 0, W, b); ctx.fillRect(0, H - b, W, b); + ctx.fillRect(0, 0, b, H); ctx.fillRect(W - b, 0, b, H); + } // HUD — Wimbledon manual scoreboard as a 16-bit panel: bevelled bottle-green box (light // top/left, dark bottom/right), a purple club spine, cream serif names, and the LIVE game diff --git a/state.js b/state.js index cb35a07..6e66be4 100644 --- a/state.js +++ b/state.js @@ -49,6 +49,15 @@ export const G = { idleHintT: 0, // s the player has sat in pretoss without touching anything — after a beat the // pull gesture demonstrates itself (render.drawPullHint) meleeT: 0, // s of racket-swish cooldown left + // ---- the SECOND damage channel. Only ranged attacks (ball machine, chapter boss) touch this; + // a shambler reaching you is still instant permadeath, deliberately. No HP bar anywhere — the + // state is drawn as blood on the man (drawChar's spatter + bleedPlayer's live trickle). + hp: TUNE.playerHp, + hpMax: TUNE.playerHp, + hurtT: 0, // s of post-hit grace left; also drives the red hit flash + hurtFlash: 0, // s of the impact wash left + bossDue: 0, // chapter number the schedule owes you — fired at the next hand-cleared court + bossFight: 0, // chapter number of the boss currently on court, 0 = none. Blocks all other spawns. interlude: false, // sudden-death duel vs the racket zombie: falsy, or {introT} while announcing duelLerp: 0, // 0..1 — how far the court has returned to tennis colors for the duel horde: null, // active horde wave {t, spawnT, mgT} — machine-gun racket, no serving diff --git a/zombies.js b/zombies.js index 7c59910..806ebc5 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, UI, COURT } from './config.js'; +import { TUNE, UI, COURT, VOLLEY, CONTACT } 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, showModal } from './gameplay.js'; +import { splatPoint, cheer, spentBall, resolveInterlude, showModal, endBossFight } 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 @@ -14,6 +14,12 @@ export const piles = []; // 3D-ish gore mounds {x, z, size 1..4, seed} — gro 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} let spawnT = 1.5, moanT = 3; @@ -28,9 +34,88 @@ export function spawnZombie(x, z, variant) { 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 === 's') zb.shieldT = 0; // chair up; > 0 = seconds of stagger left + if (variant === 'm') { // the rogue ball machine: furniture, not a body. It does not walk at all. + zb.v = 0; zb.hp = TUNE.machHp; zb.fireT = TUNE.machCycle; zb.windT = 0; + } zombies.push(zb); } +// 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'); + if (!G.seen.mach) { + G.seen.mach = true; + showModal({ title: 'A BALL MACHINE', color: UI.warm, + body: ['IT FIRES AT YOU ON A TIMER', 'SWING TO RETURN THE SHOT', 'OR SWISH TO BAT IT AWAY'] }); + } +} + +// ---- 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 HANDOFF), so no weapon needs special-casing. +export function spawnChapterBoss(chapter) { + const hp = TUNE.bossHp + (chapter - 1) * TUNE.bossHpPerChapter; + zombies.push({ x: 0, z: -1.2, v: TUNE.bossSpeed, walkT: 0, homeOff: 0, variant: 'B', + chapter, hp, hpMax: hp, scale: TUNE.bossScale + (chapter - 1) * TUNE.bossScalePer, + fireT: TUNE.bossCycle, windT: 0 }); +} + +// ---- the player's second health channel. Only these two functions touch it. +export function hurtPlayer(n) { + if (G.mode !== 'splatter' || G.hurtT > 0 || G.mode === 'eaten') return; // grace window: a volley + G.hp -= n; // must not delete you at once + G.hurtT = TUNE.hurtGrace; G.hurtFlash = 0.3; + 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 voice('order', 0.3); // the umpire objects on your behalf. He is still watching. +} + +// 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; + 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 + 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; // everything stops + try { // the run is over — settle it against the best (games survived, sets as tiebreak) + const b = JSON.parse(localStorage.splatterBest || 'null') || { games: 0, sets: 0 }; + G.newBest = G.runGames > b.games; // beat it → the summary crows about it + if (G.newBest) { b.games = G.runGames; b.sets = G.sets[0]; localStorage.splatterBest = JSON.stringify(b); } + G.best = b; + } catch (e) { G.best = null; } // private mode etc. — the summary just skips the best line +} + +// 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) { + 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 / TUNE.machSpeed); + 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. @@ -49,18 +134,25 @@ function pickVariant() { // 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; +// 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 === 'r' ? 0.7 + : 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; // 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, f: 1.8, s: 2.5, g: 4, r: 3 }; +const GORE_MUL = { a: 1, b: 1, f: 1.8, s: 2.5, g: 4, r: 3, m: 1.5, B: 6 }; // a chapter boss comes apart like nothing else on the court function killZombie(i, quiet) { const zb = zombies[i]; const mul = GORE_MUL[zb.variant] || 1; zombies.splice(i, 1); if (G.interlude && zb.variant === 'r') resolveInterlude('P'); // regicide ends the duel your way — any weapon counts + if (zb.variant === 'B') endBossFight(); // the chapter is over 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), @@ -80,7 +172,10 @@ function killZombie(i, quiet) { splat(); 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 - if (!G.horde && !G.clearing) splatPoint('P', quiet); // horde = survival, clearing = mopping up an already-won game — neither scores + // 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 @@ -115,7 +210,7 @@ function burnTick(zb, i, dt) { zb.burnT -= dt; 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 * (zb.variant === 'r' ? TUNE.bossBurnMul : 1) * dt, false); + const dead = wound(zb, TUNE.burnDps * (isBoss(zb) ? 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, @@ -143,6 +238,22 @@ export function playerGore() { // 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, @@ -162,8 +273,20 @@ export function meleeSwipe(x, z, range) { 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 also BATS AWAY incoming shots. This is what gives you a defence in `pretoss`, where you + // are rooted to the spot and cannot swing at all — without it a machine winding up while you were + // lining up a serve was an unanswerable hit. It only destroys the shot; the timed rally parry below + // is the skilled option that sends it back as a weapon. + for (let i = shots.length - 1; i >= 0; i--) { + const s = shots[i]; + if (Math.hypot(s.x - x, s.z - z) < range && s.y < 2.4) { shots.splice(i, 1); pock(0.7, 900); hit++; } + } if (hit) swish(0.35); return hit; } @@ -229,7 +352,7 @@ export function explode(x, z) { 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); } + 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 @@ -304,7 +427,7 @@ export function updateZombies(dt) { if (G.phase === 'rally' && (G.mgCool -= dt) <= 0) { firePellet(); G.mgCool = 1 / TUNE.mgRate; } if (G.mgT <= 0) setupPoint(); // ammo dry — the serve loop resumes } - 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) + if (!G.horde && !G.changeover && !G.interlude && !G.clearing && !G.bossFight) { // breathers, duels, the mop-up after a won game AND a boss fight 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 @@ -336,6 +459,9 @@ export function updateZombies(dt) { // 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]; @@ -346,33 +472,34 @@ export function updateZombies(dt) { bleed(zb); // open wounds bleed every frame they're standing if (zb.shieldT > 0) zb.shieldT -= dt; // staggered chair comes back up when this runs out - if (held || zb.variant === 'r') { /* the racket boss holds station — its AI owns its x */ } + // ---- 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') { + if (zb.windT > 0) { + zb.windT -= dt; + if (zb.windT <= 0) { + const n = zb.variant === 'B' ? TUNE.bossVolley : 1; + for (let k = 0; k < n; k++) fireAtPlayer(zb.x, zb.z, n === 1 ? 0 : (k - (n - 1) / 2) * 1.9); + } + } else if ((zb.fireT -= dt) <= 0) { + zb.fireT = zb.variant === 'B' ? TUNE.bossCycle : TUNE.machCycle; + zb.windT = TUNE.machWind; + pock(0.3, 1500); // the tell: a mechanical click as it loads + } + } + if (held || zb.variant === 'r' || zb.variant === 'm') { /* boss AI owns its x; the machine is bolted down */ } 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 - if (!held && G.mode === 'splatter' && 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 - gore.push({ x: P.x, z: P.z, r: 0.9, a: 0.9 }); - for (let k = 0; k < 30; k++) parts.push({ - x: P.x, y: 1.1, z: P.z, - 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 - 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; G.mgT = 0; // everything stops; the eaten screen takes over - try { // the run is over — settle it against the best (games survived, sets as tiebreak) - const b = JSON.parse(localStorage.splatterBest || 'null') || { games: 0, sets: 0 }; - G.newBest = G.runGames > b.games; // beat it → the summary crows about it - if (G.newBest) { b.games = G.runGames; b.sets = G.sets[0]; localStorage.splatterBest = JSON.stringify(b); } - G.best = b; - } catch (e) { G.best = null; } // private mode etc. — the summary just skips the best line - } + // 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' && zb.variant !== 'm' + && 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) { const d = Math.hypot(ball.x - zb.x, ball.z - zb.z); if (d < hitR(zb) + ballSweep) { // brute and boss are bigger targets @@ -381,7 +508,7 @@ export function updateZombies(dt) { 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 takes a heavy wound AND catches light + if (isBoss(zb)) { // a boss takes a heavy wound AND catches light if (wound(zb, TUNE.dmgFlame)) splatter(i); else zb.burnT = TUNE.burnTime; continue; @@ -391,7 +518,7 @@ export function updateZombies(dt) { ignite(bx, bz, TUNE.burnIgniteR); // ...and everyone standing near the body goes up continue; } - if (zb.variant === 'r') { // mini-boss: splat-quality wounds it; soft balls it rallies (racketAI) + if (isBoss(zb)) { // boss: splat-quality wounds it; soft balls the racket one rallies (racketAI) if ((ball.pow || 0) >= TUNE.zSplatQ) { const dead = wound(zb, TUNE.dmgBall); pock(0.6, 300); @@ -401,9 +528,9 @@ export function updateZombies(dt) { 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) { - zb.hp--; splat(0.5); pock(0.6, 220); + if (zb.variant === 'g' || zb.variant === 'm') { // brute AND the bolted-down machine: soak + if ((ball.pow || 0) >= TUNE.zSplatQ) { // splat-quality hits, never stagger back + zb.hp--; splat(0.5); pock(0.6, zb.variant === 'm' ? 700 : 220); // machine rings, meat thuds ball.vx *= 0.3; ball.vz *= 0.3; ball.lastHit = 'X'; if (zb.hp <= 0) { splatter(i); continue; } // timber } else { pock(0.6, 300); ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; } // soft ball bounces off @@ -446,7 +573,7 @@ export function updateZombies(dt) { // 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); } + if (isBoss(zb)) { 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; @@ -455,6 +582,29 @@ export function updateZombies(dt) { if (hit || pe.t <= 0) pellets.splice(i, 1); } + // ---- 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]; + 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; + const dP = Math.hypot(s.x - P.x, (s.z - P.z) * 0.8); + if (!done && P.swingT >= TUNE.swingWindup + && P.swingT <= TUNE.swingWindup + TUNE.swingWindow * VOLLEY.window + && dP < TUNE.reach * VOLLEY.reach && 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 + extras.push({ x: s.x, y: s.y, z: s.z, g: TUNE.gravity, pow: TUNE.parryPow, t: 2.5, + vx: s.vx * 0.3, vy: 4.2, vz: -Math.abs(s.vz) * 1.15 }); + pock(0.85, 1300); G.flash = { text: 'RETURNED', t: 0.6 }; cheer(0.8, 0.3); + done = true; + } + if (!done && dP < 0.55 && s.y < 2.0) { 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]; 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 @@ -468,7 +618,7 @@ export function updateZombies(dt) { + 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 (zb.variant === 'r') { + if (isBoss(zb)) { if (e.pow >= TUNE.zSplatQ) { if (wound(zb, TUNE.dmgSide)) killZombie(j, !!G.horde); } else pock(0.5, 300); }