Three things from the phone test. The drag meant two different things depending on invisible state: aiming from pretoss, but walking the player around while the last ball was still in the air. That "mode between the hits" read as a bug, so it's gone — canAimServe now covers every phase of splatter, and firing over a live ball is allowed again (the old one flies on as a ballistic clone). That was the mid-rally re-toss that used to be the spam vector, and it's safe now because the brake moved into the pull: a rhythm of lazy 30px pulls fires 100 shots in 30s and kills NOTHING (the court fills up instead), while 85 committed full pulls kill 11. hitServe never had doHit's net-clearance loop, so every shot at a shambler hugging the tape died in the net — not balance, a missing loop. Both paths now trade pace for arc until the ball is genuinely over, and the ceiling goes 8 -> 12 tries because a target right behind the net needs a near-vertical drop (measured: clears the tape by 0.20m and connects). The trade is real: the loft arrives slower and the walk-lead was computed for the original pace, so a lob is honestly the less reliable shot. And the camera now rises and leans back a little while the slingshot is drawn. That steeper look is what makes the gap between the net and the bodies behind it readable — the shot that used to be blind. Eased once per frame before anything projects, since one sheared frame would be visible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
119 lines
7.9 KiB
JavaScript
119 lines
7.9 KiB
JavaScript
// All tuning and constants. TUNE is live-editable in game via the T panel;
|
|
// the exported consts below are structural values that code shares but the panel doesn't show.
|
|
|
|
export const TUNE = {
|
|
camZ: 42, camY: 9, camF: 406, horizon: 40, // camF/horizon are per-orientation, set in fitViewport
|
|
gravity: 9.2, // slightly floaty vs 9.8 for readability
|
|
spinFactor: 0.9, // extra dive on quality shots (fake topspin)
|
|
bounceY: 0.58, bounceXZ: 0.78,
|
|
playerSpeed: 5.4, aiSpeed: 4.3,
|
|
reach: 1.3, aiReach: 1.6, // tight contact radius: the assist positions you, the tap must be honest
|
|
swingWindup: 0.06, // s before contact window opens
|
|
swingWindow: 0.16, // s contact window length
|
|
baseShotSpeed: 16, qualityShotSpeed: 12, // horizontal m/s = base + q*quality; perfect timing = real pace
|
|
serveSpeed: 21, serveQualitySpeed: 7,
|
|
aimError: 2.4, // m of scatter at quality 0
|
|
perfectMs: 45, // |offset from window center| for PERFECT
|
|
aiQualityMin: 0.45, aiQualityMax: 0.85, aiFlub: 0.09,
|
|
aiReaction: 0.45, // s before the CPU reads your shot — well-aimed corners are winners
|
|
autoAssist: matchMedia('(pointer: coarse)').matches ? 1.15 : 0, // auto-run speed factor (mobile)
|
|
zSpeed: 0.7, // zombie shamble speed m/s
|
|
zSpawnEvery: 3.0, // s between spawns in game 1 — shrinks by zSpawnStep per finished game
|
|
zSpawnMin: 1.0, // spawn-gap floor late in a run
|
|
zSpawnStep: 0.22, // spawn-gap reduction per finished game
|
|
zMax: 3, // shamblers on court in game 1 (cap grows +1 per game to zMaxCap; 0 = spawns off, for tests)
|
|
zMaxCap: 14, // absolute shambler cap
|
|
changeoverT: 4.5, // s the changeover breather lasts (after odd games, as the rules demand)
|
|
gameBeatT: 1.6, // s the game-end beat lasts on even (non-changeover) games
|
|
eatenR: 0.9, // m a shambler must close to eat you — matches the visual sprite overlap
|
|
zSnap: 1.4, // m free-aim magnetism: a zombie this close to your flick's landing point is the target
|
|
killsPerPoint: 3, // splatter: zombie kills to score ONE tennis point (1 = every kill scores, old behavior)
|
|
zSplatQ: 0.5, // min shot quality to splatter; below = stagger pushback
|
|
zPushback: 1.4, // m a staggered zombie is knocked back
|
|
splatterOverT: 0.1, // s the spent-ball 'over' beat lasts before the next serve is armed
|
|
// the slingshot: the pull's DIRECTION picks a lane, its LENGTH is power. Deliberately DECOUPLED —
|
|
// reaching a far shambler must not cost a longer drag than the screen has room for. The old
|
|
// mapping (pull length = reach) is why you had to drag off the bottom of the phone.
|
|
pullMin: 18, // px of pull below which the release cancels — no shot, nothing spent
|
|
pullFull: 95, // px of pull for a full-power shot. Keep this well inside one thumb's travel.
|
|
pullQMin: 0.12, // shot quality at pullMin, scaling to 1.0 (= PERFECT) at pullFull. Below
|
|
// zSplatQ it only staggers, so a lazy flick can't kill: THE anti-spam knob.
|
|
aimCone: 0.45, // lane half-width per meter of range — the aim cone widens with distance
|
|
aimReach: 22, // m the aim ray runs when no shambler stands in the lane
|
|
aimCamY: 1.2, // m the camera rises while you're drawing — a steeper look, so the gap between
|
|
aimCamZ: 1.2, // m it also leans back, so the lift doesn't push the near court off-screen.
|
|
// Together: you can read the depth behind the net, which is exactly the shot
|
|
// that used to be blind. Set both to 0 for the old fixed camera.
|
|
backstopZ: -1.8, // z of the far backstop — must sit IN FRONT of the -2 retire line, or balls die before they reach it
|
|
backstopKeep: 0.55, // pace a ball keeps after the rebound — still lethal, slower
|
|
backstopPop: 1.8, // m/s of lift off the fence, so the rebound travels back instead of dying at the net posts
|
|
meleeCool: 1.0, // s between racket swishes — melee is the panic button, not the default
|
|
zPierce: 0.75, // pace a ball keeps after a splatter — hot balls plow through lined-up pairs
|
|
ballChangeFirst: 3, // game of the first new-balls delivery (real rule: 7, then every 9 — too sparse here)
|
|
ballChangeEvery: 4, // games between deliveries after the first
|
|
bombMax: 3, // bomb balls in a fresh tin
|
|
wCap: 3, // per-type cap on the courtside rack — tins accumulate, but only so many
|
|
rackH: 22, // px height of the bottom weapon rack (feel knob: how much court it covers)
|
|
bombRadius: 2.5, // m blast radius of a bomb ball
|
|
hordeLen: 12, // s a horde wave lasts
|
|
hordeRate: 2.5, // zombies spawned per second during a horde
|
|
mgRate: 6, // machine-gun pellets fired per second during a horde
|
|
mgPickupLen: 8, // s the machine-gun tin (4th delivery) auto-fires outside hordes
|
|
fps: 60, // frame cap — 30 halves battery/heat but Oliver felt it; smoothness wins (0 = uncapped)
|
|
runnerSpeed: 3.2, // runner ('f') speed multiplier over the shamble
|
|
runnerVMax: 4.2, // hard runner speed cap — the late-game ramp must stay humanly reactable
|
|
shamblerHp: 2, // splat-quality hits a classic shambler ('a'/'b') soaks before it dies — only the runner ('f') is still one-shot glass
|
|
setCardT: 2.0, // s the centered SET card holds (the 'kurze unterbrechung' before the horde)
|
|
bruteHp: 2, // splat-quality hits (or pellets) a brute ('g') soaks before it dies
|
|
shieldDown: 1.5, // s a shield zombie's ('s') chair stays dropped after a block — the kill window
|
|
meleeRange: 1.7, // m — a racket swish cleaves shamblers this close to you
|
|
racketHp: 3, // splat-quality hits a racket zombie (mini-boss) takes before it dies
|
|
racketSpeed: 2.8, // m/s the racket zombie shambles to chase a ball
|
|
racketQMax: 0.55, // its return quality is capped here (slower, sloppier than the tennis CPU)
|
|
racketFlub: 0.2, // chance the racket zombie whiffs a return
|
|
missHud: 0, // show the last miss reason on screen (full log: T.G.missLog) — off; dev-only
|
|
killPips: 1, // splatter: show the kills-to-next-point pips on the scoreboard
|
|
};
|
|
|
|
export const COURT = { L: 23.77, W: 8.23, dblW: 10.97, netZ: 11.885, netH: 0.914, netW: 10.06 };
|
|
COURT.svcFar = COURT.netZ - 6.4; // far service line
|
|
COURT.svcNear = COURT.netZ + 6.4; // near service line
|
|
|
|
// world sizes (meters)
|
|
export const PLAYER_H = 1.88;
|
|
export const BOY_H = 1.2;
|
|
export const UMP_H = 3.0;
|
|
export const UMP_X = 5.6; // chair right beside the doubles line — x 7.1 projected off-screen in portrait
|
|
|
|
// contact rules: outside this height band the ball can't be hit (below = it's past you)
|
|
export const CONTACT = { minY: 0.24, maxY: 2.3 };
|
|
|
|
// volleys (pre-bounce contact) get extra forgiveness: the ball passes at full speed and
|
|
// there's no bounce cue to time against, so reach and swing window widen by these factors
|
|
export const VOLLEY = { reach: 1.4, window: 1.8 };
|
|
|
|
// how deep from the far/near baseline a player's interception zone extends
|
|
export const ZONE_DEPTH = 9.5;
|
|
|
|
// recovery positions between shots
|
|
export const HOME = { P: { x: 0, z: 22.8 }, O: { x: 0, z: 2.5 } };
|
|
|
|
// where the near player is allowed to move
|
|
export const P_BOUNDS = { x: 6.5, zMin: COURT.netZ + 1.2, zMax: 26 };
|
|
|
|
// serve toss: release height and upward speed (apex derives from these + gravity)
|
|
export const TOSS = { y0: 1.8, vy: 4.6 };
|
|
|
|
// shot target depth from the receiving baseline, by aim (up = deep, none = mid, down = short)
|
|
export const SHOT_DEPTH = { deep: 3.0, mid: 5.4, short: 8.6 };
|
|
|
|
// how long swing/serve poses hold before returning to idle (s)
|
|
export const POSE_TIME = 0.38;
|
|
|
|
// the server's idle ball bounce: sideways offset to the free hand, apex height, tempo.
|
|
// tempo drives the ball, the hand sprite AND the pock sound — one value, always in sync.
|
|
export const BOUNCE_IDLE = { dx: 0.5, apex: 0.55, tempo: 3.6 };
|
|
|
|
// generated sprite sheets: the idle pose is this many pixels tall; all scales derive from it
|
|
export const SPRITE_H = 112;
|