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>
No files matched your search
@@ -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
|
long history, `~/.claude/projects/.../memory/splatter-tennis-history.md` is auto-loaded; for the
|
||||||
detail of any individual change, read the git log.
|
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
|
Last updated 2026-07-30. The **late-game depth work is complete**: steps 1-5 of the plan agreed after
|
||||||
playtest. Steps 1-3 of that plan are DONE; **steps 4 and 5 are the next job and are specified in
|
Oliver's 50+ game playtest are all built and verified in-browser. Step 6 (crowd turning, ball-boy
|
||||||
full at the bottom of this file** — start there. Everything from the earlier retest rounds is closed
|
catcher) is PARKED by his decision. **Awaiting his phone playtest of the boss/ranged build.**
|
||||||
and recorded below it.
|
What is still owed is art, not mechanics — see "Art still owed" at the bottom.
|
||||||
|
|
||||||
## What this is
|
## What this is
|
||||||
SNES-style pseudo-3D tennis (Super Tennis reference) that transitions into a zombie splatter game
|
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).
|
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
|
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`).
|
**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
|
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
|
## 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
|
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
|
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.
|
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
|
- **`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.
|
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
|
- **`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
|
The only outstanding work on the boss/ranged build is art. Everything below is playable now.
|
||||||
in the invariants above — every difficulty dial bottomed out by game 12. Steps 1-3 removed the flat
|
|
||||||
line; **steps 4 and 5 are the actual content and are NOT built yet.** Step 6 is parked by his
|
|
||||||
decision. Read this whole section before starting: the decisions in it were argued out and settled,
|
|
||||||
and re-litigating them wastes his time.
|
|
||||||
|
|
||||||
## Settled design decisions (do not reopen)
|
- **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
|
# CLOSED — chapter bosses + ranged attacks, steps 4-5 (2026-07-30)
|
||||||
only ranged attacks touch. A shambler reaching you is still `G.mode = 'eaten'`, still permadeath,
|
|
||||||
still the whole run over. Oliver: *"keep the instant death separate"*. The reason: two threats that
|
|
||||||
demand two different responses is the depth; making contact chip instead of kill would make the game
|
|
||||||
easier and lose the dread that carries it.
|
|
||||||
- **Player health is his SHIRT, not a bar.** Tennis whites going progressively bloodier, plus
|
|
||||||
`bleed()` on the player once wounded. No HUD box, no meter, no number — same reasoning that removed
|
|
||||||
the boss's HP bar (see invariants). This is the aesthetic rule for this game: state is expressed in
|
|
||||||
the art or in tennis furniture, never in floating UI.
|
|
||||||
- **Healing happens by winning a sudden-death duel.** That finally gives the duel a reason to be
|
|
||||||
WANTED rather than just being a weapon vending machine.
|
|
||||||
- **Step 6 is parked.** The crowd turning into zombies and the ball-boy-zombie-who-catches-your-balls
|
|
||||||
were both discussed; Oliver: *"for 6 that's my least favourite one and we should park it"*. Do not
|
|
||||||
build them. They are recorded at the bottom only so the idea isn't lost.
|
|
||||||
|
|
||||||
## Step 4 — ranged attacks and player damage (NOT BUILT)
|
## 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
|
**The ball machine (`'m'`)**, Oliver's own idea and the most on-brand thing on the list, because a ball
|
||||||
adds more of what the player already does. The player's whole vocabulary today is pull-aim-fire,
|
machine is real tennis furniture. It never walks, so it never crosses your baseline and never scores:
|
||||||
panic-swish, pick a tin, walk during a rally.
|
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.**
|
**The projectile is a real ARC**, solved like a serve rather than fired flat, so it reads coming in and
|
||||||
`trySwing` already implements "an incoming ball, a generous volley window, time it or eat it", and
|
so the swing window has something to meet. `fireAtPlayer` aims where you *stand*, which means moving
|
||||||
`VOLLEY` already widens reach ×1.4 / window ×1.8 for a pre-bounce contact. A thrower is therefore
|
is a partial answer during a rally — and is exactly why the swish path exists for `pretoss`, where you
|
||||||
much closer to "a racket zombie that serves at you" than to a new system. Reuse that path rather than
|
cannot move or swing.
|
||||||
writing a projectile-dodging mechanic from scratch.
|
|
||||||
|
|
||||||
Deliver it as the **rogue ball machine** — Oliver's own idea and the most on-brand thing in the list,
|
**Player damage.** 4 hp, `hurtGrace` 1.1 s, +2 on a won duel (the only healing in the game, and now
|
||||||
because a ball machine is *real tennis furniture*. Suggested shape:
|
THE reason to want a duel rather than tolerate it). Verified: 4 hits kill you and land in the same
|
||||||
- A new variant (`'m'`?) that does NOT shamble: it parks on the far court and fires at you on a cycle.
|
`eaten` path as being caught, with `eatenAt`, the death animation and the best-run write all intact;
|
||||||
- It must **telegraph**: a wind-up pose plus a sound, far enough ahead that a phone player with one
|
three hits in one frame cost only one point.
|
||||||
thumb can react. Reuse the `shriek()` fairness-contract pattern the runner already uses.
|
|
||||||
- Returning the shot is a normal swing/volley. Missing it costs one point of player HP and starts the
|
|
||||||
bleed. It must never be an instant kill — that is what contact is for.
|
|
||||||
- Because it doesn't walk, it never reaches your baseline, so it can't score for THEM by crossing —
|
|
||||||
it has to be a standing threat you choose when to deal with. That makes it a target-priority
|
|
||||||
decision, which is new.
|
|
||||||
|
|
||||||
Player-state plumbing this needs (none of it exists yet):
|
## Step 5 — chapter bosses, and the racket zombie demoted
|
||||||
- `G.hp` / `G.hpMax` on the `G` object in state.js, reset per run.
|
|
||||||
- The bloodied-shirt skins: new `p_*` sprites per wound stage, resolved in `drawChar` exactly the way
|
|
||||||
`drawZombie` resolves boss skins — **as a fraction of hpMax, not against literal hp numbers**
|
|
||||||
(see invariants; this trap has already been hit once).
|
|
||||||
- `bleed()` currently takes a zombie; it will need to work for the player, or get a sibling.
|
|
||||||
- Healing in `resolveInterlude('P')`, alongside `deliverTin()`.
|
|
||||||
- The eaten/run-summary path must still work when death comes from HP reaching zero rather than from
|
|
||||||
contact — check `G.eatenAt`, `playerGore()`, and the `localStorage` best-run write.
|
|
||||||
|
|
||||||
## Step 5 — chapter bosses every 10 games (NOT BUILT — Oliver's favourite)
|
**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 —
|
**The economy fix this was really for:** tins arrive every 3 games forever, so nothing was ever worth
|
||||||
tins arrive every 3 games forever (`duelFirst` 2, `duelEvery` 3), so there is currently no reason to
|
banking. Measured cost of a chapter-4 boss — 12 bombs, or 100 machine-gun rounds, or 34 side balls.
|
||||||
bank anything. A rare wall you must save weapons for is what turns the rack into a decision.
|
`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
|
`G.bossDue` never moves backwards (`Math.max`): going a long stretch without ever clearing the court
|
||||||
— it uses ranged attacks too, so escalation becomes numbers rather than new mechanics.
|
skips a chapter rather than queueing a backlog. Deliberate — you face the boss the run has earned.
|
||||||
- **The damage model is already ready for this.** `wound()` + `hitR()` + the `TUNE.dmg*` table mean a
|
|
||||||
boss is just a bigger `hp` number and every weapon stays proportional. Verified: a 9 hp body takes
|
|
||||||
3 off per bomb, ~3 pellets per point, 1 per side ball. Do not add per-boss damage special cases.
|
|
||||||
- **Bigger is free**: the brute already renders at 2.7 m against a shambler's 1.8 m, so sprite scale
|
|
||||||
is an existing, proven lever.
|
|
||||||
- **The always-present racket zombie must stop being "the boss" first**, or a real boss will not read
|
|
||||||
as special. Today `splatPoint` respawns one every single game you win. Decide: does the mini-boss
|
|
||||||
become rarer, or get renamed/re-skinned as an ordinary "opponent" while the chapter boss is a
|
|
||||||
distinct thing?
|
|
||||||
- Sprite work is the bulk of this step and it is Oliver's stated priority. Follow the asset pipeline
|
|
||||||
section above exactly: `generate-image` in EDIT mode with the existing sheet as `--input` is what
|
|
||||||
makes a new sheet match, magenta background, then `_slice.py` (frames) or `_key.py` (standalone).
|
|
||||||
Wound stages must be generated as a SET so they colour-match — and remember a wound stage's height
|
|
||||||
must always come from its A frame or it pops.
|
|
||||||
|
|
||||||
## Parked (step 6) — do not build without asking
|
## 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.
|
- A blast **deleted** a 34 hp chapter boss outright: `explode` only wounded `'r'`. This is what
|
||||||
attacks arriving from behind and beside you, which breaks the one assumption the whole game rests
|
`isBoss()` now exists to prevent, and it is the second time the same class of bug has shipped.
|
||||||
on. Highest spectacle-per-line available, and `x_crowd`/`x_crowd2` already exist. Oliver's least
|
- The machine's `machHp` did nothing — `'m'` fell through every variant branch to the generic
|
||||||
favourite; parked.
|
one-shot fallback. It soaks like the brute now (3 balls, verified).
|
||||||
- **The ball-boy zombie that catches your balls.** Denies your best lane, forcing target priority.
|
- The boss both scored a kill and fired its chapter card, so two code paths set "what happens next"
|
||||||
Parked with the above.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 679 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 17 KiB |
@@ -51,6 +51,35 @@ export const TUNE = {
|
|||||||
// purpose (that is the point of it), but hordeGrow means a late wave now tries
|
// 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,
|
// 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.
|
// 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)
|
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
|
// ---- 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
|
// boss is just a bigger hp number and every weapon stays proportional. Fractions are legal: they
|
||||||
|
|||||||
@@ -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 { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck } from './audio.js';
|
||||||
import { axisX, axisZ, takeFlickAim, touchPX } from './input.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 { 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 ----
|
// ------------------------------------------------------------- ballistics ----
|
||||||
// Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g.
|
// 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.
|
// 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,
|
// The scoreboard IS the progression: games drive spawn pressure, 6 games = a set = a horde,
|
||||||
// odd games end in a changeover breather.
|
// 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) {
|
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 < TUNE.killsPerPoint) return; // K kills bank one point; the rest just splatter
|
||||||
if (side === 'P') G.killCount = 0;
|
if (side === 'P') G.killCount = 0;
|
||||||
@@ -295,6 +300,13 @@ export function splatPoint(side, quiet = false) {
|
|||||||
if (G.runGames === TUNE.duelFirst ||
|
if (G.runGames === TUNE.duelFirst ||
|
||||||
(G.runGames > TUNE.duelFirst && (G.runGames - TUNE.duelFirst) % TUNE.duelEvery === 0))
|
(G.runGames > TUNE.duelFirst && (G.runGames - TUNE.duelFirst) % TUNE.duelEvery === 0))
|
||||||
G.duelDue = true;
|
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
|
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
|
if (side === 'P') cheer(2.2, 0.5); else aww(0.85); // your game = applause, theirs = a groan
|
||||||
const i = side === 'P' ? 0 : 1;
|
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 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
|
else G.afterClear = 'game'; // even game: a beat, then the next wave
|
||||||
G.clearWinner = side;
|
G.clearWinner = side;
|
||||||
if (side === 'P' && !zombies.some(z => z.variant === 'r'))
|
// The racket zombie is DEMOTED: he used to appear on every single game you won, which is exactly
|
||||||
spawnRacketZombie(); // every game you win, an opponent takes the far court (rally it, or splat it)
|
// 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
|
// 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()
|
// 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.
|
// 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.clearing = true; ball.active = false;
|
||||||
G.flash = { text: 'CLEAR THE COURT', t: 1.8 };
|
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
|
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;
|
G.afterClear = null;
|
||||||
// A clear is the ONE moment the court is guaranteed empty — the natural slot for the scheduled
|
// 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.)
|
// 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.duelDue) { G.duelDue = false; G.afterDuel = mode || 'game'; startDuel(); return; }
|
||||||
if (mode === 'set') {
|
if (mode === 'set') {
|
||||||
G.card = { title: 'SET ' + (G.sets[0] + G.sets[1]), // the set just banked (sets already ++'d at game over)
|
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 };
|
} 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) {
|
export function resolveInterlude(side) {
|
||||||
if (!G.interlude) return;
|
if (!G.interlude) return;
|
||||||
G.interlude = false;
|
G.interlude = false;
|
||||||
@@ -415,6 +477,12 @@ export function resolveInterlude(side) {
|
|||||||
ball.active = false; G.phase = 'over'; G.overT = 2.2;
|
ball.active = false; G.phase = 'over'; G.overT = 2.2;
|
||||||
if (side === 'P') {
|
if (side === 'P') {
|
||||||
cheer(3, 0.7);
|
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
|
deliverTin(); // win the point, win the next tin — on the spot
|
||||||
} else {
|
} else {
|
||||||
voice('game_opponent', 0.4); aww(0.85); // lost the sudden-death duel
|
voice('game_opponent', 0.4); aww(0.85); // lost the sudden-death duel
|
||||||
@@ -904,14 +972,14 @@ export function update(dt) {
|
|||||||
// --- zombies + transition timeline
|
// --- zombies + transition timeline
|
||||||
if (G.mode !== 'tennis') updateZombies(dt);
|
if (G.mode !== 'tennis') updateZombies(dt);
|
||||||
if (G.meleeT > 0) G.meleeT -= 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);
|
if (G.mode === 'transition') transition(dt);
|
||||||
|
|
||||||
// --- interlude trigger: the court is clear, an opponent holds the far court, you're about
|
// --- 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.
|
// 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
|
// The schedule is a FLOOR, not a ceiling: this lucky-clear trigger stays, and a bonus duel is
|
||||||
// pure upside for weapon supply.
|
// 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)
|
&& G.phase === 'pretoss' && zombies.length === 1 && zombies[0].variant === 'r' && !zombies[0].dueled)
|
||||||
startDuel();
|
startDuel();
|
||||||
if (G.interlude && G.interlude.introT > 0) G.interlude.introT -= dt;
|
if (G.interlude && G.interlude.introT > 0) G.interlude.introT -= dt;
|
||||||
|
|||||||
@@ -60,6 +60,6 @@
|
|||||||
<p class="start">TAP TO START</p>
|
<p class="start">TAP TO START</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="main.js?v=39"></script>
|
<script type="module" src="main.js?v=40"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -5,10 +5,10 @@
|
|||||||
|
|
||||||
import { TUNE, COURT, UMP_H, UMP_X, EATEN_RESTART } from './config.js';
|
import { TUNE, COURT, UMP_H, UMP_X, EATEN_RESTART } from './config.js';
|
||||||
import { P, O, ball, score, G, setupPoint } from './state.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 { fitViewport, render, proj, RES } from './render.js';
|
||||||
import { voice, ambient } from './audio.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 { cv } from './dom.js';
|
||||||
import './input.js';
|
import './input.js';
|
||||||
|
|
||||||
@@ -58,6 +58,6 @@ cv.addEventListener('pointerdown', e => {
|
|||||||
requestAnimationFrame(frame);
|
requestAnimationFrame(frame);
|
||||||
|
|
||||||
// exposed for in-browser sanity checks and simulated playtests
|
// 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,
|
step: dt => update(dt), render, press: keyPressed,
|
||||||
get phase() { return G.phase; }, set phase(v) { G.phase = v; } };
|
get phase() { return G.phase; }, set phase(v) { G.phase = v; } };
|
||||||
@@ -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 { 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 { P, O, ball, score, crowd, G } from './state.js';
|
||||||
import { predictLanding, flickTarget } from './gameplay.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 ----
|
// ---------------------------------------------------------------- palette ----
|
||||||
// tennis palette -> drained splatter palette; the transition lerps between them
|
// 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_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
|
'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_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_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_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)
|
'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();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
const ZFRAMES = { a: 4, b: 4, f: 4 }; // 4-frame walk cycles where sheets exist; the rest stay 2
|
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) {
|
function drawZombie(zb) {
|
||||||
|
if (zb.variant === 'm') return drawMachine(zb);
|
||||||
const rk = zb.variant === 'r';
|
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;
|
let N = ZFRAMES[v] || 2;
|
||||||
if (!SPR['z_' + v + N]) N = 2; // frames still loading (or sheet rejected): 2-frame fallback
|
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
|
// 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;
|
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
|
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
|
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
|
// 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
|
// 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.
|
// 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
|
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';
|
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);
|
drawStatic(name, zb.x, zb.z, h, Math.floor(zb.walkT * 5.2) % 2);
|
||||||
if (zb.burnT > 0) drawFlames(zb, h);
|
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
|
// 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 —
|
// 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
|
// 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.drawImage(img, -w / 2, -h, w, h);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
} else drawFallbackChar(ch, p, hpx);
|
} 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
|
function drawFallbackChar(ch, p, h) { // shown only until sprites load
|
||||||
const you = ch.side === 'P';
|
const you = ch.side === 'P';
|
||||||
@@ -645,6 +712,17 @@ export function render() {
|
|||||||
// the match ball (and any triple-serve side balls in flight)
|
// 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);
|
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);
|
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)
|
// splatter particles (fire embers ride the same array)
|
||||||
for (const pt of parts) {
|
for (const pt of parts) {
|
||||||
const pp = proj(pt.x, pt.y, pt.z);
|
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
|
// 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.
|
// 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); }
|
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
|
// 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
|
// top/left, dark bottom/right), a purple club spine, cream serif names, and the LIVE game
|
||||||
|
|||||||
@@ -49,6 +49,15 @@ export const G = {
|
|||||||
idleHintT: 0, // s the player has sat in pretoss without touching anything — after a beat the
|
idleHintT: 0, // s the player has sat in pretoss without touching anything — after a beat the
|
||||||
// pull gesture demonstrates itself (render.drawPullHint)
|
// pull gesture demonstrates itself (render.drawPullHint)
|
||||||
meleeT: 0, // s of racket-swish cooldown left
|
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
|
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
|
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
|
horde: null, // active horde wave {t, spawnT, mgT} — machine-gun racket, no serving
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
// your baseline — one crossing it is a point for THEM.
|
// your baseline — one crossing it is a point for THEM.
|
||||||
// (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.)
|
// (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 { G, P, ball, sendBoy, setupPoint } from './state.js';
|
||||||
import { splat, moan, pock, voice, boom, swish, shriek, aww, sfx } from './audio.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 zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
|
||||||
export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer
|
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 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 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
|
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;
|
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 === '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 === '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 === '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);
|
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,
|
// 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
|
// 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.
|
// 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 —
|
// 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.
|
// 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 —
|
// 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.
|
// 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
|
// 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) {
|
function killZombie(i, quiet) {
|
||||||
const zb = zombies[i];
|
const zb = zombies[i];
|
||||||
const mul = GORE_MUL[zb.variant] || 1;
|
const mul = GORE_MUL[zb.variant] || 1;
|
||||||
zombies.splice(i, 1);
|
zombies.splice(i, 1);
|
||||||
if (G.interlude && zb.variant === 'r') resolveInterlude('P'); // regicide ends the duel your way — any weapon counts
|
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
|
const stains = Math.round(Math.sqrt(mul)); // advanced kills splash a scatter of stains, not one puddle
|
||||||
for (let k = 0; k < stains; k++) {
|
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),
|
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();
|
splat();
|
||||||
sfx('headshot-' + (Math.random() < 0.5 ? 1 : 2), 0.6); // random headshot crack layered on the gore burst
|
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
|
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
|
// ---- 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;
|
zb.burnT -= dt;
|
||||||
ignite(zb.x, zb.z, TUNE.burnSpread);
|
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
|
// 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
|
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,
|
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,
|
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
|
// 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.
|
// 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) {
|
function woundBurst(zb) {
|
||||||
for (let k = 0; k < 5; k++) parts.push({
|
for (let k = 0; k < 5; k++) parts.push({
|
||||||
x: zb.x + (Math.random() * 2 - 1) * 0.15, y: 1.4, z: zb.z,
|
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--) {
|
for (let i = zombies.length - 1; i >= 0; i--) {
|
||||||
const zb = zombies[i];
|
const zb = zombies[i];
|
||||||
if (!zb || zb.variant === 'r') continue; // !zb: killZombie can sweep the court mid-loop
|
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++; }
|
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);
|
if (hit) swish(0.35);
|
||||||
return hit;
|
return hit;
|
||||||
}
|
}
|
||||||
@@ -229,7 +352,7 @@ export function explode(x, z) {
|
|||||||
if (!zb || Math.hypot(zb.x - x, zb.z - z) >= TUNE.bombRadius) continue;
|
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
|
// 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.
|
// 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);
|
else splatter(i);
|
||||||
}
|
}
|
||||||
gore.push({ x, z, r: 2.2, a: 1, scorch: true }); if (gore.length > 40) gore.shift(); // the crater — near-permanent
|
gore.push({ x, z, r: 2.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.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.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;
|
spawnT -= dt;
|
||||||
// The spawn director: games finished sets both crowd size and tempo. Both curves used to hit a
|
// 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
|
// 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
|
// 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.)
|
// walk freely during the beat — was tried and vetoed: he never walks freely during play either.)
|
||||||
const held = !!G.changeover;
|
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--) {
|
for (let i = zombies.length - 1; i >= 0; i--) {
|
||||||
const zb = zombies[i];
|
const zb = zombies[i];
|
||||||
@@ -346,33 +472,34 @@ export function updateZombies(dt) {
|
|||||||
bleed(zb); // open wounds bleed every frame they're standing
|
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 (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
|
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;
|
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)
|
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
|
+ Math.sin(zb.walkT * 2.3) * 0.25 * dt; // shamble wobble — the approach reads organic, not tracked
|
||||||
} else
|
} else
|
||||||
zb.x += Math.sin(zb.walkT * 1.7) * 0.15 * dt; // far half: aimless shamble
|
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
|
// CONTACT IS STILL INSTANT DEATH — settled decision, and the ball machine's chip damage does not
|
||||||
gore.push({ x: P.x, z: P.z, r: 0.9, a: 0.9 });
|
// soften it. Two threats that demand two different responses is the point. The machine ('m') is
|
||||||
for (let k = 0; k < 30; k++) parts.push({
|
// furniture and cannot eat you.
|
||||||
x: P.x, y: 1.1, z: P.z,
|
if (!held && G.mode === 'splatter' && zb.variant !== 'm'
|
||||||
vx: (Math.random() * 2 - 1) * 4.5, vy: 1 + Math.random() * 4, vz: (Math.random() * 2 - 1) * 4.5,
|
&& 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
|
||||||
t: 0.5 + Math.random() * 0.5,
|
killPlayer();
|
||||||
});
|
|
||||||
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
|
|
||||||
}
|
|
||||||
if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) {
|
if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) {
|
||||||
const d = Math.hypot(ball.x - zb.x, ball.z - zb.z);
|
const d = Math.hypot(ball.x - zb.x, ball.z - zb.z);
|
||||||
if (d < hitR(zb) + ballSweep) { // brute and boss are bigger targets
|
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
|
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 (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);
|
if (wound(zb, TUNE.dmgFlame)) splatter(i);
|
||||||
else zb.burnT = TUNE.burnTime;
|
else zb.burnT = TUNE.burnTime;
|
||||||
continue;
|
continue;
|
||||||
@@ -391,7 +518,7 @@ export function updateZombies(dt) {
|
|||||||
ignite(bx, bz, TUNE.burnIgniteR); // ...and everyone standing near the body goes up
|
ignite(bx, bz, TUNE.burnIgniteR); // ...and everyone standing near the body goes up
|
||||||
continue;
|
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) {
|
if ((ball.pow || 0) >= TUNE.zSplatQ) {
|
||||||
const dead = wound(zb, TUNE.dmgBall);
|
const dead = wound(zb, TUNE.dmgBall);
|
||||||
pock(0.6, 300);
|
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
|
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
|
// 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 (zb.variant === 'g' || zb.variant === 'm') { // brute AND the bolted-down machine: soak
|
||||||
if ((ball.pow || 0) >= TUNE.zSplatQ) {
|
if ((ball.pow || 0) >= TUNE.zSplatQ) { // splat-quality hits, never stagger back
|
||||||
zb.hp--; splat(0.5); pock(0.6, 220);
|
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';
|
ball.vx *= 0.3; ball.vz *= 0.3; ball.lastHit = 'X';
|
||||||
if (zb.hp <= 0) { splatter(i); continue; } // timber
|
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
|
} 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
|
// 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
|
// 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.
|
// 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 if (zb.hp > 1) { zb.hp--; pock(0.5, 260); } // brutes AND shamblers soak pellets — wound, don't kill
|
||||||
else killZombie(j, true);
|
else killZombie(j, true);
|
||||||
hit = true; break;
|
hit = true; break;
|
||||||
@@ -455,6 +582,29 @@ export function updateZombies(dt) {
|
|||||||
if (hit || pe.t <= 0) pellets.splice(i, 1);
|
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
|
for (let i = extras.length - 1; i >= 0; i--) { // triple-serve side balls: full ballistic clones
|
||||||
const e = extras[i];
|
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
|
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
|
+ 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
|
// 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.
|
// 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); }
|
if (e.pow >= TUNE.zSplatQ) { if (wound(zb, TUNE.dmgSide)) killZombie(j, !!G.horde); }
|
||||||
else pock(0.5, 300);
|
else pock(0.5, 300);
|
||||||
}
|
}
|
||||||
|
|||||||