From 108ed695ebeca46d083301764869c70bd588206b Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Fri, 14 Aug 2026 16:35:46 +0200 Subject: [PATCH] The felled body earns its fall: the burst waits for the collapse, the card waits for the burst Co-Authored-By: Claude Fable 5 --- index.html | 2 +- src/beats.js | 137 +++++++++++++++++++++++++++++++------------------- src/config.js | 2 + test.js | 29 ++++++++++- 4 files changed, 117 insertions(+), 53 deletions(-) diff --git a/index.html b/index.html index e889811..82de44c 100644 --- a/index.html +++ b/index.html @@ -494,6 +494,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/beats.js b/src/beats.js index 3eef50b..ed29e3d 100644 --- a/src/beats.js +++ b/src/beats.js @@ -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 diff --git a/src/config.js b/src/config.js index 8876602..e45ca38 100644 --- a/src/config.js +++ b/src/config.js @@ -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 diff --git a/test.js b/test.js index 53d8555..88bec3b 100644 --- a/test.js +++ b/test.js @@ -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'); });