Make the down-flick the soft ball: a short one drops, a long one lobs
Step 3 of the rally rework, Oliver's pick: in rally modes the down-flick is soft, always, and its length is how far — a dying drop just over the net at one end, an attacking lob over a net man at the other. One gesture, one meaning; not the context-armed lob that was cut. Ballistics from the cut lob (pace derived from the apex); backspin lives on ball.spin and every bounce planner reads it, netplay sends it. Against the CPU it was returned ~100% until the real-tennis reasons went in: soft balls are read late (disguise), a net man reacts to a pass and backpedals from a lob, and a lobbed or dug-out reply is a weak block. Four new tests; v363. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f9b0dd2efb
commit
72760a9d1e
7 files changed
+215
-28
No files matched your search
+74
-22
@@ -33,6 +33,13 @@ const strikeKick = () => {
|
||||
if (p > 0 && !G.shake) G.shake = { t: 0.12, mag: p * 2.92 }; // mag*t/0.35 = the pixels it starts at
|
||||
};
|
||||
|
||||
// How much of its horizontal pace a ball keeps through a bounce: the court's own number, times the
|
||||
// shot's SPIN (ball.spin, 1 = none). Only the soft ball sets it — a drop carries backspin and dies.
|
||||
// Every planner that rolls a bounce forward reads this, or it predicts a ball that is not there.
|
||||
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)));
|
||||
|
||||
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;
|
||||
return { vx: dx / T, vz: dz / T, vy: (ty - y0 + 0.5 * g * T * T) / T };
|
||||
@@ -193,7 +200,7 @@ function doHit(ch, quality, aim) {
|
||||
ball.x = ch.x + ch.facing * 0.5;
|
||||
ball.z = ch.z + (toFar ? -0.3 : 0.3);
|
||||
const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality);
|
||||
let tx, tz, aimedLane = false, sway = 0;
|
||||
let tx, tz, aimedLane = false, sway = 0, soft = null;
|
||||
let s = (TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed) * leaguePace();
|
||||
const ft = G.mode !== 'tennis' && !G.interlude && ch.side === 'P' // the duel aims like tennis: lanes, not free-aim
|
||||
? (aim && aim.fx !== undefined ? flickTarget(ch, aim) : { zt: zombieTarget(aim) }) : null;
|
||||
@@ -246,11 +253,26 @@ function doHit(ch, quality, aim) {
|
||||
// THE COURT HAS ITS LENGTH: in a rally mode the ball goes DEEP, and bad timing costs DEPTH — a
|
||||
// mistimed ball sits up short, which is the ball a real opponent attacks — never length, so
|
||||
// depth adds no out. The down-flick keeps its old short ball (it becomes the soft ball next).
|
||||
const deep = rallyFeel() && aim.az <= 0;
|
||||
const tzBase = aim.az < 0 ? (deep ? TUNE.rallyDeep : SHOT_DEPTH.deep)
|
||||
: aim.az > 0 ? SHOT_DEPTH.short : deep ? TUNE.rallyMid : SHOT_DEPTH.mid;
|
||||
const dz = deep ? (1 - quality) * TUNE.shortK * (0.5 + Math.random()) : err() * 0.8;
|
||||
tz = deep ? (toFar ? tzBase + dz : COURT.L - tzBase - dz) : (toFar ? tzBase : COURT.L - tzBase) + dz;
|
||||
// THE SOFT BALL (step 3, Oliver's pick 2026-10-13): in a rally mode the DOWN-flick is soft, always,
|
||||
// and its LENGTH is how far — a short one is a drop just over the net, a long one a lob into the
|
||||
// back corner, the space between a continuum of floaters. One gesture, one meaning, whoever is
|
||||
// where: this is NOT the context-armed lob that was cut on 2026-08-22. Mistiming pulls it toward
|
||||
// softSitter — a drop that sits up, a lob that falls short: the easy ball either way.
|
||||
// `aim.soft` lets a caller with no swipe (the opponent, step 2) ask for one; keys take the lob.
|
||||
soft = rallyFeel() && aim.az > 0
|
||||
? (aim.soft ?? (aim.mag === undefined ? 1 : Math.min(1, Math.max(0, (aim.mag - TUNE.flickMin) / (TUNE.flickFull - TUNE.flickMin)))))
|
||||
: null;
|
||||
if (soft !== null) {
|
||||
const want = COURT.netZ - TUNE.dropNet + (TUNE.lobDeep - COURT.netZ + TUNE.dropNet) * soft; // from his baseline
|
||||
const d = want + (TUNE.softSitter - want) * (1 - quality) * TUNE.softErr;
|
||||
tz = toFar ? d : COURT.L - d;
|
||||
} else {
|
||||
const deep = rallyFeel() && aim.az <= 0;
|
||||
const tzBase = aim.az < 0 ? (deep ? TUNE.rallyDeep : SHOT_DEPTH.deep)
|
||||
: aim.az > 0 ? SHOT_DEPTH.short : deep ? TUNE.rallyMid : SHOT_DEPTH.mid;
|
||||
const dz = deep ? (1 - quality) * TUNE.shortK * (0.5 + Math.random()) : err() * 0.8;
|
||||
tz = deep ? (toFar ? tzBase + dz : COURT.L - tzBase - dz) : (toFar ? tzBase : COURT.L - tzBase) + dz;
|
||||
}
|
||||
// A FORWARD FLICK IS A SMASH. Up-screen is the far court, so flicking INTO the court asked for
|
||||
// depth and nothing else — the gesture had a direction with no consequence behind it. It buys
|
||||
// PACE now, scaled by timing like the width is. Note what it deliberately does not do: solveShot
|
||||
@@ -304,8 +326,18 @@ function doHit(ch, quality, aim) {
|
||||
G.placeMark = { x: tx, z: tz, t: 0.8, q: quality };
|
||||
}
|
||||
// topspin: quality shots dive under extra effective gravity, which is what lets real
|
||||
// pace arc over the net and still land in — the core tennis trade-off
|
||||
const g = TUNE.gravity * (1 + quality * TUNE.spinFactor);
|
||||
// pace arc over the net and still land in — the core tennis trade-off. The soft ball carries none.
|
||||
const g = soft !== null ? TUNE.gravity : TUNE.gravity * (1 + quality * TUNE.spinFactor);
|
||||
// ...and its PACE IS DERIVED FROM ITS HEIGHT (the cut lob's ballistics, 4f4534e): to peak at
|
||||
// `apex` it must leave at vy = sqrt(2g(apex - y)), which fixes the hang time, and the ground still
|
||||
// to cover fixes the speed — the same arc from the baseline and from the net. After the
|
||||
// opponent's pace dials on purpose: a floater is a floater from any racket.
|
||||
if (soft !== null) {
|
||||
const apex = TUNE.dropApex + (TUNE.lobApex - TUNE.dropApex) * soft;
|
||||
const vy0 = Math.sqrt(2 * TUNE.gravity * Math.max(0.5, apex - ball.y));
|
||||
const hang = (vy0 + Math.sqrt(vy0 * vy0 + 2 * TUNE.gravity * Math.max(0, ball.y - 0.08))) / TUNE.gravity;
|
||||
s = Math.hypot(tx - ball.x, tz - ball.z) / hang;
|
||||
}
|
||||
let v = solveShot(ball.x, ball.y, ball.z, tx, tz, s, g);
|
||||
// low contact → trade pace for loft until the net is cleared; good timing gets a
|
||||
// comfortable margin, sloppy timing keeps some tape risk. Same crossesNet guard as serveArc's,
|
||||
@@ -321,6 +353,9 @@ function doHit(ch, quality, aim) {
|
||||
}
|
||||
ball.g = g;
|
||||
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
|
||||
// a drop carries backspin and DIES after its bounce; it fades out across the soft range to a lob
|
||||
ball.spin = soft === null ? 1 : 1 - (1 - TUNE.dropSpin) * Math.max(0, 1 - soft * 2);
|
||||
ball.soft = soft !== null; // ...and it is DISGUISED: racketAI reads it late (TUNE.softRead)
|
||||
// ...and a short ball taken is an APPROACH: he follows it in (rallyHome reads this while it flies).
|
||||
// Never against a man already at the net — that ball is a PASS, and two volleyers is the old
|
||||
// reflex duel at a quarter of a second per shot. ball.from is the striker's distance from the net.
|
||||
@@ -542,7 +577,7 @@ export function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
|
||||
perfect && G.mode === 'splatter' && !G.interlude && G.weapon === 0 && !G.temp);
|
||||
ball.g = g;
|
||||
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
|
||||
ball.lastHit = sv.side; ball.bounces = 0; ball.rx = null; ball.approach = null; ball.from = Math.abs(sv.z - COURT.netZ);
|
||||
ball.lastHit = sv.side; ball.bounces = 0; ball.rx = null; ball.approach = null; ball.spin = 1; ball.soft = false; ball.from = Math.abs(sv.z - COURT.netZ);
|
||||
ball.pow = quality;
|
||||
ball.kills = 0; // fresh serve: multi-kill streak restarts
|
||||
ball.praise = null; // ...and any word a spent ball never got to pop dies with it
|
||||
@@ -1213,7 +1248,10 @@ const rzRawSpeed = rz => TUNE.racketSpeed * leaguePace()
|
||||
// wrong-footed: a body mid-lean is slow to turn — THE READ's one-ball cost, and it lives HERE
|
||||
// so the intercept planner and the reach tests believe it too (a surprised champion plans like
|
||||
// a body that genuinely cannot get there, which is the whole point)
|
||||
* (G.match && G.match.surprised ? 1 - TUNE.surpSlow * learnK(rz) : 1);
|
||||
* (G.match && G.match.surprised ? 1 - TUNE.surpSlow * learnK(rz) : 1)
|
||||
// ...and a net man who has been LOBBED turns and runs backwards: real legs do that slowly, and it is
|
||||
// what makes the lob the answer to the net (the planner believes it too)
|
||||
* (ball.rx && ball.rx.lob ? TUNE.lobTurn : 1);
|
||||
|
||||
// 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
|
||||
@@ -1240,8 +1278,8 @@ 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 * TUNE.bounceY) / g);
|
||||
const vx2 = ball.vx * TUNE.bounceXZ, vz2 = ball.vz * TUNE.bounceXZ;
|
||||
const t2 = Math.max(0.05, 2 * (-vyl * bY()) / g);
|
||||
const vx2 = ball.vx * bXZ(), vz2 = ball.vz * bXZ();
|
||||
const T = pred.t + t2;
|
||||
for (let i = 0; i <= 10; i++) {
|
||||
const t = T * i / 10;
|
||||
@@ -1394,12 +1432,15 @@ function bounceContact(short) {
|
||||
if (ball.bounces === 0) {
|
||||
const p = predictLanding(); if (!p) return null;
|
||||
x = p.x; z = p.z; y = 0;
|
||||
vy = -(vy - g * p.t) * TUNE.bounceY; vx *= TUNE.bounceXZ; vz *= TUNE.bounceXZ;
|
||||
vy = -(vy - g * p.t) * bY(); vx *= bXZ(); vz *= bXZ();
|
||||
}
|
||||
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 t = Math.max(0, (short && vy > 0 ? vy - r : vy + r) / g);
|
||||
return { x: x + vx * t, z: z + vz * t };
|
||||
const at = t => ({ x: x + vx * t, z: z + vz * t });
|
||||
const fall = at(Math.max(0, (vy + r) / g)), rise = at(Math.max(0, (vy - r) / g));
|
||||
// a ball that will only come down past rallyBehind (a deep lob's high bounce) is taken on the RISE
|
||||
// instead — waiting for it would mean standing in the stand, and above CONTACT.maxY it is unplayable
|
||||
return short && vy > 0 || fall.z < -TUNE.rallyBehind ? rise : fall;
|
||||
}
|
||||
|
||||
function intercept(side) {
|
||||
@@ -1415,7 +1456,7 @@ 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 * TUNE.bounceY; vx *= TUNE.bounceXZ; vz *= TUNE.bounceXZ; nb++; }
|
||||
if (y <= 0 && vy < 0) { y = 0; vy = -vy * bY(); vx *= bXZ(); vz *= bXZ(); nb++; }
|
||||
// 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.
|
||||
@@ -1544,8 +1585,13 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
|
||||
const qMin = G.diff ? G.diff.qMin : G.match && !G.net ? (ch.foe?.qMin ?? TUNE.aiQualityMin) : TUNE.aiQualityMin;
|
||||
if (isAI) q = Math.min(q, qMin + Math.random() * (Math.max(qCap, qMin) - qMin));
|
||||
// a volley struck BELOW THE TAPE can only be blocked up: soft, short, little width. This is what
|
||||
// the net costs — the passing shot dipped at your feet.
|
||||
if (rallyFeel() && ball.bounces === 0 && ball.y < COURT.netH) q = Math.min(q, TUNE.lowVolleyQ);
|
||||
// the net costs — the passing shot dipped at your feet. ...and a DROP dug out at the ankles after
|
||||
// its bounce comes back the same way: reaching it is not enough. Backspin balls only — any ball met
|
||||
// on its first frame in reach is often low, and capping those made every rally ball a sitter.
|
||||
if (rallyFeel() && (ball.bounces === 0 && ball.y < COURT.netH || ball.spin < 1 && ball.y < TUNE.scoopY)) q = Math.min(q, TUNE.lowVolleyQ);
|
||||
// ...and a LOBBED net man who ran it down comes back on his heels: a defensive ball, not a reply.
|
||||
// That is the lob's real reward — rarely the winner itself, nearly always the point.
|
||||
if (rallyFeel() && ball.rx && ball.rx.lob) q = Math.min(q, TUNE.lowVolleyQ);
|
||||
if (!isAI && G.mode === 'tennis') {
|
||||
G.swingSnaps = null; G.rallyNear = null;
|
||||
logSwing({ result: 'HIT', reason: `contact q=${Math.round(q * 100) / 100}`, d: Math.round(Math.hypot(dx, dz * 0.8) * 100) / 100, y: Math.round(ball.y * 100) / 100 });
|
||||
@@ -1599,8 +1645,14 @@ function racketAI(rz, dt) {
|
||||
const plan = G.match && incoming ? rxPlan(rz) : null;
|
||||
if (plan && plan.net && ball.vz < 0) {
|
||||
const tc = (rz.z - ball.z) / ball.vz, gg = ball.g || TUNE.gravity;
|
||||
if (tc > 0 && ball.y + ball.vy * tc - 0.5 * gg * tc * tc > CONTACT.maxY) plan.net = false;
|
||||
if (tc > 0 && ball.y + ball.vy * tc - 0.5 * gg * tc * tc > CONTACT.maxY) { plan.net = false; plan.lob = true; }
|
||||
}
|
||||
// ...and AT THE NET he needs a moment to read the pass: a volleyer reacts, he does not pre-know.
|
||||
// Without it a body with these legs covered every passing lane the instant the ball left.
|
||||
if (G.match) G.aiReactT += dt;
|
||||
// ...and a SOFT ball is read late from anywhere: until the strike it looks like any other swing,
|
||||
// which is the whole reason a drop or a lob wins points in the real game. Rallies are untouched.
|
||||
const reading = plan && (plan.net && G.aiReactT < TUNE.netReact || ball.soft && G.aiReactT < TUNE.softRead);
|
||||
const c = plan && !plan.net ? bounceContact(plan.short) : null;
|
||||
const netX = plan && plan.net ? ball.x + ball.vx * Math.max(0, (rz.z - ball.z) / ball.vz) : null;
|
||||
const tgtX = c ? c.x : netX ?? (pred && pred.x);
|
||||
@@ -1612,7 +1664,7 @@ function racketAI(rz, dt) {
|
||||
: pred ? (G.match ? pred.z : ball.z + (pred.z - ball.z) * read) : ball.z;
|
||||
const tz = Math.max(G.match ? -TUNE.rallyBehind : 1.5, Math.min(G.match ? COURT.netZ - 1.2 : COURT.svcFar, aimZ));
|
||||
const dx = aimX - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz);
|
||||
if (d > 0.15) {
|
||||
if (d > 0.15 && !reading) {
|
||||
// THE MATCH ESCALATES HERE, not in hp: with regicide gone the pool is pure scoreboard,
|
||||
// so a harder opponent is a FASTER one that flubs less. The BASE is boosted too — the duel's
|
||||
// 2.8 m/s lumber was tuned for one sudden-death point against a fresh player, and across a
|
||||
@@ -2307,8 +2359,8 @@ export function update(dt) {
|
||||
const back = dt * (1 - yOld / (yOld - ball.y)); // time spent below the ground this step
|
||||
ball.x -= ball.vx * back; ball.z -= ball.vz * back;
|
||||
}
|
||||
ball.y = 0; ball.vy = -ball.vy * TUNE.bounceY;
|
||||
ball.vx *= TUNE.bounceXZ; ball.vz *= TUNE.bounceXZ;
|
||||
ball.y = 0; ball.vy = -ball.vy * bY();
|
||||
ball.vx *= bXZ(); ball.vz *= bXZ();
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user