Phase 12: the weapon ladder — every tin opens something nastier

Deliveries cycle BOMB -> TRIPLE -> FIRE -> GUN (G.deliveries indexes the
ladder); one tin at a time, a fresh delivery replaces unspent stock. All
weapons stay one-thumb: the serve auto-consumes, zero new inputs.

- Triple serve (x3): one real serve fans two ballistic clones +-6deg (extras[]
  in zombies.js — NOT the reverted rapid-fire: each fan costs a proper serve).
  Side balls respect variant rules (wound brutes, clank off chairs, pass the boss)
- Flame ball (x3): kills on ANY contact regardless of pow, chair ignored,
  full pierce (splatter pace tax undone), ember trail, white-hot render
- Machine gun (8s): horde auto-fire as a pickup — no serving, drag steers,
  no assist, spawner keeps running, ammo bar drains on the HUD, then the
  serve loop resumes
- HUD pips per type (black/yellow/orange) at the bomb slot; flash names the
  tin ('NEW BALLS: FIRE')

Verified: ladder cycles across 5 deliveries; triple spawns 2 extras and the
fan kills flanked targets; pow-0.1 flame ball clears shambler+shield+brute in
one pass with TRIPLE KILL chaining; GUN tin arms at changeover, blocks taps,
streams pellets (kill in 5 frames), expires back to pretoss; tennis 11/12;
no weapon-flag leaks into tennis; no console errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-07-12 10:58:28 +02:00
1 parent 7bc26aec1c
commit 06bd24e6ea
6 files changed
+114 -22

No files matched your search

