Every animation in this round gets real frames. The procedural squash-and-sway on the wounded brute read as fake on sight, and that gait is where the comedy lives. - Each brute wound stage now has its own 2-frame lurch (z_gw/z_gwb, z_gw2/z_gw2b, ...). The generator gave every B frame a dragged pole that only stage 1 actually carries, so it would have blinked in and out each step — stripped by keeping the largest blob, and where the pole touched the body, by cutting on column density (a torso is dense, a dragged pole is a thin diagonal). - Frames step off DISTANCE COVERED, never elapsed time — boy.dist for ball boys, z/bruteStride for brutes. A time-driven cycle moonwalks the moment speed and frame rate disagree, which is exactly what "he slides faster than the animation suggests" was. Change a speed now and the legs keep up on their own. - One boy, one slot. sendBoy no longer replaces a boy already on court: that was the appearing and disappearing, since every kill teleported him back to the wing mid-stride. New mess is appended to his round instead. He now walks for 10.5s a crossing, and there is a lot of mopping to do. - The applauding crowd is a real two-frame animation (seated / arms up), swapped per column on its own phase. The old "smooth wave" still shifted each column by a FRACTIONAL dy, and nearest-neighbour resampling at a fractional y re-snaps a column's pixel rows every frame — that boiling beside the HUD is what read as the scoreboard flickering. The board art is 99.9% opaque; nothing was ever showing through it. With the crowd calm the frame delta is now 0.0%. Benchmarks: 136/150 serve returns (90.7% ±2.4%, the 11/12 bar is inside one sigma), 30 human-tapped points = 50 hits / 0 whiffs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
706 lines
42 KiB
JavaScript
706 lines
42 KiB
JavaScript
// 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_crowd2','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','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_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;
|
||
// 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[(mop ? 'x_boymop' : 'x_boyrun') + (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();
|
||
}
|
||
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]) {
|
||
// 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] && Math.floor(zb.z / TUNE.bruteStride) % 2 ? b : stage;
|
||
h *= SPR[stage].height / SPRITE_H; // height ALWAYS off the A frame, or the size pops each step
|
||
} else if (SPR.z_gw) name = 'z_gw'; // a missing asset degrades to the last skin that loaded
|
||
}
|
||
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';
|
||
drawStatic(name, zb.x, zb.z, h, Math.floor(zb.walkT * 5.2) % 2);
|
||
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) {
|
||
// 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 * (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';
|
||
}
|