The receiver stands where the serve can actually go
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
ece27827e5
commit
86b8275f5d
6 files changed
+77
-10
No files matched your search
+1
-1
@@ -659,6 +659,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="src/main.js?v=273"></script>
|
||||
<script type="module" src="src/main.js?v=274"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
+29
-7
@@ -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';
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user