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
|
||||
detail of any individual change, read the git log.
|
||||
|
||||
Last updated 2026-07-30, mid-way through the **late-game depth work** after Oliver's 50+ game
|
||||
playtest. Steps 1-3 of that plan are DONE; **steps 4 and 5 are the next job and are specified in
|
||||
full at the bottom of this file** — start there. Everything from the earlier retest rounds is closed
|
||||
and recorded below it.
|
||||
Last updated 2026-07-30. The **late-game depth work is complete**: steps 1-5 of the plan agreed after
|
||||
Oliver's 50+ game playtest are all built and verified in-browser. Step 6 (crowd turning, ball-boy
|
||||
catcher) is PARKED by his decision. **Awaiting his phone playtest of the boss/ranged build.**
|
||||
What is still owed is art, not mechanics — see "Art still owed" at the bottom.
|
||||
|
||||
## What this is
|
||||
SNES-style pseudo-3D tennis (Super Tennis reference) that transitions into a zombie splatter game
|
||||
@@ -41,8 +41,11 @@ Tennis scoring keeps running. `TUNE.killsPerPoint` kills = one point; games/sets
|
||||
6 games = a set = a **horde** (racket auto-fires, finger steers you absolutely).
|
||||
Odd games end in a **changeover** breather. A won game leaves the wave standing — you must
|
||||
**clear it by hand** (`G.clearing`) before play advances (`proceedAfterClear`).
|
||||
Every **10 games a CHAPTER BOSS** walks on (`G.bossFight`) — it clears the stage, blocks all spawns,
|
||||
and volleys at you. Every 4 games a **rogue ball machine** is wheeled out. Both attack the second
|
||||
damage channel: `shots[]` chip `G.hp`, which only a won duel heals. Contact is still instant death.
|
||||
Weapons are **only ever won**, in the **sudden-death duel** (`G.interlude`) against the racket
|
||||
zombie. Getting caught = `G.mode='eaten'` = permadeath, and the run summary is the whole game.
|
||||
zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixture he used to be). Getting caught = `G.mode='eaten'` = permadeath, and the run summary is the whole game.
|
||||
|
||||
## Architecture & invariants — know these before touching anything
|
||||
|
||||
@@ -89,6 +92,36 @@ zombie. Getting caught = `G.mode='eaten'` = permadeath, and the run summary is t
|
||||
know what it meant. The wound skins already encode the same information in the language every other
|
||||
multi-hit enemy uses. A one-per-run modal (`G.seen.racket`, fired from `spawnRacketZombie`) explains
|
||||
what he *is* instead. Do not re-add a bar; give a new boss more wound stages.
|
||||
- **There are TWO damage channels and they must never be merged.** Contact with a body is still
|
||||
instant permadeath (`killPlayer`, `G.mode='eaten'`); ranged attacks chip `G.hp` (`hurtPlayer`).
|
||||
Oliver settled this explicitly — two threats that demand two different responses is the depth, and
|
||||
making contact chip instead of kill would lose the dread the whole game runs on. `killPlayer()` is
|
||||
the single death path for BOTH causes (extracted for exactly that reason: the run summary, the
|
||||
best-run localStorage write and the death animation exist once). `TUNE.hurtGrace` is what stops a
|
||||
three-shot volley deleting you in one frame.
|
||||
- **The player's health is drawn ON HIM and nowhere else.** No bar, no meter, no HUD number — same
|
||||
rule that removed the boss's bar. `drawPlayerWounds` paints procedural spatter at FIXED anchors
|
||||
(fixed so it cannot crawl frame to frame), `bleedPlayer()` adds the live trickle, and `G.hurtFlash`
|
||||
washes the screen edges inward on impact. Blood is never painted into the player sprite: that is a
|
||||
settled feel decision, which is why this is an overlay and not a set of bloodied-kit PNGs.
|
||||
- **`shots[]` in zombies.js is the HOSTILE ball array** — the ball machine's shots and the chapter
|
||||
boss's volleys. Deliberately separate from `ball` (there is only ever one of those and it is yours)
|
||||
and from `extras` (also yours, and they kill zombies). Three outcomes, in this order: a swing inside
|
||||
the volley window RETURNS it — as an `extras` clone at `TUNE.parryPow`, so "a parry is a real
|
||||
weapon" costs nothing to build; `meleeSwipe` merely bats it away; reaching you unanswered costs hp.
|
||||
**The swish path is not optional politeness** — in `pretoss` the player is rooted and cannot swing
|
||||
at all, so without it a machine winding up while you lined up a serve was an unanswerable hit.
|
||||
- **`isBoss(zb)`** (`'r'` or `'B'`) marks bodies that take `wound()` damage from everything instead of
|
||||
being deleted by it. The brute `'g'` is deliberately NOT one: melee/bomb/fire one-shotting a brute
|
||||
is a settled "power exception". Every weapon path checks it — miss one and that weapon silently
|
||||
no-ops or one-shots a 34 hp boss. This has already been the bug twice.
|
||||
- **The chapter boss scores NOTHING when it dies.** Its reward is the chapter card plus the parked
|
||||
game beat resuming. Letting it bank a kill meant `splatPoint`'s game-over branch and
|
||||
`endBossFight` both decided "what happens next" in the same frame and clobbered each other.
|
||||
- **`blocksClear`** decides which bodies gate the hand-clear after a won game: NOT the racket zombie
|
||||
(it keeps its residence) and NOT the ball machine (furniture — a standing threat you deal with when
|
||||
you choose). Making the machine gate progression turned "decide what to prioritise" into a chore
|
||||
every four games.
|
||||
- **`UI` in config.js is the accent palette** (hot/warm/pick/cream/dim/bad/ball). Every HUD colour
|
||||
goes through it. Gore reds are deliberately NOT in it — the splatter stays loud.
|
||||
- **`F(size, bold)` in render.js builds every canvas font string.** Bare `monospace` resolves to
|
||||
@@ -231,97 +264,83 @@ Known generator quirks, all still true:
|
||||
|
||||
---
|
||||
|
||||
# NEXT UP — late-game depth (agreed with Oliver 2026-07-30)
|
||||
# ART STILL OWED (mechanics are done)
|
||||
|
||||
He has played 50+ games. Verdict: **nothing new happens after about game 30.** The measured cause is
|
||||
in the invariants above — every difficulty dial bottomed out by game 12. Steps 1-3 removed the flat
|
||||
line; **steps 4 and 5 are the actual content and are NOT built yet.** Step 6 is parked by his
|
||||
decision. Read this whole section before starting: the decisions in it were argued out and settled,
|
||||
and re-litigating them wastes his time.
|
||||
The only outstanding work on the boss/ranged build is art. Everything below is playable now.
|
||||
|
||||
## Settled design decisions (do not reopen)
|
||||
- **The chapter boss has no B-frames for its wound stages.** `assets/_sheet_boss.png` is a 5-frame
|
||||
sheet: `z_B1`/`z_B2` (a real 2-frame stride, driven off `zb.z / TUNE.bruteStride` per the
|
||||
distance-not-time invariant) plus three static wound skins `z_Bw1` (arm torn off) / `z_Bw2` (ribs
|
||||
out) / `z_Bw3` (headless, still throwing). The brute precedent has an A **and** B frame per wound
|
||||
stage (`z_gw` + `z_gwb`, etc.), so a wounded boss currently stops striding mid-fight. Fix by
|
||||
generating a second sheet of the three wound poses with the stride swapped, named `z_Bw1b`..`z_Bw3b`,
|
||||
and extending drawZombie's stage pick the way the brute's does.
|
||||
**Generating it: the model laid the first sheet out in TWO ROWS, which `_slice.py` cannot cut (it
|
||||
segments on column gaps).** Don't fight the prompt — detect the blobs and re-flow them into one
|
||||
baseline-aligned row before slicing. The reflow script is in this session's git history.
|
||||
- **The ball machine is drawn procedurally** (`drawMachine` in render.js) — a hopper, a barrel and two
|
||||
wheels, with the muzzle glowing hotter through the wind-up. That was a choice, not a stopgap: it is
|
||||
furniture, a box is four rects, and the readable parts are the barrel and the charge. Replace with a
|
||||
sheet only if it reads as a crate on a phone.
|
||||
- **Chapter cards reuse `drawCard`,** which still cuts in with no entrance while modals now swoosh
|
||||
(`TUNE.modalIn`). Giving the card a matching entrance was offered and not yet asked for.
|
||||
|
||||
- **Contact still kills outright. This was explicitly decided.** Player HP is a SEPARATE channel that
|
||||
only ranged attacks touch. A shambler reaching you is still `G.mode = 'eaten'`, still permadeath,
|
||||
still the whole run over. Oliver: *"keep the instant death separate"*. The reason: two threats that
|
||||
demand two different responses is the depth; making contact chip instead of kill would make the game
|
||||
easier and lose the dread that carries it.
|
||||
- **Player health is his SHIRT, not a bar.** Tennis whites going progressively bloodier, plus
|
||||
`bleed()` on the player once wounded. No HUD box, no meter, no number — same reasoning that removed
|
||||
the boss's HP bar (see invariants). This is the aesthetic rule for this game: state is expressed in
|
||||
the art or in tennis furniture, never in floating UI.
|
||||
- **Healing happens by winning a sudden-death duel.** That finally gives the duel a reason to be
|
||||
WANTED rather than just being a weapon vending machine.
|
||||
- **Step 6 is parked.** The crowd turning into zombies and the ball-boy-zombie-who-catches-your-balls
|
||||
were both discussed; Oliver: *"for 6 that's my least favourite one and we should park it"*. Do not
|
||||
build them. They are recorded at the bottom only so the idea isn't lost.
|
||||
# CLOSED — chapter bosses + ranged attacks, steps 4-5 (2026-07-30)
|
||||
|
||||
## Step 4 — ranged attacks and player damage (NOT BUILT)
|
||||
## Step 4 — the ranged channel
|
||||
|
||||
The point of this step: it is the only idea on the list that adds a **new verb**. Every other option
|
||||
adds more of what the player already does. The player's whole vocabulary today is pull-aim-fire,
|
||||
panic-swish, pick a tin, walk during a rally.
|
||||
**The ball machine (`'m'`)**, Oliver's own idea and the most on-brand thing on the list, because a ball
|
||||
machine is real tennis furniture. It never walks, so it never crosses your baseline and never scores:
|
||||
it is a standing threat you choose when to deal with, which is the target-priority decision the game
|
||||
was missing. Measured: 3.4 s cycle, **0.9 s telegraph** (`TUNE.machWind`, muzzle glow + a mechanical
|
||||
click — the same fairness contract the runner's `shriek()` is), 3 hp, exempt from the clear gate.
|
||||
|
||||
**The cheap insight to build on: a thrown projectile IS just a ball hit by the other side.**
|
||||
`trySwing` already implements "an incoming ball, a generous volley window, time it or eat it", and
|
||||
`VOLLEY` already widens reach ×1.4 / window ×1.8 for a pre-bounce contact. A thrower is therefore
|
||||
much closer to "a racket zombie that serves at you" than to a new system. Reuse that path rather than
|
||||
writing a projectile-dodging mechanic from scratch.
|
||||
**The projectile is a real ARC**, solved like a serve rather than fired flat, so it reads coming in and
|
||||
so the swing window has something to meet. `fireAtPlayer` aims where you *stand*, which means moving
|
||||
is a partial answer during a rally — and is exactly why the swish path exists for `pretoss`, where you
|
||||
cannot move or swing.
|
||||
|
||||
Deliver it as the **rogue ball machine** — Oliver's own idea and the most on-brand thing in the list,
|
||||
because a ball machine is *real tennis furniture*. Suggested shape:
|
||||
- A new variant (`'m'`?) that does NOT shamble: it parks on the far court and fires at you on a cycle.
|
||||
- It must **telegraph**: a wind-up pose plus a sound, far enough ahead that a phone player with one
|
||||
thumb can react. Reuse the `shriek()` fairness-contract pattern the runner already uses.
|
||||
- Returning the shot is a normal swing/volley. Missing it costs one point of player HP and starts the
|
||||
bleed. It must never be an instant kill — that is what contact is for.
|
||||
- Because it doesn't walk, it never reaches your baseline, so it can't score for THEM by crossing —
|
||||
it has to be a standing threat you choose when to deal with. That makes it a target-priority
|
||||
decision, which is new.
|
||||
**Player damage.** 4 hp, `hurtGrace` 1.1 s, +2 on a won duel (the only healing in the game, and now
|
||||
THE reason to want a duel rather than tolerate it). Verified: 4 hits kill you and land in the same
|
||||
`eaten` path as being caught, with `eatenAt`, the death animation and the best-run write all intact;
|
||||
three hits in one frame cost only one point.
|
||||
|
||||
Player-state plumbing this needs (none of it exists yet):
|
||||
- `G.hp` / `G.hpMax` on the `G` object in state.js, reset per run.
|
||||
- The bloodied-shirt skins: new `p_*` sprites per wound stage, resolved in `drawChar` exactly the way
|
||||
`drawZombie` resolves boss skins — **as a fraction of hpMax, not against literal hp numbers**
|
||||
(see invariants; this trap has already been hit once).
|
||||
- `bleed()` currently takes a zombie; it will need to work for the player, or get a sibling.
|
||||
- Healing in `resolveInterlude('P')`, alongside `deliverTin()`.
|
||||
- The eaten/run-summary path must still work when death comes from HP reaching zero rather than from
|
||||
contact — check `G.eatenAt`, `playerGore()`, and the `localStorage` best-run write.
|
||||
## Step 5 — chapter bosses, and the racket zombie demoted
|
||||
|
||||
## Step 5 — chapter bosses every 10 games (NOT BUILT — Oliver's favourite)
|
||||
**Demoted, as Oliver asked.** The racket zombie used to respawn on *every* game you won, which is
|
||||
precisely why he read as "the boss" and why a real boss could not have stood out next to him. He is an
|
||||
occasional opponent now (`TUNE.racketEvery` = 3 games).
|
||||
|
||||
*"my favourite - we need spectacular new sprites for the bosses and his wounds"*
|
||||
**The chapter boss (`'B'`)** arrives every `TUNE.bossEvery` = 10 games, fired from `proceedAfterClear`
|
||||
— the one beat where the court is guaranteed empty — and it OUTRANKS the set/horde and duel beats
|
||||
there, because a boss walking on into a standing wave would be unreadable. It clears the stage,
|
||||
blocks all other spawns while it lives, and threatens both channels: volleys of `bossVolley` = 3
|
||||
chip your hp, and letting it reach you is the ordinary instant death. Chapter 1 is 10 hp and 2.7 m;
|
||||
chapter 4 is 34 hp and 3.67 m.
|
||||
|
||||
Why this is worth the most: it gives the run **chapters**, and it fixes a real economy problem —
|
||||
tins arrive every 3 games forever (`duelFirst` 2, `duelEvery` 3), so there is currently no reason to
|
||||
bank anything. A rare wall you must save weapons for is what turns the rack into a decision.
|
||||
**The economy fix this was really for:** tins arrive every 3 games forever, so nothing was ever worth
|
||||
banking. Measured cost of a chapter-4 boss — 12 bombs, or 100 machine-gun rounds, or 34 side balls.
|
||||
`TUNE.wCap` is 9 per type, so **no single weapon can kill a late boss** and a mixed, banked arsenal is
|
||||
mandatory. That is the strategic pressure Oliver was asking for.
|
||||
|
||||
- Every 10 games. Escalating each time: more hp, bigger, more wound stages, and — once step 4 exists
|
||||
— it uses ranged attacks too, so escalation becomes numbers rather than new mechanics.
|
||||
- **The damage model is already ready for this.** `wound()` + `hitR()` + the `TUNE.dmg*` table mean a
|
||||
boss is just a bigger `hp` number and every weapon stays proportional. Verified: a 9 hp body takes
|
||||
3 off per bomb, ~3 pellets per point, 1 per side ball. Do not add per-boss damage special cases.
|
||||
- **Bigger is free**: the brute already renders at 2.7 m against a shambler's 1.8 m, so sprite scale
|
||||
is an existing, proven lever.
|
||||
- **The always-present racket zombie must stop being "the boss" first**, or a real boss will not read
|
||||
as special. Today `splatPoint` respawns one every single game you win. Decide: does the mini-boss
|
||||
become rarer, or get renamed/re-skinned as an ordinary "opponent" while the chapter boss is a
|
||||
distinct thing?
|
||||
- Sprite work is the bulk of this step and it is Oliver's stated priority. Follow the asset pipeline
|
||||
section above exactly: `generate-image` in EDIT mode with the existing sheet as `--input` is what
|
||||
makes a new sheet match, magenta background, then `_slice.py` (frames) or `_key.py` (standalone).
|
||||
Wound stages must be generated as a SET so they colour-match — and remember a wound stage's height
|
||||
must always come from its A frame or it pops.
|
||||
`G.bossDue` never moves backwards (`Math.max`): going a long stretch without ever clearing the court
|
||||
skips a chapter rather than queueing a backlog. Deliberate — you face the boss the run has earned.
|
||||
|
||||
## Parked (step 6) — do not build without asking
|
||||
## Bugs found and fixed while building this (all were silent)
|
||||
|
||||
- **The crowd turning.** Mechanically it is "spawn from the stands instead of the far baseline", i.e.
|
||||
attacks arriving from behind and beside you, which breaks the one assumption the whole game rests
|
||||
on. Highest spectacle-per-line available, and `x_crowd`/`x_crowd2` already exist. Oliver's least
|
||||
favourite; parked.
|
||||
- **The ball-boy zombie that catches your balls.** Denies your best lane, forcing target priority.
|
||||
Parked with the above.
|
||||
- A blast **deleted** a 34 hp chapter boss outright: `explode` only wounded `'r'`. This is what
|
||||
`isBoss()` now exists to prevent, and it is the second time the same class of bug has shipped.
|
||||
- The machine's `machHp` did nothing — `'m'` fell through every variant branch to the generic
|
||||
one-shot fallback. It soaks like the brute now (3 balls, verified).
|
||||
- The boss both scored a kill and fired its chapter card, so two code paths set "what happens next"
|
||||
in the same frame.
|
||||
- The machine gated the hand-clear, turning a priority decision into a chore.
|
||||
- A boss death produced a shambler-sized gore burst (`GORE_MUL` had no `'B'`; now 6 → ~305 particles).
|
||||
|
||||
## Regression net after steps 4-5
|
||||
30 tapped tennis points = **50 hits / 0 whiffs** (unchanged — tennis is untouched by any of this).
|
||||
45-game splatter soak: no exceptions, **all four chapters fired [1,2,3,4]**, machines appeared, ranged
|
||||
hits landed, `mgT` drains, gore 40 / piles 30 / bodies capped at 34.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
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
|
||||
// for ~120 spawns where the old fixed 12 s x 2.5/s could only ever ask for 30,
|
||||
// and a 40-body court on a 270px-wide portrait screen is mush, not menace.
|
||||
// ---- PLAYER HEALTH. A SECOND, SEPARATE damage channel: only ranged attacks touch it. A shambler
|
||||
// reaching you is still instant permadeath (G.mode='eaten') — that decision is settled, see HANDOFF.
|
||||
// There is no HP bar and there never will be: the state shows as blood on the man.
|
||||
playerHp: 4, // hits from ranged attacks you can absorb before you go down
|
||||
hurtGrace: 1.1, // s of invulnerability after a hit — a burst must not delete you in one volley
|
||||
healPerDuel: 2, // hp back for winning a sudden-death duel. THE reason to want the duel.
|
||||
// ---- the rogue ball machine ('m'): real tennis furniture that shoots at you. It never shambles,
|
||||
// so it never crosses your baseline and never scores — it is a standing threat you choose when to
|
||||
// deal with, which is the target-priority decision the game was missing.
|
||||
machEvery: 4, // games before the first machine can appear, and games between them after
|
||||
machMax: 2, // machines allowed on court at once
|
||||
machHp: 3, // splat-quality hits to wreck one
|
||||
machCycle: 3.4, // s between its shots
|
||||
machWind: 0.9, // s of TELEGRAPH before each shot — the fairness contract, same idea as shriek()
|
||||
machSpeed: 13, // m/s it fires at
|
||||
machDmg: 1, // player hp a shot costs if you don't return it
|
||||
parryPow: 0.85, // shot quality of a RETURNED machine ball — a parry is a real weapon
|
||||
// ---- chapter bosses: one every bossEvery games, escalating. The racket zombie is demoted to an
|
||||
// occasional opponent (racketEvery) so the boss is the only thing that reads as a wall.
|
||||
bossEvery: 10, // games between chapter bosses
|
||||
bossHp: 10, // hp of the FIRST one; each later chapter adds bossHpPerChapter
|
||||
bossHpPerChapter: 8,
|
||||
bossScale: 1.5, // body height multiplier at chapter 1 (a shambler is 1.8 m)
|
||||
bossScalePer: 0.18, // ...growing per chapter, so chapter 4 towers
|
||||
bossSpeed: 0.42, // m/s it advances — slow, deliberate, unstoppable
|
||||
bossCycle: 2.6, // s between its ranged attacks (it uses the machine's projectile)
|
||||
bossVolley: 3, // shots per attack, fanned
|
||||
racketEvery: 3, // games between racket-zombie (plain opponent) spawns — was EVERY won game, which
|
||||
// is why he read as "the boss" and why a real boss would not have stood out
|
||||
racketHpPerGame: 0.12, // the racket zombie stiffens as the run goes on (floored into whole hp)
|
||||
// ---- damage. Bodies with hp (racket boss 'r', brute 'g') take damage in these units, so a big
|
||||
// boss is just a bigger hp number and every weapon stays proportional. Fractions are legal: they
|
||||
|
||||
@@ -5,7 +5,7 @@ import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } fr
|
||||
import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck } from './audio.js';
|
||||
import { axisX, axisZ, takeFlickAim, touchPX } from './input.js';
|
||||
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
|
||||
import { updateZombies, spawnZombie, spawnRacketZombie, zombies, gore, explode, meleeSwipe, extras, parts, pellets, playerGore } from './zombies.js';
|
||||
import { updateZombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore } from './zombies.js';
|
||||
|
||||
// ------------------------------------------------------------- ballistics ----
|
||||
// Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g.
|
||||
@@ -284,6 +284,11 @@ function pointTo(side, reason) {
|
||||
// quiet (during a horde) rate-limits the umpire so he doesn't call every rapid-fire point.
|
||||
// The scoreboard IS the progression: games drive spawn pressure, 6 games = a set = a horde,
|
||||
// odd games end in a changeover breather.
|
||||
// Which bodies gate the hand-clear after a won game. The racket zombie keeps its residence, and the
|
||||
// ball machine is furniture — a standing threat you deal with WHEN YOU CHOOSE. Making either block
|
||||
// progression would turn "decide what to prioritise" into "clear the chore list".
|
||||
const blocksClear = z => z.variant !== 'r' && z.variant !== 'm';
|
||||
|
||||
export function splatPoint(side, quiet = false) {
|
||||
if (side === 'P' && ++G.killCount < TUNE.killsPerPoint) return; // K kills bank one point; the rest just splatter
|
||||
if (side === 'P') G.killCount = 0;
|
||||
@@ -295,6 +300,13 @@ export function splatPoint(side, quiet = false) {
|
||||
if (G.runGames === TUNE.duelFirst ||
|
||||
(G.runGames > TUNE.duelFirst && (G.runGames - TUNE.duelFirst) % TUNE.duelEvery === 0))
|
||||
G.duelDue = true;
|
||||
// ...and every bossEvery games the schedule calls a CHAPTER BOSS. Like the duel it fires from
|
||||
// proceedAfterClear(), the one beat where the court is guaranteed empty, and it outranks
|
||||
// everything else there — a boss must not walk on into a standing wave.
|
||||
if (G.runGames % TUNE.bossEvery === 0)
|
||||
G.bossDue = Math.max(G.bossDue, Math.floor(G.runGames / TUNE.bossEvery)); // never backwards; a
|
||||
// long stretch with no clear skips a chapter rather than queueing one, which is intended
|
||||
|
||||
voice(side === 'P' ? 'game_set_massacre' : 'game_opponent', 0.5); // game-win lines always speak
|
||||
if (side === 'P') cheer(2.2, 0.5); else aww(0.85); // your game = applause, theirs = a groan
|
||||
const i = side === 'P' ? 0 : 1;
|
||||
@@ -302,12 +314,20 @@ export function splatPoint(side, quiet = false) {
|
||||
else if ((score.games[0] + score.games[1]) % 2 === 1) G.afterClear = 'changeover'; // real rule: after odd games
|
||||
else G.afterClear = 'game'; // even game: a beat, then the next wave
|
||||
G.clearWinner = side;
|
||||
if (side === 'P' && !zombies.some(z => z.variant === 'r'))
|
||||
spawnRacketZombie(); // every game you win, an opponent takes the far court (rally it, or splat it)
|
||||
// The racket zombie is DEMOTED: he used to appear on every single game you won, which is exactly
|
||||
// why he read as "the boss" and why a real boss would not have stood out next to him. He is an
|
||||
// occasional opponent now — every racketEvery games — and the chapter boss is the wall.
|
||||
if (side === 'P' && G.runGames % TUNE.racketEvery === 0 && !zombies.some(z => z.variant === 'r')
|
||||
&& !G.bossFight)
|
||||
spawnRacketZombie();
|
||||
// the rogue ball machine gets wheeled out on the same kind of schedule, capped on court
|
||||
if (side === 'P' && G.runGames >= TUNE.machEvery && G.runGames % TUNE.machEvery === 0
|
||||
&& zombies.filter(z => z.variant === 'm').length < TUNE.machMax && !G.bossFight)
|
||||
spawnMachine();
|
||||
// The wave does NOT auto-collapse anymore: the survivors stay and must be cleared BY HAND before
|
||||
// play advances — only no NEW shamblers spawn. When the last one is down, proceedAfterClear()
|
||||
// fires (see update()). If the winning shot already emptied the court, proceed at once.
|
||||
if (zombies.some(z => z.variant !== 'r')) {
|
||||
if (zombies.some(blocksClear)) {
|
||||
G.clearing = true; ball.active = false;
|
||||
G.flash = { text: 'CLEAR THE COURT', t: 1.8 };
|
||||
G.phase = 'over'; G.overT = TUNE.gameBeatT; // a short beat to register the game, then the serve loop resumes for the mop-up
|
||||
@@ -326,7 +346,10 @@ export function proceedAfterClear() {
|
||||
G.afterClear = null;
|
||||
// A clear is the ONE moment the court is guaranteed empty — the natural slot for the scheduled
|
||||
// duel. (A wall-clock timer would need its own "wait for a safe moment" and could fire mid-swarm.)
|
||||
// The set/horde beat outranks it; the duel just waits for the next clear.
|
||||
// The chapter boss outranks everything here, including a set: a boss walking on into a standing
|
||||
// wave (or into a horde) would be unreadable. Like the duel it parks the interrupted beat.
|
||||
if (G.bossDue) { const c = G.bossDue; G.bossDue = 0; G.afterDuel = mode || 'game'; startBossFight(c); return; }
|
||||
// The set/horde beat outranks the duel; the duel just waits for the next clear.
|
||||
if (mode !== 'set' && G.duelDue) { G.duelDue = false; G.afterDuel = mode || 'game'; startDuel(); return; }
|
||||
if (mode === 'set') {
|
||||
G.card = { title: 'SET ' + (G.sets[0] + G.sets[1]), // the set just banked (sets already ++'d at game over)
|
||||
@@ -408,6 +431,45 @@ export function startDuel() {
|
||||
} else G.flash = { text: 'SUDDEN DEATH', t: 1.6 };
|
||||
}
|
||||
|
||||
// ---- the chapter boss: every TUNE.bossEvery games, the court is cleared and ONE thing walks on.
|
||||
// No other spawns until it is down, so the fight is legible and so banking a bomb for it is a real
|
||||
// decision (which is the point — tins arrive every 3 games, so there was never a reason to save one).
|
||||
// It threatens BOTH channels: its volleys chip your hp, and letting it reach you is the ordinary
|
||||
// instant death. G.bossFight holds the chapter number while it runs.
|
||||
export function startBossFight(chapter) {
|
||||
if (G.bossFight) return;
|
||||
G.bossFight = chapter;
|
||||
zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // a clean stage
|
||||
G.horde = null; G.mgT = 0; G.clearing = false;
|
||||
ball.active = false; setupPoint();
|
||||
spawnChapterBoss(chapter);
|
||||
zombieDuck(true); // the music drops out for it
|
||||
voice('quiet_please', 0, 0.8); voice('sudden_death', 1.6, 0.85);
|
||||
G.card = { title: 'CHAPTER ' + chapter, sub1: 'SOMETHING BIG IS COMING', sub2: 'IT THROWS. RETURN IT.',
|
||||
color: UI.bad, t: TUNE.setCardT };
|
||||
G.flash = null;
|
||||
if (!G.seen.boss) { // the rules change: a wall, and a thing that shoots back
|
||||
G.seen.boss = true;
|
||||
showModal({ title: 'IT BRINGS A MACHINE', color: UI.bad,
|
||||
body: ['IT THROWS BALLS AT YOU', 'SWING TO RETURN THEM — OR SWISH TO BAT THEM AWAY',
|
||||
'A HIT COSTS BLOOD. WIN A DUEL TO HEAL.'] });
|
||||
}
|
||||
}
|
||||
|
||||
// the chapter boss is down: the parked game beat resumes and normal spawning comes back
|
||||
export function endBossFight() {
|
||||
if (!G.bossFight) return;
|
||||
const c = G.bossFight; G.bossFight = 0;
|
||||
zombieDuck(false);
|
||||
shots.length = 0;
|
||||
cheer(3.4, 0.9); voice('unbelievable', 0.3);
|
||||
G.card = { title: 'CHAPTER ' + c + ' DOWN', sub1: 'THE STANDS ARE ON THEIR FEET',
|
||||
sub2: 'GAME ' + (G.runGames + 1) + ' NEXT', color: UI.pick, t: TUNE.setCardT };
|
||||
ball.active = false; G.phase = 'over'; G.overT = TUNE.setCardT;
|
||||
// hand back the game beat the boss interrupted — same contract resolveInterlude uses
|
||||
if (G.afterDuel) { G.afterClear = G.afterDuel; G.afterDuel = null; proceedAfterClear(); }
|
||||
}
|
||||
|
||||
export function resolveInterlude(side) {
|
||||
if (!G.interlude) return;
|
||||
G.interlude = false;
|
||||
@@ -415,6 +477,12 @@ export function resolveInterlude(side) {
|
||||
ball.active = false; G.phase = 'over'; G.overT = 2.2;
|
||||
if (side === 'P') {
|
||||
cheer(3, 0.7);
|
||||
// winning the duel patches you up. THE reason to want a duel rather than merely tolerate it —
|
||||
// it is the only healing in the game, and blood is the only thing you cannot buy back otherwise.
|
||||
if (G.hp < G.hpMax) {
|
||||
G.hp = Math.min(G.hpMax, G.hp + TUNE.healPerDuel);
|
||||
G.flash = { text: 'PATCHED UP', t: 1.4 };
|
||||
}
|
||||
deliverTin(); // win the point, win the next tin — on the spot
|
||||
} else {
|
||||
voice('game_opponent', 0.4); aww(0.85); // lost the sudden-death duel
|
||||
@@ -904,14 +972,14 @@ export function update(dt) {
|
||||
// --- zombies + transition timeline
|
||||
if (G.mode !== 'tennis') updateZombies(dt);
|
||||
if (G.meleeT > 0) G.meleeT -= dt;
|
||||
if (G.clearing && !zombies.some(z => z.variant !== 'r')) proceedAfterClear(); // the won-game wave is mopped up — advance
|
||||
if (G.clearing && !zombies.some(blocksClear)) proceedAfterClear(); // the won-game wave is mopped up — advance
|
||||
if (G.mode === 'transition') transition(dt);
|
||||
|
||||
// --- interlude trigger: the court is clear, an opponent holds the far court, you're about
|
||||
// to serve — the umpire recognizes a legal match and restores order. One point. Sudden death.
|
||||
// The schedule is a FLOOR, not a ceiling: this lucky-clear trigger stays, and a bonus duel is
|
||||
// pure upside for weapon supply.
|
||||
if (G.mode === 'splatter' && !G.interlude && !G.horde && !G.changeover && !(G.mgT > 0)
|
||||
if (G.mode === 'splatter' && !G.interlude && !G.horde && !G.changeover && !(G.mgT > 0) && !G.bossFight
|
||||
&& G.phase === 'pretoss' && zombies.length === 1 && zombies[0].variant === 'r' && !zombies[0].dueled)
|
||||
startDuel();
|
||||
if (G.interlude && G.interlude.introT > 0) G.interlude.introT -= dt;
|
||||
|
||||
@@ -60,6 +60,6 @@
|
||||
<p class="start">TAP TO START</p>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="main.js?v=39"></script>
|
||||
<script type="module" src="main.js?v=40"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,10 +5,10 @@
|
||||
|
||||
import { TUNE, COURT, UMP_H, UMP_X, EATEN_RESTART } from './config.js';
|
||||
import { P, O, ball, score, G, setupPoint } from './state.js';
|
||||
import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget } from './gameplay.js';
|
||||
import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight } from './gameplay.js';
|
||||
import { fitViewport, render, proj, RES } from './render.js';
|
||||
import { voice, ambient } from './audio.js';
|
||||
import { zombies, spawnZombie, piles, gore, parts, pellets, extras, explode } from './zombies.js';
|
||||
import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, explode, hurtPlayer, killPlayer } from './zombies.js';
|
||||
import { cv } from './dom.js';
|
||||
import './input.js';
|
||||
|
||||
@@ -58,6 +58,6 @@ cv.addEventListener('pointerdown', e => {
|
||||
requestAnimationFrame(frame);
|
||||
|
||||
// exposed for in-browser sanity checks and simulated playtests
|
||||
window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, piles, gore, parts, pellets, extras, explode, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget,
|
||||
window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight,
|
||||
step: dt => update(dt), render, press: keyPressed,
|
||||
get phase() { return G.phase; }, set phase(v) { G.phase = v; } };
|
||||
@@ -4,7 +4,7 @@ import { cv, ctx } from './dom.js';
|
||||
import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, PLAYER_H, BOY_H, UMP_H, UMP_X, BOUNCE_IDLE, SPRITE_H } from './config.js';
|
||||
import { P, O, ball, score, crowd, G } from './state.js';
|
||||
import { predictLanding, flickTarget } from './gameplay.js';
|
||||
import { zombies, gore, piles, parts, pellets, extras } from './zombies.js';
|
||||
import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js';
|
||||
|
||||
// ---------------------------------------------------------------- palette ----
|
||||
// tennis palette -> drained splatter palette; the transition lerps between them
|
||||
@@ -79,6 +79,7 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','
|
||||
'x_boymop1','x_boymop2','x_boymop3','x_boymop4', // the push cycle, used only while he is stopped and working
|
||||
'x_boycarry1','x_boycarry2','x_boycarry3','x_boycarry4','x_howto', // walking with the mop shouldered, and the slingshot tutorial panel
|
||||
'z_gw2','z_gw3','z_gw4','z_gwb','z_gw2b','z_gw3b','z_gw4b', // the brute comes apart in stages (4/5 hp = z_gw .. 1/5 = z_gw4), each with a 2-frame lurch (…b)
|
||||
'z_B1','z_B2','z_Bw1','z_Bw2','z_Bw3', // the CHAPTER BOSS: 2-frame stride + three wound stages (arm off -> ribs out -> headless)
|
||||
'z_a1','z_a2','z_a3','z_a4','z_b1','z_b2','z_b3','z_b4','z_c1','z_c2',
|
||||
'z_f1','z_f2','z_f3','z_f4','z_g1','z_g2','z_gw','z_s1','z_s2','z_s3',
|
||||
'z_cw1','z_cw2','z_aw','z_bw', // wounded skins for multi-hit enemies (racket boss, brute, and now the classic shamblers)
|
||||
@@ -164,15 +165,41 @@ function drawBoy(b) {
|
||||
ctx.restore();
|
||||
}
|
||||
const ZFRAMES = { a: 4, b: 4, f: 4 }; // 4-frame walk cycles where sheets exist; the rest stay 2
|
||||
// The rogue ball machine: real tennis furniture, so it is drawn as furniture — a squat hopper on
|
||||
// wheels with a barrel pointed down-court. Procedural like drawFlames, and it stays that way unless
|
||||
// a sheet turns up: a box is four rects, and the readable part is the BARREL and the charge glow.
|
||||
function drawMachine(zb) {
|
||||
const p = proj(zb.x, 0, zb.z), u = p.s * 0.16; // u = one "pixel" at this depth
|
||||
const R = (x, y, w, h, c) => { ctx.fillStyle = c; ctx.fillRect(Math.round(p.x + x * u), Math.round(p.y + y * u), Math.max(1, w * u), Math.max(1, h * u)); };
|
||||
drawShadow(p.x, p.y, u * 4.4, u * 1.3);
|
||||
const dead = zb.hp / TUNE.machHp;
|
||||
R(-4, -7, 8, 7, dead > 0.5 ? '#4a5460' : '#3a3038'); // hopper, scorched as it takes damage
|
||||
R(-4, -7, 8, 1, '#6b7684'); // top light
|
||||
R(-4, -1, 8, 1, '#20262e'); // bottom shadow
|
||||
R(-2.2, -9.6, 4.4, 2.6, '#2b3138'); // ball basket
|
||||
R(-1.6, -9.2, 3.6, 1.8, UI.ball); // ...full of tennis balls
|
||||
R(1.2, -6.2, 4.6, 2.2, '#565f6b'); // the BARREL, pointed at you
|
||||
R(4.4, -6.2, 1.4, 2.2, '#161a1f'); // muzzle
|
||||
R(-3.4, -1.2, 2, 1.6, '#15181c'); R(1.4, -1.2, 2, 1.6, '#15181c'); // wheels
|
||||
if (zb.windT > 0) { // the TELEGRAPH: the muzzle glows hotter the closer the shot is
|
||||
const k = 1 - zb.windT / TUNE.machWind;
|
||||
ctx.globalAlpha = 0.35 + k * 0.65;
|
||||
R(4.2, -6.4, 2 + k * 1.6, 2.6, k > 0.6 ? '#fff2c0' : UI.warm);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function drawZombie(zb) {
|
||||
if (zb.variant === 'm') return drawMachine(zb);
|
||||
const rk = zb.variant === 'r';
|
||||
const v = rk ? 'c' : zb.variant; // the racket boss reuses the turned-CPU sprite (already holds a racket)
|
||||
const v = rk ? 'c' : zb.variant; // the racket zombie reuses the turned-CPU sprite (already holds a racket)
|
||||
let N = ZFRAMES[v] || 2;
|
||||
if (!SPR['z_' + v + N]) N = 2; // frames still loading (or sheet rejected): 2-frame fallback
|
||||
// same CYCLE frequency as before — more frames subdivide the stride, they don't speed it up
|
||||
let frame = rk && zb.pose === 'swing' ? 2 : Math.floor(zb.walkT * (zb.variant === 'f' ? 6 : 2.6) * N / 2) % N + 1;
|
||||
if (zb.variant === 's' && zb.shieldT > 0) frame = 3; // chair dropped — hit it NOW
|
||||
let h = zb.variant === 'g' ? 2.7 : rk ? 1.85 : 1.8; // the brute towers half again over the crowd
|
||||
if (zb.variant === 'B') h = 1.8 * (zb.scale || 1.5); // ...and a chapter boss towers over the brute
|
||||
// wounded skins: multi-hit enemies show their damage as hp drops (arm off → head gone, still
|
||||
// shambling). The blood is NOT painted in — open wounds gush live particles (see bleed() in
|
||||
// zombies.js). Fall back to the healthy sprite until the wounded PNG has loaded.
|
||||
@@ -198,8 +225,25 @@ function drawZombie(zb) {
|
||||
if (f <= 0.34 && SPR.z_cw2) { name = 'z_cw2'; h = 1.52; } // headless: a head shorter, so the shoulders land where a whole zombie's do
|
||||
else if (f <= 0.67 && SPR.z_cw1) name = 'z_cw1';
|
||||
}
|
||||
// The chapter boss comes apart through the SAME fraction-of-hpMax rule as the racket zombie, over
|
||||
// three stages, so a 34 hp chapter-4 boss visibly degrades all the way down: arm off, then ribs
|
||||
// out, then headless and still throwing. Its 2-frame stride steps off DISTANCE COVERED (the same
|
||||
// rule the brute uses) — it is the slowest thing on court and a time-driven cycle would foot-slide.
|
||||
if (zb.variant === 'B') {
|
||||
const f = zb.hp / (zb.hpMax || 1);
|
||||
const stage = f <= 0.25 ? 'z_Bw3' : f <= 0.5 ? 'z_Bw2' : f <= 0.75 ? 'z_Bw1'
|
||||
: (Math.floor(zb.z / TUNE.bruteStride) % 2 ? 'z_B2' : 'z_B1');
|
||||
if (SPR[stage]) { name = stage; h *= SPR[stage].height / SPRITE_H; } // height ALWAYS off the frame, or the size pops
|
||||
}
|
||||
drawStatic(name, zb.x, zb.z, h, Math.floor(zb.walkT * 5.2) % 2);
|
||||
if (zb.burnT > 0) drawFlames(zb, h);
|
||||
if (zb.windT > 0 && zb.variant === 'B') { // the boss telegraphs too: it rears up, glowing, then throws
|
||||
const k = 1 - zb.windT / TUNE.machWind, p = proj(zb.x, h * 0.62, zb.z);
|
||||
ctx.globalAlpha = 0.25 + k * 0.55; ctx.fillStyle = k > 0.6 ? '#fff2c0' : UI.bad;
|
||||
const r = Math.max(2, p.s * 0.1 * (0.5 + k));
|
||||
ctx.beginPath(); ctx.arc(Math.round(p.x), Math.round(p.y), r, 0, 7); ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
// No HP bar. It was the only floating UI on a body anywhere in the game, it was on screen for
|
||||
// almost the whole run (a boss respawns every game you win), and nobody knew what it meant —
|
||||
// while the wound SKINS above already encode exactly the same information in the language every
|
||||
@@ -303,6 +347,29 @@ function drawChar(ch) {
|
||||
ctx.drawImage(img, -w / 2, -h, w, h);
|
||||
ctx.restore();
|
||||
} else drawFallbackChar(ch, p, hpx);
|
||||
if (ch.side === 'P') drawPlayerWounds(p, hpx);
|
||||
}
|
||||
|
||||
// The player's health, drawn ON HIM and nowhere else — there is no bar and there will not be one
|
||||
// (same rule that removed the boss's). Blood is never painted into the sprite (settled feel
|
||||
// decision), so this is a procedural overlay plus bleedPlayer()'s live trickle: fixed anchors, so it
|
||||
// does not crawl frame to frame, and it grows with every hit. The white kit is the meter.
|
||||
const WOUND_AT = [[-0.18, -0.62], [0.22, -0.55], [-0.05, -0.72], [0.14, -0.42], [-0.26, -0.48], [0.06, -0.33]];
|
||||
function drawPlayerWounds(p, hpx) {
|
||||
if (G.hp >= G.hpMax) return;
|
||||
const lost = G.hpMax - G.hp, n = Math.min(WOUND_AT.length, lost * 2);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const [ox, oy] = WOUND_AT[i];
|
||||
const s = Math.max(1, hpx * (0.055 + (i % 2) * 0.02));
|
||||
ctx.fillStyle = i % 3 ? 'rgba(150,16,16,0.92)' : 'rgba(86,10,10,0.95)';
|
||||
ctx.fillRect(Math.round(p.x + ox * hpx * 0.5), Math.round(p.y + oy * hpx), s, s);
|
||||
}
|
||||
if (G.hurtFlash > 0) { // the impact itself: he goes red for a moment
|
||||
ctx.save(); ctx.globalAlpha = Math.min(1, G.hurtFlash / 0.3) * 0.5;
|
||||
ctx.fillStyle = UI.bad;
|
||||
ctx.fillRect(Math.round(p.x - hpx * 0.2), Math.round(p.y - hpx * 0.82), hpx * 0.4, hpx * 0.6);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
function drawFallbackChar(ch, p, h) { // shown only until sprites load
|
||||
const you = ch.side === 'P';
|
||||
@@ -645,6 +712,17 @@ export function render() {
|
||||
// the match ball (and any triple-serve side balls in flight)
|
||||
if ((ball.active || G.phase === 'over') && !ball.collected) drawBall(ball.x, ball.y, ball.z, true, ball.bomb, ball.flame);
|
||||
for (const e of extras) drawBall(e.x, e.y, e.z);
|
||||
// INCOMING hostile balls, marked so they can never be mistaken for one of yours: a red ring and a
|
||||
// hard ground shadow, so you can read WHERE it will land while it is still in the air.
|
||||
for (const s of shots) {
|
||||
const sh = proj(s.x, 0, s.z);
|
||||
drawShadow(sh.x, sh.y, Math.max(1.5, sh.s * 0.05), Math.max(1, sh.s * 0.025), 0.4);
|
||||
const sp = proj(s.x, s.y, s.z), r = Math.max(2, sp.s * 0.045);
|
||||
ctx.fillStyle = UI.bad;
|
||||
ctx.beginPath(); ctx.arc(Math.round(sp.x), Math.round(sp.y), r + 1.5, 0, 7); ctx.fill();
|
||||
ctx.fillStyle = UI.ball;
|
||||
ctx.beginPath(); ctx.arc(Math.round(sp.x), Math.round(sp.y), r, 0, 7); ctx.fill();
|
||||
}
|
||||
// splatter particles (fire embers ride the same array)
|
||||
for (const pt of parts) {
|
||||
const pp = proj(pt.x, pt.y, pt.z);
|
||||
@@ -677,6 +755,14 @@ export function render() {
|
||||
// bomb bloom: a warm blood-orange wash, not a white-out. At 0.85 white it blinded you on a phone
|
||||
// and hid the very gore eruption it was announcing.
|
||||
if (G.boomFlash > 0) { ctx.fillStyle = `rgba(255,150,70,${Math.min(1, G.boomFlash / 0.06) * 0.3})`; ctx.fillRect(0, 0, W, H); }
|
||||
// took a ranged hit: a red wash from the screen EDGES inward. Deliberately not a full-screen fill —
|
||||
// this channel must read as "you are bleeding", not as "something exploded" (that is boomFlash).
|
||||
if (G.hurtFlash > 0) {
|
||||
const a = Math.min(1, G.hurtFlash / 0.3) * 0.55, b = 26;
|
||||
ctx.fillStyle = `rgba(150,10,14,${a.toFixed(3)})`;
|
||||
ctx.fillRect(0, 0, W, b); ctx.fillRect(0, H - b, W, b);
|
||||
ctx.fillRect(0, 0, b, H); ctx.fillRect(W - b, 0, b, H);
|
||||
}
|
||||
|
||||
// HUD — Wimbledon manual scoreboard as a 16-bit panel: bevelled bottle-green box (light
|
||||
// top/left, dark bottom/right), a purple club spine, cream serif names, and the LIVE game
|
||||
|
||||
@@ -49,6 +49,15 @@ export const G = {
|
||||
idleHintT: 0, // s the player has sat in pretoss without touching anything — after a beat the
|
||||
// pull gesture demonstrates itself (render.drawPullHint)
|
||||
meleeT: 0, // s of racket-swish cooldown left
|
||||
// ---- the SECOND damage channel. Only ranged attacks (ball machine, chapter boss) touch this;
|
||||
// a shambler reaching you is still instant permadeath, deliberately. No HP bar anywhere — the
|
||||
// state is drawn as blood on the man (drawChar's spatter + bleedPlayer's live trickle).
|
||||
hp: TUNE.playerHp,
|
||||
hpMax: TUNE.playerHp,
|
||||
hurtT: 0, // s of post-hit grace left; also drives the red hit flash
|
||||
hurtFlash: 0, // s of the impact wash left
|
||||
bossDue: 0, // chapter number the schedule owes you — fired at the next hand-cleared court
|
||||
bossFight: 0, // chapter number of the boss currently on court, 0 = none. Blocks all other spawns.
|
||||
interlude: false, // sudden-death duel vs the racket zombie: falsy, or {introT} while announcing
|
||||
duelLerp: 0, // 0..1 — how far the court has returned to tennis colors for the duel
|
||||
horde: null, // active horde wave {t, spawnT, mgT} — machine-gun racket, no serving
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
// your baseline — one crossing it is a point for THEM.
|
||||
// (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.)
|
||||
|
||||
import { TUNE, UI, COURT } from './config.js';
|
||||
import { TUNE, UI, COURT, VOLLEY, CONTACT } from './config.js';
|
||||
import { G, P, ball, sendBoy, setupPoint } from './state.js';
|
||||
import { splat, moan, pock, voice, boom, swish, shriek, aww, sfx } from './audio.js';
|
||||
import { splatPoint, cheer, spentBall, resolveInterlude, showModal } from './gameplay.js';
|
||||
import { splatPoint, cheer, spentBall, resolveInterlude, showModal, endBossFight } from './gameplay.js';
|
||||
|
||||
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
|
||||
export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer
|
||||
@@ -14,6 +14,12 @@ export const piles = []; // 3D-ish gore mounds {x, z, size 1..4, seed} — gro
|
||||
export const parts = []; // splatter particles {x, y, z, vx, vy, vz, t, dark}
|
||||
export const pellets = []; // machine-gun rounds during a horde {x, y, z, vx, vz, t}
|
||||
export const extras = []; // triple-serve side balls {x, y, z, vx, vy, vz, pow, t} — ballistic clones
|
||||
// INCOMING hostile balls: the ball machine's shots and the chapter boss's volleys. Deliberately a
|
||||
// separate array from `ball` and `extras` rather than an extension of either — the main ball is the
|
||||
// one you serve and there can only be one of it, and extras are yours and kill zombies. These are
|
||||
// theirs and they hurt YOU. Swat one with a swing and it becomes an `extras` entry flying back
|
||||
// up-court, which is how a parry turns into a real weapon for free.
|
||||
export const shots = []; // {x, y, z, vx, vy, vz, g, t}
|
||||
|
||||
let spawnT = 1.5, moanT = 3;
|
||||
|
||||
@@ -28,9 +34,88 @@ export function spawnZombie(x, z, variant) {
|
||||
if (variant === 'a' || variant === 'b') zb.hp = TUNE.shamblerHp; // classic shamblers take a wound before they drop — only the runner is one-shot glass
|
||||
if (variant === 'g') { zb.v *= 0.55; zb.hp = TUNE.bruteHp; } // slow, but soaks hits
|
||||
if (variant === 's') zb.shieldT = 0; // chair up; > 0 = seconds of stagger left
|
||||
if (variant === 'm') { // the rogue ball machine: furniture, not a body. It does not walk at all.
|
||||
zb.v = 0; zb.hp = TUNE.machHp; zb.fireT = TUNE.machCycle; zb.windT = 0;
|
||||
}
|
||||
zombies.push(zb);
|
||||
}
|
||||
|
||||
// the rogue ball machine, wheeled out onto the far court. It never moves and never scores; it just
|
||||
// shoots at you until you deal with it.
|
||||
export function spawnMachine() {
|
||||
spawnZombie((Math.random() * 2 - 1) * 3.6, 2.2 + Math.random() * 3, 'm');
|
||||
if (!G.seen.mach) {
|
||||
G.seen.mach = true;
|
||||
showModal({ title: 'A BALL MACHINE', color: UI.warm,
|
||||
body: ['IT FIRES AT YOU ON A TIMER', 'SWING TO RETURN THE SHOT', 'OR SWISH TO BAT IT AWAY'] });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the chapter boss. One every TUNE.bossEvery games, bigger and tougher each time. It advances
|
||||
// slowly and volleys at you, so it threatens BOTH channels: its shots chip your hp, and letting it
|
||||
// reach you is the ordinary instant death. Reuses the whole existing damage model — it is just a
|
||||
// body with a lot of hp (see the wound() invariant in HANDOFF), so no weapon needs special-casing.
|
||||
export function spawnChapterBoss(chapter) {
|
||||
const hp = TUNE.bossHp + (chapter - 1) * TUNE.bossHpPerChapter;
|
||||
zombies.push({ x: 0, z: -1.2, v: TUNE.bossSpeed, walkT: 0, homeOff: 0, variant: 'B',
|
||||
chapter, hp, hpMax: hp, scale: TUNE.bossScale + (chapter - 1) * TUNE.bossScalePer,
|
||||
fireT: TUNE.bossCycle, windT: 0 });
|
||||
}
|
||||
|
||||
// ---- the player's second health channel. Only these two functions touch it.
|
||||
export function hurtPlayer(n) {
|
||||
if (G.mode !== 'splatter' || G.hurtT > 0 || G.mode === 'eaten') return; // grace window: a volley
|
||||
G.hp -= n; // must not delete you at once
|
||||
G.hurtT = TUNE.hurtGrace; G.hurtFlash = 0.3;
|
||||
G.shake = { t: 0.22, mag: 4 };
|
||||
splat(0.7); aww(0.5);
|
||||
for (let k = 0; k < 14; k++) parts.push({ // he takes it in the chest
|
||||
x: P.x + (Math.random() * 2 - 1) * 0.2, y: 1.2, z: P.z,
|
||||
vx: (Math.random() * 2 - 1) * 2.2, vy: 0.8 + Math.random() * 2, vz: (Math.random() * 2 - 1) * 2.2,
|
||||
t: 0.35 + Math.random() * 0.35, dark: Math.random() < 0.5 });
|
||||
if (G.hp <= 0) killPlayer();
|
||||
else voice('order', 0.3); // the umpire objects on your behalf. He is still watching.
|
||||
}
|
||||
|
||||
// Death, from EITHER channel: a shambler reaching you, or your hp running out. Extracted so the two
|
||||
// causes cannot drift apart — the run summary, the best-run write and the death animation are all
|
||||
// here exactly once.
|
||||
export function killPlayer() {
|
||||
if (G.mode === 'eaten') return;
|
||||
gore.push({ x: P.x, z: P.z, r: 0.9, a: 0.9 }); if (gore.length > 40) gore.shift();
|
||||
for (let k = 0; k < 30; k++) parts.push({
|
||||
x: P.x, y: 1.1, z: P.z,
|
||||
vx: (Math.random() * 2 - 1) * 4.5, vy: 1 + Math.random() * 4, vz: (Math.random() * 2 - 1) * 4.5,
|
||||
t: 0.5 + Math.random() * 0.5 });
|
||||
splat(1);
|
||||
voice('game_set_massacre', 0.8); aww(1); // the run is over — the crowd groans
|
||||
// the death animation runs off G.eatenT: thrash here, burst at EATEN_BURST (playerGore), summary after
|
||||
G.eatenT = 0; G.eatenAt = { x: P.x, z: P.z };
|
||||
G.mode = 'eaten'; ball.active = false; G.flash = null; G.modal = null; // zombies keep shambling for ambience
|
||||
G.horde = null; pellets.length = 0; extras.length = 0; shots.length = 0; G.mgT = 0; // everything stops
|
||||
try { // the run is over — settle it against the best (games survived, sets as tiebreak)
|
||||
const b = JSON.parse(localStorage.splatterBest || 'null') || { games: 0, sets: 0 };
|
||||
G.newBest = G.runGames > b.games; // beat it → the summary crows about it
|
||||
if (G.newBest) { b.games = G.runGames; b.sets = G.sets[0]; localStorage.splatterBest = JSON.stringify(b); }
|
||||
G.best = b;
|
||||
} catch (e) { G.best = null; } // private mode etc. — the summary just skips the best line
|
||||
}
|
||||
|
||||
// One hostile ball, lobbed at where the player is standing. `spread` fans a volley sideways.
|
||||
// It is a real ARC (solved like a serve, not fired flat) so it is readable coming in and so the
|
||||
// swing window has something to meet — a flat bolt at head height is not a tennis shot.
|
||||
function fireAtPlayer(x, z, spread = 0) {
|
||||
const y0 = 1.1;
|
||||
const tx = P.x + spread, tz = P.z;
|
||||
const d = Math.hypot(tx - x, tz - z), Tf = Math.max(0.35, d / TUNE.machSpeed);
|
||||
const g = TUNE.gravity;
|
||||
shots.push({ x, y: y0, z,
|
||||
vx: (tx - x) / Tf, vz: (tz - z) / Tf,
|
||||
vy: (0.9 - y0 + 0.5 * g * Tf * Tf) / Tf, // land at racket height, not on the grass
|
||||
g, t: Tf + 1.6 });
|
||||
pock(0.55, 190); // the thunk of the throw
|
||||
}
|
||||
|
||||
// Which shambler steps out next. Unlocks are keyed on GAMES, not on sets won: a set is six games,
|
||||
// so the old top gate (`tier >= 2`) opened around game 12 and after that the mix was frozen forever
|
||||
// at 15% shield / 15% brute / 12% runner — a third of why a 50-game run stopped changing.
|
||||
@@ -49,18 +134,25 @@ function pickVariant() {
|
||||
|
||||
// How big a target a body is. Bosses are deliberately a little more generous than a shambler —
|
||||
// part of "the racket zombie doesn't seem harmable" was simply missing him.
|
||||
const hitR = zb => zb.variant === 'g' ? 0.75 : zb.variant === 'r' ? 0.7 : 0.55;
|
||||
// A BOSS BODY: takes wound damage from everything instead of being deleted by it. The brute ('g') is
|
||||
// deliberately NOT one — melee/bomb/fire one-shotting a brute is a settled "power exception".
|
||||
const isBoss = zb => zb.variant === 'r' || zb.variant === 'B';
|
||||
const hitR = zb => zb.variant === 'g' ? 0.75 : zb.variant === 'r' ? 0.7
|
||||
: zb.variant === 'B' ? 0.55 * (zb.scale || 1) * 1.4 // a chapter boss is a barn door, as it should be
|
||||
: zb.variant === 'm' ? 0.8 // the machine is a wide low box
|
||||
: 0.55;
|
||||
|
||||
// core kill: remove the zombie, spawn its gore/pile/particles, score. No ball coupling —
|
||||
// shared by ball hits, machine-gun pellets, melee swipes, and extra (multi-serve) balls.
|
||||
// how absurdly a variant dies: classics splash, the advanced roster DETONATES
|
||||
const GORE_MUL = { a: 1, b: 1, f: 1.8, s: 2.5, g: 4, r: 3 };
|
||||
const GORE_MUL = { a: 1, b: 1, f: 1.8, s: 2.5, g: 4, r: 3, m: 1.5, B: 6 }; // a chapter boss comes apart like nothing else on the court
|
||||
|
||||
function killZombie(i, quiet) {
|
||||
const zb = zombies[i];
|
||||
const mul = GORE_MUL[zb.variant] || 1;
|
||||
zombies.splice(i, 1);
|
||||
if (G.interlude && zb.variant === 'r') resolveInterlude('P'); // regicide ends the duel your way — any weapon counts
|
||||
if (zb.variant === 'B') endBossFight(); // the chapter is over
|
||||
const stains = Math.round(Math.sqrt(mul)); // advanced kills splash a scatter of stains, not one puddle
|
||||
for (let k = 0; k < stains; k++) {
|
||||
gore.push({ x: zb.x + (k ? (Math.random() * 2 - 1) * 0.9 : 0), z: zb.z + (k ? (Math.random() * 2 - 1) * 0.9 : 0),
|
||||
@@ -80,7 +172,10 @@ function killZombie(i, quiet) {
|
||||
splat();
|
||||
sfx('headshot-' + (Math.random() < 0.5 ? 1 : 2), 0.6); // random headshot crack layered on the gore burst
|
||||
sendBoy(zb.x, zb.z, true); // a boy comes out with the mop
|
||||
if (!G.horde && !G.clearing) splatPoint('P', quiet); // horde = survival, clearing = mopping up an already-won game — neither scores
|
||||
// The chapter boss deliberately scores NOTHING: its reward is the chapter card and the parked game
|
||||
// beat resuming. Letting it bank a point meant splatPoint's game-over branch and endBossFight both
|
||||
// decided "what happens next" in the same frame and clobbered each other.
|
||||
if (!G.horde && !G.clearing && zb.variant !== 'B') splatPoint('P', quiet); // horde = survival, clearing = mopping up an already-won game — neither scores
|
||||
}
|
||||
|
||||
// ---- fire. The flame ball doesn't just kill what it touches, it starts an epidemic: the kill
|
||||
@@ -115,7 +210,7 @@ function burnTick(zb, i, dt) {
|
||||
zb.burnT -= dt;
|
||||
ignite(zb.x, zb.z, TUNE.burnSpread);
|
||||
// fx off: the embers below already ARE the feedback, a wet pop per cooked hp point would be noise
|
||||
const dead = wound(zb, TUNE.burnDps * (zb.variant === 'r' ? TUNE.bossBurnMul : 1) * dt, false);
|
||||
const dead = wound(zb, TUNE.burnDps * (isBoss(zb) ? TUNE.bossBurnMul : 1) * dt, false);
|
||||
if (parts.length < 110) for (let k = 0; k < 2; k++) parts.push({ // embers off the body
|
||||
x: zb.x + (Math.random() * 2 - 1) * 0.3, y: 0.5 + Math.random() * 1.4, z: zb.z,
|
||||
vx: (Math.random() * 2 - 1) * 0.5, vy: 1.2 + Math.random(), vz: (Math.random() * 2 - 1) * 0.5,
|
||||
@@ -143,6 +238,22 @@ export function playerGore() {
|
||||
|
||||
// a small dark spatter the instant a multi-hit shambler is wounded — subtle impact pop, NOT the
|
||||
// kill spray (the ongoing thin trickle is bleed()'s job). Kept low per the settled blood feel.
|
||||
// The player's own wound trickle. Same rule as the zombies': blood is NEVER painted into the player
|
||||
// sprite (settled feel decision) — it is live particles, sparse, thin, and the flow scales with how
|
||||
// far gone he is. Together with drawChar's procedural spatter this IS the health bar.
|
||||
function bleedPlayer() {
|
||||
if (parts.length > 95) return;
|
||||
const lost = G.hpMax - G.hp;
|
||||
for (let k = 0; k < lost; k++) {
|
||||
if (Math.random() > 0.22) continue; // sparse: most frames each wound emits nothing
|
||||
parts.push({
|
||||
x: P.x + (k % 2 ? 0.22 : -0.2) + (Math.random() * 2 - 1) * 0.03,
|
||||
y: 1.45 - k * 0.13, z: P.z + (Math.random() * 2 - 1) * 0.03,
|
||||
vx: (Math.random() * 2 - 1) * 0.28, vy: 0.5 + Math.random() * 0.7, vz: (Math.random() * 2 - 1) * 0.26,
|
||||
t: 0.3 + Math.random() * 0.3, dark: Math.random() < 0.55 });
|
||||
}
|
||||
}
|
||||
|
||||
function woundBurst(zb) {
|
||||
for (let k = 0; k < 5; k++) parts.push({
|
||||
x: zb.x + (Math.random() * 2 - 1) * 0.15, y: 1.4, z: zb.z,
|
||||
@@ -162,8 +273,20 @@ export function meleeSwipe(x, z, range) {
|
||||
for (let i = zombies.length - 1; i >= 0; i--) {
|
||||
const zb = zombies[i];
|
||||
if (!zb || zb.variant === 'r') continue; // !zb: killZombie can sweep the court mid-loop
|
||||
if (zb.variant === 'm' || isBoss(zb)) { // furniture and bosses don't cleave — they take a wound
|
||||
if (Math.hypot(zb.x - x, zb.z - z) < range && wound(zb, TUNE.dmgBall)) { splatter(i); hit++; }
|
||||
continue;
|
||||
}
|
||||
if (Math.hypot(zb.x - x, zb.z - z) < range) { killZombie(i, !!G.horde); hit++; }
|
||||
}
|
||||
// The swish also BATS AWAY incoming shots. This is what gives you a defence in `pretoss`, where you
|
||||
// are rooted to the spot and cannot swing at all — without it a machine winding up while you were
|
||||
// lining up a serve was an unanswerable hit. It only destroys the shot; the timed rally parry below
|
||||
// is the skilled option that sends it back as a weapon.
|
||||
for (let i = shots.length - 1; i >= 0; i--) {
|
||||
const s = shots[i];
|
||||
if (Math.hypot(s.x - x, s.z - z) < range && s.y < 2.4) { shots.splice(i, 1); pock(0.7, 900); hit++; }
|
||||
}
|
||||
if (hit) swish(0.35);
|
||||
return hit;
|
||||
}
|
||||
@@ -229,7 +352,7 @@ export function explode(x, z) {
|
||||
if (!zb || Math.hypot(zb.x - x, zb.z - z) >= TUNE.bombRadius) continue;
|
||||
// A blast used to delete the boss outright, ignoring his hp entirely — which made BOMB the only
|
||||
// real answer to him and would have made any bigger boss meaningless. It's heavy damage now.
|
||||
if (zb.variant === 'r') { if (wound(zb, TUNE.dmgBomb)) splatter(i); }
|
||||
if (isBoss(zb)) { if (wound(zb, TUNE.dmgBomb)) splatter(i); }
|
||||
else splatter(i);
|
||||
}
|
||||
gore.push({ x, z, r: 2.2, a: 1, scorch: true }); if (gore.length > 40) gore.shift(); // the crater — near-permanent
|
||||
@@ -304,7 +427,7 @@ export function updateZombies(dt) {
|
||||
if (G.phase === 'rally' && (G.mgCool -= dt) <= 0) { firePellet(); G.mgCool = 1 / TUNE.mgRate; }
|
||||
if (G.mgT <= 0) setupPoint(); // ammo dry — the serve loop resumes
|
||||
}
|
||||
if (!G.horde && !G.changeover && !G.interlude && !G.clearing) { // breathers, duels AND the mop-up after a won game get a quiet court (no new spawns)
|
||||
if (!G.horde && !G.changeover && !G.interlude && !G.clearing && !G.bossFight) { // breathers, duels, the mop-up after a won game AND a boss fight all get a quiet court (no new spawns)
|
||||
spawnT -= dt;
|
||||
// The spawn director: games finished sets both crowd size and tempo. Both curves used to hit a
|
||||
// wall — the cap pinned at 14 by game 11 and the gap floored at 1.0 s by game 10, after which
|
||||
@@ -336,6 +459,9 @@ export function updateZombies(dt) {
|
||||
// in the air, but nothing advances and nothing can eat you. (The alternative — letting the player
|
||||
// walk freely during the beat — was tried and vetoed: he never walks freely during play either.)
|
||||
const held = !!G.changeover;
|
||||
if (G.hurtT > 0) G.hurtT -= dt;
|
||||
if (G.hurtFlash > 0) G.hurtFlash -= dt;
|
||||
if (G.hp < G.hpMax && G.mode === 'splatter') bleedPlayer();
|
||||
|
||||
for (let i = zombies.length - 1; i >= 0; i--) {
|
||||
const zb = zombies[i];
|
||||
@@ -346,33 +472,34 @@ export function updateZombies(dt) {
|
||||
bleed(zb); // open wounds bleed every frame they're standing
|
||||
|
||||
if (zb.shieldT > 0) zb.shieldT -= dt; // staggered chair comes back up when this runs out
|
||||
if (held || zb.variant === 'r') { /* the racket boss holds station — its AI owns its x */ }
|
||||
// ---- the two RANGED attackers. Both wind up visibly before firing (TUNE.machWind / drawZombie's
|
||||
// charge glow) because a shot you cannot see coming is not a fairness contract, it is a gotcha.
|
||||
if (!held && !G.interlude && (zb.variant === 'm' || zb.variant === 'B') && G.mode === 'splatter') {
|
||||
if (zb.windT > 0) {
|
||||
zb.windT -= dt;
|
||||
if (zb.windT <= 0) {
|
||||
const n = zb.variant === 'B' ? TUNE.bossVolley : 1;
|
||||
for (let k = 0; k < n; k++) fireAtPlayer(zb.x, zb.z, n === 1 ? 0 : (k - (n - 1) / 2) * 1.9);
|
||||
}
|
||||
} else if ((zb.fireT -= dt) <= 0) {
|
||||
zb.fireT = zb.variant === 'B' ? TUNE.bossCycle : TUNE.machCycle;
|
||||
zb.windT = TUNE.machWind;
|
||||
pock(0.3, 1500); // the tell: a mechanical click as it loads
|
||||
}
|
||||
}
|
||||
if (held || zb.variant === 'r' || zb.variant === 'm') { /* boss AI owns its x; the machine is bolted down */ }
|
||||
else if (G.mode === 'splatter' && zb.z > COURT.netZ) { // on YOUR half they close in — each on its own lane, not a column
|
||||
const tx = P.x + zb.homeOff;
|
||||
zb.x += Math.sign(tx - zb.x) * Math.min(Math.abs(tx - zb.x), zb.v * 0.7 * dt)
|
||||
+ Math.sin(zb.walkT * 2.3) * 0.25 * dt; // shamble wobble — the approach reads organic, not tracked
|
||||
} else
|
||||
zb.x += Math.sin(zb.walkT * 1.7) * 0.15 * dt; // far half: aimless shamble
|
||||
if (!held && G.mode === 'splatter' && Math.hypot(zb.x - P.x, (zb.z - P.z) * 0.75) < TUNE.eatenR + (zb.variant === 'g' ? 0.2 : 0)) { // z weighted down: depth is foreshortened, so a head-on shambler eats you when it LOOKS on top of you
|
||||
gore.push({ x: P.x, z: P.z, r: 0.9, a: 0.9 });
|
||||
for (let k = 0; k < 30; k++) parts.push({
|
||||
x: P.x, y: 1.1, z: P.z,
|
||||
vx: (Math.random() * 2 - 1) * 4.5, vy: 1 + Math.random() * 4, vz: (Math.random() * 2 - 1) * 4.5,
|
||||
t: 0.5 + Math.random() * 0.5,
|
||||
});
|
||||
splat(1);
|
||||
voice('game_set_massacre', 0.8); aww(1); // the run is over — the crowd groans
|
||||
// the death animation runs off G.eatenT: thrash here, burst at EATEN_BURST (playerGore), summary after
|
||||
G.eatenT = 0; G.eatenAt = { x: P.x, z: P.z };
|
||||
G.mode = 'eaten'; ball.active = false; G.flash = null; G.modal = null; // zombies keep shambling for ambience
|
||||
G.horde = null; pellets.length = 0; extras.length = 0; G.mgT = 0; // everything stops; the eaten screen takes over
|
||||
try { // the run is over — settle it against the best (games survived, sets as tiebreak)
|
||||
const b = JSON.parse(localStorage.splatterBest || 'null') || { games: 0, sets: 0 };
|
||||
G.newBest = G.runGames > b.games; // beat it → the summary crows about it
|
||||
if (G.newBest) { b.games = G.runGames; b.sets = G.sets[0]; localStorage.splatterBest = JSON.stringify(b); }
|
||||
G.best = b;
|
||||
} catch (e) { G.best = null; } // private mode etc. — the summary just skips the best line
|
||||
}
|
||||
// CONTACT IS STILL INSTANT DEATH — settled decision, and the ball machine's chip damage does not
|
||||
// soften it. Two threats that demand two different responses is the point. The machine ('m') is
|
||||
// furniture and cannot eat you.
|
||||
if (!held && G.mode === 'splatter' && zb.variant !== 'm'
|
||||
&& Math.hypot(zb.x - P.x, (zb.z - P.z) * 0.75) < TUNE.eatenR + (zb.variant === 'g' ? 0.2 : 0)) // z weighted down: depth is foreshortened, so a head-on shambler eats you when it LOOKS on top of you
|
||||
killPlayer();
|
||||
if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) {
|
||||
const d = Math.hypot(ball.x - zb.x, ball.z - zb.z);
|
||||
if (d < hitR(zb) + ballSweep) { // brute and boss are bigger targets
|
||||
@@ -381,7 +508,7 @@ export function updateZombies(dt) {
|
||||
spentBall(); break; // zombies mutated — stop this frame's loop
|
||||
}
|
||||
if (ball.flame) { // burns through anything it touches — no quality gate, no chair, no pace tax
|
||||
if (zb.variant === 'r') { // the boss takes a heavy wound AND catches light
|
||||
if (isBoss(zb)) { // a boss takes a heavy wound AND catches light
|
||||
if (wound(zb, TUNE.dmgFlame)) splatter(i);
|
||||
else zb.burnT = TUNE.burnTime;
|
||||
continue;
|
||||
@@ -391,7 +518,7 @@ export function updateZombies(dt) {
|
||||
ignite(bx, bz, TUNE.burnIgniteR); // ...and everyone standing near the body goes up
|
||||
continue;
|
||||
}
|
||||
if (zb.variant === 'r') { // mini-boss: splat-quality wounds it; soft balls it rallies (racketAI)
|
||||
if (isBoss(zb)) { // boss: splat-quality wounds it; soft balls the racket one rallies (racketAI)
|
||||
if ((ball.pow || 0) >= TUNE.zSplatQ) {
|
||||
const dead = wound(zb, TUNE.dmgBall);
|
||||
pock(0.6, 300);
|
||||
@@ -401,9 +528,9 @@ export function updateZombies(dt) {
|
||||
continue; // soft balls pass through ON PURPOSE, for racketAI to return — that rally IS the
|
||||
// feedback that it wasn't hard enough, so no clank here; see HANDOFF
|
||||
}
|
||||
if (zb.variant === 'g') { // brute: soaks splat-quality hits, never staggers back
|
||||
if ((ball.pow || 0) >= TUNE.zSplatQ) {
|
||||
zb.hp--; splat(0.5); pock(0.6, 220);
|
||||
if (zb.variant === 'g' || zb.variant === 'm') { // brute AND the bolted-down machine: soak
|
||||
if ((ball.pow || 0) >= TUNE.zSplatQ) { // splat-quality hits, never stagger back
|
||||
zb.hp--; splat(0.5); pock(0.6, zb.variant === 'm' ? 700 : 220); // machine rings, meat thuds
|
||||
ball.vx *= 0.3; ball.vz *= 0.3; ball.lastHit = 'X';
|
||||
if (zb.hp <= 0) { splatter(i); continue; } // timber
|
||||
} else { pock(0.6, 300); ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; } // soft ball bounces off
|
||||
@@ -446,7 +573,7 @@ export function updateZombies(dt) {
|
||||
// Pellets used to skip the boss entirely, so a GUN burst (and every horde) did literally
|
||||
// nothing to him with no feedback at all. They CHIP him now: dmgPellet is fractional, so
|
||||
// three rounds take a point off and the gun is a slow, committed answer rather than a no-op.
|
||||
if (zb.variant === 'r') { if (wound(zb, TUNE.dmgPellet, false)) killZombie(j, true); else pock(0.45, 240); }
|
||||
if (isBoss(zb)) { if (wound(zb, TUNE.dmgPellet, false)) killZombie(j, true); else pock(0.45, 240); }
|
||||
else if (zb.hp > 1) { zb.hp--; pock(0.5, 260); } // brutes AND shamblers soak pellets — wound, don't kill
|
||||
else killZombie(j, true);
|
||||
hit = true; break;
|
||||
@@ -455,6 +582,29 @@ export function updateZombies(dt) {
|
||||
if (hit || pe.t <= 0) pellets.splice(i, 1);
|
||||
}
|
||||
|
||||
// ---- INCOMING hostile balls (ball machine, chapter boss volleys). Three outcomes, in this order:
|
||||
// a timed swing inside the volley window RETURNS it as your own ball flying up-court, the panic
|
||||
// swish (meleeSwipe, above) merely bats it away, and reaching you unanswered costs a point of hp.
|
||||
for (let i = shots.length - 1; i >= 0; i--) {
|
||||
const s = shots[i];
|
||||
s.t -= dt; s.vy -= s.g * dt;
|
||||
s.x += s.vx * dt; s.y += s.vy * dt; s.z += s.vz * dt;
|
||||
let done = s.t <= 0 || s.y < -0.25 || Math.abs(s.x) > 8 || s.z > COURT.L + 2;
|
||||
const dP = Math.hypot(s.x - P.x, (s.z - P.z) * 0.8);
|
||||
if (!done && P.swingT >= TUNE.swingWindup
|
||||
&& P.swingT <= TUNE.swingWindup + TUNE.swingWindow * VOLLEY.window
|
||||
&& dP < TUNE.reach * VOLLEY.reach && s.y < CONTACT.maxY) {
|
||||
// the parry: it becomes an `extras` clone — that array is already ballistic and already kills
|
||||
// zombies, so "returned shot is a live weapon" costs nothing to build
|
||||
extras.push({ x: s.x, y: s.y, z: s.z, g: TUNE.gravity, pow: TUNE.parryPow, t: 2.5,
|
||||
vx: s.vx * 0.3, vy: 4.2, vz: -Math.abs(s.vz) * 1.15 });
|
||||
pock(0.85, 1300); G.flash = { text: 'RETURNED', t: 0.6 }; cheer(0.8, 0.3);
|
||||
done = true;
|
||||
}
|
||||
if (!done && dP < 0.55 && s.y < 2.0) { hurtPlayer(TUNE.machDmg); done = true; }
|
||||
if (done) shots.splice(i, 1);
|
||||
}
|
||||
|
||||
for (let i = extras.length - 1; i >= 0; i--) { // triple-serve side balls: full ballistic clones
|
||||
const e = extras[i];
|
||||
e.t -= dt; e.vy -= (e.g || TUNE.gravity) * dt; // e.g: a clone flies under the SAME topspin as the ball it came from
|
||||
@@ -468,7 +618,7 @@ export function updateZombies(dt) {
|
||||
+ Math.hypot(e.vx, e.vz) * dt * 0.5) { // same sweep: clones inherit the smash pace
|
||||
// Side balls used to pass THROUGH the boss doing nothing at all, which is most of why a
|
||||
// TRIPLE serve read as "he can't be hurt". They wound him like the main ball now.
|
||||
if (zb.variant === 'r') {
|
||||
if (isBoss(zb)) {
|
||||
if (e.pow >= TUNE.zSplatQ) { if (wound(zb, TUNE.dmgSide)) killZombie(j, !!G.horde); }
|
||||
else pock(0.5, 300);
|
||||
}
|
||||
|
||||