Six calls the umpire got wrong

- The duel misread a spent ('X') ball: bounce out = instant win, two bounces
  = a loss plus a surge, for a shot that should just be re-served.
- Duel serves routed through the temp weapon riding slot 0 — a laser fired
  instead of the ball. Tennis rules now mean plain balls, temp or no temp.
- A tap during the death animation tossed a ball that hung frozen midair.
- The EPIC boss card was clobbered by the SET card when game 30 was both.
- The telegraph glow divided by the wrong wind: negative alpha, no fade-in.
- The crowd clapped forever over the death screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-06 09:33:08 +02:00
1 parent 1f22d3d5a0
commit f1f0d05a61
3 files changed
+33 -8

No files matched your search

+26 -5
View File
@@ -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 // 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 // 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. // 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; // ...and the DUEL is plain balls, full stop. startDuel forces slot 0, but slot 0 routes through
const wid = wsel === 0 && !G.temp ? 'ball' : slotId(wsel), armed = slotStock(wsel) > 0; // 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 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 // 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. // 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); zombieDuck(false);
shots.length = 0; shots.length = 0;
cheer(3.4, 0.9); voice('unbelievable', 0.3); 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, // 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. // 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 // (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', G.card = { title: 'EPIC!', sub1: 'CHAPTER ' + c + ' DOWN',
sub2: 'THE STANDS ARE ON THEIR FEET', color: UI.pick, t: TUNE.bossWinT, sub2: 'THE STANDS ARE ON THEIR FEET', color: UI.pick, t: TUNE.bossWinT,
wait: TUNE.bossDeathT }; // ...after the body has finished coming apart wait: TUNE.bossDeathT }; // ...after the body has finished coming apart
ball.active = false; G.phase = 'over'; G.overT = TUNE.bossWinT + TUNE.bossDeathT; ball.active = false;
// hand back the game beat the boss interrupted — same contract resolveInterlude uses // a duel the handback just started owns the phase; otherwise hold the boss beat, and never
if (G.afterDuel) { G.afterClear = G.afterDuel; G.afterDuel = null; proceedAfterClear(); } // 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. // ---- THE DEADLOCK WATCHDOG.
@@ -912,6 +921,11 @@ function onBounce() {
spentBall(); return; spentBall(); return;
} }
if (G.mode === 'splatter' && G.interlude) { // sudden death: the lines are LIVE again 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) { if (ball.bounces === 1) {
const okSide = ball.lastHit === 'P' ? ball.z < COURT.netZ : ball.z > COURT.netZ; 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'); } 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) { 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. // 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. // 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') { 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 // 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. // 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); } 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.shake && (G.shake.t -= dt) <= 0) G.shake = null;
if (G.boomFlash > 0) G.boomFlash -= dt; if (G.boomFlash > 0) G.boomFlash -= dt;
updateZombies(dt); updateZombies(dt);
+2 -2
View File
@@ -428,7 +428,7 @@ function drawMachine(zb) {
const h = MACH_H * (img.height / SPRITE_H); const h = MACH_H * (img.height / SPRITE_H);
drawStatic(name, zb.x, zb.z, 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 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.globalAlpha = 0.3 + k * 0.7;
ctx.fillStyle = k > 0.6 ? '#fff2c0' : UI.warm; ctx.fillStyle = k > 0.6 ? '#fff2c0' : UI.warm;
const r = Math.max(1.5, p.s * 0.05 * (0.5 + k)); 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)); drawStatic(name, zb.x, zb.z, h, bob, zb.face > 0 && !'cfmB'.includes(v));
if (zb.burnT > 0) drawFlames(zb, h); if (zb.burnT > 0) drawFlames(zb, h);
if (zb.windT > 0 && zb.variant === 'B') { // the boss telegraphs too: it rears up, glowing, then throws 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; 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)); 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(); ctx.beginPath(); ctx.arc(Math.round(p.x), Math.round(p.y), r, 0, 7); ctx.fill();
+5 -1
View File
@@ -858,7 +858,11 @@ export function updateZombies(dt) {
} }
} else if ((zb.fireT -= dt) <= 0) { } else if ((zb.fireT -= dt) <= 0) {
zb.fireT = boss ? (A?.cycle ?? TUNE.bossCycle) : TUNE.machCycle; 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 pock(0.3, 1500); // the tell: a mechanical click as it loads
} }
} }