Three courts, picked on a second start-screen stage: CENTRE COURT (grass), TERRE ROUGE (clay) and THE SLAB (hard). A court is a row in config.COURTS and four PNGs — render() never learns their names. Each row carries two palettes, because every court still rots: what "drained" means on orange clay is not what it means on green lawn. Mow stripes became a flag rather than a colour — recolouring the lawn and leaving the bands on still read as grass. Bounce is two numbers copied into TUNE on selection, so the three bounce sites in gameplay.js and the T panel stay untouched: grass skids low and fast, clay sits up and drags, hard is the old baseline. Per-court crowd sheets and ad strips. Each court's clap frame is generated from its OWN seated sheet, not from x_crowd2 — pairing one court's crowd with another's applause cross-fades between two different crowds. The picker is a second panel in the existing #intro overlay, rows built from the table. Last pick is remembered and comes up pre-highlighted; 1..N or Enter on a keyboard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
383 lines
30 KiB
JavaScript
383 lines
30 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
|
|
// ---- THE DIFFICULTY DIRECTOR. Every dial below used to bottom out by about game 12, which is why
|
|
// a 50-game run stopped changing (games 12..50 were arithmetically identical). They are all keyed
|
|
// on G.runGames now and all keep moving; the ceilings that remain exist for perf or fairness, not
|
|
// because the curve ran out. See HANDOFF "The difficulty director".
|
|
// Calibrated to TRACK the old curve through roughly game 12 and then keep going, rather than to
|
|
// replace it: the first pass at this made games 1-30 easier, which is the opposite of the ask.
|
|
zSpawnEvery: 3.0, // s between spawns in game 1
|
|
zSpawnAccel: 0.16, // hyperbolic tightening: gap = zSpawnEvery / (1 + games * this). g5 1.67s,
|
|
// g11 1.09s (old floored at 1.0 here and stopped dead), g20 0.71s, g30 0.52s
|
|
zSpawnMin: 0.35, // hard spawn-gap floor, safety only — reached around game 41
|
|
zMax: 3, // shamblers on court in game 1
|
|
zMaxPerGame: 0.9, // cap growth per finished game (was a flat +1 into a wall at game 11)
|
|
zMaxCap: 24, // absolute shambler cap — readability ceiling, reached ~game 24
|
|
zSpeedPerGame: 0.13, // shamble-speed ramp per finished game. Was TIME-based (splatT/70) and hit its
|
|
// ceiling after 105 seconds of splatter, i.e. during game 2 — so shamble speed
|
|
// was never a progression lever at all. g12 matches the old top speed (1.75
|
|
// m/s), and it climbs to 2.7 m/s by game 22.
|
|
zSpeedMax: 2.9, // cap on that ramp (top speed = zSpeed * 3.9). runnerVMax still caps runners.
|
|
nastyPerGame: 0.03, // share of spawns drawn from the advanced roster, per finished game
|
|
nastyMax: 0.75, // ...capped here (g14 matches the old 0.42, g25 hits the cap). The old mix was
|
|
// frozen at exactly 0.42 from game 12 to the end of the run.
|
|
hordeGrow: 0.2, // horde length AND spawn rate both scale by this per set already banked
|
|
zHordeCap: 34, // hard body ceiling DURING a horde. A horde ignores the director cap on
|
|
// purpose (that is the point of it), but hordeGrow means a late wave now tries
|
|
// for ~120 spawns where the old fixed 12 s x 2.5/s could only ever ask for 30,
|
|
// and a 40-body court on a 270px-wide portrait screen is mush, not menace.
|
|
// ---- PLAYER HEALTH. A SECOND, SEPARATE damage channel: only ranged attacks touch it. A shambler
|
|
// reaching you is still instant permadeath (G.mode='eaten') — that decision is settled, see HANDOFF.
|
|
// There is no HP bar and there never will be: the state shows as blood on the man.
|
|
playerHp: 4, // hits from ranged attacks you can absorb before you go down
|
|
hurtGrace: 1.1, // s of invulnerability after a hit — a burst must not delete you in one volley
|
|
healPerDuel: 2, // hp back for winning a sudden-death duel. THE reason to want the duel.
|
|
// ---- the rogue ball machine ('m'): real tennis furniture that shoots at you. It never shambles,
|
|
// so it never crosses your baseline and never scores — it is a standing threat you choose when to
|
|
// deal with, which is the target-priority decision the game was missing.
|
|
machEvery: 4, // games before the first machine can appear, and games between them after
|
|
machMax: 2, // machines allowed on court at once
|
|
machHp: 6, // splat-quality hits to wreck one. It is a steel cart, not a body — and it now
|
|
// shows the damage on the way down (z_machw1 dented, z_machw2 split open)
|
|
machRoll: 1.6, // m/s it trundles when it repositions. It CANNOT fire while rolling, which is
|
|
// both honest (a rolling cart cannot aim) and the window you use to close on it
|
|
machRepo: 7, // s between repositions, jittered. "Furniture that occasionally wheels itself
|
|
// somewhere else" — it stops being a landmark you can ignore once it is aimed away
|
|
machCycle: 3.4, // s between its shots
|
|
machWind: 0.9, // s of TELEGRAPH before each shot — the fairness contract, same idea as shriek()
|
|
machSpeed: 13, // m/s it fires at
|
|
machDmg: 1, // player hp a shot costs if you don't return it
|
|
shotHitR: 0.42, // m — how close a hostile ball must ACTUALLY be to hit you. Measured: the old test
|
|
// (0.55 m with the parry's 0.8 z-weighting) fired while the ball was still 0.68 m
|
|
// up-court and drawn up around his head — "visually the ball is not yet at the
|
|
// player but somehow the player already is hit". Depth is NOT weighted here.
|
|
shotHitY: 1.75, // m — and it has to be at body height, so a lob passing overhead never counts
|
|
parryPow: 0.85, // shot quality of a RETURNED machine ball — a parry is a real weapon
|
|
// The parry gets its OWN generous reach and window, not the normal swing's. Returning a shot is the
|
|
// intended answer, on a phone, with one thumb, against something aimed at you — it has to be
|
|
// forgiving. parryWindow must stay <= swingWindup + swingWindow*VOLLEY.window (0.348 s) or the
|
|
// swing clock in update() expires before the window closes.
|
|
// TIGHTENED after Oliver's second playtest: "I can tap pretty early and it returns the ball".
|
|
// At parryArm 6.0 / window 0.34 / reach 2.4 the shot flew INTO reach during the window, so any tap
|
|
// once it was vaguely close worked and the timing stopped mattering. At 13 m/s a 3.4 m arm is about
|
|
// 0.26 s of "the tap counts", which is a real reaction test and still landable one-thumbed.
|
|
parryArm: 3.4, // m — an incoming shot this close makes the next tap a SWING instead of a serve
|
|
parryReach: 1.7, // m — how far off the racket a return still connects (normal volley reach is 1.82)
|
|
parryWindow: 0.2, // s the return window stays open after the tap
|
|
// A return is a GLANCING blow: contact off the middle of the racket face throws it that many
|
|
// degrees off straight up-court. Returns used to mirror the incoming line — straight back at the
|
|
// thrower — so a boss handed you a free wound with every volley. Meeting it dead centre still
|
|
// sends it back at him; that is now a placement you earn by moving your feet.
|
|
parryDeflect: 55, // ° of deflection at the edge of the reach
|
|
parryJitter: 8, // ° of scatter on top — a bat-away is never surgical
|
|
// ---- chapter bosses: one every bossEvery games, escalating. The racket zombie is demoted to an
|
|
// occasional opponent (racketEvery) so the boss is the only thing that reads as a wall.
|
|
bossEvery: 10, // games between chapter bosses
|
|
bossHp: 16, // hp of the FIRST one; each later chapter adds bossHpPerChapter. 10 was far too
|
|
// soft — three banked flame balls took chapter 1 down (2 impact + 2.4 burn each)
|
|
bossHpPerChapter: 9,
|
|
bossScale: 1.5, // body height multiplier at chapter 1 (a shambler is 1.8 m)
|
|
bossScalePer: 0.18, // ...growing per chapter, so chapter 4 towers
|
|
bossSpeed: 0.42, // m/s it advances — slow, deliberate, unstoppable
|
|
bossCycle: 2.6, // s between its ranged attacks (it uses the machine's projectile)
|
|
bossVolley: 3, // shots per attack — STAGGERED IN TIME, not fanned across space. Fanning them
|
|
// +-1.9 m meant only the middle ball could ever reach you and the other two were
|
|
// decoration; Oliver called that out. Three real threats in a rhythm instead.
|
|
bossSalvoGap: 0.42, // s between the shots of one volley
|
|
bossWind: 0.5, // s of telegraph before a boss throws — shorter than the machine's 0.9 on purpose:
|
|
// he asked for a tighter reaction time, and a boss should be harder than furniture
|
|
bossDeathT: 0.7, // s of GAME time the chapter card holds off after the boss dies. The card used to
|
|
// land on the same frame as the kill and bury it — you never saw the thing you
|
|
// spent a whole fight earning. Game time, so the slow motion below stretches it
|
|
// to roughly a second and a half on the clock.
|
|
bossSlowT: 1.2, // s of REAL time the world runs slow while a boss comes apart
|
|
bossSlowMo: 0.35, // ...and how slow
|
|
bossShotSpeed: 15.5, // m/s a boss throws at (the machine's is machSpeed 13)
|
|
racketEvery: 3, // games between racket-zombie (plain opponent) spawns — was EVERY won game, which
|
|
// is why he read as "the boss" and why a real boss would not have stood out
|
|
asideEvery: 55, // s between the umpire's sarcastic asides in splatter (jittered +-40%). He never
|
|
// acknowledged that the match had changed; now he comments on it, disapprovingly.
|
|
racketHpPerGame: 0.12, // the racket zombie stiffens as the run goes on (floored into whole hp)
|
|
// ---- damage. Bodies with hp (racket boss 'r', brute 'g') take damage in these units, so a big
|
|
// boss is just a bigger hp number and every weapon stays proportional. Fractions are legal: they
|
|
// bank on zb.chip and only whole points come off hp (the wound SKINS switch on hp thresholds).
|
|
dmgBall: 1, // a splat-quality ball or serve
|
|
dmgSide: 1, // one TRIPLE side ball — used to pass straight through the boss doing nothing
|
|
dmgPellet: 0.34, // one machine-gun round — three chip a point off. Also used to no-op on the boss
|
|
dmgBomb: 3, // caught in a blast. Was an instant kill that ignored hp entirely
|
|
dmgFlame: 2, // a direct flame-ball touch (and it sets the boss alight, which it didn't before)
|
|
bossBurnMul: 0.4, // burnDps multiplier while a boss body cooks — it burns, just slower than a body
|
|
bossHitCool: 0.2, // s a boss body is immune to further BALL contact after taking a hit. A flame ball
|
|
// has full pierce (it is not consumed on contact, so it can cut a lane), and a boss
|
|
// is not removed when hit — so the same ball sat inside its radius and re-wounded it
|
|
// EVERY FRAME. That is why one flame ball killed a 16 hp chapter boss.
|
|
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: 130, // px of pull for a full-power shot. Keep this well inside one thumb's travel — but
|
|
// 95 was so short that EVERY pull maxed out by accident and the meter never
|
|
// meant anything. A full draw should cost a deliberate stretch of the thumb.
|
|
// Capped short of ~150 because a pull that STARTS low on the screen has to
|
|
// still be completable downward before the finger runs off the glass.
|
|
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.
|
|
pullHintAfter: 1.5, // s of doing nothing in the serve stance before the pull gesture demos itself
|
|
aimTie: 0.12, // how much worse-aimed a NEARER body may be and still win the lane (0..1 of the
|
|
// cone half-width). 0 = pure best-aligned wins; 1 = the old "nearest in the
|
|
// lane always wins", which is what made the magnet grab the wrong shambler.
|
|
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
|
|
meleeCrowd: 0.38, // ...but the panic button PANICS. The cooldown shortens with how many bodies are
|
|
// actually on top of you, so a late horde stops outrunning your only close-range
|
|
// answer: cool = meleeCool / (1 + (near-1)*meleeCrowd), floored at meleeCoolMin.
|
|
// 1 body 1.00s, 4 bodies 0.47s, 8 bodies 0.28s.
|
|
meleeCoolMin: 0.22,
|
|
meleeCrowdR: 3.4, // m — the radius that counts as "on top of you" for that scaling (> meleeRange,
|
|
// so the swish speeds up as they close in, not only once they are already on you)
|
|
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: 30, // px height of the bottom weapon rack (feel knob: how much court it covers).
|
|
// 22 logical px is ~30 CSS px on a phone — under half Apple's 44px target, which
|
|
// is why switching tins felt fiddly. rackHit also pads the touch band (render.js).
|
|
bombRadius: 4.2, // m blast radius of a bomb ball — 2.5 barely cleared the bodies stood on the crater
|
|
hordeLen: 12, // s a horde wave lasts
|
|
hordeRate: 3.5, // zombies spawned per second during a horde. Raised WITH the gun buff below: the
|
|
// wave is meant to leave a standing crowd on your half when it ends, not to be a
|
|
// twelve-second interruption that clears itself.
|
|
mgRate: 12, // machine-gun pellets fired per second during a horde. It scales with hordeGrow too
|
|
// (see startHorde): at 6/s single-target the gun simply could not out-kill a grown
|
|
// wave — Oliver died on the second horde of every run.
|
|
mgSpread: 6, // ± degrees of pellet spread — a cone that sweeps bodies, not a needle up the middle
|
|
mgPelletR: 0.5, // m of extra hit radius on a pellet: a fat round. "Bigger ball = more damage."
|
|
dmgPelletHp: 2, // hp a pellet takes off a body — shamblerHp is 2, so the horde gun one-shots them
|
|
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
|
|
stewardHp: 5, // the steward ('d'). Was 3 and died like a shambler, which made a bigger body read
|
|
// as decoration; at 5 he is a brute's worth of hp on a faster, ordinary-looking
|
|
// walker, and he has a wound skin (z_dw) so the second half of that shows
|
|
hordeSteward: 0.2, // share of horde spawns that come out as stewards (from game 4)
|
|
// 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)
|
|
modalIn: 0.34, // s the modal swooshes in — icon+title from the right, body cascading from the
|
|
// left, each on easeOutBack. The gesture that dismisses is swallowed until it
|
|
// lands, so a panic tap can't skip a popup before it has been read.
|
|
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
|
|
racketStride: 0.42, // m the racket zombie covers per walk frame — his cycle steps off GROUND
|
|
// COVERED, so he stops animating when he stops moving
|
|
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;
|
|
|
|
// A: they drag you down and you fight it on your knees · B (EATEN_BURST): the crowd closes over you
|
|
// and what is left is the mound · C (EATEN_SUMMARY): the summary fades in over them still feeding.
|
|
// It is deliberately long. Oliver: "the movies surround and bite them, blood spills, and then the
|
|
// person basically drowns in the zombies" — that cannot happen in half a second.
|
|
export const EATEN_GRAB = 0.9, EATEN_BURST = 2.3, EATEN_SUMMARY = 3.6, EATEN_RESTART = 4.6;
|
|
export const EATEN_FEED_R = 1.15; // m — how tight the feeding ring packs around the body
|
|
|
|
// 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' };
|
|
|
|
// ------------------------------------------------------------------ courts ----
|
|
// A court type is pure DATA: adding one is a row here plus its four PNGs, never a change to render().
|
|
// Each carries TWO palettes because every court still rots — `tennis` is how it looks before the
|
|
// horde arrives, `splatter` is the drained version render.js lerps to (see paletteT).
|
|
// stripes mown lawn bands. Grass only: clay and acrylic are laid flat, and drawing stripes on
|
|
// them was the single thing that made a recoloured court still read as grass.
|
|
// line painted line colour. Chalk-white reads on green and blue; on clay it has to go warmer
|
|
// or the lines glow like neon.
|
|
// ads/crowd sprite BASE names; the clap frame is <crowd>2 (see the two-frame note in render()).
|
|
// bounceY/XZ copied into TUNE on selection, so the T panel keeps editing the live values and the
|
|
// three physics call sites never learn that courts exist. Grass skids low and fast, clay
|
|
// sits up and drags, acrylic is the middle it always was.
|
|
export const COURTS = [
|
|
{ id: 'grass', name: 'CENTRE COURT', sub: 'GRASS · fast, low skid', tile: 'court_grass',
|
|
ads: 'x_ads', crowd: 'x_crowd', stripes: true, line: '#e8e8dc', border: '#e8e8dc',
|
|
bounceY: 0.50, bounceXZ: 0.84,
|
|
tennis: { stands: '#232e42', tierA: '#2a3750', tierB: '#26324a', board: '#0e5c2e',
|
|
out: '#295c2d', lawn: '#4cae52', stripe: '#46a54c' },
|
|
splatter: { stands: '#181426', tierA: '#201a33', tierB: '#1c172e', board: '#3a1212',
|
|
out: '#20331c', lawn: '#5e7747', stripe: '#576f42' } },
|
|
{ id: 'clay', name: 'TERRE ROUGE', sub: 'CLAY · slow, high kick', tile: 'court_clay',
|
|
ads: 'x_ads_c', crowd: 'x_crowd_c', stripes: false, line: '#efe3cf', border: '#efe3cf',
|
|
bounceY: 0.66, bounceXZ: 0.70,
|
|
tennis: { stands: '#4a3025', tierA: '#5a3a2c', tierB: '#513428', board: '#8a4426',
|
|
out: '#7a3f26', lawn: '#c4652f', stripe: '#c4652f' },
|
|
splatter: { stands: '#2a1a18', tierA: '#33201d', tierB: '#2d1c19', board: '#3a1212',
|
|
out: '#4a2a1c', lawn: '#8a4a2a', stripe: '#8a4a2a' } },
|
|
{ id: 'hard', name: 'THE SLAB', sub: 'HARD · true bounce', tile: 'court_hard',
|
|
ads: 'x_ads_h', crowd: 'x_crowd_h', stripes: false, line: '#f2f4f8', border: '#f2f4f8',
|
|
bounceY: 0.58, bounceXZ: 0.78,
|
|
tennis: { stands: '#1d2836', tierA: '#243141', tierB: '#1f2b3a', board: '#12525e',
|
|
out: '#2c7a52', lawn: '#1f5c93', stripe: '#1f5c93' },
|
|
splatter: { stands: '#141a26', tierA: '#1a2130', tierB: '#161d2a', board: '#3a1212',
|
|
out: '#25452f', lawn: '#1c3e5c', stripe: '#1c3e5c' } },
|
|
];
|
|
|
|
// 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;
|
|
// The chapter-boss ROSTER. One design per chapter, cycling — chapter 5 gets design 1 again, but at
|
|
// far more hp and much bigger, so it still escalates. Each entry is pure DATA so adding a design is a
|
|
// sheet plus a row here, never a change to drawZombie.
|
|
// walk: two frames, stepped off DISTANCE COVERED (never time — see the animation invariant)
|
|
// wounds: damage skins, healthiest first. However many there are, they are spread evenly over hp.
|
|
// throws: the same states mid-throw, used while windT > 0. Index is clamped, so a design with
|
|
// fewer throw frames than wound stages simply reuses its last one.
|
|
// Every name must be ALL LOWERCASE and a whole word — see the case-collision note in the pipeline.
|
|
export const BOSS_ART = [
|
|
{ name: 'THE GROUNDSKEEPER', walk: ['z_boss1', 'z_boss2'],
|
|
wounds: ['z_bossw1', 'z_bossw2', 'z_bossw3'],
|
|
throws: ['z_bosst0', 'z_bosst1', 'z_bosst2', 'z_bosst3'] },
|
|
{ name: 'THE LINE JUDGE', walk: ['z_judge1', 'z_judge2'],
|
|
wounds: ['z_judgew'], throws: ['z_judget', 'z_judgewt'] },
|
|
{ name: 'THE CHAIR UMPIRE', walk: ['z_chair1', 'z_chair2'],
|
|
wounds: ['z_chairw'], throws: ['z_chairt', 'z_chairwt'] },
|
|
];
|
|
|
|
export const MACH_H = 1.15; // a ball machine stands about waist high — it must read as furniture, not as a body
|
|
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.
|
|
// Measured off the sprite, not guessed: the player is RIGHT-handed, so he pats the ball with his
|
|
// LEFT hand — the left-hand side of the image — whose palm sits ~0.69 m above his feet and ~0.36 m
|
|
// off his centre line. The ball tops out just under that hand instead of beside it.
|
|
export const BOUNCE_IDLE = { dx: 0.36, apex: 0.68, tempo: 3.6 };
|
|
|
|
// generated sprite sheets: the idle pose is this many pixels tall; all scales derive from it
|
|
export const SPRITE_H = 224; // 2x remaster (was 112). RES is 3, so the player at the baseline
|
|
// rasterises into ~170 DEVICE pixels — a 112px source was being UPSCALED
|
|
// 1.5x, which is why near sprites looked soft and mushy. Every sprite that
|
|
// goes through drawChar/drawBoy/the wound-stage height rule must share this
|
|
// scale, so unmatched sprites were 2x-upscaled rather than left at 112.
|