Let an early tap be a weak ball, and let the CPU put a sitter away
Step 4 (b), Oliver's pick. v372 measured why the ladder could not climb: nearly every lost point was the player's own early whiff, the CPU barely missed, and the auto-run reached everything, so no opponent dial decided a point. - The follow-through: a groundstroke swing meets a bounced ball up to 60 ms after its window at q 0.05. The early whiff edge goes from ~70 ms back to ~130 ms, where the volley window had it before step 1. - A sitter (a player ball under q 0.5) is put away with a chance that is the ladder (0.2 at the bottom of the Climb, 0.85 at the Champion, by difficulty in classic): clean, at the line, bounded by the rung's width. - The player is on his heels for 0.5 s after a put-away of his own sitter. Without it the auto-run covered the whole court and no put-away won. - Fixed a step-2 regression this surfaced: the drop's dig-out cap fired on every slice drive, so every low contact against the slicer was a sitter. Measured at level 26: the Climb now slopes from 95% of points at the Shambler to 28-39% at the Champion. The net rushers are still easier than their neighbours. The log names put-aways. Three new tests; v373. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5dd5d71c33
commit
4406aeafdb
8 files changed
+231
-31
No files matched your search
+12
-3
@@ -153,6 +153,15 @@ export const TUNE = {
|
||||
// screen: a short swipe is a safe half-court ball, and only a committed
|
||||
// one goes hunting the line, which is what makes going OUT a chosen risk.
|
||||
perfectMs: 45, // |offset from window center| for PERFECT
|
||||
swingTail: 0.06, // s: rally modes, a groundstroke swing still meets a ball arriving this long after
|
||||
// its window closes — the follow-through, q 0.05 (a weak short ball, not a whiff).
|
||||
// 0 = the old edge: a tap ~70 ms early whiffed (step 4, see gameplay.trySwing)
|
||||
punishBelow: 0.5, // a player ball under this q is a SITTER the CPU may put away (gameplay.punishes)
|
||||
punishMin: 0.2, // ...the chance it does at the bottom of the ladder
|
||||
punishMax: 0.85, // ...and at the CHAMPION (classic: DIFFS[].punish)
|
||||
punishQ: 0.95, // the put-away's quality, played to the open court...
|
||||
punishX: 3.5, // ...at this x, inside the singles line (4.1), bounded by the rung's width dial
|
||||
punishRead: 0.5, // s the player's auto-run stands flat-footed on a put-away of his own sitter
|
||||
qSeamless: 1, // rally modes: past perfectMs, q falls from 1 to the window edge with no step.
|
||||
// 0 = the old curve, 1 - off/half, which dropped a groundstroke 1 ms past
|
||||
// PERFECT straight to q 0.43 (step 4, 2026-10-14 — see gameplay.trySwing)
|
||||
@@ -919,9 +928,9 @@ export const MENU = [{ mode: 'zombie' }, { mode: 'train' }, { mode: 'classic' }]
|
||||
export const DIFFS = [
|
||||
// EASY's first cut (flub 0.30, quality cap 0.62, reaction 0.62) was "way too easy" once HARD
|
||||
// taught us which dials bite — it is a mild step below MEDIUM now, not a training dummy
|
||||
{ id: 'easy', name: 'EASY', flub: 0.15, quality: 0.80, qMin: 0.45, speed: 0.95, reaction: 0.50, reach: 1.55, serve: 0.95, smart: 0, recover: 0.48 },
|
||||
{ id: 'medium', name: 'MEDIUM', flub: 0.06, quality: 0.92, qMin: 0.56, speed: 1.18, reaction: 0.34, reach: 1.70, serve: 1.05, smart: 0.35, recover: 0.66 },
|
||||
{ id: 'hard', name: 'HARD', flub: 0.02, quality: 1.0, qMin: 0.66, speed: 1.38, reaction: 0.27, reach: 1.80, serve: 1.15, smart: 0.7, recover: 0.80 },
|
||||
{ id: 'easy', name: 'EASY', flub: 0.15, quality: 0.80, qMin: 0.45, speed: 0.95, reaction: 0.50, reach: 1.55, serve: 0.95, smart: 0, recover: 0.48, punish: 0.2 },
|
||||
{ id: 'medium', name: 'MEDIUM', flub: 0.06, quality: 0.92, qMin: 0.56, speed: 1.18, reaction: 0.34, reach: 1.70, serve: 1.05, smart: 0.35, recover: 0.66, punish: 0.5 },
|
||||
{ id: 'hard', name: 'HARD', flub: 0.02, quality: 1.0, qMin: 0.66, speed: 1.38, reaction: 0.27, reach: 1.80, serve: 1.15, smart: 0.7, recover: 0.80, punish: 0.8 },
|
||||
];
|
||||
|
||||
// THE TOURNAMENT (Oliver's pick over the endless ladder, 2026-08-13): four rounds up the club's
|
||||
|
||||
+43
-9
@@ -268,6 +268,9 @@ function doHit(ch, quality, aim) {
|
||||
const bound = Math.max(-TUNE.flickWidth, Math.min(TUNE.flickWidth, ch.x));
|
||||
tx = sway < 0 ? Math.min(tx, bound) : Math.max(tx, bound);
|
||||
}
|
||||
// a PUT-AWAY goes for the line, not the rally lane (the match's width dial below still bounds it,
|
||||
// so the bottom of the ladder cannot paint lines even when it punishes)
|
||||
if (aim && aim.kill) tx = (aim.ax || (P.x > 0 ? -1 : 1)) * TUNE.punishX + err() * 0.3;
|
||||
// THE COURT HAS ITS LENGTH: in a rally mode the ball goes DEEP, and bad timing costs DEPTH — a
|
||||
// mistimed ball sits up short, which is the ball a real opponent attacks — never length, so
|
||||
// depth adds no out. The down-flick keeps its old short ball (it becomes the soft ball next).
|
||||
@@ -384,6 +387,7 @@ function doHit(ch, quality, aim) {
|
||||
ball.approach = rallyFeel() && ball.rx && ball.rx.short && !(ball.from < TUNE.netFrom) ? ch.side : null;
|
||||
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
|
||||
ball.heels = aim && aim.kill ? TUNE.punishRead : 0; // a put-away: he is on his heels (see the auto-run)
|
||||
// THE SWING LOG's other half: what came at you. A LOST "passed you" line is unreadable without it —
|
||||
// was that ball reachable at all? And "never swung" is judged per incoming ball, not once a point.
|
||||
if (ch.side !== 'P' && swingLog()) {
|
||||
@@ -393,7 +397,7 @@ function doHit(ch, quality, aim) {
|
||||
const out = Math.abs(land.x) > COURT.W / 2 || land.z > COURT.L || land.z < COURT.netZ;
|
||||
return ` · lands ${r2(Math.abs(dx))}m ${dx < 0 ? 'left' : 'right'} of you, ${inside}m inside your line${out ? ' (OUT)' : ''}`;
|
||||
})();
|
||||
const kind = soft === null ? (aim && aim.az < 0 ? 'deep' : 'drive') + (ball.spin > 1 ? ' topspin' : ball.spin < 1 ? ' slice' : '')
|
||||
const kind = aim && aim.kill ? 'PUT-AWAY of your weak ball' : soft === null ? (aim && aim.az < 0 ? 'deep' : 'drive') + (ball.spin > 1 ? ' topspin' : ball.spin < 1 ? ' slice' : '')
|
||||
: soft === 0 ? 'drop' : soft >= 1 ? 'lob' : 'moonball';
|
||||
logSwing({ result: 'THEM', reason: `${kind} q=${r2(quality)} · ${Math.round(Math.hypot(ball.vx, ball.vz))}m/s${mark}`
|
||||
+ (ball.approach === ch.side ? ' · coming in' : ''), px: r2(P.x), pz: r2(P.z) }); // where HE stood when it left
|
||||
@@ -613,7 +617,7 @@ export function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
|
||||
perfect && G.mode === 'splatter' && !G.interlude && G.weapon === 0 && !G.temp);
|
||||
ball.g = g;
|
||||
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
|
||||
ball.lastHit = sv.side; ball.bounces = 0; ball.rx = null; ball.spin = 1; ball.soft = false;
|
||||
ball.lastHit = sv.side; ball.bounces = 0; ball.rx = null; ball.heels = 0; ball.spin = 1; ball.soft = false;
|
||||
// a serve-and-volleyer follows its serve in (STYLES.sv) — the serve is its approach shot
|
||||
ball.approach = sv.side === 'O' && styleOf(zombies.find(z => z.variant === 'r'))?.sv ? 'O' : null; ball.from = Math.abs(sv.z - COURT.netZ);
|
||||
ball.pow = quality;
|
||||
@@ -1650,6 +1654,25 @@ function logWhiff() {
|
||||
...(P.tgt ? { runTo: r2(P.tgt.x) + ',' + r2(P.tgt.z) } : {}) }); // where the auto-run was taking him
|
||||
}
|
||||
|
||||
// THE FOLLOW-THROUGH (step 4 (b), 2026-10-14). A tap a little EARLY on a baseline ball starts the swing
|
||||
// while the ball is still in the air; the swing waits for the bounce (letBounce), and by then the
|
||||
// groundstroke window has run out — a clean whiff at ~70 ms early, while a tap 120 ms LATE still met
|
||||
// the ball. Before step 1 the same ball was volleyed in a 1.8x window, so it never came up. In the
|
||||
// rally modes the swing now carries on for swingTail after its window and meets a ball arriving there
|
||||
// at q 0.05: a weak short ball the opponent has to punish. Whether he does is what the ladder is.
|
||||
const swingTail = isAI => !isAI && rallyFeel() ? TUNE.swingTail : 0;
|
||||
// ...and the other half, without which the follow-through only made rallies endless: the CPU had NO
|
||||
// way to win a point. It returned a floater exactly like a drive, its whiff rate is near zero past
|
||||
// mid-ladder, and every point it took was a whiff of the player's — which no opponent dial touches, so
|
||||
// the ladder could not climb. A sitter (the player's ball under punishBelow) is now PUT AWAY with a
|
||||
// chance that IS the ladder: punishMin at the shambler to punishMax at the champion, classic by
|
||||
// difficulty. Rally modes, solo only.
|
||||
function punishes(ch) {
|
||||
if (!rallyFeel() || G.net || ch.side === 'P' || (ball.pow ?? 1) >= TUNE.punishBelow) return false;
|
||||
// the Climb's own progress (matchStep: its brisker bottom included), so a level-26 run is not filler
|
||||
const k = G.diff ? (G.diff.punish ?? 0) : G.match ? TUNE.punishMin + (TUNE.punishMax - TUNE.punishMin) * Math.min(1, matchStep() / (LADDER - 1)) : 0;
|
||||
return Math.random() < k;
|
||||
}
|
||||
function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
|
||||
// Contact happens if the ball is inside reach while the swing window is open.
|
||||
// Player volleys (pre-bounce) get wider reach + window: full-speed ball, no bounce cue.
|
||||
@@ -1663,7 +1686,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
|
||||
: TUNE.reach * (volley ? VOLLEY.reach : 1) * (rallyFeel() ? TUNE.matchReach : 1);
|
||||
const win = TUNE.swingWindow * (volley ? VOLLEY.window : 1) * (isAI ? 1 : winLevel());
|
||||
const t = ch.swingT;
|
||||
if (t < TUNE.swingWindup || t > TUNE.swingWindup + win) return;
|
||||
if (t < TUNE.swingWindup || t > TUNE.swingWindup + win + (volley ? 0 : swingTail(isAI))) return; // the tail meets a BOUNCED ball
|
||||
if (!ball.active || G.phase !== 'rally') return;
|
||||
// splatter: your OWN ball is fair game. It rebounds off the backstop and comes back at you —
|
||||
// volleying it is how you keep firing without walking it down. (The duel is tennis: one touch.)
|
||||
@@ -1681,7 +1704,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
|
||||
// bounce frame (his v367 log, 8.98 s).
|
||||
if (letBounce()) {
|
||||
const gg = ball.g || TUNE.gravity, tb = (ball.vy + Math.sqrt(ball.vy * ball.vy + 2 * gg * ball.y)) / gg;
|
||||
if (t + tb < TUNE.swingWindup + TUNE.swingWindow * winLevel() - 0.03) return;
|
||||
if (t + tb < TUNE.swingWindup + TUNE.swingWindow * winLevel() + swingTail(isAI) - 0.03) return;
|
||||
}
|
||||
// quality: how centered in the window the contact lands
|
||||
const center = TUNE.swingWindup + win / 2;
|
||||
@@ -1697,6 +1720,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
|
||||
if (rallyFeel() && !isAI && TUNE.qSeamless && half > p)
|
||||
q = Math.max(0.05, 1 - (Math.abs(t - center) - p) / (half - p));
|
||||
if (Math.abs(t - center) * 1000 < TUNE.perfectMs) q = 1;
|
||||
if (t > TUNE.swingWindup + win) q = 0.05; // the follow-through: met, barely
|
||||
ch.facing = dx >= 0 ? 1 : -1; // forehand right (righty), backhand left → mirrored sprite
|
||||
// the LIVE swipe outranks the released one: since the crossing fires the swing (input.js), the
|
||||
// finger is usually still down at contact, and the vector under it NOW is the aim the player
|
||||
@@ -1708,7 +1732,9 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
|
||||
// makes a hard CPU feel like it is PLAYING you. ax is an absolute court lane (see doHit).
|
||||
const smartChance = G.diff ? (G.diff.smart || 0) : G.match && !G.net ? (ch.foe?.smart || 0) : 0;
|
||||
// Keep the old random sequence for classic and for foes without smart aim.
|
||||
const smart = isAI && (G.diff || smartChance) && Math.random() < smartChance;
|
||||
// THE SITTER IS PUNISHED: rolled first and only on a sitter, so every other ball keeps its old sequence
|
||||
const kill = isAI && punishes(ch);
|
||||
const smart = kill || isAI && (G.diff || smartChance) && Math.random() < smartChance;
|
||||
const aim = isAI
|
||||
? { ax: smart ? (P.x > 0.6 ? -1 : P.x < -0.6 ? 1 : (Math.random() < 0.5 ? -1 : 1))
|
||||
: [ -1, 0, 1 ][Math.floor(Math.random() * 3)],
|
||||
@@ -1717,7 +1743,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
|
||||
// THE STYLES (step 2): what this body DOES with the ball. A lob when you are at the net, a drop
|
||||
// when you hang back and it is inside its own court, a moonball as a habit — otherwise the rally
|
||||
// ball it always hit, carrying its spin. One roll, so the shares are exactly the table's.
|
||||
const st = isAI ? styleOf(ch) : null;
|
||||
const st = isAI && !kill ? styleOf(ch) : null;
|
||||
if (st) {
|
||||
const atNet = Math.abs(P.z - COURT.netZ) < TUNE.netFrom, r = Math.random();
|
||||
const inside = Math.abs(ch.z - COURT.netZ) < COURT.netZ - 1.5;
|
||||
@@ -1730,15 +1756,19 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
|
||||
// luckiest returns. Classic and solo match traits own their bands; gore keeps its own floor.
|
||||
const qMin = G.diff ? G.diff.qMin : G.match && !G.net ? (ch.foe?.qMin ?? TUNE.aiQualityMin) : TUNE.aiQualityMin;
|
||||
if (isAI) q = Math.min(q, qMin + Math.random() * (Math.max(qCap, qMin) - qMin));
|
||||
if (kill) q = Math.max(q, TUNE.punishQ);
|
||||
const q0 = q; // (the log names whichever cap below took it down)
|
||||
// a volley struck BELOW THE TAPE can only be blocked up: soft, short, little width. This is what
|
||||
// the net costs — the passing shot dipped at your feet. ...and a DROP dug out at the ankles after
|
||||
// its bounce comes back the same way: reaching it is not enough. Backspin balls only — any ball met
|
||||
// on its first frame in reach is often low, and capping those made every rally ball a sitter.
|
||||
if (rallyFeel() && (ball.bounces === 0 && ball.y < COURT.netH || ball.spin < 1 && ball.y < TUNE.scoopY)) q = Math.min(q, TUNE.lowVolleyQ);
|
||||
// ...and SOFT backspin balls only (step 4): step 2's slicer hits every rally ball at spin 0.8, so this
|
||||
// capped every low contact against him, his own included — invisible until a sitter got punished.
|
||||
if (rallyFeel() && (ball.bounces === 0 && ball.y < COURT.netH || ball.soft && ball.spin < 1 && ball.y < TUNE.scoopY)) q = Math.min(q, TUNE.lowVolleyQ);
|
||||
// ...and a LOBBED net man who ran it down comes back on his heels: a defensive ball, not a reply.
|
||||
// That is the lob's real reward — rarely the winner itself, nearly always the point.
|
||||
if (rallyFeel() && ball.rx && ball.rx.lob) q = Math.min(q, TUNE.lowVolleyQ);
|
||||
if (kill && q >= TUNE.punishQ) aim.kill = true; // a put-away only if no cap took it down (a low volley is a block)
|
||||
if (!isAI && swingLog()) {
|
||||
G.swingSnaps = null; G.rallyNear = null;
|
||||
const kind = aim.az > 0 && rallyFeel() ? (aim.mag === undefined || aim.mag >= TUNE.dropMax ? ' lob' : ' drop')
|
||||
@@ -2271,7 +2301,11 @@ export function update(dt) {
|
||||
const chasing = ball.lastHit === 'O' || (G.mode === 'splatter' && !G.interlude && ball.vz > 0);
|
||||
// no `slow` here: tapping must not freeze your legs, or anticipating a shot loses it
|
||||
// ...and lobbed at the net he backpedals, exactly as slowly as the CPU does (TUNE.lobTurn)
|
||||
const rate = TUNE.playerSpeed * aK * dt * (chasing ? 1 : 0.55) * (chasing && ball.rx && ball.rx.lob ? TUNE.lobTurn : 1);
|
||||
let rate = TUNE.playerSpeed * aK * dt * (chasing ? 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
|
||||
// started on the strike's frame with a perfect read and covered the whole court — no put-away ever
|
||||
// won a point, and the CPU still had no way to take one
|
||||
if (chasing && ball.heels > 0) { ball.heels -= dt; rate = 0; }
|
||||
// Run to the ball — then recover. WHERE you recover to is mode-specific and each rule exists
|
||||
// for a reason:
|
||||
// GORE SPLATTER recovers in DEPTH ONLY, holding your lane — recovering to HOME.P's x undid
|
||||
@@ -2368,7 +2402,7 @@ export function update(dt) {
|
||||
// 0.24 s and closes TUNE.parryWindow (0.34 s) before it has had its chance
|
||||
const win = TUNE.swingWindow * ((shots.length || (ball.active && ball.bounces === 0)) ? VOLLEY.window : 1)
|
||||
* winLevel(); // the clock must outlive the widened window or the level's gift is dead time
|
||||
if (P.swingT > TUNE.swingWindup + win + 0.02) { P.swingT = -1; if (G.phase === 'rally') swish(); logWhiff(); } // log even if the point ended mid-swing
|
||||
if (P.swingT > TUNE.swingWindup + Math.max(win, TUNE.swingWindow * winLevel() + swingTail(false)) + 0.02) { P.swingT = -1; if (G.phase === 'rally') swish(); logWhiff(); } // log even if the point ended mid-swing
|
||||
}
|
||||
expirePose(P, dt);
|
||||
|
||||
|
||||
+1
-1
@@ -75,7 +75,7 @@ export function netSendShot() {
|
||||
// the opponent struck: their ball, mirrored, becomes the live ball — and the authority flips here
|
||||
export function netApplyShot(m) {
|
||||
const s = mirror(m);
|
||||
ball.active = true; ball.collected = false; ball.bounces = 0; ball.rx = null; ball.approach = null;
|
||||
ball.active = true; ball.collected = false; ball.bounces = 0; ball.rx = null; ball.heels = 0; ball.approach = null;
|
||||
ball.x = s.x; ball.y = m.y; ball.z = s.z;
|
||||
ball.vx = s.vx; ball.vy = m.vy; ball.vz = s.vz;
|
||||
ball.g = m.g; ball.pow = m.pow; ball.spin = m.spin || 1; // the soft ball's backspin: both phones bounce it alike
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ import { gore } from './zombies.js'; // runtime-only cycle (zombies imports stat
|
||||
export const P = { x: 1.3, z: 22.8, side: 'P', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 };
|
||||
export const O = { x: -1.3, z: 1.0, side: 'O', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 };
|
||||
export const ball = { x: 0, y: 1, z: 20, vx: 0, vy: 0, vz: 0, g: 9.2,
|
||||
active: false, lastHit: 'P', bounces: 0, rx: null, approach: null, spin: 1, soft: false, collected: false, kills: 0, bomb: false, flame: false,
|
||||
active: false, lastHit: 'P', bounces: 0, rx: null, heels: 0, approach: null, spin: 1, soft: false, collected: false, kills: 0, bomb: false, flame: false,
|
||||
// NETPLAY: THE CALLED BALL PLAYS OUT. A verdict used to freeze the ball where it was — on the
|
||||
// striker's phone that is mid-roll, a whole court away from the call, and it read as the game
|
||||
// loop stopping. `scored` means "this flight is decided": physics keeps running, every scoring
|
||||
@@ -206,7 +206,7 @@ export const other = s => s === 'P' ? 'O' : 'P';
|
||||
export const deuceSide = () => (score.pts[0] + score.pts[1]) % 2 === 0; // even points → deuce (right) side
|
||||
|
||||
export function setupPoint() {
|
||||
G.phase = 'pretoss'; ball.active = false; ball.bounces = 0; ball.scored = false; ball.rx = null; ball.approach = null;
|
||||
G.phase = 'pretoss'; ball.active = false; ball.bounces = 0; ball.scored = false; ball.rx = null; ball.heels = 0; ball.approach = null;
|
||||
G.swingSnaps = null; G.rallyNear = null; G.swung = false;
|
||||
if (G.mode === 'splatter' && !G.match) { // always your serve, from wherever you're standing
|
||||
// (the MATCH falls through to the tennis branch below:
|
||||
|
||||
Reference in new issue
Block a user