diff --git a/PROJECT.md b/PROJECT.md index 74f4c8e..d6b03a8 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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. diff --git a/index.html b/index.html index 6a34c97..5ea265f 100644 --- a/index.html +++ b/index.html @@ -1140,6 +1140,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/config.js b/src/config.js index 015cbf4..04a9912 100644 --- a/src/config.js +++ b/src/config.js @@ -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 diff --git a/src/gameplay.js b/src/gameplay.js index ff75075..e266e04 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -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 diff --git a/src/net.js b/src/net.js index 725d382..8f9aa1f 100644 --- a/src/net.js +++ b/src/net.js @@ -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 diff --git a/src/state.js b/src/state.js index 7c29b78..fab6d43 100644 --- a/src/state.js +++ b/src/state.js @@ -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 diff --git a/test.js b/test.js index ce60b70..38f4c40 100644 --- a/test.js +++ b/test.js @@ -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;