Files
splatter-tennis/src/config.js
T
wuwiandClaude Opus 5 031a203bb4 Blood fountains at neck height over a body lying flat
Two notes off the bench, both about the fall.

THE BLEEDING DID NOT STOP. bleed()'s wound anchors are ABSOLUTE WORLD HEIGHTS —
the headless neck spurt is pinned at y = 1.5 m, the torn arm at 1.15 — so a
corpse lying flat through the whole 2 s floor hold kept spraying from where its
neck used to be, two metres above itself. Oliver: "the dynamic bleeding must
stop during fall as this looks like blood fountains midair." The line calling it
already said "open wounds bleed every frame they're STANDING" and never enforced
it; now it does. Measured on the headless stage: 20 particles emitted while
walking, 0 during the fall. Only the body G.matchFall owns is suppressed —
everything else on court bleeds as before.

THE SLIDE NOW FINISHES BEFORE THE RISE. "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. fallSlideMix goes
[.10 .30 .45 .15] -> [.12 .35 .53 0], the rise's share redistributed across the
first three IN PROPORTION so the total travel is unchanged: 0.06 / 0.26 / 0.41 /
0.000 m, still 0.77 m end to end.

Watch fall3: it carries 0.41 m over its 2 s hold now. The ease-out front-loads
it so it should still read as a skid that settles rather than a creep, and if it
drags, share moves back into fall2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 08:23:19 +02:00

