Files
splatter-tennis/config.js
T
wuwiandClaude Opus 5 55ddf5932e Fix the serve deadlock, give the mop boy a job, align net and umpire
The freeze first: G.mgT only counted down inside `G.phase === 'rally'`, so
anything that moved the phase mid-burst — a scheduled duel calling setupPoint —
stranded it above zero forever. mgT > 0 is one of keyPressed's early returns, so
no tap could serve again, and the yellow shape over the player's head was
drawMgRacket's muzzle flash still being drawn. Fixed at the root (the clock ticks
in every phase, only the pellets are gated on rally) and semantically: starting a
duel cancels any burst, because the duel is tennis.

The mop boy now does an actual round: walk to the biggest nearby stain, stand on
it and push the mop while the cycle animates in place, scrub it out, move to the
next, then leave. Gore fades ~3.5x faster while he is stopped and working than
when walking past, so the stain dies under him instead of fading as he drifts by.
boyMaxJobs caps the round so a kill streak can't employ him forever.

Applause: the clip is 4.16s and always plays in full, but every cheer() passed a
shorter beat, so the stand sat down while it was still audible. Floored at the
clip length. New, much bolder clap frame — the re-roll came back brighter and
bluer, which would have flashed the whole stand, so it is colour-matched to frame
one per channel on mean and sd before quantising.

Net and umpire were both wrong since forever: netW was the singles-stick width
(10.06) against a court drawn to the doubles lines, so the net stopped 0.46m
short each side; the chair's near legs stood 0.45m inside the sideline. Both now
sit exactly on the doubles line.

26-game soak clean, no stalls. Serve returns 134/150 = 89.3% +/-2.5% with the
wider net (the bar is inside one sigma), 30 human-tapped points = 50 hits /
0 whiffs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 18:09:18 +03:00

