Let the player take the net and come back, and smash a lob he gets under

Oliver: "Steuerung ans Netz gehen", option (1), and "einen Lob vom Gegner
als Volley annehmen soll derb schmettern".

- A long up-flick (90 px or more) is the attack: the deep ball, and he
  follows it in. A long down-flick is the lob, and he goes back behind
  it. Short flicks keep the deep ball / the drop and leave him where he
  is. Length only, never context.
- The net costs him what it costs the CPU: he split-steps (netReact)
  before reading the pass, and a CPU facing a net man passes at the
  line. Without both, the attacker won 78-95% of his net points.
- The smash: a ball taken out of the air at 1.9-2.5 m is struck harder
  and to the open court, read late by the opponent, and pops "SMASH!".
  It wins ~80% of its points.
- Lobbed at the net, he decides once whether he can get under it. If he
  can, he smashes. If not, he runs it down and only blocks it back, even
  if he meets it in the air. Re-deciding every frame smashed every lob.

Measured at level 26: the base ladder is unchanged. Attacking pays
against pushers and touch players and costs against big hitters,
slicers and rushers. Three new tests; v374.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5.5 committed 2026-10-14 20:05:59 +02:00
1 parent 4406aeafdb
commit 599d5b0506
8 files changed
+201 -17

No files matched your search

