Show the shot itself: one solver, one band, no meter
The reticle was a chevron trail on the ground and a draw meter across the bottom. The trail was a RAY — it pointed at the target but said nothing about the ball getting over the net — and the meter sat in the apron, which is exactly where your thumb is while you pull, so the one readout that mattered mid-gesture spent the whole gesture underneath your hand. Now the aim IS the flight. previewServe runs the same targeting and the same loft solve hitServe fires with (both extracted, neither duplicated), so the arc you see is bit-identical to the ball you get — verified: vx/vy/vz/g all match to zero at quality 1.0, where the aim error vanishes. A preview drawn from a second copy of the maths is worse than no preview; it lies with confidence. One object now carries all three readings, up-court where you are looking: the parabola is WHERE, the colour is WHETHER (cool blue still means it only staggers), and the band's thickness and opacity are HOW MUCH — which is the part a hue cannot carry on its own. Because the arc ends at the auto-picked target, a weak pull and a full one at the same shambler draw the same curve in different colours: "pulling further does not reach further" made visible. A shadow tracks the arc along the lawn. An arc hanging in the air is ambiguous about where it comes down, and that trail is what pins it. The apron is left to the gesture demo alone, drawn bigger now that it owns the space, with its chevrons lifted clear of the player's legs. Tennis and the duel still serve to the box, not at shamblers — checked both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
75936d3324
commit
3a91d87b08
2 files changed
+130
-90
No files matched your search
+64
-29
@@ -146,43 +146,78 @@ export function serveApexY() { return TOSS.y0 + TOSS.vy * TOSS.vy / (2 * TUNE.gr
|
||||
// sitting right behind the tape — that needs a near-vertical drop, so ~10 cuts (pace × 0.35).
|
||||
const NET_LOFT_TRIES = 12;
|
||||
|
||||
// ---- ONE SOLVER for the slingshot serve. The reticle draws the arc the ball will actually fly,
|
||||
// so the targeting and the ballistics below are shared with hitServe rather than reimplemented.
|
||||
// The moment a preview and a shot come from two copies of this maths they drift, and then the
|
||||
// preview is worse than nothing — it lies with confidence.
|
||||
|
||||
// dragServe snaps the ball to a FIXED contact before striking, which is the whole reason the arc
|
||||
// is previewable: the launch point is known while you are still pulling. Both live here.
|
||||
export const dragContact = () => ({ x: P.x + 0.12, y: 2.2, z: P.z });
|
||||
|
||||
// Where a splatter serve is aimed. `ex` is the aim error: hitServe passes the real random one,
|
||||
// the preview passes 0, so the arc you see is the shot you MEANT rather than the shot you got.
|
||||
// Returns null when this is not a free-aim/lane serve at all (the tennis serve keeps its box).
|
||||
function splatServeTarget(sv, aim, s, x0, z0, ex = 0) {
|
||||
if (G.mode === 'tennis' || G.interlude) return null; // the duel serves like tennis
|
||||
const ft = aim && aim.fx !== undefined ? flickTarget(sv, aim) : { zt: zombieTarget(aim) };
|
||||
if (ft.zt) {
|
||||
const T0 = Math.hypot(ft.zt.x - x0, ft.zt.z - z0) / s;
|
||||
return { tx: ft.zt.x + ex, tz: Math.min(23, ft.zt.z + ft.zt.v * T0), ft }; // lead the walk
|
||||
}
|
||||
if (ft.tx !== undefined) return { tx: ft.tx, tz: ft.tz, ft };
|
||||
return null;
|
||||
}
|
||||
|
||||
// The launch itself: topspin gravity, then trade pace for arc until the net is genuinely cleared.
|
||||
// A shambler just the other side of the tape is only reachable on a loft.
|
||||
function serveArc(x0, y0, z0, tx, tz, quality) {
|
||||
let s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed;
|
||||
const g = TUNE.gravity * (1 + quality * TUNE.serveSpin); // the harder you hit it, the harder it dips
|
||||
let v = solveShot(x0, y0, z0, tx, tz, s, g);
|
||||
for (let i = 0; i < NET_LOFT_TRIES; i++) {
|
||||
const tN = (COURT.netZ - z0) / v.vz;
|
||||
const yN = y0 + v.vy * tN - 0.5 * g * tN * tN;
|
||||
// The sim only samples the net plane once a frame, so a steeply DIVING ball can test below
|
||||
// the tape even when the closed form clears it. Charge margin for exactly that sampling
|
||||
// error (descent rate × the worst frame step) rather than a flat quality cushion — that
|
||||
// cushion is paid for in pace, and it was taxing precisely the shots meant to hurt.
|
||||
const margin = 0.06 + Math.abs(v.vy - g * tN) * 0.04; // 0.04 s = main.js's dt clamp
|
||||
if (tN <= 0 || yN >= COURT.netH + margin) break;
|
||||
s *= 0.9; v = solveShot(x0, y0, z0, tx, tz, s, g);
|
||||
}
|
||||
return { v, g };
|
||||
}
|
||||
|
||||
// What render draws while you pull: the whole shot, error-free, from the same two functions the
|
||||
// release will use. null when there is nothing to show yet.
|
||||
export function previewServe(aim) {
|
||||
if (!aim || aim.mag < TUNE.pullMin) return null;
|
||||
const q = pullQuality(aim.mag);
|
||||
const c = dragContact();
|
||||
const s = TUNE.serveSpeed + q * TUNE.serveQualitySpeed;
|
||||
const st = splatServeTarget(P, aim, s, c.x, c.z);
|
||||
if (!st) return null;
|
||||
const { v, g } = serveArc(c.x, c.y, c.z, st.tx, st.tz, q);
|
||||
return { x0: c.x, y0: c.y, z0: c.z, v, g, tx: st.tx, tz: st.tz, zt: st.ft.zt,
|
||||
q, f: pullFrac(aim.mag) };
|
||||
}
|
||||
|
||||
function hitServe(sv, quality, aim = null) {
|
||||
const toFar = sv.side === 'P';
|
||||
let s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed;
|
||||
const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed;
|
||||
// splatServeTarget + serveArc are shared with previewServe, so the arc drawn under your thumb
|
||||
// is the one the ball flies. Only the aim error is added here and left out of the preview.
|
||||
const st = splatServeTarget(sv, aim, s, ball.x, ball.z, (Math.random() * 2 - 1) * (1 - quality) * 0.8);
|
||||
let boxX, tzs;
|
||||
const ft = G.mode !== 'tennis' && !G.interlude // the duel serves like tennis: to the box, not free-aim
|
||||
? (aim && aim.fx !== undefined ? flickTarget(sv, aim) : { zt: zombieTarget(aim) }) : null;
|
||||
if (ft && ft.zt) { // magnet hit, or (tap) the most advanced zombie
|
||||
const zt = ft.zt;
|
||||
boxX = zt.x + (Math.random() * 2 - 1) * (1 - quality) * 0.8;
|
||||
const T0 = Math.hypot(zt.x - ball.x, zt.z - ball.z) / s;
|
||||
tzs = Math.min(23, zt.z + zt.v * T0);
|
||||
} else if (ft && ft.tx !== undefined) { // free-aim, no magnet: serve to the flicked spot
|
||||
boxX = ft.tx; tzs = ft.tz;
|
||||
} else {
|
||||
if (st) { boxX = st.tx; tzs = st.tz; }
|
||||
else {
|
||||
// target: diagonal service box. Serving from deuce side → target box is across.
|
||||
boxX = (deuceSide() ? -1 : 1) * (toFar ? 1 : -1) * 2.0 + (Math.random() * 2 - 1) * TUNE.aimError * 0.8 * (1 - quality);
|
||||
const boxZ = toFar ? COURT.svcFar + 1.6 : COURT.svcNear - 1.6;
|
||||
tzs = boxZ + (Math.random() * 2 - 1) * (1 - quality) * 1.5;
|
||||
}
|
||||
const g = TUNE.gravity * (1 + quality * TUNE.serveSpin); // the harder you hit it, the harder it dips
|
||||
// CLEAR THE NET. A shambler standing just the other side of the tape is only reachable on a
|
||||
// loft, so trade pace for arc until the ball actually gets over. doHit has done this since
|
||||
// forever; the serve never did, which is why every shot at a net-hugger died in the tape.
|
||||
// The trade is real: the loftier it goes the slower it arrives, and the walk-lead below was
|
||||
// computed for the original pace — a lob is a genuinely less reliable shot.
|
||||
let v = solveShot(ball.x, ball.y, ball.z, boxX, tzs, s, g);
|
||||
for (let i = 0; i < NET_LOFT_TRIES; i++) {
|
||||
const tN = (COURT.netZ - ball.z) / v.vz;
|
||||
const yN = ball.y + v.vy * tN - 0.5 * g * tN * tN;
|
||||
// The sim only samples the net plane once a frame, so a steeply DIVING ball can test below
|
||||
// the tape even when the closed form clears it. Charge margin for exactly that sampling
|
||||
// error (descent rate × the worst frame step) instead of doHit's flat quality cushion —
|
||||
// that cushion is paid for in pace, and it was taxing precisely the shots meant to hurt.
|
||||
const margin = 0.06 + Math.abs(v.vy - g * tN) * 0.04; // 0.04 s = main.js's dt clamp
|
||||
if (tN <= 0 || yN >= COURT.netH + margin) break;
|
||||
s *= 0.9; v = solveShot(ball.x, ball.y, ball.z, boxX, tzs, s, g);
|
||||
}
|
||||
const { v, g } = serveArc(ball.x, ball.y, ball.z, boxX, tzs, quality);
|
||||
ball.g = g;
|
||||
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
|
||||
ball.lastHit = sv.side; ball.bounces = 0;
|
||||
|
||||
Reference in new issue
Block a user