diff --git a/PROJECT.md b/PROJECT.md index d6b03a8..01a73e9 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -5323,14 +5323,76 @@ the hook for step 2's opponent. - **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. +### v363's phone verdict: the drop never came (2026-10-14, fixed in v364) +Oliver: *"in touch nach unten erzeugt gefühlt immer einen Lob.. mir ist ein Stoppball nicht gelungen.. +vielleicht ist die Geste zu subtil."* Two reasons stacked. The swing fires at `flickMin` (28 px) but the +length is read at CONTACT, by when the thumb has usually travelled on. And on the continuum even a 50 px +flick already floated the ball to mid-court. **Now it is two shots:** a down-flick shorter than +`dropMax` (90 px) is the drop, longer is the lob. The continuum survives only for the opponent's +moonball (`aim.soft` 0.85). Verified with real touch events: 50 px and 75 px give drops, 150 px a lob. +Pinned: a 75 px flick lands exactly where the shortest drop does. A slow, long swipe that is still +short of 90 px at contact is still a drop, so "long and brisk" is the lob gesture. + +### Step 2: OPPONENTS WITH A GAME (2026-10-14, v364) +`config.STYLES`: eight styles as dials, one `style` per OPPONENTS row, and the walk-on carries the +style's `tag` as one bold row ("PUSHER — IT ALL COMES BACK HIGH"), because the player has to know what +he faces to plan against it. Dials: +- `loft`: a moonball habit, the soft ball at 0.85. +- `drop`: only when the player stands behind his line AND the body is 1.5 m inside its own. +- `lob`: when the player is at the net. +- `spin`: rally-ball bounce, >1 topspin kick, <1 slice. +- `charge`: lowers its short-ball line. +- `sv`: serve and volley. + +Context-chosen shots are fine for the CPU; the one-gesture rule binds the player's thumb. + +| style | who | does | +|---|---|---| +| pusher | Shambler, Punter, Steward | 45% moonballs, lobs a net man half the time | +| retriever | Ball Boy, Runner | some moonballs, lobs | +| slicer | Keeper, Seller | low ball (spin 0.8), the odd drop | +| power | Roller, Stringer, Brute | heavy topspin kick (1.15) | +| baseliner | Line Judge | mild topspin, the odd lob | +| touch | Mascot, Physio | drops you when you hang back, lobs you at the net | +| rusher | Umpire, Club Pro, Director | serve and volley, comes in 1.6 m earlier | +| allcourt | Head Coach, Champion | a little of everything | + +Wiring: +- **Serve and volley:** `hitServe` stamps `ball.approach = 'O'`. `rz.rushing` is set while its own ball + flies, and the recovery SPRINTS instead of walking. `rxPlan` plays the return as a net man wherever + the run has got to (never a serve return), and net mode heads to `netStand` depth on the ball's line, + with `netReact` as the split step. +- **The player lobbed at the net** gets the CPU's rule: `intercept` flips his plan to `lob`, his + backpedal runs at `lobTurn`, and his reply is capped. +- `rzChaseSpeed`'s lob factor reads only the CPU's OWN plan. + +Tests (160, ten seeds): +- every rung styled, tags fit +- pusher moonball and lob shares match the table, and it never drops +- big-hitter balls all kick +- touch drops from inside, never from behind its line +- the rusher charges more than 2 m behind its serve +- the player's plan turns to lob + +The step-1 baseline-shape test now plays THE LINE JUDGE (a baseliner, `oppOf().foe` set; the body plays +the style, not the rung number), and its net share floor is 50%. The autopilot mistimes into short balls, +and a body is meant to come in on those. The back-court volley cap is the real guard. The depth and Climb +pace samplers skip soft balls, since a moonball's pace comes from its arc. Also fixed in passing: the +machine-squeak test was ~1% seed-fragile (one 3 m roll, about 5 grind ticks at 60%). It now keeps +rolling (seed 31337 found it). + +**Not built:** the champion LEARNING whether the net works for him (Free Tennis's idea), a style for +classic's CPU, and a "DROP"/"LOB" word at contact. + # OPEN — leftovers, in rough priority order Nothing here is broken; these are known gaps and unverified risks. 0. **THE RALLY GOES FLAT (2026-10-13), the top item.** Every match converged on serve, both at the net, reflex volleys, won by a wrong-foot. Step 1 (the court gets its length) SHIPPED v361, awaiting the - phone verdict. Next up: step 3 (the soft ball, approved), step 2 (opponent styles), and step 4 (the legs: - the Champion runs 15.7 m/s). See the section of that name above. Fallback tag `rally-baseline-v360`. + phone verdict; the tap timing + leg knee (v362), the soft ball (v363, drop/lob split v364) and the + opponent styles (v364) followed. Awaiting the phone verdict on v364. See the section of that name + 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. diff --git a/index.html b/index.html index 5ea265f..fd85b7d 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/beats.js b/src/beats.js index 17f3059..d1ce2ab 100644 --- a/src/beats.js +++ b/src/beats.js @@ -7,7 +7,7 @@ // (beats <-> gameplay and beats <-> zombies are runtime-only import cycles, same pattern as // input <-> gameplay: the imported bindings are only touched inside functions, never at load.) -import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME, racketStage, leagueOf, cupRoundsFor, cupFixture, foeLevel } from './config.js'; +import { TUNE, UI, bossArt, matchFoe, STYLES, BENCH, ZFAMS, COURT, HOME, racketStage, leagueOf, cupRoundsFor, cupFixture, foeLevel } from './config.js'; import { endRun, MODE_MATCH } from './stats.js'; import { award, prof, save as profSave, push as profPush, setPromo, levelAt, recordCup, recordCupWin } from './profile.js'; import { G, P, O, ball, score, setupPoint, sendBoy, other } from './state.js'; @@ -685,7 +685,7 @@ function matchOpponent() { showModal({ title: zb.foe.name, color: UI.hot, body: [zb.foe.line, 'LEVEL ' + lvl + ' — YOURS: ' + mine].concat(sub), foe: { head: 'z_' + zb.fam + 'head', name: zb.foe.name, line: zb.foe.line, - lvl, mine, peer, sub } }); + lvl, mine, peer, sub, style: STYLES[zb.foe.style]?.tag } }); // ...and a fresh body has read NOTHING yet: the habit tracker starts clean (see THE READ, config) G.match.laneRun = 0; G.match.lastLane = undefined; G.match.surprised = G.match.expected = false; // a FLAT pool per body: with regicide gone the body is pure scoreboard (matchPoint takes a diff --git a/src/config.js b/src/config.js index 04a9912..8f7e7fb 100644 --- a/src/config.js +++ b/src/config.js @@ -94,6 +94,7 @@ export const TUNE = { 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. + dropMax: 90, // px: a down-flick shorter than this is the DROP, this long or longer the LOB 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) @@ -1220,54 +1221,77 @@ export const bossArt = ch => ch % 10 === 0 ? BOSS_ART[BOSS_ART.length - 1] // a ladder is a hierarchy, not an arrival order, so the spectator belongs at the bottom with the // ball boy and the head coach belongs up beside the club pro. Every new rung wears a family rolled // off the body it already has in the gore mode, same rule as the twelve before it. +// THE STYLES (step 2 of the rally rework, 2026-10-14 — PROJECT.md "THE RALLY GOES FLAT"). How a body +// PLAYS, as dials, so a style is data and never a branch. Read in trySwing (what it does with the +// ball), rxPlan (when it comes in), racketAI (serve and volley) and doHit (the spin it carries). +// Context-chosen shots are fine HERE — the one-gesture rule binds the player's thumb, and a lob or +// a drop announces itself in its own arc. +// loft share of rally balls sent back as a high deep MOONBALL (the soft ball's long end) +// drop chance of a DROP when it stands inside its own baseline and you stand behind yours +// lob chance of a LOB when you stand at the net +// spin the bounce its rally balls carry: >1 heavy topspin (kicks up and on), <1 slice (low) +// charge m it lowers its short-ball line: it comes in on balls a baseliner would stay back for +// sv serves and volleys +// tag the walk-on's one line about it — the player has to know what he is facing to plan +export const STYLES = { + pusher: { loft: 0.45, lob: 0.5, tag: 'PUSHER — IT ALL COMES BACK HIGH' }, + retriever: { loft: 0.15, lob: 0.35, tag: 'RETRIEVER — RUNS DOWN EVERYTHING' }, + slicer: { spin: 0.8, drop: 0.25, tag: 'SLICER — LOW BALLS THAT STAY DOWN' }, + power: { spin: 1.15, tag: 'BIG HITTER — HEAVY TOPSPIN' }, + baseliner: { spin: 1.07, lob: 0.25, tag: 'BASELINER — PAINTS THE LINES' }, + touch: { drop: 0.45, lob: 0.55, tag: 'TOUCH — DROPS YOU WHEN YOU HANG BACK' }, + rusher: { charge: 1.6, sv: true, tag: 'NET RUSHER — PASS HIM OR LOB HIM' }, + allcourt: { loft: 0.08, drop: 0.2, lob: 0.4, spin: 1.07, charge: 0.8, tag: 'ALL-COURT — A BIT OF EVERYTHING' }, +}; + const OPPONENTS = [ - { short: 'SHAMBLER', name: 'THE SHAMBLER', fam: 'sh', + { short: 'SHAMBLER', style: 'pusher', name: 'THE SHAMBLER', fam: 'sh', line: 'HE WAS HERE BEFORE ANY OF THIS.', speed: 0.72, pace: 0.72, wide: 0.75, flub: 1.35 }, - { short: 'BALL BOY', name: 'THE BALL BOY', fam: 'bb', + { short: 'BALL BOY', style: 'retriever', name: 'THE BALL BOY', fam: 'bb', line: 'HE STILL FETCHES. HE JUST BITES NOW.', speed: 0.80, pace: 0.78, wide: 0.82, flub: 1.25 }, - { short: 'PUNTER', name: 'THE SPECTATOR', fam: 'sp', + { short: 'PUNTER', style: 'pusher', name: 'THE SPECTATOR', fam: 'sp', line: 'HE PAID FOR THE WHOLE SEAT.', speed: 0.86, pace: 0.82, wide: 0.86, flub: 1.18 }, - { short: 'KEEPER', name: 'THE GROUNDSKEEPER', fam: 'gk', + { short: 'KEEPER', style: 'slicer', name: 'THE GROUNDSKEEPER', fam: 'gk', line: 'HE MOWED THIS LAWN FOR THIRTY YEARS.', speed: 0.9, pace: 0.88, wide: 0.9, flub: 1.05 }, // 5: the heavy of the ground staff. Slow legs, but everything he touches comes back flat and hard - { short: 'ROLLER', name: 'THE ROLLER MAN', fam: 'rm', h: 1.95, + { short: 'ROLLER', style: 'power', name: 'THE ROLLER MAN', fam: 'rm', h: 1.95, line: 'HE FLATTENED THIS COURT. THEN THE REST.', speed: 0.88, pace: 0.98, wide: 0.92, flub: 1.0 }, - { short: 'STEWARD', name: 'THE STEWARD', fam: 'st', + { short: 'STEWARD', style: 'pusher', name: 'THE STEWARD', fam: 'st', line: 'HE IS ASKING YOU TO LEAVE.', speed: 0.98, pace: 0.95, wide: 1.0, flub: 0.98 }, // 7-8: the entertainment, and then the legs - { short: 'MASCOT', name: 'THE MASCOT', fam: 'ma', + { short: 'MASCOT', style: 'touch', name: 'THE MASCOT', fam: 'ma', line: 'THE CROWD STILL LOVES HIM.', speed: 1.02, pace: 0.98, wide: 1.05, flub: 0.95 }, - { short: 'RUNNER', name: 'THE RUNNER', fam: 'ru', + { short: 'RUNNER', style: 'retriever', name: 'THE RUNNER', fam: 'ru', line: 'HE COVERS THE COURT. ALL OF IT.', speed: 1.22, pace: 1.0, wide: 1.05, flub: 0.92 }, // 9-12: the trades and the officials — width arrives here - { short: 'SELLER', name: 'THE STRAWBERRY SELLER', fam: 'sb', + { short: 'SELLER', style: 'slicer', name: 'THE STRAWBERRY SELLER', fam: 'sb', line: 'CREAM WITH THAT?', speed: 1.04, pace: 1.06, wide: 1.10, flub: 0.90 }, - { short: 'STRINGER', name: 'THE STRINGER', fam: 'sg', + { short: 'STRINGER', style: 'power', name: 'THE STRINGER', fam: 'sg', line: 'HE STRUNG YOUR RACKET. HE KNOWS ITS LIMIT.', speed: 1.06, pace: 1.12, wide: 1.14, flub: 0.86 }, - { short: 'JUDGE', name: 'THE LINE JUDGE', fam: 'lj', + { short: 'JUDGE', style: 'baseliner', name: 'THE LINE JUDGE', fam: 'lj', line: 'EVERY BALL OF YOURS IS OUT.', speed: 1.05, pace: 1.08, wide: 1.28, flub: 0.84 }, - { short: 'UMPIRE', name: 'THE CHAIR UMPIRE', fam: 'cu', + { short: 'UMPIRE', style: 'rusher', name: 'THE CHAIR UMPIRE', fam: 'cu', line: 'HIS CALL WAS ALWAYS FINAL.', speed: 1.08, pace: 1.14, wide: 1.26, flub: 0.80 }, - { short: 'PHYSIO', name: 'THE PHYSIO', fam: 'ph', + { short: 'PHYSIO', style: 'touch', name: 'THE PHYSIO', fam: 'ph', line: 'HE KNOWS EXACTLY WHERE IT HURTS.', speed: 1.12, pace: 1.16, wide: 1.24, flub: 0.78 }, - { short: 'COACH', name: 'THE HEAD COACH', fam: 'hc', + { short: 'COACH', style: 'allcourt', name: 'THE HEAD COACH', fam: 'hc', line: 'HE TAUGHT YOU EVERY SHOT YOU HAVE.', speed: 1.20, pace: 1.20, wide: 1.22, flub: 0.72 }, - { short: 'CLUB PRO', name: 'THE CLUB PRO', + { short: 'CLUB PRO', style: 'rusher', name: 'THE CLUB PRO', line: 'HE GAVE LESSONS ON THIS COURT.', speed: 1.16, pace: 1.22, wide: 1.20, flub: 0.70 }, // THE BRUTE, not "THE BULK": the gore mode's stats screen has called this body THE BRUTE since it // was the pole-dragger (ENEMIES.g below), and one character may not carry two names. // Final-league returns hunt the open court (smart) and have individual quality bands. Earlier // opponents keep random lanes and racketQMax. Brute's legs must clear even league 4's fastest. - { short: 'BRUTE', name: 'THE BRUTE', fam: 'br', h: 2.0, + { short: 'BRUTE', style: 'power', name: 'THE BRUTE', fam: 'br', h: 2.0, line: 'NOTHING GETS PAST HIM TWICE.', speed: 1.12, pace: 1.34, wide: 1.24, flub: 0.66, smart: 0.50, qMin: 0.62, qMax: 0.76 }, - { short: 'DIRECTOR', name: 'THE DIRECTOR', fam: 'dr', + { short: 'DIRECTOR', style: 'rusher', name: 'THE DIRECTOR', fam: 'dr', line: 'HE CAN HAVE YOU REMOVED.', speed: 1.26, pace: 1.28, wide: 1.28, flub: 0.60, smart: 0.70, qMin: 0.68, qMax: 0.83 }, // 18: the man whose name is on the honours board. `h` is WORLD height (zombieH reads it, so the // serve aims at the body actually drawn): four family rolls could not make the art itself bulky // against the pose sheet — see _famroll.py's pair-reference note — so his size is stature. // Six new rungs stand between him and the start now, so he took the step up Oliver asked for: // he reads the court faster, hits harder, paints wider, and misses half as often as he did. - { short: 'CHAMPION', name: 'THE CHAMPION', fam: 'ch', h: 2.1, + { short: 'CHAMPION', style: 'allcourt', name: 'THE CHAMPION', fam: 'ch', h: 2.1, line: 'HE RETURNS EVERYTHING.', speed: 1.45, pace: 1.42, wide: 1.36, flub: 0.38, smart: 0.85, qMin: 0.74, qMax: 0.90 }, ]; diff --git a/src/gameplay.js b/src/gameplay.js index e266e04..5b251ad 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -1,6 +1,6 @@ // Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update. -import { TUNE, UI, bossArt, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS, racketStage, matchWound, LADDER, LEAGUES, leagueOfRung, foeLevel, TIN_LEAGUE, TIN_DOUBLE_LEAGUE } from './config.js'; +import { TUNE, UI, bossArt, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS, racketStage, matchWound, STYLES, LADDER, LEAGUES, leagueOfRung, foeLevel, TIN_LEAGUE, TIN_DOUBLE_LEAGUE } from './config.js'; import { P, O, ball, score, crowd, G, court, tour, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js'; import { audio, pock, swish, whoosh, applause, voice, voiceSeq, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js'; import { haptic } from './haptics.js'; @@ -255,12 +255,16 @@ function doHit(ch, quality, aim) { // depth adds no out. The down-flick keeps its old short ball (it becomes the soft ball next). // 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 + // back corner (the opponent's moonball uses the space between). 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. + // TWO shots, not a continuum (Oliver on v363: "nach unten erzeugt gefühlt immer einen Lob ... mir + // ist ein Stoppball nicht gelungen"). The swing fires at flickMin and the length is read at + // contact, by when a thumb has usually travelled on — and on a continuum even 50 px already + // floated the ball to mid-court. Below dropMax it is the drop, at or past it 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))))) + ? (aim.soft ?? (aim.mag === undefined || aim.mag >= TUNE.dropMax ? 1 : 0)) : null; if (soft !== null) { const want = COURT.netZ - TUNE.dropNet + (TUNE.lobDeep - COURT.netZ + TUNE.dropNet) * soft; // from his baseline @@ -354,7 +358,7 @@ 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.spin = soft === null ? (aim && aim.spin) || 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 @@ -577,7 +581,9 @@ 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.spin = 1; ball.soft = false; ball.from = Math.abs(sv.z - COURT.netZ); + ball.lastHit = sv.side; ball.bounces = 0; ball.rx = null; 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; 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 @@ -1251,7 +1257,7 @@ const rzRawSpeed = rz => TUNE.racketSpeed * leaguePace() * (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); + * (ball.lastHit === 'P' && ball.rx && ball.rx.lob ? TUNE.lobTurn : 1); // HIS plan, not the player's // 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 @@ -1411,10 +1417,16 @@ function rxPlan(ch) { if (!ball.rx) { const land = ball.bounces === 0 ? predictLanding() : null; const depth = land ? (ch.side === 'P' ? COURT.L - land.z : land.z) : 0; - ball.rx = { net: Math.abs(ch.z - COURT.netZ) < TUNE.netFrom, short: !G.wasServe && depth > TUNE.shortBall }; + const st = styleOf(ch); + // a body still RUSHING the net (serve and volley, a charge behind its approach) plays it as a + // net man from wherever its run has got to — a serve return never counts + ball.rx = { net: Math.abs(ch.z - COURT.netZ) < TUNE.netFrom || !!(ch.rushing && !G.wasServe), + short: !G.wasServe && depth > TUNE.shortBall - (st && st.charge || 0) }; } return ball.rx; } +// THE STYLES (step 2): how this body plays — the solo match's opponents only (see config.STYLES) +const styleOf = ch => (G.match && !G.net && ch && ch.foe && STYLES[ch.foe.style]) || null; // ...a baseliner lets a low ball bounce: only a man at the net takes it out of the air below the tape const letBounce = () => ball.bounces === 0 && ball.y < COURT.netH && ball.rx && !ball.rx.net; // ...and where he recovers to is where he IS: at the net he holds the net, otherwise he goes back @@ -1452,7 +1464,13 @@ function intercept(side) { const g = ball.g || TUNE.gravity, dt = 0.01; const inZone = side === 'O' ? zz => zz <= ZONE_DEPTH : zz => zz >= COURT.L - ZONE_DEPTH; const home = rallyFeel() ? rallyHome(ch) : HOME[side]; - const plan = rallyFeel() ? rxPlan(ch) : null, back = plan && !plan.net; // a baseliner lets it bounce + const plan = rallyFeel() ? rxPlan(ch) : null; + // 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; } + } + const back = plan && !plan.net; // a baseliner lets it bounce 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; @@ -1580,6 +1598,18 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) { : [ -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) }; + // THE STYLES (step 2): what this body DOES with the ball. A lob when you are at the net, a drop + // when you hang back and it is inside its own court, a moonball as a habit — otherwise the rally + // ball it always hit, carrying its spin. One roll, so the shares are exactly the table's. + const st = isAI ? styleOf(ch) : null; + if (st) { + const atNet = Math.abs(P.z - COURT.netZ) < TUNE.netFrom, r = Math.random(); + const inside = Math.abs(ch.z - COURT.netZ) < COURT.netZ - 1.5; + if (atNet && r < (st.lob || 0)) { aim.az = 1; aim.soft = 1; } + 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; + } // A quality floor makes strong opponents sustain pressure, instead of only improving their // luckiest returns. Classic and solo match traits own their bands; gore keeps its own floor. const qMin = G.diff ? G.diff.qMin : G.match && !G.net ? (ch.foe?.qMin ?? TUNE.aiQualityMin) : TUNE.aiQualityMin; @@ -1612,6 +1642,8 @@ function racketAI(rz, dt) { const recovering = G.match && G.phase === 'rally' && ball.active && !ball.scored && !G.match.decided && !G.match.ending && ball.lastHit === 'O' && ball.vz > 0 && !(rz.serveHold > 0); + // ...and while its own ball flies, is it RUSHING the net behind it? (serve and volley, an approach) + if (recovering) rz.rushing = ball.approach === 'O'; // ...and the sprint velocity dies WITH the chase. This return kept the last chase's vx/vz on // the body, so the speed ghosts trailed a zombie that had already stopped ("teilweise bleiben // die doppelten ghosts länger sichtbar") — the trail must read as motion, never as residue. @@ -1660,7 +1692,7 @@ function racketAI(rz, dt) { const aimX = recovering ? home.x : tgtX != null ? ball.x + (tgtX - ball.x) * read : ball.x; // Read the landing depth immediately in a match, including rising returns. Blending with // the ball's current depth would send every receiver towards the net before it arrives. - const aimZ = recovering ? home.z : c ? c.z : netX !== null ? rz.z + const aimZ = recovering ? home.z : c ? c.z : netX !== null ? Math.max(rz.z, COURT.netZ - TUNE.netStand) : 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); @@ -1670,7 +1702,8 @@ function racketAI(rz, dt) { // 2.8 m/s lumber was tuned for one sudden-death point against a fresh player, and across a // whole game it read as "very weak and slow": opponent one already moves at matchSpeedBase. // (the dials themselves live in rzChaseSpeed, which the rally-ending reach tests share) - const step = Math.min(rzChaseSpeed(rz) * (recovering ? 0.5 : 1) * dt, d); + // (recovery walks at half pace — but a body charging the net behind its shot SPRINTS) + const step = Math.min(rzChaseSpeed(rz) * (recovering && !rz.rushing ? 0.5 : 1) * dt, d); rz.dist = (rz.dist || 0) + step; // ground covered — his walk cycle steps off this, not the clock rz.x += dx / d * step; rz.z += dz / d * step; rz.runT += dt; if (rz.pose !== 'swing') rz.pose = 'run'; @@ -2106,7 +2139,8 @@ export function update(dt) { // included. The assist puts you there; the volley timing stays honest. 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 - const rate = TUNE.playerSpeed * aK * dt * (chasing ? 1 : 0.55); + // ...and lobbed at the net he backpedals, exactly as slowly as the CPU does (TUNE.lobTurn) + const rate = TUNE.playerSpeed * aK * dt * (chasing ? 1 : 0.55) * (chasing && ball.rx && ball.rx.lob ? TUNE.lobTurn : 1); // Run to the ball — then recover. WHERE you recover to is mode-specific and each rule exists // for a reason: // GORE SPLATTER recovers in DEPTH ONLY, holding your lane — recovering to HOME.P's x undid diff --git a/src/render.js b/src/render.js index c1a3a2c..cb03b9b 100644 --- a/src/render.js +++ b/src/render.js @@ -2369,6 +2369,8 @@ function modalRows(m, inner, skipBody) { const push = r => { rows.push(r); return r; }; if (m.foe) { push({ kind: 'split', h: m.foe.peer ? 38 : 26 }); + // HOW IT PLAYS (the styles, step 2): one bold line, because a plan needs to know what it faces + if (m.foe.style) { const lead = wrapText(m.foe.style, F(8, 700), inner); push({ kind: 'rule', lead, h: lead.length * 12 + 4 }); } const sub = []; for (const l of m.foe.sub || []) sub.push(...wrapText(l, F(8, 0), inner)); if (sub.length) push({ kind: 'text', lines: sub, dim: true, h: sub.length * 12 + 2 }); diff --git a/test.js b/test.js index 38f4c40..1491e42 100644 --- a/test.js +++ b/test.js @@ -1069,12 +1069,16 @@ test('THE COURT HAS ITS LENGTH: a match rally is played from the baselines, not // the reachability soak's autopilot, asserting the SHAPE of the rally rather than a win rate const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15; try { - startMatch(); G.interlude.introT = 0; G.match.opp = 12; + // a BASELINER (THE LINE JUDGE): since the styles (step 2) a net rusher volleys on his way in by + // design, so the baseline shape is asserted against a body whose game is the baseline + startMatch(); G.interlude.introT = 0; G.match.opp = 11; + assert.equal(cfg.matchFoe(11).style, 'baseliner', 'the subject plays from the back'); + oppOf().foe = cfg.matchFoe(11); // the BODY plays the style, not the rung number for (let i = 0; i < 2.2 * 60; i++) update(DT); const hits = []; let prev = ball.lastHit; for (let i = 0; i < 120 * 60; i++) { - G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; // keep playing: the shape is the subject + G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; G.match.opp = 11; // keep playing: the shape is the subject if (G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0)) { if (i % 40 === 0) keyPressed('Space'); } else if (G.phase === 'toss') { if (ball.vy < 0.4 && ball.y > 2.4) keyPressed('Space'); } else if (G.phase === 'rally' && ball.active && ball.lastHit === 'O') { @@ -1091,7 +1095,9 @@ test('THE COURT HAS ITS LENGTH: a match rally is played from the baselines, not } const back = hits.filter(h => h.back); assert.ok(hits.length >= 40, 'a real rally sample: ' + hits.length + ' contacts'); - assert.ok(back.length / hits.length >= 0.6, `most of the rally is played from the back: ${back.length}/${hits.length}`); + // the autopilot mistimes into short balls and a body is MEANT to come in on those, so the share of + // net contacts follows its timing; the back-court volley share below is the guard against the old T + assert.ok(back.length / hits.length >= 0.5, `most of the rally is played from the back: ${back.length}/${hits.length}`); const volleys = back.filter(h => h.volley).length; assert.ok(volleys / back.length <= 0.1, `a baseliner lets it bounce: ${volleys}/${back.length} back-court volleys`); const gaps = []; @@ -1199,6 +1205,10 @@ 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); + // Oliver on v363: "nach unten erzeugt gefühlt immer einen Lob" — a NATURAL short flick (a thumb + // travels 40-80 px) must already be the drop, not a floater on the way to a lob + const flick = softShot(TUNE.dropMax - 15); + assert.ok(Math.abs(flick.depth - drop.depth) < 0.3 && flick.spin === drop.spin, 'a short natural flick is the drop'); 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) @@ -1252,6 +1262,97 @@ test('a LOBBED net man who runs it down only gets a defensive ball back', () => } finally { TUNE.matchFlub = flub; } }); +// ---- THE STYLES (step 2, 2026-10-14): how a body plays is a row of dials -------------------------- +// 400 returns off one body, the player parked at `pz`: what share came back soft, and the spins. +function styleReturns(rung, pz, aiZ = 2) { + freshSplatter(); startMatch(); G.interlude.introT = 0; G.match.opp = rung; + const zb = oppOf(); zb.foe = cfg.matchFoe(rung); + const out = { n: 0, soft: 0, lob: 0, drop: 0, spins: new Set() }; + const flub = TUNE.matchFlub; TUNE.matchFlub = 0; + try { + for (let i = 0; i < 400; i++) { + G.phase = 'rally'; G.wasServe = false; G.match.surprised = G.match.expected = false; + P.x = 0; P.z = pz; P.swingT = -1; + zb.x = 0; zb.z = aiZ; zb.swingT = -1; zb.swingCool = 0; zb.serveHold = 0; + Object.assign(ball, { active: true, lastHit: 'P', bounces: 1, rx: null, spin: 1, soft: false, + x: 0, z: aiZ + 0.4, y: 1.2, vx: 0, vy: 0, vz: -1 }); + update(DT); + if (ball.lastHit !== 'O') continue; + out.n++; + if (ball.soft) { out.soft++; if (ball.spin < 1) out.drop++; else out.lob++; } + else out.spins.add(Math.round(ball.spin * 100) / 100); + ball.active = false; + } + } finally { TUNE.matchFlub = flub; } + return out; +} + +test('THE STYLES: every opponent has one, and the walk-on says which', () => { + for (let n = 1; n <= cfg.LADDER; n++) { + const f = cfg.matchFoe(n), st = cfg.STYLES[f.style]; + assert.ok(st, `${f.short} plays a known style (${f.style})`); + assert.ok(st.tag.length <= 38, `${f.style}'s tag fits the modal's one line: ${st.tag}`); + } + startMatch(); + const m = G.modal || G.modalQ.find(q => q.foe); + assert.ok(!m || !m.foe || m.foe.style === cfg.STYLES[cfg.matchFoe(1).style].tag, 'the walk-on carries the tag'); +}); + +test('a PUSHER moonballs as a habit and lobs a man at the net; a BIG HITTER carries topspin', () => { + const pusher = cfg.STYLES[cfg.matchFoe(1).style]; + assert.equal(cfg.matchFoe(1).style, 'pusher'); + const back = styleReturns(1, 24); + assert.ok(Math.abs(back.soft / back.n - pusher.loft) < 0.08, `moonballs ${back.soft}/${back.n} vs loft ${pusher.loft}`); + assert.equal(back.drop, 0, 'a pusher does not drop'); + const net = styleReturns(1, COURT.netZ + TUNE.netStand); + assert.ok(Math.abs(net.lob / net.n - pusher.lob) < 0.08, `lobs at the net man ${net.lob}/${net.n} vs ${pusher.lob}`); + const power = styleReturns(5, 24); + assert.equal(cfg.matchFoe(5).style, 'power'); + assert.deepEqual([...power.spins], [cfg.STYLES.power.spin], 'every big-hitter rally ball kicks'); + assert.equal(power.soft, 0, 'and none of them floats'); +}); + +test('a TOUCH player drops you from inside when you hang back — and never from his own baseline', () => { + assert.equal(cfg.matchFoe(7).style, 'touch'); + const inside = styleReturns(7, COURT.L + 0.5, 4), deep = styleReturns(7, COURT.L + 0.5, -0.5); + assert.ok(Math.abs(inside.drop / inside.n - cfg.STYLES.touch.drop) < 0.08, `drops from inside ${inside.drop}/${inside.n}`); + assert.equal(deep.drop, 0, 'from behind his own line a drop is no option'); +}); + +test('a NET RUSHER serves and volleys: the serve is his approach and he sprints in behind it', () => { + assert.equal(cfg.matchFoe(15).style, 'rusher'); + startMatch(); G.interlude.introT = 0; G.match.opp = 15; + const zb = oppOf(); zb.foe = cfg.matchFoe(15); + score.server = 'O'; setupPoint(); + Object.assign(zb, { x: 1.3, z: -0.5, serveHold: 0 }); + // the serve's flight plus the return's: ~1.1 s — what a real serve-and-volleyer has to close in + let served = -1, z0 = null, appr = null, zMax = -99; + for (let i = 0; i < 4 * 60 && !(served >= 0 && i - served > 66); i++) { + G.modal = null; G.modalQ.length = 0; + update(DT); + if (served < 0 && ball.active && ball.lastHit === 'O' && G.phase === 'rally') { served = i; z0 = zb.z; appr = ball.approach; } + if (served >= 0) zMax = Math.max(zMax, zb.z); + } + assert.ok(served >= 0, 'he served'); + assert.equal(appr, 'O', 'the serve is an approach'); + assert.ok(zMax > z0 + 2, `he charged the net behind it: ${z0.toFixed(1)} -> ${zMax.toFixed(1)}`); +}); + +test('LOBBED at the net, the player backpedals too, and only blocks it back', () => { + freshSplatter(); startMatch(); G.interlude.introT = 0; + const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15; + try { + const zb = oppOf(); zb.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, COURT.L - 2, 9); + Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 0, rx: null, spin: 1, soft: true, + x: 0, y: 1, z: 2, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity }); + update(DT); + assert.ok(ball.rx && ball.rx.lob && !ball.rx.net, 'the plan turns: it is a lob, he runs it down'); + } finally { TUNE.autoAssist = assist; } +}); + 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; @@ -1266,7 +1367,7 @@ test('rally depth: deep by default, mistiming lands it SHORTER, never longer', ( Object.assign(ball, { active: true, lastHit: 'P', bounces: 1, rx: null, x: 0, z: 2.4, y: 1.2, vx: 0, vy: 0, vz: -1 }); update(DT); - if (ball.lastHit !== 'O') continue; + if (ball.lastHit !== 'O' || ball.soft) continue; // a style's moonball has its own depth (and tests) const t = (ball.vy + Math.sqrt(ball.vy * ball.vy + 2 * ball.g * ball.y)) / ball.g; depths.push(COURT.L - (ball.z + ball.vz * t)); ball.active = false; @@ -1294,7 +1395,7 @@ test('The Climb opens briskly, converges at opponent eight, and leaves cup and t x: 3, z: 5, y: 2, vx: 0, vy: 4, vz: -8 }); update(DT); const chase = Math.hypot(zb.vx, zb.vz); - let hits = 0, pace = 0, width = 0; + let hits = 0, pace = 0, paced = 0, width = 0; const seed = _seed; _seed = 9173; // repeatable samples; cosmetic timers may still consume different rolls for (let i = 0; i < 1200; i++) { @@ -1305,13 +1406,14 @@ test('The Climb opens briskly, converges at opponent eight, and leaves cup and t x: 0, z: 2.4, y: 1.2, vx: 0, vy: 0, vz: -1 }); update(DT); if (ball.lastHit !== 'O') continue; - hits++; pace += Math.hypot(ball.vx, ball.vz); + // a style's soft ball takes its pace from its arc on purpose, so the pace dial reads rally balls + hits++; if (!ball.soft) { pace += Math.hypot(ball.vx, ball.vz); paced++; } const t = (ball.vy + Math.sqrt(ball.vy ** 2 + 2 * ball.g * ball.y)) / ball.g; width = Math.max(width, Math.abs(ball.x + ball.vx * t)); ball.active = false; } _seed = seed; - return { chase, hits, pace: pace / hits, width }; + return { chase, hits, pace: pace / paced, width }; }; let previous = 0; for (let n = 1; n <= 8; n++) { @@ -2360,7 +2462,9 @@ test('the machine squeaks, fire crackles and catches, the gun cracks, the boss s audio.sfxTest.log = []; spawnZombie(0, 8, 'm'); const m = zombies[zombies.length - 1]; m.rollTo = 3; m.rollT = 0; m.fireT = 99; - for (let i = 0; i < 60 * 4 && !heard('squeak'); i++) { update(DT); G.hp = 9; } + // keep it ROLLING for the whole window: one squeak per grind tick is a 60% roll, and a single 3 m + // trip is only ~5 ticks — about 1 seed in 100 heard nothing (seed 31337 found it) + for (let i = 0; i < 60 * 4 && !heard('squeak'); i++) { if (m.rollTo === undefined) m.rollTo = m.x > 0 ? -3 : 3; update(DT); G.hp = 9; } assert.ok(heard('squeak'), 'a rolling machine announces itself'); freshSplatter();