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

+17
View File
@@ -5485,6 +5485,23 @@ there when the window opened) or `early 40ms`. That was the question his first l
kicks UP. Resetting `ball.g` at the bounce would fix the picture but moves every timing in the
rally (all four planners), so it is a decision to put to him.
### The dip ends at the bounce: topspin kicks UP (2026-10-14, v369, Oliver: "ja, bau den topspin-fix")
A shot's extra gravity (quality topspin `spinFactor`, the serve's `serveSpin`) is its DIVE IN FLIGHT,
and it used to be carried THROUGH the bounce: a "heavy topspin" ball skidded away low and nearly every
contact in his logs was 0-25 cm off the grass. `gAfter()` hands a ball plain gravity at its bounce
(tennis modes; gore's balls keep theirs). The kick itself was already `bY`/`bXZ`'s spin factor. Every
planner that rolls a bounce forward switches at the same moment (physics, `intercept`, `bounceContact`,
`ringCue`, `matchOutOfReachFlight`); one left on the old gravity would predict a ball that is not there.
- **Side effect, caught by the probe:** higher bounces fell outside the auto-run's 0.45-1.3 m comfort
band, so it found no point behind the line and ran forward to VOLLEY (38-42% volleys). The player
now takes a kicking bounce at its top, up to `rallyTop` 1.7 m. Volleys are back to 8-18%.
- **Measured** (probe, median player contact): rung 1 0.42 -> 1.13 m, rung 6 0.53 m, and against the
big hitter still 0.35 m. The cause there is no longer the bounce. A contact fires on the FIRST frame
the ball is in reach, so against hard pace it is met as it enters reach, just off the bounce. Waist
high contact against pace would need the contact model itself changed: contact at the closest
approach, quality by distance from the sweet spot. That is a separate decision. The timed ring still
returns 92-98%. 162 tests, nine seeds.
# OPEN — leftovers, in rough priority order
Nothing here is broken; these are known gaps and unverified risks.
+1 -1
View File
@@ -1140,6 +1140,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div>
</div>
<span id="recLamp" hidden>REC</span>
<script type="module" src="src/main.js?v=368"></script>
<script type="module" src="src/main.js?v=369"></script>
</body>
</html>
+1
View File
@@ -91,6 +91,7 @@ export const TUNE = {
rallyBack: 0.6, // m behind the baseline a baseliner recovers to
rallyBehind: 2.2, // m behind the baseline he will drop back to let a deep ball come down
rallyHit: 1.0, // m: a baseliner meets the ball falling through this height
rallyTop: 1.7, // m: ...and the player's auto-run accepts a kicking bounce up to here (chest high)
lowVolleyQ: 0.3, // quality cap on a volley struck below the tape: you can only block it up
scoopY: 0.45, // m: a ball met lower than this after its bounce is dug out, same cap
// THE SOFT BALL (step 3): the down-flick, its length = how far. Drop at one end, lob at the other.
+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
+1
View File
@@ -152,6 +152,7 @@ for (const rung of RUNGS) {
'<3.5m(net)%': pct(h, x => x.fromNet < 3.5),
'volley%': pct(h, x => !x.bounced),
'medTimeToHit_s': +med(h.filter(x => x.dt).map(x => x.dt)).toFixed(2),
'medContactY': +med(h.map(x => x.y)).toFixed(2),
});
}
for (const side of ['P', 'O']) {