// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state). import { cv, ctx } from './dom.js'; import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS, HEAD_CROP, CUP_ROUNDS, TINT, SHOT_DEPTH } from './config.js'; import { P, O, ball, score, crowd, G, court, tour, kit, skin } from './state.js'; import { RAISERS, FRAMES } from './crowd.js'; import { predictLanding, previewServe, slotId, slotStock, serveApexY, rallyFeel, swayOf } from './gameplay.js'; import { drillQueue, drillLegal, drillMult } from './drill.js'; import { zombies, gore, piles, parts, pellets, extras, shots, pops } from './zombies.js'; // ---------------------------------------------------------------- palette ---- // tennis palette -> drained splatter palette; the transition lerps between them. // Both halves now come from the SELECTED COURT (config.COURTS) — see the court table for why each // court carries a pair rather than one palette and a global rot filter. const hexLerp = (a, b, t) => { const c = h => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]; const [ar, ag, ab] = c(a), [br, bg, bb] = c(b); return `rgb(${Math.round(ar + (br - ar) * t)},${Math.round(ag + (bg - ag) * t)},${Math.round(ab + (bb - ab) * t)})`; }; // The live aim cloud's ramp, walked in TWO steps: pale lime -> gold -> red. A straight green-to-red // lerp was the obvious version and it failed on the only court that matters — green on green is // invisible, so the whole safe half of the range showed nothing. The cool end carries its contrast // in LIGHTNESS instead of hue, which is what makes it read on grass, clay and slab alike, and gold // in the middle keeps every step of the ramp distinct from the two beside it. const AIM_COOL = '#d8eaae', AIM_MID = '#e0cc72', AIM_HOT = '#d64a3a'; const aimHeat = t => t < 0.5 ? hexLerp(AIM_COOL, AIM_MID, t * 2) : hexLerp(AIM_MID, AIM_HOT, (t - 0.5) * 2); // how far into the rot we are, 0 (tennis) .. 1 (splatter) — duels lerp it back home function paletteT() { return G.mode === 'tennis' ? 0 : G.mode === 'splatter' ? 1 - G.duelLerp : Math.min(1, G.transT / 6); } function palette() { const t = paletteT(); if (t === 0) return court.tennis; if (t === 1) return court.splatter; const m = {}; for (const k in court.tennis) m[k] = hexLerp(court.tennis[k], court.splatter[k], t); return m; } // Every string on the canvas goes through this. Bare `monospace` resolves to Courier — a slab // SERIF — which is what made the HUD look generic; Silkscreen is the bundled pixel face. const F = (sz, b = 1) => `${b ? 700 : 400} ${sz}px Silkscreen, monospace`; // ...and the ANNOUNCEMENT face. Two registers, and which one a message uses is its category: // Silkscreen — the HUD, the rack, the control hints, RESUMING IN. Things you read at leisure. // Bangers — SET, CHAPTER, EPIC!, PERFECT, GAME N. Things shouted at you. // It was one face for everything, so a milestone and a control hint looked like the same object at // two sizes. Falls back to Silkscreen (not monospace) if the TTF ever fails to load. const B = sz => `400 ${sz}px Bangers, Silkscreen, monospace`; const hexA = (h, a) => `rgba(${parseInt(h.slice(1, 3), 16)},${parseInt(h.slice(3, 5), 16)},${parseInt(h.slice(5, 7), 16)},${a})`; // Free-floating text has no panel behind it, so it has to carry its own contrast: over the crowd, // over sunlit clay, over blood. A 1px drop shadow does it and a box would cover the artwork. // (RESUMING IN hand-rolled exactly this; now everything that floats shares it — the loud face AND // the quiet one. The two exceptions are the SCOREBOARD and the RACK: both already sit on their own // dark panels, and a 1px shadow under 6-8px type on a flat dark fill only smears it.) function shadowText(txt, x, y, fill) { ctx.fillStyle = 'rgba(0,0,0,0.62)'; ctx.fillText(txt, x + 1, y + 1); ctx.fillStyle = fill; ctx.fillText(txt, x, y); } // ---------------------------------------------------------------- camera ---- const IS_TOUCH = matchMedia('(pointer: coarse)').matches; // hoisted: a matchMedia call per frame churns GC // Physical pixels per logical pixel, derived from the device instead of pinned at the iPhone's 4. // On a 3x iPhone the canvas ends up ~390 CSS px wide = 1170 DEVICE px, so RES 3 (810) was upscaled // 1.44x by the browser and 4 gets us to 1080, near 1:1 — but a flat 4 made every 2x phone paint // nearly double the pixels it can show, on exactly the hardware least able to afford it. The same // near-1:1 logic per tier: dpr 3 -> 4, dpr 2 -> 3, dpr 1 -> 2. // THIS IS THE FIRST KNOB TO TURN DOWN if the phone ever stutters: it is the whole fill-rate budget. // `let`, not const: TUNE.lowPower caps it at 3 live (fitViewport re-reads on every fit) — importers // see the change through the module binding. const RES_AUTO = Math.max(2, Math.min(4, Math.ceil((devicePixelRatio || 3) * 1.35))); export let RES = RES_AUTO; export let W = 480, H = 270; // Split responsibility, and the split is deliberate: // TOP — index.html insets the canvas element below the status bar. SAFE.top stays 0; adding an // offset here as well would subtract the notch twice. // BOTTOM — the canvas deliberately runs to the PHYSICAL bottom edge so the court art fills the // screen (the home indicator is a slim bar, not opaque UI). Only the interactive RACK is // lifted clear of it, by SAFE.bottom below. const SAFE = { top: 0, bottom: 0, topCss: 0 }; // Logical px of slack under a full draw, on top of the rack: the home indicator (~34 CSS px) plus // enough that a pull ENDING near the edge is still nowhere near STARTING there. See fitViewport. const PULL_ROOM = 30; function measureSafe(scale) { const el = document.getElementById('safe'); if (!el) return; const cs = getComputedStyle(el); SAFE.bottom = Math.round((parseFloat(cs.paddingBottom) || 0) / scale); SAFE.topCss = Math.round(parseFloat(cs.paddingTop) || 0); // CSS px — for the page background below // THE HUD MARGIN, IN CSS PX. The canvas HUD is laid out in LOGICAL px (the scoreboard and the // BLOOD panel both sit 6 logical px in from the top-right); the DOM chips on top of it are laid // out in CSS px — and on a phone one logical px is ~1.4 CSS px, so the same "6" put the quit // chip a visible notch above the panels it is meant to line up with. One variable, one grid. document.documentElement?.style.setProperty('--hud', 6 * scale + 'px'); // ?. — the headless suite has no root element } // The page background behind the canvas. It shows in exactly two places: the strip above the canvas // under the status bar, and the slivers a phone's ROUNDED DISPLAY CORNERS clip out of the canvas // itself. A flat dark body therefore drew a visible dark notch where the bottom corners curve in. // So: dark down to the top inset, then the LIVE out-area colour for the rest — live because `pal.out` // lerps between the tennis and splatter palettes, so any fixed hex would only match in one mode. // Written in resolved px (not env()) so the declaration can never be invalid and drop the background. let bgLast = ''; function pageBackground(out) { const t = SAFE.topCss; const css = `linear-gradient(#10141c 0px, #10141c ${t}px, ${out} ${t}px, ${out} 100%)`; if (css === bgLast) return; // only touch the DOM when it actually changed bgLast = css; // documentElement, NOT body: the ROOT element's background is what paints the viewport canvas. // index.html gives html a background, so body's never propagates — setting it there painted only // body's own box, which has no height (the canvas is position: fixed) and so painted nothing. document.documentElement.style.background = css; } // The CSS box is only a PROPOSAL. Added to the homescreen, iOS resolves 100dvh once at launch against // a provisional viewport and never re-resolves it — no resize, no layout change, so nothing (not even // a ResizeObserver) fires and the canvas stays a strip short of the bottom until you rotate the phone // and back. visualViewport is the authority on what is actually visible, so measure that and pin the // height ourselves. Re-read on every fit, so a late safe-area inset or a rotation just re-pins it. function fitBox() { const el = document.getElementById('safe'); const top = el ? parseFloat(getComputedStyle(el).paddingTop) || 0 : 0; // the inset the canvas starts below const want = Math.round((window.visualViewport?.height || innerHeight) - top); if (want > 120 && Math.abs(cv.clientHeight - want) > 1) cv.style.height = want + 'px'; // sane, and only when it differs } export function fitViewport() { // landscape 480x270; portrait 270 wide and as TALL as the phone is fitBox(); // battery saver: RES 4 -> 3 is −44% of pixels painted per frame, by far the biggest lever there // is. Sprites are 224px sources, so at 3 they still draw at-or-below 1:1 — softer than 4, never // mushy. main.js re-calls fitViewport when the panel flips the knob. RES = TUNE.lowPower >= 0.5 ? Math.min(3, RES_AUTO) : RES_AUTO; const portrait = (cv.clientHeight || innerHeight) > (cv.clientWidth || innerWidth); // Portrait height ADAPTS to the device. A fixed 270x480 is 9:16, but a modern iPhone is taller // (~390x844 = 0.46), so width bound the fit and ~150 CSS px of screen went unused at the bottom. // The court is positioned by proj() off TUNE.horizon in ABSOLUTE logical px, and the rack/meter hang // off `H - ...`, so extra height simply widens the apron between the court and the rack — nothing // has to be re-laid-out. Clamped so a freak aspect can't stretch the apron absurdly. // Derive the logical size from the box CSS actually gave the canvas, not from innerWidth/innerHeight. // The canvas is stretched to 100vw x 100dvh, so matching its logical aspect to that box exactly is // what guarantees no letterbox and no distortion, however the phone's toolbars behave. const bw = cv.clientWidth || innerWidth, bh = cv.clientHeight || innerHeight; const base = portrait ? 270 : 480; const other = Math.round(base * (portrait ? bh / Math.max(1, bw) : bh / Math.max(1, bw))); const [w, h] = portrait ? [base, Math.max(430, Math.min(680, other))] : [base, Math.max(230, Math.min(340, other))]; if (cv.width !== w * RES || cv.height !== h * RES) { W = w; H = h; cv.width = w * RES; cv.height = h * RES; // flatter lens: camZ pushed back (config 42, was 36) with camF scaled to keep the PLAYER // the same size — far-court zombies render ~25% bigger and actually read as zombies // READABILITY PASS. Oliver's wife: "everything is a tad too small" — a far shambler on an iPhone // was 26 logical px tall with almost no legible detail. Pushing the camera BACK (camZ) while // lengthening the lens (camF) flattens the perspective, so distant bodies grow relative to near // ones instead of everything just zooming. camY is raised with it for a steeper look-down (bodies // overlap each other less at depth) and `horizon` re-centres the court after both. // Net: far shambler 26 -> 30 logical px, the player unchanged, the court occupying the same band. // If you retune these, check all four: far size, near size, court top, court bottom. // PORTRAIT WENT 48 -> 49. THE THREE NUMBERS ARE NOT INDEPENDENT, which is what the "check all // four" note above is really about: holding the near player's drawn size fixed means // camF = s_player * (camZ - P_BOUNDS.zMax), and holding the court's on-screen depth fixed means // camY = depth / (s_player - camF/camZ). So 49 gives 795 and 11.23, and `horizon` below // re-derives itself and drops 51 -> 43. Net: the far opponent 33.1 -> 33.9 logical px, the player // unchanged at 64.9, the court band within a pixel of where it was. // ONE METRE IS DELIBERATE. Pushing the lens further does keep growing the far body, but it spends // the court to do it — measured at camZ 55 the umpire is already clipped off the right edge, and // by 70 the sidelines run near-parallel and the whole thing reads top-down instead of like a // broadcast camera. It also narrows the player's lateral room, because xVisible() clamps him to // what fits on screen and a wider court fits less. TUNE.camZ = portrait ? 49 : 42; TUNE.camY = portrait ? 11.23 : 9; TUNE.camF = portrait ? 795 : 406; // THE COURT RIDES HIGH ENOUGH TO PULL UNDER HIM. A full draw is TUNE.pullFull CSS px of thumb // travel STARTING at his feet, and it has to finish clear of the rack and of the iOS bottom // edge — where a swipe down is Reachability and the system takes the gesture out from under // you, which no web page can refuse. Positioning the court from the TOP could not promise that: // on a 375x812 it left 112 CSS px under him for a 130 px pull, so the wind-up physically did // not fit and the thumb was driven onto the edge every single rally. // So park his feet a full draw above the rack and let the horizon fall wherever it must. What // gets shorter is the stand above the court, and that was only ever crowd. // (`horizon` is the live knob — it survives in the T panel until the viewport actually changes.) const perCss = h / Math.max(1, bh); // logical px per CSS px (bh is 0 in a hidden tab) const apron = TUNE.pullFull * perCss + TUNE.rackH + PULL_ROOM; // his scale at his DEEPEST, so the room holds wherever he is standing when you draw const feetY = TUNE.camY * TUNE.camF / (TUNE.camZ - P_BOUNDS.zMax); TUNE.horizon = portrait ? Math.round(h - apron - feetY) : 40; buildCrowd(); } ctx.imageSmoothingEnabled = false; // resets on canvas resize // CSS owns the on-screen size and the TOP inset; we match the logical grid and measure the bottom // inset in logical px so the rack can lift clear of the home indicator. measureSafe((cv.clientWidth || innerWidth) / w); } // Every signal that the visible area may have changed, plus a short settle salvo — because the case // that broke it (a homescreen launch) fires NO signal at all: the viewport iOS hands you at launch is // simply wrong and is never corrected. fitViewport is idempotent (fitBox no-ops within 1px, and the // logical size early-outs unless it really changed), so re-running it costs nothing. addEventListener('resize', fitViewport); addEventListener('orientationchange', fitViewport); addEventListener('pageshow', fitViewport); // the app was resumed from the bfcache window.visualViewport?.addEventListener('resize', fitViewport); // the one iOS does fire when the bars move new ResizeObserver(fitViewport).observe(cv); // the box moved, whatever moved it for (const ms of [0, 120, 400, 1200]) setTimeout(fitViewport, ms); // ...and the launch that says nothing // The aim camera: while the slingshot is drawn the view rises and leans back a little, which // steepens the look down the court. That extra depth is what makes a shambler standing right // behind the net a shot you can actually judge. Eased once per frame in render(), never mid-frame // — every proj() call within one frame must share the same camera or the scene shears. let aimCam = 0; // The ball-follow lean (see config farCamY/farCamZ): eased once per frame like aimCam. fOff is the // compensation that pivots the move about the near player's deepest stance — without it any camY // or camZ change slides his feet toward the rack (14 logical px for 0.4 m of camY), and the apron // math in fitViewport promised that pull room. Recomputed in render() whenever farCam moves. let farCam = 0, fOff = 0; export function proj(x, y, z) { const d = TUNE.camZ + aimCam * TUNE.aimCamZ + farCam * TUNE.farCamZ - z, s = TUNE.camF / d; return { x: W / 2 + x * s, y: TUNE.horizon + fOff + (TUNE.camY + aimCam * TUNE.aimCamY + farCam * TUNE.farCamY - y) * s, s }; } // FAR-BODY READABILITY (config farK): the flattened scale curve BODIES draw with, while the court // keeps the true projection. Anchored at the player's deepest stance, clamped so nothing nearer // ever shrinks. It is a pure function of z so every anchor that hangs off a body (flames, boss HP, // muzzle glow) agrees with the sprite by passing a boosted METRES height through the same proj() // it always used — boosting only the drawn frame left the HP bar sitting on the boss's hat. function farB(z) { if (!TUNE.farK) return 1; const s = proj(0, 0, z).s, r = proj(0, 0, P_BOUNDS.zMax).s; return s > 0 && s < r ? Math.pow(r / s, TUNE.farK) : 1; } // ---------------------------------------------------------------- sprites ---- // assets/.png; game falls back to code-drawn figures until sprites load. // Asset cache-buster — bump with main.js?v=N (see the img.src note below). EXPORTED because the // menu screens hang tags on the same files, and two copies of this number is one copy too // many: the one that does not get bumped is the one that serves a cached 404. export const SPRV = 148; // The boot bar in main.js watches this. Nothing here CHANGES when things load — every sprite was // already requested at import — it just makes the wait visible and holds the menu back until the // last sheet has landed. See the onerror note below for the other half of "sometimes missing". export const LOAD = { done: 0, total: 0 }; export const SPR = {}; for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_run3','p_run4','p_run5','p_run6', // SIX phases lifted out of a five-second // image-to-video clip (2026-08-15): a video model animates a body through time, // so the legs alternate on their own — every still roll drew three phases and // repeated them ("ropeskipping, not running"). WHOLE frames, cut and // palette-snapped to his settled art; the phases are spaced across one // measured stride (his loped: 23 clip frames against her 6). 'p_swing1','p_swing2','p_die1','p_die2','p_die3','p_die4','p_die5', 'p_pull', // the serve wind-up: the racket dropping behind him while the pull is drawn 'o_idle1','o_idle2','o_bounce','o_bounce2','o_toss','o_smash','o_swing1','o_swing2', // THE FRIEND ACROSS THE NET, in YOUR kit seen from the front (assets/_frontroll.py). // Netplay only: the roster CPU keeps the red o_ sheets, because it is a club // opponent and not a person. The poses are the o_ family's, redrawn on the player's // white-over-blue body, so the friend's pick dyes his SHORTS exactly as yours does. 'n_idle1','n_idle2','n_bounce','n_bounce2','n_toss','n_smash','n_swing1','n_swing2', 'n_run1','n_run2', 'o_run1','o_run2', // TWO frames, a true leg-swap pair — the whole 2026-08-14 tuning session's verdict: fewer, better-chosen frames at a calm rate beat every 6/8-frame roll ('this looks better than anything before') 'x_ump','x_boyrun1','x_boyrun2','x_boyrun3','x_boyrun4', 'x_crowd','x_ads', // CENTRE COURT (grass) — also the fallback set 'x_crowd_c','x_ads_c', // TERRE ROUGE (clay) 'x_crowd_h','x_ads_h', // THE SLAB (hard) // ...each crowd sheet is FRAMES frames stacked vertically (seated / arms high / // mid-clap). See src/crowd.js, and drawRaisers below. 'x_boymop1','x_boymop2','x_boymop3','x_boymop4', // the push cycle, used only while he is stopped and working 'x_boycarry1','x_boycarry2','x_boycarry3','x_boycarry4','x_howto','x_cross', // walking with the mop shouldered, and the slingshot tutorial panel 'x_hand', // the ghost tutorial hand (drawGhostHand) — generated art, Oliver's reference pose 'z_gw2','z_gw3','z_gw4','z_gwb','z_gw2b','z_gw3b','z_gw4b', // the brute comes apart in stages (4/5 hp = z_gw .. 1/5 = z_gw4), each with a 2-frame lurch (…b) 'z_boss1','z_boss2','z_bossw1','z_bossw2','z_bossw3', // the CHAPTER BOSS: 2-frame stride + three wound stages (arm off -> ribs out -> headless) 'z_bosst0','z_bosst1','z_bosst2','z_bosst3', // ...and each of those four states mid-THROW (windT) 'z_mach1','z_mach2','z_machw1','z_machw2', // the ball machine: idle, firing, dented, split open 'z_judge1','z_judge2','z_judgew','z_judget','z_judgewt', // boss design 2 (chapter 2, then the second lap) 'z_chair1','z_chair2','z_chairw','z_chairt','z_chairwt', // boss design 3 (chapter 3, then the second lap) // designs 4-9 and the final boss, each with the full set the groundskeeper has: 2 stride, 3 wounds, 4 throws 'z_coach1','z_coach2','z_coachw1','z_coachw2','z_coachw3','z_coacht0','z_coacht1','z_coacht2','z_coacht3', 'z_physio1','z_physio2','z_physiow1','z_physiow2','z_physiow3','z_physiot0','z_physiot1','z_physiot2','z_physiot3', 'z_mascot1','z_mascot2','z_mascotw1','z_mascotw2','z_mascotw3','z_mascott0','z_mascott1','z_mascott2','z_mascott3', // designs 7-9 and THE CHAMPION (the final boss, every tenth chapter — see bossArt) 'z_string1','z_string2','z_stringw1','z_stringw2','z_stringw3','z_stringt0','z_stringt1','z_stringt2','z_stringt3', 'z_dir1','z_dir2','z_dirw1','z_dirw2','z_dirw3','z_dirt0','z_dirt1','z_dirt2','z_dirt3', 'z_berry1','z_berry2','z_berryw1','z_berryw2','z_berryw3','z_berryt0','z_berryt1','z_berryt2','z_berryt3', 'z_champ1','z_champ2','z_champw1','z_champw2','z_champw3','z_champt0','z_champt1','z_champt2','z_champt3', // the late roster: ball boy (3 hp, 2 skins), bouncer (6 hp, 3), roller man (9 hp, 4). // Every skin has its …b stride twin, so a wounded body keeps walking (see ZWOUND). 'z_e1','z_e2','z_e3','z_e4','z_ew','z_ewb','z_ew2','z_ew2b', 'z_h1','z_h2','z_h3','z_h4','z_hw','z_hwb','z_hw2','z_hw2b','z_hw3','z_hw3b', 'z_k1','z_k2','z_k3','z_k4','z_kw','z_kwb','z_kw2','z_kw2b','z_kw3','z_kw3b','z_kw4','z_kw4b', 'z_a1','z_a2','z_a3','z_a4','z_b1','z_b2','z_b3','z_b4','z_c1','z_c2', 'z_d1','z_d2','z_d3','z_d4', // the steward: bigger body, hi-vis vest, 4-frame walk 'z_awb','z_bwb','z_cw1b','z_cw2b','z_dw','z_dwb', // ...and every wound skin's second frame: wounded bodies walk too 'z_f1','z_f2','z_f3','z_f4','z_g1','z_g2','z_g3','z_g4','z_gw', 'z_s1','z_s2','z_s3','z_s4','z_sdown', // the chair spectator: 4-frame shuffle + the staggered pose 'z_c3','z_c4', // ...and the turned CPU walks on four frames too 'z_cw1','z_cw2','z_aw','z_bw', // wounded skins for multi-hit enemies (racket boss, brute, and now the classic shamblers) // THE MATCH OPPONENT, drawn to play tennis rather than to shamble: ready stance, // shuffle pair, forehand — and the same three poses again at each wound stage // (…s is the swing). Its own family, so the turned CPU above keeps its shamble. // EVERY MATCH-OPPONENT FAMILY, derived from config.ZFAMS — 31 names each, so a new // opponent generated by _master.py --variant is ONE row in that table and not 31 // filenames typed out here (each of which is a chance to typo one and have a body // vanish for a frame — the oldest bug in this file). ...ZFAMS.flatMap(famFrames), // the card's head icons (assets/_heads.py), 'r' included since 2026-08-15: the // club pro's is cut from z_c1, the turned opponent, who IS him. The HEAD_CROP // fallback below him stays as the net for a family that has no head file yet. ...ZFAMS.map(k => 'z_' + k + 'head'), 'w_ball','w_bomb','w_triple','w_fire','w_gun', // weapon-rack icons 'w_beacon','w_laser','w_rocket','w_flamer', // ...and the four that come out of a barrel 'z_barrel1','z_barrel2', // the weapon barrel, two frames so it ROLLS // THE WOMEN'S TOUR. Same poses, same 224 px anchor, a different body — see SK(). // Only the two players and the turned CPU have a twin; everything above is shared. 'p_idle1_f','p_idle2_f','p_bounce_f','p_bounce2_f','p_toss_f','p_smash_f','p_pull_f', 'p_run1_f','p_run2_f','p_run3_f','p_run4_f','p_run5_f','p_run6_f','p_swing1_f','p_swing2_f', // The death is DRAWN for frames 1-3 and RECOLOURED for 4-5. The generator refuses to // draw a dead woman in any wording worth using, and frames 4-5 survive that fine — // she is flat and face down, then a heap of torn cloth — so those two are the men's // drawing with the blue shorts painted over in her skirt's white. The one thing you // could actually see was blue popping in after three frames of white. 'p_die1_f','p_die2_f','p_die3_f','p_die4_f','p_die5_f', 'o_idle1_f','o_idle2_f','o_bounce_f','o_bounce2_f','o_toss_f','o_smash_f', 'o_run1_f','o_run2_f','o_swing1_f','o_swing2_f', // her pair likewise (frames 7+8 of the face-transplant cycle) 'n_idle1_f','n_idle2_f','n_bounce_f','n_bounce2_f','n_toss_f','n_smash_f', 'n_run1_f','n_run2_f','n_swing1_f','n_swing2_f', // ...and the friend in tennis whites 'z_c1_f','z_c2_f','z_c3_f','z_c4_f','z_cw1_f','z_cw2_f','z_cw1b_f','z_cw2b_f', 'z_r1_f','z_r2_f','z_r3_f','z_r4_f', 'z_rw1_f','z_rw1b_f','z_rw1s_f','z_rw2_f','z_rw2b_f','z_rw2s_f', 'scoreboard','scoreboard_z']) { // the club's board, and the one the match is played on const img = new Image(); // ?v=SPRV, for the same reason index.html versions main.js. It is also what buys the year-long // immutable cache in server.py: a versioned URL can never go stale, because changing the file // changes the URL. A brand-new sheet whose name was added to this list before the PNG existed // stays permanently "missing" for that browser, and drawZombie silently falls back to another // design. That cost a debugging round. // BUMP THIS whenever assets/ changes, alongside main.js?v=N in index.html. const url = 'assets/' + n + '.png?v=' + SPRV; // ONE RETRY. A dropped request used to be permanent for the whole session — the sprite stayed // undefined, the zombie drew as some other design, and the stats screen showed a broken . // That is the "sometimes the graphics don't load" report. onerror still has to count on the // second failure, or one genuinely missing file hangs the boot bar forever. let retried = false; LOAD.total++; img.onload = () => { SPR[n] = brighten(n, img); LOAD.done++; }; img.onerror = () => { if (retried) LOAD.done++; else { retried = true; img.src = url + '&r=1'; } }; img.src = url; } // OPPONENT BODIES DRAW BRIGHTER, without touching a single PNG (Oliver: "make the opponent // sprites brighter in game — filter or something — without changing the spritesheet files"). // Baked ONCE at load into an offscreen copy: an additive self-blend at alpha (b-1) is an exact // brightness multiply with no per-pixel loop and no per-frame ctx.filter (which iOS Safari's // canvas doesn't reliably support). Covers every far-court body — the zombie families and the // tennis CPU — and leaves the player, the boys, the furniture and the HUD art untouched. // TUNE.oppBright is read at LOAD, so retuning it needs a reload, not just the panel. function brighten(n, img) { if (!(n[0] === 'z' && n[1] === '_') && !(n[0] === 'o' && n[1] === '_')) return img; const b = TUNE.oppBright || 1; if (b === 1) return img; const c = document.createElement('canvas'); c.width = img.width; c.height = img.height; const g = c.getContext('2d'); g.drawImage(img, 0, 0); g.globalCompositeOperation = 'lighter'; g.globalAlpha = b - 1; g.drawImage(img, 0, 0); g.globalCompositeOperation = 'destination-in'; // re-clip: additive blending fattened the alpha g.globalAlpha = 1; g.drawImage(img, 0, 0); return c; } // ------------------------------------------------------------------ crowd ---- function buildCrowd() { crowd.dots.length = 0; const rows = Math.floor((proj(0, 0, 0).y - 26) / 5.5), cols = Math.ceil(W / 8); for (let row = 0; row < rows; row++) for (let i = 0; i < cols; i++) crowd.dots.push({ x: 4 + i * 8 + (row % 2) * 4 + Math.random() * 3, y: 8 + row * 5.5 + Math.random() * 2, c: ['#c9b18f','#8f6f4f','#d9c6a5','#5f7f9f','#9f5f5f','#7f9f5f','#e0d0b0','#b0a0c0'][(i * 7 + row) % 8] }); } // CAMERA FLASHES IN THE STAND (Oliver, 2026-08-15: "random sporadic subtle flashes like someone is // photographing, slightly increasing during applause"). AN OVERLAY, never backdrop content: the // backdrop is one cached blit and anything that moves in it would repaint the whole scene every // frame (see drawBackdrop's key). Clocked off G.idleT rather than dt for the same reason the clap // ripple is — this function has no dt, and idleT is the one clock that never stops. // Each flash is a two-frame pop: a hot core, a soft bloom, gone in ~TUNE.flashLife. Rate doubles // while the crowd is on its feet, which is the "slightly increase during applause" — a stand that // went white during a point would be a different, much louder feature. const flashes = []; // {x, y, r, t0} let flashNext = 0, flashLast = 0; function tickFlashes(standsBottom) { const now = G.idleT, top = Math.max(4, standsBottom - AD_H - 4); // THE CLOCK CAN GO BACKWARDS AND THIS STATE CANNOT. `resetState` puts G.idleT back to 0 for every // new run, while `flashes` and `flashNext` live in this module and survive it — so a fresh match // inherited a `flashNext` from the last one (no photographs at all until the clock climbed back // past it) and any flash still alive was left with a t0 in its future, which makes its fade // `1 - (now - t0)/life` permanently greater than 1: a shutter frozen at full brightness in the // stand, for ever. Oliver, 2026-08-21: "at a random place in the crowd some frozen midflash circle // is visible. after a few points the flashes unfreeze". One guard, here, because this is the only // place that owns the state — and it covers any future mode that rewinds the clock too. if (now < flashLast) { flashes.length = 0; flashNext = now; } flashLast = now; if (now >= flashNext) { // THE BIGGER THE MOMENT, THE MORE SHUTTERS. cheer() queues crowd.burst shutters proportional to // its own vol, and they fire back to back at a fifth of the normal gap — a match point empties // the stand's cameras at once, a won rally gets one or two. Outside a burst the rate still // doubles while the crowd is up. The gap is exponential-ish so they never look metronomic. const rush = crowd.burst > 0; if (rush) crowd.burst--; const mean = TUNE.flashEvery / (1 + (crowd.cheer > 0 ? 1.2 + 2.4 * crowd.pop : 0)); flashNext = now + (rush ? mean * 0.13 : mean * (0.35 + Math.random() * 1.6)); flashes.push({ x: 6 + Math.random() * (W - 12), y: 6 + Math.random() * (top - 6), // small. The first cut was r 2.2-4.2 and its bloom drew a 6 px disc that read as // a LAMP in the stand rather than a shutter going off half a second ago r: 0.9 + Math.random() * 1.1, t0: now }); if (flashes.length > 12) flashes.shift(); } for (let i = flashes.length - 1; i >= 0; i--) { const f = flashes[i], a = 1 - (now - f.t0) / TUNE.flashLife; if (a <= 0) { flashes.splice(i, 1); continue; } ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = a * a * 0.30; ctx.fillStyle = '#8fa6d8'; // the bloom, cold and dim ctx.beginPath(); ctx.arc(f.x, f.y, f.r * 2.4, 0, 7); ctx.fill(); ctx.globalAlpha = Math.min(1, a * 1.5); ctx.fillStyle = '#fdfdff'; // the bulb itself ctx.beginPath(); ctx.arc(f.x, f.y, f.r * 0.8, 0, 7); ctx.fill(); ctx.restore(); } ctx.globalAlpha = 1; } // How dark the STAND is: the court's own `dim` if it names one (centre court's crowd sheet is // already night-dark, the night court is darker still), otherwise `TUNE.standBack` — the stand is // scenery thirty metres back and should not compete with the court — plus the rot the splatter // palette brings with it. ?? not ||: a court that says 0 means 0. ONE function because it is applied // in two places and they must agree exactly — the cached sheet and the live clap columns drawn over // it. Two copies of this number is a stand whose clapping columns pop brighter than the rest of it, // which is the same class of bug as the crowd frames drifting apart. Capped: past ~0.85 the stand // is a black rectangle and the applause stops reading at all. const standDim = pt => Math.min(0.88, (court.dim ?? TUNE.standBack) + 0.26 * pt); // THE STAND MOVES IN COLUMNS, and the sheets are cut to match: `assets/_crowdpass.py` guarantees // the clap frame is pixel-identical to the seated one EXCEPT inside blobs that each sit inside ONE // of these columns. That is what makes any column swap seamless — a column with nobody raising in // it draws exactly what was already there. CHANGE THIS AND THE SHEETS MUST BE RECUT (same constant, // COLS, at the top of that script). // THE STAND MOVES ONE SPECTATOR AT A TIME. `crowd.js` (generated by assets/_crowdpass.py) lists a // box around every pair of raised arms in the clap sheet, and those arms are the ONLY thing that // differs between the two sheets — so drawing one box over the cached stand raises one person and // touches nobody else. The 16-column strip swap this replaced moved a seventeenth of the crowd as a // block, which is what made it read as machinery rather than as people (Oliver: "the animation also // looks pretty artificial"). // The sub-rect is drawn at EXACTLY the scale paintBackdrop used for the whole sheet (kx/ky below), // which is what guarantees the raised arms land on the same pixel grid as the body under them. // THE TOP FADE, in ONE place. It is baked into the cached stand, and the live raisers have to paint // it over themselves as well — a spectator whose box was redrawn without it lit up as a bright // rectangle every time he stood, which is what Oliver saw: "what is this white flashing while // applause in the upper right corner of the crowd". Same class of bug as two copies of standDim. // A gradient in user space, so filling ANY sub-rect with it picks up the right alpha for free. function topFade(c, standsBottom) { if (!(TUNE.standTop > 0)) return null; const g = c.createLinearGradient(0, -8, 0, standsBottom * 0.42); g.addColorStop(0, `rgba(6,10,6,${TUNE.standTop})`); g.addColorStop(1, 'rgba(6,10,6,0)'); return g; } const rnd = i => ((i * 9301 + 49297) % 233280) / 233280; // one stable pseudo-random per spectator function drawRaisers(img, standsBottom, pt, frameOf) { const fh = img.height / FRAMES, crowdH = standsBottom - AD_H + 2; const kx = W / img.width, ky = (crowdH + 2) / fh; const d = standDim(pt), tint = d > 0 ? `rgba(12,8,20,${d})` : null; const fade = topFade(ctx, standsBottom); let open = false; for (let i = 0; i < RAISERS.length; i++) { const r = RAISERS[i], f = frameOf(i, r[0], r[4]); if (!f) continue; // frame 0 IS the backdrop: nothing to draw if (!open) { // most frames raise nobody: no clip, no save, nothing drawn open = true; ctx.save(); ctx.beginPath(); ctx.rect(0, 0, W, standsBottom - AD_H); ctx.clip(); } const sx = r[0] * img.width, sy = r[1] * fh, sw = r[2] * img.width, sh = r[3] * fh; ctx.drawImage(img, sx, sy + f * fh, sw, sh, sx * kx, sy * ky, sw * kx + 0.5, sh * ky + 0.5); // re-tinted AND re-faded exactly like the cached stand underneath, or the box lights up if (tint) { ctx.fillStyle = tint; ctx.fillRect(sx * kx, sy * ky, sw * kx + 0.5, sh * ky + 0.5); } if (fade) { ctx.fillStyle = fade; ctx.fillRect(sx * kx, sy * ky, sw * kx + 0.5, sh * ky + 0.5); } } if (open) ctx.restore(); } // ---------------------------------------------------------------- backdrop ---- // THE STATIC SCENE, PRE-COMPOSED. Stands, ads, rot tint, out area, court, lines and the run-off // apron are pixel-identical frame to frame except while a palette lerps (the 6 s transition, the // duel's 0.8 s fade) — and repainting all ~40 draw ops per frame was the biggest steady-state cost // after raw resolution. One offscreen canvas, rebuilt only when the cache key actually moves, blit // as a single drawImage. The crowd's clap ripple stays LIVE, overlaid on top while cheering. // Oversized by 8 px a side for the same reason the old full-frame clear was: a camera shake must // never expose an edge. let bg = null, bgKey = ''; function paintBackdrop(c, pal, pt) { const line = (x1, z1, x2, z2, w = 1) => { const a = proj(x1, 0, z1), b = proj(x2, 0, z2); c.strokeStyle = UI.cream; c.lineWidth = w; c.beginPath(); c.moveTo(a.x, a.y); c.lineTo(b.x, b.y); c.stroke(); }; const quadXZ = (x1, z1, x2, z2, color) => { // filled court-plane rect (screen trapezoid) const a = proj(x1, 0, z1), b = proj(x2, 0, z1), d = proj(x2, 0, z2), e = proj(x1, 0, z2); c.fillStyle = color; c.beginPath(); c.moveTo(a.x, a.y); c.lineTo(b.x, b.y); c.lineTo(d.x, d.y); c.lineTo(e.x, e.y); c.fill(); }; c.fillStyle = pal.out; c.fillRect(-8, -8, W + 16, H + 16); // stands. Seated is one uniform stretch of the sheet, so it is ONE drawImage here — the // per-column split only ever existed for the clap ripple, which drawBackdrop overlays live. const standsBottom = proj(0, 0, -6).y, crowdH = standsBottom - AD_H + 2; const crowdImg = SPR[court.crowd] || SPR['x_crowd']; // FRAME 0 of the stacked sheet: the whole stand, seated. The raised frames below it in the same // image are never drawn whole — drawRaisers picks one spectator's box out of them. if (crowdImg) c.drawImage(crowdImg, 0, 0, crowdImg.width, crowdImg.height / FRAMES, 0, 0, W, crowdH + 2); else { // fallback until the image loads: the old tier stripes + dots (static — boot gates play anyway) c.fillStyle = pal.stands; c.fillRect(0, 0, W, standsBottom); for (let r = 0; r < 16; r++) { c.fillStyle = r % 2 ? pal.tierA : pal.tierB; c.fillRect(0, 6 + r * 5.5, W, 5.5); } for (const d of crowd.dots) { c.fillStyle = d.c; c.fillRect(d.x | 0, d.y | 0, 2, 2); } } // courtside advertising: tiled sponsor panels at native aspect (never half-cut text) const ads = SPR[court.ads] || SPR['x_ads']; if (ads) { const tw = ads.width * (AD_H / ads.height); for (let x = 0; x < W; x += tw) c.drawImage(ads, x, standsBottom - AD_H, tw, AD_H); } else { c.fillStyle = pal.board; c.fillRect(0, standsBottom - AD_H, W, AD_H); } // the rot dims the stadium: one tint over crowd + boards, fading home during duels. 0.26, not // 0.5: on an already-dark crowd sheet 0.5 buried the stands; 0.26 still reads as "the lights // went wrong". const dim = standDim(pt); if (dim > 0) { c.fillStyle = `rgba(12,8,20,${dim})`; c.fillRect(0, 0, W, standsBottom); } // THE PICTURE COMES OUT OF THE DARK AT THE TOP, the way it falls into it at the bottom. Same // colour and the same idea as the run-off apron below the near baseline (further down this // function): the stand is the far edge of the frame, and a hard cut at y = 0 reads as a // photograph that has been cropped rather than as a stadium with a roof somewhere above it. // It also happens to be where the scoreboard sits, which is the second reason it earns its keep. const fade = topFade(c, standsBottom); if (fade) { c.fillStyle = fade; c.fillRect(-8, -8, W + 16, standsBottom * 0.42 + 8); } // ground + court (Super Tennis lawn); everything outside the doubles court is OUT const dw = COURT.dblW / 2; quadXZ(-14, -6, 14, 30, pal.out); // out area. NEVER let z approach // TUNE.camZ (48): proj() divides by (camZ - z), so at z >= camZ the scale blows up and then goes // NEGATIVE, and the quad's far edge folds back above the screen as an inverted triangle. The // full-frame clear above is what covers the bottom of the screen; this quad does not need to. quadXZ(-dw - 0.15, -0.15, dw + 0.15, COURT.L + 0.15, court.border); // perimeter border line quadXZ(-dw, 0, dw, COURT.L, pal.lawn); // playing surface (doubles court) // mow stripes: grass only. Clay and acrylic are laid flat, and banding them was the one thing // that kept a recoloured court reading as grass however far the palette moved. if (court.stripes) for (let z = 0; z < COURT.L; z += 2.97) quadXZ(-dw, z, dw, Math.min(z + 1.49, COURT.L), pal.stripe); // lines: baselines, doubles + singles sidelines, service box line(-dw, 0, dw, 0, 2); line(-dw, COURT.L, dw, COURT.L, 2); line(-dw, 0, -dw, COURT.L, 1.5); line(dw, 0, dw, COURT.L, 1.5); line(-COURT.W / 2, 0, -COURT.W / 2, COURT.L, 1.5); line(COURT.W / 2, 0, COURT.W / 2, COURT.L, 1.5); line(-COURT.W / 2, COURT.svcFar, COURT.W / 2, COURT.svcFar, 1); line(-COURT.W / 2, COURT.svcNear, COURT.W / 2, COURT.svcNear, 1); line(0, COURT.svcFar, 0, COURT.svcNear, 1); line(0, 0, 0, 0.35, 1); line(0, COURT.L - 0.35, 0, COURT.L, 1); // center marks // The run-off apron below the near baseline: light falling off toward you, which gives the band // depth, frames the rack, and darkens exactly the area the thumb covers anyway. Starts at alpha 0 // on the baseline, so there is no seam. NOT a near backstop — you are looking OVER that, which is // why a broadcast frame never shows one either. const top = proj(0, 0, COURT.L).y; if (top < H) { // landscape has no apron at all — the baseline projects off the bottom const g = c.createLinearGradient(0, top, 0, H); g.addColorStop(0, 'rgba(6,10,6,0)'); g.addColorStop(1, 'rgba(6,10,6,0.72)'); c.fillStyle = g; c.fillRect(0, top, W, H - top); } } // blit the cached backdrop (rebuilding if the key moved), then overlay the live clap ripple. // Returns standsBottom for the overlays render() draws above the stands. function drawBackdrop(pal, pt) { const crowdImg = SPR[court.crowd] || SPR['x_crowd']; // the key: everything paintBackdrop's output depends on. pt quantised to 1/100 — hexLerp rounds // to whole rgb anyway, so the steps are invisible; the aim-camera terms are 0 unless the two // aimCam knobs are actually in use, so an ordinary pull never invalidates the cache. const key = [W, H, court.id, pt.toFixed(2), !!crowdImg, !!(SPR[court.ads] || SPR['x_ads']), TUNE.horizon, TUNE.camZ, TUNE.camY, TUNE.camF, (aimCam * TUNE.aimCamY).toFixed(2), (aimCam * TUNE.aimCamZ).toFixed(2), // the lean moves the cached court too — repaints happen only while farCam eases, // then the key settles (farCam snaps to exactly 0/1) (farCam * TUNE.farCamY).toFixed(2), (farCam * TUNE.farCamZ).toFixed(2)].join('|'); if (key !== bgKey) { bgKey = key; if (!bg) bg = document.createElement('canvas'); if (bg.width !== (W + 16) * RES || bg.height !== (H + 16) * RES) { bg.width = (W + 16) * RES; bg.height = (H + 16) * RES; } const c = bg.getContext('2d'); c.setTransform(RES, 0, 0, RES, 8 * RES, 8 * RES); c.imageSmoothingEnabled = false; paintBackdrop(c, pal, pt); } ctx.drawImage(bg, -8, -8, W + 16, H + 16); const standsBottom = proj(0, 0, -6).y; // THE CROWD SHEET IS AN ANIMATION SHEET (assets/_crowdpass.py): frame 0 is the seated stand that // the backdrop above is drawn from, frame 1 is 100 pairs of arms raised over it, frame 2 is those // arms brought down and in, mid-clap. Only the arms differ, so ONE SPECTATOR can be redrawn over // the cached stand without touching anybody near him. if (crowdImg) { // WHO IS MOVING, AND IN WHICH POSE. Three behaviours, one draw: // applause — how many join scales with the size of the moment, and each of them CYCLES: the // 28 spectators who own both raised poses clap between them, the rest go up and down. A // raiser is ~8x6 logical px on a phone, so a pose that appears once and holds is not // something the eye catches — Oliver, "when clapping I barely see any movement at all now"; // the WAVE — a front travelling across the stand, person by person, arms high; // a FIDGET — one person, briefly, between points, so an idle stand is not a photograph. const pop = crowd.cheer > 0 ? crowd.pop : 0; const joins = 0.2 + 0.8 * pop; const beat = (i) => Math.floor(G.idleT * 7 + rnd(i + 11) * 6) % 2; // one pose, or the other, or the seated frame — whichever this spectator actually has const pose = (i, mask, b) => mask === 3 ? (b ? 2 : 1) : (b ? 0 : mask); const frameOf = pop >= 0.75 // 0.75, not 0.6: a game won calls cheer(2.4, 0.6), and a wave on every held serve is a wave // nobody looks up for. This leaves it to the set, the match, and the umpire's "unbelievable". ? (i, x, mask) => ((G.idleT / 1.4 - x * 0.8 - rnd(i) * 0.03) % 1 + 1) % 1 < 0.18 ? pose(i, mask, beat(i)) : 0 : pop > 0 // rnd(i) picks WHO (a stable draw, so the same people come in for a small moment), rnd(i+3) // and a period of his own stagger WHEN — a stand clapping in unison is a machine. ? (i, x, mask) => rnd(i) < joins && ((G.idleT / (1.5 + rnd(i + 7) * 1.3) + rnd(i + 3)) % 1) < 0.5 ? pose(i, mask, beat(i)) : 0 : (i, x, mask) => { const slot = Math.floor(G.idleT / TUNE.fidgetEvery); return i === (slot * 37) % RAISERS.length && (G.idleT / TUNE.fidgetEvery) % 1 < 0.34 ? pose(i, mask, beat(i)) : 0; }; drawRaisers(crowdImg, standsBottom, pt, frameOf); } tickFlashes(standsBottom); // ...and somebody in there is taking pictures return standsBottom; } // -------------------------------------------------------------- primitives ---- // Rounded panel. ctx.roundRect is native everywhere this game runs (Safari 16.4+), and the square // fallback only matters on a browser that could not render the rest of this anyway. function panel(x, y, w, h, r, fill, stroke) { ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(x, y, w, h, r); else ctx.rect(x, y, w, h); if (fill) { ctx.fillStyle = fill; ctx.fill(); } if (stroke) { ctx.strokeStyle = stroke; ctx.stroke(); } } // A DROP SHADOW UNDER THE HUD. The board, the blood panel and the rack sit on a crowd now, and an // overlay lifts off a busy backdrop with a shadow — the same thing shadowText already does for loose // text, one pixel at a time. Canvas shadow offsets are NOT scaled by the transform (they are device // pixels), so they are multiplied by RES here, or a 2 px shadow reads as half a logical pixel on a // 4x phone. Call hudShadow(false) as soon as the element is drawn: the state is sticky and would // otherwise land under every glyph that follows. function hudShadow(on, off = 3, blur = 3, a = 0.68) { ctx.shadowColor = on ? `rgba(0,0,0,${a})` : 'transparent'; ctx.shadowBlur = on ? blur * RES : 0; ctx.shadowOffsetX = ctx.shadowOffsetY = on ? off * RES : 0; } function drawShadow(px, py, rx, ry, alpha = 0.28) { ctx.fillStyle = `rgba(0,0,0,${alpha})`; ctx.beginPath(); ctx.ellipse(px, py, rx, Math.max(1, ry), 0, 0, 7); ctx.fill(); } function drawBall(x, y, z, glint = false, bomb = false, flame = false) { const sh = proj(x, 0, z); drawShadow(sh.x, sh.y, Math.max(1.5, sh.s * 0.09), sh.s * 0.04, 0.3); const bp = proj(x, y, z); const r = Math.max(1.5, bp.s * 0.09); if (bomb) { // black ball with a flickering orange fuse pixel on top ctx.fillStyle = '#111'; ctx.beginPath(); ctx.arc(bp.x, bp.y, r, 0, 7); ctx.fill(); if (Math.floor(G.idleT * 20) % 2) { ctx.fillStyle = '#ff8010'; ctx.fillRect(bp.x - 0.5, bp.y - r - 1, Math.max(1, r * 0.5), Math.max(1, r * 0.6)); } return; } if (flame) { // a burning ball, not an orange one: an additive halo, then shell, then white core const fl = Math.floor(G.idleT * 24) % 2 ? 1 : 0; ctx.save(); ctx.globalCompositeOperation = 'lighter'; for (const [rf, af] of [[3.1, 0.16], [2.0, 0.26]]) { // the halo is what makes it read as heat ctx.globalAlpha = af; ctx.fillStyle = '#ff7a10'; ctx.beginPath(); ctx.arc(bp.x, bp.y, r * rf + fl, 0, 7); ctx.fill(); } ctx.restore(); ctx.globalAlpha = 1; ctx.fillStyle = '#ff8010'; ctx.beginPath(); ctx.arc(bp.x, bp.y, r * 1.25 + fl, 0, 7); ctx.fill(); ctx.fillStyle = '#ffc030'; ctx.beginPath(); ctx.arc(bp.x, bp.y, Math.max(1, r * 0.8), 0, 7); ctx.fill(); ctx.fillStyle = '#fffbe0'; ctx.beginPath(); ctx.arc(bp.x, bp.y, Math.max(1, r * 0.42), 0, 7); ctx.fill(); return; } ctx.fillStyle = UI.ball; ctx.beginPath(); ctx.arc(bp.x, bp.y, r, 0, 7); ctx.fill(); if (glint) { ctx.fillStyle = '#f8f8d0'; ctx.fillRect(bp.x - r * 0.3, bp.y - r * 0.5, Math.max(1, r * 0.5), Math.max(1, r * 0.5)); } } // ----------------------------------------------------------------- actors ---- // `o.sink` lowers the frame by a fraction of its own drawn height, and `o.shadow: false` drops the // contact ellipse. Both exist for the PRONE frame of a fall — see the note at drawZombie's call. function drawStatic(name, x, z, hMeters, bob = 0, flip = false, o = {}) { const img = SPR[name]; if (!img) return; const p = proj(x, 0, z); const h = hMeters * p.s, w = h * img.width / img.height; const y = p.y - h - bob + h * (o.sink || 0); if (o.shadow !== false) drawShadow(p.x, p.y, w * 0.4, h * 0.04, 0.25); if (!flip) return ctx.drawImage(img, p.x - w / 2, y, w, h); ctx.save(); ctx.translate(p.x, 0); ctx.scale(-1, 1); // the art is drawn facing LEFT ctx.drawImage(img, -w / 2, y, w, h); ctx.restore(); } function drawBoy(b) { // mopping is not sprinting: its own sheet and a slower push cycle. Falls back to the run // frames until the mop PNGs have loaded. // Three sheets, because a boy WALKING to the next stain is not a boy mopping: x_boymop is the // push cycle (used only while he's stopped and working), x_boycarry is him walking upright with // the mop shouldered, x_boyrun is the plain tennis-mode ball fetch. const mop = b.mop && SPR.x_boymop1; const sheet = !mop ? 'x_boyrun' : b.mopT > 0 ? 'x_boymop' : SPR.x_boycarry1 ? 'x_boycarry' : 'x_boymop'; // Frames step off DISTANCE COVERED, not elapsed time: a time-driven cycle made him moonwalk // whenever his speed and the frame rate disagreed. Now the stride owns the ground he covers. const stride = TUNE.boyStride * (mop ? 1 : 2.1); // a sprint eats more ground per frame than a push const img = SPR[sheet + (Math.floor((b.dist || 0) / stride) % 4 + 1)]; if (!img) return; const p = proj(b.x, 0, b.z); // scale off the frame's OWN pixel height, not a fixed BOY_H: the sheets are common-scaled per // sheet, so a deep mopping lean is genuinely shorter and must not be stretched back upright const h = BOY_H * p.s * (img.height / SPRITE_H) * farB(b.z), w = h * img.width / img.height; drawShadow(p.x, p.y, w * 0.3, h * 0.04, 0.25); ctx.save(); ctx.translate(p.x, p.y - (mop ? 0 : Math.floor(b.runT * 20) % 2)); if (b.dir < 0) ctx.scale(-1, 1); ctx.drawImage(img, -w / 2, -h, w, h); ctx.restore(); } // Frames stepped by DISTANCE WALKED need a POSITIVE modulo. Bodies spawn BEHIND the far baseline at // negative z, and JS keeps the sign of the dividend: floor(-1.5 / 0.25) % 4 is -2, so the pick asked // for 'z_g-1' — a sprite that does not exist, and drawStatic silently draws nothing. That is the // blinking Oliver saw on the way in, on exactly the bodies that step off distance: brutes and bosses. const stepFrame = (v, n) => ((Math.floor(v) % n) + n) % n; // 0..n-1, whatever the sign of v const ZFRAMES = { a: 4, b: 4, e: 4, f: 4, d: 4, g: 4, h: 4, k: 4, s: 4, c: 4 }; // 4-frame walk cycles where sheets exist; the rest stay 2 // Bodies whose stride steps off GROUND COVERED instead of the clock. They are the slow heavy ones, // and a time-driven cycle on them is pure foot-slide (the defect the brute's rule was written for). const ZSTRIDE = { g: TUNE.bruteStride, k: TUNE.rollerStride }; // The rogue ball machine, on its own generated sheet: a dented hopper cart on wheels with a barrel // aimed down-court, and a second frame with the muzzle flashing. Oliver called the first, procedural // version "naively painted" — it was four rects, which is fine for fire but not for furniture the // player has to recognise and prioritise. // The weapon barrel. The DRUM is a sprite (it is an object, and a procedural one would read as // "naively painted" the way the machine's first version did); the GLOW is procedural, because light // is one of the three things that rule explicitly exempts, along with blood and fire. It pulses, so // it separates itself from the static ads and the crowd behind it at 20 px tall. function drawBarrel(zb) { const img = SPR.z_barrel1; const B = farB(zb.z); // readability boost — drum, glow anchor and rolling frames share it const h = TUNE.barrelH * (img ? img.height / SPRITE_H : 1) * B; const p = proj(zb.x, h * 0.5, zb.z); const k = 0.55 + 0.45 * Math.sin((zb.roll || 0) * 3.1); // the pulse rides the roll, not a clock ctx.save(); // THE PINK IS ALL HERE, and it can be as pink as it likes: this is light painted at draw time and // it never goes near the chroma key that eats magenta out of PNGs. Three rings instead of one — // a wide soft bloom, a bright body, and a white-hot core — because one flat disc at 20 px tall // read as a smudge on the grass rather than as something GLOWING on a dark court. const rgb = RGB(UI.pink); for (const [rf, af] of [[0.62, 0.16], [0.34, 0.34], [0.17, 0.55]]) { ctx.globalAlpha = af * (0.55 + k * 0.65); ctx.fillStyle = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`; ctx.beginPath(); ctx.arc(Math.round(p.x), Math.round(p.y), Math.max(2, p.s * rf * (0.85 + k * 0.35)), 0, 7); ctx.fill(); } // ...and a pink pool on the LAWN under it, which is what actually sells "this thing is lit up" const gp = proj(zb.x, 0, zb.z); ctx.globalAlpha = 0.16 + k * 0.20; ctx.beginPath(); ctx.ellipse(gp.x, gp.y, Math.max(3, gp.s * 0.55), Math.max(1.5, gp.s * 0.20), 0, 0, 7); ctx.fill(); ctx.globalAlpha = 1; ctx.restore(); if (img) { // two frames alternating off GROUND COVERED is what makes it read as rolling rather than sliding const n = SPR.z_barrel2 && stepFrame((zb.roll || 0) / 0.5, 2) ? 'z_barrel2' : 'z_barrel1'; drawStatic(n, zb.x, zb.z, TUNE.barrelH * (SPR[n].height / SPRITE_H) * B); } else { // until the PNG lands, a lit drum ctx.fillStyle = UI.pink; const w = Math.max(2, p.s * 0.34), hh = Math.max(3, p.s * 0.44); ctx.fillRect(Math.round(p.x - w / 2), Math.round(p.y - hh / 2), Math.round(w), Math.round(hh)); } ctx.globalAlpha = 0.5 + k * 0.5; // the core, brightest at the pulse peak ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(Math.round(p.x), Math.round(p.y), Math.max(1, p.s * 0.05), 0, 7); ctx.fill(); ctx.globalAlpha = 1; } function drawMachine(zb) { const firing = zb.windT > 0; // It wears its damage like every other multi-hit body on court: dented at two thirds, split open // and sparking at one. The healthy sheet keeps the muzzle-flash frame; a damaged machine flashes // procedurally instead of needing a firing frame per stage. const f = zb.hp / (zb.hpMax || TUNE.machHp); const dmg = f <= 0.34 ? 'z_machw2' : f <= 0.67 ? 'z_machw1' : null; const name = dmg && SPR[dmg] ? dmg : firing && SPR.z_mach2 ? 'z_mach2' : 'z_mach1'; const img = SPR[name]; if (!img) return; // sheet not loaded yet: draw nothing, not a box const h = MACH_H * (img.height / SPRITE_H) * farB(zb.z); // readability boost — muzzle glow rides h too drawStatic(name, zb.x, zb.z, h); if (firing) { // the TELEGRAPH still needs to read at 20px tall, so the muzzle glow stays procedural const k = Math.max(0, 1 - zb.windT / (zb.windMax || TUNE.machWind)), p = proj(zb.x, h * 0.62, zb.z); // fade over the ACTUAL wind ctx.globalAlpha = 0.3 + k * 0.7; ctx.fillStyle = k > 0.6 ? '#fff2c0' : UI.warm; const r = Math.max(1.5, p.s * 0.05 * (0.5 + k)); ctx.beginPath(); ctx.arc(Math.round(p.x + p.s * 0.1), Math.round(p.y), r, 0, 7); ctx.fill(); ctx.globalAlpha = 1; } } const p0 = zb => proj(zb.x, 0, zb.z).s; // projected scale, for effects that need pixels not metres function drawZombie(zb) { if (zb.variant === 'o') return drawBarrel(zb); if (zb.variant === 'm') return drawMachine(zb); const rk = zb.variant === 'r'; // THE MATCH OPPONENT HAS ITS OWN SPRITES NOW. It used to borrow the turned CPU's ('c') — a body // drawn to SHAMBLE at the camera with its racket hanging at its side, which is what was playing // tennis points against you. The 'r' family is drawn off the LIVING opponent's poses instead (a // ready stance, a shuffle pair and a real forehand, at every wound stage). 'c' keeps its shamble // untouched, because in the gore mode shambling is exactly what the turned CPU should be doing. // WHICH FAMILY THIS BODY WEARS. `fam` rides on the zombie (spawnRacketZombie leaves it unset, so // the club pro is 'r'), which is what lets a generated variant be a different opponent rather than // a different game: every name below is built from it. Falls back to the turned CPU's shamble // while a family's sheets have not landed. const fam = zb.fam || 'r'; const v = rk ? (SPR['z_' + fam + '1'] ? fam : 'c') : zb.variant; const rNew = rk && v !== 'c'; let N = ZFRAMES[v] || 2; if (!SPR['z_' + v + N]) N = 2; // frames still loading (or sheet rejected): 2-frame fallback // same CYCLE frequency as before — more frames subdivide the stride, they don't speed it up // The racket zombie holds a position and only moves when the ball makes him, so a time-driven // cycle had him flicking between two frames on the spot. His stride steps off ground covered like // the brute's — standing still is standing still — and his swing keeps its own frame. // The 'r' family's own cycle: 1 = the ready stance, held whenever racketAI is not running him; // 2/3 = the shuffle; 4 = the forehand. `pose` is racketAI's own signal, so the picture and the // behaviour cannot drift. let frame = rNew ? (zb.pose === 'swing' ? 4 : zb.pose === 'run' ? stepFrame((zb.dist || 0) / TUNE.racketStride, 2) + 2 : 1) : rk && zb.pose === 'swing' ? 2 : rk ? stepFrame((zb.dist || 0) / TUNE.racketStride, N) + 1 : ZSTRIDE[v] ? stepFrame(zb.z / (ZSTRIDE[v] * 2 / N), N) + 1 // heaviest bodies: step off DISTANCE // WALKED, like their wound lurch below, // or the feet slide (same cycle length) : Math.floor(zb.walkT * (zb.variant === 'f' ? 6 : 2.6) * N / 2) % N + 1; const chairDown = zb.variant === 's' && zb.shieldT > 0; // chair dropped — hit it NOW. Its own sprite // now (z_sdown); it used to be walk frame 3, // which a 4-frame cycle would walk right through // the brute towers half again over the crowd, the steward stands between him and it, and a // chapter boss towers over the brute. Shared with gameplay, which aims the serve at a fraction // of this — if the two tables drifted the ball would arrive at the wrong part of the body. let h = zombieH(zb); // NOT const: the headless boss skin shortens it, and the sprite sheet scales it // wounded skins: multi-hit enemies show their damage as hp drops (arm off → head gone, still // shambling). The blood is NOT painted in — open wounds gush live particles (see bleed() in // zombies.js). Fall back to the healthy sprite until the wounded PNG has loaded. let name = chairDown && SPR.z_sdown ? 'z_sdown' : 'z_' + v + frame; // ONE lookup for every multi-hit body that reads damage against absolute hp. config.ZWOUND is // worst-first and indexed by REMAINING hp − 1, which is the brute's old hardcoded stage array // generalised; a single-skin body clamps to index 0 and falls out of the same line. This replaced // two branches that did the same job against different constants, which is why the ball boy, the // bouncer and the roller man each cost a table row here and no code at all. const stages = ZWOUND[zb.variant], sIdx = stages ? woundStage(zb, stages.length) : 0; if (sIdx) { const stage = stages[sIdx - 1]; if (SPR[stage]) { // Every wound stage has its OWN two-frame lurch (…w / …wb = the stride swapped). The heavy // bodies step it by DISTANCE WALKED (a time-driven cycle on them was pure foot-slide, and // replaced a procedural squash-and-sway that read as exactly what it was: fake); everything // else steps at the SAME cadence as its healthy walk, or a wounded body reads as frozen next // to the crowd it is walking in. The comedy is in the gait, and the gait has to be drawn. const b = stage + 'b', phase = ZSTRIDE[v] ? zb.z / ZSTRIDE[v] : zb.walkT * 5.2; name = SPR[b] && stepFrame(phase, 2) ? b : stage; } else if (SPR[stages[0]]) name = stages[0]; // a missing asset degrades to the lightest skin that loaded } // The boss's damage is read as a FRACTION of full health — his hp scales with the run // (TUNE.racketHpPerGame), so the old `hp === 2` / `hp <= 1` thresholds would have left a late-run // boss looking untouched until the killing blow. Keep these in step with bleed()'s anchors. else if (rk) { const st = racketStage(zb); // config's rule — the fall beat reads the same one // NO HEIGHT OVERRIDE FOR THE HEADLESS STAGE. There used to be `if (st === 2) h = 1.52` here, to // take a head off the top — and it was double-counting, because the ART already accounts for the // missing head and the line above already scales every frame by its own PNG height. Oliver, from // the bench: "every headless mode shrinks the whole opponent a little." Measured: the drawn body // went 34.0 px healthy -> 33.8 arm off -> 27.8 headless on ALL EIGHT families, a flat -18%, and // the FEET shrank 16-19% with it. A body that loses its head does not get smaller feet. // Why the correction went stale: a headless sprite is still a full-height frame, because the // generator draws a gushing neck stump whose blood plume fills the space the head occupied — // 18.3% of the frame sits above z_rw2's shoulders against 10.7% above z_rw1's head. Shrinking the // whole frame on top of that scaled the torso and the legs too. It was also wrong for the // borrowed 'c' fallback skins (z_cw2 is a SHORTER PNG, which the per-frame scaling handles): // their body drew at 1.30 m against the healthy 1.78 m. // Residual: the plume is not exactly a head, so the headless body still lands within about -8% to // +2% of the wounded one, varying by family (r -8.5%, gk +1.8%). That is art variance, not a // code-imposed shrink, and the place to fix it is _zsheet.py — levelling the wound rows on the // SHOULDER line, the same way it already levels the walk frames on total height. // NOTE the HP bar and the aim mark read config.zombieH directly, so they were floating above a // shrunken body for as long as this override existed; removing it re-aligns them for free. if (st && rNew) { // every wound stage carries the SAME three poses as the healthy body, so a one-armed // opponent still plays tennis rather than reverting to a shamble the moment you wound it // ...stepped off GROUND COVERED, the same clock the healthy cycle two blocks up uses. It was // walkT — a wall clock — so a wounded opponent's legs cycled at a fixed cadence however fast // it was actually moving, which is the foot-slide this project banned for the heavy bodies. const base = 'z_' + fam + 'w' + st; const want = zb.pose === 'swing' ? base + 's' : zb.pose === 'run' && SPR[base + 'b'] && stepFrame((zb.dist || 0) / TUNE.racketStride, 2) ? base + 'b' : base; if (SPR[want]) name = want; else if (SPR[base]) name = base; } else if (st) { // the old borrowed 'c' skins, while they are all there is if (st === 2 && SPR.z_cw2) name = 'z_cw2'; else if (st === 1 && SPR.z_cw1) name = 'z_cw1'; const b = name + 'b'; // ...and he limps through his wounds too, instead of gliding if (name !== 'z_' + v + frame && SPR[b] && stepFrame(zb.walkT * 5.2, 2)) name = b; } } // The chapter boss comes apart through the SAME fraction-of-hpMax rule as the racket zombie, over // three stages, so a 34 hp chapter-4 boss visibly degrades all the way down: arm off, then ribs // out, then headless and still throwing. Its 2-frame stride steps off DISTANCE COVERED (the same // rule the brute uses) — it is the slowest thing on court and a time-driven cycle would foot-slide. // The chapter boss picks its frames from its DESIGN (`zb.art`, one row of config.BOSS_ART per // chapter, cycling), so a new boss is a sheet plus a data row and never a change here. Damage is a // FRACTION of hpMax spread over however many wound stages that design has, and the wounded state // stays visible MID-THROW — the one place Oliver found damage disappearing. // NEVER name a sprite so it differs from another only by case: macOS is case-insensitive, so // writing z_B1.png silently OVERWROTE the 'b' shambler's z_b1.png and a boss turned up in the crowd. if (zb.variant === 'B') { const A = zb.art || BOSS_ART[0]; const idx = woundStage(zb, A.wounds.length); // the shared rule — see config.woundStage // The stride steps off zb.dist — GROUND COVERED on BOTH axes, which zb.z alone is not: a boss // that paces or hops sideways would slide across the court with its feet frozen. (zb.z is the // fallback so a boss spawned by an older path still animates.) const walked = (zb.dist ?? zb.z) / TUNE.bruteStride; // retT is the CHAMPION'S RETURN — the same throw frame, held briefly, so a ball he sends back // has a stroke behind it instead of arriving out of a body that never moved. const wanted = zb.windT > 0 || zb.retT > 0 ? A.throws[Math.min(idx, A.throws.length - 1)] : idx ? A.wounds[idx - 1] : A.walk[stepFrame(walked, 2)]; // a design whose sheet has not been generated (or has not loaded) degrades to design 1 rather // than vanishing, since drawStatic silently draws nothing for a missing sprite const stage = SPR[wanted] ? wanted : BOSS_ART[0].walk[stepFrame(walked, 2)]; if (SPR[stage]) name = stage; } // A MISSING FRAME MUST NEVER BLINK A BODY OUT OF EXISTENCE. N above is validated against the LAST // frame only, so a set with (say) z_h2 missing but z_h4 present keeps N = 4, the cycle steps onto a // name that never loaded, and drawStatic silently draws NOTHING — the body vanishes for that frame // and comes back on the next one. That is what Oliver saw on his phone, and a cached 404 (this // project's oldest trap) is enough to cause it. Hold frame 1 instead: a body that stops animating // is a bug you can see and diagnose; one that flickers in and out reads as the game breaking. if (!SPR[name] && SPR['z_' + v + '1']) name = 'z_' + v + '1'; // THE BENCH OVERRIDE, last of all: it names a frame outright, so states the game cannot reach // yet (the serve, the fall) can be looked at the day they are generated rather than the day they // are wired up. Only the back room's sprite bench ever sets this. if (zb.showFrame) { // a wound stage that has not been drawn yet degrades to the HEALTHY frame rather than to the // walk cycle — the bench prints what it actually drew, so the substitution is visible const heal = zb.showFrame.replace(new RegExp('^z_' + fam + 'w\\d'), 'z_' + fam); if (SPR[zb.showFrame]) name = zb.showFrame; else if (SPR[heal]) name = heal; } // ...and only NOW the tour swap, so the height read below comes off the frame actually drawn. The // branches above all test the men's names (which always exist), so the wound logic is untouched. name = SK(name); // Every frame is drawn at ITS OWN height. drawStatic forces the height it is given, so a crouched // frame sliced 13 px shorter than its neighbours was being stretched back to full size — the body // grew and shrank once per stride. Oliver: "the brute is still pulsating while walking". The rule // already existed for the wound stages; it belongs to every frame, and it is the same rule // _slice.py's common scaling is built on (see PROJECT.md: heights follow the PNG). if (SPR[name]) h *= SPR[name].height / SPRITE_H; // the readability boost, AFTER every art-driven height rule: h is in metres here, so the flames, // the boss glow and the HP bar below all inherit it through the same proj() as the sprite h *= farB(zb.z); // feeding: a fast bob over the body instead of a stride. Same sprites, different rhythm. // ...and the SHAMBLE BOB is the same rule again, which it was breaking twice over. It was // `Math.floor(zb.walkT * 5.2) % 2` — a 1 px lift on a WALL CLOCK, applied to every body every // frame whether it was moving or not. So a held pose bobbed forever (the sprite bench shows a // FOREHAND that will not sit still), a collapsing body bobbed while it lay on the ground, and at // RES 4 that 1 logical px is 4 device px flicking at 2.6 Hz. Two fixes, both the house rule: // it only happens while the body is actually WALKING, and it steps off GROUND COVERED. const still = zb.pose === 'idle' || zb.pose === 'swing' || zb.pose === 'serve'; const bob = zb.feeding ? Math.abs(Math.sin(zb.walkT * 11)) * Math.max(1, p0(zb) * 0.05) : still ? 0 : Math.floor((zb.dist ?? zb.z) / 0.42) % 2; // Every side-on body is DRAWN facing left, so one walking right was moonwalking — obvious the // moment they close on you across your own half. The front-facing ones (the turned CPU, the // runner, the machine, a chapter boss) have no left or right to turn and are left alone. zb.drawn = name; // WHICH FRAME THIS ACTUALLY IS. The sprite bench prints it, so a frame that looks // wrong on the court names its own file instead of being hunted for afterwards. // THE PRONE FRAME LIES ON ITS RACKET, NOT ON THE GROUND. Every frame is bottom-aligned on the // ground point, and in the fall3 art the lowest ink is the racket hoop thrown out beside the body — // so the torso hangs above the line and the corpse reads as levitating, propped on its own racket. // Oliver, from the bench: "the player basically levitates over the ground held by his racket." // Measured on z_shw1fall3 (249x85): the body's underside is at y67 and rows 68-84 are the racket, // so the body floats a fifth of the frame's height. TUNE.proneSink drops it by that fraction, which // puts the body line where fall2 leaves its knees; the racket then lies across the line, which is // where a dropped racket belongs. The suffix-tolerant test is for the tour swap (SK appends to the // name, so a plain /fall3$/ would miss z_..fall3_f). // ...AND NO CONTACT SHADOW WHILE IT FALLS. The ellipse is sized off the DRAWN WIDTH, and a prone // frame is three times wider than a standing one — so the widest, darkest shadow in the game was // being painted under the one body that is not standing on it, which is most of why it read as // floating. Suppressed for the whole beat, not just the prone frame: a body already off its feet // has no business casting a standing man's contact patch. // THE TWO ART GENERATIONS FACE OPPOSITE WAYS, so they flip on opposite signs. The old shamblers are // drawn facing LEFT — one walking right is mirrored, which is what `face > 0` is for. THE MATCH- // OPPONENT FAMILIES ARE DRAWN FACING RIGHT, and were being mirrored by the same test, so every one // of them walked backwards: on court and in the bench alike, since racketAI and updateBench both set // face as the sign of x movement. // MEASURED, not guessed, and the racket is what measures it. Its string bed keys out to an enclosed // transparent hole, so the hoop locates itself: in all seven families, in BOTH walk frames, the // hoop's x centroid sits ~50 px to the RIGHT of the body's (sh 101 vs 53, lj 103 vs 52, br 102 // vs 48). Oliver's own test — "if this racket is facing to the right I would assume that he also // walks to the right" — and a racket thrown out to the right is a body heading right. // The front-facing bodies ('c' turned CPU, 'f' sprinter, 'm' machine, 'B' boss) are excluded from // flipping entirely: mirroring a body with no left or right only swaps which hand holds the racket. // ...AND THE WOUND WALK FRAMES ARE MIRRORED RELATIVE TO THE HEALTHY ONES. Measured the same way, // on the master and on every family: healthy stride +0.38..+0.42 (racket right), wound stride // -0.39..-0.45 (racket left). The wounded body is drawn facing the OTHER WAY, and it has been since // the master was made, so the reference carries it and all seven families copied it. One flip rule // therefore cannot serve both rows — fixing the healthy walk (which is where Oliver first saw it) // inverted the wound walk, which is what he saw next. // The test is the WALK FRAMES ONLY (`w1`, `w1b`, `w2`, `w2b`), because they are the only frames whose // orientation tracks travel. The wound FOREHAND (`w1s`) measures racket-right like the healthy one, // and the serve/fall poses put the racket overhead or flailing where it says nothing about direction // — so they stay on the healthy convention rather than being dragged along by their row. // `_famref.py` pins these sides, so a future roll that mirrors a row fails the gate instead of // silently reaching the court. If that gate ever fires, fix the ART and delete this branch. const prone = /fall3(_|$)/.test(name); const artFacesRight = !rNew || !/w\db?(_[a-z0-9]+)?$/.test(name); const flip = (artFacesRight ? zb.face < 0 : zb.face > 0) && !'cfmB'.includes(v); drawStatic(name, zb.x, zb.z, h, bob, flip, { sink: prone ? TUNE.proneSink : 0, shadow: !G.matchFall }); if (zb.burnT > 0) drawFlames(zb, h); if (zb.windT > 0 && zb.variant === 'B') { // the boss telegraphs too: it rears up, glowing, then throws const k = Math.max(0, 1 - zb.windT / (zb.windMax || TUNE.bossWind)), p = proj(zb.x, h * 0.62, zb.z); // fade over the design's own wind ctx.globalAlpha = 0.25 + k * 0.55; ctx.fillStyle = k > 0.6 ? '#fff2c0' : UI.bad; const r = Math.max(2, p.s * 0.1 * (0.5 + k)); ctx.beginPath(); ctx.arc(Math.round(p.x), Math.round(p.y), r, 0, 7); ctx.fill(); ctx.globalAlpha = 1; } // NO HP BAR ON A SHAMBLER — that one was cut and stays cut: it was the only floating UI on a body // anywhere in the game, it was up for almost the whole run, and the wound SKINS already encode the // same thing in the language every other multi-hit enemy uses. A CHAPTER BOSS is the exception, // and for the reason the rule exists: it does NOT re-skin as it takes damage, its pool runs to // dozens of points, and the fight is long enough that "am I getting anywhere" is a real question // with no other answer on screen. One boss, on screen only during its own fight. if (v === 'B' && zb.hpMax) drawBossHp(zb, h); } // THE MATCH'S TIMING, DRAWN. Oliver: "the tennis timing mechanic would need to be visualized // somehow (what is the possible tap window what is the sweet spot)". Two cues, both riding numbers // the mechanics already use, so the picture cannot drift from the physics: // // THE APPROACH RING (the rally tap): a ring around the incoming ball whose radius tracks its // distance to the sweet contact range — the rings kiss exactly when the ball is where the tap // lands a clean hit (matchRingSweet, the centre of the tapped-points benchmark's 2.2..3.2 m), and // the ring turns gold inside the band. The landing ring set this precedent: a marker that // contracts toward its moment IS a timing cue, no legend needed. function drawApproachRing() { // ...in CLASSIC TENNIS TOO. This was `!G.match`, so the mode whose entire pitch is "if the core // tennis is good everything else is good" was the one mode without the timing cue that makes the // core tennis legible. Reported three times before it was believed. The gore mode is left out on // purpose: there is no rally there, the ball comes back off bodies. if (!(G.match || G.mode === 'tennis') || G.phase !== 'rally' || !ball.active || ball.lastHit !== 'O') return; const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); // the CONTACT test's own metric if (d > TUNE.matchRingFar || ball.vz < 0) return; // far away, or already going back const p = proj(ball.x, ball.y, ball.z); if (p.s <= 0) return; const inBand = Math.abs(d - TUNE.matchRingSweet) < TUNE.matchRingBand; const rSweet = Math.max(6, p.s * 0.055); // the fixed target ring const r = rSweet * Math.max(1, d / TUNE.matchRingSweet); // the closing ring — they kiss at sweet ctx.lineWidth = 1.5; ctx.strokeStyle = inBand ? UI.warm : 'rgba(255,255,255,0.55)'; ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, 7); ctx.stroke(); ctx.strokeStyle = inBand ? UI.warm : 'rgba(255,255,255,0.3)'; ctx.beginPath(); ctx.arc(p.x, p.y, rSweet, 0, 7); ctx.stroke(); } // THE APEX BAND (the serve tap): a thin guide beside the tossed ball with a gold band spanning the // perfect window (serveApexY ± TUNE.apexSnap — the SAME number keyPressed scores as perfect, so // tightening the mechanic tightens the picture) and a wider white zone for the playable one. A // tick rides the live ball height. The band makes the strike a target you can see coming. function drawApexBand() { if (!(G.match || G.mode === 'tennis') || G.phase !== 'toss' || score.server !== 'P') return; const gx = ball.x + 0.55; // beside the toss, out of the ball's way const apex = serveApexY(); const yTop = proj(gx, apex + 0.45, ball.z), yBot = proj(gx, 1.2, ball.z); if (yTop.s <= 0) return; const x = Math.round(yTop.x); ctx.lineWidth = 1; ctx.strokeStyle = 'rgba(255,255,255,0.35)'; // the guide ctx.beginPath(); ctx.moveTo(x, yTop.y); ctx.lineTo(x, yBot.y); ctx.stroke(); const bTop = proj(gx, apex + TUNE.apexSnap, ball.z).y, bBot = proj(gx, apex - TUNE.apexSnap, ball.z).y; ctx.fillStyle = hexA(UI.warm, 0.8); // the perfect window, gold ctx.fillRect(x - 3, Math.round(bTop), 6, Math.max(2, Math.round(bBot - bTop))); const tick = proj(gx, ball.y, ball.z).y; // where the ball is NOW ctx.fillStyle = '#fff'; ctx.fillRect(x - 5, Math.round(tick), 10, 2); } // THE TRIAGE READOUT: score, multiplier, best — and the calibration marker. There is no strip and // no called list any more; the court is the puzzle, so the HUD only carries what the court cannot // say: how much the run is worth and how hot the streak is. // THE SPRITE BENCH'S READOUT. The one thing a contact sheet cannot give you: what the game is // ACTUALLY drawing, right now, at the size it plays at — including the filename, so a frame that // looks wrong on the lawn names itself instead of being hunted for afterwards. // THE BENCH'S OWN BUTTONS. Arrow keys are not a control scheme on the device this game is played // on, so every family and every wound stage was unreachable from a phone — which is where "the // walking looks wrong on some of them" has to be checked. Geometry lives here, next to the drawing, // and input.js hit-tests the same function: one source, so a moved button cannot stop responding // (the rack learned this the hard way — see rackGeom). export function benchButtons() { // FOUR across now: FALL plays the real collapse-and-rise beat (beats.benchFall), which is a SEQUENCE // and not a pose, so it could not be one of the stepped stops. const w = Math.floor((W - 28) / 4), h = 22, y = H - SAFE.bottom - h - 6; return [ { k: 'ArrowUp', k2: 'ArrowDown', label: 'BODY', x: 8, y, w, h }, { k: 'ArrowLeft', k2: 'ArrowRight', label: 'WOUND', x: 8 + (w + 4), y, w, h }, { k: 'Space', k2: 'Space', label: 'POSE', x: 8 + (w + 4) * 2, y, w, h }, { k: 'KeyF', k2: 'KeyF', label: 'FALL', x: 8 + (w + 4) * 3, y, w, h }, ]; } function drawBenchButtons() { for (const b of benchButtons()) { panel(b.x, b.y, b.w, b.h, 4, 'rgba(12,14,20,0.82)', UI.dim); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = F(8); // '<' and '>' on the two-way ones say the tap SIDE matters, which is the whole control: // left half steps back, right half steps forward. shadowText(b.k === b.k2 ? b.label : '\u2039 ' + b.label + ' \u203a', b.x + b.w / 2, b.y + b.h / 2 + 1, UI.cream); } ctx.textBaseline = 'alphabetic'; } function drawBenchHud() { const b = G.bench; if (!b) return; drawBenchButtons(); const zb = zombies.find(z => z.variant === 'r'); const y = CARD_TOP + 4; ctx.textAlign = 'center'; ctx.font = B(20); shadowText(b.char ? (b.char[0] === 'p' ? 'PLAYER ' : 'OPPONENT ') + (b.char.endsWith('f') ? 'FEMALE' : 'MALE') + ' · RUN CYCLE' : (ZFAMS.length > 1 ? ZFAMS[b.fam].toUpperCase() + ' · ' : '') + BENCH[b.i].name, W / 2, y, UI.cream); if (b.char) { // the finetune readout: the dials a live tuning session actually turns ctx.font = F(8); shadowText('runHz ' + TUNE.runHz + ' · aiSpeed ' + TUNE.aiSpeed + ' · T.TUNE.runHz = … to tune live', W / 2, y + 24, UI.warm); ctx.font = F(7, 0); shadowText('↑/↓: OPPONENT · THE LIVING SLOTS SHOW ONLY THE RUN', W / 2, H - 26, UI.dim); return; } ctx.font = F(8); // ...and say so when the stop has no wound variant. The serve and the fall exist for the HEALTHY // body only so far, so a stage label there would be claiming art that has not been drawn — and the // whole reason this screen exists is to stop the art and the wiring lying to each other. // FELL BACK means the stage asked for a frame that has not been drawn and got the healthy one — // said out loud, because a bench that quietly substitutes art is worse than no bench. const fell = zb && zb.showFrame && zb.drawn && zb.drawn !== zb.showFrame; shadowText(BENCH_STAGES[b.stage] + ' · ' + (b.i + 1) + '/' + BENCH.length + (fell ? ' · FELL BACK' : ''), W / 2, y + 24, fell ? UI.bad : UI.warm); // the file on screen, and the hp fraction the wound stage was read from — the two numbers you // need to tell "the art is wrong" from "the wiring picked the wrong art" ctx.font = F(8); shadowText(zb ? zb.drawn || '—' : 'NO BODY', W / 2, y + 40, UI.pick); if (zb) shadowText('hp ' + zb.hp + '/' + zb.hpMax, W / 2, y + 54, UI.dim); ctx.font = F(7, 0); // WHILE THE FALL RUNS, the hint line becomes the beat's stopwatch — refining the pause means // watching a number next to the picture, not counting frames afterwards. The frame name two lines // up already says WHICH picture, so this only has to say how long it has been up. const mf = G.matchFall; shadowText(mf ? 'FALL ' + mf.t.toFixed(2) + 's · TAP FALL AGAIN TO REPLAY' : IS_TOUCH ? 'TAP: NEXT STATE · FALL: THE REAL COLLAPSE + RISE' : 'SPACE: NEXT STATE ←/→: WOUND STAGE' + (ZFAMS.length > 1 ? ' ↑/↓: OPPONENT' : '') + ' F: FALL', W / 2, H - 26, mf ? UI.warm : UI.dim); } function drawDrillHud() { const d = G.drill; if (!d) return; const y = CARD_TOP + 4; ctx.textAlign = 'center'; ctx.font = B(24); shadowText(String(d.score), W / 2, y, UI.cream); const m = drillMult(d); ctx.font = B(14); // the multiplier is the streak made visible — gold once it is working, dim while it rebuilds shadowText('x' + m + ' · ' + d.streak + ' STREAK', W / 2, y + 26, m > 1 ? UI.warm : UI.dim); ctx.font = F(8); shadowText('BEST ' + Math.max(d.best, d.score), W / 2, y + 44, UI.dim); if (d.msg && d.msg.t > 0) { ctx.font = B(20); shadowText(d.msg.text, W / 2, y + 60, d.msg.bad ? UI.bad : UI.pick); } // THE MARKERS ARE TRAINING WHEELS THAT TAKE THEMSELVES OFF. Every LEGAL kill is marked — the // whole slack set, not the bare head: the rule accepts any near-tie, so a single chevron was // stricter than the rule it explained and every kill made it jump between tied bodies. Marking // the set is also stable by construction (arrival differences never change while bodies walk; // the set only moves on a kill or an arrival). // They HIDE once the streak shows you have the read (drillMarkOff) and come back on any fault — // which answers "would a player get this without the marker": the marker teaches, the streak // decides when you have learned it, and unlearning (a fault) brings the teacher back. if (TUNE.drillMark >= 0.5 && d.streak < TUNE.drillMarkOff) { const legal = drillLegal(); for (let i = 0; i < legal.length; i++) { const zb = legal[i]; const p = proj(zb.x, zombieH(zb) * farB(zb.z) + 0.5, zb.z); // hover clear of the BOOSTED head if (p.s <= 0) continue; const bob = Math.sin(G.idleT * 6) * 2; const px = Math.round(p.x), py = Math.round(p.y + bob); ctx.globalAlpha = i === 0 ? 1 : 0.45; // the head is bright; a tied body is a quiet "also fine" ctx.fillStyle = UI.warm; ctx.beginPath(); ctx.moveTo(px - 5, py - 7); ctx.lineTo(px + 5, py - 7); ctx.lineTo(px, py); ctx.closePath(); ctx.fill(); ctx.globalAlpha = 1; } } } // YOUR BLOOD, COUNTED. The vignette is the mood and it stays, but Oliver: "you do not immediately // know when it is really urgent" — a wash that deepens smoothly cannot answer "how many more can I // take", and in the match (8 hp) that is the whole question. So: one pip per hit you have left, in // the top-right corner beside the scoreboard, in the same recessed-panel language the game/sets bar // under the board already uses. Full pips are blood red, spent ones are an empty socket, and the // LAST TWO breathe — the one state where the number matters more than the picture. // ponytail: pips, not a bar. hpMax is 4 (gore) or 8 (match); a bar would be a smooth readout of a // small integer, which is exactly the ambiguity being fixed. Switch to a bar if a pool ever passes ~12. function drawBlood() { if (G.mode !== 'splatter' || !G.hpMax) return; const n = G.hpMax, hp = Math.max(0, Math.min(n, G.hp)); const r = 2.6, gap = n > 6 ? 7 : 9, pw = (n - 1) * gap + r * 2; // The quit chip owns the corner (index.html #quit), so the pips move in to clear it: 32 CSS px of // button plus its 6px margin plus 6px of air, converted to logical px, because the two live in // different coordinate systems and only the CSS one is fixed. const chip = Math.round(44 * W / (cv.clientWidth || innerWidth)); const w = Math.max(46, pw + 10), h = 24, x = W - 6 - chip - w, y = 6; hudShadow(true); panel(x + 0.5, y + 0.5, w - 1, h - 1, 3, 'rgba(26,6,8,0.82)', 'rgba(163,74,66,0.45)'); hudShadow(false); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = F(7, 0); ctx.fillStyle = hp <= 2 ? UI.bad : '#89a08f'; ctx.fillText('BLOOD', x + w / 2, y + 7); // the low-blood breath: the whole row pulses once you are two hits from the end const pulse = hp > 0 && hp <= 2 ? 0.55 + 0.45 * Math.abs(Math.sin(G.idleT * 5)) : 1; const x0 = x + w / 2 - pw / 2 + r; for (let i = 0; i < n; i++) { ctx.beginPath(); ctx.arc(x0 + i * gap, y + 16, r, 0, 7); if (i < hp) { ctx.fillStyle = hexA('#c8232a', pulse); ctx.fill(); } else { ctx.strokeStyle = 'rgba(120,60,58,0.7)'; ctx.lineWidth = 1; ctx.stroke(); } } ctx.textBaseline = 'top'; } // The boss's remaining life, over its head, in the scoreboard's own 16-bit language: a dark sunk // well, a bevel, and a fill that runs gold -> orange -> red as the pool empties, so the colour says // how the fight is going without reading the length. Integer-snapped and a FIXED pixel size — this // is HUD, and a bar that grew as the boss walked toward you would read as the boss healing. function drawBossHp(zb, hMeters) { // 1.06 sat ON the hat — a bar touching the sprite reads as part of the costume. It hangs clear // now, and it hangs from its own BOTTOM edge so the gap is the gap you see rather than the gap // minus the bar. The anchor is the body's own height, so it stays put as the boss scales up by // chapter instead of drifting into the crowd. const p = proj(zb.x, hMeters * 1.3, zb.z); if (p.s <= 0) return; // behind the camera: see drawAimMark const k = Math.max(0, Math.min(1, zb.hp / zb.hpMax)); const w = 46, h = 5, x = Math.round(p.x - w / 2), y = Math.round(p.y) - h; ctx.fillStyle = 'rgba(6,4,6,0.85)'; ctx.fillRect(x - 1, y - 1, w + 2, h + 2); // frame ctx.fillStyle = '#2a1518'; ctx.fillRect(x, y, w, h); // empty well if (k > 0) { ctx.fillStyle = k > 0.5 ? UI.pick : k > 0.22 ? '#ff8010' : UI.bad; ctx.fillRect(x, y, Math.max(1, Math.round(w * k)), h); ctx.fillStyle = 'rgba(255,255,255,0.28)'; ctx.fillRect(x, y, Math.max(1, Math.round(w * k)), 1); // bevel } } // Fire, drawn procedurally instead of as a sprite sheet: a few vertically-stretched quads whose // offsets ride each body's own phase (walkT), so no two burning zombies flicker in sync — and it // reads at every burn stage, from fresh yellow flare to guttering smoke, for free. function drawFlames(zb, hMeters) { const p = proj(zb.x, 0, zb.z), s = p.s; if (s <= 0) return; const k = Math.max(0, Math.min(1, zb.burnT / TUNE.burnTime)); // 1 = just lit, 0 = about to gutter out const col = k > 0.6 ? '#ffc030' : k > 0.3 ? '#ff8010' : '#6b5a52'; // A BODY ON FIRE SHOULD LOOK LIKE ONE. Five thin tongues over the torso read as a pilot light; // this is now a column that engulfs — nine tongues spread across the full silhouette, up to // shoulder height, over an additive heat bloom that is the thing you actually see from across // the court. The bloom dies with k, so a guttering body cools to smoke exactly as before. if (k > 0.15) { ctx.save(); ctx.globalCompositeOperation = 'lighter'; const by = p.y - hMeters * s * 0.42, br = hMeters * s * 0.42; for (const [rf, af] of [[1.25, 0.13], [0.72, 0.20]]) { ctx.globalAlpha = af * k; ctx.fillStyle = k > 0.45 ? '#ff9a20' : '#c05a10'; ctx.beginPath(); ctx.arc(p.x, by, br * rf, 0, 7); ctx.fill(); } ctx.restore(); ctx.globalAlpha = 1; } for (let i = 0; i < 9; i++) { const ph = G.idleT * (7 + i * 1.7) + zb.walkT * 3.1 + i * 2; const fx = p.x + Math.sin(ph) * s * 0.11 + (i - 4) * s * 0.042; const fy = p.y - (0.18 + i * 0.085 + 0.42 * k) * hMeters * s - Math.abs(Math.sin(ph * 1.6)) * s * 0.10; ctx.fillStyle = i % 4 === 0 && k > 0.5 ? '#fff2c0' : col; // white-hot core tongues while it rages ctx.fillRect(fx | 0, fy | 0, Math.max(1, s * 0.05), Math.max(2, s * (0.13 + 0.10 * Math.abs(Math.sin(ph * 2))))); } } // AIMING and FLYING are two different pictures out of one arc. // While you pull, the whole span to the target in full colour is exactly right: it is the // information you are deciding on. Carrying that picture unchanged into the flight is what made // the shot read as a laser — a bright, saturated, continuous line running from the projectile to // the thing it is about to hit is the visual definition of a targeting beam, and no real ball ever // shows its own future. So once fired the band is spent: it desaturates toward dust, collapses, // and shrinks to a short streak BEHIND the ball, with nothing at all drawn ahead of it. // (A variant that put that streak ahead of the ball instead was built, compared and cut.) const TAIL = 0.15; // fraction of the arc drawn behind the ball once it is flying const DUST = [206, 200, 184]; const RGB = h => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]; const mix = (a, b, t) => a.map((v, i) => Math.round(v + (b[i] - v) * t)); function drawShotBand(a, u0, flying) { const left = 1 - u0; if (left <= 0.01) return; // cool blue-white = this only staggers. It stays READABLE on the dark lawn: you still need to // see where you're pointing, you just need to know it won't kill. // The LASER re-paints the band it is about to become: blue, and widening down the court instead // of tapering, so the gesture SHOWS you the wedge before you commit to it. Oliver asked for the // band itself to communicate the shape of the shot. const beamW = WEAPONS[slotId(G.weapon)]?.beam ? 1 : 0; // CHALK WHITE = this one is SLICED. The band has always had one threshold (kills / only staggers) // and the perfect window sat inside the killing half, invisible: quality saturates at pullFull, so // an overpull drew exactly the band a perfect pull did. White is the third state, and the only // place white appears on the court — nothing else in the palette is near it. const hex = beamW ? WEAPONS[slotId(G.weapon)].col : a.pf ? '#f2f2e6' : a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0'; // the slice of arc to draw: everything while aiming, a short streak behind the ball in flight const lo = flying ? Math.max(0, u0 - TAIL) : 0; const hi = flying ? u0 : 1; if (hi - lo < 0.001) return; // Linear, not squared. Squared made both variants basically gone by a third of the flight, which // is not "less like a laser", it is "no streak at all" — and the strobing at release is the thing // this whole trail exists to prevent. The SHORT SLICE is what kills the beam; the fade only has // to make sure nothing is still burning when the ball lands. const decay = flying ? left : 1; const col = flying ? mix(RGB(hex), DUST, Math.min(1, u0 * 3)) : RGB(hex); // the colour is spent const N = 14; const at = t => ({ x: a.x0 + a.v.vx * t, y: a.y0 + a.v.vy * t - 0.5 * a.g * t * t, z: a.z0 + a.v.vz * t }); const uAt = i => lo + (hi - lo) * (i / N); // s: 0 at the far end of the slice, 1 at the ball (or at the racket, while aiming) — so the // streak is always brightest where the action is and dies off away from it. const sAt = i => flying ? i / N : 1 - i / N; // The shadow first, under everything: an arc hanging in the air is ambiguous about where it // comes down until something pins it to the lawn. This is the whole 3D cue. It follows the same // slice — "nothing ahead" has to mean the shadow too, or the ground still gives the future away. for (let i = 0; i <= N; i++) { const w = at(a.T * uAt(i)), p = proj(w.x, 0, w.z); ctx.fillStyle = `rgba(0,0,0,${(0.18 + 0.24 * sAt(i)) * decay})`; ctx.beginPath(); ctx.ellipse(p.x, p.y, Math.max(0.8, p.s * 0.07), Math.max(0.5, p.s * 0.028), 0, 0, 7); ctx.fill(); } // ...then the band. Width comes from each point's OWN projection scale, so it tapers into the // distance like everything else on the court rather than being a flat screen-space stroke. ctx.strokeStyle = `rgb(${col[0]},${col[1]},${col[2]})`; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; const head = at(a.T * lo); let prev = proj(head.x, head.y, head.z); for (let i = 1; i <= N; i++) { const w = at(a.T * uAt(i)), p = proj(w.x, w.y, w.z); ctx.globalAlpha = (0.30 + 0.55 * a.f) * (0.45 + 0.55 * sAt(i)) * decay * (beamW ? 1.25 : 1); // a normal band is brightest at the racket and thins away; the beam does the OPPOSITE, opening // out toward the far end into the plane it is going to cut ctx.lineWidth = beamW ? Math.max(1.5, p.s * (0.05 + 0.42 * (uAt(i)))) : Math.max(1, p.s * (0.04 + a.f * 0.15) * (flying ? 0.75 : 1)); ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(p.x, p.y); ctx.stroke(); prev = p; } ctx.globalAlpha = 1; ctx.lineWidth = 1; } // The laser's plane, shared by the aim PREVIEW and the fired shot. ONE triangle, apex at the // player's chest, opening out down the court. It is FULLY OPAQUE — solid colour, deep blue at the // edges and near-white down the core, dimming with distance. Depth is carried by BRIGHTNESS, not by // transparency, so nothing shows through it: a body above the plane is drawn on top of it and a body // below is simply hidden behind it, which is as unambiguous as the split can be. (It was a // translucent veil; at a readable opacity it washed everything out, and at a subtle one you could // not tell above from below at all — the two pulled against each other. Opaque settles it.) // `from` is the erase front: 0 draws the whole wedge, 0.5 only its far half, 1 nothing. // Returns the length it drew, so the split can use the same geometry. function drawBeamWedge(dirX, dirZ, ox, oy, oz, from) { const d = Math.hypot(dirX, dirZ) || 1, ux = dirX / d, uz = dirZ / d; const px = -uz, pz = ux; // the lane's perpendicular, on the ground // The DRAWN length is TUNE.beamLen, straight. It used to be solved here each frame so the far end // landed on the middle of the ad strip — which made beamLen a knob that visibly did nothing, since // it only governed the kill. One meaning is worth more than one clever behaviour. const L = TUNE.beamLen; if (from >= 1) return L; // it leaves the racket high and settles to the cutting height within the first stretch const yAt = u => oy + (TUNE.beamY - oy) * Math.min(1, u / TUNE.beamRise); const wAt = u => TUNE.beamW0 + (TUNE.beamW1 - TUNE.beamW0) * u; const corner = (u, w) => proj(ox + ux * (u * L) + px * w, yAt(u), oz + uz * (u * L) + pz * w); // >= 0.5, not just truthy: the T panel steps numbers by 0.02, so one nudge on this flag set it to // 0.02 — still truthy, and the renderer silently switched under Oliver mid-tune. A flag living in // a table of numbers has to be robust to being treated as a number. if (TUNE.beamBand >= 0.5) { drawBeamBand(ux, uz, ox, oy, oz, L, from); return L; } const u0 = Math.max(0, from), w0 = wAt(u0), w1 = wAt(1); // THE SHADOW ON THE LAWN, borrowed from the ball's aim band and from the band variant — this is // most of why those read as lying in the world. A bright shape hanging in the air is ambiguous // about where it actually is until something pins it to the ground. for (let i = 0; i <= 14; i++) { const u = u0 + (1 - u0) * (i / 14), w = wAt(u); const p = proj(ox + ux * (u * L), 0, oz + uz * (u * L)); ctx.fillStyle = `rgba(0,0,0,${0.16 * (1 - u * 0.55)})`; ctx.beginPath(); ctx.ellipse(p.x, p.y, Math.max(0.8, p.s * w * 0.9), Math.max(0.4, p.s * w * 0.3), 0, 0, 7); ctx.fill(); } // ONE gradient, along the LENGTH only. There was a second one across the width (near-white core // falling to blue at the edges) and it is gone: across a shape this narrow it read as a highlight // painted down the middle rather than as depth. The plane is a flat solid colour now, dimming as // it runs away from you — that is the whole shading. const c1 = corner(u0, -w0), c2 = corner(u0, w0), c3 = corner(1, w1), c4 = corner(1, -w1); const mix = (c, m) => `rgb(${Math.round(c[0] * m)},${Math.round(c[1] * m)},${Math.round(c[2] * m)})`; const grad = ctx.createLinearGradient((c1.x + c2.x) / 2, (c1.y + c2.y) / 2, (c3.x + c4.x) / 2, (c3.y + c4.y) / 2); grad.addColorStop(0, mix(BEAM_MID, 1)); grad.addColorStop(1, mix(BEAM_MID, 0.5)); ctx.fillStyle = grad; ctx.beginPath(); ctx.moveTo(c1.x, c1.y); ctx.lineTo(c2.x, c2.y); ctx.lineTo(c3.x, c3.y); ctx.lineTo(c4.x, c4.y); ctx.closePath(); ctx.fill(); // ...and the OUTER LINES. It reads as a plane because its edges are drawn, not merely implied by // where the fill stops. ctx.strokeStyle = 'rgb(196,230,255)'; // the outer lines — width is a knob (beamBorder) ctx.lineWidth = TUNE.beamBorder; ctx.beginPath(); ctx.moveTo(c2.x, c2.y); ctx.lineTo(c3.x, c3.y); ctx.moveTo(c1.x, c1.y); ctx.lineTo(c4.x, c4.y); ctx.stroke(); if (from > 0) { // the erase front: a bright bar racing away down the lane ctx.strokeStyle = 'rgb(255,255,255)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(c1.x, c1.y); ctx.lineTo(c2.x, c2.y); ctx.stroke(); } ctx.lineWidth = 1; return L; } // The cut line: BLUEISH and faint. Near-white and bold, it read as a HUD marker stamped on the // sprite rather than as light crossing a body — it has to whisper, not label. const CUT_COL = 'rgba(58,175,255,0.85)'; // the laser icon's blue (#1d99e8), lifted so a flat bar reads // The plane's colour. Opaque, one flat blue, dimmed with distance — see drawBeamWedge. BEAM_CORE is // still used by the band variant's hot centre line (TUNE.beamBand). const BEAM_MID = [63, 116, 255]; const BEAM_CORE = [206, 238, 255]; const CUT_MAX = 8; // bodies that get a cut line in one frame (nearest first) const CUT_MIN_PX = 14; // ...and only if it is drawn at least this tall // NO ctx.filter ANYWHERE. The cut line used to be the sprite redrawn clipped to a band and flattened // to one colour by a canvas filter, which put it exactly on the silhouette — and on an iPhone the // line did not draw AT ALL and bodies blinked in and out of existence, which is what a filter // leaking past its restore() looks like. Not worth it at any price. Plain bars from here. // THE BAND VARIANT (TUNE.beamBand = 1). Drawn the way the BALL's aim trajectory is drawn: a path // down the lane whose width comes from each point's OWN projection scale, so it narrows into the // distance exactly like everything else on the court. That is the difference — the cone is a real // projection too, but it GROWS in world width, so on screen it opens out while the court recedes, // and the eye reads that as a flat shape stuck to the screen rather than a plane lying in the world. function drawBeamBand(ux, uz, ox, oy, oz, L, from) { const N = 20; const yAt = u => oy + (TUNE.beamY - oy) * Math.min(1, u / TUNE.beamRise); const at = u => ({ x: ox + ux * (u * L), y: yAt(u), z: oz + uz * (u * L) }); const u0 = Math.max(0, from); if (u0 >= 1) return; const uAt = i => u0 + (1 - u0) * (i / N); // the shadow on the lawn first, same as the ball's band: an arc hanging in the air is ambiguous // about where it lies until something pins it to the ground for (let i = 0; i <= N; i++) { const w = at(uAt(i)), p = proj(w.x, 0, w.z); ctx.fillStyle = `rgba(0,0,0,${0.20 * (1 - uAt(i) * 0.6)})`; ctx.beginPath(); ctx.ellipse(p.x, p.y, Math.max(0.8, p.s * TUNE.beamBandW * 0.9), Math.max(0.4, p.s * TUNE.beamBandW * 0.3), 0, 0, 7); ctx.fill(); } ctx.lineCap = 'round'; ctx.lineJoin = 'round'; // two passes: a wide soft body and a hot core down the middle, both scaled per point for (const [wf, col] of [[2.0, BEAM_MID], [0.7, BEAM_CORE]]) { let prev = null; for (let i = 0; i <= N; i++) { const u = uAt(i), w = at(u), p = proj(w.x, w.y, w.z); if (prev) { const f = 1 - 0.45 * u; // depth by brightness, as the cone does ctx.strokeStyle = `rgb(${Math.round(col[0] * f)},${Math.round(col[1] * f)},${Math.round(col[2] * f)})`; ctx.lineWidth = Math.max(1, p.s * TUNE.beamBandW * wf); ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(p.x, p.y); ctx.stroke(); } prev = p; } } ctx.lineWidth = 1; } // THE CUT. The beam is a plane at TUNE.beamY, so a body standing in it is sliced at the height that // plane passes through it: everything below stays where it was drawn (and is tinted by the // translucent beam laid over it), and everything above is redrawn on TOP of the beam, clipped. // drawZombie is a pure draw — it mutates nothing — so calling it a second time is safe. function drawBeamCuts(dirX, dirZ, ox, oz, L) { const d = Math.hypot(dirX, dirZ) || 1, ux = dirX / d, uz = dirZ / d; // BOUNDED: each body in the beam is drawn once MORE (the part above the plane), every frame you // are aiming and not only on the shot. Nearest first, capped, and bodies too small on screen for a // cut to say anything are skipped. let budget = CUT_MAX; for (const zb of [...zombies].sort((a, b) => b.z - a.z)) { if (budget <= 0) break; if (!zb) continue; const rx = zb.x - ox, rz = zb.z - oz; const along = rx * ux + rz * uz; if (along < 0.5 || along > L) continue; // behind him, or past the end const off = Math.abs(rx * uz - rz * ux); const halfW = TUNE.beamBand >= 0.5 ? TUNE.beamBandW * 2 // must match fireBeam's test, or the cut : TUNE.beamW0 + (TUNE.beamW1 - TUNE.beamW0) * (along / L); // marks bodies the shot will miss if (off > halfW + 0.8) continue; const gp = proj(zb.x, 0, zb.z), h = zombieH(zb); // whole pixels: a clip edge on a fractional y crawls under nearest-neighbour scaling const cutY = Math.round(proj(zb.x, TUNE.beamY, zb.z).y); // a NaN anywhere here would make the clip path invalid, and an invalid clip is the sort of thing // that makes a body vanish for a frame — skip rather than draw something undefined if (!Number.isFinite(cutY) || !Number.isFinite(h) || !Number.isFinite(gp.s)) continue; if (h * gp.s < CUT_MIN_PX) continue; budget--; // the part ABOVE the plane is redrawn on top of it; everything below stays where it was and is // tinted by the translucent beam already laid over it ctx.save(); ctx.beginPath(); ctx.rect(0, 0, W, cutY); ctx.clip(); drawZombie(zb); ctx.restore(); // ...and THE LINE, only where the plane actually passes through the body (one that the beam // clears over the head of, or passes under the feet of, gets none). if (cutY < proj(zb.x, h, zb.z).y || cutY > gp.y) continue; // Thickness follows perspective — a share of the body's own drawn height, squared, because this // camera's scale only spans about 1.5x across the court and a linear falloff was invisible. const bandH = Math.max(0.4, h * gp.s * gp.s * 0.0009); const hw = h * 0.22 * gp.s; // narrow, so it stays inside the silhouette rather than overshooting ctx.fillStyle = CUT_COL; ctx.fillRect(Math.round(gp.x - hw), cutY - bandH / 2, Math.round(hw * 2), bandH); } } // The target mark, drawn AFTER the bodies — it is a mark ON the shambler, so painting it with the // rest of the band (which happens before the sprite pass, so a nearer body correctly occludes the // arc) hid it completely behind the very thing it was marking. That is why it was never once seen. // It sits on the BODY: that is where the ball actually strikes (the hit test is a radius at any // height under 1.9 m) and where the wound lands, so a ring on the grass pointed at the one part of // a shambler the shot has nothing to do with. Filled and washed out in the band's own colour // rather than a hard 2px ring, so it marks without looking like a decal stuck to the lawn. function drawAimMark(a, u0, flying) { const left = 1 - u0; if (left <= 0.01) return; const col = a.pf ? '#f2f2e6' : a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0'; const onBody = !!a.zt; const m = proj(a.tx, a.ty, a.tz); // a.ty is where the ball ARRIVES — mark and impact are one point // NOTHING BEHIND THE CAMERA DRAWS. proj's scale is camF/(camZ - z), so a point at or past the // camera plane comes back with s <= 0 — x and y are mirrored nonsense and the radii below go // NEGATIVE, which ctx.ellipse throws on (IndexSizeError). A throw here killed the REST of the // frame: no HUD, no rack, no message band, every frame it happened. A solver can hand us such a // point (a target off the back of the world), so the projection is checked, not the aim. if (m.s <= 0) return; // it goes with the band once fired: a mark still burning on the target is the other half of the // laser look, and the aim it represents is already spent ctx.globalAlpha = (onBody ? 0.42 : 0.24) * (flying ? left * left : 1); ctx.fillStyle = col; ctx.beginPath(); if (onBody) ctx.ellipse(m.x, m.y, m.s * 0.26, m.s * 0.38, 0, 0, 7); // upright, over the torso else ctx.ellipse(m.x, m.y, m.s * 0.30, m.s * 0.12, 0, 0, 7); // flat, lying on the grass ctx.fill(); // ...and a SECOND ring outside it when the shot is sliced: the fill says where the ball lands, // the ring says it will not stop there. Drawn on the mark rather than as a third HUD element // because your eye is already on this body — that is what you are aiming at. if (a.pf && onBody) { ctx.globalAlpha = 0.75 * (flying ? left * left : 1); ctx.strokeStyle = col; ctx.lineWidth = Math.max(1, m.s * 0.02); ctx.beginPath(); ctx.ellipse(m.x, m.y, m.s * 0.36, m.s * 0.50, 0, 0, 7); ctx.stroke(); } ctx.globalAlpha = 1; ctx.lineWidth = 1; } function drawRing(x, z, color, k = 1) { // k: radius scale — the landing ring contracts on time-to-touchdown const p = proj(x, 0, z); // integer-snapped so the moving ring steps whole pixels instead of shimmering const cx = Math.round(p.x), cy = Math.round(p.y); const rx = Math.max(2, Math.round(p.s * 0.3 * k)), ry = Math.max(1, Math.round(p.s * 0.12 * k)); ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, 7); ctx.stroke(); } // gore piles: little 3D-ish mounds that accrete where kills cluster. Each pile's blobs are // seeded ONCE from p.seed (rebuilt only when it grows), so the pile never shimmers frame to // frame and drawing costs one proj() call — blob screen positions offset from that base. function mulberry32(a) { return () => { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; } const PILE_COLS = ['#6e1414', '#8c1212', '#4e0e0e', '#7a2a18']; // ...and the same mound for something the fire got: cold, dry, grey. A body that burns has nothing // left to spray, so it should not leave a wet red heap behind it. const ASH_COLS = ['#4a4744', '#3a3835', '#5c5854', '#2e2c2a']; function buildBlobs(pl) { const rnd = mulberry32((pl.seed | 0) + 1), n = Math.round(6 + pl.size * 5); pl.blobs = []; for (let k = 0; k < n; k++) { const ang = rnd() * 6.2832, rad = Math.sqrt(rnd()); // uniform over the disk pl.blobs.push({ nx: Math.cos(ang) * rad, nz: Math.sin(ang) * rad, lift: rnd(), pr: 1 + rnd() * 2, color: pl.ash ? (rnd() < 0.12 ? '#8a8580' : ASH_COLS[(rnd() * 4) | 0]) // a few pale flecks of bone : (rnd() < 0.1 ? '#cfc9b0' : PILE_COLS[(rnd() * 4) | 0]) }); } pl.blobs.sort((a, b) => (b.nz - 1.25 * b.lift) - (a.nz - 1.25 * a.lift)); // back/low first, peak on top pl.builtSize = pl.size; } function drawGorePile(pl) { if (!pl.blobs || pl.size > pl.builtSize) { buildBlobs(pl); pl.cv = null; } // rebuild only on growth, never on the mop-shrink const base = proj(pl.x, 0, pl.z), s = base.s, sz = pl.size; const key = Math.round(s * 4); // proj scale is constant per pile until growth or an orientation change if (!pl.cv || pl.cvKey !== key) { // pre-render: 1 drawImage per frame instead of ~26 ellipses (battery) const pad = Math.ceil(sz * 0.28 * s + 4); const off = document.createElement('canvas'); off.width = off.height = pad * 2; const oc = off.getContext('2d'); for (const b of pl.blobs) { const sx = pad + b.nx * sz * 0.28 * s; const sy = pad + b.nz * sz * 0.28 * s * 0.4 - b.lift * sz * 0.14 * s; // depth squished onto ground; lift raises it const r = Math.max(1.2, b.pr * s * 0.035); oc.fillStyle = b.color; oc.beginPath(); oc.ellipse(sx, sy, r, Math.max(1, r * 0.72), 0, 0, 7); oc.fill(); } pl.cv = off; pl.cvKey = key; pl.cvPad = pad; } ctx.drawImage(pl.cv, base.x - pl.cvPad, base.y - pl.cvPad); } function drawPiles(near) { // split at the net like zombies so height occludes correctly for (const pl of piles.filter(p => near ? p.z > COURT.netZ : p.z <= COURT.netZ).sort((a, b) => a.z - b.z)) drawGorePile(pl); } // THE TOUR SWAP. Everything above builds a sprite NAME the same way it always did; this is the one // place the women's set exists. Her frames are the men's name + '_f', and a name with no twin — or // one whose PNG has not loaded yet — falls straight through to the men's frame. That fallback is // deliberate: a cached 404 on one female frame degrades to a man for a frame, it does not blink the // body out of existence (the same rule drawZombie's missing-frame guard is built on). // THE TOUR THE BODY ACROSS THE NET IS PLAYING. Yours everywhere except netplay, where it is the // one their phone sent — a tour is which BODY you play in, and the friend picked that on their own // setup screen. It reaches three things and they must not disagree: the sprite suffix (SK), how the // art takes a colour (PS: her whites wash, his blue dyes) and the palm the idle ball bounces under // (BOUNCE_IDLE.oDx/oApex, which are measured per body). const oTour = () => (G.net && G.net.look && G.net.look.tour) || tour; const SK = n => { // the bench's living slots pick the suffix directly (G.bench.char 'o'/'of'/'p'/'pf'), so all four // tennis bodies can be reviewed without touching the picked tour const sfx = G.bench && G.bench.char != null ? (G.bench.char.endsWith('f') ? '_f' : '') : n[0] === 'n' && n[1] === '_' ? (oTour().sfx || '') // the friend, in THEIR tour : tour.sfx; return (sfx && SPR[n + sfx]) ? n + sfx : n; }; // ---- THE PLAYER'S COLOURS. One pixel pass, no new art, no new assets (see KITS/SKINS in config). // // Why a HUE BUCKET and not a colour->colour table: the sheets are shaded, not flat — p_idle1 alone // carries 1510 opaque colours. A lookup would have to name every ramp step and would still miss the // anti-aliased ones. Hue is the thing that actually separates the regions, and it separates them // cleanly: kit blue sits around 208 deg, skin around 22, and the two populations never overlap in // any frame. Rotating hue while KEEPING each pixel's own lightness means every ramp, every fold and // every shadow survives the swap — the man changes colour, not shading. // // Skin gets `l` rather than a big hue move for the reason in config: skin tone is lightness, and a // rotated one is a green man. const rgb2hsl = (r, g, b) => { r /= 255; g /= 255; b /= 255; const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn, l = (mx + mn) / 2; if (!d) return [0, 0, l]; const s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn); const h = mx === r ? ((g - b) / d + (g < b ? 6 : 0)) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4; return [h * 60, s, l]; }; const hue2c = (p, q, t) => { t = (t + 1) % 1; return t < 1 / 6 ? p + (q - p) * 6 * t : t < 0.5 ? q : t < 2 / 3 ? p + (q - p) * (2 / 3 - t) * 6 : p; }; const hsl2rgb = (h, s, l) => { if (!s) { const v = Math.round(l * 255); return [v, v, v]; } const q = l < 0.5 ? l * (1 + s) : l + s - l * s, p = 2 * l - q, t = h / 360; return [hue2c(p, q, t + 1 / 3), hue2c(p, q, t), hue2c(p, q, t - 1 / 3)].map(v => Math.round(v * 255)); }; // windows wrap (the skin one runs 330 -> 45 through zero), so the test has to wrap with them const inArc = (h, [a, b]) => a <= b ? h >= a && h < b : h >= a || h < b; // ONE PIXEL, no canvas — which is the whole reason it is a function of its own and exported. The // interesting claim in this feature is "the kit can move without the skin moving", and that claim // lives entirely here; test.js checks it against the colours actually measured off p_idle1.png, // with no DOM in the way. Returns null for "leave this pixel exactly as it is". export function tintPx(r, g, b, k, sk, mode = 'dye', arc = TINT.kit) { const [h, s, l] = rgb2hsl(r, g, b); // THE KIT, two ways. 'dye' rotates a coloured kit's hue and needs only a low saturation floor // (nothing else on those sheets is blue, and a low floor catches the soft edges). 'wash' lays the // pick over a WHITE kit — bright, barely saturated — which is a different test entirely and one // that would swallow the men's shirt, which is why it belongs to the tour and not to the pixel. const isKit = mode === 'wash' ? (l >= TINT.whiteLum && s <= TINT.whiteSat) : (inArc(h, arc) && s >= TINT.satKit); if (isKit) { if (!k || k.h == null) return null; return mode === 'wash' // lightness kept (times any darkening the pick asks for), the hue laid on at a pastel strength ? hsl2rgb(k.h, Math.min(1, TINT.washS * (k.s ?? 1)), Math.min(1, l * TINT.washL * (k.l ?? 1))) : hsl2rgb(k.h, Math.min(1, s * (k.s ?? 1)), Math.min(1, l * (k.l ?? 1))); } // the skin needs a floor AND a ceiling: below the floor is grey, above the ceiling is shirt if (!(inArc(h, TINT.skin) && s >= TINT.satSkin && l <= TINT.lumSkin)) return null; if (!sk || sk.h == null) return null; // "as drawn" entries cost one branch and exit return hsl2rgb(sk.h, Math.min(1, s * (sk.s ?? 1)), Math.min(1, l * (sk.l ?? 1))); } function tint(img, k, sk, mode, arc) { const c = document.createElement('canvas'); c.width = img.width; c.height = img.height; const g = c.getContext('2d'); g.drawImage(img, 0, 0); const d = g.getImageData(0, 0, c.width, c.height), px = d.data; for (let i = 0; i < px.length; i += 4) { if (px[i + 3] < 8) continue; // fully transparent: nothing to recolour const out = tintPx(px[i], px[i + 1], px[i + 2], k, sk, mode, arc); if (out) { px[i] = out[0]; px[i + 1] = out[1]; px[i + 2] = out[2]; } } g.putImageData(d, 0, 0); return c; } // Recoloured sheets are built on FIRST DRAW, not on the pick: a swatch tap costs nothing, and each // frame pays its ~1 ms once, the first time it is actually on screen. The cache key carries the // whole look, so a changed pick simply misses and rebuilds — there is no invalidation to forget. // The cap is a runaway guard for the sprite bench, which can cycle looks all afternoon. const TINTC = new Map(); export function tinted(n, img, k, sk, mode, arc) { if ((!k || k.h == null) && (!sk || sk.h == null)) return img; // "as drawn" both ways: no pass const key = n + '|' + (k ? k.id : '-') + '|' + (sk ? sk.id : '-') + '|' + mode; let out = TINTC.get(key); if (!out) { if (TINTC.size > 96) TINTC.clear(); TINTC.set(key, out = tint(img, k, sk, mode, arc)); } return out; } function PS(name) { const n = SK(name), img = SPR[n]; if (!img) return img; // YOUR body, dyed or washed depending on what this tour is dressed in (see TOURS.kit) if (n[0] === 'p' && n[1] === '_') return tinted(n, img, kit, skin, tour.kit, TINT.kit); // ...AND THE FRIEND ACROSS THE NET, wearing what THEY picked on their own phone. He is drawn on // his OWN sheets now (n_*, the player's kit from the front), so he goes through the player's arc // and the player's tour mode: his shorts dye and her whites wash, exactly as yours do. // // He used to be the roster opponent's red art on TINT.oKit, and that arc is why this changed. Red // is the one hue the kit CANNOT own on these sheets: the only saturated red on the men's art is // the SHIRT (his shorts are neutral grey, so a hue rotation never touched them) and on the // women's it sits at the same hue as her skin and her hair, so a dark pick painted her arms, her // legs and her ponytail. Blue-over-white is hue-isolated by construction — see TINT in config. if (n[0] === 'n' && n[1] === '_' && G.net && G.net.look) return tinted(n, img, G.net.look.kit, G.net.look.skin, oTour().kit, TINT.kit); return img; } // THE SETUP SCREEN'S LIVE PLAYER. Not a mock-up of the pick — the pick, run through the same tint // the court will draw, so what the menu shows cannot drift from what walks on. // // It shows the FRONT of him (`n_idle1`, the netplay body), not the back he plays in. Picking a face // off the back of a head was the best the art allowed while every player frame was a back view; the // moment the front existed, Oliver asked for it here. It is the same kit, the same palette and the // same tint pass, so the promise above still holds — only the side you are standing on changes. // Returns false while the sprites are still loading, so the caller knows to come back. export function drawLook(cv2, t, k, sk) { const img = SPR['n_idle1' + (t.sfx || '')] || SPR['p_idle1' + (t.sfx || '')] || SPR.p_idle1; if (!img) return false; const out = tinted('look' + (t.sfx || ''), img, k, sk, t.kit, TINT.kit); const g = cv2.getContext('2d'); g.clearRect(0, 0, cv2.width, cv2.height); g.imageSmoothingEnabled = false; // it is pixel art; let it be pixel art const s = Math.min(cv2.width / out.width, cv2.height / out.height); const w = Math.round(out.width * s), h = Math.round(out.height * s); g.drawImage(out, Math.round((cv2.width - w) / 2), cv2.height - h, w, h); return true; } // BOUNCE_IDLE.dx/apex are measured off the DRAWN PALM (see config), so a tour whose player reaches // differently needs its own pair. Only the women's row carries them; ?? falls back to the men's. const bounceDx = () => tour.dx ?? BOUNCE_IDLE.dx; const bounceApex = () => tour.apex ?? BOUNCE_IDLE.apex; function drawChar(ch) { // No split-step. The receiver used to be slid 0.12 m side to side while waiting on a serve, on top // of a swap to the run frames; both are gone. Sliding a sprite sideways is not footwork — it reads // as the man skating — and the idle pair's own weight shift is the whole of what he should be doing. const p = proj(ch.x, 0, ch.z); // the readability boost is what makes the MATCH OPPONENT legible: at the far baseline he was // ~34 logical px of faithful anatomy, which is mush. The near player anchors the curve (farB // clamps at his deepest stance), so P draws within a hair of his old size everywhere he stands. const hpx = PLAYER_H * p.s * farB(ch.z); // pick sprite // THE BODY ACROSS THE NET is a PERSON in netplay and a club opponent everywhere else, and they // are two different sets of art: n_* is the player's own kit drawn from the front (so the friend's // colour lands where yours does), o_* is the roster's red. See PS() for why that split exists. const pre = ch.side === 'P' ? 'p_' : (G.net ? 'n_' : 'o_'); let name = pre + (Math.floor(G.idleT * 1.6) % 2 ? 'idle1' : 'idle2'); // idle weight shift if (G.phase === 'pretoss' && ch.side === score.server && ch.pose === 'idle') { // The two bounce frames are the SAME drawing from one sheet — identical stance, identical legs, // and only the free hand's height and reach differ — so the swap cannot read as a flicker. It // follows the BALL: hand up when the ball is up at it, hand out and low when the ball is near // the ground. BOUNCE_IDLE.apex is measured off that palm, so the ball meets it. name = pre + (Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) > 0.5 ? 'bounce2' : 'bounce'); } // THE PULL IS THE WIND-UP — three poses, not a scrubbable animation. Waiting: the bounce stance. // Drawing the slingshot: `p_pull`, the racket dropping behind him. Release: `p_smash`, the drive. // A four-stage version keyed to pullFrac was built and played, and Oliver cut it back to this: // on a phone, at thumb speed, the middle stages are never read — the three states are the beat. if (G.aim && G.aim.mag > 0 && ch.side === 'P' && score.server === 'P' && ch.pose === 'idle') name = 'p_pull'; // The run cycle at whatever count the character's sheet carries: the _runroll.py cycles are 6 // (one call, one row, one character — see assets/_runroll.py for why the old per-frame art // flickered); un-rolled characters fall back to 4 or 2. // TUNE.runN: live override for the bench's tuning sessions (0 = probe the sheet as always) // ...probed through SK, so the WOMEN'S sheet is counted when the women's tour is on. Her clip // gave a six-frame stride and his five; a probe blind to the suffix would have played five of // her six and cut the cycle mid-stride. const runN = TUNE.runN || [8, 6, 5, 4].find(n => SPR[SK(pre + 'run' + n)]) || 2; // The rolled 6/8-frame cycles run at their own STRIDE RATE (TUNE.runHz, cycles/s). The old 4.5 // was tuned on 2-4 near-duplicate frames, where it read as vigour — on eight real phases it was // a strobe ("very flashy and unnatural"), most of a full stride every display frame. // The PLAYER runs a two-frame pair like the opponents. It used to be picked out of a six-frame // roll by eye (his swap was frames 5+6, hers 1+2); the man's pair is now DRAWN as a pair — // one back-view figure plus its _stride.py twin — so the pair is always frames 1+2. if (ch.pose === 'run') name = pre + 'run' + (Math.floor(ch.runT * (runN > 2 ? TUNE.runHz : TUNE.runHz2) * runN) % runN + 1); // (the receiver used to flip to run1/run2 here as a "split-step" — it read as a man sprinting on // the spot, because the run frames are a full leaning stride, not a ready-hop. He idles now.) if (ch.pose === 'swing') name = pre + (ch.poseT < 0.15 ? 'swing1' : 'swing2'); if (ch.pose === 'serve') name = pre + (G.phase === 'toss' ? 'toss' : 'smash'); // the run bob rides the STRIDE, not a wall clock: one dip per step, in phase with the frame // flip — the old fixed 18 Hz toggle read as independent hopping over the calm 2-frame cycle if (ch.pose === 'run') p.y -= Math.floor(ch.runT * (runN > 2 ? TUNE.runHz : TUNE.runHz2) * runN) % 2; const img = PS(name); drawShadow(p.x, p.y, hpx * 0.22, hpx * 0.06); if (img) { const k = hpx / SPRITE_H; // sprites share one scale; the idle pose defines it const w = img.width * k, h = img.height * k; ctx.save(); ctx.translate(p.x, p.y); if (ch.facing < 0) ctx.scale(-1, 1); ctx.drawImage(img, -w / 2, -h, w, h); ctx.restore(); } else drawFallbackChar(ch, p, hpx); // No blood painted on the player. Oliver: "the player blood looks odd, appears as a painted layer // on top" — and he is right, and it also broke the settled rule that blood is never painted into // the man. His health reads as bleedPlayer()'s live drip plus the screen-edge wash on impact. } // The death. It used to be a red tint and a shake for half a second, which reads as "the sprite is // having a moment", not as being eaten. Now: he is dragged to his knees fighting it (p_die1/p_die2 // alternating), then pulled flat and pressed DOWN into the ground as the ring of bodies closes over // him — he is drawn inside the crowd's own z order (see render()), so the ones between him and the // camera hide him exactly as they arrive. What is left when he is gone is the mound. // Five DRAWN frames, arched back -> knees -> hands -> flat -> a heap, each one genuinely shorter // than the last (100/84/60/47/20% of standing height, common-scaled off one sheet). The first pass // faked the collapse by scaling one sprite down, which is miniaturising a man, not killing him. // Nothing here is scaled: each frame is drawn at ITS OWN height, the same rule the walk cycles use. const DIE_AT = [0, 0.15, 0.35, 0.6, 0.8]; // fraction of the way to EATEN_BURST each frame takes over function drawDying() { const at = G.eatenAt || P, t = G.eatenT; if (t >= EATEN_BURST) return; // he is the pile now const k = t / EATEN_BURST; let i = 0; while (i + 1 < DIE_AT.length && k >= DIE_AT[i + 1]) i++; const img = PS('p_die' + (i + 1)); const p = proj(at.x, 0, at.z); const j = i < 2 ? 1.6 : i < 4 ? 0.7 : 0; // he stops fighting on the way down ctx.save(); ctx.translate((Math.random() * 2 - 1) * j, (Math.random() * 2 - 1) * j * 0.6); if (img) { const h = PLAYER_H * p.s * (img.height / SPRITE_H), w = h * img.width / img.height; drawShadow(p.x, p.y, w * 0.42, h * 0.06, 0.3); ctx.drawImage(img, p.x - w / 2, p.y - h, w, h); } else drawFallbackChar(P, p, PLAYER_H * p.s); // sprites not loaded: he still dies ctx.restore(); } function drawFallbackChar(ch, p, h) { // shown only until sprites load const you = ch.side === 'P'; const u = h / 16, x = p.x, y = p.y; const skin = '#e8b88a', shirt = you ? '#e8e8e8' : '#d04848', shorts = you ? '#3858a8' : '#404040'; ctx.fillStyle = skin; ctx.fillRect(x - 2 * u, y - 16 * u, 4 * u, 4 * u); ctx.fillStyle = you ? '#804820' : '#282828'; ctx.fillRect(x - 2 * u, y - 16 * u, 4 * u, 1.2 * u); ctx.fillStyle = shirt; ctx.fillRect(x - 2.6 * u, y - 12 * u, 5.2 * u, 6 * u); ctx.fillStyle = shorts; ctx.fillRect(x - 2.4 * u, y - 6 * u, 4.8 * u, 3 * u); ctx.fillStyle = skin; ctx.fillRect(x - 1.8 * u, y - 3 * u, 1.4 * u, 3 * u); ctx.fillRect(x + 0.4 * u, y - 3 * u, 1.4 * u, 3 * u); } // the courtside weapon rack along the bottom: PLAIN + the four tins. Tap a tile (or 1–5) to // pick which one your next serve throws; empty tins dim out. Geometry is shared with input // via rackHit so a tap lands on the tile you see. const RACK_BOT = 0; // px below the rack: none — it sits ON the bottom edge. SAFE.bottom still lifts // it clear of the home indicator, which is the only gap that has to be there. const RACK_GRAB = 7; // px the TOUCH band extends above the drawn rack (below, it runs to the screen edge) // The bottom text slot. Named for the draw meter that used to live here; the meter is gone (the // arc band carries power now) and the apron below is left to the gesture demo. // The rack holds ten now, and ten tiles across a 480px canvas would be ~39 CSS px on a phone — // under the thumb target rackHit was widened for in the first place. So it grows a SECOND ROW // UPWARD instead, and only once you are actually carrying more than five: the common case is still // one row on the bottom edge, exactly as before. const rackRows = () => (G.rack.length + 1 > RACK_ROW ? 2 : 1); const rackTop = () => H - TUNE.rackH * rackRows() - RACK_BOT - SAFE.bottom; const METER_Y = () => rackTop() - 17; function rackGeom() { const h = TUNE.rackH, tw = Math.min(64, (W - 4) / RACK_ROW), rw = tw * RACK_ROW; const rows = rackRows(); return { x0: Math.round((W - rw) / 2), y0: rackTop(), tw, h, rows, n: rows * RACK_ROW, rw }; } // A NEAR-MISS AT THE RACK IS DEAD, NOT A SHOT. A tap that lands a thumb-width off a tile used to // return -1 — free court — and input turned it into Space, which INSTANTLY armed whatever burst // weapon was selected. So reaching for the flamer and missing by 10px fired the gun: the exact // "selecting X activated Y" report, made likely by 22px tiles, a second row the thumb hasn't // learned yet, and a selection that can move under you (a won duel re-equips the tin it tops up). const RACK_MISS = 18; // px of dead zone around the band: no selection, no swing, no burst export function rackHit(nx, ny) { // normalized [0,1] canvas coords -> slot index; -1 free court, -2 dead near-miss if (G.mode !== 'splatter' || G.interlude) return -1; // no rack during the tennis-rules duel const g = rackGeom(), cx = nx * W, cy = ny * H; // The touch band is deliberately BIGGER than the art: up by RACK_GRAB and all the way down to // the screen edge, reclaiming the dead margin below. At 22px tall the tiles were ~30 CSS px on a // phone — well under the 44px everyone's thumb expects — which is why switching tins felt fiddly. if (cy < g.y0 - RACK_GRAB || cx < g.x0 || cx >= g.x0 + g.rw) return cy >= g.y0 - RACK_GRAB - RACK_MISS && cx >= g.x0 - RACK_MISS && cx < g.x0 + g.rw + RACK_MISS ? -2 : -1; // The FIRST five never move: they stay welded to the bottom edge and the second five stack above, // so growing the rack doesn't slide the ball out from under a thumb that already knows where it is. // Clamped rather than rejected — the RACK_GRAB overhang sits above the top row. const fromTop = Math.max(0, Math.min(g.rows - 1, ((cy - g.y0) / g.h) | 0)); const col = Math.max(0, Math.min(RACK_ROW - 1, ((cx - g.x0) / g.tw) | 0)); return (g.rows - 1 - fromTop) * RACK_ROW + col; } function drawRack() { const g = rackGeom(), pad = 3; // Built from what you are CARRYING, not from three fixed arrays. Slot 0 is BALL unless an // overflow weapon is riding it (G.temp), and an empty slot is a blank socket rather than a // permanently dimmed weapon you never had. const W_ = i => WEAPONS[slotId(i)]; ctx.save(); ctx.lineWidth = 1; for (let i = 0; i < g.n; i++) { const w = W_(i), stockI = slotStock(i), temp = i === 0 && G.temp; const x = g.x0 + (i % RACK_ROW) * g.tw; const y0 = g.y0 + (g.rows - 1 - ((i / RACK_ROW) | 0)) * g.h; // slots 0-4 on the bottom edge, always const sel = G.weapon === i, dim = stockI <= 0; const label = i === 0 && !temp ? 'BALL' : w ? w.label : ''; const icon = i === 0 && !temp ? 'w_ball' : w ? w.icon : null; // rounded slots: five separate tiles read as five buttons, which is what they are. // The LIVE one has to read at a glance ("the active weapon should be more pronounced"): // a visibly brighter well and a 2px gold frame, not the same hairline every other tile gets. if (sel) ctx.lineWidth = 2; hudShadow(true, 2, 0, 0.5); // hard, no blur: this runs once per slot per frame panel(x + 1.5, y0 + 1.5, g.tw - 3, g.h - 3, 4, sel ? '#1c5230' : '#0a2418', // sunk slot — the live one sits visibly lit sel ? UI.pick : '#05160e'); // gold frame when live hudShadow(false); if (sel) ctx.lineWidth = 1; // THE BURST COUNTDOWN IS THE TILE ITSELF. It was a 1.5 px bar along the tile's bottom edge — // which is both the rounded frame AND the very bottom of a phone screen, so it was invisible // ("the progress bar is not visible anymore because of the lower border"). The armed slot's // WELL drains instead: a lit wash across the whole tile, emptying to the left as the window // runs out. Clipped to the tile's own rounded path (panel leaves it current) so it can never // square off the corners, and drawn UNDER the icon and labels, which stay fully readable. if (G.mgT > 0 && slotId(i) === G.gunId) { const frac = Math.min(1, G.mgT / (WEAPONS[G.gunId]?.len || TUNE.mgPickupLen)); ctx.save(); ctx.clip(); ctx.fillStyle = hexA(UI.pick, 0.34); ctx.fillRect(x + 1.5, y0 + 1.5, (g.tw - 3) * frac, g.h - 3); ctx.restore(); } // a slot that just came out of a barrel announces itself: a pink pulse for a moment, WITHOUT // stealing the selection (grabbing the gun mid-plan was the complaint — see popBarrel) if (G.rackGlow && G.rackGlow.slot === i) { const p = 0.35 + 0.55 * Math.abs(Math.sin(G.rackGlow.t * 7)); ctx.save(); ctx.lineWidth = 2; panel(x + 1.5, y0 + 1.5, g.tw - 3, g.h - 3, 4, null, hexA(UI.pink, p)); ctx.restore(); } // icon: left, vertically centred, padded off the edge const box = g.h - pad * 2, icx = x + pad + box / 2, icy = y0 + g.h / 2; if (!icon) { ctx.globalAlpha = 1; continue; } // an empty socket: nothing gathered for it yet ctx.globalAlpha = dim ? 0.35 : 1; const spr = SPR[icon]; if (spr) { const k = Math.min(box / spr.width, box / spr.height), iw = spr.width * k, ih = spr.height * k; ctx.drawImage(spr, icx - iw / 2, icy - ih / 2, iw, ih); } else { ctx.fillStyle = UI.ball; ctx.beginPath(); ctx.arc(icx, icy, 3, 0, 7); ctx.fill(); } // fallback until the PNG loads // count over label, both centred on the text column right of the icon — aligned as one unit const tx = x + pad + box + (g.tw - pad * 2 - box) / 2; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.globalAlpha = dim ? 0.5 : 1; ctx.font = F(8); ctx.fillStyle = sel ? UI.pick : '#e8f0dc'; ctx.fillText(stockI === Infinity ? '∞' : '×' + stockI, tx, y0 + g.h * 0.33); // ball = ∞, else ×count ctx.font = F(6, 0); ctx.fillStyle = sel ? '#f2ead6' : temp ? UI.pink : '#8fa89a'; ctx.fillText(label, tx, y0 + g.h * 0.72); // weapon label, under its count ctx.globalAlpha = 1; } ctx.restore(); } // the centered milestone 'block': a real pause, front and center. Shared by the sudden-death duel // and the SET announcement so both read as the same interruption. Assumes textAlign is already set. // ---- THE MESSAGE BAND. Every transient centred message — the milestone card, the RESUMING // countdown, the one-line flash — has ONE definite home: over the CROWD, below the scoreboard // block, above the ad strip. Nothing transient sits on the court anymore — a card at centre court // covered the very play it was announcing, non-blocking or not. const AD_TOP = () => Math.round(proj(0, 0, -6).y) - AD_H; // top of the courtside ad strip // The band no longer has ROWS — it has an AXIS. Card, countdown and flash all centre their ink on // CARD_MID; only one of the three is ever up (see cardUp/flashUp), so they cannot collide, and the // eye learns one place to look instead of three stacked slots. The icon sits LEFT of the text // rather than above it: stacked, an icon card ran 102 px tall and its lower third hung over live // court; side-by-side it is flat, sits fully over the crowd, and the art draws BIGGER than the // stacked layout gave it. CARD_H is the MAXIMUM plate height — the plate itself is sized to what // it actually holds (see plate()), so a one-line countdown gets a slim strip instead of a // three-line box with nothing in two thirds of it. const CARD_TOP = 68, CARD_H = 62, CARD_PADY = 9; // px of breathing room above and below the ink const CARD_MID = CARD_TOP + CARD_H / 2; // the axis everything in the band sits on // THE CARD IS A PHYSICAL THING that gets carried on and carried off, courtside-sign style: it // swipes in from the left, holds, and leaves to the right. Entry overshoots and settles back // (easeOutBack) so it arrives with weight; the exit accelerates away (easeInCubic) so it reads as // leaving rather than fading. The whole card moves, plate included — a sign travels, its backing // does not stay bolted across the screen while the type slides out from under it. const CARD_IN = 0.44, CARD_OUT = 0.24; // seconds; clamped below so a short card still gets both. // The entry is the SLOWER of the two on purpose: at 0.3 the overshoot covered the whole width in // about four frames, so the smear happened off-screen and only the exit ever showed a trail. // x offset for something that lives for T0 seconds and has t left. Anything with a countdown can // ride the band this way — the card does, and so does the RESUMING clock. function slideX(T0, t) { const tIn = Math.min(CARD_IN, T0 / 3), tOut = Math.min(CARD_OUT, T0 / 3); const el = T0 - t; if (el < tIn) { const k = Math.max(0, el) / tIn - 1, s = 1 + 2.7 * k * k * k + 1.7 * k * k; return -W * (1 - s); } if (t < tOut) { const k = 1 - t / tOut; return W * k * k * k; } return 0; } // MOTION BLUR: ghost copies sampled BACK IN TIME along the card's own easing curve, so the smear // stretches exactly as far as the card actually travelled — long through the entry snap, short as // it settles, and nothing at all while it holds. Sampling the curve beats differencing frames: // it looks identical at 60 fps and at the calm-state 20, and needs no per-card state. const CARD_GHOSTS = 4, CARD_GHOST_DT = 0.026; // 4 copies over ~100 ms of history — the trail length // The RESUMING clock's own T0, latched on the frame it takes the band and cleared when it leaves. // A card has a fixed life to slide against; the clock's is whatever was left when the band freed up. let cdT0 = 0; function slideDraw(T0, t, body) { const dx = slideX(T0, t); for (let i = CARD_GHOSTS; i >= 1; i--) { const gx = slideX(T0, t + i * CARD_GHOST_DT); if (Math.abs(gx - dx) < 1.5) continue; // holding: no trail to draw ctx.globalAlpha = 0.42 / i; body(gx); } ctx.globalAlpha = 1; body(dx); } function drawCard(c) { const T0 = c.T0 || (c.T0 = c.t); // recorded on the first drawn frame — `wait` holds slideDraw(T0, c.t, dx => cardBody(c, dx)); // `t` frozen until then, so this is exact } function cardBody(c, dx) { const { title, sub1, sub2, color, icon } = c; const img = icon && SPR[icon]; ctx.save(); ctx.translate(Math.round(dx), 0); // TWO layouts, by whether there is art. The WEAPON card is one centred row — art, name, amount, // measured as a single group and centred as one. No prose: the tin's own picture says what it is // and the count says how much, and the sentence explaining it was a line nobody read mid-run. ctx.textAlign = 'center'; if (img) { const k = Math.min(44 / img.height, 46 / img.width); const iw = Math.round(img.width * k), ih = Math.round(img.height * k); ctx.font = B(26); const nameW = ctx.measureText(title).width; ctx.font = B(20); const amtW = sub1 ? 8 + ctx.measureText(sub1).width : 0; let x = Math.round((W - (iw + 10 + nameW + amtW)) / 2); const s = inkSpan([[title, B(26), 0]]); plate(Math.max(ih, s.bot - s.top)); // the art is usually the taller of the two ctx.imageSmoothingEnabled = false; ctx.drawImage(img, x, Math.round(CARD_MID - ih / 2), iw, ih); ctx.textAlign = 'left'; x += iw + 10; ctx.font = B(26); shadowText(title, x, inkCentre([[title, B(26), 0]]), color); if (sub1) { ctx.font = B(20); shadowText(sub1, x + nameW + 8, inkCentre([[sub1, B(20), 0]]), UI.cream); } ctx.textAlign = 'center'; ctx.restore(); return; } // the title still SHOUTS (Bangers) but at 22, not 30 — up here it shares the screen with the // scoreboard and the ads, and 30 read as oversized the moment it left centre court. The stack // keeps its own line rhythm (0/30/44) and is centred as ONE block, so a one-line card and a // three-line card sit on the same axis instead of both hanging off the band's top edge. // ...and the title FITS. Cards carry whole sentences now ("SWING TO RETURN — OR SWISH IT AWAY") // and boss names of any length, and Bangers 22 ran a long one off both edges of a phone. Measure // once and shrink to the band; 22 stays 22 for everything that already fit. // THE OPPONENT CARD CARRIES ITS HEAD (Oliver: the punchline plus a picture of his head). The // icon prefers the 4K-cut z_head.png (assets/_heads.py — the installed 224 px frames gave // ~65 px heads drawn at 144 device px, which is the "very low res" he reported) and degrades // to the HEAD_CROP crop of the healthy frame, then to the plain text card. Never separate art: // both sources are the body already on court. const headImg = c.head && SPR['z_' + c.head + 'head']; const hd = headImg ? { img: headImg, w: headImg.width, h: headImg.height } : c.head && HEAD_CROP[c.head] && SPR['z_' + c.head + '1'] ? { img: SPR['z_' + c.head + '1'], box: HEAD_CROP[c.head], w: HEAD_CROP[c.head].w, h: HEAD_CROP[c.head].h } : null; const ih = hd ? CARD_H - 14 : 0, iw = hd ? Math.round(hd.w * ih / hd.h) : 0; const room = W - 24 - (hd ? iw + 8 : 0); ctx.font = B(22); const tw = ctx.measureText(title).width; const tSize = tw > room ? Math.max(13, Math.floor(22 * room / tw)) : 22; const lines = [[title, B(tSize), 0, color]]; // THE SUB LINES WRAP. The title shrinks to fit `room` (above), but the sub lines did neither — // "HE MOWED THIS LAWN FOR THIRTY YEARS." ran off both edges of a phone, and because the head icon // is placed off the widest line, an over-long sub also shoved the portrait against the left edge. // Wrapping to the same `room` fixes both. One line each still lands on 30 / 44 exactly, so every // card that already fit is untouched — this only ever adds a row where one was overflowing. const subs = []; if (sub1) subs.push(...wrapText(sub1, F(8), room)); if (sub2) subs.push(...wrapText(sub2, F(8), room)); subs.forEach((t, i) => lines.push([t, F(8), 30 + i * 14, UI.cream])); const s = inkSpan(lines); plate(Math.max(s.bot - s.top, ih)); const y = Math.round(CARD_MID - (s.top + s.bot) / 2); const cx = Math.round(W / 2 + (hd ? (iw + 8) / 2 : 0)); if (hd) { let blockW = 0; for (const [t, f] of lines) { ctx.font = f; blockW = Math.max(blockW, ctx.measureText(t).width); } ctx.imageSmoothingEnabled = false; const hx = Math.round(cx - blockW / 2 - 8 - iw), hy = Math.round(CARD_MID - ih / 2); if (hd.box) ctx.drawImage(hd.img, hd.box.x, hd.box.y, hd.box.w, hd.box.h, hx, hy, iw, ih); else ctx.drawImage(hd.img, hx, hy, iw, ih); } for (const [t, f, rel, col] of lines) { ctx.font = f; shadowText(t, cx, y + rel, col); } ctx.textAlign = 'center'; ctx.restore(); // the band's neighbours (flash, countdown) assume centred } // Greedy word wrap to `max` px in `font`. A word longer than the whole line gets its own row rather // than being broken mid-word — nothing in the roster is that long, and a hyphenless break would read // as a typo on a card that is up for four seconds. function wrapText(t, font, max) { ctx.font = font; if (ctx.measureText(t).width <= max) return [t]; // the common case allocates nothing extra const out = []; let line = ''; for (const w of t.split(' ')) { const next = line ? line + ' ' + w : w; if (line && ctx.measureText(next).width > max) { out.push(line); line = w; } else line = next; } if (line) out.push(line); return out; } // The plate, sized to the `h` px of content it carries and centred on the axis. It GROWS with a // wrapped card (see wrapText) — the CARD_H cap was tuned when three lines was the maximum a card // could hold, and clamping there now would leave a wrapped fourth row hanging outside its own plate. function plate(h) { const ph = Math.round(h) + CARD_PADY * 2; ctx.fillStyle = 'rgba(10,6,8,0.72)'; ctx.fillRect(0, Math.round(CARD_MID - ph / 2), W, ph); } // The measured INK extent of a stack of [text, font, relativeY]. Off ink, not off the font box: // Bangers carries a lot of leading, so a nominal 'middle' baseline (or a fixed offset tuned on one // string) hangs the caps visibly high. textBaseline is 'top' here, so ascent is negative — ink runs // rel-ascent .. rel+descent. Everything in the band sizes and centres itself off this. function inkSpan(lines) { let top = Infinity, bot = -Infinity; for (const [t, f, rel] of lines) { ctx.font = f; const m = ctx.measureText(t); top = Math.min(top, rel - m.actualBoundingBoxAscent); bot = Math.max(bot, rel + m.actualBoundingBoxDescent); } return { top, bot }; } function inkCentre(lines) { const s = inkSpan(lines); return Math.round(CARD_MID - (s.top + s.bot) / 2); } // A GHOST HAND, fingertip at (x, y) — the tutorial finger every gesture hint shares. A drawn // pointing hand rather than the old abstract dot: testers did not read a sliding circle as "your // finger goes here". `press` (0..1) dips it toward the glass, which is what makes a mimed tap // read as a tap and not as a cursor hovering. Pure canvas paths, no sprite: it is HUD chrome in // the UI palette, same family as the arrowheads and the dotted track, not a body on the court. function drawGhostHand(x, y, press = 0, hPx = 34) { const img = SPR.x_hand, yy = Math.round(y) - 3 + press * 4; if (img) { // real art now (assets/x_hand.png, drawn to Oliver's reference pose) const w = Math.round(img.width * hPx / img.height); ctx.imageSmoothingEnabled = false; // 0.368: the index fingertip's x as a fraction of the PNG's width, MEASURED off the sprite // (topmost opaque row's centroid) so the tip lands exactly on the point being taught ctx.drawImage(img, Math.round(x - w * 0.368), yy, w, hPx); return; } // fallback while the sprite loads (or a cached 404 — this project's oldest trap): the drawn hand ctx.save(); ctx.translate(Math.round(x), yy); ctx.scale(1.4, 1.4); ctx.rotate(-0.15); ctx.fillStyle = UI.cream; ctx.strokeStyle = 'rgba(8,12,10,0.8)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(-2.5, 0, 5, 15, 2.5); ctx.fill(); ctx.stroke(); // index finger, tip at origin ctx.beginPath(); ctx.roundRect(-2.5, 12, 13, 11, 5); ctx.fill(); ctx.stroke(); // the palm behind it for (const [bx, by, br] of [[4.5, 13.2, 2.4], [7.4, 13.7, 2.2], [9.9, 14.4, 2.0]]) { ctx.beginPath(); ctx.arc(bx, by, br, 0, 7); ctx.fill(); ctx.stroke(); // curled knuckles } ctx.beginPath(); ctx.ellipse(-3.9, 17.5, 2.6, 4.4, 0.6, 0, 7); ctx.fill(); ctx.stroke(); // thumb ctx.restore(); } // the contact ring a mimed tap leaves on the glass: grows and fades under the fingertip function drawTapRing(x, y, p) { ctx.save(); ctx.globalAlpha *= Math.max(0, 0.9 * (1 - p)); ctx.strokeStyle = UI.warm; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(x, y, 4 + p * 16, 0, 7); ctx.stroke(); ctx.restore(); } // Sit in the serve stance doing nothing and the gesture demonstrates itself: a ghost hand slides // BACK from the player while chevrons grow FORWARD up the court, then it releases and repeats. // THE APRON IS THIS DEMO'S ROOM, and now it is the only thing in it. The draw meter used to share // the space, which was backwards on both counts: a live readout down here is under your thumb the // whole time it matters (the arc band carries power now, up-court where you can see it), while // the demo only ever plays when your thumb is NOT there. So it gets the whole apron and is drawn // at a size you can read at arm's length. // One mimed gesture: a dotted track out of the player's feet, an arrowhead at the far end, the // ghost thumb sliding along it, and the words. (dx, dy) is the unit direction — the only thing that // differs between the two lessons, which is why this is one function and not two near-copies. function demoSwipe(dx, dy, label) { const cyc = 1.9, t = (G.idleHintT - TUNE.pullHintAfter) % cyc; const draw = Math.min(1, t / 1.15); // the stroke itself, then a beat held at full const gone = t > 1.45; // released — the thumb lifts off const feet = proj(P.x, 0, P.z).y; const room = Math.max(30, METER_Y() - 10 - feet); // apron between his feet and the text slot const travel = Math.min(46, room * 0.5); const ax = Math.round(proj(P.x, 0, P.z).x), ay = Math.round(feet + 12); const len = travel * draw; ctx.save(); ctx.globalAlpha = gone ? 0 : 0.85; // the dotted track the thumb travels ctx.fillStyle = UI.dim; for (let d = 7; d < travel + 4; d += 7) ctx.fillRect(ax + dx * d - 1, ay + dy * d, 2, 2); // An arrowhead at the far end, pointing the way you actually move. The demo used to grow chevrons // UP-court as the pull grew — the shot going the opposite way to the thumb was meant to be the // lesson, but an up arrow on screen is an instruction to swipe up, and that gesture does not exist. ctx.fillStyle = UI.warm; const av = travel + 16, s = 5; // clear of where the thumb lands at full stroke const hx = ax + dx * av, hy = ay + dy * av; // arrow tip, and the two barbs behind it ctx.beginPath(); ctx.moveTo(hx + dx * s, hy + dy * s); ctx.lineTo(hx - dx * s * 0.7 + dy * s, hy - dy * s * 0.7 + dx * s); ctx.lineTo(hx - dx * s * 0.7 - dy * s, hy - dy * s * 0.7 - dx * s); ctx.fill(); // ...and say it in words. The mime alone was too abstract to read as an instruction. ctx.font = F(10); // hint type is 10 now, one size up — arm's-length readable was the ask ctx.textAlign = 'center'; ctx.textBaseline = 'top'; // The label sits under the TRACK, not under the arrowhead — following the arrow sideways would // carry it off the edge of the screen, and clearing the full travel below a horizontal stroke // left it floating in the middle of an empty apron with nothing to attach itself to. // (offsets clear the bigger hand: its palm hangs ~32 px below the fingertip now) shadowText(label, ax, ay + (dy ? travel + 36 : 38), UI.warm); // the ghost hand riding the stroke, pressed into the glass for the whole draw ctx.globalAlpha = gone ? 0 : 0.9; drawGhostHand(ax + dx * len, ay + dy * len, 1); ctx.restore(); } // The apron plays the two gestures in alternation — pull, dodge, pull, dodge. Both are one-thumb // moves made from the same stance and the direction is the ONLY thing that tells them apart, so // showing them in the same place, one after the other, is what makes that legible. The sidestep // alternates sides too, or it reads as a move that only goes right. function drawPullHint() { const n = ((G.idleHintT - TUNE.pullHintAfter) / 1.9) | 0; if (n % 2 === 0) { demoSwipe(0, 1, 'PULL DOWN'); return; } // ...and it mimes AWAY from whichever edge he is standing against, or the stroke it is teaching // runs off the side of the screen. Only the alternation gives way; the lesson does not. const px = proj(P.x, 0, P.z).x; const dir = px < W * 0.3 ? 1 : px > W * 0.7 ? -1 : (n % 4 === 1 ? -1 : 1); demoSwipe(dir, 0, 'SWIPE TO DODGE'); } // ---- the TENNIS lesson, same ghost hand. The serve hints (an idle tap demo on the apron, an // apex rider on a dropped toss) lived here too and were DELETED on Oliver's report — a ghost // finger over the running serve animation read as clutter, and the zombie duel serves fine with // no teacher at all. Only the return lesson below survives. // two returns passed untouched: the hand presses harder as the ball closes on the player function drawHitHint() { const p0 = proj(P.x, 0, P.z); const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); // the same distance trySwing tests const press = Math.max(0, Math.min(1, 1 - (d - 1.6) / 2.2)); const hx = Math.round(p0.x) + 26, hy = Math.round(p0.y) - 52; // over his shoulder, off the sprite ctx.save(); ctx.globalAlpha = 0.9; if (press >= 1) drawTapRing(hx, hy, (G.idleT * 2) % 1); drawGhostHand(hx, hy, press); ctx.font = F(10); ctx.textAlign = 'center'; ctx.textBaseline = 'top'; shadowText('TAP AS IT ARRIVES', hx - 26, hy + 44, UI.warm); ctx.restore(); } // ---- ...and the lesson AFTER that one. The tap gets the ball back; the FLICK decides where it // goes, and wrong-footing the body across the net is how points are actually won. It was taught // nowhere on touch: the placement ring shows the result of a flick, which is a receipt, not a // lesson — if you never happen to swipe you never get one, so you never learn there was anything // to learn. // IT IS TAUGHT IN THE PAUSE, NOT IN THE POINT. It first rode the incoming ball, over the player's // shoulder — which is the moment he has the least attention to spare for reading anything: "it is // displayed mid-play.. please rather show it in one of the pauses" (Oliver, 2026-08-17). So it // moved into the room this game already keeps for teaching gestures: standing still in the serve // stance, on the apron, in the very demo the slingshot lesson plays. Same clock, same mime, same // words slot — one teacher, three lessons, and nothing new drawn over live play. // Both ways in turn, or it teaches one corner, and away from whichever edge he is standing against. function drawFlickHint() { const n = ((G.idleHintT - TUNE.pullHintAfter) / 1.9) | 0; const px = proj(P.x, 0, P.z).x; // ...and the words carry BOTH halves, because either one alone is useless: WHEN (as you hit — // a swipe at any other moment does nothing, and a player who tries one and sees nothing concludes // the gesture is not there) and WHAT FOR (it aims). One line, the same imperative as PULL DOWN // and TAP AS IT ARRIVES. demoSwipe(px < W * 0.3 ? 1 : px > W * 0.7 ? -1 : (n % 2 ? -1 : 1), 0, 'SWIPE AS YOU HIT TO AIM'); } // The modal: drawn LAST, over everything, and the world behind it is frozen (gameplay.update // early-returns). Unlike drawCard this is a wall — nothing moves until it is tapped away. // easeOutBack: overshoots past the target and settles. The arcade snap — nothing in a cabinet // menu ever arrived by decelerating politely into place. const BACK_C = 1.70158; const easeBack = p => 1 + (BACK_C + 1) * (p - 1) ** 3 + BACK_C * (p - 1) ** 2; function drawModal(m) { // The entrance (m.in, 0..1, ticked in gameplay's update behind the world freeze): the icon and // title fly in from the RIGHT, the body lines cascade in from the LEFT one after another, each // on its own staggered easeOutBack. Every x is ROUNDED — a fractional x on a nearest-neighbour // scaled image re-snaps its pixel columns every frame, which is the same boiling that once read // as HUD flicker. Legacy/test modals with no `in` field draw fully landed. const t = m.in === undefined ? 1 : m.in; const STAG = 0.08, RUN = 0.62; // per-element delay, and travel time const lane = k => { // 0 = off screen, 1 = home (may overshoot) const p = Math.max(0, Math.min(1, (t - k * STAG) / RUN)); return p >= 1 ? 1 : easeBack(p); }; const dx = (k, from) => Math.round(from * (1 - lane(k)) * W * 0.85); // from: -1 left, 1 right ctx.fillStyle = `rgba(8,6,10,${(0.82 * Math.min(1, t / 0.35)).toFixed(3)})`; // the dim washes in first ctx.fillRect(0, 0, W, H); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const body = m.body || [], img = m.icon && SPR[m.icon]; // cap the illustration against the viewport, not just the asset: at 270px tall (landscape) a // portrait-sized icon pushed the TAP prompt down onto the weapon rack const iconH = img ? Math.min(m.iconH || 40, H * 0.28) : 0; const blockH = (iconH ? iconH + 12 : 0) + 26 + body.length * 13; let y = Math.round(H / 2 - blockH / 2); const headX = dx(0, 1); // icon and title travel together, as one masthead if (img) { const iw = Math.min(W - 24, iconH * img.width / img.height); ctx.drawImage(img, Math.round(W / 2 - iw / 2) + headX, y, Math.round(iw), iconH); y += iconH + 12; } ctx.font = B(24); ctx.fillStyle = m.color || UI.warm; // an announcement, so the announcement face if (headX) for (const [f, a] of [[0.7, 0.12], [0.35, 0.26]]) { // speed streak: ghosts strung out behind it ctx.save(); ctx.globalAlpha = a; ctx.fillText(m.title, W / 2 + Math.round(headX * (1 + f)), y + 8); ctx.restore(); } ctx.fillText(m.title, W / 2 + headX, y + 8); y += 26; ctx.font = F(8, 0); body.forEach((l, i) => { shadowText(l, W / 2 + dx(i + 1, -1), y + 6, UI.cream); y += 13; }); // the prompt only exists once everything has landed — offering the tap mid-flight would be a lie, // dismissModal swallows it until then if (t >= 1 && Math.floor(G.idleT * 2) % 2) { // the prompt blinks so it never reads as part of the copy ctx.font = F(8); shadowText(IS_TOUCH ? 'TAP TO CONTINUE' : 'PRESS ANY KEY', W / 2, Math.round(H / 2 + blockH / 2 + 22), UI.warm); } ctx.textBaseline = 'top'; } // ------------------------------------------------------------------ frame ---- const ballHist = []; // hot-ball ghost trail positions (render-owned, see the streak block in render) const AD_H = 14; // px tall the courtside ad strip is drawn — also the ceiling for centred HUD text // (FLASH_Y is gone — the flash centres on CARD_MID with everything else in the band now.) export function render() { ctx.setTransform(RES, 0, 0, RES, 0, 0); // logical W x H coordinates at RES density // ease the aim camera FIRST, before anything projects — one camera per frame (see proj) aimCam += ((G.aim ? 1 : 0) - aimCam) * 0.15; // ...and the ball-follow lean with it, tennis rallies only: the gore modes fire a ball down-court // every second and the camera would pump. Slower ease than the aim (a broadcast lean, not a snap). farCam += (((G.match || G.mode === 'tennis') && ball.active && ball.z < COURT.netZ ? 1 : 0) - farCam) * 0.06; if (farCam < 0.001) farCam = 0; else if (farCam > 0.999) farCam = 1; // settle exactly, so the // backdrop cache key stops moving at either end // pivot the lean about the player's deepest stance — see the fOff note at proj() const fs0 = TUNE.camF / (TUNE.camZ - P_BOUNDS.zMax); const fs1 = TUNE.camF / (TUNE.camZ + farCam * TUNE.farCamZ - P_BOUNDS.zMax); fOff = TUNE.camY * fs0 - (TUNE.camY + farCam * TUNE.farCamY) * fs1; if (G.shake) { // bomb kick: jitter the whole world (transform is reset before the HUD) const m = G.shake.mag * G.shake.t / 0.35; ctx.translate((Math.random() * 2 - 1) * m, (Math.random() * 2 - 1) * m); } // A scripted PUNCH-IN on a world point, in the same place and for the same reason as the shake: // it is a camera move on the drawn frame, not on proj(). A perspective dolly would want camZ, // aimCam and every projected HUD anchor to agree with each other for the sake of one 1.15s beat // in the transition — this is a flat 2D scale about a point, it only ever crops inward (so no // background edge can show), and it costs three lines. if (G.zoom) { const p = Math.min(1, G.zoom.t / G.zoom.T); const k = 1 + (G.zoom.k - 1) * Math.sin(p * Math.PI); // in and back out, no cut at either end const q = proj(G.zoom.x, 0, G.zoom.z); ctx.translate(q.x, q.y); ctx.scale(k, k); ctx.translate(-q.x, -q.y); } const pal = palette(), pt = paletteT(); pageBackground(pal.out); // the page behind the canvas matches, for the rounded display corners // stands, ads, rot tint, court, lines and the run-off, as one cached blit (see paintBackdrop); // the clap ripple is overlaid live inside. The ad strip sits flush on the court's back edge (the // out-area quad reaches z=-6), which keeps the boards clear of the lawn in BOTH orientations. drawBackdrop(pal, pt); // (a random floodlight stutter darkened the stands here for one build — on the phone it read as // the crowd area flickering black, i.e. as a rendering bug, not as ambience. Gone.) // gore decals: what remains of the visiting team (flat ground layer, then far-side mounds) for (const d of gore) { const p = proj(d.x, 0, d.z); if (p.s <= 0) continue; // behind the camera: see drawAimMark ctx.fillStyle = d.scorch ? `rgba(28,22,16,${d.a})` : d.ash ? `rgba(58,56,54,${d.a})` // burnt out: dark grey ash, not blood : `rgba(140,18,18,${d.a})`; // craters burn black-brown ctx.beginPath(); ctx.ellipse(p.x, p.y, p.s * d.r * 0.4, p.s * d.r * 0.16, 0, 0, 7); ctx.fill(); } drawPiles(false); // far-side piles (occluded by the net); near-side piles drawn after the net // THE PLACEMENT RING (match): where your aimed rally shot is going, drawn the moment you flick. // Gold, fading over 0.8 s — flick, watch the ring bite the corner, learn what the gesture does. if (G.placeMark) { // THE RING IS THE FLICK'S RECEIPT. Its POSITION says where the ball is going; its SIZE and // COLOUR say how much of your flick was actually applied, because reach scales with timing // (see doHit) and that would otherwise be an invisible rule. Tight and hot = you earned the // full width; wide and dim = you got a racket on it and little else. // ...and it goes RED when the shot is actually sailing. "The number one reason I die is that I // myself keep firing balls into the out" — and the ring said so only by sitting a few pixels // past a painted line while the ball was still in the air. The colour is the line call, early. const q = G.placeMark.q ?? 1, out = Math.abs(G.placeMark.x) > COURT.W / 2; ctx.globalAlpha = Math.min(1, G.placeMark.t / 0.5) * (0.55 + 0.45 * q); drawRing(G.placeMark.x, G.placeMark.z, out ? UI.bad : q > 0.62 ? UI.hot : UI.warm, 1.35 - 0.6 * q); ctx.globalAlpha = 1; } // THE FLICK BEFORE IT IS SPENT: where the swipe under your thumb is currently asking the ball to // go, drawn from the moment it is long enough to count until the moment it stops being a flick. // The placement ring above is a receipt — it arrives with the ball already gone, which teaches // nothing about a gesture you cannot feel the length of. This is the same aim, live: the ring // walks out toward the corner as you swipe further, and turns red once you are asking for a ball // the line cannot hold. Timing still decides how much of it you actually get — that is what the // receipt says, and the difference between the two rings is exactly the lesson. if (G.swipe && G.phase === 'rally' && TUNE.aimCloud) { const sway = swayOf(G.swipe); const x = sway * TUNE.flickWidth; const z = G.swipe.fy < -0.38 ? SHOT_DEPTH.deep : G.swipe.fy > 0.38 ? SHOT_DEPTH.short : SHOT_DEPTH.mid; // A CLOUD, NOT A DIAGRAM. The first cut was a ring on a ray from the player's feet, and a line // drawn across the court is a HUD element laid over the game — Oliver: "pretty bare bones... // the line must go away, it is distracting". This is a soft mark on the grass instead, in the // same procedural register as the dust and the blood: it sits where the ball is being asked to // land, it GROWS with the length of the swipe, and it warms from green to red as that ask // approaches the width no timing can keep in. The threshold is not a flip — the colour is // already amber before you reach it, so "too hard" is something you watch coming. const a = Math.abs(sway), p = proj(x, 0, z); if (p.s > 0) { const safe = (COURT.W / 2 - TUNE.aimError) / (TUNE.flickWidth * TUNE.flickReach); const col = aimHeat(Math.min(1, a / safe)); const rx = p.s * (0.55 + 1.35 * a), ry = rx * 0.4; // metres of ground, so perspective shrinks it with distance ctx.fillStyle = col; // NOT additive: 'lighter' turned the red end yellow over green grass, and the hue IS the // message here. A soft stain keeps green green and red red on every court in the table. // stacked rather than a gradient: three soft passes build a chunky core the way the dust and // the piles do, and a smooth radial ramp would be the one un-pixelated thing on the court for (let i = 3; i >= 1; i--) { ctx.globalAlpha = 0.13 + 0.10 * a; ctx.beginPath(); ctx.ellipse(Math.round(p.x), Math.round(p.y), rx * i / 3, ry * i / 3, 0, 0, 7); ctx.fill(); } ctx.globalAlpha = 1; } } // landing ring: white = incoming ball (a PREDICTION, gone the moment the ball touches down — // the ring that lingered where the ball had already landed confused testers and is removed) if (G.phase === 'rally' && ball.active && ball.bounces === 0 && ball.lastHit === 'O') { const l = predictLanding(); // the ring CONTRACTS toward touchdown (predictLanding's closed-form t), so the static marker // becomes a timing cue: wide while the ball is high, snapping to its true size as it arrives if (l) drawRing(l.x, l.z, 'rgba(255,255,255,0.85)', Math.min(2.2, 0.55 + l.t * 1.5)); } // SLINGSHOT AIM: the shot's actual flight, drawn as a band through the air. // It replaces a chevron trail on the ground plus a draw meter across the bottom. The trail was // a RAY — it pointed at the target but said nothing about the ball going over the net — and the // meter lived in the apron, which is precisely where your thumb is while you pull, so the one // readout you needed mid-gesture was under your hand. // Now one object carries everything, up-court where you are already looking: // WHERE the parabola itself, from previewServe — the same solver the release fires with // WHETHER the colour, flipping at the kill threshold (cool blue = this only staggers) // HOW MUCH the band's thickness and opacity, which is what a hue cannot say on its own // Because the arc terminates at the auto-picked target, a weak pull and a full one at the same // shambler draw the SAME curve in different colours — which is the "distance is free" rule made // visible instead of merely documented. // While pulling: the live preview, whole. After release: the same arc with its tail already // eaten, drawn from where the ball has got to. G.aim wins — a new pull replaces the last shot's // leftovers rather than drawing two bands at once. Held for the frame because the MARK on the // far end has to wait for the bodies to be drawn (see drawAimMark below) and previewServe is a // full ballistic solve — worth doing once. const band = G.mode === 'splatter' && !G.interlude && (G.phase === 'pretoss' || G.phase === 'toss' || G.phase === 'rally') ? (G.aim ? previewServe(G.aim) : G.trail) : null; const flying = !!band && band === G.trail; // aiming draws the whole arc; a fired one is spent const bandU0 = flying ? band.t / band.T : 0; // THE LASER PREVIEWS ITSELF. The ray IS the weapon, so an arcing ball-band (and the little ring it // puts on a body) is describing a projectile that never exists. While the laser is armed and you // are pulling, the preview is the WEDGE you are about to cut — same shape, same widening, just // not yet lit. const laserAim = G.aim && WEAPONS[slotId(G.weapon)]?.beam && !flying ? band : null; if (!laserAim && band) drawShotBand(band, bandU0, flying); // the laser previews itself below, over the court // far-side drawables in z order (far → near), then the net, then the near side const farSide = [ // the umpire wears the same boost as a body standing at his depth, or he'd read as the one // shrunken figure on a court of boosted ones { z: COURT.netZ - 0.01, f: () => drawStatic('x_ump', UMP_X, COURT.netZ, UMP_H * farB(COURT.netZ)) }, ]; if (!G.hideO) farSide.push({ z: O.z, f: () => drawChar(O) }); if (G.boy && G.boy.z <= COURT.netZ) farSide.push({ z: G.boy.z, f: () => drawBoy(G.boy) }); for (const zb of zombies) if (zb.z <= COURT.netZ) farSide.push({ z: zb.z, f: () => drawZombie(zb) }); farSide.sort((a, b) => a.z - b.z).forEach(d => d.f()); // net const nl = proj(-COURT.netW / 2, 0, COURT.netZ), nr = proj(COURT.netW / 2, 0, COURT.netZ); const nlt = proj(-COURT.netW / 2, COURT.netH, COURT.netZ), nrt = proj(COURT.netW / 2, COURT.netH, COURT.netZ); ctx.fillStyle = 'rgba(20,26,34,0.72)'; ctx.beginPath(); ctx.moveTo(nl.x, nl.y); ctx.lineTo(nr.x, nr.y); ctx.lineTo(nrt.x, nrt.y); ctx.lineTo(nlt.x, nlt.y); ctx.fill(); ctx.strokeStyle = '#eeeeee'; ctx.lineWidth = 1.5; ctx.beginPath(); const rip = G.netRipple; if (rip) { // net cord: a damped travelling wave centred where the ball clipped the tape (0.4 s) const px = proj(rip.x, COURT.netH, COURT.netZ).x; for (let i = 0; i <= 24; i++) { const x = nlt.x + (nrt.x - nlt.x) * i / 24; const y = nlt.y + (nrt.y - nlt.y) * i / 24 + Math.sin((x - px) * 0.3 + rip.t * 34) * 1.1 * (rip.t / 0.4) * Math.exp(-Math.abs(x - px) * 0.05); // ~1 px at the clip — a wobble, not a wave i ? ctx.lineTo(x, y) : ctx.moveTo(x, y); } } else { ctx.moveTo(nlt.x, nlt.y); ctx.lineTo(nrt.x, nrt.y); } ctx.stroke(); ctx.fillStyle = '#333'; ctx.fillRect(nl.x - 1, nlt.y, 2, nl.y - nlt.y); ctx.fillRect(nr.x - 1, nrt.y, 2, nr.y - nrt.y); drawPiles(true); // near-side piles: after the net (they're closer), before the near actors if (G.boy && G.boy.z > COURT.netZ) drawBoy(G.boy); const nearSide = zombies.filter(zb => zb.z > COURT.netZ).map(zb => ({ z: zb.z, f: () => drawZombie(zb) })); if (G.mode === 'eaten' && G.eatenT < EATEN_BURST) // the dying man takes his place in the queue nearSide.push({ z: (G.eatenAt || P).z - 0.12, f: drawDying }); nearSide.sort((a, b) => a.z - b.z).forEach(d => d.f()); // THE LASER's fired wedge, ERASING ALONG ITS OWN PATH. Drawn HERE — after the net and after every // body — because it is light in the air, not a decal on the grass. Drawn before them it went // UNDER the net and behind the crowd, which is exactly why it read as lying on the floor. // The fired beam, and the AIM PREVIEW, both draw here: after the net and after every body, because // this is light in the air. Then the bodies standing in it are re-cut on top of it. if (G.beam) { const b = G.beam, k = Math.max(0, b.t / TUNE.beamT); // 1 at the flash, 0 as it dies const L = drawBeamWedge(b.ux, b.uz, b.x, b.y, b.z, 1 - k); drawBeamCuts(b.ux, b.uz, b.x, b.z, L); } else if (laserAim) { const L = drawBeamWedge(laserAim.v.vx, laserAim.v.vz, P.x, TUNE.beamY + TUNE.beamOriginLift, P.z, 0); // the preview is the same solid plane drawBeamCuts(laserAim.v.vx, laserAim.v.vz, P.x, P.z, L); // "already while aiming" } if (band && !laserAim) drawAimMark(band, bandU0, flying); // ...now that there is a body to mark // server idly bouncing the ball while waiting if (G.phase === 'pretoss') { const sv = score.server === 'P' ? P : O; // The ball goes where the sprite reaches for it, and the two men reach to OPPOSITE SIDES of the // screen. Both are right-handed, so both pat the ball with the left hand — but the player is // drawn from BEHIND (his left hand is on the viewer's left) and the opponent from the FRONT // (his left hand is on the viewer's right). One sign for both put the opponent's ball out by // his racket instead of under his palm. // ...and the offset FOLLOWS THE FACING, because drawChar mirrors the whole sprite when a server // faces left — dodging during the bounce swapped his hands over and left the ball hanging off // the racket side. One `* sv.facing` fixes it for both players and both tours; it was wrong in // the men's world too, it just took a second body to notice. const mine = sv.side === 'P'; const bx = sv.x + sv.facing * (mine ? -bounceDx() : (oTour().oDx ?? BOUNCE_IDLE.oDx)); const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * (mine ? bounceApex() : (oTour().oApex ?? BOUNCE_IDLE.oApex)); drawBall(bx, by, sv.z); } // hot-ball ghost streak: a fast ball drags fading ghosts behind it. It gated on pow > 0.8 alone, // which in practice only the gore slingshot ever reached — tennis tap swings live below it, so // both tennis modes played without the streak (Oliver's sidequest). PACE now qualifies a ball // too: a flat serve or a clean winner streaks in every mode, a lob stays clean. // ...AND THE STREAK IS HOW LONG THE BALL IS FAST (Oliver, 2026-08-22, after the multiplayer test: // "when smashing the opponent sees the ball really fast — the player barely sees the speed // increase"). It was three ghosts at every pace, so it said "fast/not fast" and nothing else — // while the CAMERA works against the striker, compressing his own ball into the distance as the // same shot expands toward the man receiving it. Length reads as pace on both phones now, and it // is the striker's only cue that the ball he just hit is quick. Sampled every frame, so a fast // ball's ghosts are ALSO further apart: pace shows as reach and as spacing at once. const bspd = Math.hypot(ball.vx, ball.vz); const hot = ball.active && G.phase === 'rally' && !ball.collected && ((ball.pow || 0) > 0.8 || bspd > TUNE.trailSpeed); const ghosts = Math.max(3, Math.min(8, Math.round((bspd - TUNE.trailSpeed) / 6))); if (hot) { ballHist.push({ x: ball.x, y: ball.y, z: ball.z }); if (ballHist.length > 10) ballHist.shift(); } else if (ballHist.length) ballHist.length = 0; if (hot) { for (let i = 0; i < ghosts; i++) { const h = ballHist[ballHist.length - 2 - i]; if (!h) break; const p = proj(h.x, h.y, h.z); // 0.26 was tuned against the gore mode's dark rot palette; on the bright tennis lawn it // vanished entirely — "i see no ball trails" (Oliver, in the tournament) ctx.globalAlpha = 0.4 * (1 - i / ghosts); ctx.fillStyle = UI.ball; ctx.beginPath(); ctx.arc(p.x, p.y, Math.max(1.5, p.s * 0.09 * (0.95 - 0.6 * i / ghosts)), 0, 7); ctx.fill(); } ctx.globalAlpha = 1; } // the match ball (and any triple-serve side balls in flight) if ((ball.active || G.phase === 'over') && !ball.collected) drawBall(ball.x, ball.y, ball.z, true, ball.bomb, ball.flame); for (const e of extras) drawBall(e.x, e.y, e.z); // INCOMING hostile balls, marked so they can never be mistaken for one of yours: a red ring and a // hard ground shadow, so you can read WHERE it will land while it is still in the air. for (const s of shots) { const sh = proj(s.x, 0, s.z); drawShadow(sh.x, sh.y, Math.max(1.5, sh.s * 0.05), Math.max(1, sh.s * 0.025), 0.4); const sp = proj(s.x, s.y, s.z), r = Math.max(2, sp.s * 0.045); ctx.fillStyle = UI.bad; ctx.beginPath(); ctx.arc(Math.round(sp.x), Math.round(sp.y), r + 1.5, 0, 7); ctx.fill(); ctx.fillStyle = UI.ball; ctx.beginPath(); ctx.arc(Math.round(sp.x), Math.round(sp.y), r, 0, 7); ctx.fill(); } // splatter particles (fire embers and machine smoke ride the same array) for (const pt of parts) { const pp = proj(pt.x, pt.y, pt.z); if (pt.smoke) { // a broken machine's smoke: dark, soft-edged, growing and thinning as it climbs const age = Math.min(1, pt.t / 1.8), r = Math.max(2, pp.s * 0.09 * (2 - age)); ctx.fillStyle = `rgba(46,44,48,${0.62 * age})`; ctx.beginPath(); ctx.arc(pp.x | 0, pp.y | 0, r, 0, 7); ctx.fill(); continue; } // a ROCKET's exhaust: a soft round puff that fades hot-white -> orange -> grey, drawn additively // so the plume glows instead of stippling. This is the difference between a motor and a firework. if (pt.jet) { const age = Math.min(1, pt.t / 0.6), r = Math.max(1.2, pp.s * 0.028 * (1.8 - age)); ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.fillStyle = age > 0.72 ? `rgba(255,246,214,${age})` : age > 0.38 ? `rgba(255,170,60,${age * 0.8})` : `rgba(120,110,110,${age * 0.5})`; ctx.beginPath(); ctx.arc(pp.x, pp.y, r, 0, 7); ctx.fill(); ctx.restore(); continue; } if (pt.fine) { // the beacon's thread: ONE pixel, so the ball stays the bright thing ctx.globalAlpha = Math.min(1, pt.t * 4); ctx.fillStyle = pt.spark; ctx.fillRect(pp.x | 0, pp.y | 0, 1, 1); ctx.globalAlpha = 1; continue; } if (pt.ash) { // settling ash from a burnt body ctx.fillStyle = pt.dark ? 'rgba(46,44,42,0.9)' : 'rgba(92,88,84,0.9)'; ctx.fillRect(pp.x | 0, pp.y | 0, 2, 2); continue; } ctx.fillStyle = pt.spark ? pt.spark // a thrown round dragging its colour : pt.pink ? (pt.t > 0.2 ? UI.pink : '#ff9df6') // a barrel bursting: debris, not gore : pt.fire ? (pt.t > 0.12 ? '#ffc030' : '#ff8010') : pt.dark ? 'rgba(96,14,14,0.9)' : 'rgba(170,22,22,0.9)'; ctx.fillRect(pp.x | 0, pp.y | 0, 2, 2); } // Machine-gun rounds: they are TENNIS BALLS, fired small and hard, so they are drawn as balls and // they take the perspective like everything else. A fixed 4x4 fillRect stayed exactly as big at // the far baseline as at your feet, which read as square confetti pinned to the screen rather than // as something travelling away from you. Radius is a shade over half the match ball's 0.09. // A barrel weapon's round carries its own colour and size (see zombies.firePellet), so a beacon // shell is a fat cyan ball and a laser bolt a thin magenta one — told apart at a glance, which // matters because they are all the same projectile underneath. for (const pe of pellets) { const sc = (pe.r ?? TUNE.mgPelletR) / TUNE.mgPelletR; const sh = proj(pe.x, 0, pe.z); drawShadow(sh.x, sh.y, Math.max(1, sh.s * 0.04 * sc), Math.max(1, sh.s * 0.02 * sc), 0.25); const pp = proj(pe.x, pe.y, pe.z), r = Math.max(1, pp.s * 0.05 * sc); if (pe.glow) { // the exotic rounds carry their own light, the plain pellet does not ctx.save(); ctx.globalCompositeOperation = 'lighter'; for (const [rf, af] of [[2.6, 0.20], [1.6, 0.34]]) { // a real bloom, not one flat disc ctx.globalAlpha = af * Math.min(1.6, pe.glow); ctx.fillStyle = pe.col || UI.ball; ctx.beginPath(); ctx.arc(pp.x, pp.y, r * rf, 0, 7); ctx.fill(); } ctx.restore(); ctx.globalAlpha = 1; } ctx.fillStyle = pe.col || UI.ball; ctx.beginPath(); ctx.arc(pp.x, pp.y, r, 0, 7); ctx.fill(); // a WHITE-HOT centre, bigger on a glowing round: the ball itself should be the brightest thing ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(pp.x, pp.y, Math.max(0.6, r * (pe.glow ? 0.62 : 0.45)), 0, 7); ctx.fill(); } // the chapter boss's shockwave: a ring of blood mist blown out through the crowd noise if (G.bossBurst && proj(G.bossBurst.x, 0.5, G.bossBurst.z).s > 0) { // behind the camera: see drawAimMark const b = G.bossBurst, k = 1 - b.t / 0.75, p = proj(b.x, 0.5, b.z); ctx.strokeStyle = `rgba(190,26,26,${(1 - k) * 0.8})`; ctx.lineWidth = Math.max(1.5, p.s * 0.05 * (1 - k)); ctx.beginPath(); ctx.ellipse(p.x, p.y, p.s * 0.9 * k, p.s * 0.32 * k, 0, 0, 7); ctx.stroke(); ctx.strokeStyle = `rgba(255,180,170,${(1 - k) * 0.35})`; ctx.lineWidth = 1; ctx.beginPath(); ctx.ellipse(p.x, p.y, p.s * 0.62 * k, p.s * 0.22 * k, 0, 0, 7); ctx.stroke(); } if (G.mode !== 'eaten') drawChar(P); // ...the dying man is drawn with the crowd instead (drawDying) // world-anchored reaction pops (zombies.popText): DOUBLE KILL rises off the corpse, the praise // word lands on the impact. Drawn IN the world pass, before the un-shake, so they ride the kick // of the moment they belong to. Small, brief, and where you are already looking — the reactions // left the announcement band because up there they read as one more banner shouting over play. for (const p of pops) { const f = p.t / p.T, pr = proj(p.x, p.y, p.z); ctx.save(); ctx.globalAlpha = Math.min(1, f * 3); // holds, then a quick fade at the end ctx.font = B(13); ctx.textAlign = 'center'; ctx.textBaseline = 'top'; shadowText(p.text, Math.round(pr.x), Math.round(pr.y - (1 - f) * 14), p.col || '#ffffff'); ctx.restore(); } // (a PERFECT-contact spark ring lived here for one build — Oliver's verdict: too much. The // hitstop, the click and the praise word already carry the moment.) // The braced "rambo racket" is NOT drawn any more. It was a procedural racket laid over a sprite // that already holds one: straight up-court it projected onto his back as a floating oval, and // offset to the side it just stuck out. The pellets streaming up-court are the feedback; the man // holding his own racket is the picture. ctx.setTransform(RES, 0, 0, RES, 0, 0); // un-shake: the HUD and overlays stay rock steady // bomb bloom: a warm blood-orange wash, not a white-out. At 0.85 white it blinded you on a phone // and hid the very gore eruption it was announcing. if (G.boomFlash > 0) { ctx.fillStyle = `rgba(255,150,70,${Math.min(1, G.boomFlash / 0.06) * 0.3})`; ctx.fillRect(0, 0, W, H); } // THE BLOOD VIGNETTE — the hp channel, worn permanently. The old edge wash flashed for a third // of a second and vanished, so between hits the screen said you were fine; Oliver asked for the // opposite: "a blood red vignette which gets worse with every hit". It reads G.hp directly — a // radial darkening of the frame's edges whose depth IS your damage fraction — and the hit itself // rides on top as a brief brighter pulse (hurtFlash, kept). No HP bar anywhere, still: the // vignette is the same drawn-on-the-man philosophy, one layer out. { const dmg = 1 - Math.max(0, G.hp) / (G.hpMax || 1); const pulse = G.hurtFlash > 0 ? Math.min(1, G.hurtFlash / 0.3) * 0.3 : 0; if (dmg > 0 || pulse > 0) { // FOUR EDGE FADES, not a radial. A radial gradient in a tall portrait canvas reaches the // corners but arrives at the side edges at fraction ZERO — Oliver saw "a slight red pulsing // from below" because the bottom edge was the only place the circle got close. Linear fades // hug the actual rectangle on any aspect. sqrt(dmg) front-loads the curve (the match pool is // 8, so linear made hit one an invisible 10%), and the inset DEEPENS with damage so wounds // shrink the clean centre. const a = Math.min(0.85, Math.sqrt(dmg) * 0.8 + pulse); const col = k => `rgba(120,8,12,${(a * k).toFixed(3)})`; const D = Math.round(Math.min(W, H) * (0.2 + 0.32 * dmg)); for (const [x0, y0, x1, y1, w, h, gx, gy] of [ [0, 0, 0, D, W, D, 0, 1], // top [0, H, 0, H - D, W, D, 0, -1], // bottom [0, 0, D, 0, D, H, 1, 0], // left [W, 0, W - D, 0, D, H, -1, 0], // right ]) { const g = ctx.createLinearGradient(x0, y0, x1, y1); g.addColorStop(0, col(1)); g.addColorStop(1, col(0)); ctx.fillStyle = g; ctx.fillRect(gx < 0 ? W - D : 0, gy < 0 ? H - D : 0, w, h); } } } // HUD — Wimbledon manual scoreboard as a 16-bit panel: bevelled bottle-green box (light // top/left, dark bottom/right), a purple club spine, cream serif names, and the LIVE game // sunk into a recessed slot — purple for the server, gold digits. Splatter adds a recessed // status well (kills-to-next-point pips + weapon stock). Hard edges, no radius = retro. const benchOn = !!G.bench; // an art test has no score; the board would be the biggest thing on // screen arguing with the one frame you came here to look at const splat = G.mode !== 'tennis'; // ZOMBIE TENNIS HAS ITS OWN BOARD. The club's bottle-green plate is the club's; the match is // played on a board nobody has cleaned in a long time (assets/_board.py), and its ink has to // change with it — a green 'dim' over dried blood reads as a printing fault. Falls back to the // club board and its palette until the plate has loaded. const zBoard = G.match && SPR.scoreboard_z; const bd = zBoard ? { face: '#4a1c1c', hi: '#7a4a2a', lo: '#2a0e0e', spine: '#d9d2c4', spineHi: '#f2ead6', cream: '#f2ead6', dim: '#a8887c', gold: UI.warm } : { face: '#1e5b3a', hi: '#3f9560', lo: '#0e3624', spine: '#472a70', spineHi: '#5f3d94', cream: '#f2ead6', dim: '#89a08f', gold: UI.pick }; const R = (x, y, w, h, c) => { ctx.fillStyle = c; ctx.fillRect(x, y, w, h); }; const ptTxt = i => score.pts[0] >= 3 && score.pts[1] >= 3 ? (score.pts[i] === score.pts[1 - i] ? '40' : score.pts[i] > score.pts[1 - i] ? 'AD' : '40') : ['0', '15', '30', '40'][Math.min(score.pts[i], 3)]; ctx.save(); ctx.translate(6, 6); const bW = 150, bH = 42; // fixed: keeps the ornate PNG undistorted (art is 300x84 = 150x42) const plate = zBoard || SPR.scoreboard; if (benchOn) { /* an art test has no score — not even the empty plate */ } else if (plate) { hudShadow(true); ctx.drawImage(plate, 0, 0, bW, bH); // generated ornate board backdrop hudShadow(false); } else { // code-drawn fallback until the PNG loads hudShadow(true); R(0, 0, bW, bH, bd.face); hudShadow(false); R(0, 0, bW, 1, bd.hi); R(0, 0, 1, bH, bd.hi); R(0, bH - 1, bW, 1, bd.lo); R(bW - 1, 0, 1, bH, bd.lo); R(1, 1, 5, bH - 2, bd.spine); R(1, 1, 5, 1, bd.spineHi); R(6, 1, 1, bH - 2, bd.lo); } // live text laid straight onto the art's own regions (spine ~x14, two rows, right slot ~x105-147), // Silkscreen pixel font — no code-drawn slots or bevels painted on top of the art. ctx.textBaseline = 'middle'; // ...and the match names the thing across the net rather than calling it THEM. Eight characters, // which is the name slot — see `short` on the opponent rows in config. const foe = G.match && zombies.find(z => z.variant === 'r' && z.foe); // NETPLAY NAMES THE PERSON, in either mode — the friend typed it into the lobby and it crossed // on the look message. FRIEND until it lands (or an older build never sends one), never CPU: // there is no CPU in this mode and the board saying so was the netplay prototype's last lie. const names = G.net ? ['YOU', (G.net.look && G.net.look.name) || 'FRIEND'] : G.match ? ['YOU', (foe && foe.foe.short) || 'THEM'] : splat ? ['YOU', 'THEM'] : ['YOU', 'CPU']; for (let i = 0; i < 2 && !benchOn; i++) { const cy = 12 + i * 18, svr = (score.server === 'P') === (i === 0); // rows aligned to the art's two bands if (svr) { ctx.fillStyle = bd.gold; ctx.beginPath(); ctx.arc(11, cy, 1.8, 0, 7); ctx.fill(); } // server pip on the purple spine ctx.textAlign = 'left'; ctx.font = F(8, svr); ctx.fillStyle = svr ? bd.cream : bd.dim; ctx.fillText(names[i], 18, cy); ctx.textAlign = 'center'; // ...and CLASSIC TENNIS shows them too: it is the one mode where a set is the thing you are // actually playing for, and hiding the column left a match-scoring mode reading 0-0 all match. // the SETS column, and NOT in the match: it banks no sets, so the column was a permanent 0 — // the bar under the board carries that mode's progress instead (OPPONENT n · m FELLED) if ((splat || G.classic) && !G.match) { ctx.font = F(8, 0); ctx.fillStyle = bd.dim; ctx.fillText(String(G.sets[i]), 74, cy); } ctx.font = F(8); ctx.fillStyle = bd.cream; ctx.fillText(String(score.games[i]), 90, cy); // games ctx.font = F(8); ctx.fillStyle = svr ? bd.gold : bd.cream; ctx.fillText(ptTxt(i), 126, cy); // points, in the art's slot } if ((splat || G.classic) && !benchOn) { // This row used to be bare text on the court. That was fine over one dark green lawn and is // unreadable over a sunlit clay crowd or a floodlit blue one, so it gets its own panel — the // same trick the scoreboard art above it already relies on. ctx.lineWidth = 1; // 6px was the smallest type in the game and it read as texture rather than as words. Both // strings are 7 and the panel grew to carry them — they are the only permanent readout of how // far into a run you are. hudShadow(true); panel(0.5, bH + 1.5, bW - 1, 14, 3, 'rgba(8,26,18,0.82)', hexA(bd.hi, 0.35)); hudShadow(false); // which game you're in, and the sets banked, permanently — the 1.1s GAME N flash and the // scoreboard's own per-row sets column were the only places either showed. ctx.textAlign = 'right'; ctx.font = F(7, 0); ctx.fillStyle = bd.dim; // ...and in the MATCH the run is not measured in games at all (blood-only scoring): the ladder // rung you are on and the bodies behind you are what the player is playing for, so this slot // prints those instead. Same slot, same size — the mode changes the words, not the furniture. // ...and in CLASSIC it says how far through the MATCH you are, which is the one thing the // board itself cannot: which set this is, and how many it takes to win. // the cup names the ROUND — its opp is a drawn rung, so the deathmatch arithmetic lied here ctx.fillText(G.match && G.match.cup ? `${CUP_ROUNDS[G.match.cup.round].name} · ${G.match.cup.round} FELLED` : G.match ? `OPPONENT ${G.match.opp} · ${G.match.opp - 1} FELLED` : G.classic ? `SET ${G.sets[0] + G.sets[1] + 1} · FIRST TO ${TUNE.classicSets}` : `GAME ${G.runGames + 1} · ${G.sets[0]} SET${G.sets[0] === 1 ? '' : 'S'}`, bW - 3, bH + 8); } // ...and NOT during a match: nothing on that court is killed for points, so the pips were both a // lie and a collision — 'NEXT ○○' ran straight into the OPPONENT line sharing the panel. if (splat && !G.match && !G.classic && !benchOn && TUNE.killPips && TUNE.killsPerPoint > 1) { // kills-to-next-point pips, on the panel above ctx.textAlign = 'left'; ctx.font = F(7, 0); ctx.fillStyle = bd.dim; ctx.fillText('NEXT', 4, bH + 8); for (let k = 0; k < TUNE.killsPerPoint; k++) { ctx.beginPath(); ctx.arc(34 + k * 8, bH + 8, 2.4, 0, 7); if (k < G.killCount) { ctx.fillStyle = bd.gold; ctx.fill(); } else { ctx.strokeStyle = '#5a6a5a'; ctx.lineWidth = 1; ctx.stroke(); } } } ctx.restore(); ctx.textBaseline = 'top'; ctx.textAlign = 'center'; if (!G.bench) drawBlood(); // how many more hits you have left, beside the board — the vignette says "worse", not "how many" if (G.mode === 'splatter' && !G.interlude && !G.drill && !G.bench && !G.done) drawRack(); // ...and never under a trophy screen // courtside weapon rack (hidden during the tennis-rules duel AND the drill, which is played with plain balls) drawDrillHud(); // the triage readout — no-op unless a drill is running drawBenchHud(); // ...and the sprite bench's, same rule drawApproachRing(); drawApexBand(); // the match's timing, drawn — no-ops unless a match is running // THE END OF A RUN, whichever way it ended. Three modes end three different ways and they share // one screen — a wash, a headline, the run's own two numbers, the mark to beat and the gated tap // — because only the WORDS differ, and three copies of this drift apart the first time one moves. // eaten + gore GAME, SET, MASSACRE. n games, n sets // eaten + match BLED OUT. n opponents felled, n games // done + classic MATCH. / BEATEN. the set score // phase C for the death: the summary fades UP over the animation instead of cutting on top of it. { const m = G.match && G.match.felled !== undefined ? G.match : null; const dead = G.mode === 'eaten' && G.eatenT > EATEN_SUMMARY, done = G.done && G.done.t > 0.5; if (dead || done) { const clk = dead ? G.eatenT - EATEN_SUMMARY : G.done.t - 0.5; const fade = Math.min(1, clk / 0.6); const won = !dead && G.done.won; ctx.save(); ctx.globalAlpha = fade; ctx.fillStyle = won ? 'rgba(6,10,8,0.72)' : 'rgba(10,4,6,0.72)'; ctx.fillRect(0, 0, W, H); // The announcement face, at 26 rather than the card's 30: this is the longest string in the // game and at 30 it ran edge to edge on a 270px-wide portrait canvas. const title = dead ? (m ? 'BLED OUT.' : 'GAME, SET, MASSACRE.') : G.done.cup ? 'CHAMPION OF THE DEAD CLUB.' : won ? 'GAME, SET, MATCH.' : 'BEATEN.'; const line = dead ? (m ? 'FELLED ' + m.felled + ' OPPONENT' + (m.felled === 1 ? '' : 'S') + ' · ' + m.games + ' GAMES' : 'SURVIVED ' + G.runGames + ' GAMES · ' + G.sets[0] + ' SETS') : G.done.cup ? 'THE CHAMPION FELL · ' + ((G.match && G.match.games) || G.runGames) + ' GAMES' : G.sets[0] + '–' + G.sets[1] + ' IN SETS · ' + G.runGames + ' GAMES'; ctx.font = B(26); shadowText(title, W / 2, H / 2 - 44, won ? UI.pick : UI.bad); ctx.font = F(9); shadowText(line, W / 2, H / 2 - 14, UI.cream); if (G.newBest) { // a fresh record crows; otherwise show the mark to beat const pulse = 0.55 + 0.45 * Math.abs(Math.sin(clk * 4)); ctx.font = B(20); shadowText('★ NEW BEST ★', W / 2, H / 2 + 2, hexA(UI.warm, pulse)); } else if (G.best && G.best.games > 0) { ctx.font = F(8); shadowText(m ? 'BEST ' + G.best.games + ' FELLED' : G.classic ? 'LONGEST MATCH ' + G.best.games + ' GAMES' : 'BEST ' + G.best.games + ' GAMES · ' + G.best.sets + ' SETS', W / 2, H / 2 + 4, UI.dim); } // gated well past the animation so a panic tap can't skip it (main.js gates the restart the same) if (dead ? G.eatenT > EATEN_RESTART : G.done.t > DONE_RESTART) { ctx.font = F(8); shadowText(IS_TOUCH ? 'TAP TO PLAY AGAIN' : 'PRESS ANY KEY TO PLAY AGAIN', W / 2, H / 2 + 32, UI.warm); } ctx.restore(); } } // Timed freezes swallow every input until they run out, which reads as the game having hung // ("sometimes the controls just feel frozen"). Show the clock instead of hiding it. A modal is // excluded on purpose — that one waits for YOU, and says so. // 'transition' is excluded: startTransition parks overT at 9999 to hold the point flow while its // own scripted timeline runs, and counting that down read as "resuming in 9999". const waitT = G.modal || G.mode === 'eaten' || G.mode === 'transition' ? 0 : Math.max(G.changeover > 0 ? G.changeover : 0, G.phase === 'over' ? G.overT : 0, G.interlude ? G.interlude.introT : 0); if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !G.bench && !(G.mgT > 0) && !G.aim && waitT <= 0.5) { // while pulling, the draw meter owns this slot ctx.font = F(8); const touch = IS_TOUCH, switchHint = touch ? 'TAP THE RACK TO SWITCH' : '1-0 TO SWITCH'; const selW = WEAPONS[slotId(G.weapon)], sel = selW ? selW.label : 'PLAIN'; // "is the selected weapon one you UNLOAD rather than serve" — it used to be `G.weapon === 4`, // which is meaningless once the slots hold whatever you gathered const gunLike = selW && selW.kind === 'burst'; // the only kind you UNLOAD; a laser is served // The DUEL is played under tennis rules — no slingshot, no rack, serve to the box (see the // !G.interlude guards in solveShot and the aim reticle). It was still being told to pull back // to aim, which is the one thing that does nothing there. // after a beat of inaction the apron demo takes over, and it says PULL DOWN in its own words — // so this slot shuts up rather than printing the same instruction a second time 40 px below it const demo = G.mode === 'splatter' && !G.interlude && !G.done && !gunLike && G.idleHintT > TUNE.pullHintAfter; // NO GHOST HAND ON THE TENNIS SERVE — not the idle tap demo, not the apex rider. Oliver: "this // ghost finger over the running serve animation is distracting. this must be removed. it is // not in zombie tennis mode" — and the duel serving fine without a teacher is the argument. // The rally-return hint (drawHitHint) stays: it answers a different struggle and he did not // flag it. drawTapHint/drawApexHint were deleted with their counters. // The gesture-teaching strings ('PULL DOWN TO AIM…', 'TAP TO TOSS…') are GONE — the ghost hand // is the teacher now, and saying the same thing twice in two registers was clutter. This slot // only carries STATE the mime cannot: which weapon the serve fires, and a second serve. const txt = G.mode === 'splatter' && !G.interlude ? (gunLike ? (touch ? `TAP TO UNLOAD THE ${sel}` : `SPACE UNLOADS THE ${sel}`) : !G.aim && slotId(G.weapon) !== 'ball' ? `SERVE FIRES: ${sel} · ${switchHint}` : '') : G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE') : ''; if (txt) shadowText(txt, W / 2, METER_Y(), UI.warm); // same slot the draw meter uses if (demo) drawPullHint(); // show the gesture instead of only naming it } // the tennis struggle hints, each riding the exact failure it answers. // The TAP lesson stays on the incoming ball: it is about a moment, and a moment can only be // taught while it is happening. The FLICK lesson is about a gesture, and a gesture is read in a // PAUSE — so it waits for the serve stance and mimes itself on the apron there, exactly like the // slingshot's. Nothing new is drawn over a live rally. // It belongs to the RALLY MODES, which is what rallyFeel() already means: CLASSIC and ZOMBIE // TENNIS. Not the gore mode, and not gore's OPENER either — that tennis is a prologue you play // for one game before the court turns, and placement is not the skill it is asking for. // (`G.mode === 'tennis'` covered classic AND that prologue, which is exactly the wrong seam; the // match itself is not mode 'tennis' at all, it plays under splatter's permanent interlude.) // Touch only: on a keyboard the aim is held keys and 'SWIPE' would name a gesture that does not // exist there. if (G.mode === 'tennis' && G.phase === 'rally' && G.passStreak >= 2 && ball.active && ball.lastHit === 'O') drawHitHint(); else if (IS_TOUCH && rallyFeel() && G.phase === 'pretoss' && G.idleHintT > TUNE.pullHintAfter && G.flatStreak >= TUNE.flickHintAfter) drawFlickHint(); // The free-floating register: no panel, one fixed slot, shouted. PERFECT, GAME N, DOUBLE KILL, // RETURNED, CHANGEOVER, weapon labels — everything that used to be plain white 12px text sitting // directly on the crowd. It keeps its 1px jitter; the shadow is what makes it legible anywhere. // ...and neither the flash nor the card draws UNDER a modal: the popup owns the screen, and a // band message half-visible through its dim was clutter saying nothing. Their clocks are frozen // behind the freeze too (see update), so both play out in full once the popup is dismissed. // ONE MESSAGE AT A TIME. A flash stacked above a card was two announcements shouting over each // other in the same band; the CARD wins because it is the bigger event (a set, a chapter, a // weapon won) and the flash under it is always a footnote to something you already watched. // The flash's clock keeps running, so it expires quietly rather than surfacing late. const cardUp = G.card && !G.modal && G.card.t > 0 && !(G.card.wait > 0); const flashUp = G.flash && !G.modal && !cardUp; if (flashUp) { // 18, not 24: at the card's near-size a transient one-liner read as a milestone announcement, // and half of what lands in this slot (PERFECT, RETURNED, DOUBLE KILL) is only ever a footnote // to something you already watched happen. The register is right; the volume was not. // On the band's axis, not on its top edge: the flash is the only band citizen with no plate, // so hanging it where a plate's first line would start left it sitting visibly high with // nothing under it. Same ink-centring every card uses — one axis for all three registers. ctx.font = B(18); shadowText(G.flash.text, W / 2, inkCentre([[G.flash.text, B(18), 0]]) + Math.sin(G.flash.t * 20) * 1, '#ffffff'); } // the SET milestone card (the duel's own announcement is a real modal now, not a timed card) if (cardUp) drawCard(G.card); // `wait` holds it OFF the screen (a boss dying under it) // THE RESUMING CLOCK is a band citizen too — same plate, same swipe, same smear. It used to // float bare over the net in its own face and position, which was the last message in the game // that looked like it came from a different game. It is the LOWEST priority of the three: a card // outranks it (the card explains the very pause it is counting) and so does a flash (short, and // always about something that just happened). It swipes in the moment the band frees up. // The 0.5 gate is on STARTING only — once it is on the band it plays down to zero, or the // swipe-out (0.24 s) would be cut off before it ever ran. if (waitT > 0 && (cdT0 || waitT > 0.5) && !cardUp && !flashUp) { cdT0 = cdT0 || waitT; // its own T0: the clock left when the band came free const n = Math.ceil(waitT); slideDraw(cdT0, waitT, dx => cardBody( G.changeover > 0 ? { title: 'CHANGEOVER', sub1: 'RESUMING IN ' + n, color: '#ffffff' } : { title: 'RESUMING IN ' + n, color: '#ffffff' }, dx)); } else cdT0 = 0; if (TUNE.missHud && G.lastMiss && G.idleT - G.lastMiss.at < 3.5) { // last miss reason, live ctx.font = F(8); ctx.fillStyle = '#ff9060'; ctx.fillText(G.lastMiss.text.toUpperCase(), W / 2, H - 28); } if (G.logShow) { // scoreboard tap: recent swing diagnostics, screenshot-friendly const list = G.missLog.slice(-9), lh = 10, top = 40; ctx.fillStyle = 'rgba(8,10,16,0.88)'; ctx.fillRect(6, top, W - 12, list.length * lh + 16); ctx.font = F(7, 0); ctx.textAlign = 'left'; ctx.fillStyle = UI.dim; ctx.fillText('SWING LOG — tap scoreboard to hide', 10, top + 5); const maxCh = Math.floor((W - 20) / 4.3); list.forEach((e, i) => { ctx.fillStyle = e.result === 'HIT' ? '#7fd07f' : '#ff9060'; ctx.fillText((e.at + 's ' + e.result + ' ' + e.reason).slice(0, maxCh), 10, top + 16 + i * lh); }); } if (G.modal) drawModal(G.modal); // last: it is a wall, nothing draws over it ctx.textAlign = 'left'; }