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
+22
@@ -434,6 +434,28 @@ vetoed (*"the camera movement is distracting"*) and its knobs sit at 0 — see t
|
||||
(~0.35ms/frame desktop, settled key at both ends) — if the phone stutters in rallies, zero these
|
||||
two before touching anything else.
|
||||
|
||||
## Session log: the serve gets stakes (2026-08-13, same day)
|
||||
|
||||
Oliver: *"the golden mark is just very easy to hit and hitting below it does not necessarily make a
|
||||
worse serve... the flicking does not work here — also I see no option to serve harder."* Three
|
||||
mechanical causes found, all fixed; PHONE VERDICT PENDING:
|
||||
|
||||
1. **The perfect window was ~0.3 s of hang time.** The 0.12 m snap around the apex, at toss
|
||||
gravity, held PERFECT for most of the ball's float. Now `TUNE.apexSnap` 0.05 / `apexFall` 0.55,
|
||||
and the gold band on the apex guide reads the same knob, so the picture tightened with it.
|
||||
2. **The pace ceiling was the TARGET DEPTH, not the timing.** Measured with a replica of
|
||||
serveArc's loft loop: at the old fixed mid-box depth, every serve above q≈0.7 was cut to the
|
||||
same ~36 m/s — quality could not show. Player serve depth now rides quality (flush aims 0.3 m
|
||||
inside the service line, the only depth where flat pace survives). Ladder: flush 49 /
|
||||
flush+hard 52 / mistimed 36 / bad 30 m/s. Opponent depth untouched.
|
||||
3. **The strike flick was splatter-only.** In tennis and the duel the strike gesture now aims:
|
||||
horizontal component sways the target toward T or sideline (`serveAimX`), upward component is
|
||||
pace (`serveHard`, with `serveHardSpin` — the extra dive is what buys the clearance; without it
|
||||
the loft loop cut the hard serve BELOW the plain one). Pace and sway are paid for in scatter
|
||||
(`serveHardErr`) against the real fault machinery — tap stays the safe second serve. Keyboard:
|
||||
arrows sway, up is the hard one. Verified in-pane with synthetic flicks: 49.4 / 52.5 / 51.0
|
||||
swayed +1.1 m, all legal at flush timing.
|
||||
|
||||
## Session log: nothing arrives unannounced (2026-08-11, same day)
|
||||
|
||||
Oliver's retest: *"the horde is not announced anymore"* and *"bosses are just there now"*. Both were
|
||||
|
||||
+1
-1
@@ -431,6 +431,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="src/main.js?v=212"></script>
|
||||
<script type="module" src="src/main.js?v=213"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -28,6 +28,19 @@ export const TUNE = {
|
||||
// which is the "harder pull, faster ball" relationship, and it is in serveQualitySpeed's share
|
||||
// of the pair. (baseShotSpeed is the rally volley — shared with tennis and the CPU.)
|
||||
serveSpeed: 22.8, serveQualitySpeed: 26.4,
|
||||
// THE TOSS STRIKE HAS REAL STAKES NOW (Oliver: "the golden mark is very easy to hit and hitting
|
||||
// below it does not necessarily make a worse serve... no option to serve harder").
|
||||
apexSnap: 0.05, // m of |ball - apex| that still counts flush (was a hardcoded 0.12 — at toss
|
||||
// gravity that was ~0.3 s of PERFECT, which is why the mark felt free)
|
||||
apexFall: 0.55, // m of miss at which quality hits the floor (was 0.9) — mistimes cost pace
|
||||
// ...and the strike gesture AIMS it: a flick as you hit sways the target and buys pace.
|
||||
serveAimX: 1.8, // m of sway a full flick puts on the target — centre box to the T or the line
|
||||
serveHard: 9, // m/s of extra pace at full flick strength — the harder first serve
|
||||
serveHardSpin: 0.45, // extra dip the hard serve carries. NOT free style: without it the loft
|
||||
// loop cut the +9 m/s BELOW a plain flush serve (measured 47.2 vs 49.3) —
|
||||
// the dive is what buys the pace its net clearance
|
||||
serveHardErr: 1.6,// how much a hard serve multiplies the timing scatter: pace is paid for in
|
||||
// faults, which is what makes tap = safe second serve a real decision
|
||||
serveSpin: 1.3, // extra gravity a full-power serve carries (was a fixed 0.35). Without it the
|
||||
// net-clearance loop ate the whole smash: a flat fast ball can't get over the
|
||||
// tape AND come down on a target, so it got throttled back to walking pace.
|
||||
|
||||
+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; }
|
||||
|
||||
+4
-4
@@ -865,9 +865,9 @@ function drawApproachRing() {
|
||||
}
|
||||
|
||||
// THE APEX BAND (the serve tap): a thin guide beside the tossed ball with a gold band spanning the
|
||||
// perfect window (serveApexY +- the same 0.12 m keyPressed scores as perfect) and a wider white
|
||||
// zone for the playable one. A tick rides the live ball height. The ghost hand MIMES this moment;
|
||||
// the band makes it a target you can see coming.
|
||||
// perfect window (serveApexY ± TUNE.apexSnap — the SAME number keyPressed scores as perfect, so
|
||||
// tightening the mechanic tightens the picture) and a wider white zone for the playable one. A
|
||||
// tick rides the live ball height. The band makes the strike a target you can see coming.
|
||||
function drawApexBand() {
|
||||
if (!(G.match || G.mode === 'tennis') || G.phase !== 'toss' || score.server !== 'P') return;
|
||||
const gx = ball.x + 0.55; // beside the toss, out of the ball's way
|
||||
@@ -878,7 +878,7 @@ function drawApexBand() {
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.35)'; // the guide
|
||||
ctx.beginPath(); ctx.moveTo(x, yTop.y); ctx.lineTo(x, yBot.y); ctx.stroke();
|
||||
const bTop = proj(gx, apex + 0.12, ball.z).y, bBot = proj(gx, apex - 0.12, ball.z).y;
|
||||
const bTop = proj(gx, apex + TUNE.apexSnap, ball.z).y, bBot = proj(gx, apex - TUNE.apexSnap, ball.z).y;
|
||||
ctx.fillStyle = hexA(UI.warm, 0.8); // the perfect window, gold
|
||||
ctx.fillRect(x - 3, Math.round(bTop), 6, Math.max(2, Math.round(bBot - bTop)));
|
||||
const tick = proj(gx, ball.y, ball.z).y; // where the ball is NOW
|
||||
|
||||
@@ -83,7 +83,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' });
|
||||
|
||||
// ---- game modules (real code) ----------------------------------------------
|
||||
const { G, P, O, score, ball, setupPoint } = await import('./src/state.js');
|
||||
const { update, forceZombies, splatPoint, restartRun, flickTarget, keyPressed, classicGame } = await import('./src/gameplay.js');
|
||||
const { update, forceZombies, splatPoint, restartRun, flickTarget, keyPressed, classicGame, serveApexY } = await import('./src/gameplay.js');
|
||||
const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN } = await import('./src/beats.js');
|
||||
// the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a
|
||||
// player would, so the tests step a live court
|
||||
@@ -938,6 +938,26 @@ test('classic tennis: a game is not a set, and six with a two-game lead is', ()
|
||||
assert.equal(G.done, null, 'one set is not a match');
|
||||
});
|
||||
|
||||
test('the toss strike has stakes: flush is fast, a miss is slow, the old free window is gone', () => {
|
||||
// the serve-agency pass: apexSnap/apexFall are TUNE knobs and deliberately tight, so timing
|
||||
// must show up in the ball's actual pace (the fault economy then prices the aimed serve)
|
||||
G.mode = 'tennis'; G.classic = true; G.match = null; G.interlude = false; G.done = null;
|
||||
const strike = dy => {
|
||||
G.phase = 'pretoss'; ball.active = false; score.server = 'P'; P.pose = 'idle';
|
||||
keyPressed('Space'); // toss
|
||||
assert.equal(G.phase, 'toss', 'the tap tossed');
|
||||
ball.y = serveApexY() - dy; ball.vy = 0.1; // park the ball dy below the apex
|
||||
keyPressed('Space'); // strike
|
||||
assert.equal(G.phase, 'rally', 'the strike fired');
|
||||
return Math.hypot(ball.vx, ball.vy, ball.vz);
|
||||
};
|
||||
const flush = strike(0);
|
||||
const oldFree = strike(0.1); // inside the old 0.12 snap, outside the new one
|
||||
const missed = strike(0.4);
|
||||
assert.ok(flush > missed * 1.2, `a flush strike is materially faster (${flush.toFixed(1)} vs ${missed.toFixed(1)})`);
|
||||
assert.ok(oldFree < flush - 0.5, 'the 0.12 m free-perfect window no longer exists');
|
||||
});
|
||||
|
||||
test('classic tennis: the second set ends the run, with no corpse', () => {
|
||||
G.mode = 'tennis'; G.classic = true;
|
||||
G.sets = [1, 0]; score.games = [6, 0]; G.runGames = 14;
|
||||
|
||||
Reference in new issue
Block a user