+53 -12
View File
@@ -270,7 +270,13 @@ function doHit(ch, quality, aim) {
}
// a PUT-AWAY goes for the line, not the rally lane (the match's width dial below still bounds it,
// so the bottom of the ladder cannot paint lines even when it punishes)
if (aim && aim.kill) tx = (aim.ax || (P.x > 0 ? -1 : 1)) * TUNE.punishX + err() * 0.3;
if (aim && (aim.kill || aim.pass)) tx = (aim.ax || (P.x > 0 ? -1 : 1)) * TUNE.punishX + err() * 0.3;
// ...and the player's SMASH goes for the open court the same way: his side if he flicked one, else
// away from the body across the net. Down the middle, deep, it came straight back to the baseliner
if (ch.side === 'P' && aim && aim.smash) {
const foe = G.match ? zombies.find(z => z.variant === 'r') : O;
tx = (sway ? Math.sign(sway) : foe && foe.x > 0 ? -1 : 1) * TUNE.punishX + err() * 0.3;
}
// 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).
@@ -304,6 +310,8 @@ function doHit(ch, quality, aim) {
// still lands the ball on the same tz, so extra speed makes the shot flatter and earlier rather
// than longer. A smash you can hit out on a whim would just be the bug above wearing a hat.
if (ch.side === 'P' && aim.az < 0) s *= 1 + TUNE.smashBoost * quality;
// ...and an OVERHEAD taken out of the air is the real smash (trySwing marks it): hit it hard
if (ch.side === 'P' && aim.smash) s *= 1 + TUNE.smashPace * quality;
aimedLane = true;
}
tx = Math.max(-5, Math.min(5, tx));
@@ -385,9 +393,20 @@ function doHit(ch, quality, aim) {
// 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.
ball.approach = rallyFeel() && ball.rx && ball.rx.short && !(ball.from < TUNE.netFrom) ? ch.side : null;
// THE PLAYER'S OWN WAY IN AND OUT (2026-10-14, Oliver: "Steuerung ans Netz gehen"). A LONG up-flick
// is the attack: the deep ball, and he follows it in — even onto a man at the net, it is his call.
// A long DOWN-flick is the lob, and he goes back behind it. Gesture length only, never context: the
// 2026-08-22 lob taught that a gesture whose meaning depends on where the opponent stands is noise.
const longFlick = aim && (aim.mag === undefined || aim.mag >= TUNE.attackMin);
if (ch.side === 'P' && rallyFeel() && aim && aim.az < 0 && !aim.smash && longFlick) ball.approach = 'P';
ball.retreat = ch.side === 'P' && rallyFeel() && soft !== null && soft >= 1 ? 'P' : null;
ball.smash = !!(aim && aim.smash);
ball.from = Math.abs(ch.z - COURT.netZ);
ball.lastHit = ch.side; ball.bounces = 0; ball.rx = null; // a new shot: the receiver plans it afresh
ball.heels = aim && aim.kill ? TUNE.punishRead : 0; // a put-away: he is on his heels (see the auto-run)
// ...and a player AT THE NET (or rushing it) split-steps before he reads the pass, exactly as the CPU's
// net man does (netReact): with no read at all his 2.5 m volley reach made him unpassable
if (ch.side !== 'P' && rallyFeel() && (Math.abs(P.z - COURT.netZ) < TUNE.netFrom || P.rushing)) ball.heels = Math.max(ball.heels, TUNE.netReact);
// THE SWING LOG's other half: what came at you. A LOST "passed you" line is unreadable without it —
// was that ball reachable at all? And "never swung" is judged per incoming ball, not once a point.
if (ch.side !== 'P' && swingLog()) {
@@ -617,7 +636,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.heels = 0; ball.spin = 1; ball.soft = false;
ball.lastHit = sv.side; ball.bounces = 0; ball.rx = null; ball.heels = 0; ball.retreat = null; ball.smash = false; ball.spin = 1; ball.soft = false;
// a serve-and-volleyer follows its serve in (STYLES.sv) — the serve is its approach shot
ball.approach = sv.side === 'O' && styleOf(zombies.find(z => z.variant === 'r'))?.sv ? 'O' : null; ball.from = Math.abs(sv.z - COURT.netZ);
ball.pow = quality;
@@ -1465,7 +1484,7 @@ const letBounce = () => ball.bounces === 0 && ball.y < COURT.netH && ball.rx &&
// ...and where he recovers to is where he IS: at the net he holds the net, otherwise he goes back
// behind his baseline. Read live — recovery always moves him away from netFrom, so it cannot dither.
// The one exception is the APPROACH: having taken a short ball, he follows it in.
const rallyHome = ch => ({ x: 0, z: Math.abs(ch.z - COURT.netZ) < TUNE.netFrom || ball.approach === ch.side
const rallyHome = ch => ({ x: 0, z: (Math.abs(ch.z - COURT.netZ) < TUNE.netFrom && ball.retreat !== ch.side) || ball.approach === ch.side
? COURT.netZ + (ch.side === 'P' ? TUNE.netStand : -TUNE.netStand)
: ch.side === 'P' ? COURT.L + TUNE.rallyBack : -TUNE.rallyBack });
// Where a baseliner meets this ball: after the bounce, falling through rallyHit, or at the top of a
@@ -1502,7 +1521,7 @@ function intercept(side) {
// a ball that will clear a net man's racket is a LOB — he turns and runs it down (racketAI's rule)
if (plan && plan.net && (ball.z - ch.z) * vz < 0) {
const tc = (ch.z - z) / vz;
if (tc > 0 && y + vy * tc - 0.5 * g * tc * tc > CONTACT.maxY) { plan.net = false; plan.lob = true; }
if (tc > 0 && y + vy * tc - 0.5 * g * tc * tc > (side === 'P' && rallyFeel() ? TUNE.smashMaxY : CONTACT.maxY)) { plan.net = false; plan.lob = true; }
}
const back = plan && !plan.net; // a baseliner lets it bounce
// ...and only where his legs may go: the player is held on screen (xLimit), and since the dip ends at
@@ -1510,9 +1529,18 @@ function intercept(side) {
// the edge of the frame and watched it pass (his v369 log, 65.77 s and 84.26 s)
const xl = side === 'P' ? xLimit() : 99;
let fallback = null, deepest = null, anyHit = null, rise = null, nb = ball.bounces;
// ...but the PLAYER lobbed at the net first looks for the overhead: the first point the lob comes down
// into his smash band (smashMaxY) that his backpedal reaches in time. A short lob is smashed; one he
// cannot get under is run down after its bounce, as before, and his reply is capped.
// DECIDED ONCE, when the lob is read (plan.over, latched like the plan itself): re-asked every frame, his
// backpedal toward the run-down brought the overhead into reach on the way, and every lob was smashed
if (side === 'P' && plan && plan.over) return plan.over;
const smashing = side === 'P' && plan && plan.lob && plan.over === undefined;
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++; g = gAfter(g); }
if (smashing && nb === 0 && vy < 0 && y <= TUNE.smashMaxY - 0.2 && y >= TUNE.smashMinY && Math.abs(x) <= xl
&& Math.hypot(x - ch.x, z - ch.z) / (spd * TUNE.smashRun) <= i * dt) return (plan.over = { x, z });
// 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.
@@ -1542,6 +1570,7 @@ function intercept(side) {
deepest = pt; // last resort: run at the deepest hittable point
}
}
if (smashing) plan.over = null; // no overhead in reach: he runs it down
const to = rise || fallback || deepest || anyHit || home; // zone points first: meeting deep is still better
// a ball no point of which he can reach still gets a run — to the nearest spot he is allowed to stand
return Math.abs(to.x) > xl ? { x: Math.sign(to.x) * xl, z: to.z } : to;
@@ -1566,7 +1595,7 @@ export function ringCue() {
for (let t = 0; t < 1.6; t += dt) {
const volley = b === 0;
const reach = TUNE.reach * (volley ? VOLLEY.reach : 1) * (rallyFeel() ? TUNE.matchReach : 1);
const legal = y <= CONTACT.maxY && (y >= CONTACT.minY || vy > 0)
const legal = y <= (volley && rallyFeel() ? TUNE.smashMaxY : CONTACT.maxY) && (y >= CONTACT.minY || vy > 0)
&& !(volley && y < COURT.netH && ball.rx && !ball.rx.net);
const k = togo > 0.01 ? Math.min(1, sp * t / togo) : 0;
const xl = xLimit(), px = Math.max(-xl, Math.min(xl, P.x + (tg ? tg.x - P.x : 0) * k)), pz = P.z + (tg ? tg.z - P.z : 0) * k;
@@ -1693,7 +1722,12 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
const own = G.mode === 'splatter' && !G.interlude && ch.side === 'P';
if (ball.lastHit === ch.side && !own) return;
const dx = ball.x - ch.x, dz = ball.z - ch.z;
if (Math.hypot(dx, dz * 0.8) > reach || ball.y > CONTACT.maxY) return;
// THE SMASH: the player's overhead reaches higher than a volley, out of the air, in the rally modes
const overhead = !isAI && volley && rallyFeel();
if (Math.hypot(dx, dz * 0.8) > reach || ball.y > (overhead ? TUNE.smashMaxY : CONTACT.maxY)) return;
// ...unless he only reached it TURNING AND RUNNING (a lob whose overhead was out of reach when he read
// it): that one is met on the run, a defensive block like every run-down lob, however high
const smash = overhead && ball.y >= TUNE.smashMinY && !(ball.rx && ball.rx.lob && !ball.rx.over);
if (ball.y < CONTACT.minY && ball.vy <= 0) return; // a DROPPING ball below the ankles is past you — but a rising one is a half-volley, always scoopable
// planned from the back, the swing WAITS for the bounce instead of a weak pick-up — but only while
// the bounce will still land inside this window. If it will not, this is the last chance and the
@@ -1739,7 +1773,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
? { ax: smart ? (P.x > 0.6 ? -1 : P.x < -0.6 ? 1 : (Math.random() < 0.5 ? -1 : 1))
: [ -1, 0, 1 ][Math.floor(Math.random() * 3)],
az: Math.random() < 0.25 ? -1 : 0 }
: flick || { ax: axisX(), az: axisZ() * (ch.side === 'P' ? 1 : -1) };
: flick ? { ...flick } : { ax: axisX(), az: axisZ() * (ch.side === 'P' ? 1 : -1) }; // a copy: the shot marks it (smash)
// THE STYLES (step 2): what this body DOES with the ball. A lob when you are at the net, a drop
// when you hang back and it is inside its own court, a moonball as a habit — otherwise the rally
// ball it always hit, carrying its spin. One roll, so the shares are exactly the table's.
@@ -1748,6 +1782,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
const atNet = Math.abs(P.z - COURT.netZ) < TUNE.netFrom, r = Math.random();
const inside = Math.abs(ch.z - COURT.netZ) < COURT.netZ - 1.5;
if (atNet && r < (st.lob || 0)) { aim.az = 1; aim.soft = 1; }
else if (atNet) { aim.ax = P.x > 0 ? -1 : 1; aim.pass = true; aim.spin = st.spin; } // the PASS: at the line, away from him
else if (P.z > COURT.L - 0.3 && inside && r < (st.drop || 0)) { aim.az = 1; aim.soft = 0; }
else if (!atNet && r < (st.loft || 0)) { aim.az = 1; aim.soft = 0.85; }
else aim.spin = st.spin;
@@ -1767,8 +1802,9 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
if (rallyFeel() && (ball.bounces === 0 && ball.y < COURT.netH || ball.soft && 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 (kill && q >= TUNE.punishQ) aim.kill = true; // a put-away only if no cap took it down (a low volley is a block)
if (rallyFeel() && ball.rx && ball.rx.lob && !smash) q = Math.min(q, TUNE.lowVolleyQ); // ...unless he got UNDER it
if (kill && q >= TUNE.punishQ) aim.kill = true;
if (smash) { aim.smash = true; praisePop('SMASH!', ball.x, ball.y, ball.z); } // a put-away only if no cap took it down (a low volley is a block)
if (!isAI && swingLog()) {
G.swingSnaps = null; G.rallyNear = null;
const kind = aim.az > 0 && rallyFeel() ? (aim.mag === undefined || aim.mag >= TUNE.dropMax ? ' lob' : ' drop')
@@ -1779,7 +1815,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
// ...and WHICH WAY the timing missed: contact before the window's centre means the ball was
// already there — the swing came LATE; after it, the swing came EARLY
const off = Math.round((t - center) * 1000), timing = q0 >= 0.95 ? '' : off < 0 ? ` · late ${-off}ms` : ` · early ${off}ms`;
logSwing({ result: 'HIT', reason: `q=${r2(q)}${kind}${lane}${volley ? ' volley' : ''}${timing} · ${P.flick ? 'flick' : 'tap'} ${P.tap || 0}ms${cap}`,
logSwing({ result: 'HIT', reason: `q=${r2(q)}${smash ? ' SMASH' : kind}${lane}${volley && !smash ? ' volley' : ''}${timing} · ${P.flick ? 'flick' : 'tap'} ${P.tap || 0}ms${cap}`,
d: r2(Math.hypot(dx, dz * 0.8)), y: r2(ball.y) });
}
doHit(ch, q, aim);
@@ -1840,7 +1876,8 @@ function racketAI(rz, dt) {
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 reading = plan && (plan.net && G.aiReactT < TUNE.netReact || ball.soft && G.aiReactT < TUNE.softRead
|| ball.smash && G.aiReactT < TUNE.smashRead);
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);
@@ -2301,7 +2338,11 @@ export function update(dt) {
const chasing = ball.lastHit === 'O' || (G.mode === 'splatter' && !G.interlude && ball.vz > 0);
// no `slow` here: tapping must not freeze your legs, or anticipating a shot loses it
// ...and lobbed at the net he backpedals, exactly as slowly as the CPU does (TUNE.lobTurn)
let rate = TUNE.playerSpeed * aK * dt * (chasing ? 1 : 0.55) * (chasing && ball.rx && ball.rx.lob ? TUNE.lobTurn : 1);
// HIS OWN WAY IN AND OUT runs at full pace (the attack's follow-in, the lob's retreat), and while
// his attack flies he is RUSHING: the reply is planned as a net man's from wherever he has got to
if (ball.lastHit === 'P') P.rushing = ball.approach === 'P' && !G.wasServe;
const ownRun = !chasing && rallyFeel() && ball.lastHit === 'P' && (ball.approach === 'P' || ball.retreat === 'P');
let rate = TUNE.playerSpeed * aK * dt * (chasing || ownRun ? 1 : 0.55) * (chasing && ball.rx && ball.rx.lob ? TUNE.lobTurn : 1);
// ON HIS HEELS: a put-away of his own sitter is read late (step 4 (b)). Without this the auto-run
// started on the strike's frame with a perfect read and covered the whole court — no put-away ever
// won a point, and the CPU still had no way to take one