1020 lines
82 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
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.62, // 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 CAN sail (~15% of the time at quality 0, on the worst
// half of the scatter) and a perfectly timed one never can — the risk is
// back and it is timing, not a coin toss, that decides it.
// 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.
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.
zSpeedMax: 2.9, // cap on that ramp (top speed = zSpeed * 3.9). runnerVMax still caps runners.
nastyPerGame: 0.03, // share of spawns drawn from the advanced roster, per finished game
nastyMax: 0.75, // ...capped here (g14 matches the old 0.42, g25 hits the cap). The old mix was
// frozen at exactly 0.42 from game 12 to the end of the run.
hordeGrow: 0.2, // horde length AND spawn rate both scale by this per set already banked
zHordeCap: 34, // hard body ceiling DURING a horde. A horde ignores the director cap on
// purpose (that is the point of it), but hordeGrow means a late wave now tries
// for ~120 spawns where the old fixed 12 s x 2.5/s could only ever ask for 30,
// and a 40-body court on a 270px-wide portrait screen is mush, not menace.
// ---- PLAYER HEALTH. A SECOND, SEPARATE damage channel: only ranged attacks touch it. A shambler
// reaching you is still instant permadeath (G.mode='eaten') — that decision is settled, see 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
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)
// ---- damage. Bodies with hp (racket boss 'r', brute 'g') take damage in these units, so a big
// boss is just a bigger hp number and every weapon stays proportional. Fractions are legal: they
// bank on zb.chip and only whole points come off hp (the wound SKINS switch on hp thresholds).
dmgBall: 1, // a splat-quality ball or serve
dmgSide: 1, // one TRIPLE side ball — used to pass straight through the boss doing nothing
dmgPellet: 0.34, // one machine-gun round — three chip a point off. Also used to no-op on the boss
dmgBomb: 3, // caught in a blast. Was an instant kill that ignored hp entirely
dmgFlame: 2, // a direct flame-ball touch (and it sets the boss alight, which it didn't before)
bossBurnMul: 0.4, // burnDps multiplier while a boss body cooks — it burns, just slower than a body
bossHitCool: 0.2, // s a boss body is immune to further BALL contact after taking a hit. A flame ball
// has full pierce (it is not consumed on contact, so it can cut a lane), and a boss
// is not removed when hit — so the same ball sat inside its radius and re-wounded it
// EVERY FRAME. That is why one flame ball killed a 16 hp chapter boss.
changeoverT: 5.0, // s the changeover breather lasts (after odd games, as the rules demand)
boyMopSpeed: 2.2, // m/s a MOPPING ball boy crosses at (a fetching one still sprints at 7.5)
boyMopDwell: 1.8, // s he stands on a stain pushing the mop before moving to the next one
boyMaxJobs: 4, // stains one boy will clean in a round — a kill streak must not employ him forever
boyStride: 0.28, // m of travel per animation frame. Frames are driven by DISTANCE, not time, so
// the feet cannot slide: change a boy's speed and his legs keep up on their own.
gameBeatT: 1.6, // s the game-end beat lasts on even (non-changeover) games
eatenR: 0.9, // m a shambler must close to eat you — matches the visual sprite overlap
zSnap: 0.9, // m of lateral slack at zero range — the aim cone's base width (fat-thumb forgiveness)
killsPerPoint: 3, // splatter: zombie kills to score ONE tennis point (1 = every kill scores, old behavior)
zSplatQ: 0.5, // min shot quality to splatter; below = stagger pushback
zPushback: 1.4, // m a staggered zombie is knocked back
splatterOverT: 0.1, // s the spent-ball 'over' beat lasts before the next serve is armed
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
aimTie: 0.12, // how much worse-aimed a NEARER body may be and still win the lane (0..1 of the
// cone half-width). 0 = pure best-aligned wins; 1 = the old "nearest in the
// lane always wins", which is what made the magnet grab the wrong shambler.
aimReach: 22, // m the aim ray runs when no shambler stands in the lane
// How the camera moves while you're drawing the slingshot. NEGATIVE (the default) drops it and
// pushes in — you crouch into the shot. POSITIVE lifts it and leans back for a steeper, more
// top-down look, which reads the depth behind the net better but feels less like aiming.
// Both 0 = the old fixed camera.
aimCamY: 0, // m of camera height change (negative drops you into the shot)
aimCamZ: 0, // m of camera dolly (negative = closer). Both 0 = the camera holds still,
// which is where Oliver landed: the aim graphics carry the read on their own.
backstopZ: -1.8, // z of the far backstop — must sit IN FRONT of the -2 retire line, or balls die before they reach it
backstopKeep: 0.55, // pace a ball keeps after the rebound — still lethal, slower
backstopPop: 1.8, // m/s of lift off the fence, so the rebound travels back instead of dying at the net posts
meleeCool: 1.0, // s between racket swishes — melee is the panic button, not the default
meleeCrowd: 0.38, // ...but the panic button PANICS. The cooldown shortens with how many bodies are
// actually on top of you, so a late horde stops outrunning your only close-range
// answer: cool = meleeCool / (1 + (near-1)*meleeCrowd), floored at meleeCoolMin.
// 1 body 1.00s, 4 bodies 0.47s, 8 bodies 0.28s.
meleeCoolMin: 0.22,
meleeCrowdR: 3.4, // m — the radius that counts as "on top of you" for that scaling (> meleeRange,
// so the swish speeds up as they close in, not only once they are already on you)
zPierce: 0.75, // pace a ball keeps after a splatter — hot balls plow through lined-up pairs
// ---- 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
barrelEvery: 3, // games between barrels being rolled on (like the ball machine's machEvery)
barrelRamp: 8, // ...and the gap SHRINKS by one for every this many games finished, floored at
// every game (g8 every 2, g16+ every game). Late runs live on the barrels: past
// the point where every tin is capped, fast tapping was the only way left to
// keep up, and "more inventory for strategic use" is the answer to that.
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)
drillNearZ: 9.0, // spawn spacing is measured against bodies nearer than this
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
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)
// 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
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 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
// `tile` is an existing sprite rather than new art on purpose — each one is the thing you will be
// looking at for the whole mode, which is a better advert than a drawn icon of it.
export const MODES = [
{ id: 'classic', name: 'CLASSIC TENNIS', sub: 'REAL TENNIS · BEST OF THREE SETS', tile: 'o_swing1' },
{ id: 'zombie', name: 'ZOMBIE TENNIS', sub: 'ONE GAME PER OPPONENT · BLOOD ONLY', tile: 'z_r1' },
{ id: 'gore', name: 'SPLATTER GORE', sub: 'THE MATCH TURNS · SURVIVE IT', tile: 'z_boss1' },
];
export const TOURS = [
{ id: 'men', name: "MEN'S SINGLES", tile: 'tour_m', sfx: null },
{ id: 'women', name: "WOMEN'S SINGLES", tile: 'tour_w', sfx: '_f', dx: 0.46, apex: 0.78 },
];
// 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-2 the ones who fetch and sweep. Slow, wild, forgiving.
// 3-4 the ones who keep order. They start covering ground.
// 5-6 the ones who judge. Width arrives: they begin painting lines.
// 7-8 the ones who actually played. The club pro's own body, and the brute behind him.
// 9-10 management, and then the man whose name is on the honours board.
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.82, pace: 0.8, wide: 0.82, flub: 1.2 },
{ 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 },
{ short: 'STEWARD', name: 'THE STEWARD', fam: 'st',
line: 'HE IS ASKING YOU TO LEAVE.', speed: 0.98, pace: 0.95, wide: 1.0 },
{ 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.95 },
{ short: 'JUDGE', name: 'THE LINE JUDGE', fam: 'lj',
line: 'EVERY BALL OF YOURS IS OUT.', speed: 1.05, pace: 1.05, wide: 1.28, flub: 0.85 },
{ short: 'CLUB PRO', name: 'THE CLUB PRO',
line: 'HE GAVE LESSONS ON THIS COURT.', speed: 1.12, pace: 1.15, wide: 1.15, flub: 0.8 },
{ short: 'THE BULK', name: 'THE BULK', fam: 'br',
line: 'NOTHING GETS PAST HIM TWICE.', speed: 1.0, pace: 1.3, wide: 1.2, flub: 0.75 },
{ short: 'DIRECTOR', name: 'THE DIRECTOR',
line: 'HE CAN HAVE YOU REMOVED.', speed: 1.22, pace: 1.22, wide: 1.25, flub: 0.65 },
{ short: 'CHAMPION', name: 'THE CHAMPION',
line: 'HE RETURNS EVERYTHING.', speed: 1.35, pace: 1.3, wide: 1.3, flub: 0.5 },
];
// Who opponent N is. The chapter roster's own order is reused wholesale (bossArt), so the champion
// lands on opponent 10 exactly as he lands on chapter 10 and the two rosters can never drift apart.
export const matchFoe = n => {
const art = bossArt(n), i = BOSS_ART.indexOf(art);
return { name: art.name, key: 'L' + i, fam: 'r', ...OPPONENTS[i] };
};
// ---- 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'];
// 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
};
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.
for (let i = 0; i < BOSS_ART.length; i++)
ENEMIES['L' + i] = { name: BOSS_ART[i].name, spr: 'z_r1', sub: OPPONENTS[i].line };
// 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' ? 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)
export const TOSS = { y0: 1.8, vy: 4.6 };
// shot target depth from the receiving baseline, by aim (up = deep, none = mid, down = short)
export const SHOT_DEPTH = { deep: 3.0, mid: 5.4, short: 8.6 };
// how long swing/serve poses hold before returning to idle (s)
export const POSE_TIME = 0.38;
// the server's idle ball bounce: sideways offset to the free hand, apex height, tempo.
// tempo drives the ball, the hand sprite AND the pock sound — one value, always in sync.
// Measured off the sprite, not guessed: the player is RIGHT-handed, so he pats the ball with his
// LEFT hand — the left-hand side of the image — whose palm sits ~0.69 m above his feet and ~0.36 m
// off his centre line. The ball tops out just under that hand instead of beside it.
// 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
oDx: 0.45, oApex: 0.55 }; // the opponent, drawn from the front
// 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.