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:
1 parent
fe090f2fba
commit
e25047ed0c
5 files changed
+40
-8
No files matched your search
+17
@@ -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
|
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.
|
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
|
# OPEN — leftovers, in rough priority order
|
||||||
|
|
||||||
Nothing here is broken; these are known gaps and unverified risks.
|
Nothing here is broken; these are known gaps and unverified risks.
|
||||||
|
|||||||
+1
-1
@@ -1140,6 +1140,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span id="recLamp" hidden>REC</span>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -91,6 +91,7 @@ export const TUNE = {
|
|||||||
rallyBack: 0.6, // m behind the baseline a baseliner recovers to
|
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
|
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
|
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
|
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
|
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.
|
// THE SOFT BALL (step 3): the down-flick, its length = how far. Drop at one end, lob at the other.
|
||||||
|
|||||||
+20
-7
@@ -39,6 +39,14 @@ const strikeKick = () => {
|
|||||||
const bXZ = () => TUNE.bounceXZ * (ball.spin || 1);
|
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
|
// ...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)));
|
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) {
|
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;
|
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 g = ball.g || TUNE.gravity;
|
||||||
const spd = rzChaseSpeed(rz);
|
const spd = rzChaseSpeed(rz);
|
||||||
const vyl = ball.vy - g * pred.t; // vertical speed at touchdown
|
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 vx2 = ball.vx * bXZ(), vz2 = ball.vz * bXZ();
|
||||||
const T = pred.t + t2;
|
const T = pred.t + t2;
|
||||||
for (let i = 0; i <= 10; i++) {
|
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
|
// 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).
|
// off predictLanding, for racketAI (intercept walks the flight for itself).
|
||||||
function bounceContact(short) {
|
function bounceContact(short) {
|
||||||
const g = ball.g || TUNE.gravity;
|
let g = ball.g || TUNE.gravity;
|
||||||
let { x, y, z, vx, vy, vz } = ball;
|
let { x, y, z, vx, vy, vz } = ball;
|
||||||
if (ball.bounces === 0) {
|
if (ball.bounces === 0) {
|
||||||
const p = predictLanding(); if (!p) return null;
|
const p = predictLanding(); if (!p) return null;
|
||||||
x = p.x; z = p.z; y = 0;
|
x = p.x; z = p.z; y = 0;
|
||||||
vy = -(vy - g * p.t) * bY(); vx *= bXZ(); vz *= bXZ();
|
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 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)));
|
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.
|
// 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();
|
const spd = side === 'O' ? TUNE.aiSpeed * (G.diff ? G.diff.speed : 1) : TUNE.playerSpeed * assistK();
|
||||||
let { x, y, z, vx, vy, vz } = ball;
|
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 inZone = side === 'O' ? zz => zz <= ZONE_DEPTH : zz => zz >= COURT.L - ZONE_DEPTH;
|
||||||
const home = rallyFeel() ? rallyHome(ch) : HOME[side];
|
const home = rallyFeel() ? rallyHome(ch) : HOME[side];
|
||||||
const plan = rallyFeel() ? rxPlan(ch) : null;
|
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;
|
let fallback = null, deepest = null, anyHit = null, rise = null, nb = ball.bounces;
|
||||||
for (let i = 0; i < 300; i++) {
|
for (let i = 0; i < 300; i++) {
|
||||||
vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt;
|
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
|
// 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
|
// 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.
|
// 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;
|
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 (inB && Math.hypot(x - ch.x, z - ch.z) / spd <= i * dt) {
|
||||||
if (plan.short || vy < 1) return { x, z };
|
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.
|
// that moment in the swing window — whatever the pace. TUNE.ringTime 0 restores the distance ring.
|
||||||
export function ringCue() {
|
export function ringCue() {
|
||||||
if (!ball.active || ball.lastHit !== 'O' || ball.vz <= 0) return null;
|
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;
|
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
|
// ...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
|
// 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 };
|
return { eta: t, sweet, band, gold: Math.abs(t - sweet) < band };
|
||||||
}
|
}
|
||||||
vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -2492,6 +2504,7 @@ export function update(dt) {
|
|||||||
}
|
}
|
||||||
ball.y = 0; ball.vy = -ball.vy * bY();
|
ball.y = 0; ball.vy = -ball.vy * bY();
|
||||||
ball.vx *= bXZ(); ball.vz *= bXZ();
|
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
|
if (Math.abs(ball.vy) > 1.0) dustPuff(ball.x, ball.z, 3, 0.7); // a real bounce kicks up the court
|
||||||
onBounce();
|
onBounce();
|
||||||
if (ball.active && Math.abs(ball.vy) < 0.8) { // rolled out — dead ball
|
if (ball.active && Math.abs(ball.vy) < 0.8) { // rolled out — dead ball
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ for (const rung of RUNGS) {
|
|||||||
'<3.5m(net)%': pct(h, x => x.fromNet < 3.5),
|
'<3.5m(net)%': pct(h, x => x.fromNet < 3.5),
|
||||||
'volley%': pct(h, x => !x.bounced),
|
'volley%': pct(h, x => !x.bounced),
|
||||||
'medTimeToHit_s': +med(h.filter(x => x.dt).map(x => x.dt)).toFixed(2),
|
'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']) {
|
for (const side of ['P', 'O']) {
|
||||||
|
|||||||
Reference in new issue
Block a user