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

+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