Stop the brute pulsing, and let a boss die where you can see it

The pulse was drawStatic being told a height instead of reading one. _slice.py
common-scales a sheet, so a crouched frame is genuinely shorter — 211 px against
its neighbours' 224 — and forcing every frame to the same on-screen height
stretched it back up, so the body swelled and shrank once per stride. Worst on
the brute because it is the biggest and slowest. The rule already existed for the
wound stages; it now applies to every frame, once, which also settles the
shamblers and the steward (their cycles vary by up to 9%).

A chapter boss now dies in its own beat instead of under the card announcing it:
the world drops to a third speed for 1.2 s, the screen kicks, a shockwave ring
blows out through 90 extra particles and six stains, and the CHAPTER DOWN card
holds off until the pieces have landed — about 1.4 s, measured.

Cards can hold off in general now (`wait`). First cut only delayed the card's
countdown while still drawing it, which is worse than no delay: caught it in a
screenshot before it shipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-31 20:22:55 +02:00
1 parent 7d307d90b9
commit b7899f122c
7 files changed
+61 -8

No files matched your search

+17 -3
View File
@@ -323,7 +323,6 @@ function drawZombie(zb) {
// it was: fake. The comedy is in the gait, and the gait has to be drawn.
const b = stage + 'b';
name = SPR[b] && stepFrame(zb.z / TUNE.bruteStride, 2) ? b : stage;
h *= SPR[stage].height / SPRITE_H; // height ALWAYS off the A frame, or the size pops each step
} else if (SPR.z_gw) name = 'z_gw'; // a missing asset degrades to the last skin that loaded
}
else if ('abd'.includes(zb.variant) && zb.hp < (zb.hpMax || 0) && SPR['z_' + zb.variant + 'w']) {
@@ -363,8 +362,14 @@ function drawZombie(zb) {
// a design whose sheet has not been generated (or has not loaded) degrades to design 1 rather
// than vanishing, since drawStatic silently draws nothing for a missing sprite
const stage = SPR[wanted] ? wanted : BOSS_ART[0].walk[stepFrame(zb.z / TUNE.bruteStride, 2)];
if (SPR[stage]) { name = stage; h *= SPR[stage].height / SPRITE_H; } // height ALWAYS off the frame, or the size pops
if (SPR[stage]) name = stage;
}
// Every frame is drawn at ITS OWN height. drawStatic forces the height it is given, so a crouched
// frame sliced 13 px shorter than its neighbours was being stretched back to full size — the body
// grew and shrank once per stride. Oliver: "the brute is still pulsating while walking". The rule
// already existed for the wound stages; it belongs to every frame, and it is the same rule
// _slice.py's common scaling is built on (see HANDOFF: heights follow the PNG).
if (SPR[name]) h *= SPR[name].height / SPRITE_H;
drawStatic(name, zb.x, zb.z, h, Math.floor(zb.walkT * 5.2) % 2);
if (zb.burnT > 0) drawFlames(zb, h);
if (zb.windT > 0 && zb.variant === 'B') { // the boss telegraphs too: it rears up, glowing, then throws
@@ -874,6 +879,15 @@ export function render() {
const pp = proj(pe.x, pe.y, pe.z);
ctx.fillStyle = UI.warm; ctx.fillRect((pp.x | 0) - 2, (pp.y | 0) - 2, 4, 4); // fat round — it hits like one
}
// the chapter boss's shockwave: a ring of blood mist blown out through the crowd noise
if (G.bossBurst) {
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));
ctx.beginPath(); ctx.ellipse(p.x, p.y, p.s * 0.9 * k, p.s * 0.32 * k, 0, 0, 7); ctx.stroke();
ctx.strokeStyle = `rgba(255,180,170,${(1 - k) * 0.35})`; ctx.lineWidth = 1;
ctx.beginPath(); ctx.ellipse(p.x, p.y, p.s * 0.62 * k, p.s * 0.22 * k, 0, 0, 7); ctx.stroke();
}
// death, in three stages. A: you are still there, thrashing and going red as they crowd in.
// B (from EATEN_BURST): the sprite is gone and you ARE the gore — playerGore() drops the mound.
// C: the summary fades in over it, so the biggest pile on the court stays visible underneath.
@@ -1011,7 +1025,7 @@ export function render() {
ctx.fillText(G.flash.text, W / 2, 60 + Math.sin(G.flash.t * 20) * 1);
}
// the SET milestone card (the duel's own announcement is a real modal now, not a timed card)
if (G.card && G.card.t > 0)
if (G.card && G.card.t > 0 && !(G.card.wait > 0)) // `wait` holds it OFF the screen (a boss dying under it)
drawCard(G.card.title, G.card.sub1, G.card.sub2, G.card.color);
if (TUNE.missHud && G.lastMiss && G.idleT - G.lastMiss.at < 3.5) { // last miss reason, live
ctx.font = F(8); ctx.fillStyle = '#ff9060';