From de77ccfea54129e6f66b5047e4b698870e50bbef Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Thu, 13 Aug 2026 17:23:07 +0200 Subject: [PATCH] The serve gets stakes: tight apex window, depth rides quality, the strike flick aims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PROJECT.md | 22 +++++++++++++++++ index.html | 2 +- src/config.js | 13 ++++++++++ src/gameplay.js | 65 ++++++++++++++++++++++++++++++++++++++----------- src/render.js | 8 +++--- test.js | 22 ++++++++++++++++- 6 files changed, 112 insertions(+), 20 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 04f3832..31f284f 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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 diff --git a/index.html b/index.html index f24bfa8..4fde2f3 100644 --- a/index.html +++ b/index.html @@ -431,6 +431,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/config.js b/src/config.js index 50088af..2a8a2ef 100644 --- a/src/config.js +++ b/src/config.js @@ -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. diff --git a/src/gameplay.js b/src/gameplay.js index 8ad20dc..63cd657 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -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; } diff --git a/src/render.js b/src/render.js index 3874c32..566f8ea 100644 --- a/src/render.js +++ b/src/render.js @@ -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 diff --git a/test.js b/test.js index 023246a..e318226 100644 --- a/test.js +++ b/test.js @@ -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;