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

+67 -13
View File
@@ -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));