diff --git a/HANDOFF.md b/HANDOFF.md
index 9ad3c41..2b76612 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -438,6 +438,16 @@ Clear the intro first: `document.getElementById('intro')?.remove()`.
only when `ref_only` is unused — with `ref_only` set, list the throwaway as an extra sprite
(`p_pull0`) and delete the PNG afterwards.
+15. **NOTHING IN A SPRITE MAY BE PINK, MAGENTA, VIOLET OR PURPLE.** The chroma key does not merely
+ match the corner colour, it deletes every pixel where red AND blue both beat green by 40
+ (`_key.py`: `min(r, b) > g + 40`, and `_fine.py` de-fringes the key twice). So a "vivid magenta
+ drum" and a "magenta laser pistol with a violet coil" both came back as hollow outlines — 19% and
+ 15% solid pixels against 50-56% for the icons drawn in other colours. Measure `alpha > 128` as a
+ fraction of the box if a sprite looks thin; that is the whole diagnosis. **Pink can only ever be
+ PROCEDURAL** — the weapon barrel is grey steel with yellow chevrons in its PNG, and every bit of
+ its pink is the halo `drawBarrel` paints around it, which never goes near the keyer. State the
+ forbidden colours in the prompt explicitly; "make it pink" is a request the pipeline cannot fill.
+
Known generator quirks, all still true:
- It sometimes ignores the magenta (harmless — the key uses the corner colour).
- It draws near-identical animation frames unless you describe strongly contrasted poses.
@@ -450,6 +460,42 @@ Known generator quirks, all still true:
- **Colour-match any regenerated frame** of an existing pair before shipping: fit a per-channel
linear map onto the original's mean and sd, then verify with a mean-difference number.
+## Session log: a rack you fill yourself, and barrels that fill it (2026-08-03)
+
+The second half of the same ask. **The rack no longer has fixed slots.** It used to be five hardcoded
+positions where the index WAS the weapon — `SLOTS`, `slotStock`, `WDESC`, `drawRack`'s three parallel
+arrays, `deliverTin`'s `w + 1`, and bare `wsel === 1|2|3` / `G.weapon === 4` tests in five files. Now
+`config.WEAPONS` is the single table, `G.rack` holds up to four `{id, n}` in the order you GATHERED
+them, and slot 0 is BALL. Read it through `slotId` / `slotStock` / `slotEntry` and nothing else needs
+to know how it is stored.
+
+**A fifth weapon is always allowed** (Oliver's rule): it rides slot 0 as `G.temp` until spent, then
+slot 0 goes back to being the infinite ball. A barrel arms on the spot and is spent quickly, so
+refusing it would mean a barrel you shot did nothing. **A spent weapon LEAVES its slot** — with
+gathered slots, an exhausted weapon sitting in one is a quarter of the rack you can never pick up
+into. (The old rack kept empty slots dimmed, which was right when a slot meant "BOMB goes here".)
+
+**Four new weapons, and they are all the one 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. Every one of them goes 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 16 m+, the flamer is a deliberate ~6 m cone that ignites.
+
+**`G.mgT` stayed the mode timer for all of them**, which is why 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'` (laser 10, rocket 3) holds the same braced stance and fires one per TAP.
+
+**The barrels** are the ball machine's shape reused: a rolling, shootable non-body. Three
+registrations are silent if forgotten and all three are done — `blocksClear` (a barrel must NOT gate
+the hand-clear; it is optional loot), the contact-kill guard (furniture cannot eat you), and
+`hitR`/`GORE_MUL`/`zombieH`. It scores nothing.
+
+**The pink is procedural, and it has to be** — see pipeline note 15. A magenta drum and a magenta
+laser pistol were both eaten by the chroma key.
+
+`TUNE.tinTriples` 3 -> 10, as asked.
+
## Session log: the roster grows — moving bosses, six of them, three new bodies (2026-08-03)
Oliver asked for five things. This is the first half: **enemies first, weapons second**, his call, so
diff --git a/assets/_regen_batches.json b/assets/_regen_batches.json
index e314e17..d660711 100644
--- a/assets/_regen_batches.json
+++ b/assets/_regen_batches.json
@@ -754,5 +754,19 @@
"check": "loose",
"subject": "The reference shows the SAME boss in four states: undamaged, then three worsening stages of damage. Draw all FOUR of those exact states again, in the same order, same size, same costume, same wounds \u2014 but each one caught MID-THROW: the whole body wound up and twisted, the surviving arm drawn back high over the shoulder about to hurl a tennis ball straight at the viewer, weight on the back foot. The wounds must stay clearly visible in every frame. Output exactly four figures in one row.",
"state": true
+ },
+ "barrel": {
+ "sprites": [
+ "z_barrel1",
+ "z_barrel2"
+ ],
+ "ref_only": [
+ "z_mach1"
+ ],
+ "anchor": 0,
+ "check": "any",
+ "newart": true,
+ "h": 150,
+ "subject": "The reference is a piece of tennis-court equipment drawn as pixel art, seen from the side. Draw TWO frames of a DIFFERENT object in that same style: a squat STEEL DRUM lying on its side like a barrel about to be rolled, banded with two raised hoops, its curved end-cap facing the viewer. The drum is DARK GUNMETAL GREY steel, heavily dented and streaked with rust, with bold YELLOW AND BLACK hazard chevrons stencilled around the middle band and a thin band of glowing PALE CYAN-WHITE light leaking from the seams between the staves. IMPORTANT: no part of the drum may be pink, magenta, purple or violet \u2014 use only grey steel, rust brown, yellow, black and pale cyan-white. It is a wide low object, clearly wider than it is tall, and it sits ON THE GROUND. The two frames are the same drum rotated part of a turn, so the hoops, chevrons and dents sit at visibly DIFFERENT angles \u2014 frame two is rolled about a third of a turn from frame one. Output exactly two objects in one row, evenly spaced."
}
}
\ No newline at end of file
diff --git a/assets/w_beacon.png b/assets/w_beacon.png
new file mode 100644
index 0000000..4db01af
Binary files /dev/null and b/assets/w_beacon.png differ
diff --git a/assets/w_flamer.png b/assets/w_flamer.png
new file mode 100644
index 0000000..7cb028f
Binary files /dev/null and b/assets/w_flamer.png differ
diff --git a/assets/w_laser.png b/assets/w_laser.png
new file mode 100644
index 0000000..fca718c
Binary files /dev/null and b/assets/w_laser.png differ
diff --git a/assets/w_rocket.png b/assets/w_rocket.png
new file mode 100644
index 0000000..14084e2
Binary files /dev/null and b/assets/w_rocket.png differ
diff --git a/assets/z_barrel1.png b/assets/z_barrel1.png
new file mode 100644
index 0000000..a6e3d73
Binary files /dev/null and b/assets/z_barrel1.png differ
diff --git a/assets/z_barrel2.png b/assets/z_barrel2.png
new file mode 100644
index 0000000..ecfa92f
Binary files /dev/null and b/assets/z_barrel2.png differ
diff --git a/config.js b/config.js
index f1e5188..34c735c 100644
--- a/config.js
+++ b/config.js
@@ -211,10 +211,9 @@ export const TUNE = {
// on its own so there is always a chance at one. Fires off the next hand-cleared wave.
duelFirst: 2, // game after which the first scheduled sudden-death duel is called
duelEvery: 3, // games between scheduled duels after the first
- bombMax: 3, // bomb balls in a fresh tin
- tinTriples: 3, // triple-serves in a fresh tin
- tinFlames: 3, // flame balls in a fresh tin
- tinGuns: 1, // machine-gun charges in a fresh tin
+ // How many a fresh tin holds now lives on its WEAPONS row (`tin`), because the rack is no longer
+ // four fixed types — bombMax/tinTriples/tinFlames/tinGuns lived here and nothing read them after
+ // the table landed, which is a value you can tune in the panel all day for no effect.
wCap: 9, // per-type cap on the courtside rack — tins accumulate, but only so many
rackH: 30, // px height of the bottom weapon rack (feel knob: how much court it covers).
// 22 logical px is ~30 CSS px on a phone — under half Apple's 44px target, which
@@ -231,6 +230,14 @@ export const TUNE = {
mgPelletR: 0.5, // m of extra hit radius on a pellet: a fat round. "Bigger ball = more damage."
dmgPelletHp: 2, // hp a pellet takes off a body — shamblerHp is 2, so the horde gun one-shots them
mgPickupLen: 8, // s the machine-gun tin (4th delivery) auto-fires outside hordes
+ shotHoldLen: 14, // s you stay braced with a TAP-fired weapon (laser, rocket) before the window
+ // closes on its own. Running the magazine out ends it earlier, which is the
+ // normal way; this only stops a forgotten rocket freezing the serve loop.
+ // ---- the weapon barrel: the only way to get the four weapons that are not on the duel ladder
+ barrelEvery: 3, // games between barrels being rolled on (like the ball machine's machEvery)
+ barrelRoll: 2.6, // m/s it trundles across the far court — fast enough to have to be led
+ barrelHp: 1, // anything pops it: ball, pellet, bomb, flame, swish. It is a target, not a body
+ barrelH: 1.15, // drawn height (a body is 1.8)
fps: 60, // frame cap — 30 halves battery/heat but Oliver felt it; smoothness wins (0 = uncapped)
runnerSpeed: 3.2, // runner ('f') speed multiplier over the shamble
runnerVMax: 4.2, // hard runner speed cap — the late-game ramp must stay humanly reactable
@@ -293,7 +300,10 @@ export const EATEN_FEED_R = 1.15; // m — how tight the feeding ring packs arou
// desaturated from the original arcade primaries: they were shouting over the artwork.
// (NOT the gore — blood and piles keep their loud reds; only text and interactive UI.)
export const UI = { hot: '#d9705f', warm: '#e0cc72', pick: '#cbd06a', cream: '#e8e8dc',
- dim: '#8fa0b8', bad: '#a34a42', ball: '#dbe063' };
+ dim: '#8fa0b8', bad: '#a34a42', ball: '#dbe063',
+ // the weapon barrel's glow, and the only pink on the court: nothing else in the
+ // palette is near it, so "pink means a weapon is in there" needs no explaining
+ pink: '#ff5cf0' };
// ------------------------------------------------------------------ courts ----
// A court type is pure DATA: adding one is a row here plus its four PNGs, never a change to render().
@@ -408,13 +418,54 @@ export const ZWOUND = {
k: ['z_kw', 'z_kw2', 'z_kw3', 'z_kw4'],
};
+// ---- THE ARSENAL. One table, keyed by id, replacing the five places the roster used to be spelled
+// out (SLOTS, slotStock, WDESC, drawRack's three parallel arrays, and deliverTin's index mapping).
+// The rack no longer has a fixed weapon per slot: G.rack holds up to four of these in the order you
+// GATHERED them, and slot 0 stays BALL. A weapon is defined entirely by its row.
+// kind 'serve' — arms the next serve (the four original tins)
+// kind 'burst' — commits to a braced auto-fire window: `len` seconds at `rate` rounds/s
+// kind 'shot' — same braced stance, but each TAP fires one and `tin` is the magazine
+// The pellet fields (r, dmg, pierce, boom, ignite, col, speed) are stamped onto the round by
+// firePellet, so all four new weapons are the ONE existing pellet loop with different numbers.
+export const WEAPONS = {
+ bomb: { label: 'BOMB', icon: 'w_bomb', tin: 3, kind: 'serve',
+ desc: ['DETONATES ON CONTACT', 'CLEARS A WHOLE CROWD AT ONCE'] },
+ triple: { label: 'TRIPLE', icon: 'w_triple', tin: 10, kind: 'serve', // was 3 — Oliver asked for ten
+ desc: ['ONE SERVE, THREE BALLS', 'FANNED WIDE ACROSS THE LANE'] },
+ fire: { label: 'FIRE', icon: 'w_fire', tin: 3, kind: 'serve',
+ desc: ['KILLS ON ANY TOUCH', 'AND SETS THE OTHERS ALIGHT'] },
+ gun: { label: 'GUN', icon: 'w_gun', tin: 1, kind: 'burst', len: 8, rate: 12,
+ r: 0.5, dmg: 0.34, hp: 2, speed: 26,
+ desc: ['HOLD THE LINE AND SPRAY', 'A BURST, NOT A SERVE'] },
+ // ---- the four that come out of a barrel. Won by shooting one, never delivered.
+ beacon: { label: 'BEACON', icon: 'w_beacon', tin: 1, kind: 'burst', len: 10, rate: 3,
+ r: 1.1, dmg: 3, hp: 9, speed: 17, col: '#7fefff', glow: 1,
+ desc: ['A SLOW BEAT OF BIG', 'GLOWING SHELLS'] },
+ flamer: { label: 'FLAMER', icon: 'w_flamer', tin: 1, kind: 'burst', len: 5, rate: 16,
+ r: 0.8, dmg: 2, hp: 9, speed: 11, life: 0.55, ignite: true, col: '#ffa040', glow: 1, // life 0.55s x 11 m/s = a ~6 m cone
+ desc: ['A CONE OF FIRE', 'AND THE FIRE SPREADS'] },
+ laser: { label: 'LASER', icon: 'w_laser', tin: 10, kind: 'shot',
+ // NOT magenta: the icon art is chroma-keyed and the key eats every pink/purple
+ // (min(r,b) > g+40), so a magenta laser came back as a hollow outline. Pink stays
+ // the BARREL's identity, where it is a procedural glow and never goes near the key.
+ r: 0.55, dmg: 3, hp: 9, speed: 70, pierce: true, col: '#8ab6ff', glow: 1,
+ desc: ['TEN SHOTS, AND EACH ONE', 'GOES THROUGH THE WHOLE LANE'] },
+ rocket: { label: 'ROCKET', icon: 'w_rocket', tin: 3, kind: 'shot',
+ r: 0.6, dmg: 3, hp: 9, speed: 22, boom: true, col: '#ffd050', glow: 1,
+ desc: ['THREE ROCKETS', 'EACH ONE TAKES THE CROWD'] },
+};
+// what a pink barrel can be carrying — the four above that you cannot win any other way
+export const BARREL_WEAPONS = ['beacon', 'laser', 'rocket', 'flamer'];
+export const RACK_MAX = 4; // gathered weapons the rack holds; a 5th rides slot 0 until it is spent
+
export const MACH_H = 1.55; // was 1.15 — waist high read as a prop you could ignore. Still furniture
// (a body is PLAYER_H 1.88), but big enough to be a thing on the court.
// Drawn height of a body on the far side, by variant. render draws to it AND the serve aims at a
// fraction of it, so the two cannot drift — a shot at a brute has to arrive at the brute's chest,
// not at the height a shambler's chest happens to be.
-export const zombieH = zb => zb.variant === 'g' ? 2.7 : zb.variant === 'd' ? 2.15
+export const zombieH = zb => zb.variant === 'o' ? TUNE.barrelH
+ : zb.variant === 'g' ? 2.7 : zb.variant === 'd' ? 2.15
: zb.variant === 'k' ? 3.2 : zb.variant === 'h' ? 2.4
: zb.variant === 'r' ? 1.85 : zb.variant === 'm' ? MACH_H
: zb.variant === 'B' ? 1.8 * (zb.scale || 1.5) : 1.8;
diff --git a/gameplay.js b/gameplay.js
index 8207e7a..fb08f1e 100644
--- a/gameplay.js
+++ b/gameplay.js
@@ -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') {
diff --git a/index.html b/index.html
index 42ed132..77edb27 100644
--- a/index.html
+++ b/index.html
@@ -179,6 +179,6 @@
-
+