The lob is out: a gesture the opponent's position redefines is unpredictable

Oliver, on the build: "all good but the lob. please remove it - this is
totally unpredictable."

Reverted in full — oppAtNet, farBody, TUNE.lobArm, TUNE.lobApex,
SHOT_DEPTH.lob, the branch in doHit and its two tests. The forward flick is
the smash it always was. Everything else from that commit stays: the pace
feedback (streak, crack, camera kick) and the death match's blood alignment.

The reason is worth more than the code, and PROJECT.md carries it as a rule:
a gesture whose MEANING depends on where the opponent is standing is
unpredictable however well the shot is tuned. It is the same objection
input.js already states about the slingshot — the direction you moved is the
thing you asked for, no invisible modes — and the written comparison let
option (c) past it on the argument that context cannot mis-fire. It can: you
do not track where the far body is standing while a ball is coming at you, so
one thumb movement produced two shots for reasons you were not watching. The
LOB word at contact explained the surprise; it did not prevent it.

If it comes back it needs a gesture of its own. The ballistics are solved and
are in the history at 4f4534e.

111 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-22 13:13:56 +02:00
1 parent 4f4534e272
commit a42dc6fc81
5 files changed
+41 -146

No files matched your search

+4 -35
View File
@@ -157,15 +157,6 @@ export const swayOf = aim => aim.mag === undefined ? aim.ax
// as flat rather than resetting the lesson it just failed to use.
export const flatCount = (streak, aim) => (aim && Math.abs(swayOf(aim)) > 0.15 ? 0 : streak + 1);
// WHERE THE MAN ACROSS THE NET ACTUALLY IS — and it is not always O. In the duel and the match the
// opponent is the BODY: O is the invisible tennis body the serve machinery is driven off, and it
// only moves between points, so asking O where the opponent is would answer "on his mark" all
// rally. In classic and in netplay (both kinds) O is the opponent, drawn and driven.
const farBody = () => (!G.net && zombies.find(z => z.variant === 'r')) || O;
// HAS HE COME IN? The one question the lob is armed on — see TUNE.lobArm for why 7 m and not taste.
// Exported for the suite and for anything that ever wants to show the player that the shot is live.
export const oppAtNet = () => COURT.netZ - farBody().z < TUNE.lobArm;
function doHit(ch, quality, aim) {
// aim: {ax: -1|0|1, az: -1|0|1} from held keys / flick at contact (az -1 = deep, 1 = short)
const toFar = ch.side === 'P';
@@ -180,7 +171,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, lob = false;
let tx, tz, aimedLane = false, sway = 0;
let s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed;
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;
@@ -230,30 +221,14 @@ function doHit(ch, quality, aim) {
const bound = Math.max(-TUNE.flickWidth, Math.min(TUNE.flickWidth, ch.x));
tx = sway < 0 ? Math.min(tx, bound) : Math.max(tx, bound);
}
// THE LOB IS THE SAME FLICK, READ AGAINST WHERE HE IS STANDING (Oliver 2026-08-22, "lupfen").
// Forward flick = "put it deep behind him", and when he has come to the net the trajectory that
// does that is over his head, not through him. No new gesture, nothing to learn, and it cannot
// be mis-fired — with him on his baseline the same flick is the smash it has always been.
lob = ch.side === 'P' && aim.az < 0 && oppAtNet();
const tzBase = lob ? SHOT_DEPTH.lob
: aim.az < 0 ? SHOT_DEPTH.deep : aim.az > 0 ? SHOT_DEPTH.short : SHOT_DEPTH.mid;
const tzBase = aim.az < 0 ? SHOT_DEPTH.deep : aim.az > 0 ? SHOT_DEPTH.short : SHOT_DEPTH.mid;
tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8;
// 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
// 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 && !lob) s *= 1 + TUNE.smashBoost * quality;
// PACE DERIVED FROM THE HEIGHT, not chosen: for the lob to peak at lobApex it must leave at
// vy = sqrt(2g(apex - y)), which fixes the hang time, and the hang time over the ground still
// to cover fixes the speed. Same arc from the baseline and from mid-court — a fixed speed
// would be a lob from one and a flat drive from the other. Timing still prices it: `tz` above
// already carries the same scatter every aimed ball does.
if (lob) {
const vy0 = Math.sqrt(2 * TUNE.gravity * Math.max(0.5, TUNE.lobApex - 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;
}
if (ch.side === 'P' && aim.az < 0) s *= 1 + TUNE.smashBoost * quality;
aimedLane = true;
}
tx = Math.max(-5, Math.min(5, tx));
@@ -287,9 +262,7 @@ function doHit(ch, quality, aim) {
}
// 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
// ...and a lob carries NO topspin: the dive multiplier is what makes a hard flat ball land in, and
// on a shot whose whole point is hang time it would just pull the arc down into the net.
const g = lob ? TUNE.gravity : TUNE.gravity * (1 + quality * TUNE.spinFactor);
const g = TUNE.gravity * (1 + quality * TUNE.spinFactor);
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,
@@ -328,10 +301,6 @@ function doHit(ch, quality, aim) {
const w = praiseWord(); // a rally hit IS the impact — the word pops right on the contact
if (w) popText(ball.x, ball.y, ball.z, w);
}
// ...and the lob NAMES ITSELF. (c) buys "no gesture to learn" by letting context change what the
// flick means, and the price of that is that nothing on screen says which shot you just asked
// for. One word at the contact, in the praise words' own slot, is what pays it back.
if (lob) popText(ball.x, ball.y + 0.3, ball.z, 'LOB');
ch.pose = 'swing'; ch.poseT = 0;
if (G.net && ch.side === 'P') netSendShot(); // netplay: the shot IS the sync unit
}