diff --git a/gameplay.js b/gameplay.js index c1ce0b5..2749744 100644 --- a/gameplay.js +++ b/gameplay.js @@ -282,8 +282,11 @@ function hitServe(sv, quality, aim = null, perfect = quality > 0.999) { // the SELECTED tin arms the serve; slot 0 (PLAIN) throws a normal ball and banks your // specials for later. Placement still follows the flick — only WHICH ball flies is the // rack's call now. GUN (slot 4) never reaches here; it starts a burst before the serve. - const wsel = G.mode === 'splatter' ? G.weapon : 0; - const wid = wsel === 0 && !G.temp ? 'ball' : slotId(wsel), armed = slotStock(wsel) > 0; + // ...and the DUEL is plain balls, full stop. startDuel forces slot 0, but slot 0 routes through + // G.temp — so with a temp weapon riding it every duel serve armed and spent the temp (a laser + // fired instead of the ball and aborted the point). Tennis rules means 'ball', temp or no temp. + const wsel = G.mode === 'splatter' && !G.interlude ? G.weapon : 0; + const wid = G.interlude || (wsel === 0 && !G.temp) ? 'ball' : slotId(wsel), armed = slotStock(wsel) > 0; ball.wid = armed ? wid : 'ball'; // an unstocked tin throws a plain ball — file the kills that way // THE LASER fires instead of the ball, down the lane this serve was aimed at. Everything else // about the gesture is untouched — the pull, the aim, the release — which is the whole point of it. @@ -703,6 +706,11 @@ export function endBossFight() { zombieDuck(false); shots.length = 0; cheer(3.4, 0.9); voice('unbelievable', 0.3); + // hand back the game beat the boss interrupted FIRST — same contract resolveInterlude uses. It + // used to run LAST, and its 'set' branch writes a routine SET card and a short overT: on a game + // that is both a set and a boss chapter (game 30), the EPIC card below was overwritten before it + // had drawn a single frame, and the beat was cut to setCardT. The boss card wins now. + if (G.afterDuel) { G.afterClear = G.afterDuel; G.afterDuel = null; proceedAfterClear(); } // The longest fight in the game deserves more than the two seconds a routine set gets. Big word, // long hold — the chapter and the crowd move down to the subs where they belong. // (no 'GAME n NEXT' line: the bar under the scoreboard prints the game number permanently, and a @@ -710,9 +718,10 @@ export function endBossFight() { G.card = { title: 'EPIC!', sub1: 'CHAPTER ' + c + ' DOWN', sub2: 'THE STANDS ARE ON THEIR FEET', color: UI.pick, t: TUNE.bossWinT, wait: TUNE.bossDeathT }; // ...after the body has finished coming apart - ball.active = false; G.phase = 'over'; G.overT = TUNE.bossWinT + 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(); } + ball.active = false; + // a duel the handback just started owns the phase; otherwise hold the boss beat, and never + // SHORTEN a longer one the handback set (a changeover's 5 s outlasts this) + if (!G.interlude) { G.phase = 'over'; G.overT = Math.max(G.overT, TUNE.bossWinT + TUNE.bossDeathT); } } // ---- THE DEADLOCK WATCHDOG. @@ -912,6 +921,11 @@ function onBounce() { spentBall(); return; } if (G.mode === 'splatter' && G.interlude) { // sudden death: the lines are LIVE again + // A ball SPENT on a body ('X' — it wounded the racket zombie without killing him) gets no line + // call in either direction: it is nobody's shot anymore. Without this case the P-or-not test + // below read 'X' as HIS ball — bounce out and you were handed the duel, bounce twice in-court + // and you LOST it for hitting him. Same rule as the dead-roller path in update(). + if (ball.lastHit === 'X') { spentBall(); return; } if (ball.bounces === 1) { const okSide = ball.lastHit === 'P' ? ball.z < COURT.netZ : ball.z > COURT.netZ; if (!inCourt(ball.x, ball.z) || !okSide) { ball.active = false; resolveInterlude(other(ball.lastHit)); voice('out'); } @@ -1158,6 +1172,11 @@ function meleeCooldown() { } export function keyPressed(code) { + // Dead men toss no balls. The restart tap lives in main.js (maybeRestart); everything below — + // melee, parry, and especially the pretoss branch — must not run while the death animation plays, + // or a panic tap conjured a ball that hung frozen midair over the corpse (update early-returns + // in eaten mode, so a tossed ball never got a single frame of physics). + if (G.mode === 'eaten') return; // Desktop sidestep: an arrow/A/D EDGE outside 'rally', where the walk axes do nothing anyway. // Inside a rally the same keys still walk, so one key never means two things at one moment. if (code === 'ArrowLeft' || code === 'KeyA' || code === 'ArrowRight' || code === 'KeyD') { @@ -1279,6 +1298,8 @@ export function update(dt) { // phase C: the summary IS the end menu — hand the soundtrack back to the menu theme. Not at // killPlayer: the death itself already has the massacre call and the crowd's groan over it. if (was < EATEN_SUMMARY && G.eatenT >= EATEN_SUMMARY) { zombieDuck(true); menuDuck(false); } + crowd.cheer = Math.max(0, crowd.cheer - dt); // the stand sits back down over the death too — + // a cheer live at the kill used to clap forever if (G.shake && (G.shake.t -= dt) <= 0) G.shake = null; if (G.boomFlash > 0) G.boomFlash -= dt; updateZombies(dt); diff --git a/render.js b/render.js index 0c04497..d2e716a 100644 --- a/render.js +++ b/render.js @@ -428,7 +428,7 @@ function drawMachine(zb) { const h = MACH_H * (img.height / SPRITE_H); drawStatic(name, zb.x, zb.z, h); if (firing) { // the TELEGRAPH still needs to read at 20px tall, so the muzzle glow stays procedural - const k = 1 - zb.windT / TUNE.bossWind, p = proj(zb.x, h * 0.62, zb.z); // bossWind, not machWind: the boss telegraphs faster + const k = Math.max(0, 1 - zb.windT / (zb.windMax || TUNE.machWind)), p = proj(zb.x, h * 0.62, zb.z); // fade over the ACTUAL wind ctx.globalAlpha = 0.3 + k * 0.7; ctx.fillStyle = k > 0.6 ? '#fff2c0' : UI.warm; const r = Math.max(1.5, p.s * 0.05 * (0.5 + k)); @@ -544,7 +544,7 @@ function drawZombie(zb) { drawStatic(name, zb.x, zb.z, h, bob, zb.face > 0 && !'cfmB'.includes(v)); if (zb.burnT > 0) drawFlames(zb, h); if (zb.windT > 0 && zb.variant === 'B') { // the boss telegraphs too: it rears up, glowing, then throws - const k = 1 - zb.windT / TUNE.bossWind, p = proj(zb.x, h * 0.62, zb.z); // bossWind, not machWind: the boss telegraphs faster + const k = Math.max(0, 1 - zb.windT / (zb.windMax || TUNE.bossWind)), p = proj(zb.x, h * 0.62, zb.z); // fade over the design's own wind ctx.globalAlpha = 0.25 + k * 0.55; ctx.fillStyle = k > 0.6 ? '#fff2c0' : UI.bad; const r = Math.max(2, p.s * 0.1 * (0.5 + k)); ctx.beginPath(); ctx.arc(Math.round(p.x), Math.round(p.y), r, 0, 7); ctx.fill(); diff --git a/zombies.js b/zombies.js index a0894cf..121181d 100644 --- a/zombies.js +++ b/zombies.js @@ -858,7 +858,11 @@ export function updateZombies(dt) { } } else if ((zb.fireT -= dt) <= 0) { zb.fireT = boss ? (A?.cycle ?? TUNE.bossCycle) : TUNE.machCycle; - zb.windT = boss ? (A?.wind ?? TUNE.bossWind) : TUNE.machWind; + // windMax rides along for render: the telegraph glow fades in over the ACTUAL wind. It used + // to divide by TUNE.bossWind whatever the shooter, so the machine (0.9 s) and any design + // with wind > 0.5 spent the first stretch at an invalid negative alpha — full brightness, + // no ramp, on the one effect documented as the fairness contract. + zb.windT = zb.windMax = boss ? (A?.wind ?? TUNE.bossWind) : TUNE.machWind; pock(0.3, 1500); // the tell: a mechanical click as it loads } }