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:
wuwiandClaude Fable 5 committed 2026-08-13 21:35:52 +02:00
1 parent c6218a6284
commit 53df079db6
4 files changed
+64 -2

No files matched your search

+31
View File
@@ -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;
}