Oliver: the far-side opponent is barely visible in portrait, and the lens was already at its measured ceiling. TUNE.farK (0.25) grows far bodies ~20% while the court keeps the true projection — the 16-bit tennis trick, anchored so the near player is untouched. TUNE.farCamY/farCamZ add a broadcast lean while the ball is on the far half, pivoted about the player's feet (fOff) so the pull room the apron math promised never moves. Both live in the T panel; both awaiting the phone verdict — see the PROJECT.md session log. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1033 lines
83 KiB
JavaScript
1033 lines
83 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,
|
||
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.
|
||
// 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. Defaults are deliberately
|
||
// shy of visible: the phone (not the desktop pane) is where a moving camera gets its verdict.
|
||
farCamY: 0.35, // m of camera rise while the ball is far (steepens the look-down)
|
||
farCamZ: -0.6, // 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
|
||
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.
|