The serve gets stakes: tight apex window, depth rides quality, the strike flick aims
Three mechanical causes of 'no serve agency', measured then fixed: - the 0.12 m perfect snap was ~0.3 s of hang time (now TUNE.apexSnap 0.05, and the gold band reads the same knob); - the fixed mid-box target depth capped every good serve at ~36 m/s in the loft loop — player serve depth now rides quality, flush aims 0.3 m inside the service line (ladder: 49 / 52 hard / 36 mistimed / 30 bad); - the strike flick was splatter-only — in tennis and the duel it now sways placement (horizontal) and buys pace (vertical, serveHard + serveHardSpin, the extra dive IS the clearance), paid for in scatter against the real fault machinery. Tap stays the safe second serve. Phone verdict pending — see the PROJECT.md session log. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c084d53497
commit
de77ccfea5
6 files changed
+112
-20
No files matched your search
+51
-14
@@ -283,9 +283,12 @@ function splatServeTarget(sv, aim, s, x0, z0, ex = 0) {
|
||||
|
||||
// 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, ty = 0.08) {
|
||||
let s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed;
|
||||
const g = TUNE.gravity * (1 + quality * TUNE.serveSpin); // the harder you hit it, the harder it dips
|
||||
function serveArc(x0, y0, z0, tx, tz, quality, ty = 0.08, hard = 0) { // hard: the aimed strike's 0..1
|
||||
let s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed + hard * TUNE.serveHard;
|
||||
// the harder you hit it, the harder it dips — and the HARD serve dives harder still
|
||||
// (serveHardSpin), which is what buys its net clearance: MEASURED with the replica solver,
|
||||
// +9 m/s with the old dip was cut BELOW the plain flush by the loft loop (47.2 vs 49.3).
|
||||
const g = TUNE.gravity * (1 + quality * TUNE.serveSpin + hard * TUNE.serveHardSpin);
|
||||
let v = solveShot(x0, y0, z0, tx, tz, s, g, ty);
|
||||
// THERE IS ONLY A NET TO CLEAR IF THE SHOT CROSSES IT. A shambler on your own side sits short of
|
||||
// the tape, so the ball never reaches that plane — but `tN` is still positive (the net is ahead
|
||||
@@ -359,7 +362,15 @@ export function sliceCarom(tgt, speed) {
|
||||
// so can never fail the old `quality > 0.999` test once you have pulled far enough.
|
||||
function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
|
||||
const toFar = sv.side === 'P';
|
||||
const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed;
|
||||
// {sx, hard} is the TENNIS strike aim (see keyPressed's toss branch): sx sways the target in
|
||||
// SCREEN direction — flick right, serve right — and hard buys pace and depth, paid for in
|
||||
// scatter. Nothing clamps the swayed target back inside the lines: aiming for the sideline
|
||||
// with a mistimed toss is how a real first serve goes long, and faults already have their
|
||||
// machinery (serveNum, the line call). A plain tap keeps the old safe centre-box serve.
|
||||
const sx = aim && aim.sx !== undefined ? aim.sx : 0;
|
||||
const hard = (aim && aim.hard) || 0;
|
||||
const pace = hard * TUNE.serveHard;
|
||||
const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed + pace;
|
||||
// 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);
|
||||
@@ -367,11 +378,21 @@ function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
|
||||
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 scatter = (0.8 + hard * TUNE.serveHardErr) * (1 - quality);
|
||||
boxX = (deuceSide() ? -1 : 1) * (toFar ? 1 : -1) * 2.0 + sx * TUNE.serveAimX
|
||||
+ (Math.random() * 2 - 1) * TUNE.aimError * scatter;
|
||||
// DEPTH RIDES QUALITY (player serves only): a flush strike aims near the service line, the
|
||||
// ONLY depth where flat pace survives the net-clearance loop — MEASURED with the replica
|
||||
// solver: at the old fixed mid-box depth every serve above q≈0.7 was cut to the same
|
||||
// ~36 m/s, which is the mechanical root of "hitting below the golden mark does not
|
||||
// necessarily make a worse serve". The ladder now reads flush 49 / flush+hard 52 /
|
||||
// mistimed 36 / bad 30. The 0.3 m floor inside the line is what scatter faults against.
|
||||
// The opponent keeps its fixed tuned depth — its ladder is dialled elsewhere.
|
||||
const boxZ = toFar ? COURT.svcFar + Math.max(0.3, 1.6 - quality * 1.35 - hard * 0.5)
|
||||
: COURT.svcNear - 1.6;
|
||||
tzs = boxZ + (Math.random() * 2 - 1) * scatter * 1.5;
|
||||
}
|
||||
const { v, g } = serveArc(ball.x, ball.y, ball.z, boxX, tzs, quality, st ? st.ty : 0.08);
|
||||
const { v, g } = serveArc(ball.x, ball.y, ball.z, boxX, tzs, quality, st ? st.ty : 0.08, hard);
|
||||
// THE BALL ERASES ITS OWN AIM. Cutting the band at the instant of release made a fast rally
|
||||
// strobe — band, gone, band, gone. Instead the arc stays and the ball eats it from behind as it
|
||||
// flies, so the aim resolves into the shot rather than blinking out of existence. This is the
|
||||
@@ -1253,16 +1274,32 @@ export function keyPressed(code) {
|
||||
tossBall(P); return; // plain tap: the two-stage toss ritual (tennis, and the duel)
|
||||
}
|
||||
if (G.phase === 'toss' && score.server === 'P') {
|
||||
// serve timing: distance from apex = quality — a real serve, not a free tap
|
||||
// serve timing: distance from apex = quality — a real serve, not a free tap. The snap window
|
||||
// and falloff are TUNE knobs now (apexSnap/apexFall) and deliberately tight: the old 0.12 m
|
||||
// snap held PERFECT for ~0.3 s of hang time, which is why the gold mark felt free.
|
||||
const dy = Math.abs(ball.y - serveApexY());
|
||||
if (ball.y < 2.0) return; // too low, let it drop (re-toss)
|
||||
let q = Math.max(0.05, 1 - dy / 0.9);
|
||||
if (dy < 0.12) q = 1;
|
||||
let q = Math.max(0.05, 1 - dy / TUNE.apexFall);
|
||||
if (dy < TUNE.apexSnap) q = 1;
|
||||
G.wasServe = true;
|
||||
// a flick at the apex (or held arrows) makes this a DELIBERATE serve: aimed + consumes a special
|
||||
const flick = G.mode === 'splatter' && !G.interlude ? takeFlickAim() : null;
|
||||
const flick = takeFlickAim();
|
||||
if (G.mode === 'splatter' && !G.interlude) {
|
||||
// splatter: a flick at the apex is the aimed free-aim serve, exactly as before
|
||||
const kb = { ax: axisX(), az: axisZ() };
|
||||
hitServe(P, q, flick || (kb.ax || kb.az ? kb : null));
|
||||
return;
|
||||
}
|
||||
// TENNIS AND THE DUEL: the strike gesture IS the aim (Oliver: "the flicking does not work
|
||||
// here - also i see no option to serve harder"). Flick as you strike: the HORIZONTAL
|
||||
// component sways the serve toward the T or the sideline, the UPWARD component is pace —
|
||||
// swing through the ball to hit it harder. Both scale with the flick's length (fx/fy are
|
||||
// unit components; mag carries the length). Keyboard: left/right sway, up = the hard one.
|
||||
// A plain tap stays the safe centre-box serve — that is the second-serve decision.
|
||||
const kb = { ax: axisX(), az: axisZ() };
|
||||
hitServe(P, q, flick || (kb.ax || kb.az ? kb : null));
|
||||
const k = flick ? Math.max(0, Math.min(1, (flick.mag - 24) / 130)) : 0;
|
||||
const sway = flick ? flick.fx * k : kb.ax * 0.75;
|
||||
const hard = flick ? Math.max(0, -flick.fy) * k : (kb.az < 0 ? 0.8 : 0);
|
||||
hitServe(P, q, sway || hard ? { sx: sway, hard } : null);
|
||||
return;
|
||||
}
|
||||
if (G.phase === 'rally' && P.swingT < 0) { P.swingT = 0; P.pose = 'swing'; P.poseT = 0; G.swung = true; }
|
||||
|
||||
Reference in new issue
Block a user