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

+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') {