Make the pink weapons throw themselves, and let the big bodies soak

Oliver's playtest of the barrel weapons. Four notes, all four real defects.

"The bigger guys were way too easy to kill at the beginning." A bug, and an old
one. The ball-vs-body chain handled boss, g/m, s, f and a/b, and everything NOT
named in it fell through to "splat-quality kills outright" -- so the STEWARD has
died to one serve since the day he shipped with stewardHp 5 and a comment calling
it a brute's worth of hp, and the three new bodies inherited that the moment they
existed. Pellets and melee always read hp; only the ball skipped it. One generic
hpMax > 1 branch now, and measured after: shambler 2, steward 5, brute 5, ball
boy 3, bouncer 5, roller man 7 -- each exactly its hp.

"I have seen all of them by round 10." The new three move 3/8/15 -> 5/12/20. The
original four keep their gates: that curve was already tuned through game 12.

"The laser fired only when I tapped, that was confusing." The whole tap-per-round
kind is gone. A barrel weapon is now either the normal ball gesture made
spectacular or something that fires itself -- nothing waits for a tap.
 - laser is a serve, and the RAY is the weapon: no ball anywhere in it. The
   preview IS the wedge, it leaves the RACKET rather than lying on the grass, the
   release erases the plane along its own path behind a bright front, and the
   edges are hairlines instead of the heaviest lines on the court.
 - beacon and rocket are LOBBED bursts -- `arc` turns a flat pellet into a thrown
   one, solved onto the nearest body ahead, and landing is itself a hit so a
   rocket that misses still goes off where it lands. The beacon changes colour
   every ball and drags that colour behind it.
 - flamer got "way more pronounced": 30 rounds/s, a ~10 m cone, embers.

"I got the laser almost every time." A flat 1-in-4 over four items does not feel
uniform. BARREL_WEAPONS carries weight and a `from` gate now, never repeats twice
running, and the beacon is what you meet first. Measured: laser 16-28%, rocket
unlocked at game 12 so it is genuinely a late find.

And the barrel keeps its pink halo -- that was always procedural, painted by
drawBarrel, and never went near the chroma key. Three rings and a pool on the
lawn now, because one flat disc read as a smudge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-03 14:13:05 +02:00
1 parent 25f915f236
commit 282f5145a9
8 files changed
+363 -98

No files matched your search

