Let the player take the net and come back, and smash a lob he gets under
Oliver: "Steuerung ans Netz gehen", option (1), and "einen Lob vom Gegner als Volley annehmen soll derb schmettern". - A long up-flick (90 px or more) is the attack: the deep ball, and he follows it in. A long down-flick is the lob, and he goes back behind it. Short flicks keep the deep ball / the drop and leave him where he is. Length only, never context. - The net costs him what it costs the CPU: he split-steps (netReact) before reading the pass, and a CPU facing a net man passes at the line. Without both, the attacker won 78-95% of his net points. - The smash: a ball taken out of the air at 1.9-2.5 m is struck harder and to the open court, read late by the opponent, and pops "SMASH!". It wins ~80% of its points. - Lobbed at the net, he decides once whether he can get under it. If he can, he smashes. If not, he runs it down and only blocks it back, even if he meets it in the air. Re-deciding every frame smashed every lob. Measured at level 26: the base ladder is unchanged. Attacking pays against pushers and touch players and costs against big hitters, slicers and rushers. Three new tests; v374. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4406aeafdb
commit
599d5b0506
8 files changed
+201
-17
No files matched your search
+52
-1
@@ -5627,6 +5627,55 @@ Oliver: *"b"* (options above). Two halves, each useless alone (both were measure
|
||||
the air legitimately), and synthetic returns in the suite now carry `pow: 1` (a stale `pow` from the
|
||||
previous test read as a sitter).
|
||||
|
||||
### The player's way to the net and back, and the SMASH (2026-10-14, v374)
|
||||
Oliver: *"Steuerung ans Netz gehen, wie machen wir das?"* Offered: (1) a long up-flick is the attack and he
|
||||
follows it in, (2) a separate up-swipe while his own ball flies, (3) automatic after a strong ball (breaks
|
||||
the one-meaning rule). He picked **(1)**, asked *"wie kommt man wieder zurück?"*, and added *"wenn man einen
|
||||
Lob vom Gegner als Volley annimmt, soll man derb schmettern"*.
|
||||
- **The gestures, by LENGTH only** (never context — the 2026-08-22 lob rule):
|
||||
- up, short (< `attackMin` 90 px): the deep ball, you stay;
|
||||
- up, long: **the attack**, the deep ball and you follow it in (`ball.approach = 'P'`), even onto a
|
||||
man at the net — it is his call;
|
||||
- down, short: the drop, you stay (a drop volley keeps the net);
|
||||
- down, long: **the lob, and you go back behind it** (`ball.retreat`, `rallyHome`). That is the way back.
|
||||
It is also automatic when you are lobbed and cannot get under it, and when the point ends.
|
||||
Keys: up = attack, down = lob, as before. Both of your own runs go at full pace, and while the attack
|
||||
flies `P.rushing` makes the reply a net man's plan from wherever he has got to (the CPU's rule).
|
||||
- **The net has a price, as the CPU's does.**
|
||||
- At the net (or rushing) the player split-steps for `netReact` (0.22 s, `ball.heels`) before he reads
|
||||
the pass.
|
||||
- The CPU, facing a net man and not lobbing, plays the PASS at the line away from him (`aim.pass`,
|
||||
`punishX`, still bounded by the rung's width).
|
||||
- Measured without both: an attacker won 78-95% of his net points. His volley reach is ~2.5 m and he
|
||||
read instantly.
|
||||
- **THE SMASH** (rally modes, player):
|
||||
- A ball taken OUT OF THE AIR at `smashMinY` 1.9 m or higher, up to `smashMaxY` 2.5 (above
|
||||
`CONTACT.maxY` 2.3, but under the ~2.8 m a CPU lob clears a net man by).
|
||||
- Extra pace `smashPace` 0.8 × q on top of the forward flick's. Aimed at the line (`punishX`) on the
|
||||
flicked side, else away from the body. The opponent reads it for `smashRead` 0.3 s. It pops "SMASH!",
|
||||
and the log says `HIT q=1 SMASH`.
|
||||
- Aimed deep down the middle (the first cut), it came back to the baseliner and won ~40%. At the line it
|
||||
wins ~80%. The ring shows it (`ringCue`'s volley band is the smash band).
|
||||
- **Under the lob, or not — DECIDED ONCE** (`plan.over`, latched with the plan). When the player at the net
|
||||
is lobbed, `intercept` looks for the first point the lob comes down into the smash band that he reaches
|
||||
at `smashRun` 0.25 of his legs. Found: he goes there and smashes. Not found: he turns and runs it down,
|
||||
and whatever he meets on that run (in the air included) is a capped defensive ball, not a smash.
|
||||
- Re-asked every frame, his backpedal brought the overhead into reach on the way, and every lob was
|
||||
smashed.
|
||||
- Measured (attacker, rungs 7/14/18, 3 seeds): smashRun 0.35 → 131 smashes for 141 lobs; 0.25 → 62 for
|
||||
130, won 82%; 0.18 → 49.
|
||||
- **Measured** (Climb, level 26, σ60, 8 seeds; `ATTACK=0.3` = the bot plays 30% of its baseline balls as
|
||||
the attack): the base ladder is unchanged within the noise. Attacking is a real choice:
|
||||
- better against the pushers and touch players (Steward 49 → 60, Mascot 42 → 49, the bottom rungs);
|
||||
- worse against the big hitters, slicers and rushers (Roller 66 → 57, Seller 43 → 37, Club Pro 53 → 47);
|
||||
- level against the Champion (33 / 28).
|
||||
Points get short (4-6 shots).
|
||||
- Tests (170, nine seeds): the long up-flick follows the ball in and the short one stays; the long
|
||||
down-flick from the net goes back and a drop volley holds the net; a short lob is smashed hard into the
|
||||
open court and a deep lob is not (blocked back if reached).
|
||||
- `tools/rally-sim.mjs`: `ATTACK=0.3`, `NETX=1` (attacks, lobs faced at the net, smashes, smashes won), and
|
||||
`T_<knob>=<value>` sets any TUNE dial for a sweep.
|
||||
|
||||
# OPEN — leftovers, in rough priority order
|
||||
|
||||
Nothing here is broken; these are known gaps and unverified risks.
|
||||
@@ -5637,7 +5686,9 @@ Nothing here is broken; these are known gaps and unverified risks.
|
||||
opponent styles (v364) followed; the swing-log fixes ran to v371. Step 4: v372 measured why the ladder
|
||||
could not climb (points decided by the player's whiffs); Oliver picked (b), shipped v373 (the follow-
|
||||
through, put-aways, on his heels). Awaiting the phone verdict on v373, especially the half-second
|
||||
flat-footed read and the net rushers. See "Step 4 (b)" above. Fallback tag `rally-baseline-v360`.
|
||||
flat-footed read and the net rushers. See "Step 4 (b)" above.
|
||||
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. 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.
|
||||
|
||||
+1
-1
@@ -1140,6 +1140,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
</div>
|
||||
</div>
|
||||
<span id="recLamp" hidden>REC</span>
|
||||
<script type="module" src="src/main.js?v=373"></script>
|
||||
<script type="module" src="src/main.js?v=374"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -107,6 +107,16 @@ 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
|
||||
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)
|
||||
smashPace: 0.8, // ...extra pace on top of the forward flick's, scaled by timing
|
||||
smashRead: 0.3,
|
||||
smashRun: 0.25, // share of his legs a lobbed net man may need to get UNDER the lob for the smash —
|
||||
// a short lob is smashed, a deep one is not (he turns and runs it down, lobTurn) // s the opponent stands reading a smash before he moves
|
||||
softRead: 0.35, // s any opponent stands reading a soft ball (drop, lob) — they are disguised
|
||||
aimError: 2.4, // m of scatter at quality 0
|
||||
aimTrust: 0.3, // an aimed rally shot TRUSTS its flick: the scatter may still push the
|
||||
|
||||
+53
-12
@@ -270,7 +270,13 @@ function doHit(ch, quality, aim) {
|
||||
}
|
||||
// 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;
|
||||
if (aim && (aim.kill || aim.pass)) tx = (aim.ax || (P.x > 0 ? -1 : 1)) * TUNE.punishX + err() * 0.3;
|
||||
// ...and the player's SMASH goes for the open court the same way: his side if he flicked one, else
|
||||
// away from the body across the net. Down the middle, deep, it came straight back to the baseliner
|
||||
if (ch.side === 'P' && aim && aim.smash) {
|
||||
const foe = G.match ? zombies.find(z => z.variant === 'r') : O;
|
||||
tx = (sway ? Math.sign(sway) : foe && foe.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).
|
||||
@@ -304,6 +310,8 @@ function doHit(ch, quality, aim) {
|
||||
// still lands the ball on the same tz, so extra speed makes the shot flatter and earlier rather
|
||||
// than longer. A smash you can hit out on a whim would just be the bug above wearing a hat.
|
||||
if (ch.side === 'P' && aim.az < 0) s *= 1 + TUNE.smashBoost * quality;
|
||||
// ...and an OVERHEAD taken out of the air is the real smash (trySwing marks it): hit it hard
|
||||
if (ch.side === 'P' && aim.smash) s *= 1 + TUNE.smashPace * quality;
|
||||
aimedLane = true;
|
||||
}
|
||||
tx = Math.max(-5, Math.min(5, tx));
|
||||
@@ -385,9 +393,20 @@ 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.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
|
||||
ball.heels = aim && aim.kill ? TUNE.punishRead : 0; // a put-away: he is on his heels (see the auto-run)
|
||||
// ...and a player AT THE NET (or rushing it) split-steps before he reads the pass, exactly as the CPU's
|
||||
// net man does (netReact): with no read at all his 2.5 m volley reach made him unpassable
|
||||
if (ch.side !== 'P' && rallyFeel() && (Math.abs(P.z - COURT.netZ) < TUNE.netFrom || P.rushing)) ball.heels = Math.max(ball.heels, TUNE.netReact);
|
||||
// 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()) {
|
||||
@@ -617,7 +636,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.heels = 0; ball.spin = 1; ball.soft = false;
|
||||
ball.lastHit = sv.side; ball.bounces = 0; ball.rx = null; ball.heels = 0; ball.retreat = null; ball.smash = false; 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;
|
||||
@@ -1465,7 +1484,7 @@ const letBounce = () => ball.bounces === 0 && ball.y < COURT.netH && ball.rx &&
|
||||
// ...and where he recovers to is where he IS: at the net he holds the net, otherwise he goes back
|
||||
// behind his baseline. Read live — recovery always moves him away from netFrom, so it cannot dither.
|
||||
// The one exception is the APPROACH: having taken a short ball, he follows it in.
|
||||
const rallyHome = ch => ({ x: 0, z: Math.abs(ch.z - COURT.netZ) < TUNE.netFrom || ball.approach === ch.side
|
||||
const rallyHome = ch => ({ x: 0, z: (Math.abs(ch.z - COURT.netZ) < TUNE.netFrom && ball.retreat !== ch.side) || ball.approach === ch.side
|
||||
? COURT.netZ + (ch.side === 'P' ? TUNE.netStand : -TUNE.netStand)
|
||||
: ch.side === 'P' ? COURT.L + TUNE.rallyBack : -TUNE.rallyBack });
|
||||
// Where a baseliner meets this ball: after the bounce, falling through rallyHit, or at the top of a
|
||||
@@ -1502,7 +1521,7 @@ function intercept(side) {
|
||||
// a ball that will clear a net man's racket is a LOB — he turns and runs it down (racketAI's rule)
|
||||
if (plan && plan.net && (ball.z - ch.z) * vz < 0) {
|
||||
const tc = (ch.z - z) / vz;
|
||||
if (tc > 0 && y + vy * tc - 0.5 * g * tc * tc > CONTACT.maxY) { plan.net = false; plan.lob = true; }
|
||||
if (tc > 0 && y + vy * tc - 0.5 * g * tc * tc > (side === 'P' && rallyFeel() ? TUNE.smashMaxY : CONTACT.maxY)) { plan.net = false; plan.lob = true; }
|
||||
}
|
||||
const back = plan && !plan.net; // a baseliner lets it bounce
|
||||
// ...and only where his legs may go: the player is held on screen (xLimit), and since the dip ends at
|
||||
@@ -1510,9 +1529,18 @@ function intercept(side) {
|
||||
// the edge of the frame and watched it pass (his v369 log, 65.77 s and 84.26 s)
|
||||
const xl = side === 'P' ? xLimit() : 99;
|
||||
let fallback = null, deepest = null, anyHit = null, rise = null, nb = ball.bounces;
|
||||
// ...but the PLAYER lobbed at the net first looks for the overhead: the first point the lob comes down
|
||||
// into his smash band (smashMaxY) that his backpedal reaches in time. A short lob is smashed; one he
|
||||
// cannot get under is run down after its bounce, as before, and his reply is capped.
|
||||
// DECIDED ONCE, when the lob is read (plan.over, latched like the plan itself): re-asked every frame, his
|
||||
// backpedal toward the run-down brought the overhead into reach on the way, and every lob was smashed
|
||||
if (side === 'P' && plan && plan.over) return plan.over;
|
||||
const smashing = side === 'P' && plan && plan.lob && plan.over === undefined;
|
||||
for (let i = 0; i < 300; i++) {
|
||||
vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt;
|
||||
if (y <= 0 && vy < 0) { y = 0; vy = -vy * bY(); vx *= bXZ(); vz *= bXZ(); nb++; g = gAfter(g); }
|
||||
if (smashing && nb === 0 && vy < 0 && y <= TUNE.smashMaxY - 0.2 && y >= TUNE.smashMinY && Math.abs(x) <= xl
|
||||
&& Math.hypot(x - ch.x, z - ch.z) / (spd * TUNE.smashRun) <= i * dt) return (plan.over = { x, z });
|
||||
// FROM THE BACK: meet it between its bounce and the next one — at the top or falling through
|
||||
// rallyHit+0.3, from as far as rallyBehind behind the line; a short ball the first moment it is
|
||||
// up off the grass. Only if nothing like that is reachable do the old candidates below apply.
|
||||
@@ -1542,6 +1570,7 @@ function intercept(side) {
|
||||
deepest = pt; // last resort: run at the deepest hittable point
|
||||
}
|
||||
}
|
||||
if (smashing) plan.over = null; // no overhead in reach: he runs it down
|
||||
const to = rise || fallback || deepest || anyHit || home; // zone points first: meeting deep is still better
|
||||
// a ball no point of which he can reach still gets a run — to the nearest spot he is allowed to stand
|
||||
return Math.abs(to.x) > xl ? { x: Math.sign(to.x) * xl, z: to.z } : to;
|
||||
@@ -1566,7 +1595,7 @@ export function ringCue() {
|
||||
for (let t = 0; t < 1.6; t += dt) {
|
||||
const volley = b === 0;
|
||||
const reach = TUNE.reach * (volley ? VOLLEY.reach : 1) * (rallyFeel() ? TUNE.matchReach : 1);
|
||||
const legal = y <= CONTACT.maxY && (y >= CONTACT.minY || vy > 0)
|
||||
const legal = y <= (volley && rallyFeel() ? TUNE.smashMaxY : CONTACT.maxY) && (y >= CONTACT.minY || vy > 0)
|
||||
&& !(volley && y < COURT.netH && ball.rx && !ball.rx.net);
|
||||
const k = togo > 0.01 ? Math.min(1, sp * t / togo) : 0;
|
||||
const xl = xLimit(), px = Math.max(-xl, Math.min(xl, P.x + (tg ? tg.x - P.x : 0) * k)), pz = P.z + (tg ? tg.z - P.z : 0) * k;
|
||||
@@ -1693,7 +1722,12 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
|
||||
const own = G.mode === 'splatter' && !G.interlude && ch.side === 'P';
|
||||
if (ball.lastHit === ch.side && !own) return;
|
||||
const dx = ball.x - ch.x, dz = ball.z - ch.z;
|
||||
if (Math.hypot(dx, dz * 0.8) > reach || ball.y > CONTACT.maxY) return;
|
||||
// THE SMASH: the player's overhead reaches higher than a volley, out of the air, in the rally modes
|
||||
const overhead = !isAI && volley && rallyFeel();
|
||||
if (Math.hypot(dx, dz * 0.8) > reach || ball.y > (overhead ? TUNE.smashMaxY : CONTACT.maxY)) return;
|
||||
// ...unless he only reached it TURNING AND RUNNING (a lob whose overhead was out of reach when he read
|
||||
// it): that one is met on the run, a defensive block like every run-down lob, however high
|
||||
const smash = overhead && ball.y >= TUNE.smashMinY && !(ball.rx && ball.rx.lob && !ball.rx.over);
|
||||
if (ball.y < CONTACT.minY && ball.vy <= 0) return; // a DROPPING ball below the ankles is past you — but a rising one is a half-volley, always scoopable
|
||||
// planned from the back, the swing WAITS for the bounce instead of a weak pick-up — but only while
|
||||
// the bounce will still land inside this window. If it will not, this is the last chance and the
|
||||
@@ -1739,7 +1773,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
|
||||
? { 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)],
|
||||
az: Math.random() < 0.25 ? -1 : 0 }
|
||||
: flick || { ax: axisX(), az: axisZ() * (ch.side === 'P' ? 1 : -1) };
|
||||
: flick ? { ...flick } : { ax: axisX(), az: axisZ() * (ch.side === 'P' ? 1 : -1) }; // a copy: the shot marks it (smash)
|
||||
// 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.
|
||||
@@ -1748,6 +1782,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
|
||||
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;
|
||||
if (atNet && r < (st.lob || 0)) { aim.az = 1; aim.soft = 1; }
|
||||
else if (atNet) { aim.ax = P.x > 0 ? -1 : 1; aim.pass = true; aim.spin = st.spin; } // the PASS: at the line, away from him
|
||||
else if (P.z > COURT.L - 0.3 && inside && r < (st.drop || 0)) { aim.az = 1; aim.soft = 0; }
|
||||
else if (!atNet && r < (st.loft || 0)) { aim.az = 1; aim.soft = 0.85; }
|
||||
else aim.spin = st.spin;
|
||||
@@ -1767,8 +1802,9 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
|
||||
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 (rallyFeel() && ball.rx && ball.rx.lob && !smash) q = Math.min(q, TUNE.lowVolleyQ); // ...unless he got UNDER it
|
||||
if (kill && q >= TUNE.punishQ) aim.kill = true;
|
||||
if (smash) { aim.smash = true; praisePop('SMASH!', ball.x, ball.y, ball.z); } // 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')
|
||||
@@ -1779,7 +1815,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
|
||||
// ...and WHICH WAY the timing missed: contact before the window's centre means the ball was
|
||||
// already there — the swing came LATE; after it, the swing came EARLY
|
||||
const off = Math.round((t - center) * 1000), timing = q0 >= 0.95 ? '' : off < 0 ? ` · late ${-off}ms` : ` · early ${off}ms`;
|
||||
logSwing({ result: 'HIT', reason: `q=${r2(q)}${kind}${lane}${volley ? ' volley' : ''}${timing} · ${P.flick ? 'flick' : 'tap'} ${P.tap || 0}ms${cap}`,
|
||||
logSwing({ result: 'HIT', reason: `q=${r2(q)}${smash ? ' SMASH' : kind}${lane}${volley && !smash ? ' volley' : ''}${timing} · ${P.flick ? 'flick' : 'tap'} ${P.tap || 0}ms${cap}`,
|
||||
d: r2(Math.hypot(dx, dz * 0.8)), y: r2(ball.y) });
|
||||
}
|
||||
doHit(ch, q, aim);
|
||||
@@ -1840,7 +1876,8 @@ function racketAI(rz, dt) {
|
||||
if (G.match) G.aiReactT += dt;
|
||||
// ...and a SOFT ball is read late from anywhere: until the strike it looks like any other swing,
|
||||
// which is the whole reason a drop or a lob wins points in the real game. Rallies are untouched.
|
||||
const reading = plan && (plan.net && G.aiReactT < TUNE.netReact || ball.soft && G.aiReactT < TUNE.softRead);
|
||||
const reading = plan && (plan.net && G.aiReactT < TUNE.netReact || ball.soft && G.aiReactT < TUNE.softRead
|
||||
|| ball.smash && G.aiReactT < TUNE.smashRead);
|
||||
const c = plan && !plan.net ? bounceContact(plan.short) : null;
|
||||
const netX = plan && plan.net ? ball.x + ball.vx * Math.max(0, (rz.z - ball.z) / ball.vz) : null;
|
||||
const tgtX = c ? c.x : netX ?? (pred && pred.x);
|
||||
@@ -2301,7 +2338,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)
|
||||
let rate = TUNE.playerSpeed * aK * dt * (chasing ? 1 : 0.55) * (chasing && ball.rx && ball.rx.lob ? TUNE.lobTurn : 1);
|
||||
// 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;
|
||||
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
|
||||
// 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
|
||||
|
||||
+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.heels = 0; ball.approach = null;
|
||||
ball.active = true; ball.collected = false; ball.bounces = 0; ball.rx = null; ball.heels = 0; ball.approach = null; ball.retreat = null; ball.smash = false;
|
||||
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, heels: 0, 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, retreat: null, smash: false, 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.heels = 0; ball.approach = null;
|
||||
G.phase = 'pretoss'; ball.active = false; ball.bounces = 0; ball.scored = false; ball.rx = null; ball.heels = 0; ball.approach = null; ball.retreat = null; ball.smash = false;
|
||||
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:
|
||||
|
||||
@@ -1486,6 +1486,79 @@ 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"
|
||||
// ... "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) {
|
||||
freshSplatter(); startMatch(); G.interlude.introT = 0;
|
||||
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15;
|
||||
try {
|
||||
const rz = oppOf();
|
||||
Object.assign(rz, { x: 3, z: -0.6, swingCool: 999, swingT: -1 });
|
||||
G.phase = 'rally'; G.wasServe = false;
|
||||
Object.assign(P, { x: 0, z: pz, swingT: TUNE.swingWindup + TUNE.swingWindow * winLevelNow() / 2 - DT });
|
||||
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: pz - 0.4, vx: 0, vy: -0.5, vz: 3, g: TUNE.gravity });
|
||||
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;
|
||||
} 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 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 SMASH: a lob taken out of the air is struck hard to the open court; a deep lob still beats him', () => {
|
||||
// the opponent's lob from its baseline, at the player standing at the net; a gold-ring tap, as a player would
|
||||
const lobAt = (tz, speed) => {
|
||||
freshSplatter(); startMatch(); G.interlude.introT = 0;
|
||||
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15;
|
||||
try {
|
||||
const zb = oppOf(); Object.assign(zb, { x: 2, z: 0.5, swingCool: 999 });
|
||||
G.phase = 'rally'; G.wasServe = false;
|
||||
Object.assign(P, { x: 0, z: COURT.netZ + TUNE.netStand, swingT: -1 });
|
||||
const v = solveShot(0, 1, 2, 0, tz, speed);
|
||||
Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 0, rx: null, spin: 1, soft: true, pow: 0.8,
|
||||
heels: 0, x: 0, y: 1, z: 2, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity });
|
||||
let tapped = false;
|
||||
for (let i = 0; i < 4 * 60 && ball.lastHit === 'O' && ball.active; i++) {
|
||||
zb.swingCool = 999;
|
||||
if (!tapped && ringCue()?.gold) { tapped = true; keyPressed('Space'); }
|
||||
update(DT);
|
||||
}
|
||||
return { hit: ball.lastHit === 'P', smash: ball.smash, q: ball.pow, pace: Math.hypot(ball.vx, ball.vz),
|
||||
land: ball.lastHit === 'P' ? predictLanding() : null };
|
||||
} finally { TUNE.autoAssist = assist; }
|
||||
};
|
||||
const short = lobAt(COURT.L - 6, 9), deep = lobAt(COURT.L - 1.5, 9);
|
||||
assert.ok(short.hit && short.smash, 'a short lob is smashed');
|
||||
assert.ok(short.q > 0.6 && short.pace > 30, `...hard: q ${short.q.toFixed(2)}, ${short.pace.toFixed(1)} m/s`);
|
||||
assert.ok(Math.abs(short.land.x) > 2.9, `...into the open court: lands at x ${short.land.x.toFixed(1)}`);
|
||||
assert.ok(!deep.smash, 'a deep lob is not smashed — he turns and runs it down');
|
||||
if (deep.hit) assert.ok(deep.q <= TUNE.lowVolleyQ + 1e-9, 'and only blocks it back');
|
||||
});
|
||||
|
||||
test('THE SWING LOG writes in the match too: a whiff, a hit with its tap, and why the point went', () => {
|
||||
// Oliver, 2026-10-14: "bitte bau nochmal die Funktion ein die protokolliert warum ich einen Ball nicht
|
||||
// bekommen habe". It was gated on G.mode === 'tennis' and the match runs as 'splatter' — the Climb
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// shape of a rally, not a win rate — the bot is not Oliver. See PROJECT.md "THE RALLY GOES FLAT".
|
||||
// Since step 4: BOT=human (default; JIT, BIAS ms), CLIMB=1, XP, WHY=1, PROBE=1, LOG=1|all, SUMMARY=1,
|
||||
// JSON=1 — see PROJECT.md "Step 4, measured first". Pool 8 seeds before reading a win rate.
|
||||
// ATTACK=0.3 (the long up-flick share), NETX=1 (net/smash tally), KILL=1 (put-aways), T_<knob>=<v> sets TUNE.
|
||||
// ponytail: the platform stubs are copied from test.js; fold this into a test once the rework lands.
|
||||
let _seed = (Number(process.env.SEED) || 20260811) >>> 0;
|
||||
Math.random = () => { // xorshift32 — small, fast, good enough for jitter
|
||||
@@ -91,8 +92,10 @@ 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 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 };
|
||||
const probe = [], kills = []; let kOpen = null, kpow = 1;
|
||||
const bot = { armed: false, fire: -1, jit: null, swipe: null };
|
||||
const sw = (dx, dy, mag) => { const n = Math.hypot(dx, dy) || 1, fx = dx / n, fy = dy / n;
|
||||
@@ -103,6 +106,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 (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
|
||||
@@ -171,6 +175,10 @@ for (const rung of RUNGS) {
|
||||
const lh = ball.lastHit;
|
||||
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 === '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];
|
||||
if (process.env.PROBE && lh === 'O' && ball.lastHit === 'P' && bot.land) { const L = bot.land; probe.push({ late: Math.round(((i - L.i) / 60 - L.eta) * 1000), sw: Math.round(L.sw * 1000), q: +ball.pow.toFixed(2), bAtLand: L.b, bAtHit: L.bb, d: +L.d.toFixed(2), y: +L.y.toFixed(2) }); bot.land = null; }
|
||||
if (ball.lastHit !== 'O') G.swipe = null;
|
||||
// KILL=1: every put-away (a q >= .95 CPU ball straight after a player ball under .5) — where it went,
|
||||
@@ -232,6 +240,7 @@ for (const rung of RUNGS) {
|
||||
rows.push({ rung, side: 'rally', shots: rallies.length, medDistNet: med(rallies), '<6.4m(svcline)%': '', '<3.5m(net)%': '', 'volley%': '', medTimeToHit_s: 'shots/pt mean ' + (rallies.reduce((a, b) => a + b, 0) / rallies.length).toFixed(1) });
|
||||
}
|
||||
if (!process.env.SUMMARY) { console.table(rows); console.table(land); }
|
||||
if (process.env.NETX) console.log(JSON.stringify(netx));
|
||||
if (process.env.KILL) { console.table(kills.slice(0, 30)); const l = kills.filter(k => k.got === 'LOST').length; console.log('put-aways', kills.length, 'won by CPU', l); }
|
||||
if (process.env.PROBE) { const l = probe.map(p => p.late).sort((a, b) => a - b); console.log("contact after predicted entry, ms: n", l.length, "mean", (l.reduce((a, b) => a + b, 0) / l.length).toFixed(1), "p10", l[l.length * 0.1 | 0], "p50", l[l.length / 2 | 0], "p90", l[l.length * 0.9 | 0]); }
|
||||
if (process.env.JSON) { console.log(JSON.stringify(pts)); process.exit(0); }
|
||||
|
||||
Reference in new issue
Block a user