Make every weapon hurt the boss, drop his HP bar, un-cap the director
Steps 1-3 of the late-game depth plan, after Oliver's 50+ game playtest. "Sometimes the racket zombie doesn't seem harmable" was real, not a feeling. Three of the five rack slots did nothing at all to him and said nothing about it: TRIPLE side balls skipped him, GUN and horde pellets skipped him, fire couldn't ignite him. BOMB meanwhile deleted him outright ignoring his hp. All of it now routes through one wound() with a TUNE.dmg* table, so damage is proportional to the body and a future 20 hp boss needs no new code. Fractional damage banks on zb.chip so whole points only come off hp — the wound skins switch on hp thresholds. His contact radius goes to 0.7 m, because part of "unharmable" was just missing him. Melee still skips him and that's correct: he's on the far side of the net and melee can't span it. The HP bar is gone. It was the only floating UI on a body, it was on screen for nearly the whole run, and nobody knew what it meant — while the wound skins already say the same thing in the language every other multi-hit enemy uses. A one-per-run modal explains what he is instead. Skins and bleed anchors now read a fraction of hpMax, since his hp scales with the run. And the plateau: every difficulty dial bottomed out by about game 12, so games 12-50 were arithmetically identical. Body cap, spawn gap, shamble speed, variant mix, boss hp and horde size all keep climbing now, calibrated to track the old curve through game 12 rather than replace it. One deliberate exception: shamble speed was time-based and maxed during game 2, so the early game is genuinely gentler now — zSpeedPerGame if that reads flabby. HANDOFF carries the full spec for steps 4 and 5 (ranged attacks with player damage, then chapter bosses) including the decisions we settled, so the next session can pick it up cold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4cf54603f3
commit
ffac9b5512
6 files changed
+339
-54
No files matched your search
+177
-3
@@ -5,9 +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 after Oliver playtested the fixes. All eight retest issues are closed and
|
||||
recorded at the bottom with what the fix was, because each one is a trap worth not re-digging.
|
||||
Issue 1's first fix was **vetoed on feel and replaced** — read that entry before touching movement.
|
||||
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.
|
||||
|
||||
## What this is
|
||||
SNES-style pseudo-3D tennis (Super Tennis reference) that transitions into a zombie splatter game
|
||||
@@ -61,6 +62,33 @@ zombie. Getting caught = `G.mode='eaten'` = permadeath, and the run summary is t
|
||||
While `m.in < 1`, `dismissModal` **returns true without dismissing** — it swallows the gesture but
|
||||
won't let a panic tap that was already mid-air skip a popup nobody has read. Returning false there
|
||||
would drop that tap through into a serve instead.
|
||||
- **The difficulty director lives in `updateZombies`'s spawn block + `spawnZombie` + `pickVariant`,
|
||||
and every dial is keyed on `G.runGames`.** It is the thing that broke first: as shipped, ALL of it
|
||||
bottomed out by about game 12 (body cap pinned at 14, spawn gap floored at 1.0 s, variant mix frozen
|
||||
at exactly 0.42 advanced, shamble speed maxed after 105 *seconds* of splatter, boss stuck at 3 hp,
|
||||
every horde identical). Oliver played 50+ games and reported the plateau; games 12-50 were
|
||||
arithmetically the same game. All six now keep climbing — see the TUNE block marked
|
||||
THE DIFFICULTY DIRECTOR. Calibration rule: the curve must **track the old values through ~game 12**
|
||||
and only then extend, or you have silently nerfed the part of the game that was already fine.
|
||||
The raw dials do run out around game 25 by design; bosses and ranged attacks (steps 4-5) are what
|
||||
carry escalation past that, not more bodies.
|
||||
- **`wound(zb, amount, fx)` in zombies.js is the ONE place damage is applied to a multi-hit body.**
|
||||
Fractional amounts are legal (a pellet chips `TUNE.dmgPellet` = 0.34 of a point); they bank on
|
||||
`zb.chip` and only WHOLE points come off `zb.hp`, because the wound skins and `bleed()` anchors
|
||||
switch on hp thresholds and a fractional hp would never match one. It returns true when the body
|
||||
should die and the CALLER kills it — `splatter()` when the kill should get the multi-kill fanfare,
|
||||
`killZombie()` for a quiet one — after which the caller must stop touching `zb`, because
|
||||
`killZombie` splices the array. A boss with 20 hp therefore needs no new damage code at all.
|
||||
- **Boss damage state is read as a FRACTION of `zb.hpMax`, never against literal hp numbers.**
|
||||
`spawnRacketZombie` scales hp with `TUNE.racketHpPerGame` and stores `hpMax`. Two places switch on
|
||||
it and they MUST stay in step: `drawZombie`'s skins (`<= 0.34` headless `z_cw2`, `<= 0.67` torn-arm
|
||||
`z_cw1`) and `bleed()`'s wound anchors. The old hardcoded `hp === 2` left a scaled boss looking
|
||||
pristine until the killing blow.
|
||||
- **The racket boss has NO health bar, deliberately.** It was the only floating UI on a body anywhere,
|
||||
it was on screen for nearly the whole run (a boss respawns every game you win), and players did not
|
||||
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.
|
||||
- **`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
|
||||
@@ -203,6 +231,152 @@ Known generator quirks, all still true:
|
||||
|
||||
---
|
||||
|
||||
# NEXT UP — late-game depth (agreed with Oliver 2026-07-30)
|
||||
|
||||
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.
|
||||
|
||||
## Settled design decisions (do not reopen)
|
||||
|
||||
- **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.
|
||||
|
||||
## Step 4 — ranged attacks and player damage (NOT BUILT)
|
||||
|
||||
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 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.
|
||||
|
||||
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-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 every 10 games (NOT BUILT — Oliver's favourite)
|
||||
|
||||
*"my favourite - we need spectacular new sprites for the bosses and his wounds"*
|
||||
|
||||
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.
|
||||
|
||||
- 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.
|
||||
|
||||
## Parked (step 6) — do not build without asking
|
||||
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
# CLOSED — the 50-game plateau, steps 1-3 (2026-07-30)
|
||||
|
||||
### Racket zombie's HP bar removed
|
||||
See invariants. Replaced by a one-per-run "IT HAS A RACKET / IT RETURNS YOUR WEAK SHOTS / ONLY A FULL
|
||||
PULL WOUNDS IT" modal from `spawnRacketZombie`, gated on `G.seen.racket`.
|
||||
|
||||
### "Sometimes the racket zombie doesn't seem harmable" — it was real, not a feeling
|
||||
Three of the five rack slots did **nothing at all** to him, silently:
|
||||
`TRIPLE` side balls skipped him (`variant === 'r'` → continue), `GUN`/horde pellets skipped him, and
|
||||
fire spread could not ignite him. Meanwhile `BOMB` deleted him outright ignoring hp. All fixed via the
|
||||
new `TUNE.dmg*` table and `wound()`. Verified numerically: 9 pellets kill a 3 hp boss, one side ball
|
||||
takes 1 hp and is consumed, a weak side ball does nothing (correct — the `zSplatQ` gate), a bomb takes
|
||||
3 off, and he now catches fire from a burning neighbour and cooks to death in 3.6 s at
|
||||
`bossBurnMul` 0.4.
|
||||
- **`hitR(zb)` gives him a 0.7 m contact radius** (shambler 0.55, brute 0.75) — part of "unharmable"
|
||||
was simply missing him.
|
||||
- **Melee is still skipped, and that is correct, not an oversight.** The player is bounded at
|
||||
`P_BOUNDS.zMin` = 13.1 and the boss holds `z <= COURT.svcFar` = 5.5, so they are always on opposite
|
||||
sides of the net and `meleeRange` 1.7 can never span it. Don't "fix" an unreachable path.
|
||||
- **A sub-threshold ball still passes through him on purpose** so `racketAI` can rally it back. That
|
||||
rally IS the feedback that the pull wasn't hard enough, so there is deliberately no clank there.
|
||||
|
||||
### The director un-capped
|
||||
Old vs new, verified in-browser (gap s / body cap / speed m/s / advanced share / boss hp):
|
||||
|
||||
```
|
||||
gap cap speed nasty bossHp
|
||||
g 1 2.59/2.78 3/4 0.79/1.75 0.03/— 3/3
|
||||
g10 1.15/1.00 12/13 1.61/1.75 0.30/0.27 4/3
|
||||
g12 1.03/1.00 13/14 1.79/1.75 0.36/0.42 4/3
|
||||
g20 0.71/1.00 21/14 2.52/1.75 0.60/0.42 5/3
|
||||
g30 0.52/1.00 24/14 2.73/1.75 0.75/0.42 6/3
|
||||
g50 0.35/1.00 24/14 2.73/1.75 0.75/0.42 9/3
|
||||
```
|
||||
- The spawn gap is **hyperbolic** now (`zSpawnEvery / (1 + games * zSpawnAccel)`) so it keeps
|
||||
tightening instead of hitting a floor; `zSpawnMin` is only a safety stop.
|
||||
- **Known, deliberate early-game change:** shamble speed used to be TIME-based (`splatT / 70`) and
|
||||
maxed out after 105 seconds of splatter — i.e. during game 2 — so it was never a progression lever
|
||||
and every early shambler already walked at top speed. It is games-based now, which makes games 1-10
|
||||
genuinely gentler than before. Oliver has not playtested that yet; if the on-ramp feels flabby the
|
||||
knob is `zSpeedPerGame`.
|
||||
- Hordes scale with sets banked (`hordeGrow`) — length AND spawn rate. **`TUNE.zHordeCap` = 34 is
|
||||
required**: a horde deliberately ignores the director cap, and with growth a late wave asks for
|
||||
~120 spawns where the old fixed 12 s x 2.5/s could only ask for 30. A 41-body court showed up in the
|
||||
soak before the cap went in.
|
||||
|
||||
### Regression net after all of it
|
||||
30 tapped tennis points = 50 hits / 0 whiffs (unchanged). 40-game splatter soak: no exceptions,
|
||||
`mgT` drains to 0, gore 40 / piles 30 / bodies capped at exactly 34.
|
||||
|
||||
---
|
||||
|
||||
# CLOSED — Oliver's 35-game retest (2026-07-27), all fixed 2026-07-30
|
||||
Kept because each one is a trap worth not re-digging. Every fix was verified in the browser via
|
||||
`window.T`; the numbers quoted are from those runs.
|
||||
|
||||
@@ -25,11 +25,42 @@ export const TUNE = {
|
||||
aiReaction: 0.45, // s before the CPU reads your shot — well-aimed corners are winners
|
||||
autoAssist: matchMedia('(pointer: coarse)').matches ? 1.15 : 0, // auto-run speed factor (mobile)
|
||||
zSpeed: 0.7, // zombie shamble speed m/s
|
||||
zSpawnEvery: 3.0, // s between spawns in game 1 — shrinks by zSpawnStep per finished game
|
||||
zSpawnMin: 1.0, // spawn-gap floor late in a run
|
||||
zSpawnStep: 0.22, // spawn-gap reduction per finished game
|
||||
zMax: 3, // shamblers on court in game 1 (cap grows +1 per game to zMaxCap; 0 = spawns off, for tests)
|
||||
zMaxCap: 14, // absolute shambler cap
|
||||
// ---- THE DIFFICULTY DIRECTOR. Every dial below used to bottom out by about game 12, which is why
|
||||
// a 50-game run stopped changing (games 12..50 were arithmetically identical). They are all keyed
|
||||
// on G.runGames now and all keep moving; the ceilings that remain exist for perf or fairness, not
|
||||
// because the curve ran out. See HANDOFF "The difficulty director".
|
||||
// Calibrated to TRACK the old curve through roughly game 12 and then keep going, rather than to
|
||||
// replace it: the first pass at this made games 1-30 easier, which is the opposite of the ask.
|
||||
zSpawnEvery: 3.0, // s between spawns in game 1
|
||||
zSpawnAccel: 0.16, // hyperbolic tightening: gap = zSpawnEvery / (1 + games * this). g5 1.67s,
|
||||
// g11 1.09s (old floored at 1.0 here and stopped dead), g20 0.71s, g30 0.52s
|
||||
zSpawnMin: 0.35, // hard spawn-gap floor, safety only — reached around game 41
|
||||
zMax: 3, // shamblers on court in game 1
|
||||
zMaxPerGame: 0.9, // cap growth per finished game (was a flat +1 into a wall at game 11)
|
||||
zMaxCap: 24, // absolute shambler cap — readability ceiling, reached ~game 24
|
||||
zSpeedPerGame: 0.13, // shamble-speed ramp per finished game. Was TIME-based (splatT/70) and hit its
|
||||
// ceiling after 105 seconds of splatter, i.e. during game 2 — so shamble speed
|
||||
// was never a progression lever at all. g12 matches the old top speed (1.75
|
||||
// m/s), and it climbs to 2.7 m/s by game 22.
|
||||
zSpeedMax: 2.9, // cap on that ramp (top speed = zSpeed * 3.9). runnerVMax still caps runners.
|
||||
nastyPerGame: 0.03, // share of spawns drawn from the advanced roster, per finished game
|
||||
nastyMax: 0.75, // ...capped here (g14 matches the old 0.42, g25 hits the cap). The old mix was
|
||||
// frozen at exactly 0.42 from game 12 to the end of the run.
|
||||
hordeGrow: 0.2, // horde length AND spawn rate both scale by this per set already banked
|
||||
zHordeCap: 34, // hard body ceiling DURING a horde. A horde ignores the director cap on
|
||||
// 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.
|
||||
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
|
||||
// bank on zb.chip and only whole points come off hp (the wound SKINS switch on hp thresholds).
|
||||
dmgBall: 1, // a splat-quality ball or serve
|
||||
dmgSide: 1, // one TRIPLE side ball — used to pass straight through the boss doing nothing
|
||||
dmgPellet: 0.34, // one machine-gun round — three chip a point off. Also used to no-op on the boss
|
||||
dmgBomb: 3, // caught in a blast. Was an instant kill that ignored hp entirely
|
||||
dmgFlame: 2, // a direct flame-ball touch (and it sets the boss alight, which it didn't before)
|
||||
bossBurnMul: 0.4, // burnDps multiplier while a boss body cooks — it burns, just slower than a body
|
||||
changeoverT: 5.0, // s the changeover breather lasts (after odd games, as the rules demand)
|
||||
boyMopSpeed: 2.2, // m/s a MOPPING ball boy crosses at (a fetching one still sprints at 7.5)
|
||||
boyMopDwell: 1.8, // s he stands on a stain pushing the mop before moving to the next one
|
||||
|
||||
+2
-1
@@ -442,7 +442,8 @@ function startHorde() {
|
||||
if (G.horde) return;
|
||||
G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked
|
||||
G.pendingGame = false; // the horde IS the announcement
|
||||
G.horde = { t: TUNE.hordeLen, spawnT: 0, mgT: 0 };
|
||||
const grow = 1 + (G.sets[0] + G.sets[1]) * TUNE.hordeGrow; // each set banked makes the next wave worse
|
||||
G.horde = { t: TUNE.hordeLen * grow, spawnT: 0, mgT: 0, rate: TUNE.hordeRate * grow };
|
||||
ball.active = false; G.phase = 'rally'; // ball gone; 'rally' lets the movement block steer P
|
||||
P.pose = 'idle'; P.poseT = 0; P.swingT = -1; // clear the triggering shot's serve/swing pose
|
||||
P.z = HOME.P.z; // brace at the baseline — the horde is a hold-the-line stand (finger steers x only)
|
||||
|
||||
+1
-1
@@ -60,6 +60,6 @@
|
||||
<p class="start">TAP TO START</p>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="main.js?v=38"></script>
|
||||
<script type="module" src="main.js?v=39"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -190,15 +190,20 @@ function drawZombie(zb) {
|
||||
} else if (SPR.z_gw) name = 'z_gw'; // a missing asset degrades to the last skin that loaded
|
||||
}
|
||||
else if ((zb.variant === 'a' || zb.variant === 'b') && zb.hp < TUNE.shamblerHp && SPR['z_' + zb.variant + 'w']) name = 'z_' + zb.variant + 'w'; // gashed shambler
|
||||
else if (rk && zb.hp <= 1 && 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 (rk && zb.hp === 2 && SPR.z_cw1) name = 'z_cw1';
|
||||
// The boss's damage is read as a FRACTION of full health — his hp scales with the run
|
||||
// (TUNE.racketHpPerGame), so the old `hp === 2` / `hp <= 1` thresholds would have left a late-run
|
||||
// boss looking untouched until the killing blow. Keep these in step with bleed()'s anchors.
|
||||
else if (rk) {
|
||||
const f = zb.hp / (zb.hpMax || TUNE.racketHp);
|
||||
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';
|
||||
}
|
||||
drawStatic(name, zb.x, zb.z, h, Math.floor(zb.walkT * 5.2) % 2);
|
||||
if (zb.burnT > 0) drawFlames(zb, h);
|
||||
if (rk) { // HP bar floating above the mini-boss
|
||||
const p = proj(zb.x, 2.05, zb.z), w = Math.max(10, p.s * 0.5), x = p.x - w / 2;
|
||||
ctx.fillStyle = '#300'; ctx.fillRect(x, p.y, w, 3);
|
||||
ctx.fillStyle = '#e03030'; ctx.fillRect(x, p.y, w * Math.max(0, zb.hp) / TUNE.racketHp, 3);
|
||||
}
|
||||
// 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
|
||||
// other multi-hit enemy uses. The first-sight modal in spawnRacketZombie explains him instead.
|
||||
}
|
||||
// Fire, drawn procedurally instead of as a sprite sheet: a few vertically-stretched quads whose
|
||||
// offsets ride each body's own phase (walkT), so no two burning zombies flicker in sync — and it
|
||||
|
||||
+111
-37
@@ -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, COURT } from './config.js';
|
||||
import { TUNE, UI, COURT } 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 } from './gameplay.js';
|
||||
import { splatPoint, cheer, spentBall, resolveInterlude, showModal } 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
|
||||
@@ -18,7 +18,10 @@ export const extras = []; // triple-serve side balls {x, y, z, vx, vy, vz, pow,
|
||||
let spawnT = 1.5, moanT = 3;
|
||||
|
||||
export function spawnZombie(x, z, variant) {
|
||||
const ramp = 1 + Math.min(1.5, G.splatT / 70); // they get faster the longer you survive
|
||||
// Keyed on GAMES FINISHED, not on splatT: the old `1 + min(1.5, splatT / 70)` maxed out after
|
||||
// 105 seconds of splatter, so every shambler from roughly game 3 onward walked at the same top
|
||||
// speed for the rest of a 50-game run. Games are what the player experiences as progress.
|
||||
const ramp = 1 + Math.min(TUNE.zSpeedMax, G.runGames * TUNE.zSpeedPerGame);
|
||||
const zb = { x, z, v: TUNE.zSpeed * ramp * (0.8 + Math.random() * 0.4), walkT: Math.random() * 9,
|
||||
homeOff: (Math.random() * 2 - 1) * 0.8, variant }; // personal lane offset so they don't stack into a column
|
||||
if (variant === 'f') { zb.v = Math.min(TUNE.runnerVMax, zb.v * TUNE.runnerSpeed); shriek(); } // shriek = the fairness contract; capped so late runners stay reactable
|
||||
@@ -28,17 +31,26 @@ export function spawnZombie(x, z, variant) {
|
||||
zombies.push(zb);
|
||||
}
|
||||
|
||||
// which shambler steps out next: the roster grows one type per set (runner: set 1 game 3+,
|
||||
// brute: set 2, shield: set 3). Fixed random slots so the classics stay the crowd.
|
||||
// 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.
|
||||
// Now the advanced SHARE keeps climbing after everything is unlocked, which is what makes game 40
|
||||
// feel different from game 20 rather than merely faster.
|
||||
function pickVariant() {
|
||||
const tier = G.sets[0] + G.sets[1], r = Math.random();
|
||||
if (tier >= 2 && r < 0.15) return 's';
|
||||
if (tier >= 1 && r >= 0.15 && r < 0.30) return 'g';
|
||||
if ((tier >= 1 || G.runGames >= 2) && r >= 0.30 && r < 0.42
|
||||
&& !zombies.some(z => z.variant === 'f')) return 'f'; // at most one runner alive
|
||||
const g = G.runGames;
|
||||
const pool = [];
|
||||
if (g >= 2 && !zombies.some(z => z.variant === 'f')) pool.push('f'); // at most one runner alive
|
||||
if (g >= 6) pool.push('g');
|
||||
if (g >= 12) pool.push('s');
|
||||
const nasty = Math.min(TUNE.nastyMax, g * TUNE.nastyPerGame);
|
||||
if (pool.length && Math.random() < nasty) return pool[Math.floor(Math.random() * pool.length)];
|
||||
return Math.random() < 0.5 ? 'a' : 'b';
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// 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
|
||||
@@ -76,23 +88,39 @@ function killZombie(i, quiet) {
|
||||
// Read-only over zombies[] (it only sets burnT), so it is safe to call from inside a kill loop.
|
||||
export function ignite(x, z, r) {
|
||||
for (const zb of zombies) {
|
||||
if (!zb || zb.variant === 'r' || zb.burnT > 0) continue; // the duel opponent doesn't burn
|
||||
if (!zb || zb.burnT > 0) continue; // the boss DOES catch now — just at TUNE.bossBurnMul the rate,
|
||||
// so fire is a real answer to a big body instead of a no-op
|
||||
if (Math.hypot(zb.x - x, zb.z - z) < r) { zb.burnT = TUNE.burnTime; if (zb.hp === undefined) zb.hp = 1; }
|
||||
}
|
||||
}
|
||||
|
||||
// Bank `amount` of damage on a multi-hit body and return true once it should DIE. Fractional amounts
|
||||
// are legal (a machine-gun pellet chips a third of a point): they accumulate on zb.chip and only
|
||||
// WHOLE points come off hp, because the wound skins and bleed() anchors switch on hp thresholds and
|
||||
// a fractional hp would never match one. The caller decides HOW it dies — splatter() for the
|
||||
// multi-kill fanfare, killZombie() for a quiet one — and must stop touching zb afterwards, because
|
||||
// killZombie splices the array. This is the one place damage is applied, so a future boss with 20 hp
|
||||
// needs no new code: every weapon is already proportional to it.
|
||||
function wound(zb, amount, fx = true) {
|
||||
zb.chip = (zb.chip || 0) + amount;
|
||||
let took = 0;
|
||||
while (zb.chip >= 1 && zb.hp > 0) { zb.chip -= 1; zb.hp--; took++; }
|
||||
if (took && fx) { splat(0.5); woundBurst(zb); }
|
||||
return zb.hp <= 0;
|
||||
}
|
||||
|
||||
// one burning body's frame: damage, spread, embers. Returns true if the fire killed it (the
|
||||
// caller must then stop touching zb — killZombie splices, and splatPoint can cascade further).
|
||||
function burnTick(zb, i, dt) {
|
||||
zb.burnT -= dt;
|
||||
ignite(zb.x, zb.z, TUNE.burnSpread);
|
||||
zb.burnD = (zb.burnD || 0) + TUNE.burnDps * dt;
|
||||
while (zb.burnD >= 1) { zb.burnD -= 1; zb.hp--; }
|
||||
// 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);
|
||||
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,
|
||||
t: 0.25 + Math.random() * 0.2, fire: true });
|
||||
if (zb.hp > 0) return false;
|
||||
if (!dead) return false;
|
||||
killZombie(i, !!G.horde);
|
||||
return true;
|
||||
}
|
||||
@@ -124,7 +152,11 @@ function woundBurst(zb) {
|
||||
|
||||
// swishing the racket cleaves every shambler in reach — it's the panic button for when they're
|
||||
// already on top of you, so it stays a full sweep. TUNE.meleeCool is the brake that stops it
|
||||
// from being the default attack. (the racket-zombie boss shrugs melee off entirely)
|
||||
// from being the default attack.
|
||||
// The boss skip is NOT a gap in the damage model: it is unreachable by construction. The player is
|
||||
// bounded at P_BOUNDS.zMin (netZ + 1.2 = 13.1) and the boss holds z <= COURT.svcFar (5.5), so they
|
||||
// are always on opposite sides of the net and meleeRange 1.7 can never span it. Left as a skip
|
||||
// rather than "fixed", because a melee path to the boss would need him to cross the net first.
|
||||
export function meleeSwipe(x, z, range) {
|
||||
let hit = 0;
|
||||
for (let i = zombies.length - 1; i >= 0; i--) {
|
||||
@@ -157,8 +189,18 @@ function firePellet() {
|
||||
pock(0.25, 1400); // rapid fire tick; if it clips on device, gate to every 2nd pellet
|
||||
}
|
||||
export function spawnRacketZombie() { // one takes up residence on the far court — an opponent, of sorts
|
||||
// He stiffens as the run goes on. hpMax is stored because the wound skins and bleed() anchors read
|
||||
// a FRACTION of full health now — hardcoded `hp === 2` thresholds would leave a late-run boss
|
||||
// looking pristine right up to the killing blow.
|
||||
const hp = TUNE.racketHp + Math.floor(G.runGames * TUNE.racketHpPerGame);
|
||||
zombies.push({ x: (Math.random() * 2 - 1) * 2.5, z: 3.5, v: 0, walkT: Math.random() * 9,
|
||||
variant: 'r', side: 'O', facing: 1, pose: 'idle', poseT: 0, swingT: -1, swingCool: 0, runT: 0, hp: TUNE.racketHp });
|
||||
variant: 'r', side: 'O', facing: 1, pose: 'idle', poseT: 0, swingT: -1, swingCool: 0, runT: 0,
|
||||
hp, hpMax: hp });
|
||||
if (!G.seen.racket) { // it is not a shambler and it does not behave like one — say so, once
|
||||
G.seen.racket = true;
|
||||
showModal({ title: 'IT HAS A RACKET', color: UI.hot,
|
||||
body: ['IT RETURNS YOUR WEAK SHOTS', 'ONLY A FULL PULL WOUNDS IT'] });
|
||||
}
|
||||
}
|
||||
function endHorde() {
|
||||
G.horde = null; pellets.length = 0;
|
||||
@@ -169,7 +211,12 @@ function endHorde() {
|
||||
function tickHorde(dt) {
|
||||
const h = G.horde;
|
||||
h.t -= dt;
|
||||
if ((h.spawnT -= dt) <= 0) { spawnHordeZombie(); h.spawnT = 1 / TUNE.hordeRate; }
|
||||
// h.rate is stamped by startHorde and grows per set banked — every horde used to be the same
|
||||
// twelve seconds at the same 2.5/s, so the fifth one was indistinguishable from the first.
|
||||
if ((h.spawnT -= dt) <= 0) {
|
||||
if (zombies.length < TUNE.zHordeCap) spawnHordeZombie(); // ...but bounded, see TUNE.zHordeCap
|
||||
h.spawnT = 1 / (h.rate || TUNE.hordeRate);
|
||||
}
|
||||
if ((h.mgT -= dt) <= 0) { firePellet(); h.mgT = 1 / TUNE.mgRate; }
|
||||
if (h.t <= 0) endHorde();
|
||||
}
|
||||
@@ -177,8 +224,14 @@ function tickHorde(dt) {
|
||||
// bomb ball: everything inside bombRadius splatters at once (each kill still ++ball.kills so
|
||||
// the Phase-2 fanfare escalates), plus a crater — big stain, size-3 pile, extra particles, boom.
|
||||
export function explode(x, z) {
|
||||
for (let i = zombies.length - 1; i >= 0; i--) // backwards: splatter() splices (and can sweep) the array
|
||||
if (zombies[i] && Math.hypot(zombies[i].x - x, zombies[i].z - z) < TUNE.bombRadius) splatter(i);
|
||||
for (let i = zombies.length - 1; i >= 0; i--) { // backwards: splatter() splices (and can sweep) the array
|
||||
const zb = zombies[i];
|
||||
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); }
|
||||
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.0, a: 0.9 }); if (gore.length > 40) gore.shift();
|
||||
// a ring of splatter thrown outward, so the blast paints the lawn instead of leaving one puddle
|
||||
@@ -218,8 +271,13 @@ function bleed(zb) {
|
||||
if (zb.hp <= TUNE.bruteHp - 3) wounds.push([0.05, 2.35, 2.0]);
|
||||
if (zb.hp <= 1) wounds.push([0.0, 1.2, 0.7]);
|
||||
}
|
||||
else if (zb.variant === 'r' && zb.hp <= 1) wounds = [[0.05, 1.5, 2.4], [0.4, 1.15, 0.9]]; // headless: neck spurt + torn arm
|
||||
else if (zb.variant === 'r' && zb.hp === 2) wounds = [[0.4, 1.2, 1.0]]; // racket: torn-off arm
|
||||
// the boss reads as a FRACTION of full health, not against hardcoded hp numbers: his hp scales
|
||||
// with the run now (racketHpPerGame), so `hp === 2` would leave a late-run boss bleeding from
|
||||
// nothing until the very last hit. Must stay in step with drawZombie's skin thresholds.
|
||||
else if (zb.variant === 'r' && zb.hp / (zb.hpMax || TUNE.racketHp) <= 0.34)
|
||||
wounds = [[0.05, 1.5, 2.4], [0.4, 1.15, 0.9]]; // headless: neck spurt + torn arm
|
||||
else if (zb.variant === 'r' && zb.hp / (zb.hpMax || TUNE.racketHp) <= 0.67)
|
||||
wounds = [[0.4, 1.2, 1.0]]; // racket: torn-off arm
|
||||
else if ((zb.variant === 'a' || zb.variant === 'b') && zb.hp < TUNE.shamblerHp) wounds = [[0.28, 1.35, 0.85]]; // shambler: gashed shoulder
|
||||
else return;
|
||||
for (const w of wounds) {
|
||||
@@ -248,11 +306,14 @@ export function updateZombies(dt) {
|
||||
}
|
||||
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)
|
||||
spawnT -= dt;
|
||||
// the spawn director: the match's age (games finished) sets both crowd size and tempo
|
||||
const cap = Math.min(TUNE.zMaxCap, TUNE.zMax + G.runGames);
|
||||
// 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
|
||||
// the game literally stopped changing. The gap is hyperbolic now so it keeps tightening on its
|
||||
// own instead of running into a floor; zSpawnMin is only a safety stop.
|
||||
const cap = Math.min(TUNE.zMaxCap, TUNE.zMax + Math.floor(G.runGames * TUNE.zMaxPerGame));
|
||||
if (spawnT <= 0 && zombies.length < cap) {
|
||||
spawnZombie((Math.random() * 2 - 1) * 4.8, -1.5 - Math.random(), pickVariant());
|
||||
spawnT = Math.max(TUNE.zSpawnMin, TUNE.zSpawnEvery - G.runGames * TUNE.zSpawnStep);
|
||||
spawnT = Math.max(TUNE.zSpawnMin, TUNE.zSpawnEvery / (1 + G.runGames * TUNE.zSpawnAccel));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -314,15 +375,16 @@ export function updateZombies(dt) {
|
||||
}
|
||||
if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) {
|
||||
const d = Math.hypot(ball.x - zb.x, ball.z - zb.z);
|
||||
if (d < (zb.variant === 'g' ? 0.75 : 0.55) + ballSweep) { // the brute is a bigger target
|
||||
if (d < hitR(zb) + ballSweep) { // brute and boss are bigger targets
|
||||
if (ball.bomb) { // detonate on first contact, whatever the pace
|
||||
explode(ball.x, ball.z); ball.bomb = false;
|
||||
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 only takes a wound
|
||||
zb.hp--; splat(0.5);
|
||||
if (zb.hp <= 0) { splatter(i); } continue;
|
||||
if (zb.variant === 'r') { // the boss takes a heavy wound AND catches light
|
||||
if (wound(zb, TUNE.dmgFlame)) splatter(i);
|
||||
else zb.burnT = TUNE.burnTime;
|
||||
continue;
|
||||
}
|
||||
const vx0 = ball.vx, vz0 = ball.vz, bx = zb.x, bz = zb.z;
|
||||
splatter(i); ball.vx = vx0; ball.vz = vz0; // undo splatter's pierce tax: full pace through the lane
|
||||
@@ -331,11 +393,13 @@ export function updateZombies(dt) {
|
||||
}
|
||||
if (zb.variant === 'r') { // mini-boss: splat-quality wounds it; soft balls it rallies (racketAI)
|
||||
if ((ball.pow || 0) >= TUNE.zSplatQ) {
|
||||
zb.hp--; splat(0.5); pock(0.6, 300);
|
||||
const dead = wound(zb, TUNE.dmgBall);
|
||||
pock(0.6, 300);
|
||||
ball.vx *= 0.3; ball.vz *= 0.3; ball.lastHit = 'X'; // spent on the hit
|
||||
if (zb.hp <= 0) { splatter(i); continue; } // killing blow: full gore + point
|
||||
if (dead) { splatter(i); continue; } // killing blow: full gore + point
|
||||
}
|
||||
continue; // soft balls pass through, for the AI to return
|
||||
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) {
|
||||
@@ -377,9 +441,13 @@ export function updateZombies(dt) {
|
||||
let hit = false;
|
||||
for (let j = zombies.length - 1; j >= 0; j--) {
|
||||
const zb = zombies[j];
|
||||
if (!zb || zb.variant === 'r') continue;
|
||||
if (Math.hypot(zb.x - pe.x, zb.z - pe.z) < (zb.variant === 'g' ? 0.75 : 0.55)) {
|
||||
if (zb.hp > 1) { zb.hp--; pock(0.5, 260); } // brutes AND shamblers soak pellets — wound, don't kill
|
||||
if (!zb) continue;
|
||||
if (Math.hypot(zb.x - pe.x, zb.z - pe.z) < hitR(zb)) {
|
||||
// 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); }
|
||||
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;
|
||||
}
|
||||
@@ -395,10 +463,16 @@ export function updateZombies(dt) {
|
||||
let dead = e.t <= 0 || Math.abs(e.x) > 7.5 || e.z < -2 || e.z > 25.5;
|
||||
if (!dead && e.y < 1.9) for (let j = zombies.length - 1; j >= 0; j--) {
|
||||
const zb = zombies[j];
|
||||
if (!zb || zb.variant === 'r') continue; // side balls pass the boss; !zb: kills can sweep mid-loop
|
||||
if (Math.hypot(zb.x - e.x, zb.z - e.z) < (zb.variant === 'g' ? 0.75 : 0.55)
|
||||
if (!zb) continue; // killZombie can sweep the court mid-loop
|
||||
if (Math.hypot(zb.x - e.x, zb.z - e.z) < hitR(zb)
|
||||
+ Math.hypot(e.vx, e.vz) * dt * 0.5) { // same sweep: clones inherit the smash pace
|
||||
if (zb.variant === 's' && e.vz < 0 && !(zb.shieldT > 0)) { zb.shieldT = TUNE.shieldDown; pock(0.9, 1800); } // chair blocks these too
|
||||
// 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 (e.pow >= TUNE.zSplatQ) { if (wound(zb, TUNE.dmgSide)) killZombie(j, !!G.horde); }
|
||||
else pock(0.5, 300);
|
||||
}
|
||||
else if (zb.variant === 's' && e.vz < 0 && !(zb.shieldT > 0)) { zb.shieldT = TUNE.shieldDown; pock(0.9, 1800); } // chair blocks these too
|
||||
else if (e.pow >= TUNE.zSplatQ || zb.variant === 'f') {
|
||||
if (zb.hp > 1) { zb.hp--; splat(0.4); } // wounds brutes AND shamblers
|
||||
else killZombie(j, !!G.horde);
|
||||
|
||||
Reference in new issue
Block a user