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:
1 parent
7d307d90b9
commit
b7899f122c
7 files changed
+61
-8
No files matched your search
@@ -111,6 +111,12 @@ export const TUNE = {
|
||||
bossSalvoGap: 0.42, // s between the shots of one volley
|
||||
bossWind: 0.5, // s of telegraph before a boss throws — shorter than the machine's 0.9 on purpose:
|
||||
// he asked for a tighter reaction time, and a boss should be harder than furniture
|
||||
bossDeathT: 0.7, // s of GAME time the chapter card holds off after the boss dies. The card used to
|
||||
// land on the same frame as the kill and bury it — you never saw the thing you
|
||||
// spent a whole fight earning. Game time, so the slow motion below stretches it
|
||||
// to roughly a second and a half on the clock.
|
||||
bossSlowT: 1.2, // s of REAL time the world runs slow while a boss comes apart
|
||||
bossSlowMo: 0.35, // ...and how slow
|
||||
bossShotSpeed: 15.5, // m/s a boss throws at (the machine's is machSpeed 13)
|
||||
racketEvery: 3, // games between racket-zombie (plain opponent) spawns — was EVERY won game, which
|
||||
// is why he read as "the boss" and why a real boss would not have stood out
|
||||
|
||||
+10
-3
@@ -484,8 +484,9 @@ export function endBossFight() {
|
||||
shots.length = 0;
|
||||
cheer(3.4, 0.9); voice('unbelievable', 0.3);
|
||||
G.card = { title: 'CHAPTER ' + c + ' DOWN', sub1: 'THE STANDS ARE ON THEIR FEET',
|
||||
sub2: 'GAME ' + (G.runGames + 1) + ' NEXT', color: UI.pick, t: TUNE.setCardT };
|
||||
ball.active = false; G.phase = 'over'; G.overT = TUNE.setCardT;
|
||||
sub2: 'GAME ' + (G.runGames + 1) + ' NEXT', color: UI.pick, t: TUNE.setCardT,
|
||||
wait: TUNE.bossDeathT }; // ...after the body has finished coming apart
|
||||
ball.active = false; G.phase = 'over'; G.overT = TUNE.setCardT + TUNE.bossDeathT;
|
||||
// hand back the game beat the boss interrupted — same contract resolveInterlude uses
|
||||
if (G.afterDuel) { G.afterClear = G.afterDuel; G.afterDuel = null; proceedAfterClear(); }
|
||||
}
|
||||
@@ -919,8 +920,14 @@ export function update(dt) {
|
||||
return;
|
||||
}
|
||||
if (G.flash) { G.flash.t -= dt; if (G.flash.t <= 0) G.flash = null; }
|
||||
if (G.card) { G.card.t -= dt; if (G.card.t <= 0) G.card = null; }
|
||||
// A card can wait before it shows. The chapter card used to land on the same frame as the boss
|
||||
// died, so the biggest gore burst in the game happened underneath it and nobody ever saw it.
|
||||
if (G.card) {
|
||||
if (G.card.wait > 0) G.card.wait -= dt;
|
||||
else { G.card.t -= dt; if (G.card.t <= 0) G.card = null; }
|
||||
}
|
||||
if (G.shake && (G.shake.t -= dt) <= 0) G.shake = null;
|
||||
if (G.bossBurst && (G.bossBurst.t -= dt) <= 0) G.bossBurst = null;
|
||||
if (G.boomFlash > 0) G.boomFlash -= dt;
|
||||
// A modal freezes the WORLD, not the clock: no zombie steps, no ball flight, no spawn timers,
|
||||
// so nobody gets eaten while reading a popup. idleT keeps running for the blink.
|
||||
|
||||
+1
-1
@@ -97,6 +97,6 @@
|
||||
<p id="build"></p>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="main.js?v=62"></script>
|
||||
<script type="module" src="main.js?v=63"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -16,7 +16,10 @@ let last = performance.now();
|
||||
function frame(now) {
|
||||
requestAnimationFrame(frame); // schedule FIRST: one bad frame must never kill the loop (a thrown error froze the game for good)
|
||||
if (TUNE.fps && now - last < 1000 / TUNE.fps - 2) return; // frame cap (battery): skip RAF ticks until it's our beat
|
||||
const dt = Math.min(0.04, (now - last) / 1000); last = now;
|
||||
let dt = Math.min(0.04, (now - last) / 1000); last = now;
|
||||
// A chapter boss dies in slow motion. The clock that ENDS it runs at real speed, so the effect is
|
||||
// always the same length in seconds however far the world has been slowed.
|
||||
if (G.slowT > 0) { G.slowT -= dt; dt *= TUNE.bossSlowMo; }
|
||||
update(dt); render();
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -61,6 +61,8 @@ export const G = {
|
||||
asideT: 14, // s until the umpire's next unsolicited remark (TUNE.asideEvery)
|
||||
bossDue: 0, // chapter number the schedule owes you — fired at the next hand-cleared court
|
||||
bossFight: 0, // chapter number of the boss currently on court, 0 = none. Blocks all other spawns.
|
||||
slowT: 0, // s of REAL time left running the world in slow motion (a chapter boss dying)
|
||||
bossBurst: null, // {x, z, t} — the shockwave ring thrown off by that death
|
||||
interlude: false, // sudden-death duel vs the racket zombie: falsy, or {introT} while announcing
|
||||
duelLerp: 0, // 0..1 — how far the court has returned to tennis colors for the duel
|
||||
horde: null, // active horde wave {t, spawnT, mgT} — machine-gun racket, no serving
|
||||
|
||||
+21
@@ -181,10 +181,31 @@ function wreckMachine(zb) {
|
||||
for (let k = 0; k < 26; k++) smokePuff(zb.x, 0.5 + Math.random() * 1.6, zb.z, 0.7); // a column, not a puff
|
||||
}
|
||||
|
||||
// A chapter boss does not just die. It is the thing the whole fight was about, so it comes apart
|
||||
// in its own beat: the world drops into slow motion, the screen kicks, a ring of blood blows out
|
||||
// through the crowd's roar, and the card that announces it waits until the pieces have landed.
|
||||
function bossDeath(zb) {
|
||||
G.slowT = TUNE.bossSlowT; // real seconds; main.js scales dt while it runs
|
||||
G.bossBurst = { x: zb.x, z: zb.z, t: 0.75 }; // the shockwave ring render.js draws
|
||||
G.shake = { t: 0.5, mag: 7 }; G.boomFlash = 0.12;
|
||||
boom(); splat(1); cheer(4, 1); voice('unbelievable', 0.5);
|
||||
const h = 1.8 * (zb.scale || 1.5);
|
||||
for (let k = 0; k < 90; k++) parts.push({ // on top of killZombie's own burst: a full body's worth
|
||||
x: zb.x, y: h * (0.2 + Math.random() * 0.7), z: zb.z,
|
||||
vx: (Math.random() * 2 - 1) * 7, vy: 1 + Math.random() * 7, vz: (Math.random() * 2 - 1) * 7,
|
||||
t: 0.7 + Math.random() * 0.9, dark: Math.random() < 0.4 });
|
||||
for (let k = 0; k < 6; k++) { // and it leaves a mess nothing else on the court leaves
|
||||
const a = Math.random() * 6.283, r = Math.random() * 2.4;
|
||||
gore.push({ x: zb.x + Math.cos(a) * r, z: zb.z + Math.sin(a) * r * 0.6, r: 0.6 + Math.random() * 0.7, a: 0.9 });
|
||||
if (gore.length > 40) gore.shift();
|
||||
}
|
||||
}
|
||||
|
||||
function killZombie(i, quiet) {
|
||||
const zb = zombies[i];
|
||||
const mul = GORE_MUL[zb.variant] || 1;
|
||||
zombies.splice(i, 1);
|
||||
if (zb.variant === 'B') bossDeath(zb); // before endBossFight below, so the card can wait on it
|
||||
if (zb.variant === 'm') { // furniture, not a body: no blood, no mound, no mop boy
|
||||
wreckMachine(zb);
|
||||
if (!G.horde && !G.clearing) splatPoint('P', quiet);
|
||||
|
||||
Reference in new issue
Block a user