Fill the rack yourself, and roll barrels out that fill it

The rack had five hardcoded slots where the index WAS the weapon, spelled out in
five places: SLOTS, slotStock, WDESC, drawRack's three parallel arrays,
deliverTin's w+1, and bare wsel === 1|2|3 / G.weapon === 4 tests across five
files. config.WEAPONS is the one table now, G.rack holds up to four {id, n} in
the order you GATHERED them, and slot 0 stays BALL. Everything reads through
slotId/slotStock/slotEntry, so nothing else knows how the rack is stored.

A fifth weapon is always allowed, per Oliver: it rides slot 0 as G.temp until
spent, then slot 0 goes back to the infinite ball. A barrel arms on the spot and
is spent quickly, so refusing it would mean a barrel you shot did nothing. And a
spent weapon LEAVES its slot -- with gathered slots, an exhausted weapon holding
one is a quarter of the rack you can never pick up into. (The old rack kept its
empties dimmed, which was right when a slot meant "BOMB goes here".)

Four new weapons, and they are all the ONE existing pellet loop. firePellet(w)
stamps the WEAPONS row onto the round -- r, dmg, hp, pierce, boom, ignite, col,
speed, life -- so the beacon cannon, flamethrower, laser and rocket needed no new
array and no new update; the break after the first body was the only thing making
a pellet single-target. All four go through the same isBoss branch, which is why
none can silently no-op on a boss the way three of five once did. Measured:
gun/beacon/laser/rocket reach past 16 m, the flamer is a ~6 m cone that ignites.

G.mgT stays the mode timer for all of them, so the eighteen !(G.mgT > 0) guards
across five files did not have to change. kind:'burst' auto-fires at its own rate
for len seconds; kind:'shot' holds the same braced stance and fires one per tap.

The barrels reuse the ball machine's shape: a rolling, shootable non-body. All
three of the registrations that fail silently are done -- blocksClear (loot must
not gate the hand-clear), the contact guard (furniture cannot eat you), and
hitR/GORE_MUL/zombieH. It scores nothing.

The pink had to become procedural. The chroma key deletes every pixel where red
and blue both beat green by 40, so a magenta drum and a magenta laser pistol both
came back as hollow outlines -- 19% and 15% solid against 50-56% for the icons in
other colours. The drum is grey steel with hazard chevrons and every bit of its
pink is the halo drawBarrel paints; the laser went electric blue. Both are solid
now (92% and 58%), and the rule is written down in HANDOFF.

tinTriples 3 -> 10 as asked, and the four dead TUNE ammo constants are gone --
nothing read them once the table landed, which is a panel value you can tune all
day for no effect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-03 12:09:28 +02:00
1 parent 4cda6bde76
commit 25f915f236
16 files changed
+385 -97

No files matched your search

