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 @@ - + diff --git a/input.js b/input.js index bcbd6b0..b462a3e 100644 --- a/input.js +++ b/input.js @@ -72,7 +72,7 @@ cv.addEventListener('touchstart', e => { const r = cv.getBoundingClientRect(); for (const t of e.changedTouches) { if (dismissModal()) return; // the popup owns this gesture and nothing else sees it - if (!G.horde && !(G.mgT > 0)) { // rack tap selects a tin — but never during finger-steer modes + if (!G.horde) { // rack tap selects a tin — a burst can be aborted out of, a horde cannot (selectWeapon) const slot = rackHit((t.clientX - r.left) / r.width, (t.clientY - r.top) / r.height); if (slot >= 0) { selectWeapon(slot); continue; } } diff --git a/render.js b/render.js index faaa3eb..847cb93 100644 --- a/render.js +++ b/render.js @@ -424,9 +424,17 @@ function drawBall(x, y, z, glint = false, bomb = false, flame = false) { if (Math.floor(G.idleT * 20) % 2) { ctx.fillStyle = '#ff8010'; ctx.fillRect(bp.x - 0.5, bp.y - r - 1, Math.max(1, r * 0.5), Math.max(1, r * 0.6)); } return; } - if (flame) { // white-hot core in an orange shell, flickering - ctx.fillStyle = '#ff8010'; ctx.beginPath(); ctx.arc(bp.x, bp.y, r + (Math.floor(G.idleT * 24) % 2 ? 1 : 0), 0, 7); ctx.fill(); - ctx.fillStyle = '#ffe8a0'; ctx.beginPath(); ctx.arc(bp.x, bp.y, Math.max(1, r * 0.5), 0, 7); ctx.fill(); + if (flame) { // a burning ball, not an orange one: an additive halo, then shell, then white core + const fl = Math.floor(G.idleT * 24) % 2 ? 1 : 0; + ctx.save(); ctx.globalCompositeOperation = 'lighter'; + for (const [rf, af] of [[3.1, 0.16], [2.0, 0.26]]) { // the halo is what makes it read as heat + ctx.globalAlpha = af; ctx.fillStyle = '#ff7a10'; + ctx.beginPath(); ctx.arc(bp.x, bp.y, r * rf + fl, 0, 7); ctx.fill(); + } + ctx.restore(); ctx.globalAlpha = 1; + ctx.fillStyle = '#ff8010'; ctx.beginPath(); ctx.arc(bp.x, bp.y, r * 1.25 + fl, 0, 7); ctx.fill(); + ctx.fillStyle = '#ffc030'; ctx.beginPath(); ctx.arc(bp.x, bp.y, Math.max(1, r * 0.8), 0, 7); ctx.fill(); + ctx.fillStyle = '#fffbe0'; ctx.beginPath(); ctx.arc(bp.x, bp.y, Math.max(1, r * 0.42), 0, 7); ctx.fill(); return; } ctx.fillStyle = UI.ball; ctx.beginPath(); ctx.arc(bp.x, bp.y, r, 0, 7); ctx.fill(); @@ -655,24 +663,63 @@ function drawZombie(zb) { ctx.beginPath(); ctx.arc(Math.round(p.x), Math.round(p.y), r, 0, 7); ctx.fill(); ctx.globalAlpha = 1; } - // No HP bar. It was the only floating UI on a body anywhere in the game, it was on screen for - // almost the whole run (a boss respawns every game you win), and nobody knew what it meant — - // while the wound SKINS above already encode exactly the same information in the language every - // other multi-hit enemy uses. The first-sight modal in spawnRacketZombie explains him instead. + // NO HP BAR ON A SHAMBLER — that one was cut and stays cut: it was the only floating UI on a body + // anywhere in the game, it was up for almost the whole run, and the wound SKINS already encode the + // same thing in the language every other multi-hit enemy uses. A CHAPTER BOSS is the exception, + // and for the reason the rule exists: it does NOT re-skin as it takes damage, its pool runs to + // dozens of points, and the fight is long enough that "am I getting anywhere" is a real question + // with no other answer on screen. One boss, on screen only during its own fight. + if (v === 'B' && zb.hpMax) drawBossHp(zb, h); +} + +// The boss's remaining life, over its head, in the scoreboard's own 16-bit language: a dark sunk +// well, a bevel, and a fill that runs gold -> orange -> red as the pool empties, so the colour says +// how the fight is going without reading the length. Integer-snapped and a FIXED pixel size — this +// is HUD, and a bar that grew as the boss walked toward you would read as the boss healing. +function drawBossHp(zb, hMeters) { + // 1.06 sat ON the hat — a bar touching the sprite reads as part of the costume. It hangs clear + // now, and it hangs from its own BOTTOM edge so the gap is the gap you see rather than the gap + // minus the bar. The anchor is the body's own height, so it stays put as the boss scales up by + // chapter instead of drifting into the crowd. + const p = proj(zb.x, hMeters * 1.3, zb.z); + if (p.s <= 0) return; // behind the camera: see drawAimMark + const k = Math.max(0, Math.min(1, zb.hp / zb.hpMax)); + const w = 46, h = 5, x = Math.round(p.x - w / 2), y = Math.round(p.y) - h; + ctx.fillStyle = 'rgba(6,4,6,0.85)'; ctx.fillRect(x - 1, y - 1, w + 2, h + 2); // frame + ctx.fillStyle = '#2a1518'; ctx.fillRect(x, y, w, h); // empty well + if (k > 0) { + ctx.fillStyle = k > 0.5 ? UI.pick : k > 0.22 ? '#ff8010' : UI.bad; + ctx.fillRect(x, y, Math.max(1, Math.round(w * k)), h); + ctx.fillStyle = 'rgba(255,255,255,0.28)'; ctx.fillRect(x, y, Math.max(1, Math.round(w * k)), 1); // bevel + } } // Fire, drawn procedurally instead of as a sprite sheet: a few vertically-stretched quads whose // offsets ride each body's own phase (walkT), so no two burning zombies flicker in sync — and it // reads at every burn stage, from fresh yellow flare to guttering smoke, for free. function drawFlames(zb, hMeters) { const p = proj(zb.x, 0, zb.z), s = p.s; + if (s <= 0) return; const k = Math.max(0, Math.min(1, zb.burnT / TUNE.burnTime)); // 1 = just lit, 0 = about to gutter out const col = k > 0.6 ? '#ffc030' : k > 0.3 ? '#ff8010' : '#6b5a52'; - for (let i = 0; i < 5; i++) { + // A BODY ON FIRE SHOULD LOOK LIKE ONE. Five thin tongues over the torso read as a pilot light; + // this is now a column that engulfs — nine tongues spread across the full silhouette, up to + // shoulder height, over an additive heat bloom that is the thing you actually see from across + // the court. The bloom dies with k, so a guttering body cools to smoke exactly as before. + if (k > 0.15) { + ctx.save(); ctx.globalCompositeOperation = 'lighter'; + const by = p.y - hMeters * s * 0.42, br = hMeters * s * 0.42; + for (const [rf, af] of [[1.25, 0.13], [0.72, 0.20]]) { + ctx.globalAlpha = af * k; ctx.fillStyle = k > 0.45 ? '#ff9a20' : '#c05a10'; + ctx.beginPath(); ctx.arc(p.x, by, br * rf, 0, 7); ctx.fill(); + } + ctx.restore(); ctx.globalAlpha = 1; + } + for (let i = 0; i < 9; i++) { const ph = G.idleT * (7 + i * 1.7) + zb.walkT * 3.1 + i * 2; - const fx = p.x + Math.sin(ph) * s * 0.11 + (i - 2) * s * 0.05; - const fy = p.y - (0.2 + i * 0.15 + 0.25 * k) * hMeters * s - Math.abs(Math.sin(ph * 1.6)) * s * 0.08; - ctx.fillStyle = i === 0 && k > 0.5 ? '#ffe8a0' : col; // one white-hot core tongue while it rages - ctx.fillRect(fx | 0, fy | 0, Math.max(1, s * 0.045), Math.max(2, s * (0.10 + 0.07 * Math.abs(Math.sin(ph * 2))))); + const fx = p.x + Math.sin(ph) * s * 0.11 + (i - 4) * s * 0.042; + const fy = p.y - (0.18 + i * 0.085 + 0.42 * k) * hMeters * s - Math.abs(Math.sin(ph * 1.6)) * s * 0.10; + ctx.fillStyle = i % 4 === 0 && k > 0.5 ? '#fff2c0' : col; // white-hot core tongues while it rages + ctx.fillRect(fx | 0, fy | 0, Math.max(1, s * 0.05), Math.max(2, s * (0.13 + 0.10 * Math.abs(Math.sin(ph * 2))))); } } // AIMING and FLYING are two different pictures out of one arc. @@ -921,6 +968,12 @@ function drawAimMark(a, u0, flying) { const col = a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0'; const onBody = !!a.zt; const m = proj(a.tx, a.ty, a.tz); // a.ty is where the ball ARRIVES — mark and impact are one point + // NOTHING BEHIND THE CAMERA DRAWS. proj's scale is camF/(camZ - z), so a point at or past the + // camera plane comes back with s <= 0 — x and y are mirrored nonsense and the radii below go + // NEGATIVE, which ctx.ellipse throws on (IndexSizeError). A throw here killed the REST of the + // frame: no HUD, no rack, no message band, every frame it happened. A solver can hand us such a + // point (a target off the back of the world), so the projection is checked, not the aim. + if (m.s <= 0) return; // it goes with the band once fired: a mark still burning on the target is the other half of the // laser look, and the aim it represents is already spent ctx.globalAlpha = (onBody ? 0.42 : 0.24) * (flying ? left * left : 1); @@ -1531,6 +1584,7 @@ export function render() { // gore decals: what remains of the visiting team (flat ground layer, then far-side mounds) for (const d of gore) { const p = proj(d.x, 0, d.z); + if (p.s <= 0) continue; // behind the camera: see drawAimMark ctx.fillStyle = d.scorch ? `rgba(28,22,16,${d.a})` : d.ash ? `rgba(58,56,54,${d.a})` // burnt out: dark grey ash, not blood : `rgba(140,18,18,${d.a})`; // craters burn black-brown @@ -1733,7 +1787,7 @@ export function render() { ctx.beginPath(); ctx.arc(pp.x, pp.y, Math.max(0.6, r * (pe.glow ? 0.62 : 0.45)), 0, 7); ctx.fill(); } // the chapter boss's shockwave: a ring of blood mist blown out through the crowd noise - if (G.bossBurst) { + if (G.bossBurst && proj(G.bossBurst.x, 0.5, G.bossBurst.z).s > 0) { // behind the camera: see drawAimMark const b = G.bossBurst, k = 1 - b.t / 0.75, p = proj(b.x, 0.5, b.z); ctx.strokeStyle = `rgba(190,26,26,${(1 - k) * 0.8})`; ctx.lineWidth = Math.max(1.5, p.s * 0.05 * (1 - k)); diff --git a/zombies.js b/zombies.js index 32e0b9e..405a478 100644 --- a/zombies.js +++ b/zombies.js @@ -5,7 +5,7 @@ import { TUNE, UI, COURT, CONTACT, BOSS_ART, WEAPONS, BARREL_WEAPONS, woundStage, zombieH, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js'; import { G, P, ball, sendBoy, setupPoint } from './state.js'; -import { splat, moan, pock, thud, zap, voice, boom, swish, shriek, aww, sfx , whoosh } from './audio.js'; +import { splat, moan, pock, thud, zap, voice, boom, swish, shriek, aww, sfx , whoosh, flame } from './audio.js'; import { splatPoint, cheer, spentBall, resolveInterlude, endBossFight, aside, grantWeapon, endBurst } from './gameplay.js'; import { tally, multiKill, endRun } from './stats.js'; @@ -459,14 +459,29 @@ function killZombie(i, quiet) { if (pl) { pl.size = Math.min(4, pl.size + Math.round(mul / 2 + 0.5)); if (!ash) pl.ash = false; } else { piles.push({ x: zb.x, z: zb.z, size: Math.min(4, Math.round(mul / 2 + 0.5)), seed: Math.random() * 1e4, blobs: null, builtSize: 0, ash }); if (piles.length > 30) piles.shift(); } - const n = Math.round((ash ? 18 : 30) * mul); // ash settles rather than sprays: fewer, slower motes + // A BURNT BODY COLLAPSING IS ITS OWN MOMENT. It used to be over faster than a wet kill — fewer + // motes AND shorter ones — so the most spectacular way to die in the game was the least visible. + // Ash still SETTLES rather than sprays (low, slow, no arc), but there is more of it and it hangs + // roughly twice as long, which is what sells "he came apart into a heap" instead of "he blinked". + const n = Math.round((ash ? 34 : 30) * mul); for (let k = 0; k < n; k++) { const dark = k >= n * 2 / 3; parts.push({ // ~1/3 dark & long-lived: settling gore x: zb.x, y: 0.9, z: zb.z, vx: (Math.random() * 2 - 1) * (ash ? 1.6 : 3.5), vy: (ash ? 0.6 : 1) + Math.random() * (ash ? 1.6 : 3.5), vz: (Math.random() * 2 - 1) * (ash ? 1.6 : 3.5), - t: dark ? 0.6 + Math.random() * 0.6 : 0.4 + Math.random() * 0.4, dark, ash, + t: ash ? (dark ? 1.3 + Math.random() * 1.1 : 0.9 + Math.random() * 0.8) + : (dark ? 0.6 + Math.random() * 0.6 : 0.4 + Math.random() * 0.4), dark, ash, }); } - if (ash) { for (let k = 0; k < 5; k++) smokePuff(zb.x, 0.4 + Math.random() * 1.1, zb.z, 0.45); pock(0.35, 150); } + if (ash) { + // the dust cloud: a low wide bloom at the feet where the body folded, and a thinner column + // lifting off it. Five tight puffs read as a wisp; this is the body's own volume going up. + for (let k = 0; k < 10; k++) smokePuff(zb.x, 0.25 + Math.random() * 0.5, zb.z, 1.0); // the ground bloom + for (let k = 0; k < 8; k++) smokePuff(zb.x, 0.8 + Math.random() * 1.5, zb.z, 0.5); // the column + for (let k = 0; k < 8; k++) parts.push({ // last embers off the collapse, dying as it settles + x: zb.x + (Math.random() * 2 - 1) * 0.5, y: 0.3 + Math.random() * 1.0, z: zb.z, + vx: (Math.random() * 2 - 1) * 1.1, vy: 1.0 + Math.random() * 1.4, vz: (Math.random() * 2 - 1) * 1.1, + t: 0.3 + Math.random() * 0.35, fire: true }); + pock(0.4, 130); + } else { splat(1, bodySize(zb)); // the burst is pitched by the body too, so a boss comes apart LOW sfx('headshot-' + (Math.random() < 0.5 ? 1 : 2), 0.6); // random headshot crack layered on the gore burst @@ -514,10 +529,18 @@ function burnTick(zb, i, dt) { ignite(zb.x, zb.z, TUNE.burnSpread); // fx off: the embers below already ARE the feedback, a wet pop per cooked hp point would be noise const dead = wound(zb, TUNE.burnDps * (isBoss(zb) ? TUNE.bossBurnMul : 1) * dt, false); - if (parts.length < 110) for (let k = 0; k < 2; k++) parts.push({ // embers off the body - x: zb.x + (Math.random() * 2 - 1) * 0.3, y: 0.5 + Math.random() * 1.4, z: zb.z, - vx: (Math.random() * 2 - 1) * 0.5, vy: 1.2 + Math.random(), vz: (Math.random() * 2 - 1) * 0.5, - t: 0.25 + Math.random() * 0.2, fire: true }); + // Embers off the body, and SMOKE off the top of them. Two short-lived sparks read as a body + // sizzling; a burning man throws a column, so this is three embers with more spread and more + // life, plus a puff every few frames that rises and goes grey. The cap is what keeps a lit crowd + // from flooding the particle array — raised with the count so the last body still gets its fire. + if (parts.length < 200) { + for (let k = 0; k < 3; k++) parts.push({ + x: zb.x + (Math.random() * 2 - 1) * 0.42, y: 0.4 + Math.random() * 1.6, + z: zb.z + (Math.random() * 2 - 1) * 0.2, + vx: (Math.random() * 2 - 1) * 0.8, vy: 1.5 + Math.random() * 1.2, vz: (Math.random() * 2 - 1) * 0.8, + t: 0.34 + Math.random() * 0.3, fire: true }); + if (Math.random() < 0.25) smokePuff(zb.x, 1.5 + Math.random() * 0.7, zb.z, 0.35); + } if (!dead) return false; killZombie(i, !!G.horde); return true; @@ -664,6 +687,10 @@ export function firePellet(w = WEAPONS.gun) { // and the gun's rapid tick every other round (at 12+/s every round buzzes the mixer) if (w.sfx === 'thump') boom(0.3); else if (w.sfx === 'swoosh') whoosh(0.3); + // the flamer had no case of its own and fell through to the gun's dry tick. Every THIRD round at + // rate 30 = 10 overlapping 0.26 s puffs a second, which sums to a continuous roar; firing one per + // round just stacks 30 gains on top of each other and clips. + else if (w.sfx === 'flame') { if (++mgTick % 3 === 0) flame(0.5); } else if (++mgTick % 2 === 0) pock(0.25, w.ignite ? 300 : 1400); } const HUES = ['#ff6b6b', '#ffd166', '#7bffa0', '#6bd5ff', '#c08bff', '#ff8bd5']; @@ -1063,13 +1090,19 @@ export function updateZombies(dt) { } // ...and a FINE thread for the beacon: one small mote a frame, tight to the ball, so the ball // itself is the bright thing and the trail only says where it has been. - if (pe.trail && parts.length < 240) + // ...and a FINE thread for the beacon: one small mote a frame, tight to the ball. An IGNITE + // round (the flamer) throws fat embers instead — same loop, but `fire` motes that are 2px and + // warm rather than 1px and flat, they LIFT as they burn out, and they live long enough to leave + // a body of flame hanging in the air behind the stream instead of a dotted line. + if (pe.trail && parts.length < 320) for (let k = 0; k < pe.trail; k++) parts.push({ x: pe.x + (Math.random() * 2 - 1) * (pe.trailR ?? 0.12), y: pe.y + (Math.random() * 2 - 1) * (pe.trailR ?? 0.12), z: pe.z + (Math.random() * 2 - 1) * (pe.trailR ?? 0.12), - vx: 0, vy: 0.12, vz: 0, - t: 0.16 + Math.random() * 0.16, fine: 1, spark: pe.col || '#fff' }); + vx: (Math.random() * 2 - 1) * (pe.ignite ? 0.5 : 0), vz: (Math.random() * 2 - 1) * (pe.ignite ? 0.5 : 0), + vy: pe.ignite ? 0.9 + Math.random() * 0.8 : 0.12, + t: pe.ignite ? 0.30 + Math.random() * 0.34 : 0.16 + Math.random() * 0.16, + fine: pe.ignite ? 0 : 1, fire: pe.ignite ? 1 : 0, spark: pe.ignite ? 0 : (pe.col || '#fff') }); for (let j = zombies.length - 1; j >= 0; j--) { const zb = zombies[j]; if (!zb) continue;