diff --git a/PROJECT.md b/PROJECT.md index 5879839..7e1d3a2 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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. diff --git a/index.html b/index.html index 433e52a..796e5c1 100644 --- a/index.html +++ b/index.html @@ -1140,6 +1140,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/config.js b/src/config.js index 7026e90..3f9d135 100644 --- a/src/config.js +++ b/src/config.js @@ -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. diff --git a/src/gameplay.js b/src/gameplay.js index 729c04e..3c2dacd 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -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 diff --git a/tools/rally-sim.mjs b/tools/rally-sim.mjs index 54f1729..cd218eb 100644 --- a/tools/rally-sim.mjs +++ b/tools/rally-sim.mjs @@ -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']) {