+56
View File
@@ -460,6 +460,62 @@ Known generator quirks, all still true:
- **Colour-match any regenerated frame** of an existing pair before shipping: fit a per-channel
linear map onto the original's mean and sd, then verify with a mean-difference number.
## Session log: Oliver's playtest of the barrel weapons (2026-08-03, same day)
Everything below is a fix to what shipped that morning. All four notes were real defects, which is
now seven playtests in a row where "this feels odd" was a bug and not taste.
**"The bigger guys were way too easy to kill at the beginning."** It was a bug, and an old one. The
ball-vs-body chain handled boss, `g`/`m`, `s`, `f`, `a`/`b` — and **everything not named in it fell
through to "splat-quality kills outright"**. So the STEWARD has been dying to one serve since the day
he shipped with `stewardHp: 5` and a comment calling it "a brute's worth of hp", and the ball boy,
bouncer and roller man inherited that the moment they existed. Pellets and melee always read `hp`;
only the BALL skipped it. It is one generic `hpMax > 1` branch now — hp is the only thing that says
how many hits a body takes — and measured after: shambler 2, steward 5, brute 5, ball boy 3,
bouncer 5, roller man 7, each exactly its hp. **Anything that adds a body type must NOT need a new
branch here; if you find yourself writing one, that is the bug coming back.**
**"I have seen all of them by round 10."** The new three moved 3/8/15 -> **5/12/20**. The original
four keep their gates exactly: the curve through ~game 12 was already tuned and the calibration rule
says not to touch it. Their hp came down with the fix landing (9 was a fine number while nobody
felt it and a long grind once they did).
**"The laser fired only when I tapped, that was confusing."** The whole `kind: 'shot'` idea is gone.
A barrel weapon is now exactly one of two things, and this is the rule to hold: **either it is the
normal ball gesture made spectacular, or it fires itself for a while.** Nothing waits for a tap.
- **laser** is a `serve` now: pull, aim, release. **The RAY is the weapon**, so there is no ball
anywhere in it — the preview is the WEDGE itself (`drawBeamWedge` at low alpha, not a ballistic
band with a target ring), the tap path has its own `tryLaser` so it never tosses a ball it is not
going to hit, and `fireBeam` kills everything inside the cone (`beamW0` 0.35 m at the racket to
`beamW1` 4.2 m at the far end). Measured: 8 of 11 bodies in one release.
Three things Oliver corrected on the second look, all of them "it must read as light he fired":
**it leaves the RACKET** (`dragContact()`, y 2.2, settling onto the court as it goes out — drawn
flat from his feet it read as a shape lying on the grass); **the release ERASES it along the path**
(the plane is whole at the flash and is wiped away from the racket outward behind a bright front,
so the shot reads as energy travelling down the lane rather than a decal fading where it lay); and
**the edges are hairlines** — at 2.5 px they were heavier than anything else on the court and read
as a drawn outline instead of as light.
*Gotcha for testing:* `TUNE.pullMin` is 18 **PIXELS**, and `aim.fy` is NEGATIVE for the down-pull.
A synthetic `{fx, fy, mag: 0.8}` is silently below the threshold and `previewServe` returns null,
which looks exactly like the preview being broken.
- **beacon** and **rocket** are `burst` and **LOBBED** — `arc` on the WEAPONS row turns a flat pellet
into a thrown one (solved onto the nearest body ahead), and landing is itself a hit, so a rocket
that misses still goes off where it lands. The beacon changes colour every ball and drags that
colour behind it.
- **flamer** got "way more pronounced": 30 rounds/s, a ~10 m cone, fat glowing rounds with embers.
**The pale blue washed out to grey over the lawn** — a beam colour has to be saturated (`#3f74ff`),
which is the same lesson as the barrel's halo, from the opposite direction.
**"I got the laser almost every time."** A flat 1-in-4 over four items does not feel uniform. It is
weighted and gated now (`BARREL_WEAPONS` carries `weight` and `from`), never repeats twice running,
and the beacon is what you meet first. Measured: laser 16-28% depending on the game, rocket unlocked
at game 12 so it is genuinely a late find.
**The barrel's pink is procedural and can be as pink as it likes** — Oliver asked whether it could
still have a pink halo given the key eats magenta out of PNGs, and yes: `drawBarrel` paints it at
draw time and never goes near the keyer. Three rings plus a pool on the lawn now, because one flat
disc read as a smudge.
## Session log: a rack you fill yourself, and barrels that fill it (2026-08-03)
The second half of the same ask. **The rack no longer has fixed slots.** It used to be five hardcoded
+53 -24
View File
@@ -230,9 +230,12 @@ export const TUNE = {
mgPelletR: 0.5, // m of extra hit radius on a pellet: a fat round. "Bigger ball = more damage."
dmgPelletHp: 2, // hp a pellet takes off a body — shamblerHp is 2, so the horde gun one-shots them
mgPickupLen: 8, // s the machine-gun tin (4th delivery) auto-fires outside hordes
shotHoldLen: 14, // s you stay braced with a TAP-fired weapon (laser, rocket) before the window
// closes on its own. Running the magazine out ends it earlier, which is the
// normal way; this only stops a forgotten rocket freezing the serve loop.
// ---- THE LASER's wedge: aim and release like a serve, and a plane of light widens down the lane
beamLen: 26, // m it reaches — the whole court, so a lane you aim is a lane you clear
beamW0: 0.35, // m half-width at the racket...
beamW1: 4.2, // ...and at the far end. It is a CONE: the spread is the point of it
beamT: 0.42, // s the wedge stays drawn
dmgBeam: 3, // hp the beam takes off anything it catches (dmgBomb is 3, and it reads the same)
// ---- the weapon barrel: the only way to get the four weapons that are not on the duel ladder
barrelEvery: 3, // games between barrels being rolled on (like the ball machine's machEvery)
barrelRoll: 2.6, // m/s it trundles across the far court — fast enough to have to be led
@@ -249,9 +252,12 @@ export const TUNE = {
// ---- the late roster. Three bodies that arrive as the run goes on, filling the gap between the
// 2 hp shamblers and the 5 hp brute and then extending past it. Each has one wound skin per hit it
// can still take, so the damage is readable without a bar (the rule the boss's missing bar set).
// These are now REAL: the ball path used to one-shot anything it had no branch for, so all three
// died to a single good serve no matter what these said. Retuned once they actually bite — 9 was
// fine as a number nobody felt and is a long grind as a number you do.
ballboyHp: 3, // the ball boy ('e') — a stock-sized body that takes three, with two wound skins
bouncerHp: 6, // the bouncer ('h') — 2.4 m, shoves through the crowd instead of shambling round it
rollerHp: 9, // the roller man ('k') — 3.2 m, hauling a court roller. The heaviest thing on court
bouncerHp: 5, // the bouncer ('h') — 2.4 m, shoves through the crowd instead of shambling round it
rollerHp: 7, // the roller man ('k') — 3.2 m, hauling a court roller. The heaviest thing on court
rollerStride: 0.62, // m he covers per lurch frame — longer than the brute's 0.5: a bigger body,
// a longer stride, or his feet slide the way the brute's used to
// fire: the flame ball no longer just kills what it touches, it starts an epidemic.
@@ -422,11 +428,15 @@ export const ZWOUND = {
// out (SLOTS, slotStock, WDESC, drawRack's three parallel arrays, and deliverTin's index mapping).
// The rack no longer has a fixed weapon per slot: G.rack holds up to four of these in the order you
// GATHERED them, and slot 0 stays BALL. A weapon is defined entirely by its row.
// kind 'serve' — arms the next serve (the four original tins)
// kind 'burst' — commits to a braced auto-fire window: `len` seconds at `rate` rounds/s
// kind 'shot' — same braced stance, but each TAP fires one and `tin` is the magazine
// The pellet fields (r, dmg, pierce, boom, ignite, col, speed) are stamped onto the round by
// firePellet, so all four new weapons are the ONE existing pellet loop with different numbers.
// kind 'serve' — arms the next serve: you aim and release it like a normal ball
// kind 'burst' — commits to a braced window that FIRES ITSELF: `len` seconds at `rate` rounds/s
// There is no third kind. A tap-per-round weapon shipped once and Oliver read it as broken —
// "the laser fired only when I tapped, that was confusing" — so a barrel weapon is now always one
// of exactly two things: the normal ball gesture made spectacular, or something that fires on its
// own for a while. Nothing in between.
// The pellet fields (r, dmg, hp, pierce, boom, ignite, arc, trail, col, speed, life) are stamped
// onto the round by firePellet, so every auto-firing weapon is the ONE pellet loop with different
// numbers — `arc` is what turns a flat round into a lobbed one.
export const WEAPONS = {
bomb: { label: 'BOMB', icon: 'w_bomb', tin: 3, kind: 'serve',
desc: ['DETONATES ON CONTACT', 'CLEARS A WHOLE CROWD AT ONCE'] },
@@ -438,24 +448,43 @@ export const WEAPONS = {
r: 0.5, dmg: 0.34, hp: 2, speed: 26,
desc: ['HOLD THE LINE AND SPRAY', 'A BURST, NOT A SERVE'] },
// ---- the four that come out of a barrel. Won by shooting one, never delivered.
beacon: { label: 'BEACON', icon: 'w_beacon', tin: 1, kind: 'burst', len: 10, rate: 3,
r: 1.1, dmg: 3, hp: 9, speed: 17, col: '#7fefff', glow: 1,
desc: ['A SLOW BEAT OF BIG', 'GLOWING SHELLS'] },
flamer: { label: 'FLAMER', icon: 'w_flamer', tin: 1, kind: 'burst', len: 5, rate: 16,
r: 0.8, dmg: 2, hp: 9, speed: 11, life: 0.55, ignite: true, col: '#ffa040', glow: 1, // life 0.55s x 11 m/s = a ~6 m cone
desc: ['A CONE OF FIRE', 'AND THE FIRE SPREADS'] },
laser: { label: 'LASER', icon: 'w_laser', tin: 10, kind: 'shot',
// A LOBBED tennis ball twice the weight of yours, thrown on its own beat, changing colour every
// shot and dragging its colour behind it. The showiest thing in the game and the one you meet first.
beacon: { label: 'BEACON', icon: 'w_beacon', tin: 1, kind: 'burst', len: 11, rate: 1.6,
r: 1.2, dmg: 2, hp: 4, arc: 1, hue: 1, trail: 3, glow: 1.6, speed: 19,
desc: ['IT THROWS ITSELF', 'TWICE THE BALL, EVERY COLOUR'] },
// "make this one way more pronounced": twice the rounds a second, a wider and longer cone, fat
// glowing rounds that throw their own embers, and every one of them lights what it touches.
flamer: { label: 'FLAMER', icon: 'w_flamer', tin: 1, kind: 'burst', len: 6, rate: 30,
r: 1.15, dmg: 2, hp: 9, speed: 15, life: 0.7, ignite: true, igniteR: 2.6,
trail: 2, col: '#ff8420', glow: 2, spread: 3.1, // life 0.7s x 15 m/s = a ~10 m cone
desc: ['A WALL OF FIRE', 'AND THE FIRE SPREADS'] },
// The normal ball gesture, made spectacular: pull, aim, release — and instead of a ball you get a
// blue plane that widens down the court and cuts through everything standing in it.
laser: { label: 'LASER', icon: 'w_laser', tin: 6, kind: 'serve', beam: 1,
// NOT magenta: the icon art is chroma-keyed and the key eats every pink/purple
// (min(r,b) > g+40), so a magenta laser came back as a hollow outline. Pink stays
// the BARREL's identity, where it is a procedural glow and never goes near the key.
r: 0.55, dmg: 3, hp: 9, speed: 70, pierce: true, col: '#8ab6ff', glow: 1,
desc: ['TEN SHOTS, AND EACH ONE', 'GOES THROUGH THE WHOLE LANE'] },
rocket: { label: 'ROCKET', icon: 'w_rocket', tin: 3, kind: 'shot',
r: 0.6, dmg: 3, hp: 9, speed: 22, boom: true, col: '#ffd050', glow: 1,
desc: ['THREE ROCKETS', 'EACH ONE TAKES THE CROWD'] },
col: '#3f74ff', // saturated: a pale blue washed out to grey over the lawn
desc: ['AIM AND RELEASE', 'A WIDENING WALL OF LIGHT'] },
// Lobbed, self-throwing, and it detonates like a bomb where it lands. Deliberately a LATE pickup
// (see pickBarrelWeapon) — Oliver: "best later in the game when there is really a lot going on".
rocket: { label: 'ROCKET', icon: 'w_rocket', tin: 1, kind: 'burst', len: 9, rate: 0.85,
r: 0.75, dmg: 3, hp: 9, arc: 1, boom: true, trail: 2, speed: 17,
col: '#ffd050', glow: 1.4,
desc: ['IT LAUNCHES ITSELF', 'AND EVERY ONE IS A BOMB'] },
};
// What a pink barrel can be carrying — the four above, which you cannot win any other way. NOT a
// flat 1-in-4: on that, Oliver "got the laser almost every time" and never once saw the beacon.
// Weighted, gated, and it never hands you the same thing twice running (see pickBarrelWeapon).
// weight relative chance once it is unlocked
// from games finished before it can appear at all
export const BARREL_WEAPONS = {
beacon: { weight: 4, from: 0 }, // the showpiece, and the one you should meet first
flamer: { weight: 3, from: 2 },
laser: { weight: 2, from: 6 },
rocket: { weight: 3, from: 12 }, // the late-game answer: it should never turn up in game three
};
// what a pink barrel can be carrying — the four above that you cannot win any other way
export const BARREL_WEAPONS = ['beacon', 'laser', 'rocket', 'flamer'];
export const RACK_MAX = 4; // gathered weapons the rack holds; a 5th rides slot 0 until it is spent
export const MACH_H = 1.55; // was 1.15 — waist high read as a prop you could ignore. Still furniture
+38 -32
View File
@@ -5,7 +5,7 @@ import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy, res
import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck, menuDuck } from './audio.js';
import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js';
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
import { updateZombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel } from './zombies.js';
import { updateZombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam } from './zombies.js';
// ------------------------------------------------------------- ballistics ----
// Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g.
@@ -264,6 +264,15 @@ function hitServe(sv, quality, aim = null) {
// rack's call now. GUN (slot 4) never reaches here; it starts a burst before the serve.
const wsel = G.mode === 'splatter' ? G.weapon : 0;
const wid = wsel === 0 && !G.temp ? 'ball' : slotId(wsel), armed = slotStock(wsel) > 0;
// THE LASER fires instead of the ball, down the lane this serve was aimed at. Everything else
// about the gesture is untouched — the pull, the aim, the release — which is the whole point of it.
if (wid === 'laser' && armed) {
spendSlot(wsel);
fireBeam(ball.vx, ball.vz);
ball.active = false; G.phase = 'over'; G.overT = 0.5;
sv.pose = 'serve'; sv.poseT = 0;
return;
}
ball.bomb = wid === 'bomb' && armed;
if (ball.bomb) spendSlot(wsel);
ball.flame = wid === 'fire' && armed;
@@ -878,21 +887,18 @@ function racketAI(rz, dt) {
}
}
// A GUN-like weapon isn't a ball — committing to one braces you at the baseline for a window (the
// same mode a horde uses) and the serve gesture spends it instead of serving. Generalised from
// tryGunBurst: 'burst' weapons auto-fire at their own rate for `len` seconds, 'shot' weapons hold
// the same stance but fire one per TAP until the magazine is out. G.mgT stays the mode timer for
// BOTH, which is why the eighteen `!(G.mgT > 0)` guards across five files did not have to change.
// A BURST weapon isn't a ball — committing to one braces you at the baseline for a window (the same
// mode a horde uses) and it fires ITSELF for `len` seconds while your finger only steers. G.mgT is
// the mode timer, which is why the eighteen `!(G.mgT > 0)` guards across five files never changed.
// There was briefly a third kind that fired one round per tap; Oliver read it as the weapon being
// broken, so a barrel weapon is now either this or a serve. Nothing waits for a tap.
function tryArmGun() {
if (G.mode !== 'splatter') return false;
const id = slotId(G.weapon), w = WEAPONS[id];
if (!w || (w.kind !== 'burst' && w.kind !== 'shot') || slotStock(G.weapon) <= 0) return false;
const slot = G.weapon;
if (!w || w.kind !== 'burst' || slotStock(G.weapon) <= 0) return false;
G.gunId = id;
if (w.kind === 'burst') { G.mgT = w.len ?? TUNE.mgPickupLen; G.gunShots = 0; spendSlot(slot); }
// a 'shot' weapon spends per ROUND, not on commit, so the magazine IS the stock: hold the stance
// for as long as it could take to empty it, and gunShots ends the window early when it runs out.
else { G.gunShots = slotStock(slot); G.mgT = TUNE.shotHoldLen; }
G.mgT = w.len ?? TUNE.mgPickupLen;
spendSlot(G.weapon);
G.mgCool = 0;
ball.active = false; G.phase = 'rally';
P.pose = 'idle'; P.poseT = 0; P.swingT = -1;
@@ -900,22 +906,7 @@ function tryArmGun() {
G.flash = { text: w.label, t: 1.0 };
return true;
}
// a tap while a 'shot' weapon is braced fires ONE round. Returns true if it consumed the tap.
export function tryGunShot() {
const w = WEAPONS[G.gunId];
if (!(G.mgT > 0) || !w || w.kind !== 'shot' || G.gunShots <= 0) return false;
firePellet(w);
G.gunShots--;
// the magazine lives in the slot it came from, so a half-spent laser you never finish stays half
// spent on the rack rather than silently refilling
const slot = [0, 1, 2, 3, 4].find(i => slotId(i) === G.gunId);
if (slot !== undefined) spendSlot(slot);
if (G.gunShots <= 0) G.mgT = 0; // out of rounds: the window closes, updateZombies resumes serving
return true;
}
// slingshot serve, stage one: the pull-back starts the toss animation. The ball rises while
// you aim (dotted line, render.js); the release (dragServe) fires it. Only from a fresh pretoss.
export function serveToss() {
// A NEW WIND-UP ENDS THE LAST SHOT'S FOLLOW-THROUGH. Without this he latched: hitServe sets
// pose 'serve' with poseT 0, that only expires after POSE_TIME, and the pull frame is only drawn
@@ -926,6 +917,22 @@ export function serveToss() {
if (score.server === 'P' && G.phase === 'pretoss' && !ball.active) tossBall(P);
}
// The laser fired from a TAP (the keyboard/two-stage path). The drag path solves its own direction
// and goes through hitServe; this one has no arc to read a lane off, so it takes the held flick aim
// if there is one and otherwise fires straight up-court.
function tryLaser() {
if (G.mode !== 'splatter' || G.interlude) return false;
const w = WEAPONS[slotId(G.weapon)];
if (!w || !w.beam || slotStock(G.weapon) <= 0) return false;
const flick = takeFlickAim();
const dx = flick ? flick.fx : 0, dz = flick ? -Math.abs(flick.fy) : -1;
spendSlot(G.weapon);
fireBeam(dx, dz || -1);
ball.active = false; G.phase = 'over'; G.overT = 0.5;
P.pose = 'serve'; P.poseT = 0;
return true;
}
// release of the slingshot aim: an instant serve to the reticle (quality fixed — placement
// is this serve's power; the toss ritual keeps the PERFECT-timing power ceiling)
export function dragServe(aim) {
@@ -975,10 +982,6 @@ export function keyPressed(code) {
swish(0.3);
return;
}
// A braced 'shot' weapon (laser, rocket) fires ONE round per tap — that is the whole difference
// between it and a burst weapon, and it has to sit above the auto-fire guard below, which would
// otherwise swallow the tap as a swish.
if (tryGunShot()) return;
if (G.horde || G.mgT > 0) return; // auto-fire modes: a tap only swishes, never serves
if (G.changeover) return; // the breather is mandatory — the melee swish above still works
if (G.interlude && G.interlude.introT > 0) return; // let the umpire finish announcing the duel
@@ -992,7 +995,10 @@ export function keyPressed(code) {
if (flick) { dragServe(flick); return; } // dragServe handles GUN (burst) and the ball check
}
if (G.phase === 'pretoss' && score.server === 'P') {
if (tryArmGun()) return; // a GUN-like weapon selected: the gesture arms it, no ball
if (tryArmGun()) return; // a burst weapon selected: the gesture arms it, no ball
// ...and a LASER never tosses one either. The ray IS the weapon, so a two-stage toss ritual was
// throwing a ball into the air and then firing something else entirely.
if (tryLaser()) return;
tossBall(P); return; // plain tap: the two-stage toss ritual (tennis, and the duel)
}
if (G.phase === 'toss' && score.server === 'P') {
+1 -1
View File
@@ -179,6 +179,6 @@
</div>
</div>
</div>
<script type="module" src="main.js?v=98"></script>
<script type="module" src="main.js?v=99"></script>
</body>
</html>
+1 -1
View File
@@ -47,7 +47,7 @@ export function takeFlickAim(maxAgeMs = 450) {
// "drag walks the player" mode between shots was invisible state and read as a bug.
// a weapon you UNLOAD (burst or shot) rather than serve — it takes the tap directly, so the
// slingshot reticle must stay out of its way. Was `G.weapon !== 4` back when slot 4 was always GUN.
const isGunLike = () => { const w = WEAPONS[slotId(G.weapon)]; return !!w && (w.kind === 'burst' || w.kind === 'shot'); };
const isGunLike = () => WEAPONS[slotId(G.weapon)]?.kind === 'burst'; // a laser is SERVED, so it keeps the reticle
const canAimServe = () =>
!G.modal // the world is frozen behind a popup — the thumb that clears it must not draw a bow
+86 -11
View File
@@ -3,7 +3,7 @@
import { cv, ctx } from './dom.js';
import { TUNE, UI, EATEN_GRAB, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS } from './config.js';
import { P, O, ball, score, crowd, G, court } from './state.js';
import { predictLanding, previewServe , slotId, slotStock } from './gameplay.js';
import { predictLanding, previewServe , slotId, slotStock , dragContact } from './gameplay.js';
import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js';
// ---------------------------------------------------------------- palette ----
@@ -336,9 +336,20 @@ function drawBarrel(zb) {
const p = proj(zb.x, h * 0.5, zb.z);
const k = 0.55 + 0.45 * Math.sin((zb.roll || 0) * 3.1); // the pulse rides the roll, not a clock
ctx.save();
ctx.globalAlpha = 0.20 + k * 0.30; // the halo
ctx.fillStyle = UI.pink;
ctx.beginPath(); ctx.arc(Math.round(p.x), Math.round(p.y), Math.max(3, p.s * 0.30 * (0.8 + k * 0.4)), 0, 7); ctx.fill();
// THE PINK IS ALL HERE, and it can be as pink as it likes: this is light painted at draw time and
// it never goes near the chroma key that eats magenta out of PNGs. Three rings instead of one —
// a wide soft bloom, a bright body, and a white-hot core — because one flat disc at 20 px tall
// read as a smudge on the grass rather than as something GLOWING on a dark court.
const rgb = RGB(UI.pink);
for (const [rf, af] of [[0.62, 0.16], [0.34, 0.34], [0.17, 0.55]]) {
ctx.globalAlpha = af * (0.55 + k * 0.65);
ctx.fillStyle = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
ctx.beginPath(); ctx.arc(Math.round(p.x), Math.round(p.y), Math.max(2, p.s * rf * (0.85 + k * 0.35)), 0, 7); ctx.fill();
}
// ...and a pink pool on the LAWN under it, which is what actually sells "this thing is lit up"
const gp = proj(zb.x, 0, zb.z);
ctx.globalAlpha = 0.16 + k * 0.20;
ctx.beginPath(); ctx.ellipse(gp.x, gp.y, Math.max(3, gp.s * 0.55), Math.max(1.5, gp.s * 0.20), 0, 0, 7); ctx.fill();
ctx.globalAlpha = 1;
ctx.restore();
if (img) {
@@ -519,7 +530,12 @@ function drawShotBand(a, u0, flying) {
if (left <= 0.01) return;
// cool blue-white = this only staggers. It stays READABLE on the dark lawn: you still need to
// see where you're pointing, you just need to know it won't kill.
const hex = a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0';
// The LASER re-paints the band it is about to become: blue, and widening down the court instead
// of tapering, so the gesture SHOWS you the wedge before you commit to it. Oliver asked for the
// band itself to communicate the shape of the shot.
const beamW = WEAPONS[slotId(G.weapon)]?.beam ? 1 : 0;
const hex = beamW ? WEAPONS[slotId(G.weapon)].col
: a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0';
// the slice of arc to draw: everything while aiming, a short streak behind the ball in flight
const lo = flying ? Math.max(0, u0 - TAIL) : 0;
const hi = flying ? u0 : 1;
@@ -554,14 +570,57 @@ function drawShotBand(a, u0, flying) {
let prev = proj(head.x, head.y, head.z);
for (let i = 1; i <= N; i++) {
const w = at(a.T * uAt(i)), p = proj(w.x, w.y, w.z);
ctx.globalAlpha = (0.30 + 0.55 * a.f) * (0.45 + 0.55 * sAt(i)) * decay;
ctx.lineWidth = Math.max(1, p.s * (0.04 + a.f * 0.15) * (flying ? 0.75 : 1));
ctx.globalAlpha = (0.30 + 0.55 * a.f) * (0.45 + 0.55 * sAt(i)) * decay * (beamW ? 1.25 : 1);
// a normal band is brightest at the racket and thins away; the beam does the OPPOSITE, opening
// out toward the far end into the plane it is going to cut
ctx.lineWidth = beamW
? Math.max(1.5, p.s * (0.05 + 0.42 * (uAt(i))))
: Math.max(1, p.s * (0.04 + a.f * 0.15) * (flying ? 0.75 : 1));
ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(p.x, p.y); ctx.stroke();
prev = p;
}
ctx.globalAlpha = 1; ctx.lineWidth = 1;
}
// The laser's plane on the ground, shared by the aim PREVIEW and the fired shot. `alpha` dims the
// whole thing (the preview is faint, the shot is not) and `from` is the erase front: 0 draws the
// whole wedge, 0.5 draws only its far half, 1 draws nothing. Drawn under the bodies, so the ones
// standing in it are lit from beneath instead of being covered by it.
function drawBeamWedge(dirX, dirZ, ox, oy, oz, alpha, from) {
const d = Math.hypot(dirX, dirZ) || 1, ux = dirX / d, uz = dirZ / d;
if (from >= 1) return;
const px = -uz, pz = ux; // the lane's perpendicular, on the ground
const wAt = u => TUNE.beamW0 + (TUNE.beamW1 - TUNE.beamW0) * u;
// It LEAVES THE RACKET. Drawn flat on the lawn it read as a shape lying at his feet rather than as
// something he fired; the plane starts at racket height and settles onto the court as it goes out,
// which is what makes the near end look attached to him.
const yAt = u => oy + (0.05 - oy) * Math.min(1, u * 2.2);
const corner = (u, w) => proj(ox + ux * (u * TUNE.beamLen) + px * w, yAt(u),
oz + uz * (u * TUNE.beamLen) + pz * w);
const u0 = Math.max(0, from), w0 = wAt(u0), w1 = wAt(1);
const c1 = corner(u0, -w0), c2 = corner(u0, w0), c3 = corner(1, w1), c4 = corner(1, -w1);
const rgb = RGB(WEAPONS.laser.col);
// White only right at the near edge; BLUE for the length of it. A gradient that ran white -> faint
// blue read as a pale grey smear on the lawn — the colour is the identity of the shot.
const g = ctx.createLinearGradient(c1.x, c1.y, c4.x, c4.y);
g.addColorStop(0, `rgba(255,255,255,${0.92 * alpha})`);
g.addColorStop(0.14, `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${0.88 * alpha})`);
g.addColorStop(1, `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${0.46 * alpha})`);
ctx.fillStyle = g;
ctx.beginPath(); ctx.moveTo(c1.x, c1.y); ctx.lineTo(c2.x, c2.y); ctx.lineTo(c3.x, c3.y); ctx.lineTo(c4.x, c4.y); ctx.fill();
// the two burning edges: HAIRLINES. At 2.5px they were heavier than anything else on the court and
// read as a drawn outline rather than as light — "the borders of the laser are quite big".
ctx.strokeStyle = `rgba(210,232,255,${0.42 * alpha})`; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(c2.x, c2.y); ctx.lineTo(c3.x, c3.y);
ctx.moveTo(c1.x, c1.y); ctx.lineTo(c4.x, c4.y); ctx.stroke();
// ...and the erase front itself: a bright bar racing away down the lane
if (from > 0) {
ctx.strokeStyle = `rgba(255,255,255,${0.85 * alpha})`; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(c1.x, c1.y); ctx.lineTo(c2.x, c2.y); ctx.stroke();
}
ctx.lineWidth = 1;
}
// The target mark, drawn AFTER the bodies — it is a mark ON the shambler, so painting it with the
// rest of the band (which happens before the sprite pass, so a nearer body correctly occludes the
// arc) hid it completely behind the very thing it was marking. That is why it was never once seen.
@@ -1037,7 +1096,22 @@ export function render() {
? (G.aim ? previewServe(G.aim) : G.trail) : null;
const flying = !!band && band === G.trail; // aiming draws the whole arc; a fired one is spent
const bandU0 = flying ? band.t / band.T : 0;
if (band) drawShotBand(band, bandU0, flying);
// THE LASER PREVIEWS ITSELF. The ray IS the weapon, so an arcing ball-band (and the little ring it
// puts on a body) is describing a projectile that never exists. While the laser is armed and you
// are pulling, the preview is the WEDGE you are about to cut — same shape, same widening, just
// not yet lit.
const laserAim = G.aim && WEAPONS[slotId(G.weapon)]?.beam && !flying ? band : null;
if (laserAim) { const c = dragContact(); drawBeamWedge(laserAim.v.vx, laserAim.v.vz, c.x, c.y, c.z, 0.34, 0); }
else if (band) drawShotBand(band, bandU0, flying);
// THE LASER's fired wedge, ERASING ALONG ITS OWN PATH: the whole plane is there at the flash and
// then it is wiped away from the racket outward, so the shot reads as the energy travelling down
// the lane rather than as a decal fading out where it lay. Oliver: "the release could erase the
// laserplane along the path."
if (G.beam) {
const b = G.beam, k = Math.max(0, b.t / TUNE.beamT); // 1 at the flash, 0 as it dies
drawBeamWedge(b.ux, b.uz, b.x, b.y, b.z, 1, 1 - k);
}
// far-side drawables in z order (far → near), then the net, then the near side
const farSide = [
@@ -1062,7 +1136,7 @@ export function render() {
if (G.mode === 'eaten' && G.eatenT < EATEN_BURST) // the dying man takes his place in the queue
nearSide.push({ z: (G.eatenAt || P).z - 0.12, f: drawDying });
nearSide.sort((a, b) => a.z - b.z).forEach(d => d.f());
if (band) drawAimMark(band, bandU0, flying); // ...now that there is a body to mark
if (band && !laserAim) drawAimMark(band, bandU0, flying); // ...now that there is a body to mark
// server idly bouncing the ball while waiting
if (G.phase === 'pretoss') {
@@ -1100,7 +1174,8 @@ export function render() {
ctx.beginPath(); ctx.arc(pp.x | 0, pp.y | 0, r, 0, 7); ctx.fill();
continue;
}
ctx.fillStyle = pt.pink ? (pt.t > 0.2 ? UI.pink : '#ff9df6') // a barrel bursting: debris, not gore
ctx.fillStyle = pt.spark ? pt.spark // a thrown round dragging its colour
: pt.pink ? (pt.t > 0.2 ? UI.pink : '#ff9df6') // a barrel bursting: debris, not gore
: pt.fire ? (pt.t > 0.12 ? '#ffc030' : '#ff8010')
: pt.dark ? 'rgba(96,14,14,0.9)' : 'rgba(170,22,22,0.9)';
ctx.fillRect(pp.x | 0, pp.y | 0, 2, 2);
@@ -1251,7 +1326,7 @@ export function render() {
const selW = WEAPONS[slotId(G.weapon)], sel = selW ? selW.label : 'PLAIN';
// "is the selected weapon one you UNLOAD rather than serve" — it used to be `G.weapon === 4`,
// which is meaningless once the slots hold whatever you gathered
const gunLike = selW && (selW.kind === 'burst' || selW.kind === 'shot');
const gunLike = selW && selW.kind === 'burst'; // the only kind you UNLOAD; a laser is served
// The DUEL is played under tennis rules — no slingshot, no rack, serve to the box (see the
// !G.interlude guards in solveShot and the aim reticle). It was still being told to pull back
// to aim, which is the one thing that does nothing there.
+1 -1
View File
@@ -57,7 +57,7 @@ export const G = {
// and it doubles as the window a tap-fired laser or rocket is held for)
mgCool: 0, // s until the next pellet in that burst
gunId: null, // which WEAPONS row that burst is firing — rate, radius, damage, colour
gunShots: 0, // rounds left for a kind:'shot' weapon (laser 10, rocket 3); 0 ends the window
beam: null, // the laser's live wedge {x, z, ux, uz, t} — drawn, then gone. Not a projectile.
deliveries: 0, // tins delivered this run — indexes the weapon ladder
duelDue: false, // the schedule called a duel — fire it at the next hand-cleared court
afterDuel: null, // the game beat the scheduled duel interrupted ('changeover' | 'game'), resumed when it resolves
+127 -28
View File
@@ -6,7 +6,7 @@
import { TUNE, UI, COURT, VOLLEY, CONTACT, BOSS_ART, WEAPONS, BARREL_WEAPONS, woundStage, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } 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, endBossFight, aside, grantWeapon, weaponModal } from './gameplay.js';
import { splatPoint, cheer, spentBall, resolveInterlude, showModal, endBossFight, aside, grantWeapon, weaponModal , dragContact } 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
@@ -27,7 +27,7 @@ let spawnT = 1.5, moanT = 3;
// gameplay.restartRun — a restart is in-place now, not a page reload.
export function resetZombies() {
for (const a of [zombies, gore, piles, parts, pellets, extras, shots]) a.length = 0;
spawnT = 1.5; moanT = 3; mgTick = 0;
spawnT = 1.5; moanT = 3; mgTick = 0; lastBarrel = null;
}
export function spawnZombie(x, z, variant) {
@@ -52,7 +52,7 @@ export function spawnZombie(x, z, variant) {
if ('fgsdehk'.includes(variant) && !G.seen['v' + variant]) { G.seen['v' + variant] = true; aside('ump_dress_code', 0.8); }
if (variant === 'o') { // the weapon barrel: a rolling pink target with a weapon inside it
zb.v = 0; zb.hp = zb.hpMax = TUNE.barrelHp;
zb.wid = BARREL_WEAPONS[Math.floor(Math.random() * BARREL_WEAPONS.length)];
zb.wid = pickBarrelWeapon();
zb.dir = Math.random() < 0.5 ? -1 : 1; zb.roll = 0;
}
if (variant === 'm') { // the rogue ball machine: furniture, not a body. It never SHAMBLES...
@@ -62,6 +62,25 @@ export function spawnZombie(x, z, variant) {
zombies.push(zb);
}
let lastBarrel = null;
// WHICH weapon is in the barrel. A flat 1-in-4 gave Oliver the laser "almost every time" and the
// beacon never — four samples is far too few for uniform to feel uniform. Weighted, gated on the
// run so the rocket is genuinely a late-game find, and it never hands you the same thing twice in
// a row, which is the part a player actually notices.
function pickBarrelWeapon() {
const pool = [];
for (const [id, b] of Object.entries(BARREL_WEAPONS)) {
if (G.runGames < b.from) continue;
if (id === lastBarrel) continue; // never twice running...
for (let k = 0; k < b.weight; k++) pool.push(id);
}
// ...unless it is the only thing unlocked, in which case repeating beats handing out nothing
if (!pool.length) return lastBarrel || 'beacon';
const id = pool[Math.floor(Math.random() * pool.length)];
lastBarrel = id;
return id;
}
// A WEAPON BARREL, rolled across the far court. Shoot it and you get what is inside, armed on the
// spot. It is the only source of the four weapons that are not on the duel ladder, and it is pure
// upside — it never attacks, never scores, and rolls off on its own if you ignore it.
@@ -250,11 +269,13 @@ function pickVariant() {
// to learn — so he can arrive EARLY and thicken the ordinary crowd, which the shambler mix alone
// never did. He is also the only advanced body that shows up in a horde (see spawnHordeZombie).
if (g >= 4) pool.push('d');
// The late roster, in the order the crowd should learn it: a stock-sized body that no longer dies
// to two (game 3), then a bigger one (game 8), then something heavier than the brute (game 15).
if (g >= 3) pool.push('e');
if (g >= 8) pool.push('h');
if (g >= 15) pool.push('k');
// The late roster, spread WIDE on purpose. They were at 3/8/15 and Oliver had met the whole cast
// by about game 10 — "let's introduce them more gradually". One new body roughly every seven
// games now, and the ORIGINAL four keep their gates exactly (the curve through ~game 12 was
// already tuned; see the calibration rule in HANDOFF).
if (g >= 5) pool.push('e');
if (g >= 12) pool.push('h');
if (g >= 20) pool.push('k');
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';
@@ -272,6 +293,32 @@ const hitR = zb => zb.variant === 'g' ? 0.75 : zb.variant === 'd' ? 0.64 : zb.va
: zb.variant === 'm' ? 0.8 // the machine is a wide low box
: 0.55;
// THE LASER. Not a projectile: the serve gesture you already know, and instead of a ball you get a
// widening plane of light down the lane you aimed at, killing everything standing inside the wedge
// at once. Oliver asked for exactly this after a tap-per-round version read as broken — "the normal
// ball logic, but the band is blue and gets way wider at the end, like a blue plane cutting through".
// It goes through wound()/killZombie like everything else, so it cannot no-op on a boss.
export function fireBeam(dirX, dirZ) {
const d = Math.hypot(dirX, dirZ) || 1, ux = dirX / d, uz = dirZ / d;
const c = dragContact(); // it leaves the RACKET, not his feet
G.beam = { x: c.x, y: c.y, z: c.z, ux, uz, t: TUNE.beamT };
boom(0.55); G.shake = { t: 0.18, mag: 3 }; G.boomFlash = 0.08;
for (let i = zombies.length - 1; i >= 0; i--) {
const zb = zombies[i];
if (!zb) continue;
const rx = zb.x - P.x, rz = zb.z - P.z;
const along = rx * ux + rz * uz; // how far down the lane it stands
if (along < 0 || along > TUNE.beamLen) continue; // behind you, or past the end of the beam
const off = Math.abs(rx * uz - rz * ux); // lateral offset from the lane
const halfW = TUNE.beamW0 + (TUNE.beamW1 - TUNE.beamW0) * (along / TUNE.beamLen);
if (off > halfW + hitR(zb)) continue;
if (isBoss(zb)) { if (wound(zb, TUNE.dmgBeam)) killZombie(i, true); }
else if (zb.variant === 'o') killZombie(i, true); // it pops a barrel too, which is fair
else if ((zb.hpMax || 0) > 1 && (zb.hp -= TUNE.dmgBeam) > 0) woundBurst(zb);
else splatter(i);
}
}
// 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
@@ -519,13 +566,42 @@ let mgTick = 0;
// radius, a damage number, whether it stops at the first body, and what it does when it lands.
// Called with no argument by the HORDE, which is the plain machine gun.
export function firePellet(w = WEAPONS.gun) {
const spread = w.life ? TUNE.mgSpread * 2.4 : TUNE.mgSpread; // a short-lived round is a CONE, not a line
const spread = w.spread ?? (w.life ? TUNE.mgSpread * 2.4 : TUNE.mgSpread); // a short-lived round is a CONE
const a = (Math.random() * 2 - 1) * spread * Math.PI / 180, s = w.speed ?? 26; // straight up-court (-z)
pellets.push({ x: P.x, y: 0.9, z: P.z, vx: Math.sin(a) * s, vz: -Math.cos(a) * s,
const pe = { x: P.x, y: 0.9, z: P.z, vx: Math.sin(a) * s, vz: -Math.cos(a) * s,
t: w.life ?? 1.2, r: w.r ?? TUNE.mgPelletR, dmg: w.dmg ?? TUNE.dmgPellet, hp: w.hp ?? TUNE.dmgPelletHp,
pierce: !!w.pierce, boom: !!w.boom, ignite: w.ignite ? (w.igniteR ?? 1.8) : 0, col: w.col, glow: w.glow });
if (++mgTick % 2 === 0) pock(0.25, w.boom ? 420 : w.pierce ? 2100 : 1400); // every 2nd round — at 12+/s every round buzzes
pierce: !!w.pierce, boom: !!w.boom, ignite: w.ignite ? (w.igniteR ?? 1.8) : 0,
trail: w.trail || 0, col: w.col, glow: w.glow };
// A LOBBED round: it is thrown, not sprayed. Solved so it comes down about where the crowd is, so
// "it throws itself" reads as aiming rather than as a flat stream that happens to be fat.
if (w.arc) {
const tgt = nearestAhead(); // something to actually throw AT
const dx = (tgt ? tgt.x : P.x + Math.sin(a) * 6) - P.x, dz = (tgt ? tgt.z : P.z - 12) - P.z;
const dist = Math.max(3, Math.hypot(dx, dz)), T = dist / s;
pe.vx = dx / T; pe.vz = dz / T;
pe.g = TUNE.gravity;
pe.vy = (0.35 - pe.y + 0.5 * pe.g * T * T) / T; // lands about knee height on the target
pe.t = T + 0.6;
pe.arc = 1;
}
// The BEACON changes colour every ball it throws. It is the showpiece: you should be able to tell
// it is running from the corner of your eye.
if (w.hue) pe.col = HUES[mgTick % HUES.length];
pellets.push(pe);
if (++mgTick % 2 === 0 || w.arc) pock(0.25, w.boom ? 420 : w.arc ? 900 : w.ignite ? 300 : 1400);
}
const HUES = ['#ff6b6b', '#ffd166', '#7bffa0', '#6bd5ff', '#c08bff', '#ff8bd5'];
// the best thing up-court to lob at: nearest body ahead of you, ignoring furniture you can walk past
function nearestAhead() {
let best = null, bd = 1e9;
for (const zb of zombies) {
if (!zb || zb.variant === 'o' || zb.z > P.z - 1.5) continue;
const d = Math.hypot(zb.x - P.x, zb.z - P.z);
if (d < bd) { bd = d; best = zb; }
}
return best;
}
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
@@ -649,7 +725,6 @@ export function updateZombies(dt) {
// `G.mgT > 0` blocks serving, so the game locked up with the gun racket stuck on screen.
G.mgT -= dt;
// whichever gun-like weapon is armed sets the rate and the round (see config.WEAPONS). A
// kind:'shot' weapon has no rate — it fires on the TAP instead (gameplay.tryGunShot).
const gw = WEAPONS[G.gunId] || WEAPONS.gun;
if (G.phase === 'rally' && gw.rate && (G.mgCool -= dt) <= 0) { firePellet(gw); G.mgCool = 1 / gw.rate; }
if (G.mgT <= 0) setupPoint(); // ammo dry — the serve loop resumes
@@ -825,14 +900,6 @@ 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' || 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
continue;
}
if (zb.variant === 's' && ball.vz < 0 && !(zb.shieldT > 0)) { // chair up, ball at its face: BLOCKED
zb.shieldT = TUNE.shieldDown; // ...but the block staggers it — the chair drops, kill window opens
zb.z -= TUNE.zPushback * 0.5;
@@ -841,13 +908,24 @@ export function updateZombies(dt) {
continue;
}
if (zb.variant === 'f') { splatter(i); continue; } // the sprinter is GLASS — any touch, any quality, it bursts
if (zb.variant === 'a' || zb.variant === 'b') { // classic shamblers soak a splat-quality wound before they drop
// ANY multi-hit body soaks, and hp is the ONLY thing that says how many. This used to be a
// chain of per-variant branches — brute+machine, then shamblers — and every body NOT named
// in it fell through to "splat-quality kills outright" below. That silently one-shot the
// steward (5 hp, commented "a brute's worth of hp") from the day he shipped, and then the
// ball boy, bouncer and roller man the moment they existed: Oliver's "the bigger guys were
// way too easy to kill". Pellets and melee always read hp properly; only the BALL skipped it.
if ((zb.hpMax || 0) > 1) {
const meat = zb.variant !== 'm'; // the machine rings and does not bleed
const heavy = 'gmhk'.includes(zb.variant); // a heavy body is not shoved back by a ball
if ((ball.pow || 0) >= TUNE.zSplatQ) {
if (--zb.hp <= 0) { splatter(i); continue; } // the second splat is the killing blow
woundBurst(zb); splat(0.5); pock(0.6, 280); // first splat: a bloody wound, not a death
zb.z -= TUNE.zPushback * 0.5; ball.vx *= 0.3; ball.vz *= 0.3; ball.lastHit = 'X'; continue; // ball spent on the wound
if (--zb.hp <= 0) { splatter(i); continue; } // the killing blow
if (meat) woundBurst(zb); // a bloody wound, not a death
splat(0.5); pock(0.6, meat ? (heavy ? 220 : 280) : 700); // meat thuds, machine rings
if (!heavy) zb.z -= TUNE.zPushback * 0.5;
ball.vx *= 0.3; ball.vz *= 0.3; ball.lastHit = 'X'; continue; // ball spent on the wound
}
zb.z -= TUNE.zPushback; pock(0.6, 300); ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; continue; // soft ball just staggers
if (!heavy) zb.z -= TUNE.zPushback; // soft ball staggers the light ones, bounces off the rest
pock(0.6, 300); ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; continue;
}
// chair-down shield zombies (and anything hit from behind) obey the normal rules:
if ((ball.pow || 0) >= TUNE.zSplatQ) { splatter(i); continue; }
@@ -860,10 +938,23 @@ export function updateZombies(dt) {
if (zb.z >= COURT.L + 0.7) { aside('ump_unsportsmanlike', 0.5); zombies.splice(i, 1); if (G.mode !== 'eaten' && !G.horde && !G.clearing) splatPoint('O'); } // it reached your baseline (during a mop-up, a straggler that reaches the line just despawns — no point for them)
}
for (let i = pellets.length - 1; i >= 0; i--) { // machine-gun rounds: flat, fast, one-shot kills
for (let i = pellets.length - 1; i >= 0; i--) { // gun rounds flat and fast; lobbed ones ride an arc
const pe = pellets[i];
pe.t -= dt; pe.x += pe.vx * dt; pe.z += pe.vz * dt;
let hit = false;
let hit = false, landed = false;
if (pe.arc) { // a thrown round: it falls, and landing is a hit
pe.vy -= pe.g * dt; pe.y += pe.vy * dt;
if (pe.y <= 0.05) { pe.y = 0.05; landed = true; }
}
// a colour dragged behind it — the beacon and the rocket are supposed to be seen from across
// the court, and this is what makes a lobbed round read as a THROWN object rather than a dot
if (pe.trail && parts.length < 220)
for (let k = 0; k < pe.trail; k++) parts.push({
x: pe.x + (Math.random() * 2 - 1) * 0.12, y: pe.y + (Math.random() * 2 - 1) * 0.12,
z: pe.z + (Math.random() * 2 - 1) * 0.12,
vx: (Math.random() * 2 - 1) * 0.5, vy: (Math.random() * 2 - 1) * 0.5 + 0.3,
vz: (Math.random() * 2 - 1) * 0.5,
t: 0.18 + Math.random() * 0.22, spark: pe.col || '#fff' });
for (let j = zombies.length - 1; j >= 0; j--) {
const zb = zombies[j];
if (!zb) continue;
@@ -882,8 +973,16 @@ export function updateZombies(dt) {
if (hit) break;
}
}
// A thrown round that reaches the ground goes off THERE, whether or not it touched a body —
// "like a bomb, the projectile is a rocket". Without this a rocket that missed simply expired.
if (!hit && landed) {
if (pe.boom) explode(pe.x, pe.z);
else if (pe.ignite) ignite(pe.x, pe.z, pe.ignite);
hit = true;
}
if (hit || pe.t <= 0) pellets.splice(i, 1);
}
if (G.beam) { G.beam.t -= dt; if (G.beam.t <= 0) G.beam = null; } // the laser's wedge fades and is gone
// ---- 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