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
+37
@@ -5286,6 +5286,43 @@ bekommen."* Two real defects, measured, and neither of them was a balance value:
|
||||
the rig guard (`> 7 m/s`) moved, to `> legKnee*0.8`. Rungs 1-7 are unchanged: the Steward (6) already
|
||||
ran 5.6. Ball PACE is untouched; it is at full pace times the league tier from rung 5.
|
||||
|
||||
### Step 3: THE SOFT BALL (2026-10-14, v363)
|
||||
**In rally modes the down-flick is SOFT, and its length is HOW FAR**: a flick of `flickMin` is a drop
|
||||
(`dropNet` 2 m past the net, low arc), a flick of `flickFull` or more is an attacking lob (`lobDeep` 1.6 m
|
||||
inside his line, `lobApex` 5.5 m), with a continuum of floaters in between. It is one gesture with one
|
||||
meaning everywhere, which is why it does not repeat the 2026-08-22 cut. Lateral sway still comes from
|
||||
the swipe's x. Keys take the lob. `aim.soft` (0..1) lets a caller without a swipe ask for one, which is
|
||||
the hook for step 2's opponent.
|
||||
- **Ballistics** are the cut lob's (4f4534e). The pace is derived from the apex, so the arc is the same
|
||||
from anywhere. There is no topspin. From the baseline the net-clearance loop lofts a drop to ~2.8 m,
|
||||
which is correct (a 2 m arc from there clips the tape).
|
||||
- **Mistiming pulls the target toward `softSitter`** (5.5 m, the service line): a drop that sits up, a
|
||||
lob that falls short. It never goes out by depth.
|
||||
- **Backspin: `ball.spin`.** A drop keeps `dropSpin` 0.3 of its pace through the bounce and (`spinLow`)
|
||||
half that loss in height too, so it dies. `bXZ()`/`bY()` are the ONE bounce rule. The physics,
|
||||
`intercept`, `bounceContact` and `matchOutOfReachFlight` all read them; a planner rolling the court
|
||||
numbers would predict a ball that is not there. Netplay sends `spin` with the shot, so both phones
|
||||
bounce it alike (an old build reads 1).
|
||||
- **What makes it WIN, measured against the CPU** (a well-timed bot, q >= 0.75). It was ~100% returned
|
||||
until four things went in, each of them real tennis:
|
||||
1. `softRead` 0.35 s: any opponent stands reading a soft ball, because it is disguised until contact.
|
||||
2. `netReact` 0.22 s: a net man reads every pass before moving. Without it nothing could pass him.
|
||||
3. `lobTurn` 0.6: a lobbed net man backpedals.
|
||||
4. Weak replies. A lobbed net man who runs it down (`rx.lob`) and a drop dug out below `scoopY`
|
||||
0.45 m (backspin balls only) are capped at `lowVolleyQ`.
|
||||
|
||||
A first cut capped EVERY low contact. Since contact fires on the first frame in reach, that turned
|
||||
every rally ball into a sitter and parked the player at the net, so it was reverted at once.
|
||||
- **Result:** a drop played from inside the court wins outright against the slower half of the ladder.
|
||||
Against the rest, a drop or a lob draws a defensive ball back (CPU reply q ~0.40-0.45 against 0.65
|
||||
after a drive) for the player to put away. Rally shape unchanged. A deep lob's high bounce is taken
|
||||
on the RISE when its fall would land past `rallyBehind` (`bounceContact`), otherwise it was unplayable.
|
||||
- Tests: drop/lob shape and the lob clearing a net man's racket by 0.5 m, mistimed toward the sitter,
|
||||
the disguise, the lobbed reply cap. 155, fuzzed over 8 seeds. Verified with real touch events in the
|
||||
pane: a short down-swipe is a drop with spin 0.38, a long one a spinless lob.
|
||||
- **Not done:** a lesson for the gesture (the ghost hand only teaches the side flick), and a "LOB"/"DROP"
|
||||
word at contact. The arc says it. Add one if the phone test shows nobody finds it.
|
||||
|
||||
# OPEN — leftovers, in rough priority order
|
||||
|
||||
Nothing here is broken; these are known gaps and unverified risks.
|
||||
|
||||
+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=362"></script>
|
||||
<script type="module" src="src/main.js?v=363"></script>
|
||||
</body>
|
||||
</html>
|
||||
+15
-1
@@ -87,11 +87,25 @@ export const TUNE = {
|
||||
// in and takes it on the rise (serves never count)
|
||||
netFrom: 7.0, // m from the net inside which a body is AT THE NET: it volleys, and it
|
||||
// recovers to netStand instead of the baseline
|
||||
netStand: 3.2, // m from the net a net player recovers to
|
||||
netStand: 2.6, // m from the net a net player recovers to
|
||||
rallyBack: 0.6, // m behind the baseline a baseliner recovers to
|
||||
rallyBehind: 2.2, // m behind the baseline he will drop back to let a deep ball come down
|
||||
rallyHit: 1.0, // m: a baseliner meets the ball falling through this height
|
||||
lowVolleyQ: 0.3, // quality cap on a volley struck below the tape: you can only block it up
|
||||
scoopY: 0.45, // m: a ball met lower than this after its bounce is dug out, same cap
|
||||
// THE SOFT BALL (step 3): the down-flick, its length = how far. Drop at one end, lob at the other.
|
||||
dropNet: 2.0, // m past the net a shortest down-flick lands
|
||||
dropApex: 2.0, // m its arc peaks at
|
||||
dropSpin: 0.3, // share of its pace a drop keeps through the bounce (backspin: it dies)
|
||||
spinLow: 0.5, // ...and how much of that loss backspin also takes off the rebound HEIGHT
|
||||
lobDeep: 1.6, // m inside his baseline a full-length down-flick lands
|
||||
lobApex: 5.5, // m its arc peaks at: an ATTACKING lob — over a net man's racket (CONTACT.maxY
|
||||
// 2.3) yet quick. A 7 m moonball hung 2.4 s and every net man ran it down
|
||||
softSitter: 5.5, // m from his baseline a badly timed soft ball drifts toward (a sitter)
|
||||
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
|
||||
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
|
||||
// ball further OUT (the chosen out-risk keeps its exact shape) but only
|
||||
|
||||
+74
-22
@@ -33,6 +33,13 @@ const strikeKick = () => {
|
||||
if (p > 0 && !G.shake) G.shake = { t: 0.12, mag: p * 2.92 }; // mag*t/0.35 = the pixels it starts at
|
||||
};
|
||||
|
||||
// How much of its horizontal pace a ball keeps through a bounce: the court's own number, times the
|
||||
// shot's SPIN (ball.spin, 1 = none). Only the soft ball sets it — a drop carries backspin and dies.
|
||||
// Every planner that rolls a bounce forward reads this, or it predicts a ball that is not there.
|
||||
const bXZ = () => TUNE.bounceXZ * (ball.spin || 1);
|
||||
// ...and backspin keeps it LOW as well: the vertical rebound gives up part of what the pace does
|
||||
const bY = () => TUNE.bounceY * (1 - TUNE.spinLow * (1 - (ball.spin || 1)));
|
||||
|
||||
export function solveShot(x0, y0, z0, tx, tz, s, g = TUNE.gravity, ty = 0.08) {
|
||||
const dx = tx - x0, dz = tz - z0, dist = Math.hypot(dx, dz), T = dist / s;
|
||||
return { vx: dx / T, vz: dz / T, vy: (ty - y0 + 0.5 * g * T * T) / T };
|
||||
@@ -193,7 +200,7 @@ function doHit(ch, quality, aim) {
|
||||
ball.x = ch.x + ch.facing * 0.5;
|
||||
ball.z = ch.z + (toFar ? -0.3 : 0.3);
|
||||
const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality);
|
||||
let tx, tz, aimedLane = false, sway = 0;
|
||||
let tx, tz, aimedLane = false, sway = 0, soft = null;
|
||||
let s = (TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed) * leaguePace();
|
||||
const ft = G.mode !== 'tennis' && !G.interlude && ch.side === 'P' // the duel aims like tennis: lanes, not free-aim
|
||||
? (aim && aim.fx !== undefined ? flickTarget(ch, aim) : { zt: zombieTarget(aim) }) : null;
|
||||
@@ -246,11 +253,26 @@ function doHit(ch, quality, aim) {
|
||||
// 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).
|
||||
const deep = rallyFeel() && aim.az <= 0;
|
||||
const tzBase = aim.az < 0 ? (deep ? TUNE.rallyDeep : SHOT_DEPTH.deep)
|
||||
: aim.az > 0 ? SHOT_DEPTH.short : deep ? TUNE.rallyMid : SHOT_DEPTH.mid;
|
||||
const dz = deep ? (1 - quality) * TUNE.shortK * (0.5 + Math.random()) : err() * 0.8;
|
||||
tz = deep ? (toFar ? tzBase + dz : COURT.L - tzBase - dz) : (toFar ? tzBase : COURT.L - tzBase) + dz;
|
||||
// THE SOFT BALL (step 3, Oliver's pick 2026-10-13): in a rally mode the DOWN-flick is soft, always,
|
||||
// and its LENGTH is how far — a short one is a drop just over the net, a long one a lob into the
|
||||
// back corner, the space between a continuum of floaters. One gesture, one meaning, whoever is
|
||||
// where: this is NOT the context-armed lob that was cut on 2026-08-22. Mistiming pulls it toward
|
||||
// softSitter — a drop that sits up, a lob that falls short: the easy ball either way.
|
||||
// `aim.soft` lets a caller with no swipe (the opponent, step 2) ask for one; keys take the lob.
|
||||
soft = rallyFeel() && aim.az > 0
|
||||
? (aim.soft ?? (aim.mag === undefined ? 1 : Math.min(1, Math.max(0, (aim.mag - TUNE.flickMin) / (TUNE.flickFull - TUNE.flickMin)))))
|
||||
: null;
|
||||
if (soft !== null) {
|
||||
const want = COURT.netZ - TUNE.dropNet + (TUNE.lobDeep - COURT.netZ + TUNE.dropNet) * soft; // from his baseline
|
||||
const d = want + (TUNE.softSitter - want) * (1 - quality) * TUNE.softErr;
|
||||
tz = toFar ? d : COURT.L - d;
|
||||
} else {
|
||||
const deep = rallyFeel() && aim.az <= 0;
|
||||
const tzBase = aim.az < 0 ? (deep ? TUNE.rallyDeep : SHOT_DEPTH.deep)
|
||||
: aim.az > 0 ? SHOT_DEPTH.short : deep ? TUNE.rallyMid : SHOT_DEPTH.mid;
|
||||
const dz = deep ? (1 - quality) * TUNE.shortK * (0.5 + Math.random()) : err() * 0.8;
|
||||
tz = deep ? (toFar ? tzBase + dz : COURT.L - tzBase - dz) : (toFar ? tzBase : COURT.L - tzBase) + dz;
|
||||
}
|
||||
// A FORWARD FLICK IS A SMASH. Up-screen is the far court, so flicking INTO the court asked for
|
||||
// depth and nothing else — the gesture had a direction with no consequence behind it. It buys
|
||||
// PACE now, scaled by timing like the width is. Note what it deliberately does not do: solveShot
|
||||
@@ -304,8 +326,18 @@ function doHit(ch, quality, aim) {
|
||||
G.placeMark = { x: tx, z: tz, t: 0.8, q: quality };
|
||||
}
|
||||
// topspin: quality shots dive under extra effective gravity, which is what lets real
|
||||
// pace arc over the net and still land in — the core tennis trade-off
|
||||
const g = TUNE.gravity * (1 + quality * TUNE.spinFactor);
|
||||
// pace arc over the net and still land in — the core tennis trade-off. The soft ball carries none.
|
||||
const g = soft !== null ? TUNE.gravity : TUNE.gravity * (1 + quality * TUNE.spinFactor);
|
||||
// ...and its PACE IS DERIVED FROM ITS HEIGHT (the cut lob's ballistics, 4f4534e): to peak at
|
||||
// `apex` it must leave at vy = sqrt(2g(apex - y)), which fixes the hang time, and the ground still
|
||||
// to cover fixes the speed — the same arc from the baseline and from the net. After the
|
||||
// opponent's pace dials on purpose: a floater is a floater from any racket.
|
||||
if (soft !== null) {
|
||||
const apex = TUNE.dropApex + (TUNE.lobApex - TUNE.dropApex) * soft;
|
||||
const vy0 = Math.sqrt(2 * TUNE.gravity * Math.max(0.5, apex - ball.y));
|
||||
const hang = (vy0 + Math.sqrt(vy0 * vy0 + 2 * TUNE.gravity * Math.max(0, ball.y - 0.08))) / TUNE.gravity;
|
||||
s = Math.hypot(tx - ball.x, tz - ball.z) / hang;
|
||||
}
|
||||
let v = solveShot(ball.x, ball.y, ball.z, tx, tz, s, g);
|
||||
// low contact → trade pace for loft until the net is cleared; good timing gets a
|
||||
// comfortable margin, sloppy timing keeps some tape risk. Same crossesNet guard as serveArc's,
|
||||
@@ -321,6 +353,9 @@ function doHit(ch, quality, aim) {
|
||||
}
|
||||
ball.g = g;
|
||||
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
|
||||
// a drop carries backspin and DIES after its bounce; it fades out across the soft range to a lob
|
||||
ball.spin = soft === null ? 1 : 1 - (1 - TUNE.dropSpin) * Math.max(0, 1 - soft * 2);
|
||||
ball.soft = soft !== null; // ...and it is DISGUISED: racketAI reads it late (TUNE.softRead)
|
||||
// ...and a short ball taken is an APPROACH: he follows it in (rallyHome reads this while it flies).
|
||||
// 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.
|
||||
@@ -542,7 +577,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.approach = null; ball.from = Math.abs(sv.z - COURT.netZ);
|
||||
ball.lastHit = sv.side; ball.bounces = 0; ball.rx = null; ball.approach = null; ball.spin = 1; ball.soft = false; ball.from = Math.abs(sv.z - COURT.netZ);
|
||||
ball.pow = quality;
|
||||
ball.kills = 0; // fresh serve: multi-kill streak restarts
|
||||
ball.praise = null; // ...and any word a spent ball never got to pop dies with it
|
||||
@@ -1213,7 +1248,10 @@ const rzRawSpeed = rz => TUNE.racketSpeed * leaguePace()
|
||||
// wrong-footed: a body mid-lean is slow to turn — THE READ's one-ball cost, and it lives HERE
|
||||
// so the intercept planner and the reach tests believe it too (a surprised champion plans like
|
||||
// a body that genuinely cannot get there, which is the whole point)
|
||||
* (G.match && G.match.surprised ? 1 - TUNE.surpSlow * learnK(rz) : 1);
|
||||
* (G.match && G.match.surprised ? 1 - TUNE.surpSlow * learnK(rz) : 1)
|
||||
// ...and a net man who has been LOBBED turns and runs backwards: real legs do that slowly, and it is
|
||||
// what makes the lob the answer to the net (the planner believes it too)
|
||||
* (ball.rx && ball.rx.lob ? TUNE.lobTurn : 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
|
||||
@@ -1240,8 +1278,8 @@ function matchOutOfReachFlight(rz, pred) {
|
||||
const g = ball.g || TUNE.gravity;
|
||||
const spd = rzChaseSpeed(rz);
|
||||
const vyl = ball.vy - g * pred.t; // vertical speed at touchdown
|
||||
const t2 = Math.max(0.05, 2 * (-vyl * TUNE.bounceY) / g);
|
||||
const vx2 = ball.vx * TUNE.bounceXZ, vz2 = ball.vz * TUNE.bounceXZ;
|
||||
const t2 = Math.max(0.05, 2 * (-vyl * bY()) / g);
|
||||
const vx2 = ball.vx * bXZ(), vz2 = ball.vz * bXZ();
|
||||
const T = pred.t + t2;
|
||||
for (let i = 0; i <= 10; i++) {
|
||||
const t = T * i / 10;
|
||||
@@ -1394,12 +1432,15 @@ function bounceContact(short) {
|
||||
if (ball.bounces === 0) {
|
||||
const p = predictLanding(); if (!p) return null;
|
||||
x = p.x; z = p.z; y = 0;
|
||||
vy = -(vy - g * p.t) * TUNE.bounceY; vx *= TUNE.bounceXZ; vz *= TUNE.bounceXZ;
|
||||
vy = -(vy - g * p.t) * bY(); vx *= bXZ(); vz *= bXZ();
|
||||
}
|
||||
const apex = y + Math.max(0, vy) ** 2 / (2 * g), h = Math.min(TUNE.rallyHit, apex);
|
||||
const r = Math.sqrt(Math.max(0, vy * vy - 2 * g * (h - y)));
|
||||
const t = Math.max(0, (short && vy > 0 ? vy - r : vy + r) / g);
|
||||
return { x: x + vx * t, z: z + vz * t };
|
||||
const at = t => ({ x: x + vx * t, z: z + vz * t });
|
||||
const fall = at(Math.max(0, (vy + r) / g)), rise = at(Math.max(0, (vy - r) / g));
|
||||
// a ball that will only come down past rallyBehind (a deep lob's high bounce) is taken on the RISE
|
||||
// instead — waiting for it would mean standing in the stand, and above CONTACT.maxY it is unplayable
|
||||
return short && vy > 0 || fall.z < -TUNE.rallyBehind ? rise : fall;
|
||||
}
|
||||
|
||||
function intercept(side) {
|
||||
@@ -1415,7 +1456,7 @@ function intercept(side) {
|
||||
let fallback = null, deepest = null, anyHit = null, rise = null, nb = ball.bounces;
|
||||
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 * TUNE.bounceY; vx *= TUNE.bounceXZ; vz *= TUNE.bounceXZ; nb++; }
|
||||
if (y <= 0 && vy < 0) { y = 0; vy = -vy * bY(); vx *= bXZ(); vz *= bXZ(); nb++; }
|
||||
// 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.
|
||||
@@ -1544,8 +1585,13 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
|
||||
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));
|
||||
// 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.
|
||||
if (rallyFeel() && ball.bounces === 0 && ball.y < COURT.netH) q = Math.min(q, TUNE.lowVolleyQ);
|
||||
// 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 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 (!isAI && G.mode === 'tennis') {
|
||||
G.swingSnaps = null; G.rallyNear = null;
|
||||
logSwing({ result: 'HIT', reason: `contact q=${Math.round(q * 100) / 100}`, d: Math.round(Math.hypot(dx, dz * 0.8) * 100) / 100, y: Math.round(ball.y * 100) / 100 });
|
||||
@@ -1599,8 +1645,14 @@ function racketAI(rz, dt) {
|
||||
const plan = G.match && incoming ? rxPlan(rz) : null;
|
||||
if (plan && plan.net && ball.vz < 0) {
|
||||
const tc = (rz.z - ball.z) / ball.vz, gg = ball.g || TUNE.gravity;
|
||||
if (tc > 0 && ball.y + ball.vy * tc - 0.5 * gg * tc * tc > CONTACT.maxY) plan.net = false;
|
||||
if (tc > 0 && ball.y + ball.vy * tc - 0.5 * gg * tc * tc > CONTACT.maxY) { plan.net = false; plan.lob = true; }
|
||||
}
|
||||
// ...and AT THE NET he needs a moment to read the pass: a volleyer reacts, he does not pre-know.
|
||||
// Without it a body with these legs covered every passing lane the instant the ball left.
|
||||
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 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);
|
||||
@@ -1612,7 +1664,7 @@ function racketAI(rz, dt) {
|
||||
: pred ? (G.match ? pred.z : ball.z + (pred.z - ball.z) * read) : ball.z;
|
||||
const tz = Math.max(G.match ? -TUNE.rallyBehind : 1.5, Math.min(G.match ? COURT.netZ - 1.2 : COURT.svcFar, aimZ));
|
||||
const dx = aimX - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz);
|
||||
if (d > 0.15) {
|
||||
if (d > 0.15 && !reading) {
|
||||
// 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
|
||||
@@ -2307,8 +2359,8 @@ export function update(dt) {
|
||||
const back = dt * (1 - yOld / (yOld - ball.y)); // time spent below the ground this step
|
||||
ball.x -= ball.vx * back; ball.z -= ball.vz * back;
|
||||
}
|
||||
ball.y = 0; ball.vy = -ball.vy * TUNE.bounceY;
|
||||
ball.vx *= TUNE.bounceXZ; ball.vz *= TUNE.bounceXZ;
|
||||
ball.y = 0; ball.vy = -ball.vy * bY();
|
||||
ball.vx *= bXZ(); ball.vz *= bXZ();
|
||||
if (Math.abs(ball.vy) > 1.0) dustPuff(ball.x, ball.z, 3, 0.7); // a real bounce kicks up the court
|
||||
onBounce();
|
||||
if (ball.active && Math.abs(ball.vy) < 0.8) { // rolled out — dead ball
|
||||
|
||||
+3
-2
@@ -69,7 +69,7 @@ export function netSendShot() {
|
||||
if (!G.net || !G.net.ready) return;
|
||||
send({ t: 'shot', x: r3(ball.x), y: r3(ball.y), z: r3(ball.z),
|
||||
vx: r3(ball.vx), vy: r3(ball.vy), vz: r3(ball.vz),
|
||||
g: r3(ball.g), pow: r3(ball.pow || 0), serve: !!G.wasServe });
|
||||
g: r3(ball.g), pow: r3(ball.pow || 0), spin: r3(ball.spin || 1), serve: !!G.wasServe });
|
||||
}
|
||||
|
||||
// the opponent struck: their ball, mirrored, becomes the live ball — and the authority flips here
|
||||
@@ -78,7 +78,8 @@ export function netApplyShot(m) {
|
||||
ball.active = true; ball.collected = false; ball.bounces = 0; ball.rx = null; 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.g = m.g; ball.pow = m.pow; ball.spin = m.spin || 1; // the soft ball's backspin: both phones bounce it alike
|
||||
ball.soft = false;
|
||||
ball.lastHit = 'O';
|
||||
ball.bomb = false; ball.flame = false; ball.wid = 'ball'; ball.chain = 0; ball.praise = null; ball.kills = 0;
|
||||
G.wasServe = !!m.serve; // arms the receiver's own service-box call
|
||||
|
||||
+1
-1
@@ -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, collected: false, kills: 0, bomb: false, flame: false,
|
||||
active: false, lastHit: 'P', bounces: 0, rx: null, 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
|
||||
|
||||
@@ -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