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:
wuwiandClaude Fable 5 committed 2026-08-13 17:23:07 +02:00
1 parent c084d53497
commit de77ccfea5
6 files changed
+112 -20

No files matched your search

+51 -14
View File
@@ -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; }