Give every opponent a style, and split the soft ball into drop and lob

Step 2 of the rally rework: eight styles as dials on the opponent rows —
pusher, retriever, slicer, big hitter, baseliner, touch, net rusher,
all-court — read where the CPU chooses its shot (moonball, drop, lob,
spin), plans its approach and serves (serve and volley sprints in). The
walk-on names the style in one line so the player can plan against it.
A player lobbed at the net now backpedals and blocks, like the CPU.

Oliver on v363: the down-flick "always felt like a lob". The length is read
at contact and a thumb has usually travelled on; on the continuum even
50 px floated the ball. Below dropMax (90 px) it is now the drop, above it
the lob. Also un-flakes the machine-squeak test (seed 31337). v364.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5.5 committed 2026-10-14 11:25:52 +02:00
1 parent 72760a9d1e
commit 180288ef8f
7 files changed
+268 -42

No files matched your search

+45 -11
View File
@@ -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