From 48d1069e40166e5d20a57ef98e9e17bfbecea71a Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Wed, 14 Oct 2026 20:30:38 +0200 Subject: [PATCH] Make the run to the net and back a double tap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Oliver: "das Vorlaufen und Zurücklaufen sollte durch einen Doppeltipp ausgelöst werden". It replaces v374's flick-length rule: the long up-flick is only the deep ball again, the long down-flick only the lob. While his own ball flies there is nothing to hit, so a tap there is not a swing. Two inside 0.35 s take him in to the net from the back, or back from the net. "TO THE NET" or "BACK" pops under him. An incoming ball keeps every tap a swing, so the two meanings never share a moment. After his own serve it is serve and volley. The short-ball approach stays automatic. Measured: the base ladder is unchanged, and following the ball in still pays against pushers and costs against big hitters. Three tests replace v374's two; v376. Co-Authored-By: Claude Opus 5.5 --- PROJECT.md | 30 +++++++++++++++++++- index.html | 2 +- src/config.js | 4 +-- src/gameplay.js | 30 ++++++++++++++------ src/state.js | 1 + test.js | 67 +++++++++++++++++++++++++++++++-------------- tools/rally-sim.mjs | 10 +++++-- 7 files changed, 107 insertions(+), 37 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 75a92d8..cf77c6d 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -5709,6 +5709,33 @@ flipping by metres, every other frame, between two candidates: 200 ms. The old code makes 26. The early-tap test's floor moved 0.45 → 0.42: he now stops 0.12 m from his mark instead of 0.35, so contact comes a hair later. The old curve still reads 0.33. +### The run in and out is a DOUBLE TAP (2026-10-14, v376) +Oliver on v375: *"das Vorlaufen und Zurücklaufen sollte durch einen Doppeltipp ausgelöst werden"*. It +replaces v374's flick-length rule. The shot and the run are separate decisions now: the long up-flick is +only the deep hard ball, the long down-flick only the lob. +- **While his OWN ball flies** (rally modes, `ball.lastHit === 'P'`) there is nothing to hit, so a tap there + is not a swing at all. Two taps inside `dblTap` (0.35 s) are `moveCall`: from the back to the net + (`ball.approach = 'P'`), from the net (or while already running in) back (`ball.retreat`). A word pops + under him ("TO THE NET" / "BACK"). A single tap there does nothing (it used to be a pointless swish). +- **An incoming ball keeps every tap a swing**, so the two meanings never share a moment. The rule from + the 2026-08-22 lob holds: the meaning depends on whose ball is in the air, which he always knows, never + on where the opponent stands. +- **After his own serve it is serve and volley.** `P.rushing` no longer excludes the serve. His reply is a + net man's plan from wherever his run has got to. +- **The short-ball approach stays automatic** (step 1, decision (c)). A double tap during that run takes him + back. +- Input: a tap fires at lift if under 250 ms (input.js), and the canvas has `touch-action: none`, so the + browser's double-tap zoom never sees it. +- Measured (σ60, level 26, 8 seeds; the bot follows 30% of its deep flicks in with a double tap): the base + ladder is unchanged. Attacking still pays against the pushers (Steward 52 → 61) and costs against the big + hitters and the Champion (Brute 29 → 20, Champion 33 → 25). It is a little weaker than v374's, because he + sets off after the shot, not with it. +- Tests (172): a double tap from the back takes him in, one tap or two too far apart do nothing and never + swing, the long up-flick alone no longer runs; a double tap at the net takes him back, the lob alone does + not; an incoming ball keeps both taps swings. +- **Not built:** a lesson for it. The ghost hand teaches the tap and the side flick only; the double tap + is undiscoverable without being told. Add one if the phone test says so. + # OPEN — leftovers, in rough priority order Nothing here is broken; these are known gaps and unverified risks. @@ -5723,7 +5750,8 @@ Nothing here is broken; these are known gaps and unverified risks. v374 adds the player's own net game: long up-flick = attack and follow in, long down-flick = lob and go back, and the SMASH. See "The player's way to the net and back" above. v375: the auto-run's shake is gone (a latched chase plan); he is a little stronger for it, so the bottom - of the Climb and classic easy may now be too easy. See "The auto-run stops shaking". Fallback tag `rally-baseline-v360`. + of the Climb and classic easy may now be too easy. See "The auto-run stops shaking". + v376: the run to the net and back is a DOUBLE TAP while his own ball flies (replaces v374's flick length). Fallback tag `rally-baseline-v360`. 1. **`RES` 4 is unverified on a real phone under load.** It nearly doubled the fill rate (810 -> 1080 device px across). Oliver has not reported stutter, but he also has not played a late horde since. diff --git a/index.html b/index.html index 03cb406..08cae4c 100644 --- a/index.html +++ b/index.html @@ -1140,6 +1140,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/config.js b/src/config.js index a7ec1ce..acacf6a 100644 --- a/src/config.js +++ b/src/config.js @@ -107,9 +107,7 @@ export const TUNE = { softErr: 0.8, // ...and how far toward it at quality 0 lobTurn: 0.6, // leg speed of an opponent lobbed at the net: he turns and backpedals netReact: 0.22, // s an opponent at the net stands reading the pass before he moves - attackMin: 90, // px: an UP-flick this long is the ATTACK — the deep ball and you follow it to the - // net (shorter: the deep ball, you stay). Mirror of dropMax: a long DOWN-flick is - // the lob and takes you back to the baseline. One meaning each, wherever you stand + dblTap: 0.35, // s: two taps this close while his OWN ball flies are the move — to the net, or back smashMinY: 1.9, // m: a ball the player takes OUT OF THE AIR at least this high is a SMASH... smashMaxY: 2.5, // ...up to this high (above CONTACT.maxY: the overhead reaches higher than a volley — // but under the 2.8 m the opponent's lob clears a net man by, or he smashes it standing) diff --git a/src/gameplay.js b/src/gameplay.js index 8b47a4b..2d107b1 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -393,13 +393,7 @@ function doHit(ch, quality, aim) { // Never against a man already at the net — that ball is a PASS, and two volleyers is the old // reflex duel at a quarter of a second per shot. ball.from is the striker's distance from the net. ball.approach = rallyFeel() && ball.rx && ball.rx.short && !(ball.from < TUNE.netFrom) ? ch.side : null; - // THE PLAYER'S OWN WAY IN AND OUT (2026-10-14, Oliver: "Steuerung ans Netz gehen"). A LONG up-flick - // is the attack: the deep ball, and he follows it in — even onto a man at the net, it is his call. - // A long DOWN-flick is the lob, and he goes back behind it. Gesture length only, never context: the - // 2026-08-22 lob taught that a gesture whose meaning depends on where the opponent stands is noise. - const longFlick = aim && (aim.mag === undefined || aim.mag >= TUNE.attackMin); - if (ch.side === 'P' && rallyFeel() && aim && aim.az < 0 && !aim.smash && longFlick) ball.approach = 'P'; - ball.retreat = ch.side === 'P' && rallyFeel() && soft !== null && soft >= 1 ? 'P' : null; + ball.retreat = null; // his own way back is a DOUBLE TAP while this ball flies (moveCall), never the shot ball.smash = !!(aim && aim.smash); ball.from = Math.abs(ch.z - COURT.netZ); ball.lastHit = ch.side; ball.bounces = 0; ball.rx = null; // a new shot: the receiver plans it afresh @@ -2139,6 +2133,15 @@ export function keyPressed(code, lead = 0, flick = false) { hitServe(P, q, sway || hard ? { sx: sway, hard } : null); return; } + // THE DOUBLE TAP IS HIS LEGS (2026-10-14, Oliver: "das Vorlaufen und Zurücklaufen sollte durch einen + // Doppeltipp ausgelöst werden"). While his OWN ball is flying there is nothing to hit, so a tap there is + // not a swing: two inside dblTap are the move — in to the net from the back, back from the net. An + // incoming ball keeps every tap a swing, so the two meanings never share a moment. (v374 put this on the + // flick's length; the shot and the run are separate decisions now.) + if (rallyFeel() && G.phase === 'rally' && ball.active && ball.lastHit === 'P' && !flick) { + if (G.moveTapT > 0) { G.moveTapT = 0; moveCall(); } else G.moveTapT = TUNE.dblTap; + return; + } if (G.phase === 'rally' && P.swingT < 0) { recordEvent('swing', { window: TUNE.swingWindow * winLevel(), assist: assistK(), lead }); // A TAP COUNTS FROM THE MOMENT THE FINGER LANDS (rally modes). A tap is only known to BE a tap @@ -2156,6 +2159,16 @@ export function keyPressed(code, lead = 0, flick = false) { } } +// In to the net, or back from it: the double tap's one meaning is "the other end of my half". At the net +// (or already running in) it is the way back; anywhere else it is the way in. rallyHome reads the flags +// while his ball flies, the run goes at full pace, and his reply is planned from wherever he has got to. +function moveCall() { + const goingIn = ball.approach === 'P' || (Math.abs(P.z - COURT.netZ) < TUNE.netFrom && ball.retreat !== 'P'); + ball.approach = goingIn ? null : 'P'; + ball.retreat = goingIn ? 'P' : null; + popText(P.x, 0, P.z + TUNE.praiseBehind, goingIn ? 'BACK' : 'TO THE NET', UI.warm, true); +} + function expirePose(ch, dt) { if (ch.pose === 'swing' || ch.pose === 'serve') { ch.poseT += dt; if (ch.poseT > POSE_TIME) ch.pose = 'idle'; } } @@ -2225,6 +2238,7 @@ function mopRound(b, dt) { // ---------------------------------------------------------------- update ---- export function update(dt) { recordFrame(dt); + if (G.moveTapT > 0) G.moveTapT -= dt; // the double tap's window (keyPressed) if (G.hitstop > 0) { G.hitstop -= dt; return; } // THE OTHER ENDING: a classic match played out. The world holds still — the man is standing, the // crowd is still clapping it down — and the only thing running is the clock the summary and its @@ -2353,7 +2367,7 @@ export function update(dt) { // ...and lobbed at the net he backpedals, exactly as slowly as the CPU does (TUNE.lobTurn) // HIS OWN WAY IN AND OUT runs at full pace (the attack's follow-in, the lob's retreat), and while // his attack flies he is RUSHING: the reply is planned as a net man's from wherever he has got to - if (ball.lastHit === 'P') P.rushing = ball.approach === 'P' && !G.wasServe; + if (ball.lastHit === 'P') P.rushing = ball.approach === 'P'; // a double tap behind his serve is serve and volley const ownRun = !chasing && rallyFeel() && ball.lastHit === 'P' && (ball.approach === 'P' || ball.retreat === 'P'); let rate = TUNE.playerSpeed * aK * dt * (chasing || ownRun ? 1 : 0.55) * (chasing && ball.rx && ball.rx.lob ? TUNE.lobTurn : 1); // ON HIS HEELS: a put-away of his own sitter is read late (step 4 (b)). Without this the auto-run diff --git a/src/state.js b/src/state.js index 3152cd2..ac8c3d9 100644 --- a/src/state.js +++ b/src/state.js @@ -184,6 +184,7 @@ export const G = { rallyNear: null, // closest the incoming ball got this rally (for never-swung passes) swung: false, // player swung at least once this rally — whiff log supersedes PASSED lastMiss: null, // {text, at} — most recent miss reason, drawn on the HUD briefly + moveTapT: 0, // the double tap's open window (gameplay.keyPressed, moveCall) ring: null, // the drawn gold ring's steady cue (gameplay update smooths ringCue) logShow: false, // tap the scoreboard to toggle the on-screen miss-log dump }; diff --git a/test.js b/test.js index b5b169d..e785fdd 100644 --- a/test.js +++ b/test.js @@ -1521,11 +1521,11 @@ test('LOBBED at the net, the player backpedals too, and only blocks it back', () } finally { TUNE.autoAssist = assist; } }); -// ---- THE PLAYER'S WAY TO THE NET AND BACK, AND THE SMASH (2026-10-14, Oliver: "Steuerung ans Netz gehen" +// ---- THE PLAYER'S WAY TO THE NET AND BACK (the double tap), AND THE SMASH (2026-10-14, Oliver: "Steuerung ans Netz gehen" // ... "wenn man einen Lob vom Gegner als Volley annimmt, soll man derb schmettern") --------------------- // One player shot with a given swipe from (x 0, z pz), then `secs` of flight with the opponent parked out // of play. Returns the shot's flags and where the player's own run took him. -function ownShot(swipe, pz, secs = 1.2) { +function ownShot(swipe, pz, secs = 1.2, taps = []) { freshSplatter(); startMatch(); G.interlude.introT = 0; const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15; try { @@ -1538,30 +1538,55 @@ function ownShot(swipe, pz, secs = 1.2) { G.swipe = swipe; update(DT); G.swipe = null; assert.equal(ball.lastHit, 'P', 'the swing connects'); - const out = { approach: ball.approach, retreat: ball.retreat }; - for (let i = 0; i < secs * 60 && ball.active; i++) { rz.swingCool = 999; update(DT); } - out.fromNet = Math.abs(P.z - COURT.netZ); - return out; + let swung = false; + for (let i = 0; i < secs * 60 && ball.active; i++) { + rz.swingCool = 999; + if (taps.includes(i)) { keyPressed('Space', 0.1); swung ||= P.swingT >= 0; } + update(DT); + } + return { approach: ball.approach, retreat: ball.retreat, fromNet: Math.abs(P.z - COURT.netZ), swung }; } finally { TUNE.autoAssist = assist; } } -test('THE ATTACK: a long up-flick follows the ball to the net, a short one stays back', () => { - const up = mag => ({ ax: 0, az: -1, fx: 0, fy: -1, mag }); - const attack = ownShot(up(TUNE.attackMin + 30), 24), deep = ownShot(up(TUNE.attackMin - 30), 24); - assert.equal(attack.approach, 'P', 'the long up-flick is the attack'); - assert.ok(attack.fromNet < TUNE.netFrom, `...and he is at the net by the time it gets there: ${attack.fromNet.toFixed(1)} m`); - assert.equal(deep.approach, null, 'the short one is the deep ball'); - assert.ok(deep.fromNet > TUNE.netFrom + 3, `...and he stays back: ${deep.fromNet.toFixed(1)} m`); +test('THE DOUBLE TAP is his legs: from the back it takes him to the net, a single tap does nothing', () => { + // Oliver, 2026-10-14: "das Vorlaufen und Zurücklaufen sollte durch einen Doppeltipp ausgelöst werden". + // While his own ball flies there is nothing to hit, so taps there are the move, never a swing. + const deep = { ax: 0, az: -1, fx: 0, fy: -1, mag: TUNE.flickFull + 20 }; + const plain = ownShot(deep, 24), one = ownShot(deep, 24, 1.2, [6]), two = ownShot(deep, 24, 1.2, [6, 6 + 10]); + assert.equal(plain.approach, null, 'a long up-flick is only a shot now: no run'); + assert.ok(plain.fromNet > TUNE.netFrom + 3, `...he stays back: ${plain.fromNet.toFixed(1)} m`); + assert.ok(!one.swung && one.approach === null && one.fromNet > TUNE.netFrom + 3, 'one tap while his ball flies: nothing at all'); + assert.equal(two.approach, 'P', 'the double tap is the way in'); + assert.ok(!two.swung, '...and neither tap swung'); + assert.ok(two.fromNet < TUNE.netFrom, `...and he is at the net by the time the ball gets there: ${two.fromNet.toFixed(1)} m`); + const slow = ownShot(deep, 24, 1.2, [6, 6 + Math.ceil(TUNE.dblTap * 60) + 3]); + assert.equal(slow.approach, null, 'two taps further apart than dblTap are not a double tap'); }); -test('THE WAY BACK: a long down-flick from the net is the lob, and he goes back behind it; a drop keeps the net', () => { - const down = mag => ({ ax: 0, az: 1, fx: 0, fy: 1, mag }); - const lob = ownShot(down(TUNE.flickFull + 20), COURT.netZ + TUNE.netStand, 2); - const drop = ownShot(down(TUNE.dropMax - 30), COURT.netZ + TUNE.netStand, 1); - assert.equal(lob.retreat, 'P', 'the lob is the way back'); - assert.ok(lob.fromNet > TUNE.netFrom, `...and he is back behind the net zone: ${lob.fromNet.toFixed(1)} m`); - assert.equal(drop.retreat, null, 'a drop volley is not a retreat'); - assert.ok(drop.fromNet < TUNE.netFrom, `...he holds the net: ${drop.fromNet.toFixed(1)} m`); +test('THE DOUBLE TAP at the net is the way back; a long down-flick is only the lob', () => { + const down = { ax: 0, az: 1, fx: 0, fy: 1, mag: TUNE.flickFull + 20 }; + const lob = ownShot(down, COURT.netZ + TUNE.netStand, 1.5); + assert.equal(lob.retreat, null, 'the lob alone does not take him back'); + assert.ok(lob.fromNet < TUNE.netFrom, `...he holds the net: ${lob.fromNet.toFixed(1)} m`); + const back = ownShot(down, COURT.netZ + TUNE.netStand, 1.5, [6, 14]); + assert.equal(back.retreat, 'P', 'the double tap at the net is the way back'); + assert.ok(back.fromNet > TUNE.netFrom, `...and he is back behind the net zone: ${back.fromNet.toFixed(1)} m`); +}); + +test('an INCOMING ball keeps every tap a swing: the double tap never eats a shot', () => { + freshSplatter(); startMatch(); G.interlude.introT = 0; + oppOf().swingCool = 999; + G.phase = 'rally'; G.wasServe = false; + const v = solveShot(0, 1, 2, 0.5, 21, 20); + Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 0, rx: null, spin: 1, soft: false, + x: 0, y: 1, z: 2, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity }); + P.swingT = -1; + keyPressed('Space', 0.1); + assert.ok(P.swingT >= 0, 'the first tap swings'); + P.swingT = -1; + keyPressed('Space', 0.1); + assert.ok(P.swingT >= 0, 'and so does the second'); + assert.equal(ball.approach, null, 'no run was called'); }); test('THE SMASH: a lob taken out of the air is struck hard to the open court; a deep lob still beats him', () => { diff --git a/tools/rally-sim.mjs b/tools/rally-sim.mjs index f61f1fd..0bd6990 100644 --- a/tools/rally-sim.mjs +++ b/tools/rally-sim.mjs @@ -93,7 +93,7 @@ const med = a => { const s = [...a].sort((x, y) => x - y); return s.length ? s[M // 120 ms tap), and plays the game a little — a lob or a pass against a net man, the odd drop when // the opponent hangs back. BOT=dist is the original fixed-distance tapper with random flicks. const HUMAN = (process.env.BOT || 'human') === 'human'; -const ATTACK = Number(process.env.ATTACK || 0); // share of baseline shots played as the attack (long up-flick) +const ATTACK = Number(process.env.ATTACK || 0); // share of baseline shots followed in: a deep flick, then a DOUBLE TAP const JIT = Number(process.env.JIT ?? 60) / 1000, LAG = 7; const gauss = () => Math.sqrt(-2 * Math.log(1 - Math.random())) * Math.cos(2 * Math.PI * Math.random()); const netx = { attacks: 0, smash: 0, smashWon: 0, lobbed: 0, open: null, p0: 0 }; @@ -108,7 +108,7 @@ function humanAim() { const away = rz && rz.x > 0 ? -1 : 1; // the side he is not on if (oz < TUNE.netFrom) return r < 0.4 ? sw(0, 1, 150) : sw(away, -0.3, 120); // net man: lob him or pass him if (oz > COURT.netZ + 0.5 && Math.abs(P.z - COURT.netZ) < COURT.netZ - 1.5 && r < 0.15) return sw(0, 1, 50); // drop - if (ATTACK && Math.abs(P.z - COURT.netZ) >= TUNE.netFrom && r < ATTACK) return sw(away * 0.3, -1, 130); // the attack: follow it in + if (ATTACK && Math.abs(P.z - COURT.netZ) >= TUNE.netFrom && Math.random() < ATTACK) { bot.dbl = true; return sw(away * 0.3, -1, 130); } // the attack if (r < 0.45) return sw(away, -0.2, 110); // to the open side if (r < 0.65) return sw(0, -1, 80); // deep through the middle return null; // a plain tap @@ -162,6 +162,8 @@ for (const rung of RUNGS) { } } if (i === bot.fire) { G.swipe = bot.swipe; keyPressed('Space', bot.swipe ? 0 : LAG / 60, !!bot.swipe); } + if (bot.dbl && ball.lastHit === 'P' && ball.active) { bot.dbl = false; bot.taps = [i + 4, i + 12]; } // follow it in: the double tap + if (bot.taps && bot.taps.includes(i)) keyPressed('Space', 0.1); } else if (G.phase === 'rally' && ball.active && ball.lastHit === 'O') { const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); @@ -178,7 +180,9 @@ for (const rung of RUNGS) { const px0 = P.x, pz0 = P.z, tg0 = P.tgt; if (process.env.PROBE && lh === 'O' && bot.land && P.swingT >= 0) bot.land.sw = P.swingT, bot.land.d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8), bot.land.y = ball.y, bot.land.bb = ball.bounces; update(DT); - if (lh !== 'P' && ball.lastHit === 'P' && ball.active) { if (ball.approach === 'P' && !G.wasServe) netx.attacks++; if (ball.smash) { netx.smash++; netx.open = 'smash'; } } + if (lh !== 'P' && ball.lastHit === 'P' && ball.active && ball.smash) { netx.smash++; netx.open = 'smash'; } + if (ball.lastHit === 'P' && ball.approach === 'P' && !netx.wasIn) netx.attacks++; // a run in (the double tap, or a short ball) + netx.wasIn = ball.lastHit === 'P' && ball.approach === 'P'; if (lh === 'P' && ball.lastHit === 'O' && ball.active && ball.soft && Math.abs(P.z - COURT.netZ) < TUNE.netFrom) netx.lobbed++; if (netx.open && (G.phase !== 'rally' || !ball.active)) { const d0 = score.pts[0] - netx.p0; if (d0 > 0) netx.smashWon++; netx.open = null; } if (netx.open === null) netx.p0 = score.pts[0];