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

+71 -14
View File
@@ -3,10 +3,10 @@
// your baseline — one crossing it is a point for THEM.
// (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.)
import { TUNE, UI, COURT, VOLLEY, CONTACT, BOSS_ART, woundStage, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js';
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 } from './gameplay.js';
import { splatPoint, cheer, spentBall, resolveInterlude, showModal, endBossFight, aside, grantWeapon, weaponModal } 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
@@ -50,6 +50,11 @@ export function spawnZombie(x, z, variant) {
if (zb.hp) zb.hpMax = zb.hp;
if (variant === 's') zb.shieldT = 0; // chair up; > 0 = seconds of stagger left
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.dir = Math.random() < 0.5 ? -1 : 1; zb.roll = 0;
}
if (variant === 'm') { // the rogue ball machine: furniture, not a body. It never SHAMBLES...
zb.v = 0; zb.hp = zb.hpMax = TUNE.machHp; zb.fireT = TUNE.machCycle; zb.windT = 0;
zb.repoT = TUNE.machRepo * (0.4 + Math.random()); // ...but it wheels itself somewhere else now and then
@@ -57,6 +62,18 @@ export function spawnZombie(x, z, variant) {
zombies.push(zb);
}
// 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.
export function spawnBarrel() {
spawnZombie((Math.random() * 2 - 1) * 3.2, 4.5 + Math.random() * 3, 'o');
if (!G.seen.barrel) {
G.seen.barrel = true;
showModal({ title: 'A GLOWING BARREL', color: UI.pink,
body: ['THERE IS A WEAPON INSIDE IT', 'SHOOT IT AND IT IS YOURS', 'PINK MEANS TAKE THE SHOT'] });
}
}
// the rogue ball machine, wheeled out onto the far court. It never moves and never scores; it just
// shoots at you until you deal with it.
export function spawnMachine() {
@@ -249,6 +266,7 @@ function pickVariant() {
// deliberately NOT one — melee/bomb/fire one-shotting a brute is a settled "power exception".
const isBoss = zb => zb.variant === 'r' || zb.variant === 'B';
const hitR = zb => zb.variant === 'g' ? 0.75 : zb.variant === 'd' ? 0.64 : zb.variant === 'r' ? 0.7
: zb.variant === 'o' ? 0.7 // the barrel: a wide low drum, generous on purpose
: zb.variant === 'k' ? 0.88 : zb.variant === 'h' ? 0.68 // the roller man is wider than the brute
: zb.variant === 'B' ? 0.55 * (zb.scale || 1) * 1.4 // a chapter boss is a barn door, as it should be
: zb.variant === 'm' ? 0.8 // the machine is a wide low box
@@ -257,7 +275,22 @@ const hitR = zb => zb.variant === 'g' ? 0.75 : zb.variant === 'd' ? 0.64 : zb.va
// 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
const GORE_MUL = { a: 1, b: 1, e: 1, f: 1.8, s: 2.5, h: 2.5, g: 4, k: 5, r: 3, m: 1.5, B: 6 }; // a chapter boss comes apart like nothing else on the court
const GORE_MUL = { a: 1, b: 1, e: 1, f: 1.8, s: 2.5, h: 2.5, g: 4, k: 5, r: 3, m: 1.5, o: 0, B: 6 }; // a chapter boss comes apart like nothing else on the court; a barrel is not meat at all
// A barrel bursts and hands you what was inside, armed. Pink debris rather than gore — it is a
// container, not a body — and the modal only fires the FIRST time you see each weapon, so the tenth
// rocket does not stop the world to explain itself.
function popBarrel(zb) {
boom(0.7); G.shake = { t: 0.22, mag: 4 }; G.boomFlash = 0.08;
for (let k = 0; k < 30; k++) parts.push({
x: zb.x, y: 0.7, z: zb.z,
vx: (Math.random() * 2 - 1) * 4, vy: 1.2 + Math.random() * 3.5, vz: (Math.random() * 2 - 1) * 4,
t: 0.35 + Math.random() * 0.5, pink: true });
grantWeapon(zb.wid);
const w = WEAPONS[zb.wid];
G.flash = { text: w.label, t: 1.1 };
if (!G.seen['w' + zb.wid]) { G.seen['w' + zb.wid] = true; weaponModal(zb.wid); }
}
// dark smoke: one puff, drifting up and spreading. Not gravity-bound like gore (see the parts loop).
function smokePuff(x, y, z, spread = 0.5) {
@@ -305,6 +338,7 @@ function killZombie(i, quiet) {
const mul = GORE_MUL[zb.variant] || 1;
zombies.splice(i, 1);
if (zb.variant === 'B') bossDeath(zb); // before endBossFight below, so the card can wait on it
if (zb.variant === 'o') { popBarrel(zb); return; } // a container: it pays out and scores NOTHING
if (zb.variant === 'm') { // furniture, not a body: no blood, no mound, no mop boy
wreckMachine(zb);
if (!G.horde && !G.clearing) splatPoint('P', quiet);
@@ -479,10 +513,18 @@ function spawnHordeZombie() { // all from the far baseline, across the court —
spawnZombie((Math.random() * 2 - 1) * 5, -1.5 - Math.random(), v);
}
let mgTick = 0;
function firePellet() {
const a = (Math.random() * 2 - 1) * TUNE.mgSpread * Math.PI / 180, s = 26; // spread cone, 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, t: 1.2 });
if (++mgTick % 2 === 0) pock(0.25, 1400); // rapid fire tick, every 2nd round — at 12+/s every round buzzes
// One round, for every barrel-fed weapon there is. The WEAPONS row is stamped onto the pellet, so
// the loop below serves the machine gun, the beacon cannon, the flamethrower, the laser and the
// rocket without a second array or a second update — the only things that ever differed were a
// 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 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,
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
}
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
@@ -606,7 +648,10 @@ export function updateZombies(dt) {
// moved the phase mid-burst (a duel calling setupPoint) froze mgT above zero forever — and
// `G.mgT > 0` blocks serving, so the game locked up with the gun racket stuck on screen.
G.mgT -= dt;
if (G.phase === 'rally' && (G.mgCool -= dt) <= 0) { firePellet(); G.mgCool = 1 / TUNE.mgRate; }
// 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
}
if (!G.horde && !G.changeover && !G.interlude && !G.clearing && !G.bossFight) { // breathers, duels, the mop-up after a won game AND a boss fight all get a quiet court (no new spawns)
@@ -715,7 +760,14 @@ export function updateZombies(dt) {
zb.face = away;
continue;
}
if (zb.variant === 'm') { // the machine wheels itself around instead of being bolted down
if (zb.variant === 'o') { // the barrel just rolls, turning at the sidelines. It never attacks.
if (!held && G.mode === 'splatter' && !G.interlude) {
zb.x += zb.dir * TUNE.barrelRoll * dt;
zb.roll += TUNE.barrelRoll * dt; // drawn spin, so it reads as ROLLING
if (Math.abs(zb.x) > 4.4) { zb.dir = -Math.sign(zb.x); zb.x = Math.sign(zb.x) * 4.4; }
}
}
else if (zb.variant === 'm') { // the machine wheels itself around instead of being bolted down
if (!held && G.mode === 'splatter' && !G.interlude) {
if (zb.rollTo !== undefined) {
const d = zb.rollTo - zb.x;
@@ -739,7 +791,7 @@ export function updateZombies(dt) {
// CONTACT IS STILL INSTANT DEATH — settled decision, and the ball machine's chip damage does not
// soften it. Two threats that demand two different responses is the point. The machine ('m') is
// furniture and cannot eat you.
if (!held && G.mode === 'splatter' && zb.variant !== 'm'
if (!held && G.mode === 'splatter' && !'mo'.includes(zb.variant) // furniture and barrels cannot eat you
&& Math.hypot(zb.x - P.x, (zb.z - P.z) * 0.75) < TUNE.eatenR + (zb.variant === 'g' ? 0.2 : 0)) // z weighted down: depth is foreshortened, so a head-on shambler eats you when it LOOKS on top of you
killPlayer();
if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) {
@@ -815,14 +867,19 @@ export function updateZombies(dt) {
for (let j = zombies.length - 1; j >= 0; j--) {
const zb = zombies[j];
if (!zb) continue;
if (Math.hypot(zb.x - pe.x, zb.z - pe.z) < hitR(zb) + TUNE.mgPelletR) {
if (Math.hypot(zb.x - pe.x, zb.z - pe.z) < hitR(zb) + (pe.r ?? TUNE.mgPelletR)) {
// Pellets used to skip the boss entirely, so a GUN burst (and every horde) did literally
// nothing to him with no feedback at all. They CHIP him now: dmgPellet is fractional, so
// three rounds take a point off and the gun is a slow, committed answer rather than a no-op.
if (isBoss(zb)) { if (wound(zb, TUNE.dmgPellet, false)) killZombie(j, true); else pock(0.45, 240); }
else if ((zb.hp -= TUNE.dmgPelletHp) > 0) pock(0.5, 260); // only brutes soak now; a shambler drops to one round
// EVERY new weapon comes through here, which is why none of them can silently no-op on a
// boss the way three of the five once did.
if (pe.ignite) ignite(pe.x, pe.z, pe.ignite); // the flamer lights the spot, and fire spreads itself
if (isBoss(zb)) { if (wound(zb, pe.dmg ?? TUNE.dmgPellet, false)) killZombie(j, true); else pock(0.45, 240); }
else if ((zb.hp -= (pe.hp ?? TUNE.dmgPelletHp)) > 0) pock(0.5, 260); // only brutes soak now; a shambler drops to one round
else killZombie(j, true);
hit = true; break;
if (pe.boom) { explode(pe.x, pe.z); hit = true; break; } // the rocket takes the crowd, not the body
hit = !pe.pierce; // a laser keeps going down the whole lane
if (hit) break;
}
}
if (hit || pe.t <= 0) pellets.splice(i, 1);