Files
splatter-tennis/render.js
T
wuwiandClaude Opus 5 04ee0174ce Three more chapters, and at game 100 the champion returns everything
The roster ran out at chapter 6 and then repeated itself; game 70 got the
groundskeeper again, bigger. bossArt() now walks nine designs, one per chapter,
and hands out THE CHAMPION on every tenth — the floor(ch/10) term takes his
chapters back out of the count so chapter 11 opens the second lap on the
groundskeeper rather than skipping him.

  70 THE STRINGER — leather apron, bandoliers of gut string, racket frames on
     his back. He takes your lane and puts two dead-straight ones on it.
  80 THE TOURNAMENT DIRECTOR — blazer, lanyard, radio. `adds` is new: he calls
     stewards onto the court while you fight him, which is the one thing a boss
     fight could not do before, since the stage is cleared for it.
  90 THE STRAWBERRY SELLER — tray of cream, apron soaked red, ladle. A tank
     that sprays four at once and then comes for you.
 100 THE CHAMPION — torn whites, a laurel of strapping tape, the trophy still
     in one fist. `rally`: anything under splatter quality comes straight back
     at your head, so he is the one body you have to out-HIT rather than
     out-shoot. `phases` lays three rows over his own as his health falls — he
     holds the baseline and rallies, then runs the line drilling volleys, then
     drops to a charge — and each turn is an event: cry, shake, the stand up.

Thirty-six frames through assets/_regen.py, the mascot's three-batch recipe
(2 stride, 3 wounds, 4 throws). The trap that cost the re-rolls: a throw batch
references its own wound frames, so a wounds batch that fails leaves the throw
sheet drawn against the SEED — two of the four came back half groundskeeper.
Wounds first, verify, then throws.

And the physio, who Oliver reported "does basically nothing but move towards
the net": he kept 4.2 of every 6.3 m he charged, so the fight was always over
before he arrived. `withdraw` is his own number now (0.2) and he charges half
again as fast — measured, he is on top of you in 11 s instead of never — and he
drills three balls a cycle on the way in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 16:08:57 +02:00

