Files
splatter-tennis/render.js
T
wuwiandClaude Opus 5 01498eddad Turn the bodies around, calm the racket zombie, break up the pack
Three from the playtest, all code:

Every side-on sprite is DRAWN facing left, and nothing ever mirrored them, so a
zombie walking right moonwalked — obvious the moment they close on you across
your own half. They face where they last walked now. The front-facing ones (the
turned CPU, the machine, a chapter boss) have no left or right and are left alone.

The racket zombie holds a position and only moves when the ball makes him, so a
time-driven walk cycle flicked him between two frames on the spot. His stride
steps off ground covered, like the brute's — standing still is standing still.

And the pack no longer stands on the body twitching for as long as the summary is
up: once it appears they break up and shamble away up-court. Measured — feeding
at 3s, dispersed to 3m by 6.5s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 22:24:11 +02:00

1083 lines
70 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_GRAB, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, BOUNCE_IDLE, SPRITE_H } from './config.js';
import { P, O, ball, score, crowd, G } from './state.js';
import { predictLanding, flickTarget } from './gameplay.js';
import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js';
// ---------------------------------------------------------------- palette ----
// tennis palette -> drained splatter palette; the transition lerps between them
const PAL = {
tennis: { stands: '#232e42', tierA: '#2a3750', tierB: '#26324a', board: '#0e5c2e',
out: '#295c2d', lawn: '#4cae52', stripe: '#46a54c' },
splatter: { stands: '#181426', tierA: '#201a33', tierB: '#1c172e', board: '#3a1212',
out: '#20331c', lawn: '#5e7747', stripe: '#576f42' },
};
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 PAL.tennis;
if (t === 1) return PAL.splatter;
const m = {};
for (const k in PAL.tennis) m[k] = hexLerp(PAL.tennis[k], PAL.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`;
const hexA = (h, a) => `rgba(${parseInt(h.slice(1, 3), 16)},${parseInt(h.slice(3, 5), 16)},${parseInt(h.slice(5, 7), 16)},${a})`;
// ---------------------------------------------------------------- camera ----
const IS_TOUCH = matchMedia('(pointer: coarse)').matches; // hoisted: a matchMedia call per frame churns GC
// Physical pixels per logical pixel. On an iPhone the canvas ends up ~390 CSS px wide = 1170 DEVICE
// px, so RES 3 (810) was still being upscaled 1.44x by the browser. 4 gets us to 1080, near 1:1.
// THIS IS THE FIRST KNOB TO TURN DOWN if the phone ever stutters: it is the whole fill-rate budget.
export const RES = 4;
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.
export const SAFE = { top: 0, bottom: 0, topCss: 0 };
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();
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;
// horizon RIDES the adaptive height. proj() places the court in ABSOLUTE logical px off this, so
// leaving it fixed while h grew just dumped every extra pixel into a dead green apron between the
// court and the rack. 0.72 of the extra height goes to pushing the court down (the rest becomes
// more stand above it), which keeps the apron about where it was at the old fixed 480.
TUNE.horizon = portrait ? Math.round(45 + (h - 480) * 0.72) : 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.
const SPRV = 64; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
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',
'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',
'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
'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, 5, ...)
'z_chair1','z_chair2','z_chairw','z_chairt','z_chairwt', // boss design 3 (chapter 3, 6, ...)
'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
'scoreboard']) {
const img = new Image();
img.onload = () => SPR[n] = img;
// ?v=SPRV, for the same reason index.html versions main.js. python http.server sends no cache
// headers, so the browser caches sprite responses heuristically — INCLUDING 404s. 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.
img.src = 'assets/' + n + '.png?v=' + SPRV;
}
// ------------------------------------------------------------------ crowd ----
export 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]
});
}
// -------------------------------------------------------------- primitives ----
function line(x1, z1, x2, z2, w = 1) {
const a = proj(x1, 0, z1), b = proj(x2, 0, z2);
ctx.strokeStyle = UI.cream; ctx.lineWidth = w;
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
}
function quadXZ(x1, z1, x2, z2, color) { // filled court-plane rect (screen trapezoid)
const a = proj(x1, 0, z1), b = proj(x2, 0, z1), c = proj(x2, 0, z2), d = proj(x1, 0, z2);
ctx.fillStyle = color; ctx.beginPath();
ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(c.x, c.y); ctx.lineTo(d.x, d.y); ctx.fill();
}
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) { // white-hot core in an orange shell, flickering
ctx.fillStyle = '#ff8010'; ctx.beginPath(); ctx.arc(bp.x, bp.y, r + (Math.floor(G.idleT * 24) % 2 ? 1 : 0), 0, 7); ctx.fill();
ctx.fillStyle = '#ffe8a0'; ctx.beginPath(); ctx.arc(bp.x, bp.y, Math.max(1, r * 0.5), 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, f: 4, d: 4, g: 4, s: 4, c: 4 }; // 4-frame walk cycles where sheets exist; the rest stay 2
// 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.
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 = 1 - zb.windT / TUNE.bossWind, p = proj(zb.x, h * 0.62, zb.z); // bossWind, not machWind: the boss telegraphs faster
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 === '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
: zb.variant === 'g' ? stepFrame(zb.z / (TUNE.bruteStride * 2 / N), N) + 1 // heaviest body: step off DISTANCE
// WALKED, like its 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
let h = zb.variant === 'g' ? 2.7 : zb.variant === 'd' ? 2.15 : rk ? 1.85 : 1.8; // the brute towers half again over the crowd; the steward stands between him and it
if (zb.variant === 'B') h = 1.8 * (zb.scale || 1.5); // ...and a chapter boss towers over the brute
// 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;
if (zb.variant === 'g' && zb.hp < TUNE.bruteHp) { // one arm → both → head gone → ribs out
const stage = ['z_gw4', 'z_gw3', 'z_gw2', 'z_gw'][Math.max(0, Math.min(3, zb.hp - 1))];
if (SPR[stage]) {
// Every wound stage has its OWN two-frame lurch (…w / …wb = the stride swapped). Stepped by
// DISTANCE WALKED, not time — the brute is the slowest thing on court, so a time-driven cycle
// was pure foot-slide. This replaced a procedural squash-and-sway that read as exactly what
// it was: fake. The comedy is in the gait, and the gait has to be drawn.
const b = stage + 'b';
name = SPR[b] && stepFrame(zb.z / TUNE.bruteStride, 2) ? b : stage;
} else if (SPR.z_gw) name = 'z_gw'; // a missing asset degrades to the last skin that loaded
}
else if ('abd'.includes(zb.variant) && zb.hp < (zb.hpMax || 0) && SPR['z_' + zb.variant + 'w']) {
// A wounded body used to freeze on ONE frame and slide across the court — the wound skins were
// single stills. Every skin has its …b twin now (the stride swapped), stepped off the same clock
// as the healthy walk, so losing an arm changes how he walks and not whether he walks.
const w = 'z_' + zb.variant + 'w';
// Same CADENCE as the healthy walk (which runs four frames at 5.2/s), or a wounded body reads
// as frozen next to the crowd it is walking in — it was stepping at half the rate on two frames.
name = SPR[w + 'b'] && stepFrame(zb.walkT * 5.2, 2) ? w + 'b' : w; // gashed shambler, still lurching
}
// 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 f = zb.hp / (zb.hpMax || 1), nStage = A.wounds.length;
const idx = f >= 1 ? 0 : Math.min(nStage, Math.max(1, Math.ceil((1 - f) * nStage)));
const wanted = zb.windT > 0 ? A.throws[Math.min(idx, A.throws.length - 1)]
: idx ? A.wounds[idx - 1]
: A.walk[stepFrame(zb.z / TUNE.bruteStride, 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(zb.z / TUNE.bruteStride, 2)];
if (SPR[stage]) name = stage;
}
// 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 = 1 - zb.windT / TUNE.bossWind, p = proj(zb.x, h * 0.62, zb.z); // bossWind, not machWind: the boss telegraphs faster
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. It was the only floating UI on a body anywhere in the game, it was on screen for
// almost the whole run (a boss respawns every game you win), and nobody knew what it meant —
// while the wound SKINS above already encode exactly the same information in the language every
// other multi-hit enemy uses. The first-sight modal in spawnRacketZombie explains him instead.
}
// 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;
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';
for (let i = 0; i < 5; 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 - 2) * s * 0.05;
const fy = p.y - (0.2 + i * 0.15 + 0.25 * k) * hMeters * s - Math.abs(Math.sin(ph * 1.6)) * s * 0.08;
ctx.fillStyle = i === 0 && k > 0.5 ? '#ffe8a0' : col; // one white-hot core tongue while it rages
ctx.fillRect(fx | 0, fy | 0, Math.max(1, s * 0.045), Math.max(2, s * (0.10 + 0.07 * Math.abs(Math.sin(ph * 2)))));
}
}
function drawRing(x, z, color) {
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)), ry = Math.max(1, Math.round(p.s * 0.12));
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'];
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: 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);
}
function drawChar(ch) {
const receiverWaiting = G.phase === 'pretoss' && ch.side !== score.server && ch.pose === 'idle';
const sway = receiverWaiting ? Math.sin(G.idleT * 3.2) : 0;
const p = proj(ch.x + sway * 0.12, 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') {
const w = G.idleT * BOUNCE_IDLE.tempo; // same phase as the bouncing ball
const rising = Math.sin(w) * Math.cos(w) > 0;
name = pre + (rising ? 'bounce2' : 'bounce'); // hand up while the ball rises, pat down exactly at the apex
}
// 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);
if (receiverWaiting) name = pre + (sway > 0 ? 'run1' : 'run2'); // split-step shuffle
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
// Auto-fire: the idle sprite carries a racket at his hip AND drawMgRacket puts one in his hands —
// two rackets. The run frames are the racket-less art, so he jogs on the spot instead.
if (ch.side === 'P' && ch.pose === 'idle' && G.mode === 'splatter' && (G.horde || G.mgT > 0))
name = pre + 'run' + (Math.floor(G.idleT * 3 * runN) % runN + 1);
const img = SPR[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['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);
}
function drawMgRacket() { // horde: the player braces the racket forward and up-court, machine-gun style
const hip = proj(P.x + 0.15, 1.05, P.z);
const tip = proj(P.x, 0.95, P.z - 1.35); // pointing up-court, where the pellets stream out
ctx.strokeStyle = '#d8d8d8'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(hip.x, hip.y); ctx.lineTo(tip.x, tip.y); ctx.stroke();
ctx.strokeStyle = '#b07030'; ctx.lineWidth = 1.5; // the head, aimed forward
ctx.beginPath(); ctx.ellipse(tip.x, tip.y, Math.max(2, tip.s * 0.14), Math.max(3, tip.s * 0.22), 0, 0, 7); ctx.stroke();
const cool = G.horde ? G.horde.mgT : G.mgCool; // horde or GUN-tin pickup — same racket
const rate = G.horde ? G.horde.mgRate || TUNE.mgRate : TUNE.mgRate;
if (cool > 1 / rate - 0.03) { // muzzle flash right after a shot
ctx.fillStyle = '#fff0a0'; ctx.beginPath(); ctx.arc(tip.x, tip.y, Math.max(2, tip.s * 0.12), 0, 7); ctx.fill();
}
}
// 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)
export const METER_Y = () => H - TUNE.rackH - RACK_BOT - 17 - SAFE.bottom; // the draw meter's fixed bottom slot
function rackGeom() {
const h = TUNE.rackH, n = 5, tw = Math.min(64, (W - 4) / n), rw = tw * n;
return { x0: Math.round((W - rw) / 2), y0: H - h - RACK_BOT - SAFE.bottom, tw, h, n, 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;
return Math.max(0, Math.min(g.n - 1, ((cx - g.x0) / g.tw) | 0));
}
function drawRack() {
const g = rackGeom(), pad = 3;
const stock = [Infinity, G.bombs, G.triples, G.flames, G.guns];
const labels = ['BALL', 'BOMB', 'TRIPLE', 'FIRE', 'GUN'];
const WICONS = ['w_ball', 'w_bomb', 'w_triple', 'w_fire', 'w_gun']; // generated pixel-art icons
ctx.save(); ctx.lineWidth = 1;
for (let i = 0; i < g.n; i++) {
const x = g.x0 + i * g.tw, sel = G.weapon === i, dim = stock[i] <= 0 && i > 0;
ctx.fillStyle = sel ? '#123a24' : '#0a2418'; ctx.fillRect(x + 1, g.y0 + 1, g.tw - 2, g.h - 2); // sunk slot
ctx.strokeStyle = sel ? UI.pick : '#05160e'; ctx.strokeRect(x + 1.5, g.y0 + 1.5, g.tw - 3, g.h - 3); // gold frame when live
// icon: left, vertically centred, padded off the edge
const box = g.h - pad * 2, icx = x + pad + box / 2, icy = g.y0 + g.h / 2;
ctx.globalAlpha = dim ? 0.35 : 1;
const spr = SPR[WICONS[i]];
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(i === 0 ? '∞' : '×' + stock[i], tx, g.y0 + g.h * 0.33); // ball = ∞, else ×count
ctx.font = F(6, 0); ctx.fillStyle = sel ? '#f2ead6' : '#8fa89a';
ctx.fillText(labels[i], tx, g.y0 + g.h * 0.72); // weapon label, under its count
ctx.globalAlpha = 1;
if (i === 4 && G.mgT > 0) { ctx.fillStyle = UI.pick; ctx.fillRect(x + 2, g.y0 + g.h - 2, (g.tw - 4) * Math.min(1, G.mgT / TUNE.mgPickupLen), 1.5); } // burst countdown
}
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.
function drawCard(title, sub1, sub2, color) {
ctx.fillStyle = 'rgba(10,6,8,0.66)'; ctx.fillRect(0, H / 2 - 36, W, 64);
ctx.textAlign = 'center';
ctx.fillStyle = color; ctx.font = F(16); ctx.fillText(title, W / 2, H / 2 - 26);
ctx.fillStyle = UI.cream; ctx.font = F(8);
if (sub1) ctx.fillText(sub1, W / 2, H / 2 - 2);
if (sub2) ctx.fillText(sub2, W / 2, H / 2 + 12);
}
// Sit in the serve stance doing nothing and the gesture demonstrates itself: a ghost thumb slides
// BACK from the player while chevrons grow FORWARD up the court, then it releases and repeats.
// Pulling is the whole game in splatter mode, and a line of text was carrying that alone.
function drawPullHint() {
const cyc = 1.9, t = (G.idleHintT - TUNE.pullHintAfter) % cyc;
const draw = Math.min(1, t / 1.15); // the pull itself, then a beat held at full
const gone = t > 1.45; // released — the thumb lifts off
const anchor = proj(P.x, 0, P.z + 0.6);
const ax = Math.round(anchor.x), ay = Math.round(Math.min(anchor.y + 6, METER_Y() - 26));
const len = 30 * draw;
ctx.save();
ctx.globalAlpha = gone ? 0 : 0.85;
// the dotted track the thumb travels, straight down-screen = "pull back"
ctx.fillStyle = UI.dim;
for (let d = 6; d < 34; d += 6) ctx.fillRect(ax - 1, ay + d, 2, 2);
// chevrons growing UP-court as the pull grows — the inverse relationship is the whole lesson
ctx.fillStyle = UI.warm;
for (let i = 0; i < 3; i++) {
const u = (i + 1) / 4;
if (draw < u * 0.9) continue;
const cy = ay - 8 - i * 7, s = 4;
ctx.beginPath();
ctx.moveTo(ax, cy - s); ctx.lineTo(ax + s, cy + s * 0.7); ctx.lineTo(ax - s, cy + s * 0.7);
ctx.fill();
}
// the ghost thumb
ctx.globalAlpha = gone ? 0 : 0.9;
ctx.fillStyle = UI.cream;
ctx.beginPath(); ctx.arc(ax, ay + len, 5, 0, 7); ctx.fill();
ctx.fillStyle = 'rgba(0,0,0,0.45)';
ctx.beginPath(); ctx.arc(ax, ay + len, 2, 0, 7); ctx.fill();
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) + 22 + 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 = F(14); ctx.fillStyle = m.color || UI.warm;
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 += 22;
ctx.font = F(8, 0); ctx.fillStyle = UI.cream;
body.forEach((l, i) => { ctx.fillText(l, W / 2 + dx(i + 1, -1), y + 6); 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); ctx.fillStyle = UI.warm;
ctx.fillText(IS_TOUCH ? 'TAP TO CONTINUE' : 'PRESS ANY KEY', W / 2, Math.round(H / 2 + blockH / 2 + 22));
}
ctx.textBaseline = 'top';
}
// ------------------------------------------------------------------ frame ----
const AD_H = 14; // px tall the courtside ad strip is drawn — also the ceiling for centred HUD text
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);
}
const pal = palette(), pt = paletteT();
// CLEAR THE WHOLE FRAME to the apron colour first. There was no clear at all: the stands cover the
// top and the out-area quad covers the middle, but the quad only reaches z=30, and below where that
// projects the canvas was simply TRANSPARENT — you saw the page through it as a duller band across
// the bottom of the screen. Oversized by 8px so a camera shake can never expose an edge.
ctx.fillStyle = pal.out;
ctx.fillRect(-8, -8, W + 16, H + 16);
pageBackground(pal.out); // ...and the page behind it matches, for the rounded display corners
// stands: the generated crowd, drawn in columns so it can BOUNCE when it cheers.
// sit the ad strip flush on the court's back edge (the out-area quad below reaches z=-6);
// pinning to that projected line keeps the boards clear of the lawn in BOTH orientations
// (a fixed baseline offset over-/under-shoots because camF differs portrait vs landscape).
const standsBottom = proj(0, 0, -6).y;
const crowdH = standsBottom - AD_H + 2;
const crowdImg = SPR['x_crowd'];
if (crowdImg) {
// Applause is a real TWO-FRAME animation now: x_crowd is the seated sheet, x_crowd2 the same
// stand with every arm up. Columns swap between them on their own phase, so the clap ripples
// across the stands. Crucially there is NO vertical offset any more — the old bounce shifted
// each column by a FRACTIONAL dy, and nearest-neighbour resampling of a scaled image at a
// fractional y makes its pixel rows re-snap every frame. That boiling, right beside the
// scoreboard, is what read as the HUD flickering. Integer-aligned draws don't shimmer.
const clapImg = SPR['x_crowd2'];
const cols = 16, sw = crowdImg.width / cols, dw = W / cols;
for (let c = 0; c < cols; c++) {
const clapping = crowd.cheer > 0 && clapImg && Math.floor(G.idleT * 9 + c * 0.7) % 2;
const img = clapping ? clapImg : crowdImg;
ctx.drawImage(img, c * sw, 0, sw, img.height, c * dw, 0, dw + 0.5, crowdH + 2);
}
} else { // fallback until the image loads: the old tier stripes + dots
ctx.fillStyle = pal.stands; ctx.fillRect(0, 0, W, standsBottom);
for (let r = 0; r < 16; r++) { ctx.fillStyle = r % 2 ? pal.tierA : pal.tierB; ctx.fillRect(0, 6 + r * 5.5, W, 5.5); }
for (const d of crowd.dots) {
// dot fallback (pre-sprite): whole-pixel hops only, same no-shimmer rule as above
const jx = 0;
const jy = crowd.cheer > 0 && Math.floor(G.idleT * 9 + d.x * 0.2) % 2 ? -2 : 0;
ctx.fillStyle = d.c; ctx.fillRect(d.x + jx | 0, d.y + jy | 0, 2, 2);
}
}
// courtside advertising: tiled sponsor panels at native aspect (never half-cut text)
const ads = SPR['x_ads'];
if (ads) {
const tw = ads.width * (AD_H / ads.height);
for (let x = 0; x < W; x += tw) ctx.drawImage(ads, x, standsBottom - AD_H, tw, AD_H);
} else { ctx.fillStyle = pal.board; ctx.fillRect(0, standsBottom - AD_H, W, AD_H); }
// the rot dims the stadium: one tint over crowd + boards, fading home during duels
// the rot dims the stadium — but 0.5 on top of an already-dark crowd sheet buried the stands in
// splatter mode, where you spend most of the game. 0.26 still reads as "the lights went wrong".
if (pt > 0) { ctx.fillStyle = `rgba(12,8,20,${0.26 * pt})`; ctx.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, '#e8e8dc'); // perimeter border line
quadXZ(-dw, 0, dw, COURT.L, pal.lawn); // lawn (doubles court)
for (let z = 0; z < COURT.L; z += 2.97) quadXZ(-dw, z, dw, Math.min(z + 1.49, COURT.L), pal.stripe); // mow stripes
// 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
// 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);
ctx.fillStyle = d.scorch ? `rgba(28,22,16,${d.a})` : `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();
if (l) drawRing(l.x, l.z, 'rgba(255,255,255,0.85)');
}
if (G.outMarker) drawRing(G.outMarker.x, G.outMarker.z, G.outMarker.out ? UI.hot : UI.warm);
// slingshot aim. Three readings, all of them power: a chevron trail running down the lane, an
// arrowhead on the target, and a notched meter over your head. The notch is the point of the
// whole thing — it puts the kill threshold on screen instead of leaving it to be discovered.
if (G.aim && G.mode === 'splatter' && !G.interlude && (G.phase === 'pretoss' || G.phase === 'toss' || G.phase === 'rally')) {
const ft = flickTarget(P, G.aim);
const tx = ft.zt ? ft.zt.x : ft.tx, tz = ft.zt ? ft.zt.z : ft.tz;
const kills = ft.q >= TUNE.zSplatQ;
// cool blue-white = this pull 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.
const col = !kills ? '#9db4d0' : ft.zt ? UI.hot : UI.warm;
if (tx !== undefined) {
const a = proj(P.x, 1.4, P.z), b = proj(tx, 0.35, tz);
const dx = b.x - a.x, dy = b.y - a.y, len = Math.hypot(dx, dy) || 1;
const ux = dx / len, uy = dy / len, px = -uy, py = ux; // unit along the lane, and its normal
// (proj is a true projective map, so a straight world line stays straight on screen — one
// screen direction is correct for every chevron along it, only the SIZE has to change)
const chevron = (c, size, alpha) => {
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.moveTo(c.x + ux * size, c.y + uy * size);
ctx.lineTo(c.x - ux * size * 0.4 + px * size, c.y - uy * size * 0.4 + py * size);
ctx.lineTo(c.x - ux * size * 0.4 - px * size, c.y - uy * size * 0.4 - py * size);
ctx.fill();
};
// Chevrons stepped along the lane in WORLD space, each sized by its own projection scale,
// so they shrink into the distance like everything else on the court. They also fade OUT
// toward the target: solid at your racket, thinning up the lane, so the trail reads as
// something leaving your hand rather than as a label on the zombie. More of them, and
// fatter, the harder you draw.
const n = Math.round(3 + ft.f * 5);
ctx.fillStyle = col;
for (let i = 1; i <= n; i++) {
const u = i / (n + 1);
const c = proj(P.x + (tx - P.x) * u, 1.4 + (0.35 - 1.4) * u, P.z + (tz - P.z) * u);
chevron(c, Math.max(1.5, c.s * (0.07 + ft.f * 0.09)), 1 - 0.7 * u);
}
chevron(b, Math.max(2.5, b.s * (0.10 + ft.f * 0.11)), 1); // the arrowhead lands ON the target
ctx.globalAlpha = 1;
drawRing(tx, tz, col);
}
// The draw meter: it fills as you pull and the notch marks the kill line. A fixed BAR ACROSS
// THE BOTTOM, ~90% of the width — it used to be a 26x4 px stub floating on the apron by your
// feet, which is nothing at arm's length with a thumb over it. Fixed position also means it
// never wanders behind the chevrons or off the top of the court.
const mw = Math.round(W * 0.9), mh = 10, mx = Math.round((W - mw) / 2);
const my = METER_Y();
ctx.fillStyle = 'rgba(0,0,0,0.62)'; ctx.fillRect(mx - 2, my - 2, mw + 4, mh + 4);
ctx.fillStyle = 'rgba(255,255,255,0.16)'; ctx.fillRect(mx, my, mw, mh); // empty channel
ctx.fillStyle = col; ctx.fillRect(mx, my, Math.round(mw * ft.f), mh);
const notch = Math.round(mw * (TUNE.zSplatQ - TUNE.pullQMin) / (1 - TUNE.pullQMin));
ctx.fillStyle = '#ffffff'; ctx.fillRect(mx + notch, my - 3, 2, mh + 6); // the kill line
if (ft.f >= 0.999 && Math.floor(G.idleT * 12) % 2) { // fully drawn: the whole bar flashes
ctx.fillStyle = '#ffffff'; ctx.fillRect(mx, my, mw, mh);
}
}
// 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(); 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());
// server idly bouncing the ball while waiting
if (G.phase === 'pretoss') {
const sv = score.server === 'P' ? P : O;
const bx = sv.x - BOUNCE_IDLE.dx * (sv.side === 'P' ? 1 : -1);
const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * BOUNCE_IDLE.apex;
drawBall(bx, by, sv.z);
}
// 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;
}
ctx.fillStyle = 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 pellets: flat tracers with a tiny ground shadow
for (const pe of pellets) {
const sh = proj(pe.x, 0, pe.z); drawShadow(sh.x, sh.y, Math.max(1, sh.s * 0.04), Math.max(1, sh.s * 0.02), 0.25);
const pp = proj(pe.x, pe.y, pe.z);
ctx.fillStyle = UI.warm; ctx.fillRect((pp.x | 0) - 2, (pp.y | 0) - 2, 4, 4); // fat round — it hits like one
}
// the chapter boss's shockwave: a ring of blood mist blown out through the crowd noise
if (G.bossBurst) {
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)
if ((G.horde || G.mgT > 0) && G.mode === 'splatter') drawMgRacket(); // rambo racket over the player
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', slot: '#0b2b1c', slotSvr: '#2b1a46',
spine: '#472a70', spineHi: '#5f3d94', cream: '#f2ead6', dim: '#89a08f', gold: UI.pick, engHi: '#2c7a4f' };
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) { // which game you're in, permanently — the 1.1s GAME N flash was the only place it showed
ctx.textAlign = 'right'; ctx.font = F(6, 0); ctx.fillStyle = bd.dim;
ctx.fillText('GAME ' + (G.runGames + 1), bW, bH + 7);
}
if (splat && TUNE.killPips && TUNE.killsPerPoint > 1) { // kills-to-next-point pips, just under the board — no box
ctx.textAlign = 'left'; ctx.font = F(6, 0); ctx.fillStyle = bd.dim; ctx.fillText('NEXT', 2, bH + 7);
for (let k = 0; k < TUNE.killsPerPoint; k++) {
ctx.beginPath(); ctx.arc(32 + k * 8, bH + 7, 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);
ctx.fillStyle = UI.bad; ctx.font = F(14);
ctx.fillText('GAME, SET, MASSACRE.', W / 2, H / 2 - 40);
ctx.fillStyle = UI.cream; ctx.font = F(9);
ctx.fillText('SURVIVED ' + G.runGames + ' GAMES · ' + G.sets[0] + ' SETS', W / 2, H / 2 - 14);
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.fillStyle = hexA(UI.warm, pulse); ctx.font = F(10);
ctx.fillText('★ NEW BEST ★', W / 2, H / 2 + 4);
} else if (G.best && G.best.games > 0) {
ctx.fillStyle = UI.dim; ctx.font = F(8);
ctx.fillText('BEST ' + G.best.games + ' GAMES · ' + G.best.sets + ' SETS', W / 2, H / 2 + 4);
}
// 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.fillStyle = UI.warm; ctx.font = F(8); ctx.fillText('TAP TO PLAY AGAIN', W / 2, H / 2 + 26); }
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 (waitT > 0.5) {
// Kept clear of the ad strip — centred on H alone it landed right on the boards, which is the
// busiest band on the screen. The strip's own projected line is the ceiling, so it stays legible
// whatever the phone's height does to the layout. A 1px shadow, not a box, carries it over the crowd.
const txt = 'RESUMING IN ' + Math.ceil(waitT);
const y = Math.min(Math.round(H * 0.34), Math.round(proj(0, 0, -6).y) - AD_H - 16);
ctx.font = F(10);
ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillText(txt, W / 2 + 1, y + 1);
ctx.fillStyle = hexA(UI.warm, 0.92); ctx.fillText(txt, W / 2, y);
}
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.fillStyle = UI.warm; ctx.font = F(8);
const touch = IS_TOUCH, switchHint = touch ? 'TAP THE RACK TO SWITCH' : '1-5 TO SWITCH';
const sel = ['PLAIN', 'BOMB', 'TRIPLE', 'FIRE', 'GUN'][G.weapon];
const txt = G.mode === 'splatter'
? (G.weapon === 4 ? (touch ? 'TAP TO UNLOAD THE GUN' : 'SPACE UNLOADS THE GUN')
: G.aim ? 'PULL BACK: FURTHER = HARDER'
: G.weapon > 0 ? `SERVE FIRES: ${sel} · ${switchHint}`
: touch ? 'PULL BACK TO AIM · RELEASE TO FIRE' : 'DRAG BACK TO AIM · SPACE SERVES')
: G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE')
: (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE');
ctx.fillText(txt, W / 2, METER_Y()); // same slot the draw meter uses
// and after a beat of inaction, show the gesture instead of only naming it
if (G.mode === 'splatter' && !G.interlude && G.weapon !== 4 && G.idleHintT > TUNE.pullHintAfter) drawPullHint();
}
if (G.flash) {
ctx.font = F(12); ctx.fillStyle = '#ffffff';
ctx.fillText(G.flash.text, W / 2, 60 + Math.sin(G.flash.t * 20) * 1);
}
// the SET milestone card (the duel's own announcement is a real modal now, not a timed card)
if (G.card && G.card.t > 0 && !(G.card.wait > 0)) // `wait` holds it OFF the screen (a boss dying under it)
drawCard(G.card.title, G.card.sub1, G.card.sub2, G.card.color);
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';
}