Make the down-flick the soft ball: a short one drops, a long one lobs
Step 3 of the rally rework, Oliver's pick: in rally modes the down-flick is soft, always, and its length is how far — a dying drop just over the net at one end, an attacking lob over a net man at the other. One gesture, one meaning; not the context-armed lob that was cut. Ballistics from the cut lob (pace derived from the apex); backspin lives on ball.spin and every bounce planner reads it, netplay sends it. Against the CPU it was returned ~100% until the real-tennis reasons went in: soft balls are read late (disguise), a net man reacts to a pass and backpedals from a lob, and a lobbed or dug-out reply is a weak block. Four new tests; v363. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f9b0dd2efb
commit
72760a9d1e
7 files changed
+215
-28
No files matched your search
@@ -89,7 +89,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 { solveShot, update, forceZombies, startGore, splatPoint, restartRun, leaguePace, duelSkill, tinLadder, deliverTin, peerK: _peerK, legLevel: _legLevel, winLevel: _winLevel, assistK: _assistK, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt } = await import('./src/gameplay.js');
|
||||
const { predictLanding, solveShot, update, forceZombies, startGore, splatPoint, restartRun, leaguePace, duelSkill, tinLadder, deliverTin, peerK: _peerK, legLevel: _legLevel, winLevel: _winLevel, assistK: _assistK, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt } = 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, netAgain } = await import('./src/net.js');
|
||||
// the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a
|
||||
@@ -1169,6 +1169,89 @@ test('a TAP is timed from touch-down: a normal 120 ms tap on the gold ring still
|
||||
} finally { TUNE.autoAssist = assist; }
|
||||
});
|
||||
|
||||
// ---- THE SOFT BALL (step 3, 2026-10-14): the down-flick is soft, its length is how far ----------------
|
||||
// One player shot, struck from the baseline with the swing clock put exactly where trySwing scores it:
|
||||
// q = 1 at the window's centre, `late` seconds off it for a mistimed one. The opponent is parked out of
|
||||
// play so the flight is the shot's own. Returns where it landed, how high it flew, its spin, and the
|
||||
// gap between its first and second bounce.
|
||||
function softShot(mag, late = 0, rzZ = -0.6) {
|
||||
freshSplatter(); startMatch(); G.interlude.introT = 0;
|
||||
const rz = oppOf();
|
||||
Object.assign(rz, { x: 3, z: rzZ, swingCool: 999, swingT: -1 });
|
||||
G.phase = 'rally'; G.wasServe = false;
|
||||
Object.assign(P, { x: 0, z: 24, swingT: TUNE.swingWindup + TUNE.swingWindow * winLevelNow() / 2 - DT + late });
|
||||
Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 1, rx: { net: false, short: false },
|
||||
spin: 1, soft: false, x: 0.4, y: 1.0, z: 23.6, vx: 0, vy: -0.5, vz: 3, g: TUNE.gravity });
|
||||
G.swipe = { ax: 0, az: 1, fx: 0, fy: 1, mag };
|
||||
update(DT); G.swipe = null;
|
||||
assert.equal(ball.lastHit, 'P', 'the swing connects');
|
||||
const q = ball.pow, spin = ball.spin, land = predictLanding();
|
||||
let apex = ball.y, first = null, atNetMan = null;
|
||||
const netMan = COURT.netZ - TUNE.netStand;
|
||||
for (let i = 0; i < 6 * 60 && ball.active && ball.bounces < 2; i++) {
|
||||
update(DT); apex = Math.max(apex, ball.y);
|
||||
if (atNetMan === null && ball.z <= netMan) atNetMan = ball.y;
|
||||
if (ball.bounces === 1 && !first) first = ball.z;
|
||||
}
|
||||
return { q, spin, depth: land.z, apex, atNetMan, roll: first === null ? null : Math.abs(ball.z - first), rz };
|
||||
}
|
||||
const winLevelNow = () => _winLevel();
|
||||
|
||||
test('THE SOFT BALL: a short down-flick is a dying drop, a long one an attacking lob over the net man', () => {
|
||||
const drop = softShot(TUNE.flickMin + 2), lob = softShot(TUNE.flickFull + 20);
|
||||
assert.ok(drop.q > 0.95 && lob.q > 0.95, 'both struck flush');
|
||||
assert.ok(Math.abs(drop.depth - (COURT.netZ - TUNE.dropNet)) < 0.7, 'the drop lands just over the net: z ' + drop.depth.toFixed(2));
|
||||
// (dropApex is the ask; from the baseline the net-clearance loop lofts it a little, which is right)
|
||||
assert.ok(drop.apex < lob.apex - 1.5, `on a low arc: ${drop.apex.toFixed(2)} m against the lob's ${lob.apex.toFixed(2)}`);
|
||||
assert.ok(drop.spin < 0.5 && drop.roll < 2.5, `and it DIES: spin ${drop.spin.toFixed(2)}, ${drop.roll.toFixed(2)} m between its bounces`);
|
||||
assert.ok(Math.abs(lob.depth - TUNE.lobDeep) < 0.8, 'the lob lands deep: z ' + lob.depth.toFixed(2));
|
||||
assert.ok(lob.apex > TUNE.lobApex - 0.6 && lob.spin === 1, 'high, and with no backspin: ' + lob.apex.toFixed(2) + ' m');
|
||||
// ...over a man at the net: where he stands it must be out of his racket's reach
|
||||
assert.ok(lob.atNetMan > CONTACT.maxY + 0.5, `over the net man's racket: ${lob.atNetMan.toFixed(2)} m at his depth`);
|
||||
});
|
||||
|
||||
test('a mistimed soft ball drifts toward the sitter — the easy ball, never an out', () => {
|
||||
const clean = softShot(TUNE.flickFull + 20), rushed = softShot(TUNE.flickFull + 20, 0.07);
|
||||
assert.ok(rushed.q < 0.3, 'the rushed one really is mistimed: q ' + rushed.q.toFixed(2));
|
||||
assert.ok(Math.abs(rushed.depth - TUNE.softSitter) < Math.abs(clean.depth - TUNE.softSitter),
|
||||
`it falls short of the corner: z ${clean.depth.toFixed(2)} -> ${rushed.depth.toFixed(2)}`);
|
||||
assert.ok(rushed.depth > 0 && rushed.depth < COURT.netZ, 'and stays in the court');
|
||||
});
|
||||
|
||||
test('a soft ball is DISGUISED: the opponent stands reading it, but runs at once for a drive', () => {
|
||||
const moved = (soft) => {
|
||||
freshSplatter(); startMatch(); G.interlude.introT = 0;
|
||||
const rz = oppOf();
|
||||
Object.assign(rz, { x: 0, z: -0.6, swingCool: 999, swingT: -1 });
|
||||
G.phase = 'rally'; G.wasServe = false; G.aiReactT = 0;
|
||||
const v = solveShot(0, 1, 23, 2.5, soft ? 9.5 : 3, soft ? 12 : 25);
|
||||
Object.assign(ball, { active: true, scored: false, lastHit: 'P', bounces: 0, rx: null, spin: soft ? 0.3 : 1, soft,
|
||||
x: 0, y: 1, z: 23, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity });
|
||||
for (let i = 0; i < Math.round((TUNE.softRead - 0.05) * 60); i++) update(DT);
|
||||
return Math.hypot(rz.x, rz.z + 0.6);
|
||||
};
|
||||
assert.ok(moved(false) > 0.5, 'a drive: he is already on his way');
|
||||
assert.ok(moved(true) < 0.05, 'a drop: he has not read it yet');
|
||||
});
|
||||
|
||||
test('a LOBBED net man who runs it down only gets a defensive ball back', () => {
|
||||
const flub = TUNE.matchFlub; TUNE.matchFlub = 0;
|
||||
try {
|
||||
for (const lob of [false, true]) {
|
||||
freshSplatter(); startMatch(); G.interlude.introT = 0; G.match.opp = 18;
|
||||
const rz = oppOf();
|
||||
Object.assign(rz, { x: 0, z: 3, swingCool: 0, swingT: -1, foe: cfg.matchFoe(18) });
|
||||
G.phase = 'rally'; G.wasServe = false; G.aiReactT = 1;
|
||||
Object.assign(ball, { active: true, scored: false, lastHit: 'P', bounces: 1, spin: 1, soft: lob,
|
||||
rx: { net: false, short: false, lob }, x: 0, y: 1.2, z: 3.6, vx: 0, vy: -1, vz: -4, g: TUNE.gravity });
|
||||
update(DT);
|
||||
assert.equal(ball.lastHit, 'O', 'he gets to it');
|
||||
if (lob) assert.ok(ball.pow <= TUNE.lowVolleyQ + 1e-9, 'run down from a lob: q ' + ball.pow.toFixed(2));
|
||||
else assert.ok(ball.pow > TUNE.lowVolleyQ, 'the same ball unlobbed is a full reply: q ' + ball.pow.toFixed(2));
|
||||
}
|
||||
} finally { TUNE.matchFlub = flub; }
|
||||
});
|
||||
|
||||
test('rally depth: deep by default, mistiming lands it SHORTER, never longer', () => {
|
||||
// the only out a rally ball may find is wide — depth must never put one past the baseline
|
||||
startMatch(); G.interlude.introT = 0;
|
||||
|
||||
Reference in new issue
Block a user