// All tuning and constants. TUNE is live-editable in game via the T panel; // the exported consts below are structural values that code shares but the panel doesn't show. export const TUNE = { camZ: 42, camY: 9, camF: 406, horizon: 40, // camF/horizon are per-orientation, set in fitViewport gravity: 9.2, // slightly floaty vs 9.8 for readability spinFactor: 0.9, // extra dive on quality shots (fake topspin) bounceY: 0.58, bounceXZ: 0.78, playerSpeed: 5.4, aiSpeed: 4.3, reach: 1.3, aiReach: 1.6, // tight contact radius: the assist positions you, the tap must be honest swingWindup: 0.06, // s before contact window opens swingWindow: 0.16, // s contact window length // 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. aimError: 2.4, // m of scatter at quality 0 perfectMs: 45, // |offset from window center| for PERFECT aiQualityMin: 0.45, aiQualityMax: 0.85, aiFlub: 0.09, aiReaction: 0.45, // s before the CPU reads your shot — well-aimed corners are winners autoAssist: matchMedia('(pointer: coarse)').matches ? 1.15 : 0, // auto-run speed factor (mobile) zSpeed: 0.7, // zombie shamble speed m/s // ---- THE DIFFICULTY DIRECTOR. Every dial below used to bottom out by about game 12, which is why // a 50-game run stopped changing (games 12..50 were arithmetically identical). They are all keyed // on G.runGames now and all keep moving; the ceilings that remain exist for perf or fairness, not // because the curve ran out. See HANDOFF "The difficulty director". // Calibrated to TRACK the old curve through roughly game 12 and then keep going, rather than to // replace it: the first pass at this made games 1-30 easier, which is the opposite of the ask. zSpawnEvery: 3.0, // s between spawns in game 1 zSpawnAccel: 0.16, // hyperbolic tightening: gap = zSpawnEvery / (1 + games * this). g5 1.67s, // g11 1.09s (old floored at 1.0 here and stopped dead), g20 0.71s, g30 0.52s zSpawnMin: 0.35, // hard spawn-gap floor, safety only — reached around game 41 zMax: 3, // shamblers on court in game 1 zMaxPerGame: 0.9, // cap growth per finished game (was a flat +1 into a wall at game 11) zMaxCap: 24, // absolute shambler cap — readability ceiling, reached ~game 24 zSpeedPerGame: 0.13, // shamble-speed ramp per finished game. Was TIME-based (splatT/70) and hit its // ceiling after 105 seconds of splatter, i.e. during game 2 — so shamble speed // was never a progression lever at all. g12 matches the old top speed (1.75 // m/s), and it climbs to 2.7 m/s by game 22. zSpeedMax: 2.9, // cap on that ramp (top speed = zSpeed * 3.9). runnerVMax still caps runners. nastyPerGame: 0.03, // share of spawns drawn from the advanced roster, per finished game nastyMax: 0.75, // ...capped here (g14 matches the old 0.42, g25 hits the cap). The old mix was // frozen at exactly 0.42 from game 12 to the end of the run. hordeGrow: 0.2, // horde length AND spawn rate both scale by this per set already banked zHordeCap: 34, // hard body ceiling DURING a horde. A horde ignores the director cap on // purpose (that is the point of it), but hordeGrow means a late wave now tries // for ~120 spawns where the old fixed 12 s x 2.5/s could only ever ask for 30, // and a 40-body court on a 270px-wide portrait screen is mush, not menace. // ---- PLAYER HEALTH. A SECOND, SEPARATE damage channel: only ranged attacks touch it. A shambler // reaching you is still instant permadeath (G.mode='eaten') — that decision is settled, see HANDOFF. // There is no HP bar and there never will be: the state shows as blood on the man. playerHp: 4, // hits from ranged attacks you can absorb before you go down hurtGrace: 1.1, // s of invulnerability after a hit — a burst must not delete you in one volley healPerDuel: 2, // hp back for winning a sudden-death duel. THE reason to want the duel. // ---- the rogue ball machine ('m'): real tennis furniture that shoots at you. It never shambles, // so it never crosses your baseline and never scores — it is a standing threat you choose when to // deal with, which is the target-priority decision the game was missing. machEvery: 4, // games before the first machine can appear, and games between them after machMax: 2, // machines allowed on court at once machHp: 6, // splat-quality hits to wreck one. It is a steel cart, not a body — and it now // shows the damage on the way down (z_machw1 dented, z_machw2 split open) machRoll: 1.6, // m/s it trundles when it repositions. It CANNOT fire while rolling, which is // both honest (a rolling cart cannot aim) and the window you use to close on it machRepo: 7, // s between repositions, jittered. "Furniture that occasionally wheels itself // somewhere else" — it stops being a landmark you can ignore once it is aimed away machCycle: 3.4, // s between its shots machWind: 0.9, // s of TELEGRAPH before each shot — the fairness contract, same idea as shriek() machSpeed: 13, // m/s it fires at machDmg: 1, // player hp a shot costs if you don't return it shotHitR: 0.42, // m — how close a hostile ball must ACTUALLY be to hit you. Measured: the old test // (0.55 m with the parry's 0.8 z-weighting) fired while the ball was still 0.68 m // up-court and drawn up around his head — "visually the ball is not yet at the // player but somehow the player already is hit". Depth is NOT weighted here. shotHitY: 1.75, // m — and it has to be at body height, so a lob passing overhead never counts parryPow: 0.85, // shot quality of a RETURNED machine ball — a parry is a real weapon // The parry gets its OWN generous reach and window, not the normal swing's. Returning a shot is the // intended answer, on a phone, with one thumb, against something aimed at you — it has to be // forgiving. parryWindow must stay <= swingWindup + swingWindow*VOLLEY.window (0.348 s) or the // swing clock in update() expires before the window closes. // TIGHTENED after Oliver's second playtest: "I can tap pretty early and it returns the ball". // At parryArm 6.0 / window 0.34 / reach 2.4 the shot flew INTO reach during the window, so any tap // once it was vaguely close worked and the timing stopped mattering. At 13 m/s a 3.4 m arm is about // 0.26 s of "the tap counts", which is a real reaction test and still landable one-thumbed. parryArm: 3.4, // m — an incoming shot this close makes the next tap a SWING instead of a serve parryReach: 1.7, // m — how far off the racket a return still connects (normal volley reach is 1.82) parryWindow: 0.2, // s the return window stays open after the tap // A return is a GLANCING blow: contact off the middle of the racket face throws it that many // degrees off straight up-court. Returns used to mirror the incoming line — straight back at the // thrower — so a boss handed you a free wound with every volley. Meeting it dead centre still // sends it back at him; that is now a placement you earn by moving your feet. parryDeflect: 55, // ° of deflection at the edge of the reach parryJitter: 8, // ° of scatter on top — a bat-away is never surgical // ---- chapter bosses: one every bossEvery games, escalating. The racket zombie is demoted to an // occasional opponent (racketEvery) so the boss is the only thing that reads as a wall. bossEvery: 10, // games between chapter bosses bossHp: 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: 1.5, // s a charge lasts before it withdraws 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 asideEvery: 55, // s between the umpire's sarcastic asides in splatter (jittered +-40%). He never // acknowledged that the match had changed; now he comments on it, disapprovingly. racketHpPerGame: 0.12, // the racket zombie stiffens as the run goes on (floored into whole hp) // ---- damage. Bodies with hp (racket boss 'r', brute 'g') take damage in these units, so a big // boss is just a bigger hp number and every weapon stays proportional. Fractions are legal: they // bank on zb.chip and only whole points come off hp (the wound SKINS switch on hp thresholds). dmgBall: 1, // a splat-quality ball or serve dmgSide: 1, // one TRIPLE side ball — used to pass straight through the boss doing nothing dmgPellet: 0.34, // one machine-gun round — three chip a point off. Also used to no-op on the boss dmgBomb: 3, // caught in a blast. Was an instant kill that ignored hp entirely dmgFlame: 2, // a direct flame-ball touch (and it sets the boss alight, which it didn't before) bossBurnMul: 0.4, // burnDps multiplier while a boss body cooks — it burns, just slower than a body bossHitCool: 0.2, // s a boss body is immune to further BALL contact after taking a hit. A flame ball // has full pierce (it is not consumed on contact, so it can cut a lane), and a boss // is not removed when hit — so the same ball sat inside its radius and re-wounded it // EVERY FRAME. That is why one flame ball killed a 16 hp chapter boss. changeoverT: 5.0, // s the changeover breather lasts (after odd games, as the rules demand) boyMopSpeed: 2.2, // m/s a MOPPING ball boy crosses at (a fetching one still sprints at 7.5) boyMopDwell: 1.8, // s he stands on a stain pushing the mop before moving to the next one boyMaxJobs: 4, // stains one boy will clean in a round — a kill streak must not employ him forever boyStride: 0.28, // m of travel per animation frame. Frames are driven by DISTANCE, not time, so // the feet cannot slide: change a boy's speed and his legs keep up on their own. gameBeatT: 1.6, // s the game-end beat lasts on even (non-changeover) games eatenR: 0.9, // m a shambler must close to eat you — matches the visual sprite overlap zSnap: 0.9, // m of lateral slack at zero range — the aim cone's base width (fat-thumb forgiveness) killsPerPoint: 3, // splatter: zombie kills to score ONE tennis point (1 = every kill scores, old behavior) zSplatQ: 0.5, // min shot quality to splatter; below = stagger pushback zPushback: 1.4, // m a staggered zombie is knocked back splatterOverT: 0.1, // s the spent-ball 'over' beat lasts before the next serve is armed // the slingshot: the pull's DIRECTION picks a lane, its LENGTH is power. Deliberately DECOUPLED — // reaching a far shambler must not cost a longer drag than the screen has room for. The old // mapping (pull length = reach) is why you had to drag off the bottom of the phone. pullMin: 18, // px of pull below which the release cancels — no shot, nothing spent pullFull: 130, // px of pull for a full-power shot. Keep this well inside one thumb's travel — but // 95 was so short that EVERY pull maxed out by accident and the meter never // meant anything. A full draw should cost a deliberate stretch of the thumb. // Capped short of ~150 because a pull that STARTS low on the screen has to // still be completable downward before the finger runs off the glass. pullQMin: 0.12, // shot quality at pullMin, scaling to 1.0 (= PERFECT) at pullFull. Below // zSplatQ it only staggers, so a lazy flick can't kill: THE anti-spam knob. aimCone: 0.10, // lane half-width added per meter of range. This is what makes the pull's ANGLE // mean something: at 0.45 the cone was ±10 m at the far baseline — the whole // court — so every downward pull picked the same body and the angle felt inert. // 0.10 gives ±2.9 m out there: you have to actually point at a lane. pullHintAfter: 1.5, // s of doing nothing in the serve stance before the pull gesture demos itself aimTie: 0.12, // how much worse-aimed a NEARER body may be and still win the lane (0..1 of the // cone half-width). 0 = pure best-aligned wins; 1 = the old "nearest in the // lane always wins", which is what made the magnet grab the wrong shambler. aimReach: 22, // m the aim ray runs when no shambler stands in the lane // How the camera moves while you're drawing the slingshot. NEGATIVE (the default) drops it and // pushes in — you crouch into the shot. POSITIVE lifts it and leans back for a steeper, more // top-down look, which reads the depth behind the net better but feels less like aiming. // Both 0 = the old fixed camera. aimCamY: 0, // m of camera height change (negative drops you into the shot) aimCamZ: 0, // m of camera dolly (negative = closer). Both 0 = the camera holds still, // which is where Oliver landed: the aim graphics carry the read on their own. backstopZ: -1.8, // z of the far backstop — must sit IN FRONT of the -2 retire line, or balls die before they reach it backstopKeep: 0.55, // pace a ball keeps after the rebound — still lethal, slower backstopPop: 1.8, // m/s of lift off the fence, so the rebound travels back instead of dying at the net posts meleeCool: 1.0, // s between racket swishes — melee is the panic button, not the default meleeCrowd: 0.38, // ...but the panic button PANICS. The cooldown shortens with how many bodies are // actually on top of you, so a late horde stops outrunning your only close-range // answer: cool = meleeCool / (1 + (near-1)*meleeCrowd), floored at meleeCoolMin. // 1 body 1.00s, 4 bodies 0.47s, 8 bodies 0.28s. meleeCoolMin: 0.22, meleeCrowdR: 3.4, // m — the radius that counts as "on top of you" for that scaling (> meleeRange, // so the swish speeds up as they close in, not only once they are already on you) zPierce: 0.75, // pace a ball keeps after a splatter — hot balls plow through lined-up pairs // The schedule calls the DUEL, not the tin: weapons are only ever won, but a duel comes round // on its own so there is always a chance at one. Fires off the next hand-cleared wave. duelFirst: 2, // game after which the first scheduled sudden-death duel is called duelEvery: 3, // games between scheduled duels after the first // 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: 9, // per-type cap on the courtside rack — tins accumulate, but only so many rackH: 30, // px height of the bottom weapon rack (feel knob: how much court it covers). // 22 logical px is ~30 CSS px on a phone — under half Apple's 44px target, which // is why switching tins felt fiddly. rackHit also pads the touch band (render.js). bombRadius: 4.2, // m blast radius of a bomb ball — 2.5 barely cleared the bodies stood on the crater hordeLen: 12, // s a horde wave lasts hordeRate: 3.5, // zombies spawned per second during a horde. Raised WITH the gun buff below: the // wave is meant to leave a standing crowd on your half when it ends, not to be a // twelve-second interruption that clears itself. mgRate: 12, // machine-gun pellets fired per second during a horde. It scales with hordeGrow too // (see startHorde): at 6/s single-target the gun simply could not out-kill a grown // wave — Oliver died on the second horde of every run. mgSpread: 6, // ± degrees of pellet spread — a cone that sweeps bodies, not a needle up the middle mgPelletR: 0.5, // m of extra hit radius on a pellet: a fat round. "Bigger ball = more damage." dmgPelletHp: 2, // hp a pellet takes off a body — shamblerHp is 2, so the horde gun one-shots them mgPickupLen: 8, // s the machine-gun tin (4th delivery) auto-fires outside hordes // ---- 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: 8, // bodies on court: enough to test a crowd weapon, few enough to read testZSpeed: 1.15, // m/s shamble — medium, and FIXED (no per-game ramp) testSpawnEvery: 1.6,// s between spawns testLevel: 20, // the roster level pickVariant pretends to be at, so every body type can turn up testBarrelEvery: 14,// s between barrels, so the pickup itself is testable too // ---- THE LASER's wedge: aim and release like a serve, and a plane of light widens down the lane beamLen: 26, // m it reaches — the whole court, so a lane you aim is a lane you clear beamW0: 0.35, // m half-width at the racket... beamW1: 4.2, // ...and at the far end. It is a CONE: the spread is the point of it beamT: 0.42, // s the wedge stays drawn dmgBeam: 3, // hp the beam takes off anything it catches (dmgBomb is 3, and it reads the same) // ---- 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) 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) fps: 60, // frame cap — 30 halves battery/heat but Oliver felt it; smoothness wins (0 = uncapped) runnerSpeed: 3.2, // runner ('f') speed multiplier over the shamble runnerVMax: 4.2, // hard runner speed cap — the late-game ramp must stay humanly reactable shamblerHp: 2, // splat-quality hits a classic shambler ('a'/'b') soaks before it dies — only the runner ('f') is still one-shot glass stewardHp: 5, // the steward ('d'). Was 3 and died like a shambler, which made a bigger body read // as decoration; at 5 he is a brute's worth of hp on a faster, ordinary-looking // walker, and he has a wound skin (z_dw) so the second half of that shows hordeSteward: 0.2, // share of horde spawns that come out as stewards (from game 4) // ---- 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.0, // s the centered SET card holds (the 'kurze unterbrechung' before the horde) modalIn: 0.34, // s the modal swooshes in — icon+title from the right, body cascading from the // left, each on easeOutBack. The gesture that dismisses is swallowed until it // lands, so a panic tap can't skip a popup before it has been read. bruteStride: 0.5, // m the brute covers per lurch frame — it is the slowest thing on court, so // its cycle is driven by ground covered rather than by the clock bruteHp: 5, // splat-quality hits (or pellets) a brute ('g') soaks before it dies — one wound skin per stage shieldDown: 1.5, // s a shield zombie's ('s') chair stays dropped after a block — the kill window meleeRange: 1.7, // m — a racket swish cleaves shamblers this close to you racketHp: 3, // splat-quality hits a racket zombie (mini-boss) takes before it dies racketStride: 0.42, // m the racket zombie covers per walk frame — his cycle steps off GROUND // COVERED, so he stops animating when he stops moving racketSpeed: 2.8, // m/s the racket zombie shambles to chase a ball racketQMax: 0.55, // its return quality is capped here (slower, sloppier than the tennis CPU) racketFlub: 0.2, // chance the racket zombie whiffs a return missHud: 0, // show the last miss reason on screen (full log: T.G.missLog) — off; dev-only killPips: 1, // splatter: show the kills-to-next-point pips on the scoreboard }; // the death animation's timeline, in seconds of G.eatenT. A: thrash · B: you burst into gore // · C: the summary fades in. The restart tap is gated past the whole thing so a panic tap // during the burst can't skip it. // assets/audio/tennis-applaus.wav runs 4.16s and always plays in full, so the crowd must keep // clapping at least that long — every cheer() call was passing a shorter beat, which left the // stand sitting back down while the applause was still audible. export const APPLAUSE_T = 4.2; // A: they drag you down and you fight it on your knees · B (EATEN_BURST): the crowd closes over you // and what is left is the mound · C (EATEN_SUMMARY): the summary fades in over them still feeding. // It is deliberately long. Oliver: "the movies surround and bite them, blood spills, and then the // person basically drowns in the zombies" — that cannot happen in half a second. export const EATEN_GRAB = 0.9, EATEN_BURST = 2.3, EATEN_SUMMARY = 3.6, EATEN_RESTART = 4.6; export const EATEN_FEED_R = 1.15; // m — how tight the feeding ring packs around the body // UI accent colours, one place so the whole HUD can be re-tuned at once. Deliberately // desaturated from the original arcade primaries: they were shouting over the artwork. // (NOT the gore — blood and piles keep their loud reds; only text and interactive UI.) export const UI = { hot: '#d9705f', warm: '#e0cc72', pick: '#cbd06a', cream: '#e8e8dc', dim: '#8fa0b8', bad: '#a34a42', ball: '#dbe063', // 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 2 (see the two-frame note in render()). // bounceY/XZ copied into TUNE on selection, so the T panel keeps editing the live values and the // three physics call sites never learn that courts exist. Grass skids low and fast, clay // sits up and drags, acrylic is the middle it always was. export const COURTS = [ { id: 'grass', name: 'CENTRE COURT', sub: 'GRASS · fast, low skid', tile: 'court_grass', ads: 'x_ads', crowd: 'x_crowd', stripes: true, line: '#e8e8dc', border: '#e8e8dc', bounceY: 0.50, bounceXZ: 0.84, tennis: { stands: '#232e42', tierA: '#2a3750', tierB: '#26324a', board: '#0e5c2e', out: '#295c2d', lawn: '#4cae52', stripe: '#46a54c' }, splatter: { stands: '#181426', tierA: '#201a33', tierB: '#1c172e', board: '#3a1212', out: '#20331c', lawn: '#5e7747', stripe: '#576f42' } }, { id: 'clay', name: 'TERRE ROUGE', sub: 'CLAY · slow, high kick', tile: 'court_clay', ads: 'x_ads_c', crowd: 'x_crowd_c', stripes: false, line: '#efe3cf', border: '#efe3cf', bounceY: 0.66, bounceXZ: 0.70, tennis: { stands: '#4a3025', tierA: '#5a3a2c', tierB: '#513428', board: '#8a4426', out: '#7a3f26', lawn: '#c4652f', stripe: '#c4652f' }, splatter: { stands: '#2a1a18', tierA: '#33201d', tierB: '#2d1c19', board: '#3a1212', out: '#4a2a1c', lawn: '#8a4a2a', stripe: '#8a4a2a' } }, { id: 'hard', name: 'THE SLAB', sub: 'HARD · true bounce', tile: 'court_hard', ads: 'x_ads_h', crowd: 'x_crowd_h', stripes: false, line: '#f2f4f8', border: '#f2f4f8', bounceY: 0.58, bounceXZ: 0.78, tennis: { stands: '#1d2836', tierA: '#243141', tierB: '#1f2b3a', board: '#12525e', out: '#2c7a52', lawn: '#1f5c93', stripe: '#1f5c93' }, splatter: { stands: '#141a26', tierA: '#1a2130', tierB: '#161d2a', board: '#3a1212', out: '#25452f', lawn: '#1c3e5c', stripe: '#1c3e5c' } }, ]; // netW was the singles-stick width (10.06) while the court is drawn to the DOUBLES lines, so the // net stopped 0.46 m short of each sideline and visibly failed to meet the court edge. The posts // now stand exactly on the doubles sidelines. export const COURT = { L: 23.77, W: 8.23, dblW: 10.97, netZ: 11.885, netH: 0.914, netW: 10.97 }; COURT.svcFar = COURT.netZ - 6.4; // far service line COURT.svcNear = COURT.netZ + 6.4; // near service line // world sizes (meters) export const PLAYER_H = 1.88; // The chapter-boss ROSTER. One design per chapter, cycling — chapter 5 gets design 1 again, but at // far more hp and much bigger, so it still escalates. Each entry is pure DATA so adding a design is a // sheet plus a row here, never a change to drawZombie. // walk: two frames, stepped off DISTANCE COVERED (never time — see the animation invariant) // wounds: damage skins, healthiest first. However many there are, they are spread evenly over hp. // throws: the same states mid-throw, used while windT > 0. Index is clamped, so a design with // fewer throw frames than wound stages simply reuses its last one. // Every name must be ALL LOWERCASE and a whole word — see the case-collision note in the pipeline. // // 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'], move: 'charge', hpMul: 0.8, // fast and fragile: the BODY is the weapon, and contact volley: 1, cycle: 3.4, wind: 0.35, shot: 21, spread: 0.15 }, // is still instant death { 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 ]; // 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. beacon: { label: 'BEACON', icon: 'w_beacon', tin: 1, kind: 'burst', len: 11, rate: 1.6, r: 1.2, dmg: 2, hp: 4, arc: 1, hue: 1, trail: 3, glow: 1.6, speed: 19, 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. flamer: { label: 'FLAMER', icon: 'w_flamer', tin: 1, kind: 'burst', len: 6, rate: 30, r: 1.15, dmg: 2, hp: 9, speed: 15, life: 0.7, ignite: true, igniteR: 2.6, trail: 2, col: '#ff8420', glow: 2, spread: 3.1, // life 0.7s x 15 m/s = a ~10 m cone 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: 0.85, r: 0.75, dmg: 3, hp: 9, arc: 1, boom: true, trail: 2, speed: 17, col: '#ffd050', glow: 1.4, desc: ['IT LAUNCHES ITSELF', 'AND EVERY ONE IS A BOMB'] }, }; // 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 }; export const RACK_MAX = 4; // gathered weapons the rack holds; a 5th rides slot 0 until it is spent 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.