Hidden behind TUNE.aimCloud rather than deleted: the reading it gives — how much width the swipe is buying, before the ball leaves — is the only teacher the gesture has, and the panel can flip it back live for the next look at it. And "a little harder to hit the out" is a number: flickReach 0.62 -> 0.52, which halves the worst-case rate from about 15% to about 7%, with the test tightened so it cannot drift back. The shape is untouched — a perfect flick still never sails, a badly timed full swipe still can — but a mistimed one now lands nearer the middle than the line, which is the only half of that trade a player who cannot see his own timing can do anything about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1385 lines
111 KiB
JavaScript
1385 lines
111 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
|
||
fallSlideDist: 0.5, // s of run the collapse travels: total slide = the body's speed x this
|
||
fallSlideMix: [0.12, 0.35, 0.53, 0], // how the total is shared across fall1/fall2/fall3/rise, so the
|
||
// landing SKIDS. THE RISE GETS NOTHING — "the slide must be finished before
|
||
// the rise": a body pushing itself back onto its feet is planted, and still
|
||
// drifting while it stands up reads as the court moving under it. The 0.15
|
||
// it used to take is redistributed across the first three in proportion, so
|
||
// the total travel is unchanged. Tuned in #bench with the FALL button.
|
||
proneSink: 0.20, // how far a fall3 frame drops, as a fraction of its own drawn height:
|
||
// its lowest ink is the RACKET, so bottom-aligning it floats the body
|
||
// (see the note at drawZombie's drawStatic call)
|
||
swingWindup: 0.06, // s before contact window opens
|
||
swingWindow: 0.16, // s contact window length
|
||
// ALL FOUR PACE NUMBERS ARE 1.2x THEIR ORIGINALS (16/12 and 19/22) — the whole ball, tennis and
|
||
// splatter, his and theirs, moved together so nothing about the relative feel shifts.
|
||
baseShotSpeed: 19.2, qualityShotSpeed: 14.4, // 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: 26 m/s floppy at the minimum pull, 49 m/s at full — the draw nearly DOUBLES the pace,
|
||
// which is the "harder pull, faster ball" relationship, and it is in serveQualitySpeed's share
|
||
// of the pair. (baseShotSpeed is the rally volley — shared with tennis and the CPU.)
|
||
serveSpeed: 22.8, serveQualitySpeed: 26.4,
|
||
// THE TOSS STRIKE HAS REAL STAKES NOW (Oliver: "the golden mark is very easy to hit and hitting
|
||
// below it does not necessarily make a worse serve... no option to serve harder").
|
||
apexSnap: 0.05, // m of |ball - apex| that still counts flush (was a hardcoded 0.12 — at toss
|
||
// gravity that was ~0.3 s of PERFECT, which is why the mark felt free)
|
||
apexFall: 0.55, // m of miss at which quality hits the floor (was 0.9) — mistimes cost pace
|
||
// ...and the strike gesture AIMS it: a flick as you hit sways the target and buys pace.
|
||
serveAimX: 1.8, // m of sway a full flick puts on the target — centre box to the T or the line
|
||
serveHard: 9, // m/s of extra pace at full flick strength — the harder first serve
|
||
serveHardSpin: 0.45, // extra dip the hard serve carries. NOT free style: without it the loft
|
||
// loop cut the +9 m/s BELOW a plain flush serve (measured 47.2 vs 49.3) —
|
||
// the dive is what buys the pace its net clearance
|
||
serveHardErr: 1.6,// how much a hard serve multiplies the timing scatter: pace is paid for in
|
||
// faults, which is what makes tap = safe second serve a real decision
|
||
runN: 0, // frame-count override for run cycles (0 = probe per character sheet) — bench tuning knob
|
||
runHz2: 3.4, // stride cycles/s for TWO-frame pairs (the opponents). Tuned live on the bench
|
||
// with Oliver 2026-08-14: 4.5 on six frames was strobe, 1.7 on two read as jogging,
|
||
// and 3.4 with the stride-synced bob is the one he called "way better". (6.8 was
|
||
// tried and reverted on sight — the ceiling is real.)
|
||
runHz: 1.9, // stride cycles/s for the rolled 6/8-frame run sets. The old hardcoded 4.5 was
|
||
// tuned on 2-4 near-duplicate frames; on eight real phases it strobed — and so
|
||
// did 2.4 on the back-view re-roll (Oliver: "this looks like roadrunner")
|
||
faceFlip: 0.13, // s an auto-steered direction must persist before the body mirrors (earnFlip).
|
||
// The rolled cycles hold a real racket, and an un-earned flip teleports it
|
||
// across the body — the "racket flashing left and right" report
|
||
trailSpeed: 13, // m/s of horizontal pace at which a rally ball earns the ghost streak in every
|
||
// mode (the pow>0.8 gate alone was only ever reached by the gore slingshot)
|
||
oppBright: 1.3, // brightness baked into every OPPONENT sprite copy at load (render.brighten) —
|
||
// the far bodies read dark against the crowd. Load-time: reload to retune
|
||
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.
|
||
smashBoost: 0.42, // extra pace on a FORWARD flick (aim.az < 0), scaled by timing. It does not
|
||
// send the ball longer — solveShot still lands it on the same spot — it
|
||
// gets there flatter and sooner, which is what a smash is.
|
||
aimError: 2.4, // m of scatter at quality 0
|
||
// NOT `aimReach` — that name is taken further down by the slingshot's aim-ray LENGTH (22 m), and
|
||
// the duplicate key silently won: every aimed shot reached 22x its width until a test caught it.
|
||
flickWidth: 3.95, // m off centre a PERFECTLY timed flick reaches. The singles line is 4.115,
|
||
// so this is the corner with 17 cm to spare. Raising it past the line does
|
||
// not make the flick punchier, it makes a perfect one go out.
|
||
flickReach: 0.52, // how much of an aimed shot's width a MISTIMED flick gets (1 = all of it).
|
||
// 0.42 made going out IMPOSSIBLE, which fixed the 8-in-10 bug and then
|
||
// overshot it: "flicking: too easy. i must be able to play out." At 0.62
|
||
// a mistimed flick sailed ~15% of the time at quality 0 and a perfectly
|
||
// timed one never could — the risk was back, and timing rather than a
|
||
// coin toss decided it. 0.52 keeps that shape and halves the worst-case
|
||
// rate to ~7% ("make it a little harder to hit the out"): a badly timed
|
||
// flick now lands NEARER THE MIDDLE rather than nearer the line, which
|
||
// is the only half of the trade a player who cannot see his own timing
|
||
// can do anything about. THIS is the out dial — aimError is shared with
|
||
// the opponent's returns and with every aimed shot in the gore modes.
|
||
// Reach x quality must stay under the sideline once aimError is added on
|
||
// top, or the flick goes out — which is exactly the bug this fixed:
|
||
// 3.3 * 0.45 + 2.4 = 3.89 against a 4.11 m line. Raise it and the flick
|
||
// gets punchy again AND starts sailing wide on sloppy timing.
|
||
// THE LENGTH OF THE SWIPE IS THE STRENGTH OF THE AIM (px of finger travel; see gameplay.swayOf).
|
||
// The rally flick used to be read as three LANES: 0.38 of its own length sideways bought the whole
|
||
// corner, a hair under it bought dead centre, and nothing lived in between — so the same gesture
|
||
// came out "very strong" or "barely anything" and which one you got was invisible (Oliver: "a lack
|
||
// of agency, the player can't really tell if there was skill or luck"). The serve already read its
|
||
// flick as a continuous strength; the rally does now too, on the same shape of numbers.
|
||
flickMin: 28, // px before a drag counts as a flick at all — input.js's own threshold
|
||
aimCloud: 0, // 1 draws the live aim cloud under the moving thumb (render's G.swipe
|
||
// block); 0 hides it. OFF after its first playtest — "hide the indicator
|
||
// for now" — kept as a switch rather than deleted because the reading it
|
||
// gives (how much width the swipe is buying, before the ball leaves) is
|
||
// the only teacher the gesture has, and the panel can flip it back live.
|
||
flickFull: 130, // ...and px of travel that buys the FULL width. About a third of a phone
|
||
// screen: a short swipe is a safe half-court ball, and only a committed
|
||
// one goes hunting the line, which is what makes going OUT a chosen risk.
|
||
perfectMs: 45, // |offset from window center| for PERFECT
|
||
// The tennis CPU only exists in the OPENING match (after the transition, tennis never comes
|
||
// back — duels use racket* dials), so these three are the "how long until the first game is won"
|
||
// knobs. Testers needed several games to get past him: softer cap, twice the whiffs, and
|
||
// aiFlubLoss rubber-bands him — every game HE takes adds that much flub, so a struggling player
|
||
// faces an opponent that visibly starts falling apart rather than a wall.
|
||
aiQualityMin: 0.45, aiQualityMax: 0.72, aiFlub: 0.18,
|
||
aiFlubLoss: 0.08,
|
||
// ...and in CLASSIC TENNIS the same CPU has to last a whole match rather than one game, so the
|
||
// rubber band is turned off there (aiFlubLoss would have him in pieces by the second set) and the
|
||
// cap is raised: a mode whose whole promise is "real tennis" cannot be won by attrition.
|
||
classicSets: 2, // sets to win the match — best of three
|
||
classicQualityMax: 0.86,
|
||
classicFlub: 0.10,
|
||
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)
|
||
// ---- THE SIDESTEP. Free walking stays 'rally'-only (see the movement block in gameplay), which
|
||
// left you rooted to the spot through a whole boss fight — the drag is the slingshot and there was
|
||
// no gesture left over that could mean "move". A dodge is not a walk: it is one committed step
|
||
// with a cooldown, so it answers a volley without turning the serve stance into a joystick.
|
||
dodgeDist: 1.9, // m of ground the sidestep covers
|
||
dodgeT: 0.18, // s it takes — fast enough to be a reaction, slow enough to read as a step
|
||
dodgeCool: 0.5, // s before another. THE anti-spam knob: shorter and you skate across the court
|
||
dodgeIFrame: 0.28,// s untouchable, covering the step and a hair after. Beats BOTH damage channels
|
||
// (a boss volley and the instant-death body), which is the whole point of it
|
||
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 PROJECT.md "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.
|
||
// THE LATE GAME HAD STOPPED GETTING HARDER (Oliver, 2026-08-15: "it now gets too easy to just
|
||
// play through — the splatter gore should be unbelievably hard towards the end"). Every ramp in
|
||
// this block used to reach its ceiling by about game 25 and sit there, while the barrel economy
|
||
// above kept climbing — so a run got RICHER after game 25 and never more dangerous. The body
|
||
// COUNT still caps (zMaxCap is a readability ceiling on a 270 px screen and stays), but what
|
||
// those bodies are and how fast they arrive now keeps climbing well past it.
|
||
zSpeedMax: 3.6, // cap on that ramp (top speed = zSpeed * 4.6), reached ~game 28 instead of 22
|
||
nastyPerGame: 0.03, // share of spawns drawn from the advanced roster, per finished game
|
||
nastyMax: 1.0, // ...and by game 34 there are NO ordinary shamblers left in the mix at all:
|
||
// every body that walks out is off the advanced roster. That is the wall the
|
||
// end of a run is supposed to be.
|
||
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 PROJECT.md.
|
||
// 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...
|
||
machMaxPer: 18, // ...plus one more for every this many games survived, up to machMaxCap. A third
|
||
machMaxCap: 4, // and fourth gun is the one kind of late pressure that does NOT crowd the
|
||
// ground: they hold position and shoot, so the court stays readable while the
|
||
// number of things that can reach you goes up.
|
||
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: 20, // 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).
|
||
// 16 -> 20 alongside the movement below: a boss that holds its ground and dodges
|
||
// is only harder if it also lives long enough for the dodging to matter.
|
||
bossHpPerChapter: 10,
|
||
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)
|
||
// ---- boss MOVEMENT. A boss used to have no AI at all: it fell through to the crowd's aimless
|
||
// ±0.15 m/s wobble and crawled forward until it simply arrived on top of you. Each design now owns
|
||
// a movement style (BOSS_ART.move) and these are the shared numbers it spends.
|
||
bossStrafe: 1.7, // m/s sideways — bossSpeed above is only the FORWARD crawl
|
||
bossPaceX: 3.4, // m either side of the centre line a pacing boss turns at
|
||
bossShadowX: 1.7, // m either side of YOUR lane a 'shadow' boss turns at — its beat follows you
|
||
bossZMin: -1.0, // the band a boss holds. Without a clamp a fight ends by the boss walking into
|
||
bossZMax: 5.6, // you (~57 s at bossSpeed) rather than by you killing it — and every extra hp
|
||
// above just made that MORE likely, not less. Only 'charge' breaks the band.
|
||
bossChargeV: 4.2, // m/s the physio rushes at once it commits
|
||
bossChargeT: 2.2, // s ONE RUSH takes, start to arrival — the charge speed is derived from it and
|
||
// from how far away it is (bossAI), so the run-up reads the same every time and
|
||
// is long enough to see coming. Was 1.5 s at a flat 4.2 m/s, which only reached
|
||
// you at all because the boss crept nearer every cycle until it was on top of you.
|
||
bossStrike: 0.4, // m SHORT of you the rush stops — its STRIKE LINE, reached exactly as the rush
|
||
// timer runs out, so the boss turns and withdraws on the same frame it arrives.
|
||
// A rush that stopped ON you left it standing there afterwards, and no amount of
|
||
// sidestepping gets you out from under that (dodgeCool 0.5 s vs a 0.28 s
|
||
// i-frame). It cannot run PAST you either: a body over the baseline is deleted
|
||
// and scores a point (see updateZombies), which quietly despawned the boss
|
||
// mid-fight the first time this ran through. At 0.4 m the contact test still
|
||
// reaches about 0.85 m either side of the lane, and the sidestep covers 1.9.
|
||
bossChargeRest: 2.2, // s of held ground between charges — the window you get to hit it
|
||
bossHopT: 0.55, // s per mascot hop (it moves in arcs, not in a slide)
|
||
bossHopX: 2.0, // m of ground one hop covers (the eased arc lands it at about 2/pi of this)
|
||
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
|
||
matchAsideEvery: 15, // ...but 55 s is a whole match game. In the match his asides are tied to
|
||
// things that happen every point or two — a limb, your blood — so the gore
|
||
// spacing muted him almost entirely. Same ONE cooldown mechanism, a value
|
||
// that fits the mode's own event rate.
|
||
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)
|
||
// CAMERA FLASHES IN THE STAND (render.tickFlashes). MEAN seconds between them — the actual gap is
|
||
// jittered 0.35x..1.95x so they never tick, and halved again while the crowd is cheering. Turn
|
||
// flashEvery UP to quieten them; it is the one knob. Life is deliberately shorter than a frame
|
||
// pair at 60 fps would need: a flash you can look AT is a light, a flash you only catch is a
|
||
// photograph being taken.
|
||
flashEvery: 5.0,
|
||
flashLife: 0.16,
|
||
// ---- 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
|
||
boyHurry: 4.6, // m/s he covers ground during the MATCH mop beat — brisk, so the 5 s window
|
||
// is spent scrubbing rather than walking into shot
|
||
boyMaxJobs: 4, // stains one boy will clean in a round — a kill streak must not employ him forever
|
||
matchMopT: 5, // s the mop beat holds the court between match games — the boy visibly cleans
|
||
// before the next opponent walks on (skipped when there is nothing to mop)
|
||
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
|
||
stuckT: 6, // s the deadlock watchdog waits before letting go of a frozen latch (see update)
|
||
tinCardT: 2.8, // s the won-duel weapon card holds (it does NOT wait for a tap). The swipe-in
|
||
// and swipe-out eat ~0.55 s of this, so every card number below is REAL time on
|
||
// screen minus that — 2.0 left the old static hold, and read as a flicker.
|
||
// 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 at pullFull. Below zSplatQ it only
|
||
// staggers, so a lazy flick can't kill: THE anti-spam knob.
|
||
perfectBand: 1.28,// ...and the PRAISE is a separate, narrower thing. Quality SATURATES at pullFull
|
||
// — every pull past 130 px is exactly 1.0 — so "quality == 1" meant "you pulled
|
||
// far enough", and the game shouted PERFECT at almost every serve. Oliver:
|
||
// "it displays perfect a lot even when nothing special happened". Praise now
|
||
// needs the pull to STOP in the band pullFull..pullFull*this (130-166 px), which
|
||
// is a target you can miss. The pull clicks when you enter it (see input.js) and
|
||
// goes quiet when you overshoot, so the window is learnable by feel.
|
||
praiseEvery: 14, // s between praise words, on the umpire's-asides model (asideEvery below). The
|
||
// band alone was not enough: you learn to hit it, and then the HUD talks on
|
||
// every other serve again. The CLICK is the reliable feedback for a good pull —
|
||
// the word is garnish, and garnish on every plate is just food.
|
||
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
|
||
flickHintAfter: 4, // returns hit with no aim at all before the flick demos itself. Not 2 like the
|
||
// tap lesson: hitting flat is a legal shot, not a failure, so it takes a RUN
|
||
// of them to read as "he does not know the gesture exists"
|
||
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.
|
||
// FAR-BODY READABILITY. Bodies (never the court) draw off a flattened scale curve:
|
||
// s' = s * (sNear/s)^farK, anchored at the player's deepest stance so HE is untouched. The court
|
||
// lines carry the perspective; an oversized far body reads as "legible", not "wrong" — the
|
||
// 16-bit tennis trick. It buys what the lens could not: the camZ-55 note above fitViewport
|
||
// measured the court being spent (umpire clipped, top-down look) for the same far pixels.
|
||
farK: 0.25, // 0 = pure projection. 0.25 grows the far-baseline opponent ~21%.
|
||
// BALL-FOLLOW LEAN (tennis + match only): while the ball is on the FAR half the camera eases
|
||
// toward camY+farCamY / camZ+farCamZ — a broadcast-style lean into the deep court. It pivots
|
||
// about the near player's feet (see fOff in render.js), so his stance and the pull room the
|
||
// apron math promised never move. Both 0 = the camera holds still, which is where Oliver
|
||
// landed ("the camera movement is distracting") — the same verdict the aimCam pair got. The
|
||
// flattened body curve above carries the far-court read on its own.
|
||
farCamY: 0, // m of camera rise while the ball is far (steepens the look-down)
|
||
farCamZ: 0, // m of camera dolly while the ball is far (negative = closer)
|
||
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
|
||
// ---- SLICE. Not a tin and not a rack slot: it is what a PERFECT serve DOES. Cut the ball and
|
||
// each body it kills strikes it on toward the next one instead of it plowing straight through.
|
||
// Every other weapon is something you are handed; this one is a technique, which is why it is
|
||
// earned on the timing window (the toss ritual's 45 ms, or a pull that STOPS inside perfectBand)
|
||
// rather than won out of a barrel. Only a KILL carries it on — a wound ends the chain — so it is
|
||
// a crowd skill shot that does nothing to a brute, a steward or a boss, and it hands the plain
|
||
// ball a route to the DOUBLE/TRIPLE KILL ladder only a bomb or a lucky line-up could reach.
|
||
// It is ONE new velocity per kill and then ordinary ballistics: see sliceCarom for why the ball
|
||
// must never steer, whatever it costs in prettiness.
|
||
sliceHops: 2, // extra bodies one perfect serve is struck on to. 2 => TRIPLE KILL is the ceiling
|
||
sliceR: 6.5, // m it will reach for the next body. Wider reads as the game aiming for you;
|
||
// much narrower and a spread-out crowd never chains at all
|
||
sliceArc: 120, // deg: widest bearing off the ball's heading it will take. A carom can physically
|
||
// go anywhere, but one that doubles back down-court reads as a stray deflection
|
||
// rather than as a cut, and the chain should be walking INTO the crowd anyway
|
||
// 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
|
||
// How many a fresh tin holds now lives on its WEAPONS row (`tin`), because the rack is no longer
|
||
// four fixed types — bombMax/tinTriples/tinFlames/tinGuns lived here and nothing read them after
|
||
// the table landed, which is a value you can tune in the panel all day for no effect.
|
||
wCap: 99, // per-type cap on the courtside rack. Effectively NO cap: at 9 a late run spent
|
||
// every duel win on a full tin, and the answer to game 70 is a bigger stack, not
|
||
// a rationed one. 99 rather than Infinity only because the count is drawn as
|
||
// text in a rack slot and '×999' does not fit.
|
||
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
|
||
hordeWarnT: 2.4, // s of THEY KEEP COMING breather between the set card and the wave landing.
|
||
// The horde used to start on the same frame as its own announcement, which reads
|
||
// as no announcement at all — Oliver stopped noticing hordes were being called.
|
||
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
|
||
// ---- TEST MODE (triple-tap the scoreboard). A sandbox for trying the new content: a steady
|
||
// MEDIUM wave regardless of how far into a run you are, and every barrel weapon on the rack,
|
||
// kept topped up so nothing runs dry mid-test. It overrides the difficulty director rather than
|
||
// editing it — G.runGames keeps climbing underneath and the run is still a real run.
|
||
testZMax: 16, // bodies on court. Was 8 and the sandbox kept running dry between kills — a
|
||
// crowd weapon needs a crowd to test it against
|
||
testZSpeed: 1.15, // m/s shamble — medium, and FIXED (no per-game ramp)
|
||
testSpawnEvery: 0.9,// s between spawns: they keep coming
|
||
testLevel: 20, // the roster level pickVariant pretends to be at, so every body type can turn up
|
||
// ---- THE LASER's wedge: aim and release like a serve, and a plane of light widens down the lane
|
||
beamLen: 23, // m it reaches — the DRAWN length as well as the kill length. It used to be only
|
||
// the kill: the drawn length was solved each frame so the far end landed on the
|
||
// middle of the ad strip, which meant turning this knob did nothing you could
|
||
// see. One meaning now. Tuned WITH beamY: a lower plane reaches the ads sooner,
|
||
// so dropping one wants the other shortened. Oliver's numbers, 2026-08-03.
|
||
beamY: 0.95, // m — the height the plane HOLDS down the court, and the line it cuts bodies at.
|
||
// It used to slope to the ground by about 20 m out, which meant that across the
|
||
// whole far half it lay flat on the grass: nothing to cut, and the split would
|
||
// have been invisible exactly where the crowd stands. Chest on a shambler, belly
|
||
// on a brute, thigh on the roller man — so a bigger body visibly takes it lower.
|
||
beamOriginLift: 0.44, // m the apex sits ABOVE beamY. It is a lift, not an absolute height, so
|
||
// lowering beamY brings the near end down with it — set as an absolute, dropping
|
||
// the plane left the apex hanging at chest height with the beam sloping away
|
||
// beneath it. It leaves the PLAYER, not the racket head: emitted from the racket
|
||
// (2.2 m) the apex floated above him and read as detached.
|
||
beamRise: 0.14, // fraction of the length it takes to settle from the origin to that height
|
||
beamW0: 0.08, // m half-width at the racket — very nearly a point
|
||
beamW1: 3.1, // ...and at the far end. It IS a cone — but a gentle one. At 5.0 the world width
|
||
// grew so much faster than the projection shrank that on screen it opened out
|
||
// while the court receded, and the eye read that as a flat shape stuck to the
|
||
// glass. Growing slowly, the two roughly cancel and it lies in the world.
|
||
// A/B, live from the T panel on the phone. 1 = draw the laser the way the BALL's aim band is
|
||
// drawn: a path down the lane whose width comes from each point's OWN projection scale, so it
|
||
// tapers into the distance and sits in the world. 0 = the solid widening cone.
|
||
// The cone is a real projection, but it GROWS in world width (0.06 m -> beamW1), so on screen it
|
||
// opens out while everything else recedes — which is what reads as a flat sticker.
|
||
beamBorder: 0.18, // px of the two outer edge lines — a hairline. At 1 they were the heaviest
|
||
// thing on the court and boxed the cone in.
|
||
beamBand: 0, // 0 now: the CONE won, once it stopped ballooning (see beamW1). >= 0.5 is ON —
|
||
// the panel steps by 0.02 and a nudge to 0.02 used to flip the renderer silently
|
||
beamBandW: 0.30, // world half-width of that band — CONSTANT, which is what makes it taper
|
||
beamT: 0.42, // s the wedge stays drawn
|
||
dmgBeam: 8, // what it takes off a BOSS. An ordinary body is not damaged by the beam, it is
|
||
// DELETED by it (see fireBeam); bosses "must suffer severely" but survive.
|
||
// ---- the weapon barrel: the only way to get the four weapons that are not on the duel ladder
|
||
// HALVED, AND FLOORED (Oliver, 2026-08-15: "in splatter gore it now gets too easy to just play
|
||
// through, maybe we should reduce the barrels again — pretty early in the game every won game
|
||
// gives a barrel"). He is right and the arithmetic says so: at 3/8 the gap hit ONE at game 16 and
|
||
// stayed there, so the whole back half of a run handed you a weapon for every game you won, which
|
||
// is the opposite of a difficulty curve. The ramp is still there — the late game does need more
|
||
// inventory than the early one — it is just slower and it stops.
|
||
barrelEvery: 5, // games between barrels being rolled on (like the ball machine's machEvery)
|
||
barrelRamp: 14, // ...and the gap SHRINKS by one for every this many games finished
|
||
barrelFloor: 3, // ...but never below this. g14 every 4, g28 every 3, and that is as good as it
|
||
// ever gets — a barrel is a reward for surviving, never an income.
|
||
barrelRoll: 2.6, // m/s it trundles across the far court — fast enough to have to be led
|
||
barrelHp: 1, // anything pops it: ball, pellet, bomb, flame, swish. It is a target, not a body
|
||
barrelH: 1.15, // drawn height (a body is 1.8)
|
||
// ---- THE TRIAGE DRILL (src/drill.js) — the cognitive mode, fourth design. ONE RULE: always
|
||
// kill the body that would reach you first. Nothing is called and nothing is shown — the court is
|
||
// the puzzle, and the skill is reading speeds against distances (a runner at the far baseline
|
||
// beats a roller at the service line). The streak multiplier is the hook; the score is what you
|
||
// come back to beat.
|
||
drillMaxOnCourt: 7, // bodies on court at once — the queue you are reading. Past ~7 it is noise
|
||
drillSeed: 4, // bodies placed at the start so there is a queue to read from the first second
|
||
drillSeedGap: 2.6,// m of depth between those seeded bodies
|
||
drillSpawnZ: 0.6, // z they enter at (the far baseline is 0)
|
||
drillSpread: 3.4, // m either side of the centre line the lanes fan across
|
||
drillSpeed: 0.85, // m/s at the bottom of the ramp (x the type's vMul)...
|
||
drillSpeedMax: 2.0,// ...and at the top. Stamped per body AT SPAWN — constant v is also what lets
|
||
// arrival() be a plain division, so the queue is honest
|
||
drillSpawnEvery: 3.2, // s between arrivals at the bottom of the ramp...
|
||
drillSpawnMin: 1.1, // ...and at the top. THE pressure dial: the queue outgrows your clearing rate
|
||
drillRampT: 210, // s to go from the bottom of every curve above to the top
|
||
// THE JUDGEMENT SLACK. Two bodies arriving within this of each other are BOTH legal kills —
|
||
// without it, near-simultaneous arrivals made the rule a coin flip and a fault read as theft.
|
||
// Tightening it is the real difficulty curve for good players; below ~0.4 it starts punishing
|
||
// reads the eye genuinely cannot separate.
|
||
drillSlack: 0.9,
|
||
drillMultEvery: 4, // correct kills per multiplier step...
|
||
drillMultCap: 5, // ...capped: a deep streak should feel valuable, not become the whole score
|
||
drillMark: 1, // >= 0.5 draws chevrons over every LEGAL kill (the slack set, bright head,
|
||
// dim ties). Training wheels, not a cheat: see drillMarkOff
|
||
drillMarkOff: 8, // streak at which the markers hide — you have shown you can read the court, so
|
||
// the teacher steps back. Any fault (streak to 0) brings them back. This is the
|
||
// learnability answer: the marker teaches, the streak decides when you have
|
||
// learned, unlearning re-teaches
|
||
drillOneShot: 0, // >= 0.5: every drill body drops to 1 hp. OFF after one playtest: "way too easy"
|
||
// AND a 1 hp body skips every ZWOUND stage, so the gore art never renders.
|
||
// Also flattens triage — pre-softening a heavy IS the planning layer
|
||
drillCaughtBack: 45, // s of ramp handed back when one reaches you. A real setback, not a reset
|
||
// ---- ZOMBIE TENNIS (beats.startMatch) — the exhibition match. One game per opponent, blood only.
|
||
matchOppHp: 4, // the first opponent's pool. FOUR, deliberately: a quarter comes off per point
|
||
// won, so a love game walks it through every wound stage and the game point
|
||
// fells it — the scoreboard and the body agree all the way down
|
||
matchPlayerHp: 8, // your pool for the MATCH — richer than the gore mode's 4, because a real game
|
||
// has rallies you deserve to lose without the run ending ("the player is too
|
||
// weak in this mode"). Their point still costs 1; matchHeal is how you last
|
||
matchSpeedBase: 1.25, // racketAI speed multiplier for opponent ONE. 1.4 was calibrated against
|
||
// the broken recovery (the player never walked back to centre, so every
|
||
// wide return looked like opponent strength) — with real footwork restored
|
||
// it starts gentler and the ladder still climbs
|
||
// STEEPER PER RUNG (2026-08-12): "in general this is too easy now". The flick fix means an aimed
|
||
// return can no longer sail out, so the player stopped handing over free points and the ladder had
|
||
// to earn them back. The OPENING is deliberately softer than before (the shambler starts at 0.72
|
||
// where the old first opponent was 0.85) — the ramp got steeper at both ends, not just harder.
|
||
matchSpeedPer: 0.11, // ...growing this much per opponent felled. Softened once: each rung moves
|
||
// THREE dials at once (speed, flub, width, pace), and the recorder showed
|
||
// opponent two as a wall — he beat one and lost every gap to two by ~0.3 m
|
||
matchFlub: 0.08, // the match's own whiff base — the duel's 0.2 gives away a fifth of all
|
||
// returns, which across a game is an opponent that beats itself
|
||
matchFlubKeep: 0.76, // ...and it keeps only this much per rung — the ladder now that hp is flat
|
||
// THE WOUNDS PLAY, not just show. The score is the body, so the body it has left should be worth
|
||
// something at the racket: a one-armed opponent cannot cover as much, a headless one whiffs more.
|
||
// Deliberately SMALL per stage (Oliver: "not too pronounced") — the ladder is still the difficulty
|
||
// curve and this is the point-to-point texture under it, worth ~12% reach and ~35% more whiff at
|
||
// the LAST stage, which is one point from falling anyway. Read through racketStage, never hp.
|
||
matchWoundReach: 0.06, // reach lost per wound stage (1 arm off, 2 headless) — match only
|
||
matchWoundFlub: 0.175, // ...and extra whiff earned per stage, on top of the rung's own rate
|
||
matchReach: 1.35, // contact reach multiplier in the MATCH. The recorder showed swings missing by
|
||
// centimetres point after point — the assist parks you near the ball, not inside
|
||
// a 1.3 m reach. A rally mode lives on contact feeling fair. Match-only
|
||
matchAssist: 1.15, // auto-run floor DURING A MATCH, any device. autoAssist keys off pointer:coarse,
|
||
// so desktop had no legs at all — the match is a tap-timing game and footwork
|
||
// is the game's job. Held keys still override
|
||
matchOppPace: 0.78, // scale on the opponent's return SPEED for opponent one (~21 m/s instead
|
||
// of ~27) — the flight time auto-run needs. THE reachability dial
|
||
matchOppPacePer: 0.05,// ...climbing back per rung, capped at full pace (softened with speedPer)
|
||
matchWideBase: 2.1, // m either side of centre the FIRST opponent's returns may land — the width an
|
||
// auto-run player can actually cover. Its scatter painted lines from game one
|
||
// ("still a LOT of unreachable balls"); now line-painting is the late ladder
|
||
matchWidePer: 0.25, // ...earning this much more width per opponent felled (softened with speedPer)
|
||
matchHeal: 2, // hp back for a game won (the duel's healPerDuel rate)
|
||
matchBoomHold: 0.5, // s the game-winning body lies prone before it bursts — the fall must be SEEN
|
||
killGush: 3, // prone bleed-out during the kill fall: particles ATTEMPTED per frame (gushProne)
|
||
// — the body visibly bleeds out on the ground before it bursts
|
||
matchBoomCard: 0.6, // s after the burst before the WOUNDS TREATED card may land on top of it
|
||
// The timing windows, DRAWN (the match's whole ask: "what is the possible tap window, what is the
|
||
// sweet spot"). Numbers shared with the mechanics they visualise, so the picture cannot lie:
|
||
matchRingFar: 9, // m — the approach ring fades in once the incoming ball is this close
|
||
matchRingSweet: 2.7, // m — contact distance where the rings kiss (the 30-tapped-points benchmark
|
||
// lands hits tapping at 2.2..3.2 m, so the sweet spot is its centre)
|
||
matchRingBand: 0.5, // m either side of sweet that draws gold — the plausible window, made visible
|
||
fps: 60, // frame cap — 30 halves battery/heat but Oliver felt it; smoothness wins (0 = uncapped)
|
||
lowPower: 1, // >= 0.5 caps RES at 3 (−44% of pixels painted per frame) — the battery saver,
|
||
// flippable live from the T panel. DEFAULT ON since 2026-08-08: Oliver judged
|
||
// the sharpness trade invisible on his phone. Play speed and fps are untouched.
|
||
// Set to 0 for the full-fat near-1:1 render.
|
||
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)
|
||
// ---- the late roster. Three bodies that arrive as the run goes on, filling the gap between the
|
||
// 2 hp shamblers and the 5 hp brute and then extending past it. Each has one wound skin per hit it
|
||
// can still take, so the damage is readable without a bar (the rule the boss's missing bar set).
|
||
// These are now REAL: the ball path used to one-shot anything it had no branch for, so all three
|
||
// died to a single good serve no matter what these said. Retuned once they actually bite — 9 was
|
||
// fine as a number nobody felt and is a long grind as a number you do.
|
||
ballboyHp: 3, // the ball boy ('e') — a stock-sized body that takes three, with two wound skins
|
||
bouncerHp: 5, // the bouncer ('h') — 2.4 m, shoves through the crowd instead of shambling round it
|
||
rollerHp: 7, // the roller man ('k') — 3.2 m, hauling a court roller. The heaviest thing on court
|
||
rollerStride: 0.62, // m he covers per lurch frame — longer than the brute's 0.5: a bigger body,
|
||
// a longer stride, or his feet slide the way the brute's used to
|
||
// 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.8, // s the centered SET card holds (the 'kurze unterbrechung' before the horde)
|
||
bossWinT: 4.4, // ...and s the EPIC! card holds after a chapter boss. Its OWN number: a boss is
|
||
// the longest fight in the game and the win was over before it registered, but
|
||
// stretching setCardT would have made every routine set announcement drag too.
|
||
bossIntroT: 3.0, // s the boss walk-on overlay holds the frozen court before it lets go BY ITSELF.
|
||
// A boss used to arrive on a band card nobody read ("bosses are just there now") —
|
||
// this is the one announcement the player has to be prepared by, not informed of.
|
||
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
|
||
aiRead: 0.8, // how much of the BOUNCE the racket zombie reads while the ball is in the air:
|
||
// 0 = the old pure pursuit of the live ball (a hard, angled serve was an
|
||
// automatic ace — 0/40 returned, measured), 1 = never wrong-footed. See racketAI.
|
||
matchRecvX: 2.45, // m off the centre line the match opponent WAITS on, receiving. The tennis mark
|
||
// (1.3) covers the T and concedes the corner; the box runs to 4.1. See matchReady.
|
||
aiReturnDash: 1.3, // ...and how much faster he moves for the SERVE only, the first step a
|
||
// returner takes. Serve-scoped on purpose: the rally is unchanged.
|
||
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_BURST = 2.3, EATEN_SUMMARY = 3.6, EATEN_RESTART = 4.6;
|
||
// ...and the same two beats for the ending with no corpse in it (a classic match played out). It is
|
||
// shorter because there is no animation to sit through — only the crowd finishing, and the summary.
|
||
export const DONE_RESTART = 2.0;
|
||
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',
|
||
// the weapon barrel's glow, and the only pink on the court: nothing else in the
|
||
// palette is near it, so "pink means a weapon is in there" needs no explaining
|
||
pink: '#ff5cf0' };
|
||
|
||
// ------------------------------------------------------------------ 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' } },
|
||
// THE NIGHT COURT (Oliver, 2026-08-15: "a doomy gloomy court, the lines are bright"). The only
|
||
// court whose TENNIS palette is already darker than everything else's SPLATTER one — which is
|
||
// the whole idea: it starts where the others end up, and the rot then takes it further still.
|
||
// The lines need no new knob to be bright: paintBackdrop paints every line in UI.cream, so on a
|
||
// near-black surface they read as chalk under floodlights on their own. The ball is UI.ball and
|
||
// is bright for the same reason. It borrows the hard court's night-blue stands and boards rather
|
||
// than growing two more sheets — the stands are meant to be a wall of dark up there.
|
||
// `dim` is the one new knob: how far down the STAND is taken before the rot even starts (render's
|
||
// standDim). Without it the borrowed hard-court crowd sits floodlit and blue above a near-black
|
||
// court and the whole picture stops being a night. Nothing else in COURTS carries it, so it is 0
|
||
// everywhere else and reads as absent.
|
||
{ id: 'gloom', name: 'THE NIGHT COURT', sub: 'AFTER DARK · TRUE BOUNCE, NO LIGHT', tile: 'court_gloom',
|
||
ads: 'x_ads_h', crowd: 'x_crowd_h', stripes: false, line: '#dff6ff', border: '#c8dbe6', dim: 0.62,
|
||
bounceY: 0.58, bounceXZ: 0.80,
|
||
tennis: { stands: '#0a0d15', tierA: '#0f131e', tierB: '#0c1019', board: '#131a2b',
|
||
out: '#0f1a15', lawn: '#16261e', stripe: '#16261e' },
|
||
splatter: { stands: '#06050b', tierA: '#0b0813', tierB: '#08060f', board: '#2c0e0e',
|
||
out: '#0a120d', lawn: '#101c15', stripe: '#101c15' } },
|
||
];
|
||
|
||
// The tour, picked on the same screen as the court. Only the two TENNIS PLAYERS and the turned CPU
|
||
// (the racket zombie — a zombified opponent) have a second drawing; the horde, the officials and
|
||
// the ball boys are shared, because they are staff, not the draw. `sfx` is the sprite-name suffix
|
||
// render.js appends: null means "the frames as drawn", which is the men's set.
|
||
// dx/apex are optional and override BOUNCE_IDLE's pair: she reaches further out and higher than he
|
||
// does, so the pre-serve ball has to meet a different palm. Same rule as BOUNCE_IDLE — measured off
|
||
// the drawn hand by eye, not calculated.
|
||
// THE THREE MODES, picked before a run and nowhere else. Pure DATA, like TOURS and COURTS: adding
|
||
// one is a row here plus its dispatch line in main.start(), never markup.
|
||
// classic — real tennis, best of TUNE.classicSets sets, nothing ever turns
|
||
// zombie — the exhibition match (beats.startMatch): one game per opponent, blood only
|
||
// gore — the original: the opening match TURNS and you survive what walks on
|
||
// `art` is a full-bleed banner scene behind the whole button (Oliver's call over the sprite tiles,
|
||
// 2026-08-14: "bright backgrounds symbolising the individual game mode with the descriptions
|
||
// written on top"). Generated through the _famroll OpenRouter route, one scene per mode, no text
|
||
// baked in — the button's own name/sub render over a scrim, so the caption stays crisp.
|
||
export const MODES = [
|
||
{ id: 'classic', name: 'CLASSIC TENNIS', sub: 'REAL TENNIS · BEST OF THREE SETS', art: 'mode_classic' },
|
||
{ id: 'zombie', name: 'ARCADE', sub: 'ONE GAME PER OPPONENT · BLOOD ONLY', art: 'mode_arcade' },
|
||
{ id: 'cup', name: 'THE TOURNAMENT', sub: 'FOUR ROUNDS · THE CHAMPION WAITS', art: 'mode_cup' },
|
||
// ...and gore no longer opens on a game of tennis (see gameplay.startGore), so its blurb stopped
|
||
// promising one: the match already turned, you just arrived late
|
||
{ id: 'gore', name: 'SPLATTER GORE', sub: 'NO WARM-UP · SURVIVE THE DEAD CLUB', art: 'mode_gore' },
|
||
];
|
||
|
||
// THE SETUP SCREEN'S TOP ROW (Oliver's two-step menu, 2026-08-14): three posters, and picking one
|
||
// opens its own second step — classic deals the difficulty chips, zombie tennis deals its two
|
||
// sub-mode cards (arcade / the cup), gore just IS gore. A row either points at a run mode in MODES
|
||
// (`mode`) or carries its own card and the MODES ids it fans out to (`kids`).
|
||
export const MENU = [
|
||
{ mode: 'classic', diffs: true },
|
||
{ name: 'ZOMBIE TENNIS', sub: 'THE DEAD CLUB PLAYS YOU', art: 'mode_zombie', kids: ['zombie', 'cup'] },
|
||
{ mode: 'gore' },
|
||
// NETPLAY (prototype): a poster with no run mode of its own — its second step is HOST / JOIN,
|
||
// and either chip starts classic tennis against the other phone (beats.startNetVs + src/net.js)
|
||
{ name: 'VS A FRIEND', sub: 'LIVE 1V1 · REAL TENNIS · TWO PHONES', art: 'mode_vs', net: true },
|
||
];
|
||
|
||
// CLASSIC's difficulty. The first cut scaled only flub/quality-cap/speed and Oliver felt NOTHING —
|
||
// the quality distributions overlapped (0.45 floor either way), ±12% speed never binds because the
|
||
// CPU pre-plans its intercept, and the dials that actually read as skill were untouched. What a
|
||
// player FEELS is: how fast the CPU reads a shot (reaction — "well-aimed corners are winners"),
|
||
// how long its racket is (reach), the FLOOR of its shot quality (qMin — a raised floor means every
|
||
// ball comes back with pace), how often it just misses (flub), and its serve.
|
||
// `smart` is the probability the CPU aims at the OPEN court instead of a random lane (trySwing) —
|
||
// the dial that turns "returns everything" into "plays you".
|
||
// THE 2026-08-15 RETUNE (Oliver: "medium is too easy and hard is a tad too hard, easy could also be
|
||
// a little harder"): the three chips were bunched at the bottom and then jumped. MEDIUM no longer
|
||
// repeats the shipped TUNE values — it is a real step ABOVE them, and it is where `smart` first
|
||
// appears (0.35), so the middle chip plays the court instead of just returning. HARD stepped back
|
||
// on the dials that made it feel superhuman rather than good — speed 1.5 -> 1.38, floor 0.70 ->
|
||
// 0.66, smart 0.8 -> 0.7 — and EASY closed most of its gap to the old MEDIUM. The order across all
|
||
// three still climbs on every dial (pinned by test); only MEDIUM's old pin to TUNE is gone.
|
||
// `recover` scales the between-shots jog (the rally block's 0.5 "slow recovery: wrong-footing wins
|
||
// points") — a HARD CPU that ambles back to centre is free points, a recovering one is a wall.
|
||
// Speed calibration: the zombie RUNNER covers 2.8 x 1.8 x 1.22 = 6.15 m/s with no reaction gate,
|
||
// and Oliver clocked HARD as slower than him — HARD now moves 4.3 x 1.5 = 6.45.
|
||
export const DIFFS = [
|
||
// EASY's first cut (flub 0.30, quality cap 0.62, reaction 0.62) was "way too easy" once HARD
|
||
// taught us which dials bite — it is a mild step below MEDIUM now, not a training dummy
|
||
{ id: 'easy', name: 'EASY', flub: 0.15, quality: 0.80, qMin: 0.45, speed: 0.95, reaction: 0.50, reach: 1.55, serve: 0.95, smart: 0, recover: 0.48 },
|
||
{ id: 'medium', name: 'MEDIUM', flub: 0.06, quality: 0.92, qMin: 0.56, speed: 1.18, reaction: 0.34, reach: 1.70, serve: 1.05, smart: 0.35, recover: 0.66 },
|
||
{ id: 'hard', name: 'HARD', flub: 0.02, quality: 1.0, qMin: 0.66, speed: 1.38, reaction: 0.27, reach: 1.80, serve: 1.15, smart: 0.7, recover: 0.80 },
|
||
];
|
||
|
||
// THE TOURNAMENT (Oliver's pick over the endless ladder, 2026-08-13): four rounds up the club's
|
||
// dead hierarchy, each round DRAWING one rung from its band so runs differ, the final always THE
|
||
// CHAMPION. `need` is games to take the round — round one is a single game so a run opens fast —
|
||
// and the drawn body's pool scales with need (see matchOpponent), so its wound stages spread
|
||
// across the whole round instead of being spent in game one. Blood carries between rounds; the
|
||
// trainer's heal-per-game is what makes the distance survivable. Deathmatch (id 'zombie') stays
|
||
// beside it as the arcade run.
|
||
// The bands widened with the ladder (12 rungs -> 18, 2026-08-15): the shape is unchanged — the
|
||
// bottom third opens, the middle third is the quarter, everything up to management is the semi,
|
||
// and the final is the honours board — but every round now draws from a deeper hat, which is
|
||
// exactly what the bracket screen's shuffle is showing you.
|
||
export const CUP_ROUNDS = [
|
||
{ name: 'ROUND ONE', band: [1, 2, 3, 4, 5], need: 1 },
|
||
{ name: 'QUARTER-FINAL', band: [6, 7, 8, 9, 10], need: 2 },
|
||
{ name: 'SEMI-FINAL', band: [11, 12, 13, 14, 15, 16, 17], need: 2 },
|
||
{ name: 'THE FINAL', band: [18], need: 2 },
|
||
];
|
||
|
||
export const TOURS = [
|
||
// `kit` — HOW this tour's art takes a colour, because the two sets are dressed differently and the
|
||
// same operation cannot serve both.
|
||
//
|
||
// 'dye' the men. Drawn in blue shorts, so the kit pick rotates that hue and keeps the shading.
|
||
//
|
||
// 'wash' the women. Drawn head to toe in WHITES — her skirt is (240,248,248)/(208,216,224), a
|
||
// cool-cast white — so there is no hue to rotate: rotating a near-neutral does nothing,
|
||
// and the only genuinely saturated blue on her sheets is HER HAIR, at the same hue AND
|
||
// lightness as his shorts. So her whites are TINTED INSTEAD: the pick's hue laid over
|
||
// them at a low saturation, lightness kept, which is how you dye a white kit in real
|
||
// life and gives rose / powder blue / mint / lilac rather than a solid block of colour.
|
||
// Her hair and skin fall out of it on lightness alone, which is why this is safe where
|
||
// a dye was not.
|
||
// no `tile` any more: the setup screen draws the LIVE player instead, so assets/tour_m.png and
|
||
// tour_w.png are unreferenced (left on disk — they are generated art, not code).
|
||
{ id: 'men', name: "MEN'S SINGLES", sfx: null, kit: 'dye' },
|
||
{ id: 'women', name: "WOMEN'S SINGLES", sfx: '_f', kit: 'wash', dx: 0.46, apex: 0.78, tossDx: -0.59,
|
||
oDx: 0.48, oApex: 0.73 },
|
||
];
|
||
|
||
// THE PLAYER'S OWN COLOURS — testers asked for "some control over how my player looks", and from
|
||
// behind (which is the only way you ever see him) there are exactly two things to control: the kit
|
||
// and the skin. No new art: both are a PIXEL PASS over the sheets already loaded, keyed by hue —
|
||
// see tint() in render.js. Measured on p_idle1.png (84x224, 1510 opaque colours), the sheets fall
|
||
// into three clean hue populations: the kit blue around 208 deg, the skin around 22 deg, and the
|
||
// near-neutral shirt whites at sat < 0.18 that neither bucket touches.
|
||
//
|
||
// `h` is the ABSOLUTE hue the bucket is rotated onto; `s`/`l` scale saturation and lightness from
|
||
// whatever the pixel already had, so every bit of the drawn shading survives the swap. An entry
|
||
// with no `h` is "as drawn" and costs nothing — the tint pass is skipped entirely.
|
||
//
|
||
// The kit is the whole kit, not the shorts: the run frames carry blue down at the shoes and the
|
||
// women's set is blue well above the waist, so a hue swap moves every blue on the body together.
|
||
// That is the feature — you pick a KIT — and the alternative is a per-region mask baked by the
|
||
// sprite pipeline, which is a lot of work for a distinction nobody asked for.
|
||
export const KITS = [
|
||
{ id: 'blue', name: 'CLUB BLUE', sw: '#1678ba' }, // as drawn
|
||
{ id: 'red', name: 'CLARET', sw: '#b83030', h: 356, s: 0.92 },
|
||
{ id: 'green', name: 'ACID', sw: '#6ea52a', h: 88, s: 0.86 },
|
||
{ id: 'purple', name: 'VIOLET', sw: '#7a46c0', h: 268 },
|
||
{ id: 'orange', name: 'CLAY', sw: '#d4761e', h: 28, s: 0.94 },
|
||
{ id: 'black', name: 'BLACKOUT', sw: '#2e3138', h: 220, s: 0.16, l: 0.42 },
|
||
];
|
||
// Skin is NOT a hue rotation — rotate a skin tone and you get a green man. Real tones sit in a
|
||
// narrow hue band and differ in lightness and saturation, so these mostly move `l`.
|
||
export const SKINS = [
|
||
{ id: 'tan', name: 'TAN', sw: '#d6956f' }, // as drawn
|
||
{ id: 'pale', name: 'PALE', sw: '#eec4a4', h: 26, s: 0.72, l: 1.14 },
|
||
{ id: 'olive', name: 'OLIVE', sw: '#b8825a', h: 27, s: 0.94, l: 0.9 },
|
||
{ id: 'brown', name: 'BROWN', sw: '#8e5a38', h: 22, s: 1.02, l: 0.68 },
|
||
{ id: 'deep', name: 'DEEP', sw: '#5d3722', h: 20, s: 1.05, l: 0.46 },
|
||
];
|
||
// The hue windows the two buckets claim, in degrees, and the saturation each needs before it will
|
||
// claim a pixel at all. The kit window runs generously wide (150-300) because the ~165 anti-aliased
|
||
// pixels where blue meets the white shirt land out at 270-300, and a tight window leaves them
|
||
// behind as a dull fringe when the kit moves far. Nothing else on the sheet is blue, so its floor
|
||
// can stay low and catch every soft edge.
|
||
//
|
||
// THE SKIN BUCKET IS FENCED ON THREE SIDES, AND EVERY NUMBER IS MEASURED. It has two neighbours it
|
||
// must not touch, and each needed a different fence:
|
||
//
|
||
// THE SHIRT, above it. The whites are not neutral — they are WARM, hue ~37, running up to
|
||
// (243,236,224) on the run and smash frames — so they sit inside the skin's hue arc. Tinted, they
|
||
// came out as mud spattered across the man's back on every dark pick. Saturation does not
|
||
// separate them (they reach 0.35, inside real skin's range); LIGHTNESS does. Real leg skin never
|
||
// exceeds lum 0.77 across p_idle1/p_run1/p_smash/p_idle1_f, and the shirt whites start at 0.82,
|
||
// so `lumSkin` sits in that gap.
|
||
//
|
||
// THE BLOOD, beside it — and this is the one that matters, because this is Splatter Tennis. The
|
||
// death frames carry DRAWN blood (spray on the shirt, a pool under the body), and at a 330 deg
|
||
// lower edge a DEEP pick turned every drop of it to mud. Saturation cannot separate blood either:
|
||
// blood runs 0.8-0.9 and so does real skin ((240,160,128) is a highlight at 0.79). HUE separates
|
||
// them cleanly and only just — blood is a spike at 0 deg, skin peaks at 16-24 — so the arc starts
|
||
// at 10. What that leaves untinted on the death frames is the reddest skin, which is the
|
||
// blood-soaked skin, and it reads as gore rather than as a miss.
|
||
//
|
||
// THE OPPONENT wears red, not blue — (184,8,8) for him, (184,48,48) for her — so the friend across
|
||
// the net in a netplay match needs his own kit arc, `oKit`, sitting around 0 deg. It is safe there
|
||
// for the one reason it would not be on the player's sheets: the opponent art carries no DRAWN
|
||
// blood (his damage is all live particles), so nothing in that arc is blood.
|
||
//
|
||
// THE WHITES, for a 'wash' tour. Bright and barely saturated, which is exactly what her skin is
|
||
// NOT — hers reaches lum 0.77 at saturation 0.79, so the pair of limits keeps them apart with room
|
||
// to spare — and what her hair is not either, at lum 0.25. washS/washL are the dye strength: the
|
||
// pick's saturation scaled down to a pastel, and a slight darkening without which a tint laid on a
|
||
// 0.96-lightness white is invisible.
|
||
export const TINT = {
|
||
kit: [150, 300], oKit: [345, 15], skin: [10, 45],
|
||
satKit: 0.15, satSkin: 0.28, lumSkin: 0.80,
|
||
// washS/washL retuned after Oliver played it: 0.30/0.93 was a hint of colour rather than a kit
|
||
// ("a little too subtle on the white — could be more"). 0.55/0.86 reads as dyed from across the
|
||
// court and still keeps every fold; past about 0.70 it stops looking like tennis whites at all.
|
||
whiteLum: 0.72, whiteSat: 0.60, washS: 0.55, washL: 0.86,
|
||
};
|
||
|
||
// 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.
|
||
//
|
||
// BEHAVIOUR is data too, and every field below is OPTIONAL — anything left out falls back to the
|
||
// shared TUNE.boss* number, so a row with only art in it behaves exactly like a boss did before:
|
||
// move 'pace' | 'mirror' | 'advance' | 'charge' | 'bounce' (spawnChapterBoss -> bossAI)
|
||
// hpMul / speedMul scale this design's hp and its forward crawl
|
||
// volley shots per attack · gap s between them (0 = the whole volley leaves in ONE frame,
|
||
// which is a WALL to dodge rather than a rhythm to parry) · spread m of scatter
|
||
// cycle s between attacks · wind s of telegraph · shot m/s it throws at
|
||
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'],
|
||
move: 'pace', speedMul: 0.7 },
|
||
{ name: 'THE LINE JUDGE', walk: ['z_judge1', 'z_judge2'],
|
||
wounds: ['z_judgew'], throws: ['z_judget', 'z_judgewt'],
|
||
move: 'shadow' }, // he walks the line, and the line is wherever you are
|
||
{ name: 'THE CHAIR UMPIRE', walk: ['z_chair1', 'z_chair2'],
|
||
wounds: ['z_chairw'], throws: ['z_chairt', 'z_chairwt'],
|
||
move: 'surge', hpMul: 1.1 }, // the immovable one — he just keeps coming, and coming back
|
||
{ name: 'THE HEAD COACH', walk: ['z_coach1', 'z_coach2'],
|
||
wounds: ['z_coachw1', 'z_coachw2', 'z_coachw3'],
|
||
throws: ['z_coacht0', 'z_coacht1', 'z_coacht2', 'z_coacht3'],
|
||
move: 'pace', paceMul: 1.9, hpMul: 0.9, // never where he was: drills you while running the line
|
||
volley: 5, gap: 0.14, cycle: 2.2, wind: 0.4, shot: 19 },
|
||
{ name: 'THE PHYSIO', walk: ['z_physio1', 'z_physio2'],
|
||
wounds: ['z_physiow1', 'z_physiow2', 'z_physiow3'],
|
||
throws: ['z_physiot0', 'z_physiot1', 'z_physiot2', 'z_physiot3'],
|
||
// THE BODY IS THE WEAPON — which it never once was. One ball every 3.4 s and a charge that gave
|
||
// back most of its own ground meant the fight was over long before it arrived: Oliver, "the
|
||
// physio does basically nothing but move towards the net, too easy". It covers the ground half
|
||
// again as fast now (speedMul) and drills you on the way in, and since the charge style itself
|
||
// retreats to the front of the band after every rush (bossAI), it arrives OVER AND OVER rather
|
||
// than either never or once and for good.
|
||
move: 'charge', hpMul: 0.9, speedMul: 1.45,
|
||
volley: 3, gap: 0.26, cycle: 2.4, wind: 0.3, shot: 22, spread: 0.5 },
|
||
{ name: 'THE SPONSOR MASCOT', walk: ['z_mascot1', 'z_mascot2'],
|
||
wounds: ['z_mascotw1', 'z_mascotw2', 'z_mascotw3'],
|
||
throws: ['z_mascott0', 'z_mascott1', 'z_mascott2', 'z_mascott3'],
|
||
move: 'bounce', hpMul: 1.3, speedMul: 0.6, // soaks, and answers with a WALL: five at once, so
|
||
volley: 5, gap: 0, spread: 1.6, cycle: 3.2, wind: 0.65, shot: 14 }, // there is nothing to parry
|
||
{ name: 'THE STRINGER', walk: ['z_string1', 'z_string2'],
|
||
wounds: ['z_stringw1', 'z_stringw2', 'z_stringw3'],
|
||
throws: ['z_stringt0', 'z_stringt1', 'z_stringt2', 'z_stringt3'],
|
||
move: 'shadow', paceMul: 1.6, hpMul: 1.1, // he never mis-strings: he takes your lane and puts
|
||
volley: 2, gap: 0.28, spread: 0.1, cycle: 2.1, wind: 0.34, shot: 23 }, // two dead-straight ones on it
|
||
{ name: 'THE TOURNAMENT DIRECTOR', walk: ['z_dir1', 'z_dir2'],
|
||
wounds: ['z_dirw1', 'z_dirw2', 'z_dirw3'],
|
||
throws: ['z_dirt0', 'z_dirt1', 'z_dirt2', 'z_dirt3'],
|
||
move: 'surge', hpMul: 1.2, // the one fight you cannot stand still in: he has a
|
||
volley: 2, gap: 0.4, spread: 0.7, cycle: 3.0, wind: 0.5, // radio, and he calls security with it
|
||
line: 'HE IS CALLING SECURITY.', adds: { every: 6, variant: 'd', cap: 5 } },
|
||
{ name: 'THE STRAWBERRY SELLER', walk: ['z_berry1', 'z_berry2'],
|
||
wounds: ['z_berryw1', 'z_berryw2', 'z_berryw3'],
|
||
throws: ['z_berryt0', 'z_berryt1', 'z_berryt2', 'z_berryt3'],
|
||
move: 'charge', hpMul: 1.4, speedMul: 1.15, // a tray of cream and a ladle: he
|
||
volley: 4, gap: 0.12, spread: 1.4, cycle: 3.0, wind: 0.55, shot: 15 }, // sprays, then comes for you
|
||
// THE FINAL BOSS, and the only row that is not in the chapter rotation: bossArt() hands him out
|
||
// every TENTH chapter (game 100, then 200). Two things make him the wall the run ends on.
|
||
// `rally` he RETURNS anything that is not a splatter-quality kill shot — see the boss branch in
|
||
// zombies.js. Every other body in the game can be worn down with soft balls; he is the
|
||
// one you have to out-HIT, and a lazy serve comes straight back at your head.
|
||
// `phases` three rows that override this one as his health falls (bossPlan), each with its own
|
||
// cry. He holds the baseline and rallies, then runs the line drilling volleys, then
|
||
// drops the trophy and comes for you. Same body, three fights.
|
||
{ name: 'THE CHAMPION', walk: ['z_champ1', 'z_champ2'],
|
||
wounds: ['z_champw1', 'z_champw2', 'z_champw3'],
|
||
throws: ['z_champt0', 'z_champt1', 'z_champt2', 'z_champt3'],
|
||
rally: true, hpMul: 1.6, line: 'HE RETURNS EVERYTHING.',
|
||
phases: [
|
||
{ move: 'pace', paceMul: 0.9, volley: 1, cycle: 3.2, wind: 0.6, shot: 17, spread: 0.3 },
|
||
{ move: 'shadow', paceMul: 1.8, volley: 4, gap: 0.22, cycle: 2.2, wind: 0.4, shot: 20, spread: 0.5,
|
||
cry: 'SECOND SET.' },
|
||
{ move: 'charge', speedMul: 1.5, volley: 3, gap: 0.1, cycle: 1.9, wind: 0.3,
|
||
shot: 24, spread: 1.1, cry: 'MATCH POINT.' },
|
||
] },
|
||
];
|
||
|
||
// WHICH design a chapter gets. The roster is walked one design per chapter, and every TENTH chapter
|
||
// is THE CHAMPION — last in the array and never part of the rotation. The floor(ch/10) term takes
|
||
// his chapters back out of the count, so chapter 11 opens the second lap on the groundskeeper
|
||
// instead of skipping him.
|
||
export const bossArt = ch => ch % 10 === 0 ? BOSS_ART[BOSS_ART.length - 1]
|
||
: BOSS_ART[(ch - 1 - Math.floor(ch / 10)) % (BOSS_ART.length - 1)];
|
||
|
||
// ---- THE MATCH OPPONENTS. In zombie tennis you play a SEQUENCE of opponents, one game each:
|
||
// opponent 1, opponent 2, and so on. This is that list. It walks the SAME dead club staff onto the
|
||
// court as the gore mode's chapter bosses — the roster is the joke and it is worth telling twice:
|
||
// in gore they come for you, here they play you at tennis. So opponent N takes design N's NAME
|
||
// (bossArt, the champion on ten) and a PERSONALITY.
|
||
//
|
||
// A personality is DIALS, not new mechanics: every field below multiplies a number racketAI and the
|
||
// return shaping already read, so an opponent is a data row and never a branch. All optional.
|
||
// pace scale on its returns' speed wide scale on how far off centre they may land
|
||
// flub scale on its whiff chance speed scale on how fast it covers the ground
|
||
// line the personality, said once, on the name plate as it walks on
|
||
// fam which SPRITE FAMILY this opponent wears (config.ZFAMS). Absent = the club pro's body.
|
||
// This is the payoff of _master.py --variant: generate a body, add its key to ZFAMS,
|
||
// name it here, and that opponent walks on as it — through all three wound stages.
|
||
// short 8 characters, because that is the board's name slot — the match board names
|
||
// the thing you are playing instead of calling it THEM
|
||
// THE CARD, in order. It is a ladder of STAFF: the further you get, the further up the club's
|
||
// hierarchy the thing across the net used to be. That is the narrative, and the difficulty ramp is
|
||
// the same list read as numbers — every dial climbs monotonically, so "start easy and get harder"
|
||
// is a property of the table rather than something the code has to arrange.
|
||
// 1-3 the ones who fetch, sweep and watch. Slow, wild, forgiving.
|
||
// 4-6 the ground staff and the ones who keep order. They start covering ground.
|
||
// 7-8 the entertainment and the legs.
|
||
// 9-12 the trades and the officials. Width arrives: they begin painting lines.
|
||
// 13-16 the ones who actually worked the players, and then played themselves.
|
||
// 17-18 management, and then the man whose name is on the honours board.
|
||
// EIGHTEEN RUNGS since 2026-08-15 (Oliver: expand the roster, "the champion can then be even a
|
||
// little bit harder to beat"). The six new ones are interleaved rather than stacked on the end —
|
||
// 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.
|
||
const OPPONENTS = [
|
||
{ short: 'SHAMBLER', 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',
|
||
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',
|
||
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',
|
||
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,
|
||
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',
|
||
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',
|
||
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',
|
||
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',
|
||
line: 'CREAM WITH THAT?', speed: 1.04, pace: 1.06, wide: 1.10, flub: 0.90 },
|
||
{ short: 'STRINGER', 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',
|
||
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',
|
||
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',
|
||
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',
|
||
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',
|
||
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.
|
||
{ short: 'BRUTE', name: 'THE BRUTE', fam: 'br', h: 2.0,
|
||
line: 'NOTHING GETS PAST HIM TWICE.', speed: 1.02, pace: 1.34, wide: 1.24, flub: 0.66 },
|
||
{ short: 'DIRECTOR', name: 'THE DIRECTOR', fam: 'dr',
|
||
line: 'HE CAN HAVE YOU REMOVED.', speed: 1.26, pace: 1.28, wide: 1.28, flub: 0.60 },
|
||
// 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,
|
||
line: 'HE RETURNS EVERYTHING.', speed: 1.45, pace: 1.42, wide: 1.36, flub: 0.38 },
|
||
];
|
||
|
||
// Who opponent N is. This used to reuse the chapter roster's cycling (bossArt) so the two lists
|
||
// could never drift — DECOUPLED 2026-08-13 on Oliver's call: the match ladder is 12 rungs with the
|
||
// champion capping rung 12, while the chapter bosses stay a 10-design rotation with the champion
|
||
// on every tenth. The lists share CHARACTERS (a rung and a boss are the same person, anchored on
|
||
// the same art), just not arithmetic. The ladder cycles past 12 exactly as bossArt cycles past 10.
|
||
export const matchFoe = n => {
|
||
const i = (n - 1) % OPPONENTS.length;
|
||
return { key: 'L' + i, fam: 'r', ...OPPONENTS[i] };
|
||
};
|
||
// How many rungs there ARE, for everything that has to know the shape of the ladder rather than
|
||
// one rung of it: the ramp test, and the bracket screen's shuffle. OPPONENTS stays private —
|
||
// exporting the length is the whole of what anyone outside needs.
|
||
export const LADDER = OPPONENTS.length;
|
||
|
||
// ---- THE SPRITE BENCH's stops (back room only, see beats.startBench). Each is either an ANIMATED
|
||
// state or ONE NAMED FRAME, so frames the game cannot reach yet — the serve, the fall — can still be
|
||
// looked at the day they are generated rather than the day they are wired up.
|
||
export const BENCH = [
|
||
{ name: 'WALK CYCLE', walk: true },
|
||
{ name: 'READY STANCE', pose: 'idle' },
|
||
{ name: 'FOREHAND', pose: 'swing' },
|
||
// `spr` is a SUFFIX, not a filename: the bench builds z_r<suffix> / z_rw1<suffix> / z_rw2<suffix>
|
||
// from the wound stage, so the same left/right control walks the serve and the fall through their
|
||
// damage stages exactly as it walks the shuffle through them.
|
||
{ name: 'SERVE · BOUNCE', spr: 'bounce' },
|
||
{ name: 'SERVE · TOSS', spr: 'toss' },
|
||
{ name: 'SERVE · STRIKE', spr: 'serve' },
|
||
{ name: 'FALL 1 · BUCKLING', spr: 'fall1' },
|
||
{ name: 'FALL 2 · KNEES', spr: 'fall2' },
|
||
{ name: 'FALL 3 · DOWN', spr: 'fall3' },
|
||
{ name: 'RISE', spr: 'rise' },
|
||
];
|
||
export const BENCH_STAGES = ['HEALTHY', 'ARM OFF', 'HEADLESS'];
|
||
|
||
// ---- MATCH-OPPONENT SPRITE FAMILIES. A family is a complete opponent: the eleven states at three
|
||
// wound stages, 31 frames, all named off one key (`r` -> z_r1, z_rtoss, z_rw1fall2 …). Generating a
|
||
// new one is `assets/_master.py --variant <key>`; USING it is one row here, because everything that
|
||
// needs the names — the preloader, drawZombie's wound lookup, the sprite bench — derives them from
|
||
// this table rather than spelling them out. That is the whole point: 31 hand-written filenames in a
|
||
// preload list is 31 chances to typo one and have a body silently vanish for a frame.
|
||
// THE BRUTE ('br') IS PARKED, not deleted. Its healthy play sheet is good and is on disk; its serve
|
||
// and fall sheets each RE-INVENT the character, because they restyle from the MASTER — a lean
|
||
// red-shirted man — rather than from the brute that was just made. The palette gate now measures
|
||
// that (15 to 48 away from z_br1, where a good frame scores under 8.5) and refuses them, which is
|
||
// the gate working, not the gate being wrong. Re-registering it is one entry here; see the note at
|
||
// the top of _master.py for the fix its next attempt needs.
|
||
export const ZFAMS = ['r', 'br', 'sh', 'bb', 'st', 'ru', 'gk', 'lj', 'dr', 'ch', 'ph', 'ma',
|
||
'hc', 'sg', 'sb', 'cu', 'sp', 'rm'];
|
||
|
||
// HEAD CROPS for the opponent intro card — a straight source-pixel crop out of each family's own
|
||
// healthy frame (z_<fam>1.png), so the card's icon is the body on court, never separate art.
|
||
// MEASURED offline (silhouette-width profile: the head band ends where the shoulders widen; br and
|
||
// gk hand-extended past the rule — the brute's shoulders start early, the keeper's hat brim wider
|
||
// than his face). Re-run the measurement when a family sheet is regenerated. A family missing here
|
||
// simply gets a text-only card, so a new roster row never breaks the walk-on.
|
||
export const HEAD_CROP = {
|
||
r: { x: 3, y: 0, w: 71, h: 73 },
|
||
br: { x: 0, y: 0, w: 87, h: 61 },
|
||
sh: { x: 8, y: 0, w: 62, h: 65 },
|
||
bb: { x: 7, y: 0, w: 62, h: 65 },
|
||
st: { x: 14, y: 0, w: 57, h: 55 },
|
||
ru: { x: 14, y: 0, w: 57, h: 55 },
|
||
gk: { x: 6, y: 0, w: 73, h: 65 },
|
||
lj: { x: 6, y: 0, w: 74, h: 56 },
|
||
dr: { x: 10, y: 0, w: 62, h: 49 },
|
||
ch: { x: 3, y: 0, w: 68, h: 63 },
|
||
ph: { x: 12, y: 0, w: 63, h: 58 },
|
||
ma: { x: 0, y: 0, w: 124, h: 81 }, // the mascot's head IS the ball rim too — hand-deepened
|
||
};
|
||
|
||
// THE RACKET FAMILY'S THREE STAGES, as a FRACTION of the pool — never against literal hp, the rule
|
||
// this whole file already holds (see woundStage). It lived inline in drawZombie; the FALL BEAT needs
|
||
// the same answer to know which body collapses and which one gets back up, and two copies of a
|
||
// threshold is exactly how the boss's skins and its blood drifted apart before.
|
||
export const racketStage = zb => {
|
||
const f = zb.hp / (zb.hpMax || TUNE.racketHp);
|
||
return f <= 0.34 ? 2 : f <= 0.67 ? 1 : 0; // 0 unhurt, 1 arm off, 2 headless
|
||
};
|
||
// ...and what that stage COSTS it at the racket. One place, because racketAI reads it twice and two
|
||
// copies of a threshold is how the boss's skins and its blood drifted apart before. An unhurt body
|
||
// gets exactly 1 and 1, so the gore duel's racket zombie is untouched by this (see the G.match gate
|
||
// at the call sites — the duel is one sudden-death point and has no wound arc to express).
|
||
export const matchWound = zb => {
|
||
const st = racketStage(zb);
|
||
return { reach: 1 - TUNE.matchWoundReach * st, flub: 1 + TUNE.matchWoundFlub * st };
|
||
};
|
||
const POSES = ['bounce', 'toss', 'serve', 'fall1', 'fall2', 'fall3', 'rise'];
|
||
export const famFrames = f => [
|
||
`z_${f}1`, `z_${f}2`, `z_${f}3`, `z_${f}4`, // stance, shuffle A, shuffle B, forehand
|
||
...POSES.map(s => `z_${f}${s}`),
|
||
// the wound rows carry no separate stance — the shuffle doubles as one, which is how drawZombie
|
||
// reads them (…w1 / …w1b are the stride pair, …w1s the forehand)
|
||
...[1, 2].flatMap(st => [`z_${f}w${st}`, `z_${f}w${st}b`, `z_${f}w${st}s`,
|
||
...POSES.map(s => `z_${f}w${st}${s}`)]),
|
||
];
|
||
|
||
// The roster, as the BODY COUNT screen reads it. Until now a variant's NAME lived only in the
|
||
// comment above its hp value — fine while nothing ever had to print one. The key is the `variant`
|
||
// char the zombie already carries, except that 'a' and 'b' fold together (they are one shambler in
|
||
// two shirts, and two identical rows on the stats list would just look like a bug) and a chapter
|
||
// boss files under 'B0'..'B5' by BOSS_ART index, so the screen can name the design that got you.
|
||
// `spr` is the frame the row draws — the first walk frame, which every body has.
|
||
// The barrel ('o') is deliberately absent: it is loot, not an opponent, and killZombie scores it
|
||
// as nothing. Keep that agreement — a barrel row would put a crate in a list of the dead.
|
||
export const ENEMIES = {
|
||
a: { name: 'SHAMBLER', spr: 'z_a1', sub: 'THE ORDINARY DEAD' },
|
||
d: { name: 'THE STEWARD', spr: 'z_d1', sub: 'HI-VIS · ONE HIT TOUGHER' },
|
||
e: { name: 'THE BALL BOY', spr: 'z_e1', sub: 'STILL FETCHING' },
|
||
f: { name: 'THE RUNNER', spr: 'z_f1', sub: 'ONE HIT · BUT HE SPRINTS' },
|
||
g: { name: 'THE BRUTE', spr: 'z_g1', sub: 'SLOW · DRAGS A POLE' },
|
||
h: { name: 'THE BOUNCER', spr: 'z_h1', sub: 'BUILT LIKE A DOOR' },
|
||
k: { name: 'THE ROLLER MAN', spr: 'z_k1', sub: 'HAULS THE COURT ROLLER' },
|
||
s: { name: 'THE SPECTATOR', spr: 'z_s1', sub: 'BLOCKS WITH HIS CHAIR' },
|
||
// The man you were playing, after the transition turns him (gameplay.js spawns him as 'c'). One
|
||
// per run, and he is NOT the racket zombie below — that one keeps coming back. Missing this row
|
||
// is what put a second "SHAMBLER" on the screen: an unmapped key falls back to the shambler art.
|
||
c: { name: 'THE OPPONENT', spr: 'z_c1', sub: 'HE TURNED MID-MATCH' },
|
||
r: { name: 'THE CLUB PRO', spr: 'z_r1', sub: 'STILL HAS A BACKHAND' },
|
||
m: { name: 'THE BALL MACHINE', spr: 'z_mach1', sub: 'FIRES BACK · FURNITURE' },
|
||
};
|
||
for (let i = 0; i < BOSS_ART.length; i++) // the chapter bosses, named where they already are
|
||
ENEMIES['B' + i] = { name: BOSS_ART[i].name, spr: BOSS_ART[i].walk[0], boss: true,
|
||
sub: i === BOSS_ART.length - 1 ? 'THE FINAL · EVERY TENTH CHAPTER' : 'CHAPTER BOSS' };
|
||
// ...and the same roster again as MATCH opponents, because that is who walks on in zombie tennis.
|
||
// Separate keys, not shared ones: the two are different fights with different bodies, they are
|
||
// counted on different screens (the mode split in stats.js), and one row cannot mean both. The 'L'
|
||
// prefix is kept as-is because it is written into saved run records — renaming it would orphan them.
|
||
// ...and it reads OPPONENTS, not BOSS_ART. It used to take the NAME off the boss roster and the
|
||
// line off this one — which was the same answer only while the two lists were the same list. They
|
||
// were decoupled 2026-08-13 and the screen has been mislabelling rungs ever since (L4 printed THE
|
||
// PHYSIO over the mascot's line); at 18 rungs against 10 designs it would also have left the top
|
||
// six with no row at all. One list, its own names, its own bodies.
|
||
for (let i = 0; i < OPPONENTS.length; i++)
|
||
ENEMIES['L' + i] = { name: OPPONENTS[i].name, sub: OPPONENTS[i].line,
|
||
spr: 'z_' + (OPPONENTS[i].fam || 'r') + '1' };
|
||
|
||
// Which wound stage a body is in: 0 = unhurt, 1..n = the skins, spread evenly over its full health.
|
||
// This is the rule the chapter boss already used, and it turns out to reproduce the brute's old
|
||
// hardcoded thresholds EXACTLY (5 hp over 4 skins = one skin per hit) and the shamblers' single skin
|
||
// too — so there is now one rule for damage readout instead of three, and it is the one that keeps
|
||
// working when a body's hp is not one-per-skin. Every caller passes its own stage count.
|
||
export const woundStage = (zb, n) => {
|
||
const f = zb.hp / (zb.hpMax || 1);
|
||
return f >= 1 ? 0 : Math.min(n, Math.max(1, Math.ceil((1 - f) * n)));
|
||
};
|
||
// Wound skins by variant, HEALTHIEST FIRST — the same ordering BOSS_ART.wounds uses, indexed by
|
||
// woundStage above. This one table replaced two branches in drawZombie that did the same job against
|
||
// different constants (the brute had a hardcoded stage array, everything else a single skin behind
|
||
// `'abd'.includes(...)`), which is why a new multi-stage body is a row here and no code at all.
|
||
// Each name's `…b` twin — the stride swapped — is looked up automatically, so a wounded body keeps
|
||
// WALKING instead of freezing and sliding. 'r' and 'B' carry their skins elsewhere ('B' on its
|
||
// design row) but read them through the very same woundStage call.
|
||
export const ZWOUND = {
|
||
a: ['z_aw'], b: ['z_bw'], d: ['z_dw'],
|
||
e: ['z_ew', 'z_ew2'],
|
||
h: ['z_hw', 'z_hw2', 'z_hw3'],
|
||
g: ['z_gw', 'z_gw2', 'z_gw3', 'z_gw4'],
|
||
k: ['z_kw', 'z_kw2', 'z_kw3', 'z_kw4'],
|
||
};
|
||
|
||
// ---- THE ARSENAL. One table, keyed by id, replacing the five places the roster used to be spelled
|
||
// out (SLOTS, slotStock, WDESC, drawRack's three parallel arrays, and deliverTin's index mapping).
|
||
// The rack no longer has a fixed weapon per slot: G.rack holds up to four of these in the order you
|
||
// GATHERED them, and slot 0 stays BALL. A weapon is defined entirely by its row.
|
||
// kind 'serve' — arms the next serve: you aim and release it like a normal ball
|
||
// kind 'burst' — commits to a braced window that FIRES ITSELF: `len` seconds at `rate` rounds/s
|
||
// There is no third kind. A tap-per-round weapon shipped once and Oliver read it as broken —
|
||
// "the laser fired only when I tapped, that was confusing" — so a barrel weapon is now always one
|
||
// of exactly two things: the normal ball gesture made spectacular, or something that fires on its
|
||
// own for a while. Nothing in between.
|
||
// The pellet fields (r, dmg, hp, pierce, boom, ignite, arc, trail, col, speed, life) are stamped
|
||
// onto the round by firePellet, so every auto-firing weapon is the ONE pellet loop with different
|
||
// numbers — `arc` is what turns a flat round into a lobbed one.
|
||
export const WEAPONS = {
|
||
bomb: { label: 'BOMB', icon: 'w_bomb', tin: 3, kind: 'serve',
|
||
desc: ['DETONATES ON CONTACT', 'CLEARS A WHOLE CROWD AT ONCE'] },
|
||
triple: { label: 'TRIPLE', icon: 'w_triple', tin: 10, kind: 'serve', // was 3 — Oliver asked for ten
|
||
desc: ['ONE SERVE, THREE BALLS', 'FANNED WIDE ACROSS THE LANE'] },
|
||
fire: { label: 'FIRE', icon: 'w_fire', tin: 3, kind: 'serve',
|
||
desc: ['KILLS ON ANY TOUCH', 'AND SETS THE OTHERS ALIGHT'] },
|
||
gun: { label: 'GUN', icon: 'w_gun', tin: 1, kind: 'burst', len: 8, rate: 12,
|
||
r: 0.5, dmg: 0.34, hp: 2, speed: 26,
|
||
desc: ['HOLD THE LINE AND SPRAY', 'A BURST, NOT A SERVE'] },
|
||
// ---- the four that come out of a barrel. Won by shooting one, never delivered.
|
||
// A LOBBED tennis ball twice the weight of yours, thrown on its own beat, changing colour every
|
||
// shot and dragging its colour behind it. The showiest thing in the game and the one you meet first.
|
||
// rate was 1.6: about one lob every two thirds of a second, which is a long time to stand still
|
||
// late on. The abort in selectWeapon is the real answer to "I picked the wrong tin", but a lob
|
||
// cannon you cannot leave AND cannot feel working was two problems, so it throws faster now.
|
||
beacon: { label: 'BEACON', icon: 'w_beacon', tin: 1, kind: 'burst', len: 11, rate: 2.4,
|
||
r: 1.2, dmg: 2, hp: 4, arc: 1, hue: 1, trail: 1, trailR: 0.035, glow: 3.2, speed: 19,
|
||
sfx: 'thump', // it LOBS — a soft low thump as it goes, not a rifle crack
|
||
desc: ['IT THROWS ITSELF', 'TWICE THE BALL, EVERY COLOUR'] },
|
||
// "make this one way more pronounced": twice the rounds a second, a wider and longer cone, fat
|
||
// glowing rounds that throw their own embers, and every one of them lights what it touches.
|
||
// It REACHES THE FAR BASELINE (life 1.15 s x 23 m/s = ~26 m), it PIERCES, and it does no impact
|
||
// damage at all: dmg/hp 0. Fire is not a bullet — being hit sets you alight and the burn does the
|
||
// killing, so a lit crowd walks a few steps and goes down together. That is the whole weapon.
|
||
// trail 2 -> 4 and a WIDE trailR: an ignite round throws fat drifting embers rather than the
|
||
// beacon's one-pixel thread (see the pe.ignite branch in the trail block), so the stream reads as
|
||
// a body of fire with edges instead of a line of dots. glow 2 -> 2.8 to match.
|
||
flamer: { label: 'FLAMER', icon: 'w_flamer', tin: 1, kind: 'burst', len: 6, rate: 30,
|
||
r: 1.15, dmg: 0, hp: 0, pierce: true, speed: 23, life: 1.15, ignite: true, igniteR: 2.6,
|
||
trail: 4, trailR: 0.34, col: '#ff8420', glow: 2.8, spread: 3.1, sfx: 'flame',
|
||
desc: ['A WALL OF FIRE', 'AND THE FIRE SPREADS'] },
|
||
// The normal ball gesture, made spectacular: pull, aim, release — and instead of a ball you get a
|
||
// blue plane that widens down the court and cuts through everything standing in it.
|
||
laser: { label: 'LASER', icon: 'w_laser', tin: 6, kind: 'serve', beam: 1,
|
||
// NOT magenta: the icon art is chroma-keyed and the key eats every pink/purple
|
||
// (min(r,b) > g+40), so a magenta laser came back as a hollow outline. Pink stays
|
||
// the BARREL's identity, where it is a procedural glow and never goes near the key.
|
||
col: '#3f74ff', // saturated: a pale blue washed out to grey over the lawn
|
||
desc: ['AIM AND RELEASE', 'A WIDENING WALL OF LIGHT'] },
|
||
// Lobbed, self-throwing, and it detonates like a bomb where it lands. Deliberately a LATE pickup
|
||
// (see pickBarrelWeapon) — Oliver: "best later in the game when there is really a lot going on".
|
||
rocket: { label: 'ROCKET', icon: 'w_rocket', tin: 1, kind: 'burst', len: 9, rate: 1.1,
|
||
r: 0.75, dmg: 3, hp: 9, arc: 1, boom: true, jet: 3, speed: 38, sfx: 'swoosh',
|
||
col: '#ffd050', glow: 1.8,
|
||
desc: ['IT LAUNCHES ITSELF', 'AND EVERY ONE IS A BOMB'] },
|
||
};
|
||
// Every row knows its own key. The rack has always looked weapons up BY key and passed the row
|
||
// around after that, so nothing needed the id — the stats screen does, because a kill has to be
|
||
// filed under the weapon that made it long after the row itself is out of scope.
|
||
for (const k in WEAPONS) WEAPONS[k].id = k;
|
||
// What a pink barrel can be carrying — the four above, which you cannot win any other way. NOT a
|
||
// flat 1-in-4: on that, Oliver "got the laser almost every time" and never once saw the beacon.
|
||
// Weighted, gated, and it never hands you the same thing twice running (see pickBarrelWeapon).
|
||
// weight relative chance once it is unlocked
|
||
// from games finished before it can appear at all
|
||
export const BARREL_WEAPONS = {
|
||
beacon: { weight: 4, from: 0 }, // the showpiece, and the one you should meet first
|
||
flamer: { weight: 3, from: 2 },
|
||
laser: { weight: 2, from: 6 },
|
||
rocket: { weight: 3, from: 12 }, // the late-game answer: it should never turn up in game three
|
||
};
|
||
// Gathered weapons the rack holds (plus slot 0 = BALL, so ten tiles). Was 4, and at four you were
|
||
// throwing away a barrel almost every time one popped — Oliver: "that makes replacing the ball rarer
|
||
// if not unnecessary at all". The `temp` valve below survives for the eleventh, which nobody will see.
|
||
export const RACK_MAX = 9;
|
||
export const RACK_ROW = 5; // tiles per row — past five the rack grows a SECOND row upward, so a tile
|
||
// never shrinks below the thumb target rackHit was widened for
|
||
|
||
export const MACH_H = 1.55; // was 1.15 — waist high read as a prop you could ignore. Still furniture
|
||
// (a body is PLAYER_H 1.88), but big enough to be a thing on the court.
|
||
|
||
// Drawn height of a body on the far side, by variant. render draws to it AND the serve aims at a
|
||
// fraction of it, so the two cannot drift — a shot at a brute has to arrive at the brute's chest,
|
||
// not at the height a shambler's chest happens to be.
|
||
export const zombieH = zb => zb.variant === 'o' ? TUNE.barrelH
|
||
: zb.variant === 'g' ? 2.7 : zb.variant === 'd' ? 2.15
|
||
: zb.variant === 'k' ? 3.2 : zb.variant === 'h' ? 2.4
|
||
: zb.variant === 'r' ? ((zb.foe && zb.foe.h) || 1.85) : zb.variant === 'm' ? MACH_H
|
||
: zb.variant === 'B' ? 1.8 * (zb.scale || 1.5) : 1.8;
|
||
// Where on that body the ball is aimed, and where the aim mark is drawn. Solving the shot to the
|
||
// GROUND under a shambler meant the ball came down past it and struck at the ankles — the wound is
|
||
// on the torso, so the ball should arrive there.
|
||
export const Z_CHEST = 0.58;
|
||
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).
|
||
// dx/oDx: the tossing HAND's x offset from the frame centre, in metres, at facing +1 — the ball
|
||
// must rise over the raised palm, not the head (Oliver's report). MEASURED off the toss frames
|
||
// (topmost opaque row = the fingertips, × PLAYER_H/SPRITE_H): p_toss −52 px → −0.44 m,
|
||
// o_toss +50 px → +0.42 m (he is drawn from the front, so his left hand is the viewer's right).
|
||
// The women's frame reaches further (p_toss_f −70 px → −0.59 m) — TOURS overrides tossDx.
|
||
export const TOSS = { y0: 1.8, vy: 4.6, dx: -0.44, oDx: 0.42 };
|
||
|
||
// 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.
|
||
// dx/apex are per-man because the two sprites reach differently, and a single pair put the
|
||
// opponent's ball out by his racket instead of under his palm. Both are FEEL KNOBS measured off the
|
||
// drawn palm by eye — a skin-detector guess at the hand lands on a knee, so tune these by looking.
|
||
export const BOUNCE_IDLE = { dx: 0.36, apex: 0.68, tempo: 3.6, // the player, drawn from behind
|
||
// the opponent, drawn from the front. Remeasured 2026-08-14 for the
|
||
// friendlier anchor art: HIS new palm-out bounce holds the ball close
|
||
// (palm +0.24 m, 0.83 m up); HER reach stays wide — her pair rides
|
||
// TOURS like the player's dx/apex already does.
|
||
oDx: 0.24, oApex: 0.72 };
|
||
|
||
// 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.
|