The felled body earns its fall: the burst waits for the collapse, the card waits for the burst

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-14 16:35:46 +02:00
1 parent 97b076c574
commit 108ed695eb
4 files changed
+117 -53

No files matched your search

+86 -51
View File
@@ -491,8 +491,23 @@ export function tickMatchFall(dt) {
// setupPoint() and re-places the body on its receiving mark, which is a MATCH doing match things
// inside an art test. Same one-mode-owns-the-court rule the start* functions all obey.
const done = () => { G.matchFall = null; if (zb) zb.showFrame = null; if (!G.bench) matchReady(); };
if (!zb) { G.matchFall = null; if (!G.bench) matchReady(); return; } // felled by something else mid-fall
if (!zb && !f.kill) { G.matchFall = null; if (!G.bench) matchReady(); return; } // felled by something else mid-fall
f.t += dt;
// A GAME-WINNING FALL DOES NOT GET UP. The explosion used to land on the same frame as the point,
// so the collapse never played (Oliver: "it would be better if the explosion would happen after
// the fall"). Now the body drops, holds a beat prone, bursts — and only THEN does the deferred
// finish run (d.afterFall: the heal card, the mop beat, the walk-on), so the WOUNDS TREATED
// popup lands after the burst instead of covering it.
if (f.kill) {
const boomAt = FALL[0][1] + FALL[1][1] + TUNE.matchBoomHold;
if (!f.boomed && (!zb || f.t >= boomAt)) { f.boomed = true; if (zb) explode(zb.x, zb.z); }
if (f.boomed) {
if (f.t < boomAt + TUNE.matchBoomCard) return; // let the burst be seen before any card
G.matchFall = null;
const fin = d.afterFall; d.afterFall = null;
return fin && fin();
}
}
let acc = 0, cur = null;
for (const [suf, len] of FALL) { acc += len; if (f.t < acc) { cur = suf; break; } }
if (!cur) return done(); // back on its feet: hand the point back
@@ -635,53 +650,74 @@ function matchPoint(side) {
matchReady(); // next point, same opponent, both on their marks
}
function matchGameWon(zb, banked = false) {
const d = G.match;
if (d.cup) return cupGameWon(zb, banked);
d.ending = true; // killZombie below re-enters resolveInterlude
if (zb && zombies.includes(zb)) explode(zb.x, zb.z); // the game-winning point IS the kill
// REGICIDE arrives here without advancePoint having banked anything (the body died mid-game, not
// on a game point) — bank the game by hand or the scoreboard drifts from the ladder.
if (!banked) { score.games[0]++; score.pts = [0, 0]; }
G.runGames++; // the ladder metric, same as every mode
// a clean game is how you last — and when it actually restores blood, the trainer SAYS so
// (Oliver: "show wounds healed popup in zombie tennis when appropriate"). Same popup as the
// duel's, so healing has one face everywhere; a full-blood win stays silent.
// a clean game is how you last — and when it actually restores blood, the trainer SAYS so
// (Oliver: "show wounds healed popup in zombie tennis when appropriate"). Same popup as the
// duel's, so healing has one face everywhere; a full-blood win stays silent.
function matchHealBeat() {
const healed = Math.min(G.hpMax, G.hp + TUNE.matchHeal) - G.hp;
G.hp += healed;
if (healed > 0)
showModal({ title: 'WOUNDS TREATED', icon: 'x_cross', iconH: 52, color: UI.pick,
body: ['THE TRAINER PATCHED YOU UP', G.hp + ' OF ' + G.hpMax + ' BACK'] });
}
// The game-winning point fells the body but no longer bursts it on the same frame: the KILL FALL
// plays first (same FALL table, kill-flagged), tickMatchFall detonates it after the prone hold,
// and only then runs d.afterFall — the caller's deferred finish. Regicide arrives with the body
// already gone (it came apart mid-game), so there is nothing left to fall: finish immediately.
function startKillFall(zb) {
const d = G.match;
if (!(zb && zombies.includes(zb))) { const fin = d.afterFall; d.afterFall = null; return fin(); }
G.matchFall = { t: 0, from: racketStage(zb), vx: zb.vx || 0, x0: zb.x, kill: true };
if (!G.matchEarly) ball.active = false;
// serving stays barred through the fall and the burst; afterFall re-arms the real beat's bar
G.interlude = { introT: 4 };
G.phase = 'over'; G.overT = 4;
}
function matchGameWon(zb, banked = false) {
const d = G.match;
if (d.cup) return cupGameWon(zb, banked);
d.ending = true; // killZombie in the burst re-enters resolveInterlude
// REGICIDE arrives here without advancePoint having banked anything (the body died mid-game, not
// on a game point) — bank the game by hand or the scoreboard drifts from the ladder.
if (!banked) { score.games[0]++; score.pts = [0, 0]; }
G.runGames++; // the ladder metric, same as every mode
voice('game_player', 0.3); cheer(2.4, 0.6);
// GAME is the FLASH and the next opponent's name plate is the CARD. It used to be the other way
// round and matchOpponent's plate landed on the same slot a frame later, so 'GAME.' never drew
// once — and the plate is the better card anyway: it announces the win by replacing the loser.
G.flash = { text: 'GAME.', t: 1.4 };
// ...and the boy comes out to deal with what four wound stages left on the far court. Deliberately
// the FAR side: that is where the body has been standing and bleeding, and it keeps him out of the
// near court where the player is about to serve. Between games only — see sendBoy's match note.
sendBoy(0, 4.5, true);
// THE MOP BEAT (Oliver: "mopboy must mop for 5 seconds and really remove some gore visibly
// before the new opponent arrives"). The fresh opponent used to spawn on the same frame as the
// kill, so the boy always mopped behind the next game's back. Now the court belongs to him
// first: the full job allowance (a between-games sendBoy books one stain), and matchOpponent
// waits on d.mopT (ticked in tickMatchFall). A court with nothing worth mopping skips the wait —
// an empty beat is a stall, not a show.
if (G.boy && G.boy.mop) { G.boy.jobs = TUNE.boyMaxJobs; d.mopT = TUNE.matchMopT; }
else d.mopT = 0.6;
// Serving stays blocked for the whole beat: the match's permanent interlude carries it, and
// keyPressed's introT bar sits ABOVE its over-beat re-arm, so a tap cannot serve at an empty
// court. matchOpponent replaces this object and re-arms the normal 1.6 s intro when it runs.
G.interlude = { introT: d.mopT + 1.6 };
d.opp++;
// ALTERNATING SERVICE, as the sport has it. The match calls advancePoint directly, which banks
// the game but never touches the server — that flip lives in tennis's own pointTo, which the
// match bypasses (the same shape as the G.serveNum bug this file already carries a note about).
score.server = other(score.server);
// d.ending STAYS TRUE for the whole beat — the empty-court check in resolveInterlude reads
// "no body + not ending" as regicide, so a stray resolve during the beat would bank a phantom
// game. The walk-on tick below is what re-opens play, exactly where the old code did.
G.phase = 'over'; G.overT = d.mopT + 2.2; // ticks down through the beat; reset at the walk-on
// Everything past the burst waits for it — the heal card, the boy, the walk-on clock.
d.afterFall = () => {
matchHealBeat();
// ...and the boy comes out to deal with what four wound stages left on the far court. Deliberately
// the FAR side: that is where the body has been standing and bleeding, and it keeps him out of the
// near court where the player is about to serve. Between games only — see sendBoy's match note.
sendBoy(0, 4.5, true);
// THE MOP BEAT (Oliver: "mopboy must mop for 5 seconds and really remove some gore visibly
// before the new opponent arrives"). The fresh opponent used to spawn on the same frame as the
// kill, so the boy always mopped behind the next game's back. Now the court belongs to him
// first: the full job allowance (a between-games sendBoy books one stain), and matchOpponent
// waits on d.mopT (ticked in tickMatchFall). A court with nothing worth mopping skips the wait —
// an empty beat is a stall, not a show.
if (G.boy && G.boy.mop) { G.boy.jobs = TUNE.boyMaxJobs; d.mopT = TUNE.matchMopT; }
else d.mopT = 0.6;
// Serving stays blocked for the whole beat: the match's permanent interlude carries it, and
// keyPressed's introT bar sits ABOVE its over-beat re-arm, so a tap cannot serve at an empty
// court. matchOpponent replaces this object and re-arms the normal 1.6 s intro when it runs.
G.interlude = { introT: d.mopT + 1.6 };
// d.ending STAYS TRUE for the whole beat — the empty-court check in resolveInterlude reads
// "no body + not ending" as regicide, so a stray resolve during the beat would bank a phantom
// game. The walk-on tick is what re-opens play, exactly where the old code did.
G.phase = 'over'; G.overT = d.mopT + 2.2; // ticks down through the beat; reset at the walk-on
};
startKillFall(zb);
}
// THE CUP'S GAME. Mid-round: the same body plays on, wearing what the games have cost it. Round
@@ -691,15 +727,11 @@ function cupGameWon(zb, banked = false) {
d.ending = true;
if (!banked) { score.games[0]++; score.pts = [0, 0]; }
G.runGames++;
const healed = Math.min(G.hpMax, G.hp + TUNE.matchHeal) - G.hp;
G.hp += healed;
if (healed > 0)
showModal({ title: 'WOUNDS TREATED', icon: 'x_cross', iconH: 52, color: UI.pick,
body: ['THE TRAINER PATCHED YOU UP', G.hp + ' OF ' + G.hpMax + ' BACK'] });
voice('game_player', 0.3); cheer(2.4, 0.6);
cup.g++;
if (cup.g < R.need) {
// the round is still alive: no kill, no mop beat — the body keeps its wounds and its feet
matchHealBeat();
G.flash = { text: 'GAME.', t: 1.4 };
G.card = { title: R.name, sub1: 'YOU LEAD ' + cup.g + ' OF ' + R.need + ' — SAME BODY, FEWER PIECES',
color: UI.pick, t: 3.0 };
@@ -709,19 +741,22 @@ function cupGameWon(zb, banked = false) {
G.phase = 'over'; G.overT = 2.2;
return;
}
// the round is won: the corpse comes apart
if (zb && zombies.includes(zb)) explode(zb.x, zb.z);
cup.round++; cup.g = 0;
if (cup.round >= CUP_ROUNDS.length) return cupWon();
G.flash = { text: R.name + ' — YOURS.', t: 1.8 };
// the mop beat, exactly as the deathmatch runs it: the boy owns the court, then the next draw
sendBoy(0, 4.5, true);
if (G.boy && G.boy.mop) { G.boy.jobs = TUNE.boyMaxJobs; d.mopT = TUNE.matchMopT; }
else d.mopT = 0.6;
G.interlude = { introT: d.mopT + 1.6 };
cupDraw(); // the next round's rung -> d.opp
score.server = other(score.server);
G.phase = 'over'; G.overT = d.mopT + 2.2; // d.ending stays true until the walk-on tick
// the round is won: the corpse goes down, and THEN comes apart (the kill fall, as the deathmatch)
d.afterFall = () => {
matchHealBeat();
cup.round++; cup.g = 0;
if (cup.round >= CUP_ROUNDS.length) return cupWon();
G.flash = { text: R.name + ' — YOURS.', t: 1.8 };
// the mop beat, exactly as the deathmatch runs it: the boy owns the court, then the next draw
sendBoy(0, 4.5, true);
if (G.boy && G.boy.mop) { G.boy.jobs = TUNE.boyMaxJobs; d.mopT = TUNE.matchMopT; }
else d.mopT = 0.6;
G.interlude = { introT: d.mopT + 1.6 };
cupDraw(); // the next round's rung -> d.opp
score.server = other(score.server);
G.phase = 'over'; G.overT = d.mopT + 2.2; // d.ending stays true until the walk-on tick
};
startKillFall(zb);
}
// CHAMPION OF THE DEAD CLUB — the run's other ending, on the classic win's whole shape (a held