diff --git a/config.js b/config.js index 2e8219d..93b55a4 100644 --- a/config.js +++ b/config.js @@ -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 diff --git a/gameplay.js b/gameplay.js index 81ff80e..678e8e1 100644 --- a/gameplay.js +++ b/gameplay.js @@ -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. diff --git a/index.html b/index.html index 1f1bd19..4ef5baa 100644 --- a/index.html +++ b/index.html @@ -97,6 +97,6 @@

- + diff --git a/main.js b/main.js index e405d4d..3bae771 100644 --- a/main.js +++ b/main.js @@ -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(); } diff --git a/render.js b/render.js index de3cf74..cc50190 100644 --- a/render.js +++ b/render.js @@ -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'; diff --git a/state.js b/state.js index e1c4dc8..b17fd9b 100644 --- a/state.js +++ b/state.js @@ -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 diff --git a/zombies.js b/zombies.js index a80eaef..4bafe80 100644 --- a/zombies.js +++ b/zombies.js @@ -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);