Files
splatter-tennis/render.js
T
wuwiandClaude Opus 4.8 37e5bfd10f Weapons: a courtside rack you pick from + pixel-art HUD
Tins now accumulate on a courtside rack (cap 3) instead of replacing each other; a PLAIN slot banks specials for later, and GUN is a charge you pop for an auto-fire burst. Tap the rack or press 1-5 to choose what your serve throws. A fresh delivery auto-equips but never overrides a deliberate PLAIN, and the 1:1 sudden-death duel forces plain balls.

HUD gets real art: generated pixel-art weapon icons, an ornate Wimbledon scoreboard PNG backing the live text (no more code-drawn boxes on top), and a bundled Silkscreen pixel font replacing the system serif. Rack tiles get padding, count-over-label alignment, and an infinity marker on the ball.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 16:11:12 +02:00

540 lines
31 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, 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;
}
// ---------------------------------------------------------------- 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);
export function proj(x, y, z) {
const d = TUNE.camZ - z, s = TUNE.camF / d;
return { x: W / 2 + x * s, y: TUNE.horizon + (TUNE.camY - 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',
'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_s1','z_s2','z_s3',
'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 = '#e8e8dc'; 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 = '#e8f03a'; 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) {
const img = SPR['x_boyrun' + (Math.floor(b.runT * 14) % 4 + 1)]; if (!img) return;
const p = proj(b.x, 0, b.z);
const h = BOY_H * p.s, 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 - (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
const h = zb.variant === 'g' ? 2.7 : rk ? 1.85 : 1.8; // the brute towers half again over the crowd
drawStatic('z_' + v + frame, zb.x, zb.z, h, Math.floor(zb.walkT * 5.2) % 2);
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);
}
}
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 ? '#e6ee3a' : '#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 = '#e8f03a'; 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 = '700 8px Silkscreen, monospace'; ctx.fillStyle = sel ? '#e6ee3a' : '#e8f0dc';
ctx.fillText(i === 0 ? '∞' : '×' + stock[i], tx, g.y0 + pad + 5); // ball = ∞, else ×count
ctx.font = '400 6px Silkscreen, monospace'; 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 = '#e6ee3a'; 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();
}
// ------------------------------------------------------------------ frame ----
export function render() {
ctx.setTransform(RES, 0, 0, RES, 0, 0); // logical W x H coordinates at RES density
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
const standsBottom = proj(0, 0, 0).y - 12;
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++) {
const dy = crowd.cheer > 0 ? -Math.random() * 2 : 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 jx = crowd.cheer > 0 ? (Math.random() * 2 - 1) : 0;
const jy = crowd.cheer > 0 ? -Math.random() * 1.5 : 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 ? '#ff5050' : '#f8e048');
// two-stage serve aim: the reticle tracks the thumb; red = the magnet has a zombie locked
if (G.aim && G.mode === 'splatter' && G.phase === 'pretoss') {
const ft = flickTarget(P, G.aim);
if (ft.zt) { // locked: the ring rides the zombie itself, plus a marker over its head
drawRing(ft.zt.x, ft.zt.z, '#ff5050');
const hp = proj(ft.zt.x, 2.2, ft.zt.z);
ctx.fillStyle = '#ff5050'; ctx.fillRect(hp.x - 1, hp.y - 4, 3, 3);
} else if (ft.tx !== undefined) drawRing(ft.tx, ft.tz, '#f8e048');
}
// 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 = '#f8e048'; ctx.fillRect((pp.x | 0) - 1, (pp.y | 0) - 1, 2, 2);
}
if (G.mode !== 'eaten') drawChar(P); // there is no P anymore
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: '#e6ee3a', 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.
const F = (sz, b = 1) => `${b ? 700 : 400} ${sz}px Silkscreen, monospace`;
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)
if (G.mode === 'eaten') { // the run summary — permadeath makes this scoreboard the whole game
ctx.fillStyle = 'rgba(10,4,6,0.72)'; ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#c02020'; ctx.font = 'bold 16px monospace';
ctx.fillText('GAME, SET, MASSACRE.', W / 2, H / 2 - 40);
ctx.fillStyle = '#c8d0dc'; ctx.font = 'bold 10px monospace';
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 = `rgba(248,224,72,${pulse})`; ctx.font = 'bold 11px monospace';
ctx.fillText('★ NEW BEST ★', W / 2, H / 2 + 4);
} else if (G.best && G.best.games > 0) {
ctx.fillStyle = '#8fa0b8'; ctx.font = 'bold 8px monospace';
ctx.fillText('BEST ' + G.best.games + ' GAMES · ' + G.best.sets + ' SETS', W / 2, H / 2 + 4);
}
if (G.eatenT > 0.7) { ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace'; ctx.fillText('TAP TO PLAY AGAIN', W / 2, H / 2 + 26); }
}
if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !(G.mgT > 0)) {
ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace';
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 ? 'RELEASE TO SERVE · DRAG BACK TO CANCEL'
: G.weapon > 0 ? `SERVE FIRES: ${sel} · ${switchHint}`
: touch ? 'TAP TO SERVE · HOLD + DRAG 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 = 'bold 14px monospace'; ctx.fillStyle = '#ffffff';
ctx.fillText(G.flash.text, W / 2, 60 + Math.sin(G.flash.t * 20) * 1);
}
if (G.interlude && G.interlude.introT > 0) { // the duel card: a real pause, front and center
ctx.fillStyle = 'rgba(10,6,8,0.66)'; ctx.fillRect(0, H / 2 - 36, W, 64);
ctx.fillStyle = '#ff5050'; ctx.font = 'bold 18px monospace';
ctx.fillText('SUDDEN DEATH', W / 2, H / 2 - 26);
ctx.fillStyle = '#e8e8dc'; ctx.font = 'bold 8px monospace';
ctx.fillText('ONE POINT · THE LINES ARE LIVE', W / 2, H / 2 - 2);
ctx.fillText('WIN IT FOR THE NEXT TIN', W / 2, H / 2 + 12);
}
if (TUNE.missHud && G.lastMiss && G.idleT - G.lastMiss.at < 3.5) { // last miss reason, live
ctx.font = 'bold 8px monospace'; 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 = 'bold 7px monospace'; ctx.textAlign = 'left';
ctx.fillStyle = '#8fa0b8'; 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);
});
}
ctx.textAlign = 'left';
}