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

+1 -1
View File
@@ -494,6 +494,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
</div>
</div>
</div>
<script type="module" src="src/main.js?v=250"></script>
<script type="module" src="src/main.js?v=251"></script>
</body>
</html>
+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
+2
View File
@@ -497,6 +497,8 @@ export const TUNE = {
// ("still a LOT of unreachable balls"); now line-painting is the late ladder
matchWidePer: 0.25, // ...earning this much more width per opponent felled (softened with speedPer)
matchHeal: 2, // hp back for a game won (the duel's healPerDuel rate)
matchBoomHold: 0.5, // s the game-winning body lies prone before it bursts — the fall must be SEEN
matchBoomCard: 0.6, // s after the burst before the WOUNDS TREATED card may land on top of it
// The timing windows, DRAWN (the match's whole ask: "what is the possible tap window, what is the
// sweet spot"). Numbers shared with the mechanics they visualise, so the picture cannot lie:
matchRingFar: 9, // m — the approach ring fades in once the incoming ball is this close
+28 -1
View File
@@ -510,6 +510,8 @@ const oppOf = () => zombies.find(z => z.variant === 'r');
// freezes the world until tapped. Tests that play across a game boundary step through all of it,
// dismissing popups the way a player would.
const passMopBeat = () => { // ...stopping at the walk-on: past it, the next opponent PLAYS
// the KILL FALL plays first — the felled body is still on court, so oppOf() must not count it
for (let i = 0; i < 5 * 60 && G.matchFall; i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
for (let i = 0; i < (TUNE.matchMopT + 8) * 60 && !oppOf(); i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
};
@@ -538,7 +540,16 @@ test('a point won tears a wound stage off the body; only the game point fells it
}
assert.equal(zb.hp, 1, 'three points in: one stage left');
resolveInterlude('P'); // the game point
assert.ok(!zombies.includes(zb), 'the game-winning point IS the kill');
// THE KILL FALL: the game point fells the body but no longer bursts it on the same frame —
// the collapse plays, the prone hold holds, and only then the explosion (Oliver: "it would be
// better if the explosion would happen after the fall").
assert.ok(zombies.includes(zb), 'the game point drops the body — the burst waits for the fall');
assert.ok(G.matchFall && G.matchFall.kill, 'the kill fall is armed');
assert.ok(G.interlude && G.interlude.introT > 0, 'serving is barred while it plays');
for (let i = 0; i < (0.3 + TUNE.matchBoomHold) * 60 + 5 && zombies.includes(zb); i++) update(DT);
assert.ok(!zombies.includes(zb), 'the fallen body bursts after the prone hold');
assert.ok(!(G.match.mopT > 0), '...and the finish still waits for the smoke to be seen');
for (let i = 0; i < (TUNE.matchBoomCard + 0.5) * 60 && !(G.match.mopT > 0); i++) update(DT);
// THE MOP BEAT: the corpse's gore holds the court first — no fresh body until the boy has had
// his time (TUNE.matchMopT, skipped only on a clean court; the explode() above guarantees gore).
assert.ok(!oppOf(), 'no fresh opponent on the kill frame — the mop beat owns the court');
@@ -555,6 +566,18 @@ test('a point won tears a wound stage off the body; only the game point fells it
assert.deepEqual(score.pts, [0, 0], 'and the points reset');
});
test('the WOUNDS TREATED card lands after the explosion, never on top of it', () => {
startMatch();
const zb = oppOf();
G.hp = G.hpMax - 2; // something for the trainer to treat
for (let k = 0; k < 4; k++) resolveInterlude('P'); // a straight game
assert.ok(!G.modal, 'no card while the body is still falling');
for (let i = 0; i < (0.3 + TUNE.matchBoomHold) * 60 + 5 && zombies.includes(zb); i++) update(DT);
assert.ok(!zombies.includes(zb) && !G.modal, 'no card on the burst frame either');
for (let i = 0; i < (TUNE.matchBoomCard + 0.5) * 60 && !G.modal; i++) update(DT);
assert.ok(G.modal && G.modal.title === 'WOUNDS TREATED', 'the trainer card follows the burst');
});
test('their points draw blood from the MATCH pool, and running dry is the run', () => {
startMatch();
assert.equal(G.hp, TUNE.matchPlayerHp, 'the match brings its own, deeper pool');
@@ -829,6 +852,9 @@ test('THE TOURNAMENT: bands draw, bodies deepen, rounds hold their body, the cha
if (g < R.need)
assert.ok(zombies.includes(zb), R.name + ' game ' + g + ': the same body plays on');
}
// the round-winning game arms the KILL FALL: the body drops first, bursts after the hold
assert.ok(G.matchFall && G.matchFall.kill, R.name + ': the kill fall is armed');
for (let i = 0; i < 4 * 60 && G.matchFall; i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
assert.ok(!zombies.includes(zb), R.name + ': the round-winning game is the kill');
if (round < CUP_ROUNDS.length - 1) passMopBeat(); // the boy, then the next draw walks on
}
@@ -1001,6 +1027,7 @@ test('a game won heals, so a clean player can go the distance', () => {
startMatch();
G.hp = 1;
for (let k = 0; k < 4; k++) resolveInterlude('P');
for (let i = 0; i < 5 * 60 && G.matchFall; i++) update(DT); // the heal waits for the burst now
assert.equal(G.hp, Math.min(G.hpMax, 1 + TUNE.matchHeal), 'the game paid its heal');
});