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:
1 parent
f352612de3
commit
655b86d27f
7 files changed
+184
-31
No files matched your search
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user