+37 -7
View File
@@ -4,7 +4,7 @@ import { TUNE, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DE
import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } from './state.js';
import { audio, pock, swish, applause, voice } from './audio.js';
import { axisX, axisZ, takeFlickAim } from './input.js';
import { updateZombies, spawnZombie, zombies, explode, meleeSwipe } from './zombies.js';
import { updateZombies, spawnZombie, zombies, explode, meleeSwipe, extras, parts } from './zombies.js';
// ------------------------------------------------------------- ballistics ----
// Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g.
@@ -79,7 +79,7 @@ function doHit(ch, quality, aim) {
G.wasServe = false; // a serve volleyed back pre-bounce must not fault-check the return
ball.pow = quality; // carried pace: zombies splatter only above zSplatQ
ball.kills = 0; // fresh shot: multi-kill streak restarts
ball.bomb = false; // only a bomb-consuming serve is a bomb; any other hit clears it
ball.bomb = false; ball.flame = false; // only a consuming serve arms these; any other hit clears them
if (ch.side === 'P') { // shot marker: where this will land — red means it's going out
const ok = G.mode !== 'tennis' || (Math.abs(tx) <= COURT.W / 2 + 0.02 && tz >= -0.02 && tz <= COURT.netZ);
G.outMarker = { x: tx, z: tz, out: !ok, t: 1.3 };
@@ -121,8 +121,19 @@ function hitServe(sv, quality, aim = null) {
ball.lastHit = sv.side; ball.bounces = 0;
ball.pow = quality;
ball.kills = 0; // fresh serve: multi-kill streak restarts
ball.bomb = G.mode === 'splatter' && G.bombs > 0; // auto-consume a stocked bomb ball
// the armed tin auto-loads the serve — one-thumb rule, no weapon-select input
ball.bomb = G.mode === 'splatter' && G.bombs > 0;
if (ball.bomb) G.bombs--;
ball.flame = G.mode === 'splatter' && !ball.bomb && G.flames > 0;
if (ball.flame) G.flames--;
if (G.mode === 'splatter' && G.triples > 0) { // one serve, three balls: clones fanned ±6°
G.triples--;
for (const deg of [-6, 6]) {
const r = deg * Math.PI / 180, c = Math.cos(r), sn = Math.sin(r);
extras.push({ x: ball.x, y: ball.y, z: ball.z, vy: ball.vy, pow: quality, t: 2.5,
vx: ball.vx * c - ball.vz * sn, vz: ball.vx * sn + ball.vz * c });
}
}
if (sv.side === 'P') G.outMarker = { x: boxX, z: tzs, out: G.mode === 'tennis' && !inServeBox(boxX, tzs), t: 1.3 };
G.phase = 'rally'; pock(1, 1100);
sv.pose = 'serve'; sv.poseT = 0;
@@ -213,11 +224,24 @@ function startChangeover() {
if (G.tinDue) { // the boys arm you — unless they took a game since the last delivery
G.tinDue = false;
if (G.shameGame) G.flash = { text: 'NO NEW BALLS', t: 1.6 }; // the umpire withholds
else { G.bombs = TUNE.bombMax; voice('new_balls', 0.6); G.flash = { text: 'NEW BALLS', t: 1.6 }; }
else deliverTin();
G.shameGame = false; // the slate is clean either way
} else G.flash = { text: 'CHANGEOVER', t: 1.4 };
}
// the weapon ladder: each delivery of the run opens a nastier tin. One tin at a time —
// a fresh delivery replaces any unspent stock (simpler than stacking, and it keeps you spending).
function deliverTin() {
const w = G.deliveries++ % 4;
G.bombs = G.triples = G.flames = 0; G.mgT = 0;
if (w === 0) G.bombs = TUNE.bombMax;
else if (w === 1) G.triples = 3;
else if (w === 2) G.flames = 3;
else G.mgT = TUNE.mgPickupLen; // starts firing when play resumes
sendBoy(); voice('new_balls', 0.6);
G.flash = { text: 'NEW BALLS: ' + ['BOMB', 'TRIPLE', 'FIRE', 'GUN'][w], t: 1.6 };
}
// retire the splatter ball; a running changeover's long beat must not be clobbered short
export function spentBall() {
ball.active = false;
@@ -434,7 +458,7 @@ export function keyPressed(code) {
if (code !== 'Space') return;
audio(); // unlock on first gesture
if (G.mode === 'splatter') meleeSwipe(P.x, P.z, TUNE.meleeRange); // a swish cleaves adjacent shamblers
if (G.horde) return; // horde: the racket auto-fires; a tap only swishes, never serves
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
// splatter: a tap/flick during the brief 'over' beat re-arms the next serve immediately
if (G.mode === 'splatter' && G.phase === 'over') { ball.active = false; setupPoint(); }
@@ -495,7 +519,7 @@ export function update(dt) {
P.x += mx * TUNE.playerSpeed * slow * dt;
P.z += mz * TUNE.playerSpeed * slow * dt;
let assist = 0;
if (!mx && !mz && TUNE.autoAssist > 0 && ball.active && !G.horde) { // horde: you aim by hand, no assist
if (!mx && !mz && TUNE.autoAssist > 0 && ball.active && !G.horde && !(G.mgT > 0)) { // auto-fire: you aim by hand, no assist
const chasing = ball.lastHit === 'O';
// no `slow` here: tapping must not freeze your legs, or anticipating a shot loses it
const rate = TUNE.playerSpeed * TUNE.autoAssist * dt * (chasing ? 1 : 0.55);
@@ -575,6 +599,9 @@ export function update(dt) {
// --- ball physics
if (ball.active) {
if (ball.flame) parts.push({ // fire trail: one ember per frame, brief and rising
x: ball.x, y: ball.y, z: ball.z, t: 0.22, fire: true,
vx: (Math.random() * 2 - 1) * 0.6, vy: 0.8, vz: (Math.random() * 2 - 1) * 0.6 });
ball.vy -= (ball.g || TUNE.gravity) * dt;
const oldZ = ball.z;
ball.x += ball.vx * dt; ball.y += ball.vy * dt; ball.z += ball.vz * dt;
@@ -637,7 +664,10 @@ export function update(dt) {
G.overT -= dt;
if (G.overT <= 0) {
if (G.changeover) { G.changeover = false; voice('time', 0, 0.95); } // "Time." — play resumes
setupPoint();
if (G.mgT > 0) { // a GUN tin was delivered: no serving, the racket fires itself, drag steers
G.phase = 'rally'; ball.active = false;
P.pose = 'idle'; P.poseT = 0; P.swingT = -1;
} else setupPoint();
}
}
}