From 86b8275f5dcf025d38603c037d5911cc130a9e2a Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sat, 15 Aug 2026 20:29:17 +0200 Subject: [PATCH] The receiver stands where the serve can actually go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trick: hard serve, slight slide, and the first ten opponents died where they stood. Measured headless — that serve was returned 0 times out of 24 at EVERY rung. Three things were wrong and all three were the same thing: he waited on the tennis mark 1.3 m off the centre line (covering the T, conceding the whole corner), he took no first step, and he chased the ball's LIVE position, which during a serve is still up by the server's baseline. Now: he waits on the bisector of the serves he has to cover, takes a returner's first step (serve-scoped — the rally is untouched), and runs at the bounce instead of at the ball. The slid serve comes back from rung one; a flush serve into the very corner of the box still aces the bottom of the ladder, which is the reward placement is supposed to earn. Co-Authored-By: Claude Opus 5 --- index.html | 2 +- src/beats.js | 7 +++++++ src/config.js | 7 +++++++ src/gameplay.js | 36 +++++++++++++++++++++++++++++------- src/render.js | 2 +- test.js | 33 ++++++++++++++++++++++++++++++++- 6 files changed, 77 insertions(+), 10 deletions(-) diff --git a/index.html b/index.html index 876811a..70df955 100644 --- a/index.html +++ b/index.html @@ -659,6 +659,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/beats.js b/src/beats.js index 57fc81a..f74d67e 100644 --- a/src/beats.js +++ b/src/beats.js @@ -421,6 +421,13 @@ export function matchReady() { // exported for net.js: a paired death match re-p // machinery is driven off — on exactly that spot, so the zombie simply takes it. One source for // the mark, which is what stops the picture and the ballistics disagreeing about where it stands. if (rz) { + // WHERE A RETURNER ACTUALLY STANDS. The tennis mark is 1.3 m off the centre line, and from + // there he bisects nothing: the T is one step away and the corner of the box is four. Measured + // headless, a flush serve with full sway into that corner was returned 0 times out of 20 at + // EVERY rung of the ladder — Oliver: "the first ten zombies can be killed the moment you + // serve". He now stands out on the bisector of the two serves he has to cover, which is what a + // real receiver does, and O takes the same mark so the picture and the ballistics still agree. + if (score.server === 'P') O.x = Math.sign(O.x || 1) * TUNE.matchRecvX; rz.x = O.x; rz.z = score.server === 'O' ? O.z : 1.0; rz.pose = 'idle'; rz.swingT = -1; rz.swingCool = 0.3; rz.showFrame = null; rz.serveHold = 0; diff --git a/src/config.js b/src/config.js index 8f0e64f..b3655be 100644 --- a/src/config.js +++ b/src/config.js @@ -581,6 +581,13 @@ export const TUNE = { racketStride: 0.42, // m the racket zombie covers per walk frame — his cycle steps off GROUND // COVERED, so he stops animating when he stops moving racketSpeed: 2.8, // m/s the racket zombie shambles to chase a ball + aiRead: 0.8, // how much of the BOUNCE the racket zombie reads while the ball is in the air: + // 0 = the old pure pursuit of the live ball (a hard, angled serve was an + // automatic ace — 0/40 returned, measured), 1 = never wrong-footed. See racketAI. + matchRecvX: 2.45, // m off the centre line the match opponent WAITS on, receiving. The tennis mark + // (1.3) covers the T and concedes the corner; the box runs to 4.1. See matchReady. + aiReturnDash: 1.3, // ...and how much faster he moves for the SERVE only, the first step a + // returner takes. Serve-scoped on purpose: the rally is unchanged. racketQMax: 0.55, // its return quality is capped here (slower, sloppier than the tennis CPU) racketFlub: 0.2, // chance the racket zombie whiffs a return missHud: 0, // show the last miss reason on screen (full log: T.G.missLog) — off; dev-only diff --git a/src/gameplay.js b/src/gameplay.js index f65c522..f6cf398 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -365,7 +365,10 @@ export function sliceCarom(tgt, speed) { // `perfect` is passed SEPARATELY from `quality` because the two serves earn it differently: the toss // ritual earns it on a 45 ms timing window, while the slingshot's quality saturates at pullFull and // 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) { +// exported for the headless suite only: it is the one honest way to serve without a thumb, and a +// serve-defence test that fakes its own ball is testing a ball the game never hits (it did, once — +// the fake was a flat rocket that skidded out the back and nothing could have returned it). +export function hitServe(sv, quality, aim = null, perfect = quality > 0.999) { const toFar = sv.side === 'P'; // {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 @@ -1010,8 +1013,16 @@ function matchFallWillPlay() { return racketStage({ hp: after, hpMax: rz.hpMax }) !== racketStage(rz); // a limb } +// How fast this body chases a ball. ONE definition, because racketAI's legs and the two +// out-of-reach tests that end a rally early must never disagree about what it can get to. +// THE RETURN DASH is the returner's first step: a serve is the one ball in the game that is +// hit before he is moving at all, and against a hard one into the corner the walk-up simply +// could not start in time (Oliver: the first ten opponents can be killed the moment you serve). +// It lasts exactly as long as the serve does — G.wasServe is cleared at the first bounce and at +// any return — so a rally is chased at the ordinary ladder speed and nothing else moves. const rzChaseSpeed = rz => TUNE.racketSpeed - * (TUNE.matchSpeedBase + (G.match.opp - 1) * TUNE.matchSpeedPer) * ((rz.foe && rz.foe.speed) || 1); + * (G.match ? (TUNE.matchSpeedBase + (G.match.opp - 1) * TUNE.matchSpeedPer) * ((rz.foe && rz.foe.speed) || 1) : 1) + * (G.wasServe && ball.lastHit === 'P' ? TUNE.aiReturnDash : 1); // Can the match opponent still reach this ball before its second bounce? Closed-form flight from // the POST-BOUNCE state (velocities already damped when the line call runs) against the body's own @@ -1279,16 +1290,27 @@ function racketAI(rz, dt) { const incoming = ball.active && ball.lastHit === 'P' && ball.vz < 0 && (G.interlude || (ball.pow || 0) < TUNE.zSplatQ); // in the duel it plays EVERY ball — it's tennis now if (!incoming) { if (rz.pose === 'run') rz.pose = 'idle'; return; } - const tz = Math.max(1.5, Math.min(COURT.svcFar, ball.z)); // chase laterally, hold deep on the far court - const dx = ball.x - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz); + // ANTICIPATION. He used to run at the ball's LIVE position, and against a serve that is a + // trap: for the first third of the flight the ball is still up by YOUR baseline, so he ran + // deep and straight while the ball was already leaving sideways, and only started sliding + // across once it was past him. Measured headless: a flush, hard serve into the corner of the + // box was returned 0 times out of 40 at every rung of the ladder — Oliver's "you can kill the + // first ten of them the moment you serve". He reads the BOUNCE now and runs at that instead. + // aiRead is how much of the read he gets: 1 would be a machine that is never wrong-footed, and + // being wrong-footed is most of what makes the rally a rally. Serves only in spirit but applied + // to every flight — the same lag was there in a rally, just hidden by the shorter distances. + const pred = ball.y > 0.35 && ball.vy < 3 ? predictLanding() : null; // not while it is still climbing off the racket + const aimX = pred ? ball.x + (pred.x - ball.x) * TUNE.aiRead : ball.x; + const aimZ = pred ? ball.z + (pred.z - ball.z) * TUNE.aiRead : ball.z; + const tz = Math.max(1.5, Math.min(COURT.svcFar, aimZ)); // chase laterally, hold deep on the far court + const dx = aimX - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz); if (d > 0.15) { // THE MATCH ESCALATES HERE, not in hp: with regicide gone the pool is pure scoreboard, // so a harder opponent is a FASTER one that flubs less. The BASE is boosted too — the duel's // 2.8 m/s lumber was tuned for one sudden-death point against a fresh player, and across a // whole game it read as "very weak and slow": opponent one already moves at matchSpeedBase. - const spd = TUNE.racketSpeed * (G.match ? (TUNE.matchSpeedBase + (G.match.opp - 1) * TUNE.matchSpeedPer) - * (rz.foe?.speed || 1) : 1); - const step = Math.min(spd * dt, d); + // (the dials themselves live in rzChaseSpeed, which the rally-ending reach tests share) + const step = Math.min(rzChaseSpeed(rz) * dt, d); rz.dist = (rz.dist || 0) + step; // ground covered — his walk cycle steps off this, not the clock rz.x += dx / d * step; rz.z += dz / d * step; rz.runT += dt; if (rz.pose !== 'swing') rz.pose = 'run'; diff --git a/src/render.js b/src/render.js index 45fa9b6..9294e48 100644 --- a/src/render.js +++ b/src/render.js @@ -85,7 +85,7 @@ function measureSafe(scale) { // BLOOD panel both sit 6 logical px in from the top-right); the DOM chips on top of it are laid // out in CSS px — and on a phone one logical px is ~1.4 CSS px, so the same "6" put the quit // chip a visible notch above the panels it is meant to line up with. One variable, one grid. - document.documentElement.style.setProperty('--hud', 6 * scale + 'px'); + document.documentElement?.style.setProperty('--hud', 6 * scale + 'px'); // ?. — the headless suite has no root element } // The page background behind the canvas. It shows in exactly two places: the strip above the canvas diff --git a/test.js b/test.js index b6b30f7..073ee0e 100644 --- a/test.js +++ b/test.js @@ -84,7 +84,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' }); // ---- game modules (real code) ---------------------------------------------- const state = await import('./src/state.js'); const { G, P, O, score, ball, setupPoint } = state; -const { update, forceZombies, startGore, splatPoint, restartRun, flickTarget, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap } = await import('./src/gameplay.js'); +const { update, forceZombies, startGore, splatPoint, restartRun, flickTarget, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe } = await import('./src/gameplay.js'); const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN, startNetVs, endNetVs } = await import('./src/beats.js'); const { mirror, netMsg, netTest, netTick } = await import('./src/net.js'); // the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a @@ -1083,6 +1083,37 @@ test('REACHABILITY: an auto-run player can cover the first opponent\'s returns', } finally { TUNE.autoAssist = assist; } }); +test('a served ball with a slide on it is no longer a free kill', () => { + // Oliver: "the moment you serve you can serve hard with a slight slide and almost all of them + // fail to get the ball" — the first ten opponents died to one gesture. Measured before the fix: + // a flush, hard, swayed serve was returned 0 times out of 24 at EVERY rung of the ladder, because + // the receiver waited on the tennis mark (1.3 m off the centre line, covering the T and conceding + // the whole corner) and then chased the ball's LIVE position, which during a serve is still up by + // the server's baseline. Mark, first step and read are all dialled in config; this asserts the + // EXPERIENCE, not the dials, exactly like the reachability soak above. + // A serve into the very corner of the box is deliberately still an ace at the bottom of the + // ladder — the trick that had to die is the one that needed no placement. + const serve = sx => { + freshSplatter(); + startMatch(); + for (let i = 0; i < 2.2 * 60; i++) update(DT); + ball.active = true; ball.collected = false; ball.bounces = 0; ball.scored = false; + ball.x = P.x + 0.12; ball.z = P.z; ball.y = 2.6; ball.vx = ball.vy = ball.vz = 0; + G.wasServe = true; + hitServe(P, 1, { sx, hard: 1 }); // flush timing, full pace, `sx` of slide + for (let i = 0; i < 5 * 60; i++) { + update(DT); + if (ball.lastHit === 'O') return true; // he got a racket on it + if (!ball.active || G.phase !== 'rally') break; + } + return false; + }; + let got = 0; + const n = 10; + for (let i = 0; i < n; i++) if (serve(-0.5)) got++; + assert.ok(got >= n * 0.6, `opponent one returns a slid serve: ${got}/${n} (was 0)`); +}); + test('the match racket is longer, and only the match racket', () => { assert.ok(TUNE.matchReach > 1.2, 'match contact reach is widened — the recorder showed misses by centimetres'); // the gore modes' contact stays untouched: matchReach only multiplies inside G.match