2030 lines
133 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
import { cv, ctx } from './dom.js';
import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_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 } from './config.js';
import { P, O, ball, score, crowd, G, court, tour } from './state.js';
import { predictLanding, previewServe, slotId, slotStock } from './gameplay.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)})`;
};
// 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 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.
TUNE.camZ = portrait ? 48 : 42;
TUNE.camY = portrait ? 11 : 9;
TUNE.camF = portrait ? 760 : 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;
export function proj(x, y, z) {
const d = TUNE.camZ + aimCam * TUNE.aimCamZ - z, s = TUNE.camF / d;
return { x: W / 2 + x * s, y: TUNE.horizon + (TUNE.camY + aimCam * TUNE.aimCamY - y) * s, s };
}
// ---------------------------------------------------------------- sprites ----
// assets/<name>.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 <img> 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 = 88;
// 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 };
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_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_run1','o_run2','o_swing1','o_swing2',
'x_ump','x_boyrun1','x_boyrun2','x_boyrun3','x_boyrun4',
'x_crowd','x_crowd2','x_ads', // CENTRE COURT (grass) — also the fallback set
'x_crowd_c','x_crowd_c2','x_ads_c', // TERRE ROUGE (clay)
'x_crowd_h','x_crowd_h2','x_ads_h', // THE SLAB (hard)
'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)
'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.
// No o_run3_f/o_run4_f on purpose: runN reads the MEN'S table, where o_run4 is not
// preloaded, so the opponent runs on two frames whichever tour you picked.
'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_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',
'z_c1_f','z_c2_f','z_c3_f','z_c4_f','z_cw1_f','z_cw2_f','z_cw1b_f','z_cw2b_f',
'scoreboard']) {
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 <img>.
// 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] = img; LOAD.done++; };
img.onerror = () => { if (retried) LOAD.done++; else { retried = true; img.src = url + '&r=1'; } };
img.src = url;
}
// ------------------------------------------------------------------ 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]
});
}
// ---------------------------------------------------------------- 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'];
if (crowdImg) c.drawImage(crowdImg, 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".
if (pt > 0) { c.fillStyle = `rgba(12,8,20,${0.26 * pt})`; c.fillRect(0, 0, W, standsBottom); }
// 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)].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;
// Applause is a real TWO-FRAME animation: x_crowd is the seated sheet (cached above), <crowd>2
// the same stand with every arm up. Columns swap on their own phase so the clap ripples across
// the stands — integer-aligned draws, no vertical offset, because fractional-y nearest-neighbour
// resampling is the boiling that once read as HUD flicker. Clipped above the ad strip so the
// redrawn columns never paint over the boards, and re-tinted per column so a clapping column
// does not pop brighter than the rotted stand around it.
const clapImg = crowdImg && (crowdImg === SPR['x_crowd'] ? SPR['x_crowd2'] : SPR[court.crowd + '2']);
if (crowd.cheer > 0 && crowdImg && clapImg) {
const crowdH = standsBottom - AD_H + 2;
const cols = 16, sw = clapImg.width / cols, cw = W / cols;
const tint = pt > 0 ? `rgba(12,8,20,${0.26 * pt})` : null;
ctx.save(); ctx.beginPath(); ctx.rect(0, 0, W, standsBottom - AD_H); ctx.clip();
for (let i = 0; i < cols; i++) {
if (!(Math.floor(G.idleT * 9 + i * 0.7) % 2)) continue;
ctx.drawImage(clapImg, i * sw, 0, sw, clapImg.height, i * cw, 0, cw + 0.5, crowdH + 2);
if (tint) { ctx.fillStyle = tint; ctx.fillRect(i * cw, 0, cw + 0.5, crowdH + 2); }
}
ctx.restore();
}
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(); }
}
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 ----
function drawStatic(name, x, z, hMeters, bob = 0, flip = false) {
const img = SPR[name]; if (!img) return;
const p = proj(x, 0, z);
const h = hMeters * p.s, w = h * img.width / img.height;
drawShadow(p.x, p.y, w * 0.4, h * 0.04, 0.25);
if (!flip) return ctx.drawImage(img, p.x - w / 2, p.y - h - bob, w, h);
ctx.save(); ctx.translate(p.x, 0); ctx.scale(-1, 1); // the art is drawn facing LEFT
ctx.drawImage(img, -w / 2, p.y - h - bob, 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), 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 h = TUNE.barrelH * (img ? img.height / SPRITE_H : 1);
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));
} 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);
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';
const v = rk ? 'c' : zb.variant; // the racket zombie reuses the turned-CPU sprite (already holds a racket)
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.
let frame = 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 f = zb.hp / (zb.hpMax || TUNE.racketHp);
if (f <= 0.34 && SPR.z_cw2) { name = 'z_cw2'; h = 1.52; } // headless: a head shorter, so the shoulders land where a whole zombie's do
else if (f <= 0.67 && 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';
// ...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 HANDOFF: heights follow the PNG).
if (SPR[name]) h *= SPR[name].height / SPRITE_H;
// feeding: a fast bob over the body instead of a stride. Same sprites, different rhythm.
const bob = zb.feeding ? Math.abs(Math.sin(zb.walkT * 11)) * Math.max(1, p0(zb) * 0.05)
: Math.floor(zb.walkT * 5.2) % 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.
drawStatic(name, zb.x, zb.z, h, bob, zb.face > 0 && !'cfmB'.includes(v));
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 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).
const SK = n => (tour.sfx && SPR[n + tour.sfx]) ? n + tour.sfx : n;
// 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);
const hpx = PLAYER_H * p.s;
// pick sprite
const pre = ch.side === 'P' ? 'p_' : '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';
// Four-frame run where the frames exist (the player), two where they don't (the opponent). Same
// CYCLE rate either way — more frames subdivide the stride, they do not speed it up, exactly as
// ZFRAMES does for the shamblers.
const runN = SPR[pre + 'run4'] ? 4 : 2;
if (ch.pose === 'run') name = pre + 'run' + (Math.floor(ch.runT * 4.5 * 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');
if (ch.pose === 'run') p.y -= Math.floor(ch.runT * 18) % 2; // 1px run bob
const img = SPR[SK(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 = SPR[SK('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 };
}
export function rackHit(nx, ny) { // normalized [0,1] canvas coords -> slot index, or -1
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 -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;
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
if (sel) ctx.lineWidth = 1;
// 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;
// the burst countdown belongs to whichever slot is holding the armed weapon now, not to slot 4
if (G.mgT > 0 && slotId(i) === G.gunId) { ctx.fillStyle = UI.pick; ctx.fillRect(x + 2, y0 + g.h - 2, (g.tw - 4) * Math.min(1, G.mgT / (WEAPONS[G.gunId]?.len || TUNE.mgPickupLen)), 1.5); }
}
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.
const lines = [[title, B(22), 0, color]];
if (sub1) lines.push([sub1, F(8), 30, UI.cream]);
if (sub2) lines.push([sub2, F(8), 44, UI.cream]);
const s = inkSpan(lines);
plate(s.bot - s.top);
const y = Math.round(CARD_MID - (s.top + s.bot) / 2);
for (const [t, f, rel, col] of lines) { ctx.font = f; shadowText(t, W / 2, y + rel, col); }
ctx.textAlign = 'center'; ctx.restore(); // the band's neighbours (flash, countdown) assume centred
}
// The plate, sized to the `h` px of content it carries and centred on the axis. Capped at CARD_H:
// the three-line SET card is what that number was tuned for and it still fills it exactly.
function plate(h) {
const ph = Math.min(CARD_H, 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 lessons, same ghost hand. Tennis has no slingshot to mime, but the testteam
// struggled to grasp the tap ritual too — so the hand TAPS: on the apron when the player sits idle
// in the serve stance, riding the toss when they keep dropping it, and beside the player when
// returns keep sailing past untouched. Each appears on the exact struggle it answers and goes
// away the moment the player does the thing (the counters live in state.G, reset in gameplay).
function drawTapHint() {
const p0 = proj(P.x, 0, P.z);
const ax = Math.round(p0.x) + 20, ay = Math.round(p0.y) + 14; // beside his feet, on the apron
const t = (G.idleHintT - TUNE.pullHintAfter) % 1.6; // hover · press · hold · lift
const press = t < 0.6 ? 0 : t < 0.75 ? (t - 0.6) / 0.15 : t < 0.95 ? 1 : Math.max(0, 1 - (t - 0.95) / 0.2);
ctx.save(); ctx.globalAlpha = 0.9;
if (t >= 0.72 && t < 1.32) drawTapRing(ax, ay, (t - 0.72) / 0.6);
drawGhostHand(ax, ay, press);
ctx.font = F(10); ctx.textAlign = 'center'; ctx.textBaseline = 'top';
shadowText('TAP TO TOSS', ax - 20, ay + 40, UI.warm);
shadowText('TAP AGAIN AT THE TOP', ax - 20, ay + 54, UI.warm);
ctx.restore();
}
// two dropped tosses in a row: the hand rides the ball itself and presses exactly at the apex,
// so the TIMING is the thing being shown, not just the verb
function drawApexHint() {
const b = proj(ball.x, ball.y, ball.z);
const apex = TOSS.y0 + TOSS.vy * TOSS.vy / (2 * TUNE.gravity);
const press = Math.max(0, 1 - Math.abs(ball.y - apex) / 0.45);
ctx.save(); ctx.globalAlpha = 0.9;
if (press > 0.55) drawTapRing(b.x + 4, b.y + 4, 1 - press); // tight at the apex, blooming as it passes
drawGhostHand(b.x + 4, b.y + 4, press);
ctx.font = F(10); ctx.textAlign = 'center'; ctx.textBaseline = 'top';
shadowText('TAP AT THE TOP', b.x, b.y - 28, UI.warm);
ctx.restore();
}
// 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();
}
// 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;
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
// landing rings: white = incoming ball, yellow/red = your shot (red lands out)
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));
}
if (G.outMarker) drawRing(G.outMarker.x, G.outMarker.z, G.outMarker.out ? UI.hot : UI.warm);
// 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 = [
{ z: COURT.netZ - 0.01, f: () => drawStatic('x_ump', UMP_X, COURT.netZ, UMP_H) },
];
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() : BOUNCE_IDLE.oDx);
const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * (mine ? bounceApex() : BOUNCE_IDLE.oApex);
drawBall(bx, by, sv.z);
}
// hot-ball ghost streak: a rally ball above 0.8 pow drags three fading ghosts behind it. Serves
// already have the erasing arc band; this gives the volley its own read. Render-owned visual
// state (like the crowd dots) — gameplay never sees it.
const hot = ball.active && (ball.pow || 0) > 0.8 && G.phase === 'rally' && !ball.collected;
if (hot) { ballHist.push({ x: ball.x, y: ball.y, z: ball.z }); if (ballHist.length > 7) ballHist.shift(); }
else if (ballHist.length) ballHist.length = 0;
if (hot) {
for (let i = 0; i < 3; i++) {
const h = ballHist[ballHist.length - 2 - i * 2]; if (!h) break;
const p = proj(h.x, h.y, h.z);
ctx.globalAlpha = 0.26 - i * 0.08;
ctx.fillStyle = UI.ball;
ctx.beginPath(); ctx.arc(p.x, p.y, Math.max(1, p.s * 0.09 * (0.8 - i * 0.18)), 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); }
// took a ranged hit: a red wash from the screen EDGES inward. Deliberately not a full-screen fill —
// this channel must read as "you are bleeding", not as "something exploded" (that is boomFlash).
if (G.hurtFlash > 0) {
const a = Math.min(1, G.hurtFlash / 0.3) * 0.55, b = 26;
ctx.fillStyle = `rgba(150,10,14,${a.toFixed(3)})`;
ctx.fillRect(0, 0, W, b); ctx.fillRect(0, H - b, W, b);
ctx.fillRect(0, 0, b, H); ctx.fillRect(W - b, 0, b, 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 splat = G.mode !== 'tennis';
const bd = { 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)
if (SPR.scoreboard) ctx.drawImage(SPR.scoreboard, 0, 0, bW, bH); // generated ornate board backdrop
else { // code-drawn fallback until the PNG loads
R(0, 0, bW, bH, bd.face);
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';
const names = splat ? ['YOU', 'THEM'] : ['YOU', 'CPU'];
for (let i = 0; i < 2; 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';
if (splat) { ctx.font = F(8, 0); ctx.fillStyle = bd.dim; ctx.fillText(String(G.sets[i]), 74, cy); } // sets
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) {
// 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.
panel(0.5, bH + 1.5, bW - 1, 14, 3, 'rgba(8,26,18,0.82)', hexA(bd.hi, 0.35));
// 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;
ctx.fillText(`GAME ${G.runGames + 1} · ${G.sets[0]} SET${G.sets[0] === 1 ? '' : 'S'}`, bW - 3, bH + 8);
}
if (splat && 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.mode === 'splatter' && !G.interlude) drawRack(); // courtside weapon rack (hidden during the tennis-rules duel)
// phase C: the summary fades UP over the death animation instead of cutting on top of it
if (G.mode === 'eaten' && G.eatenT > EATEN_SUMMARY) {
const fade = Math.min(1, (G.eatenT - EATEN_SUMMARY) / 0.6);
ctx.save(); ctx.globalAlpha = fade;
ctx.fillStyle = '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.
ctx.font = B(26);
shadowText('GAME, SET, MASSACRE.', W / 2, H / 2 - 44, UI.bad);
ctx.font = F(9);
shadowText('SURVIVED ' + G.runGames + ' GAMES · ' + G.sets[0] + ' SETS', 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(G.eatenT * 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('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 (G.eatenT > EATEN_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.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 && !gunLike && G.idleHintT > TUNE.pullHintAfter;
const tapDemo = G.mode === 'tennis' && G.serveNum !== 2 && G.idleHintT > TUNE.pullHintAfter;
// 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
if (tapDemo) drawTapHint();
}
// the tennis struggle hints: each rides the exact failure it answers (see drawTapHint's block)
if (G.mode === 'tennis' && score.server === 'P' && G.phase === 'toss' && G.tossDrops >= 2 && ball.active)
drawApexHint();
if (G.mode === 'tennis' && G.phase === 'rally' && G.passStreak >= 2 && ball.active && ball.lastHit === 'O')
drawHitHint();
// 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';
}