Files
splatter-tennis/render.js
T
wuwiandClaude Opus 5 ff77922e85 Feedback round: fire actually spreads, brutes lurch, crowd stops flickering
- Fire never spread in play. The flame ball's full pierce wiped its own lane, so
  the bodies that would have caught were already gone. It now ignites everything
  it FLIES PAST (TUNE.burnBallR), with a wider ignition and spread radius. One
  flame serve into a scattered 10-wave kills 9, nine burning at once.
- Wounded brutes slid along like decals: every wound stage is a single frame and
  the brute now spends most of its life wounded. They get a procedural lumber
  instead of eight more sprites — translate + squash, no rotation, so the pixels
  stay on the grid, and the amplitudes scale with the projection or the whole
  thing goes sub-pixel at range.
- The "scoreboard flicker" was the crowd: the cheer bounce was per-column
  Math.random() every frame, i.e. TV static, showing through the scoreboard art's
  transparent ornate edges. Now a smooth per-column sine on idleT.
- Ball boys: mopping is the default in splatter mode now, and the boy is half
  again as tall — at 1.2m he read as a distant speck. The running fetch-boy
  survives for tennis, where there is nothing on the grass but a ball.
- Changeover 7s -> 5s, with the mop crossing timed to fill it.
- Save-to-homescreen: a bloody tennis ball, wired up as apple-touch-icon +
  favicon + webmanifest. The Dockerfile now ships the manifest and gives it its
  own MIME type via an exact-match location (a bare `types {}` block would have
  replaced nginx's whole map and broken .js and .png with it).

Benchmarks re-run: CPU returns 67/72 serves (93.1%, above the 11/12 bar — a
20/24 sample in between was flub noise), 30 human-tapped points = 50 hits /
0 whiffs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 05:58:18 +03:00

705 lines
42 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, PLAYER_H, BOY_H, UMP_H, UMP_X, 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 } 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
export const RES = 3; // physical pixels per logical pixel — 3x so phone screens downscale (crisp) instead of upscaling (soft)
export let W = 480, H = 270;
export function fitViewport() { // landscape 480x270, portrait 270x480 with a longer lens
const portrait = innerHeight > innerWidth;
const [w, h] = portrait ? [270, 480] : [480, 270];
if (cv.width !== w * 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
TUNE.camF = portrait ? 580 : 406;
TUNE.horizon = portrait ? 90 : 40;
buildCrowd();
}
ctx.imageSmoothingEnabled = false; // resets on canvas resize
const sc = Math.min(innerWidth / w, innerHeight / h);
cv.style.width = w * sc + 'px'; cv.style.height = h * sc + 'px';
}
addEventListener('resize', fitViewport);
// 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 SPR = {};
for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_swing1','p_swing2',
'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_ads',
'x_boymop1','x_boymop2','x_boymop3','x_boymop4','x_howto', // the mopping boy, and the slingshot tutorial panel
'z_gw2','z_gw3','z_gw4', // the brute comes apart in stages (4/5 hp = z_gw, down to 1/5 = z_gw4)
'z_a1','z_a2','z_a3','z_a4','z_b1','z_b2','z_b3','z_b4','z_c1','z_c2',
'z_f1','z_f2','z_f3','z_f4','z_g1','z_g2','z_gw','z_s1','z_s2','z_s3',
'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;
img.src = 'assets/' + n + '.png';
}
// ------------------------------------------------------------------ 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) {
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);
ctx.drawImage(img, p.x - w / 2, p.y - h - bob, w, h);
}
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.
const mop = b.mop && SPR.x_boymop1;
const img = SPR[(mop ? 'x_boymop' : 'x_boyrun') + (Math.floor(b.runT * (mop ? 8 : 14)) % 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();
}
const ZFRAMES = { a: 4, b: 4, f: 4 }; // 4-frame walk cycles where sheets exist; the rest stay 2
function drawZombie(zb) {
const rk = zb.variant === 'r';
const v = rk ? 'c' : zb.variant; // the racket boss 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
let frame = rk && zb.pose === 'swing' ? 2 : Math.floor(zb.walkT * (zb.variant === 'f' ? 6 : 2.6) * N / 2) % N + 1;
if (zb.variant === 's' && zb.shieldT > 0) frame = 3; // chair dropped — hit it NOW
let h = zb.variant === 'g' ? 2.7 : rk ? 1.85 : 1.8; // the brute towers half again over the crowd
// 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 = '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]) name = stage; else if (SPR.z_gw) name = 'z_gw'; // a missing asset degrades to the last skin that loaded
if (SPR[name]) h *= SPR[name].height / SPRITE_H; // the headless stage really is shorter — don't stretch it back
}
else if ((zb.variant === 'a' || zb.variant === 'b') && zb.hp < TUNE.shamblerHp && SPR['z_' + zb.variant + 'w']) name = 'z_' + zb.variant + 'w'; // gashed shambler
else if (rk && zb.hp <= 1 && 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 (rk && zb.hp === 2 && SPR.z_cw1) name = 'z_cw1';
// Every wound stage is a SINGLE frame, and the brute now spends most of its life wounded — so
// without this it slid toward you like a decal. Rather than eight more sprites, the wrecked
// stages get a procedural lumber: it heaves sideways, sags onto each step and drags the trailing
// foot. Pure translate + squash (no rotation), so the pixels stay on the grid.
const wrecked = zb.variant === 'g' && zb.hp < TUNE.bruteHp && name !== 'z_g' + frame;
if (wrecked) {
const p = proj(zb.x, 0, zb.z), w = zb.walkT * 2.4; // slow and heavy — it is barely holding together
ctx.save();
// amplitudes are in PROJECTED px, so they have to scale with p.s or they vanish at range —
// at 0.035 the whole lurch was sub-pixel out at the far baseline and read as no animation
ctx.translate(p.x + Math.sin(w) * p.s * 0.13, p.y);
ctx.scale(1 + 0.07 * Math.sin(w), 1 - 0.09 * Math.abs(Math.sin(w * 2)));
ctx.translate(-p.x, -p.y);
}
drawStatic(name, zb.x, zb.z, h, wrecked ? Math.max(0, Math.sin(zb.walkT * 4.8)) * 3 : Math.floor(zb.walkT * 5.2) % 2);
if (wrecked) ctx.restore();
if (zb.burnT > 0) drawFlames(zb, h);
if (rk) { // HP bar floating above the mini-boss
const p = proj(zb.x, 2.05, zb.z), w = Math.max(10, p.s * 0.5), x = p.x - w / 2;
ctx.fillStyle = '#300'; ctx.fillRect(x, p.y, w, 3);
ctx.fillStyle = '#e03030'; ctx.fillRect(x, p.y, w * Math.max(0, zb.hp) / TUNE.racketHp, 3);
}
}
// 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
}
if (ch.pose === 'run') name = pre + (Math.floor(ch.runT * 9) % 2 ? 'run1' : 'run2');
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
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);
}
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
if (cool > 1 / TUNE.mgRate - 0.06) { // 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 = 10; // px of breathing room below the rack (floats it up off the screen edge)
function rackGeom() {
const h = TUNE.rackH, n = 5, tw = Math.min(52, (W - 8) / n), rw = tw * n;
return { x0: Math.round((W - rw) / 2), y0: H - h - RACK_BOT, 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;
if (cy < g.y0 || cy > g.y0 + g.h || 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 + pad + 5); // ball = ∞, else ×count
ctx.font = F(6, 0); ctx.fillStyle = sel ? '#f2ead6' : '#8fa89a';
ctx.fillText(labels[i], tx, g.y0 + g.h - pad - 3); // 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);
}
// 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.
function drawModal(m) {
ctx.fillStyle = 'rgba(8,6,10,0.82)'; 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);
if (img) {
const iw = Math.min(W - 24, iconH * img.width / img.height);
ctx.drawImage(img, Math.round(W / 2 - iw / 2), y, Math.round(iw), iconH);
y += iconH + 12;
}
ctx.font = F(14); ctx.fillStyle = m.color || UI.warm;
ctx.fillText(m.title, W / 2, y + 8); y += 22;
ctx.font = F(8, 0); ctx.fillStyle = UI.cream;
for (const l of body) { ctx.fillText(l, W / 2, y + 6); y += 13; }
if (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 ----
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();
// 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 adH = 14, crowdH = standsBottom - adH + 2;
const crowdImg = SPR['x_crowd'];
if (crowdImg) {
const cols = 16, sw = crowdImg.width / cols, dw = W / cols;
for (let c = 0; c < cols; c++) {
// A smooth per-column wave, NOT per-frame noise: random jitter here read as TV static
// through the scoreboard art's transparent ornate edges, which looked like the HUD flickering.
const dy = crowd.cheer > 0 ? -1.6 * (0.5 + 0.5 * Math.sin(G.idleT * 12 + c * 1.7)) : 0;
ctx.drawImage(crowdImg, c * sw, 0, sw, crowdImg.height, c * dw, dy, 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) {
const w = G.idleT * 12 + d.x * 0.4; // same smooth wave for the pre-sprite dot fallback
const jx = crowd.cheer > 0 ? Math.sin(w * 0.7) : 0;
const jy = crowd.cheer > 0 ? -1.2 * (0.5 + 0.5 * Math.sin(w)) : 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 * (adH / ads.height);
for (let x = 0; x < W; x += tw) ctx.drawImage(ads, x, standsBottom - adH, tw, adH);
} else { ctx.fillStyle = pal.board; ctx.fillRect(0, standsBottom - adH, W, adH); }
// the rot dims the stadium: one tint over crowd + boards, fading home during duels
if (pt > 0) { ctx.fillStyle = `rgba(12,8,20,${0.5 * 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
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. Anchored on the
// apron JUST IN FRONT of your feet — over the head it sat right in the lane and the chevrons
// ran straight through it, and down here there is never anything else to collide with.
const hd = proj(P.x, 0, P.z + 1.2);
const mw = 26, mh = 4, mx = Math.round(hd.x - mw / 2);
// advanced up-court the apron runs out, so pin the meter clear of the rack and the hint line
const my = Math.min(Math.round(hd.y), H - TUNE.rackH - RACK_BOT - 28);
ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillRect(mx - 1, my - 1, mw + 2, mh + 2);
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 - 2, 1, mh + 4); // the kill line
if (ft.f >= 0.999 && Math.floor(G.idleT * 12) % 2) { // fully drawn: the meter 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);
zombies.filter(zb => zb.z > COURT.netZ).sort((a, b) => a.z - b.z).forEach(drawZombie);
// 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);
// splatter particles (fire embers ride the same array)
for (const pt of parts) {
const pp = proj(pt.x, pt.y, pt.z);
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) - 1, (pp.y | 0) - 1, 2, 2);
}
// death, in three stages. A: you are still there, thrashing and going red as they crowd in.
// B (from EATEN_BURST): the sprite is gone and you ARE the gore — playerGore() drops the mound.
// C: the summary fades in over it, so the biggest pile on the court stays visible underneath.
if (G.mode !== 'eaten') drawChar(P);
else if (G.eatenT < EATEN_BURST) {
const k = G.eatenT / EATEN_BURST, j = 1 + k * 3; // the shake grows as they pull you down
ctx.save();
ctx.translate((Math.random() * 2 - 1) * j, (Math.random() * 2 - 1) * j * 0.6);
ctx.filter = `sepia(1) saturate(${2 + 7 * k}) hue-rotate(-25deg) brightness(${1 - 0.25 * k})`;
drawChar(P);
ctx.filter = 'none'; // ponytail: unsupported filter just means no tint — the shake still reads
ctx.restore();
}
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
if (G.boomFlash > 0) { ctx.fillStyle = `rgba(255,255,255,${Math.min(1, G.boomFlash / 0.08) * 0.85})`; ctx.fillRect(0, 0, W, H); }
// HUD — Wimbledon manual scoreboard as a 16-bit panel: bevelled bottle-green box (light
// top/left, dark bottom/right), a purple club spine, cream serif names, and the LIVE game
// sunk into a recessed slot — purple for the server, gold digits. Splatter adds a recessed
// status well (kills-to-next-point pips + weapon stock). Hard edges, no radius = retro.
const 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 && 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();
}
if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !(G.mgT > 0)) {
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 ? 'TAP TO SERVE · PULL BACK TO AIM' : 'SPACE SERVES · ARROWS AIM')
: 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, H - TUNE.rackH - RACK_BOT - 17); // sit clear above the rack
}
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)
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';
}