diff --git a/audio.js b/audio.js
index 29d9b52..e517a35 100644
--- a/audio.js
+++ b/audio.js
@@ -182,6 +182,36 @@ export function swish(vol = 0.3, f0 = 1600, f1 = 3200, dur = 0.16) {
}
export const whoosh = (vol = 0.34) => swish(vol, 4200, 700, 0.3); // a panel flying in and settling
+// THE FLAMETHROWER. It was borrowing pock(0.25, 300) — a short dry bandpassed tick — on every other
+// round, so the loudest-looking weapon in the game sounded like someone tapping a table. A flame is
+// broadband ROAR with a low guttural body under it, and it has to survive being retriggered ~15
+// times a second without turning into a buzz, so each call is a soft-attacked overlapping puff that
+// sums into a continuous sheet rather than a train of clicks.
+export function flame(vol = 0.5) {
+ try {
+ const ac = audio(), t = ac.currentTime, dur = 0.26;
+ const src = ac.createBufferSource(); src.buffer = noiseBuf(ac);
+ // the roar: wide noise under a lowpass that opens then closes, which is the breath of it
+ const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.Q.value = 3;
+ lp.frequency.setValueAtTime(700, t);
+ lp.frequency.exponentialRampToValueAtTime(2600, t + dur * 0.25);
+ lp.frequency.exponentialRampToValueAtTime(500, t + dur);
+ const g = ac.createGain(); // soft attack: a hard one is a click, and 15/s of clicks is a buzz
+ g.gain.setValueAtTime(0.001, t);
+ g.gain.exponentialRampToValueAtTime(vol, t + 0.03);
+ g.gain.exponentialRampToValueAtTime(0.001, t + dur);
+ src.connect(lp).connect(g).connect(ac.destination); src.start(t, nOff(dur), dur);
+ const o = ac.createOscillator(), og = ac.createGain(); // the guttural body under the hiss
+ o.type = 'sawtooth';
+ o.frequency.setValueAtTime(64 + Math.random() * 22, t);
+ o.frequency.exponentialRampToValueAtTime(38, t + dur);
+ og.gain.setValueAtTime(0.001, t);
+ og.gain.exponentialRampToValueAtTime(vol * 0.35, t + 0.04);
+ og.gain.exponentialRampToValueAtTime(0.001, t + dur);
+ o.connect(og).connect(ac.destination); o.start(t); o.stop(t + dur + 0.02);
+ } catch (e) {}
+}
+
export function pock(vol = 0.8, freq = 900) { // racket contact / bounce thock
try {
const ac = audio(), t = ac.currentTime;
diff --git a/config.js b/config.js
index 97e7338..3f3c362 100644
--- a/config.js
+++ b/config.js
@@ -568,7 +568,10 @@ export const WEAPONS = {
// ---- the four that come out of a barrel. Won by shooting one, never delivered.
// A LOBBED tennis ball twice the weight of yours, thrown on its own beat, changing colour every
// shot and dragging its colour behind it. The showiest thing in the game and the one you meet first.
- beacon: { label: 'BEACON', icon: 'w_beacon', tin: 1, kind: 'burst', len: 11, rate: 1.6,
+ // rate was 1.6: about one lob every two thirds of a second, which is a long time to stand still
+ // late on. The abort in selectWeapon is the real answer to "I picked the wrong tin", but a lob
+ // cannon you cannot leave AND cannot feel working was two problems, so it throws faster now.
+ beacon: { label: 'BEACON', icon: 'w_beacon', tin: 1, kind: 'burst', len: 11, rate: 2.4,
r: 1.2, dmg: 2, hp: 4, arc: 1, hue: 1, trail: 1, trailR: 0.035, glow: 3.2, speed: 19,
sfx: 'thump', // it LOBS — a soft low thump as it goes, not a rifle crack
desc: ['IT THROWS ITSELF', 'TWICE THE BALL, EVERY COLOUR'] },
@@ -577,9 +580,12 @@ export const WEAPONS = {
// It REACHES THE FAR BASELINE (life 1.15 s x 23 m/s = ~26 m), it PIERCES, and it does no impact
// damage at all: dmg/hp 0. Fire is not a bullet — being hit sets you alight and the burn does the
// killing, so a lit crowd walks a few steps and goes down together. That is the whole weapon.
+ // trail 2 -> 4 and a WIDE trailR: an ignite round throws fat drifting embers rather than the
+ // beacon's one-pixel thread (see the pe.ignite branch in the trail block), so the stream reads as
+ // a body of fire with edges instead of a line of dots. glow 2 -> 2.8 to match.
flamer: { label: 'FLAMER', icon: 'w_flamer', tin: 1, kind: 'burst', len: 6, rate: 30,
r: 1.15, dmg: 0, hp: 0, pierce: true, speed: 23, life: 1.15, ignite: true, igniteR: 2.6,
- trail: 2, col: '#ff8420', glow: 2, spread: 3.1, sfx: 'flame',
+ trail: 4, trailR: 0.34, col: '#ff8420', glow: 2.8, spread: 3.1, sfx: 'flame',
desc: ['A WALL OF FIRE', 'AND THE FIRE SPREADS'] },
// The normal ball gesture, made spectacular: pull, aim, release — and instead of a ball you get a
// blue plane that widens down the court and cuts through everything standing in it.
diff --git a/gameplay.js b/gameplay.js
index 8955b89..90f2e2a 100644
--- a/gameplay.js
+++ b/gameplay.js
@@ -565,7 +565,27 @@ const slotOf = id => { // -1 if you are not carrying it
};
export function selectWeapon(i) {
if (G.interlude) return; // the sudden-death duel is played under tennis rules — plain balls only
+ // A HORDE is the one place the rack stays locked: your finger is steering the man, the racket
+ // fires itself, and there is no gesture left to spend on a tin. Everything else can be switched
+ // out of, including mid-burst — see below.
+ if (G.horde) return;
if (slotStock(i) <= 0) return; // dud: nothing in that slot (slot 0 is only ever empty if a spent
+ // A BURST IS NOT A COMMITMENT. Committing to a burst tin used to lock the rack for its whole
+ // window — eleven seconds of beacon lobs while a late-game crowd walks into you is a death you
+ // can watch coming and do nothing about, which is exactly what Oliver hit. Tapping another slot
+ // ABORTS it: the tin is spent (you did fire it) and the serve loop comes straight back, the same
+ // handover updateZombies does when the window ends on its own. Both input paths land here — the
+ // rack tap and the 1-0 keys — so the abort lives here rather than in either of them.
+ if (G.mgT > 0) {
+ // ...but spending a tin can EMPTY it, and spendSlot splices an empty entry out, which shifts
+ // every slot above it down one. A tap is at a slot; what you meant was the weapon you could see
+ // in it. So resolve to the id first and look it up again afterwards — otherwise aborting a
+ // beacon in slot 1 to reach the rocket in slot 4 hands you whatever slid into slot 4.
+ const want = slotId(i);
+ G.mgT = 0; endBurst(); setupPoint();
+ const j = slotOf(want);
+ i = j >= 0 ? j : 0; // the tin we just spent WAS the one tapped: fall back to BALL
+ }
G.weapon = i; // temp has just been cleared, and then it is BALL again)
sfx('weapon-click2.mp3', 0.45); // a real mechanical click, quiet — switching is audible, not loud
}
@@ -1527,9 +1547,19 @@ 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 });
+ // fire trail. One brief ember a frame read as a spark, not as a ball that is ON FIRE — this is
+ // the weapon whose whole pitch is "kills on any touch and sets the others alight", so it now
+ // sheds THREE embers a frame with real spread, they live long enough to hang as a tail behind
+ // the arc, and every third leaves a puff of smoke drifting off the top of it.
+ if (ball.flame) {
+ for (let k = 0; k < 3; k++) parts.push({
+ x: ball.x + (Math.random() * 2 - 1) * 0.14, y: ball.y + (Math.random() * 2 - 1) * 0.14,
+ z: ball.z + (Math.random() * 2 - 1) * 0.14, t: 0.34 + Math.random() * 0.26, fire: true,
+ vx: (Math.random() * 2 - 1) * 0.9, vy: 0.9 + Math.random() * 0.7, vz: (Math.random() * 2 - 1) * 0.9 });
+ if (Math.random() < 0.34) parts.push({ x: ball.x, y: ball.y + 0.15, z: ball.z,
+ vx: (Math.random() * 2 - 1) * 0.3, vy: 0.7, vz: (Math.random() * 2 - 1) * 0.3,
+ t: 0.5 + Math.random() * 0.4, smoke: true });
+ }
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;
diff --git a/index.html b/index.html
index 0ef5b80..9d29ea6 100644
--- a/index.html
+++ b/index.html
@@ -359,6 +359,6 @@
-
+