177 lines
13 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
// the slingshot's pace range. Weighted hard toward QUALITY so a full draw is a smash you can
// see: 21 m/s floppy at the minimum pull, 41 m/s at full. (baseShotSpeed above is the rally
// volley and stays put — it's shared with tennis mode and the CPU.)
serveSpeed: 19, serveQualitySpeed: 22,
serveSpin: 1.3, // extra gravity a full-power serve carries (was a fixed 0.35). Without it the
// net-clearance loop ate the whole smash: a flat fast ball can't get over the
// tape AND come down on a target, so it got throttled back to walking pace.
// Heavy topspin lets it leave flat and DIVE, which is what a smash actually is.
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: 5.0, // s the changeover breather lasts (after odd games, as the rules demand)
boyMopSpeed: 2.2, // m/s a MOPPING ball boy crosses at (a fetching one still sprints at 7.5)
boyMopDwell: 1.8, // s he stands on a stain pushing the mop before moving to the next one
boyMaxJobs: 4, // stains one boy will clean in a round — a kill streak must not employ him forever
boyStride: 0.28, // m of travel per animation frame. Frames are driven by DISTANCE, not time, so
// the feet cannot slide: change a boy's speed and his legs keep up on their own.
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: 0.9, // m of lateral slack at zero range — the aim cone's base width (fat-thumb forgiveness)
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.10, // lane half-width added per meter of range. This is what makes the pull's ANGLE
// mean something: at 0.45 the cone was ±10 m at the far baseline — the whole
// court — so every downward pull picked the same body and the angle felt inert.
// 0.10 gives ±2.9 m out there: you have to actually point at a lane.
aimReach: 22, // m the aim ray runs when no shambler stands in the lane
// How the camera moves while you're drawing the slingshot. NEGATIVE (the default) drops it and
// pushes in — you crouch into the shot. POSITIVE lifts it and leans back for a steeper, more
// top-down look, which reads the depth behind the net better but feels less like aiming.
// Both 0 = the old fixed camera.
aimCamY: 0, // m of camera height change (negative drops you into the shot)
aimCamZ: 0, // m of camera dolly (negative = closer). Both 0 = the camera holds still,
// which is where Oliver landed: the aim graphics carry the read on their own.
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
// The schedule calls the DUEL, not the tin: weapons are only ever won, but a duel comes round
// on its own so there is always a chance at one. Fires off the next hand-cleared wave.
duelFirst: 2, // game after which the first scheduled sudden-death duel is called
duelEvery: 3, // games between scheduled duels after the first
bombMax: 3, // bomb balls in a fresh tin
tinTriples: 3, // triple-serves in a fresh tin
tinFlames: 3, // flame balls in a fresh tin
tinGuns: 1, // machine-gun charges in a fresh tin
wCap: 9, // 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
// fire: the flame ball no longer just kills what it touches, it starts an epidemic.
burnTime: 3.0, // s a body burns before the fire goes out
burnDps: 2.0, // hp lost per second while burning — burnTime*burnDps must exceed bruteHp or tanks walk out of the fire
burnSpread: 2.2, // m — a burning body ignites anything this close, every frame it burns
burnIgniteR: 4.5, // m — the flame ball's ignition radius around the body it kills
burnBallR: 2.4, // m — the flame ball also lights anything it FLIES PAST. Without this the ball's
// full pierce simply killed the whole lane outright and nothing was left to catch fire.
burnPanic: 1.0, // walk-speed multiplier while burning (1 = off; crank it for panicked sprinting)
setCardT: 2.0, // s the centered SET card holds (the 'kurze unterbrechung' before the horde)
bruteStride: 0.5, // m the brute covers per lurch frame — it is the slowest thing on court, so
// its cycle is driven by ground covered rather than by the clock
bruteHp: 5, // splat-quality hits (or pellets) a brute ('g') soaks before it dies — one wound skin per stage
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
};
// the death animation's timeline, in seconds of G.eatenT. A: thrash · B: you burst into gore
// · C: the summary fades in. The restart tap is gated past the whole thing so a panic tap
// during the burst can't skip it.
// assets/audio/tennis-applaus.wav runs 4.16s and always plays in full, so the crowd must keep
// clapping at least that long — every cheer() call was passing a shorter beat, which left the
// stand sitting back down while the applause was still audible.
export const APPLAUSE_T = 4.2;
export const EATEN_BURST = 0.5, EATEN_SUMMARY = 1.6, EATEN_RESTART = 2.2;
// UI accent colours, one place so the whole HUD can be re-tuned at once. Deliberately
// desaturated from the original arcade primaries: they were shouting over the artwork.
// (NOT the gore — blood and piles keep their loud reds; only text and interactive UI.)
export const UI = { hot: '#d9705f', warm: '#e0cc72', pick: '#cbd06a', cream: '#e8e8dc',
dim: '#8fa0b8', bad: '#a34a42', ball: '#dbe063' };
// netW was the singles-stick width (10.06) while the court is drawn to the DOUBLES lines, so the
// net stopped 0.46 m short of each sideline and visibly failed to meet the court edge. The posts
// now stand exactly on the doubles sidelines.
export const COURT = { L: 23.77, W: 8.23, dblW: 10.97, netZ: 11.885, netH: 0.914, netW: 10.97 };
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.5; // was 1.2 — the mopping boy read as a distant speck at that scale
// how far off-court a boy starts. The mop boy comes in from just off-screen (he is slow, and
// waiting 8s for him to walk into shot is dead time); a fetching boy sprints in from further out.
export const BOY_ENTER = { mop: 9.5, run: 13 };
export const UMP_H = 3.0;
// The chair is 1.12 m wide as drawn (x_ump is 42x112 at UMP_H 3.0), so at 5.6 its near legs stood
// 0.45 m INSIDE the doubles line, on the court. 6.05 puts them exactly on the line; anything much
// beyond ~6.3 clips the right edge of the portrait viewport (7.1 was fully off-screen).
export const UMP_X = 6.05;
// 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;