The fall beats the ball: a passing winner fells the body while it still rolls
Oliver: 'the zombie falls after the ball is lost although we know this earlier — it could play out as ball lost because of the falling.' The match point now resolves at bounce ONE when the winner is provably beyond the body's reach (closed-form flight vs its own chase dials, full swing reach plus 0.6 m of doubt — borderline balls wait for the ordinary bounce-2 call). startFall keeps the ball rolling for an early-decided point, and the d.decided latch swallows the rally's later resolutions so it can never bank twice. Verified in-pane: collapse starts ~0.3 s after the bounce, ball rolls past the prone body, score banks exactly once. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c6218a6284
commit
53df079db6
4 files changed
+64
-2
No files matched your search
+1
-1
@@ -431,6 +431,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="src/main.js?v=219"></script>
|
||||
<script type="module" src="src/main.js?v=220"></script>
|
||||
</body>
|
||||
</html>
|
||||
+8
-1
@@ -357,6 +357,7 @@ export function startMatch() {
|
||||
function matchReady() {
|
||||
ball.active = false;
|
||||
G.serveNum = 1;
|
||||
if (G.match) G.match.decided = false; // the next rally may be decided early again
|
||||
setupPoint();
|
||||
const rz = zombies.find(z => z.variant === 'r');
|
||||
// THE OPPONENT STANDS WHERE THE POINT PUTS IT: on its receiving mark, or behind its own baseline
|
||||
@@ -404,7 +405,10 @@ export const FALL_LEN = fallLen(); // the load-time value test.js imports as a
|
||||
function startFall(from) {
|
||||
const fz = zombies.find(z => z.variant === 'r');
|
||||
G.matchFall = { t: 0, from, vx: (fz && fz.vx) || 0, x0: (fz && fz.x) || 0 };
|
||||
ball.active = false;
|
||||
// an EARLY-decided point keeps its ball: the whole reason the fall starts now is so the ball
|
||||
// visibly rolls past the collapsing body (gameplay's matchOutOfReach call). The rally cannot
|
||||
// resolve twice — the d.decided latch swallows every later resolution of it.
|
||||
if (!G.matchEarly) ball.active = false;
|
||||
G.phase = 'over'; G.overT = fallLen() + 0.6; // outlives the fall on purpose — see above
|
||||
}
|
||||
|
||||
@@ -642,6 +646,9 @@ export function resolveInterlude(side) {
|
||||
// interlude is permanent while a match runs (it is what keeps the serving tennis-like and the
|
||||
// spawn director quiet). Regicide arrives here too, as a 'P' with the body already gone.
|
||||
if (G.match) {
|
||||
// a rally resolved at its DECIDED moment (the early fall) is spent: the ball that kept rolling
|
||||
// for the picture still reaches its ordinary bounce-2 / rolled-out calls, and they land here
|
||||
if (G.match.decided) { ball.active = false; return; }
|
||||
if (!zombies.some(z => z.variant === 'r') && !G.match.ending) return matchGameWon(null);
|
||||
return matchPoint(side);
|
||||
}
|
||||
|
||||
@@ -930,6 +930,25 @@ export function inServeBox(x, z) {
|
||||
return xOK && Math.abs(x) <= COURT.W / 2 + 0.02 && zOK;
|
||||
}
|
||||
|
||||
// Can the match opponent still reach this ball before its second bounce? Closed-form flight from
|
||||
// the POST-BOUNCE state (velocities already damped when the line call runs) against the body's own
|
||||
// chase speed — the same dials racketAI moves with — plus a full swing reach and a 0.6 m benefit
|
||||
// of the doubt. Returns true only when the rally is provably over.
|
||||
function matchOutOfReach() {
|
||||
const rz = zombies.find(z => z.variant === 'r');
|
||||
if (!rz || ball.vz > 0) return false;
|
||||
const g = ball.g || TUNE.gravity;
|
||||
const t2 = Math.max(0.05, 2 * ball.vy / g); // time until the second touchdown
|
||||
const spd = TUNE.racketSpeed * (TUNE.matchSpeedBase + (G.match.opp - 1) * TUNE.matchSpeedPer)
|
||||
* ((rz.foe && rz.foe.speed) || 1);
|
||||
for (let i = 0; i <= 8; i++) {
|
||||
const t = t2 * i / 8;
|
||||
const bx = ball.x + ball.vx * t, bz = ball.z + ball.vz * t;
|
||||
if (Math.hypot(bx - rz.x, bz - rz.z) <= spd * t + TUNE.aiReach + 0.6) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function onBounce() {
|
||||
ball.bounces++;
|
||||
pock(0.35, 500);
|
||||
@@ -948,6 +967,18 @@ function onBounce() {
|
||||
if (ball.bounces === 1) {
|
||||
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'); }
|
||||
// THE FALL BEATS THE BALL (Oliver: "the zombie falls after the ball is lost although we know
|
||||
// this earlier — it could play out as ball lost BECAUSE of the falling"). A winner that just
|
||||
// bounced in and is provably beyond the body's reach has already decided the point, so it
|
||||
// resolves HERE: the collapse starts while the ball still rolls past, and the picture reads
|
||||
// "he fell, so it got by". Conservative on purpose — a borderline ball simply waits for the
|
||||
// ordinary bounce-2 call, because a wrong early call hands over a rally that was alive.
|
||||
else if (G.match && !G.match.ending && !G.match.decided && ball.lastHit === 'P' && matchOutOfReach()) {
|
||||
G.matchEarly = true; // startFall leaves the ball rolling
|
||||
resolveInterlude('P');
|
||||
G.matchEarly = false;
|
||||
if (G.match) G.match.decided = true; // every later resolution of THIS rally is already spent
|
||||
}
|
||||
} else if (ball.bounces === 2) { ball.active = false; resolveInterlude(ball.lastHit); }
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -761,6 +761,30 @@ test('a wounding point knocks the body down and it gets up MINUS the limb', () =
|
||||
assert.equal(G.serveNum, 1, 'matchReady ran: the next point is armed');
|
||||
});
|
||||
|
||||
test('the fall beats the ball: a passing winner fells the body while the ball still rolls', () => {
|
||||
// Oliver: "the zombie falls after the ball is lost although we know this earlier — it could
|
||||
// play out as ball lost BECAUSE of the falling." A winner that bounced in beyond the body's
|
||||
// provable reach resolves at bounce ONE: the collapse starts, the ball keeps rolling, and the
|
||||
// decided-latch makes sure the rally can never bank twice.
|
||||
startMatch();
|
||||
G.mode = 'splatter'; // the app's intoSplatter() precedes startMatch()
|
||||
G.interlude.introT = 0;
|
||||
const zb = oppOf();
|
||||
zb.hp = 3; // so THIS point crosses a wound stage (3 -> 2 is the arm)
|
||||
zb.x = -4; zb.z = 2; // parked far from where the winner lands
|
||||
ball.active = true; ball.lastHit = 'P'; ball.bounces = 0; ball.wid = 'ball'; ball.collected = false;
|
||||
ball.x = 3.4; ball.z = 7; ball.y = 0.5; ball.vx = 0.5; ball.vy = -2; ball.vz = -6; ball.g = TUNE.gravity;
|
||||
G.phase = 'rally'; G.wasServe = false;
|
||||
const pts = score.pts[0];
|
||||
for (let i = 0; i < 40 && !G.matchFall; i++) update(DT);
|
||||
assert.ok(G.matchFall, 'the collapse starts at the decided moment, not at the dead ball');
|
||||
assert.ok(ball.active, '...and the ball is still rolling past the falling body');
|
||||
assert.equal(score.pts[0], pts + 1, 'the point banked at the decision');
|
||||
for (let i = 0; i < 5 * 60; i++) update(DT); // the ball dies its ordinary death, the fall plays out
|
||||
assert.equal(score.pts[0], pts + 1, '...and never banked a second time');
|
||||
assert.ok(!G.match.decided, 'the latch cleared for the next rally');
|
||||
});
|
||||
|
||||
test('service alternates by game, and the opponent actually serves', () => {
|
||||
// Until now the corpse only ever returned: score.server was pinned to 'P' because the match calls
|
||||
// advancePoint directly and the flip lives in tennis's pointTo, which the match bypasses. Return
|
||||
|
||||
Reference in new issue
Block a user