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
- +