Fire that looks like fire, a rack you can leave, and a boss you can read

The band work from f352612 rides along; everything else is this round's list.

- proj returns a NEGATIVE scale behind the camera, so four ellipse call sites
  could be handed negative radii — ctx.ellipse throws on those, and the throw
  killed the rest of the frame (no HUD, no rack, no band). All four now skip
  points behind the camera.
- a burst tin is no longer a commitment: tapping another slot aborts it, spends
  it and hands back the serve loop. Lives in selectWeapon, so the rack tap and
  the 1-0 keys both get it. A horde still locks the rack — that is the one place
  your finger is already spoken for. Spending splices the rack, so the tap
  resolves to a weapon id and re-finds it rather than trusting the index.
- beacon 1.6 -> 2.4 rounds/s: a lob cannon you could not leave AND could not
  feel working was two problems.
- FIRE. Flamer throws fat embers instead of a one-pixel thread; burning bodies
  get nine tongues over a heat bloom plus smoke; the fire ball gets a halo and a
  real tail; an ash death gets twice the motes at twice the life over a ground
  bloom and a column. The flamer had no sfx case at all and was using the gun's
  dry tick — it has its own roar now.
- chapter bosses carry an HP bar over their head. The no-HP-bar rule stands for
  everything else and the comment says why a boss is the exception.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-09 13:21:57 +02:00
1 parent f352612de3
commit 655b86d27f
7 files changed
+184 -31

No files matched your search

+44 -11
View File
@@ -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;