End a shot's topspin dip at the bounce, so a kicking ball kicks up

A shot's extra gravity is its dive in flight, but it was carried through
the bounce: heavy topspin skidded away low and Oliver met nearly every
ball 0-25 cm off the grass. After its bounce a ball now falls under plain
gravity (tennis modes only), and every planner that rolls a bounce
forward switches at the same moment. The higher bounces pushed the
auto-run into volleys, so it now takes a kicking ball at the top of its
bounce, up to chest height. Median contact rises from 0.42 to 1.13 m at
the bottom of the ladder. v369.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5.5 committed 2026-10-14 13:38:01 +02:00
1 parent fe090f2fba
commit e25047ed0c
5 files changed
+40 -8

No files matched your search

+20 -7
View File
@@ -39,6 +39,14 @@ const strikeKick = () => {
const bXZ = () => TUNE.bounceXZ * (ball.spin || 1);
// ...and backspin keeps it LOW as well: the vertical rebound gives up part of what the pace does
const bY = () => TUNE.bounceY * (1 - TUNE.spinLow * (1 - (ball.spin || 1)));
// THE DIP ENDS AT THE BOUNCE (2026-10-14, Oliver's pick). A shot's extra gravity (quality topspin,
// the serve's spin) is its DIVE IN FLIGHT — it was carried through the bounce, so a "heavy topspin"
// ball skidded away LOW afterwards and nearly every contact in his logs was 0-25 cm off the grass.
// Real topspin kicks UP. After the bounce a ball falls under plain gravity; the kick itself is
// bY/bXZ's spin factor. Tennis modes only — gore's balls keep theirs. Every planner rolling a bounce
// forward switches here too (intercept, bounceContact, ringCue, matchOutOfReachFlight) or it would
// predict the old low ball.
const gAfter = g => (rallyFeel() || G.mode === 'tennis' ? TUNE.gravity : g);
export function solveShot(x0, y0, z0, tx, tz, s, g = TUNE.gravity, ty = 0.08) {
const dx = tx - x0, dz = tz - z0, dist = Math.hypot(dx, dz), T = dist / s;
@@ -1295,7 +1303,7 @@ function matchOutOfReachFlight(rz, pred) {
const g = ball.g || TUNE.gravity;
const spd = rzChaseSpeed(rz);
const vyl = ball.vy - g * pred.t; // vertical speed at touchdown
const t2 = Math.max(0.05, 2 * (-vyl * bY()) / g);
const t2 = Math.max(0.05, 2 * (-vyl * bY()) / gAfter(g));
const vx2 = ball.vx * bXZ(), vz2 = ball.vz * bXZ();
const T = pred.t + t2;
for (let i = 0; i <= 10; i++) {
@@ -1450,12 +1458,13 @@ const rallyHome = ch => ({ x: 0, z: Math.abs(ch.z - COURT.netZ) < TUNE.netFrom |
// bounce that never gets that high. A SHORT ball is met on the RISE instead, moving in. Closed form
// off predictLanding, for racketAI (intercept walks the flight for itself).
function bounceContact(short) {
const g = ball.g || TUNE.gravity;
let g = ball.g || TUNE.gravity;
let { x, y, z, vx, vy, vz } = ball;
if (ball.bounces === 0) {
const p = predictLanding(); if (!p) return null;
x = p.x; z = p.z; y = 0;
vy = -(vy - g * p.t) * bY(); vx *= bXZ(); vz *= bXZ();
g = gAfter(g); // the dip ends at the bounce
}
const apex = y + Math.max(0, vy) ** 2 / (2 * g), h = Math.min(TUNE.rallyHit, apex);
const r = Math.sqrt(Math.max(0, vy * vy - 2 * g * (h - y)));
@@ -1472,7 +1481,7 @@ function intercept(side) {
// classic CPU speeds up or slows down. Same guard on every dial it scales.
const spd = side === 'O' ? TUNE.aiSpeed * (G.diff ? G.diff.speed : 1) : TUNE.playerSpeed * assistK();
let { x, y, z, vx, vy, vz } = ball;
const g = ball.g || TUNE.gravity, dt = 0.01;
let g = ball.g || TUNE.gravity; const dt = 0.01; // g switches to plain gravity at the simulated bounce (gAfter)
const inZone = side === 'O' ? zz => zz <= ZONE_DEPTH : zz => zz >= COURT.L - ZONE_DEPTH;
const home = rallyFeel() ? rallyHome(ch) : HOME[side];
const plan = rallyFeel() ? rxPlan(ch) : null;
@@ -1485,11 +1494,13 @@ function intercept(side) {
let fallback = null, deepest = null, anyHit = null, rise = null, nb = ball.bounces;
for (let i = 0; i < 300; i++) {
vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt;
if (y <= 0 && vy < 0) { y = 0; vy = -vy * bY(); vx *= bXZ(); vz *= bXZ(); nb++; }
if (y <= 0 && vy < 0) { y = 0; vy = -vy * bY(); vx *= bXZ(); vz *= bXZ(); nb++; g = gAfter(g); }
// FROM THE BACK: meet it between its bounce and the next one — at the top or falling through
// rallyHit+0.3, from as far as rallyBehind behind the line; a short ball the first moment it is
// up off the grass. Only if nothing like that is reachable do the old candidates below apply.
if (back && nb === 1 && i > 5 && y > 0.45 && y < TUNE.rallyHit + (plan.short ? 0.6 : 0.3)) {
// (up to rallyTop since the dip ends at the bounce: a kicking ball is taken at the top of its
// bounce, chest high, rather than chased forward and volleyed — 40% volleys was the first cut)
if (back && nb === 1 && i > 5 && y > 0.45 && y < (plan.short ? TUNE.rallyHit + 0.6 : TUNE.rallyTop)) {
const inB = side === 'P' ? z <= COURT.L + TUNE.rallyBehind : z >= -TUNE.rallyBehind;
if (inB && Math.hypot(x - ch.x, z - ch.z) / spd <= i * dt) {
if (plan.short || vy < 1) return { x, z };
@@ -1525,7 +1536,8 @@ function intercept(side) {
// that moment in the swing window — whatever the pace. TUNE.ringTime 0 restores the distance ring.
export function ringCue() {
if (!ball.active || ball.lastHit !== 'O' || ball.vz <= 0) return null;
const g = ball.g || TUNE.gravity, dt = 1 / 120;
let g = ball.g || TUNE.gravity;
const dt = 1 / 120;
let { x, y, z, vx, vy, vz } = ball, b = ball.bounces;
// ...against where the player WILL be: the auto-run is still carrying him to its target at the pace
// he is running now, and measuring against where he stands made the cue jump 50-180 ms a frame
@@ -1544,7 +1556,7 @@ export function ringCue() {
return { eta: t, sweet, band, gold: Math.abs(t - sweet) < band };
}
vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt;
if (y <= 0 && vy < 0) { y = 0; vy = -vy * bY(); vx *= bXZ(); vz *= bXZ(); b++; if (b > 1) return null; }
if (y <= 0 && vy < 0) { y = 0; vy = -vy * bY(); vx *= bXZ(); vz *= bXZ(); g = gAfter(g); b++; if (b > 1) return null; }
}
return null;
}
@@ -2492,6 +2504,7 @@ export function update(dt) {
}
ball.y = 0; ball.vy = -ball.vy * bY();
ball.vx *= bXZ(); ball.vz *= bXZ();
ball.g = gAfter(ball.g); // the dip ends at the bounce: a topspin ball kicks UP now (gAfter)
if (Math.abs(ball.vy) > 1.0) dustPuff(ball.x, ball.z, 3, 0.7); // a real bounce kicks up the court
onBounce();
if (ball.active && Math.abs(ball.vy) < 0.8) { // rolled out — dead ball