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:
1 parent
97b076c574
commit
108ed695eb
4 files changed
+117
-53
No files matched your search
+1
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user