+101 -41
View File
@@ -1,11 +1,11 @@
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
import { TUNE, UI, BOSS_ART, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST } from './config.js';
import { TUNE, UI, BOSS_ART, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX } from './config.js';
import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js';
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 } from './zombies.js';
import { updateZombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel } from './zombies.js';
// ------------------------------------------------------------- ballistics ----
// Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g.
@@ -263,12 +263,13 @@ function hitServe(sv, quality, aim = null) {
// specials for later. Placement still follows the flick — only WHICH ball flies is the
// 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;
ball.bomb = wsel === 1 && G.bombs > 0;
if (ball.bomb) G.bombs--;
ball.flame = wsel === 3 && G.flames > 0;
if (ball.flame) G.flames--;
if (wsel === 2 && G.triples > 0) { // one serve, three balls: clones fanned ±6°
G.triples--;
const wid = wsel === 0 && !G.temp ? 'ball' : slotId(wsel), armed = slotStock(wsel) > 0;
ball.bomb = wid === 'bomb' && armed;
if (ball.bomb) spendSlot(wsel);
ball.flame = wid === 'fire' && armed;
if (ball.flame) spendSlot(wsel);
if (wid === 'triple' && armed) { // one serve, three balls: clones fanned ±6°
spendSlot(wsel);
for (const deg of [-6, 6]) {
const r = deg * Math.PI / 180, c = Math.cos(r), sn = Math.sin(r);
// g: ball.g — a serve carries up to 2.3x gravity in topspin; clones integrated at flat
@@ -278,7 +279,7 @@ function hitServe(sv, quality, aim = null) {
vx: ball.vx * c - ball.vz * sn, vz: ball.vx * sn + ball.vz * c });
}
}
if (wsel > 0 && slotStock(wsel) <= 0) G.weapon = 0; // that was the last tin — hand back to plain balls
// (spendSlot already hands you back plain balls when the last one goes)
// tennis only, same reason as doHit's: a splatter serve lands at a shambler's feet, and the ring
// sat there in yellow for 1.3 s saying nothing — right where the aim mark is trying to be read.
if (sv.side === 'P' && G.mode === 'tennis') G.outMarker = { x: boxX, z: tzs, out: !inServeBox(boxX, tzs), t: 1.3 };
@@ -376,7 +377,9 @@ function pointTo(side, reason) {
// Which bodies gate the hand-clear after a won game. The racket zombie keeps its residence, and the
// ball machine is furniture — a standing threat you deal with WHEN YOU CHOOSE. Making either block
// progression would turn "decide what to prioritise" into "clear the chore list".
const blocksClear = z => z.variant !== 'r' && z.variant !== 'm';
// A barrel must NOT gate the hand-clear: it is optional loot, and making it block progression would
// turn "decide whether that is worth a shot" into a chore every time one is on court.
const blocksClear = z => !'rmo'.includes(z.variant);
export function splatPoint(side, quiet = false) {
if (side === 'P' && ++G.killCount < TUNE.killsPerPoint) return; // K kills bank one point; the rest just splatter
@@ -414,6 +417,11 @@ export function splatPoint(side, quiet = false) {
if (side === 'P' && G.runGames >= TUNE.machEvery && G.runGames % TUNE.machEvery === 0
&& zombies.filter(z => z.variant === 'm').length < TUNE.machMax && !G.bossFight)
spawnMachine();
// ...and a weapon barrel on its own schedule. One at a time: two glowing pink things on court at
// once would out-shout everything the palette uses to say "this one is dangerous".
if (side === 'P' && G.runGames % TUNE.barrelEvery === 0
&& !zombies.some(z => z.variant === 'o') && !G.bossFight)
spawnBarrel();
// The wave does NOT auto-collapse anymore: the survivors stay and must be cleared BY HAND before
// play advances — only no NEW shamblers spawn. When the last one is down, proceedAfterClear()
// fires (see update()). If the winning shot already emptied the court, proceed at once.
@@ -469,41 +477,66 @@ function startChangeover() {
// no tins here anymore — weapons are won in the duel, never handed out on a schedule
}
// ---- the courtside rack: five slots, PLAIN plus the four tins. Stock lives on G; slot 0 is
// infinite plain balls. selectWeapon is the tap/key target — an empty special can't be picked.
export const SLOTS = ['PLAIN', 'BOMB', 'TRIPLE', 'FIRE', 'GUN'];
export const slotStock = i => [Infinity, G.bombs, G.triples, G.flames, G.guns][i];
// ---- the courtside rack. Five slots still, but only slot 0 is fixed: it is BALL (infinite), or a
// FIFTH gathered weapon riding it temporarily. Slots 1..4 are whatever you have picked up, in order.
// Everything reads through these three, so nothing else has to know how the rack is stored.
export const slotEntry = i => (i === 0 ? G.temp : G.rack[i - 1]) || null;
export const slotId = i => slotEntry(i)?.id || (i === 0 ? 'ball' : null);
export const slotStock = i => { const e = slotEntry(i); return e ? e.n : (i === 0 ? Infinity : 0); };
export function selectWeapon(i) {
if (G.interlude) return; // the sudden-death duel is played under tennis rules — plain balls only
if (i > 0 && slotStock(i) <= 0) return; // dud: no tins of that kind on the rack
G.weapon = i; pock(0.3, 1100);
if (slotStock(i) <= 0) return; // dud: nothing in that slot (slot 0 is only ever empty if a spent
G.weapon = i; pock(0.3, 1100); // temp has just been cleared, and then it is BALL again)
}
// Grant a weapon and equip it. Tops up what you already carry, else takes a free slot, else rides
// slot 0 as `temp` — Oliver's rule: a fifth is always allowed, because a barrel arms on the spot and
// is spent quickly, so refusing it would mean a barrel you shot did nothing.
export function grantWeapon(id, n = WEAPONS[id].tin) {
const have = G.rack.find(e => e.id === id);
if (have) { have.n = Math.min(TUNE.wCap, have.n + n); G.weapon = G.rack.indexOf(have) + 1; }
else if (G.rack.length < RACK_MAX) { G.rack.push({ id, n }); G.weapon = G.rack.length; }
else if (G.temp?.id === id) { G.temp.n = Math.min(TUNE.wCap, G.temp.n + n); G.weapon = 0; }
else { G.temp = { id, n }; G.weapon = 0; }
return G.weapon;
}
// Spend one from a slot, and hand you back plain balls when it runs dry. The temp weapon does not
// leave an empty dimmed slot behind the way a rack entry does — slot 0 simply becomes BALL again.
export function spendSlot(i) {
const e = slotEntry(i);
if (!e) return;
if (--e.n > 0) return;
// Spent, so it LEAVES — a slot is for what you are carrying, and an exhausted weapon sitting in
// one is a quarter of the rack you can never pick anything up into. (The old fixed rack kept its
// empty slots dimmed, which was right when the slot meant "BOMB goes here" and is wrong now.)
if (i === 0) G.temp = null; // slot 0 goes back to being the infinite ball
else G.rack.splice(i - 1, 1);
G.weapon = 0;
}
// the weapon ladder: each delivery opens the next tin in the cycle and ADDS it to the rack
// (capped per type at TUNE.wCap) — old stock is kept, so you can bank a bomb for the horde
// instead of spending it now. G.weapon picks which tin the next serve throws.
const WDESC = { // what the tin you just won actually does — one line, shown on the pickup modal
BOMB: ['DETONATES ON CONTACT', 'CLEARS A WHOLE CROWD AT ONCE'],
TRIPLE: ['ONE SERVE, THREE BALLS', 'FANNED WIDE ACROSS THE LANE'],
FIRE: ['KILLS ON ANY TOUCH', 'AND SETS THE OTHERS ALIGHT'],
GUN: ['HOLD THE LINE AND SPRAY', 'A BURST, NOT A SERVE'],
};
const TINS = ['bomb', 'triple', 'fire', 'gun']; // the duel ladder, in the order it hands them out
export function deliverTin(healLine = null) {
const w = G.deliveries++ % 4, add = k => Math.min(TUNE.wCap, k);
if (w === 0) G.bombs = add(G.bombs + TUNE.bombMax);
else if (w === 1) G.triples = add(G.triples + TUNE.tinTriples);
else if (w === 2) G.flames = add(G.flames + TUNE.tinFlames);
else G.guns = add(G.guns + TUNE.tinGuns); // machine-gun charges — a burst you pop when you choose, not on arrival
if (G.weapon !== 0) G.weapon = w + 1; // equip the new tin — but never yank you off a deliberate PLAIN (banking)
const id = TINS[G.deliveries++ % TINS.length];
const wasPlain = G.weapon === 0 && !G.temp; // never yank you off a deliberate PLAIN (banking)
grantWeapon(id);
if (wasPlain) G.weapon = 0;
sendBoy(); voice('new_balls', 0.6);
aside('ump_new_balls_dark', 2.0); // "...and a new opponent, apparently."
const name = SLOTS[w + 1];
// healLine folds the trainer's visit INTO this popup. `showModal` deliberately never clobbers an
// open modal (a kill cascade can try to open two in one frame), so when both happened the heal
// modal opened first and the weapon one was lost entirely — Oliver: "the weapon message is lost".
showModal({ title: name, body: healLine ? [...WDESC[name], '', healLine] : WDESC[name],
icon: ['w_bomb', 'w_triple', 'w_fire', 'w_gun'][w], iconH: 40, color: UI.warm });
weaponModal(id, healLine);
}
// The one-per-run "what this thing does" popup, shared by the duel ladder and the barrels.
export function weaponModal(id, healLine = null) {
const w = WEAPONS[id];
showModal({ title: w.label, body: healLine ? [...w.desc, '', healLine] : w.desc,
icon: w.icon, iconH: 40, color: UI.warm });
}
// ---- the interlude: order is restored, one sudden-death point vs the racket zombie.
@@ -845,16 +878,39 @@ function racketAI(rz, dt) {
}
}
// GUN in the rack isn't a ball — committing a charge starts an mgT auto-fire burst (the same
// mode a horde uses). With GUN selected, any serve gesture spends one charge instead of serving.
function tryGunBurst() {
if (G.mode !== 'splatter' || G.weapon !== 4 || G.guns <= 0) return false;
G.guns--; if (G.guns <= 0) G.weapon = 0; // spent the last tin — back to plain balls
G.mgT = TUNE.mgPickupLen; G.mgCool = 0;
// 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.
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;
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.mgCool = 0;
ball.active = false; G.phase = 'rally';
P.pose = 'idle'; P.poseT = 0; P.swingT = -1;
P.z = HOME.P.z; // brace at the baseline for the burst (finger steers x only)
G.flash = { text: 'GUN', t: 1.0 };
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;
}
@@ -877,7 +933,7 @@ export function dragServe(aim) {
if (G.interlude || G.horde || G.mgT > 0 || G.changeover) return; // the duel serves like tennis, not the reticle
if (G.phase === 'over') { ball.active = false; setupPoint(); } // mid-beat pull: arm the next shot now
if (G.phase !== 'pretoss' && G.phase !== 'toss' && G.phase !== 'rally') return;
if (tryGunBurst()) return;
if (tryArmGun()) return;
if (aim.mag < TUNE.pullMin) return; // released without really drawing: cancel, nothing spent
// Firing while the last ball still flies is fine again — the brake is the pull itself, not the
// rally clock. This is what lets the drag mean ONE thing at all times: a second gesture that
@@ -919,6 +975,10 @@ 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
@@ -932,7 +992,7 @@ 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 (tryGunBurst()) return; // GUN selected: the serve gesture pops a burst, no ball
if (tryArmGun()) return; // a GUN-like weapon selected: the gesture arms it, no ball
tossBall(P); return; // plain tap: the two-stage toss ritual (tennis, and the duel)
}
if (G.phase === 'toss' && score.server === 'P') {