Add chapter bosses and a second damage channel; demote the racket zombie

Steps 4 and 5 of the late-game depth plan.

The ranged channel. A rogue ball machine gets wheeled onto the far court every
four games: it never walks, never crosses your baseline and never scores, so
it is a standing threat you choose when to deal with — the target-priority
decision the game was missing. Its shot is a real arc solved like a serve, not
a flat bolt, so it reads coming in and the swing window has something to meet.
Three outcomes: a swing inside the volley window returns it as your own ball
flying up-court (an extras clone, so "a parry is a weapon" cost nothing to
build), the panic swish bats it away, and letting it reach you costs blood.
The swish path is load-bearing, not politeness: in pretoss the player is
rooted and cannot swing at all.

Contact still kills outright — that stays a separate channel, and killPlayer()
is now the single death path for both causes so the run summary and the best-run
write cannot drift. Health is drawn on the man: procedural spatter at fixed
anchors, a live trickle, and a red wash from the screen edges. No bar. Healing
is winning a duel, which finally gives the duel a reason to be wanted.

Chapter bosses every ten games, with their own sheet — a colossal zombie
groundskeeper hauling a ball hopper, coming apart through arm-off, ribs-out and
headless-still-throwing. Chapter 1 is 10 hp and 2.7 m, chapter 4 is 34 hp and
3.67 m. It outranks the set and duel beats at a clear, because a boss walking
into a standing wave is unreadable. And it fixes the economy: a chapter-4 boss
costs 12 bombs or 100 rounds or 34 side balls against a per-type cap of 9, so
no single weapon can take one and a banked mixed arsenal is mandatory.

The racket zombie is demoted to an occasional opponent, as asked — appearing
every won game is exactly why he read as the boss.

Five silent bugs found on the way, all in HANDOFF: a blast deleted a 34 hp boss
outright (isBoss() exists to stop that class recurring), the machine's hp did
nothing because it fell through to the one-shot fallback, the boss both scored
and fired its card, the machine gated the hand-clear, and a boss died with a
shambler-sized gore burst.

Tennis untouched: 30 tapped points still 50 hits / 0 whiffs. 45-game soak clean
with all four chapters firing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-30 12:55:35 +02:00
1 parent ffac9b5512
commit 4a066e5cc6
14 files changed
+492 -131

No files matched your search

+101 -82
View File
@@ -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.
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 679 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 18 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 17 KiB

+29
View File
@@ -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
+75 -7
View File
@@ -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;
+1 -1
View File
@@ -60,6 +60,6 @@
<p class="start">TAP TO START</p>
</div>
</div>
<script type="module" src="main.js?v=39"></script>
<script type="module" src="main.js?v=40"></script>
</body>
</html>
+3 -3
View File
@@ -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; } };
+88 -2
View File
@@ -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
+9
View File
@@ -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
+186 -36
View File
@@ -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);
}