The court screen picks WHO as well as WHERE now: two tiles above the court rows, men's or women's singles, remembered like the court is. No new stage — three rows and a pair of buttons already fill a phone, so the tiles go side by side and the tap count from splash to play does not change. Everything downstream is one line: drawChar already built every frame name off a single prefix, so SK() appends '_f' when the women's tour is picked and falls straight through to the men's frame for anything without a twin. The horde, the officials and the ball boys are shared — they are staff, not the draw — but the turned CPU is not: a male corpse opposite a female player reads as a bug. Her frames came back as a different woman in every batch — blonde in one, auburn in the next, greyscale line art in a third — because each was anchored on the MAN whose pose it was. "newart" repaints the character and "state" is welded to the corpse palette, so neither could say "the same living woman, a new pose". That mode now exists, and every batch anchors on her own settled frame. The death is drawn to frame 3 and recoloured after it. The generator will not draw a dead woman in any wording worth using; frames 4 and 5 survive that because by then she is flat and then a heap of cloth, so they are the men's drawing with the blue shorts painted into her white (see assets/_die_f.py). The collapse still falls 224/159/117/103/43 straight through the handoff. Her first bounce frames reached to the shoulder, which is a catching pose, not patting a ball — redrawn low rather than bending BOUNCE_IDLE to fit a wrong drawing, so both tours share one apex and the ball meets her palm. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1633 lines
108 KiB
JavaScript
1633 lines
108 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_GRAB, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW } from './config.js';
|
||
import { P, O, ball, score, crowd, G, court, tour } from './state.js';
|
||
import { predictLanding, previewServe, slotId, slotStock } from './gameplay.js';
|
||
import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js';
|
||
|
||
// ---------------------------------------------------------------- palette ----
|
||
// tennis palette -> drained splatter palette; the transition lerps between them.
|
||
// Both halves now come from the SELECTED COURT (config.COURTS) — see the court table for why each
|
||
// court carries a pair rather than one palette and a global rot filter.
|
||
const hexLerp = (a, b, t) => {
|
||
const c = h => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
|
||
const [ar, ag, ab] = c(a), [br, bg, bb] = c(b);
|
||
return `rgb(${Math.round(ar + (br - ar) * t)},${Math.round(ag + (bg - ag) * t)},${Math.round(ab + (bb - ab) * t)})`;
|
||
};
|
||
// how far into the rot we are, 0 (tennis) .. 1 (splatter) — duels lerp it back home
|
||
function paletteT() {
|
||
return G.mode === 'tennis' ? 0 : G.mode === 'splatter' ? 1 - G.duelLerp : Math.min(1, G.transT / 6);
|
||
}
|
||
function palette() {
|
||
const t = paletteT();
|
||
if (t === 0) return court.tennis;
|
||
if (t === 1) return court.splatter;
|
||
const m = {};
|
||
for (const k in court.tennis) m[k] = hexLerp(court.tennis[k], court.splatter[k], t);
|
||
return m;
|
||
}
|
||
|
||
// Every string on the canvas goes through this. Bare `monospace` resolves to Courier — a slab
|
||
// SERIF — which is what made the HUD look generic; Silkscreen is the bundled pixel face.
|
||
const F = (sz, b = 1) => `${b ? 700 : 400} ${sz}px Silkscreen, monospace`;
|
||
// ...and the ANNOUNCEMENT face. Two registers, and which one a message uses is its category:
|
||
// Silkscreen — the HUD, the rack, the control hints, RESUMING IN. Things you read at leisure.
|
||
// Bangers — SET, CHAPTER, EPIC!, PERFECT, GAME N. Things shouted at you.
|
||
// It was one face for everything, so a milestone and a control hint looked like the same object at
|
||
// two sizes. Falls back to Silkscreen (not monospace) if the TTF ever fails to load.
|
||
const B = sz => `400 ${sz}px Bangers, Silkscreen, monospace`;
|
||
const hexA = (h, a) => `rgba(${parseInt(h.slice(1, 3), 16)},${parseInt(h.slice(3, 5), 16)},${parseInt(h.slice(5, 7), 16)},${a})`;
|
||
// Free-floating text has no panel behind it, so it has to carry its own contrast: over the crowd,
|
||
// over sunlit clay, over blood. A 1px drop shadow does it and a box would cover the artwork.
|
||
// (RESUMING IN hand-rolled exactly this; now everything that floats shares it — the loud face AND
|
||
// the quiet one. The two exceptions are the SCOREBOARD and the RACK: both already sit on their own
|
||
// dark panels, and a 1px shadow under 6-8px type on a flat dark fill only smears it.)
|
||
function shadowText(txt, x, y, fill) {
|
||
ctx.fillStyle = 'rgba(0,0,0,0.62)';
|
||
ctx.fillText(txt, x + 1, y + 1);
|
||
ctx.fillStyle = fill;
|
||
ctx.fillText(txt, x, y);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- camera ----
|
||
const IS_TOUCH = matchMedia('(pointer: coarse)').matches; // hoisted: a matchMedia call per frame churns GC
|
||
// Physical pixels per logical pixel. On an iPhone the canvas ends up ~390 CSS px wide = 1170 DEVICE
|
||
// px, so RES 3 (810) was still being upscaled 1.44x by the browser. 4 gets us to 1080, near 1:1.
|
||
// THIS IS THE FIRST KNOB TO TURN DOWN if the phone ever stutters: it is the whole fill-rate budget.
|
||
export const RES = 4;
|
||
export let W = 480, H = 270;
|
||
|
||
// Split responsibility, and the split is deliberate:
|
||
// TOP — index.html insets the canvas element below the status bar. SAFE.top stays 0; adding an
|
||
// offset here as well would subtract the notch twice.
|
||
// BOTTOM — the canvas deliberately runs to the PHYSICAL bottom edge so the court art fills the
|
||
// screen (the home indicator is a slim bar, not opaque UI). Only the interactive RACK is
|
||
// lifted clear of it, by SAFE.bottom below.
|
||
export const SAFE = { top: 0, bottom: 0, topCss: 0 };
|
||
// Logical px of slack under a full draw, on top of the rack: the home indicator (~34 CSS px) plus
|
||
// enough that a pull ENDING near the edge is still nowhere near STARTING there. See fitViewport.
|
||
const PULL_ROOM = 30;
|
||
function measureSafe(scale) {
|
||
const el = document.getElementById('safe');
|
||
if (!el) return;
|
||
const cs = getComputedStyle(el);
|
||
SAFE.bottom = Math.round((parseFloat(cs.paddingBottom) || 0) / scale);
|
||
SAFE.topCss = Math.round(parseFloat(cs.paddingTop) || 0); // CSS px — for the page background below
|
||
}
|
||
|
||
// The page background behind the canvas. It shows in exactly two places: the strip above the canvas
|
||
// under the status bar, and the slivers a phone's ROUNDED DISPLAY CORNERS clip out of the canvas
|
||
// itself. A flat dark body therefore drew a visible dark notch where the bottom corners curve in.
|
||
// So: dark down to the top inset, then the LIVE out-area colour for the rest — live because `pal.out`
|
||
// lerps between the tennis and splatter palettes, so any fixed hex would only match in one mode.
|
||
// Written in resolved px (not env()) so the declaration can never be invalid and drop the background.
|
||
let bgLast = '';
|
||
function pageBackground(out) {
|
||
const t = SAFE.topCss;
|
||
const css = `linear-gradient(#10141c 0px, #10141c ${t}px, ${out} ${t}px, ${out} 100%)`;
|
||
if (css === bgLast) return; // only touch the DOM when it actually changed
|
||
bgLast = css;
|
||
// documentElement, NOT body: the ROOT element's background is what paints the viewport canvas.
|
||
// index.html gives html a background, so body's never propagates — setting it there painted only
|
||
// body's own box, which has no height (the canvas is position: fixed) and so painted nothing.
|
||
document.documentElement.style.background = css;
|
||
}
|
||
|
||
// The CSS box is only a PROPOSAL. Added to the homescreen, iOS resolves 100dvh once at launch against
|
||
// a provisional viewport and never re-resolves it — no resize, no layout change, so nothing (not even
|
||
// a ResizeObserver) fires and the canvas stays a strip short of the bottom until you rotate the phone
|
||
// and back. visualViewport is the authority on what is actually visible, so measure that and pin the
|
||
// height ourselves. Re-read on every fit, so a late safe-area inset or a rotation just re-pins it.
|
||
function fitBox() {
|
||
const el = document.getElementById('safe');
|
||
const top = el ? parseFloat(getComputedStyle(el).paddingTop) || 0 : 0; // the inset the canvas starts below
|
||
const want = Math.round((window.visualViewport?.height || innerHeight) - top);
|
||
if (want > 120 && Math.abs(cv.clientHeight - want) > 1) cv.style.height = want + 'px'; // sane, and only when it differs
|
||
}
|
||
|
||
export function fitViewport() { // landscape 480x270; portrait 270 wide and as TALL as the phone is
|
||
fitBox();
|
||
const portrait = (cv.clientHeight || innerHeight) > (cv.clientWidth || innerWidth);
|
||
// Portrait height ADAPTS to the device. A fixed 270x480 is 9:16, but a modern iPhone is taller
|
||
// (~390x844 = 0.46), so width bound the fit and ~150 CSS px of screen went unused at the bottom.
|
||
// The court is positioned by proj() off TUNE.horizon in ABSOLUTE logical px, and the rack/meter hang
|
||
// off `H - ...`, so extra height simply widens the apron between the court and the rack — nothing
|
||
// has to be re-laid-out. Clamped so a freak aspect can't stretch the apron absurdly.
|
||
// Derive the logical size from the box CSS actually gave the canvas, not from innerWidth/innerHeight.
|
||
// The canvas is stretched to 100vw x 100dvh, so matching its logical aspect to that box exactly is
|
||
// what guarantees no letterbox and no distortion, however the phone's toolbars behave.
|
||
const bw = cv.clientWidth || innerWidth, bh = cv.clientHeight || innerHeight;
|
||
const base = portrait ? 270 : 480;
|
||
const other = Math.round(base * (portrait ? bh / Math.max(1, bw) : bh / Math.max(1, bw)));
|
||
const [w, h] = portrait ? [base, Math.max(430, Math.min(680, other))] : [base, Math.max(230, Math.min(340, other))];
|
||
if (cv.width !== w * RES || cv.height !== h * RES) {
|
||
W = w; H = h;
|
||
cv.width = w * RES; cv.height = h * RES;
|
||
// flatter lens: camZ pushed back (config 42, was 36) with camF scaled to keep the PLAYER
|
||
// the same size — far-court zombies render ~25% bigger and actually read as zombies
|
||
// READABILITY PASS. Oliver's wife: "everything is a tad too small" — a far shambler on an iPhone
|
||
// was 26 logical px tall with almost no legible detail. Pushing the camera BACK (camZ) while
|
||
// lengthening the lens (camF) flattens the perspective, so distant bodies grow relative to near
|
||
// ones instead of everything just zooming. camY is raised with it for a steeper look-down (bodies
|
||
// overlap each other less at depth) and `horizon` re-centres the court after both.
|
||
// Net: far shambler 26 -> 30 logical px, the player unchanged, the court occupying the same band.
|
||
// If you retune these, check all four: far size, near size, court top, court bottom.
|
||
TUNE.camZ = portrait ? 48 : 42;
|
||
TUNE.camY = portrait ? 11 : 9;
|
||
TUNE.camF = portrait ? 760 : 406;
|
||
// THE COURT RIDES HIGH ENOUGH TO PULL UNDER HIM. A full draw is TUNE.pullFull CSS px of thumb
|
||
// travel STARTING at his feet, and it has to finish clear of the rack and of the iOS bottom
|
||
// edge — where a swipe down is Reachability and the system takes the gesture out from under
|
||
// you, which no web page can refuse. Positioning the court from the TOP could not promise that:
|
||
// on a 375x812 it left 112 CSS px under him for a 130 px pull, so the wind-up physically did
|
||
// not fit and the thumb was driven onto the edge every single rally.
|
||
// So park his feet a full draw above the rack and let the horizon fall wherever it must. What
|
||
// gets shorter is the stand above the court, and that was only ever crowd.
|
||
// (`horizon` is the live knob — it survives in the T panel until the viewport actually changes.)
|
||
const perCss = h / Math.max(1, bh); // logical px per CSS px (bh is 0 in a hidden tab)
|
||
const apron = TUNE.pullFull * perCss + TUNE.rackH + PULL_ROOM;
|
||
// his scale at his DEEPEST, so the room holds wherever he is standing when you draw
|
||
const feetY = TUNE.camY * TUNE.camF / (TUNE.camZ - P_BOUNDS.zMax);
|
||
TUNE.horizon = portrait ? Math.round(h - apron - feetY) : 40;
|
||
buildCrowd();
|
||
}
|
||
ctx.imageSmoothingEnabled = false; // resets on canvas resize
|
||
// CSS owns the on-screen size and the TOP inset; we match the logical grid and measure the bottom
|
||
// inset in logical px so the rack can lift clear of the home indicator.
|
||
measureSafe((cv.clientWidth || innerWidth) / w);
|
||
}
|
||
// Every signal that the visible area may have changed, plus a short settle salvo — because the case
|
||
// that broke it (a homescreen launch) fires NO signal at all: the viewport iOS hands you at launch is
|
||
// simply wrong and is never corrected. fitViewport is idempotent (fitBox no-ops within 1px, and the
|
||
// logical size early-outs unless it really changed), so re-running it costs nothing.
|
||
addEventListener('resize', fitViewport);
|
||
addEventListener('orientationchange', fitViewport);
|
||
addEventListener('pageshow', fitViewport); // the app was resumed from the bfcache
|
||
window.visualViewport?.addEventListener('resize', fitViewport); // the one iOS does fire when the bars move
|
||
new ResizeObserver(fitViewport).observe(cv); // the box moved, whatever moved it
|
||
for (const ms of [0, 120, 400, 1200]) setTimeout(fitViewport, ms); // ...and the launch that says nothing
|
||
|
||
// The aim camera: while the slingshot is drawn the view rises and leans back a little, which
|
||
// steepens the look down the court. That extra depth is what makes a shambler standing right
|
||
// behind the net a shot you can actually judge. Eased once per frame in render(), never mid-frame
|
||
// — every proj() call within one frame must share the same camera or the scene shears.
|
||
let aimCam = 0;
|
||
export function proj(x, y, z) {
|
||
const d = TUNE.camZ + aimCam * TUNE.aimCamZ - z, s = TUNE.camF / d;
|
||
return { x: W / 2 + x * s, y: TUNE.horizon + (TUNE.camY + aimCam * TUNE.aimCamY - y) * s, s };
|
||
}
|
||
|
||
// The run-off apron below the near baseline. Raising the court to make room for the pull (see
|
||
// fitViewport) left a wide band down there, and `pal.out` is a single flat colour — so the
|
||
// perspective visibly stopped partway down the screen and the rest read as dead margin. This falls
|
||
// the light off toward you, which gives the band depth, frames the rack, and darkens exactly the
|
||
// area the thumb covers anyway. Starts at alpha 0 on the baseline, so there is no seam to see.
|
||
// NOT a near backstop: at camZ 48 with the baseline at z 23.77 a real one would sit between the
|
||
// camera and the player and draw enormous. You are looking OVER it, which is why a broadcast frame
|
||
// never shows one either. Wear and props at the margins are the next layer, not this.
|
||
function drawRunOff() {
|
||
const top = proj(0, 0, COURT.L).y;
|
||
if (top >= H) return; // landscape has no apron at all — the baseline projects off the bottom
|
||
const g = ctx.createLinearGradient(0, top, 0, H);
|
||
g.addColorStop(0, 'rgba(6,10,6,0)');
|
||
g.addColorStop(1, 'rgba(6,10,6,0.72)');
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(0, top, W, H - top);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- sprites ----
|
||
// assets/<name>.png; game falls back to code-drawn figures until sprites load.
|
||
const SPRV = 83; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
|
||
const SPR = {};
|
||
for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_run3','p_run4','p_swing1','p_swing2','p_die1','p_die2','p_die3','p_die4','p_die5',
|
||
'p_pull', // the serve wind-up: the racket dropping behind him while the pull is drawn
|
||
'o_idle1','o_idle2','o_bounce','o_bounce2','o_toss','o_smash','o_run1','o_run2','o_swing1','o_swing2',
|
||
'x_ump','x_boyrun1','x_boyrun2','x_boyrun3','x_boyrun4',
|
||
'x_crowd','x_crowd2','x_ads', // CENTRE COURT (grass) — also the fallback set
|
||
'x_crowd_c','x_crowd_c2','x_ads_c', // TERRE ROUGE (clay)
|
||
'x_crowd_h','x_crowd_h2','x_ads_h', // THE SLAB (hard)
|
||
'x_boymop1','x_boymop2','x_boymop3','x_boymop4', // the push cycle, used only while he is stopped and working
|
||
'x_boycarry1','x_boycarry2','x_boycarry3','x_boycarry4','x_howto','x_cross', // walking with the mop shouldered, and the slingshot tutorial panel
|
||
'z_gw2','z_gw3','z_gw4','z_gwb','z_gw2b','z_gw3b','z_gw4b', // the brute comes apart in stages (4/5 hp = z_gw .. 1/5 = z_gw4), each with a 2-frame lurch (…b)
|
||
'z_boss1','z_boss2','z_bossw1','z_bossw2','z_bossw3', // the CHAPTER BOSS: 2-frame stride + three wound stages (arm off -> ribs out -> headless)
|
||
'z_bosst0','z_bosst1','z_bosst2','z_bosst3', // ...and each of those four states mid-THROW (windT)
|
||
'z_mach1','z_mach2','z_machw1','z_machw2', // the ball machine: idle, firing, dented, split open
|
||
'z_judge1','z_judge2','z_judgew','z_judget','z_judgewt', // boss design 2 (chapter 2, 8, ...)
|
||
'z_chair1','z_chair2','z_chairw','z_chairt','z_chairwt', // boss design 3 (chapter 3, 9, ...)
|
||
// designs 4-6, each with the full set the groundskeeper has: 2 stride, 3 wounds, 4 throws
|
||
'z_coach1','z_coach2','z_coachw1','z_coachw2','z_coachw3','z_coacht0','z_coacht1','z_coacht2','z_coacht3',
|
||
'z_physio1','z_physio2','z_physiow1','z_physiow2','z_physiow3','z_physiot0','z_physiot1','z_physiot2','z_physiot3',
|
||
'z_mascot1','z_mascot2','z_mascotw1','z_mascotw2','z_mascotw3','z_mascott0','z_mascott1','z_mascott2','z_mascott3',
|
||
// the late roster: ball boy (3 hp, 2 skins), bouncer (6 hp, 3), roller man (9 hp, 4).
|
||
// Every skin has its …b stride twin, so a wounded body keeps walking (see ZWOUND).
|
||
'z_e1','z_e2','z_e3','z_e4','z_ew','z_ewb','z_ew2','z_ew2b',
|
||
'z_h1','z_h2','z_h3','z_h4','z_hw','z_hwb','z_hw2','z_hw2b','z_hw3','z_hw3b',
|
||
'z_k1','z_k2','z_k3','z_k4','z_kw','z_kwb','z_kw2','z_kw2b','z_kw3','z_kw3b','z_kw4','z_kw4b',
|
||
'z_a1','z_a2','z_a3','z_a4','z_b1','z_b2','z_b3','z_b4','z_c1','z_c2',
|
||
'z_d1','z_d2','z_d3','z_d4', // the steward: bigger body, hi-vis vest, 4-frame walk
|
||
'z_awb','z_bwb','z_cw1b','z_cw2b','z_dw','z_dwb', // ...and every wound skin's second frame: wounded bodies walk too
|
||
'z_f1','z_f2','z_f3','z_f4','z_g1','z_g2','z_g3','z_g4','z_gw',
|
||
'z_s1','z_s2','z_s3','z_s4','z_sdown', // the chair spectator: 4-frame shuffle + the staggered pose
|
||
'z_c3','z_c4', // ...and the turned CPU walks on four frames too
|
||
'z_cw1','z_cw2','z_aw','z_bw', // wounded skins for multi-hit enemies (racket boss, brute, and now the classic shamblers)
|
||
'w_ball','w_bomb','w_triple','w_fire','w_gun', // weapon-rack icons
|
||
'w_beacon','w_laser','w_rocket','w_flamer', // ...and the four that come out of a barrel
|
||
'z_barrel1','z_barrel2', // the weapon barrel, two frames so it ROLLS
|
||
// THE WOMEN'S TOUR. Same poses, same 224 px anchor, a different body — see SK().
|
||
// Only the two players and the turned CPU have a twin; everything above is shared.
|
||
// No o_run3_f/o_run4_f on purpose: runN reads the MEN'S table, where o_run4 is not
|
||
// preloaded, so the opponent runs on two frames whichever tour you picked.
|
||
'p_idle1_f','p_idle2_f','p_bounce_f','p_bounce2_f','p_toss_f','p_smash_f','p_pull_f',
|
||
'p_run1_f','p_run2_f','p_run3_f','p_run4_f','p_swing1_f','p_swing2_f',
|
||
// The death is DRAWN for frames 1-3 and RECOLOURED for 4-5. The generator refuses to
|
||
// draw a dead woman in any wording worth using, and frames 4-5 survive that fine —
|
||
// she is flat and face down, then a heap of torn cloth — so those two are the men's
|
||
// drawing with the blue shorts painted over in her skirt's white. The one thing you
|
||
// could actually see was blue popping in after three frames of white.
|
||
'p_die1_f','p_die2_f','p_die3_f','p_die4_f','p_die5_f',
|
||
'o_idle1_f','o_idle2_f','o_bounce_f','o_bounce2_f','o_toss_f','o_smash_f',
|
||
'o_run1_f','o_run2_f','o_swing1_f','o_swing2_f',
|
||
'z_c1_f','z_c2_f','z_c3_f','z_c4_f','z_cw1_f','z_cw2_f','z_cw1b_f','z_cw2b_f',
|
||
'scoreboard']) {
|
||
const img = new Image();
|
||
img.onload = () => SPR[n] = img;
|
||
// ?v=SPRV, for the same reason index.html versions main.js. python http.server sends no cache
|
||
// headers, so the browser caches sprite responses heuristically — INCLUDING 404s. A brand-new sheet
|
||
// whose name was added to this list before the PNG existed stays permanently "missing" for that
|
||
// browser, and drawZombie silently falls back to another design. That cost a debugging round.
|
||
// BUMP THIS whenever assets/ changes, alongside main.js?v=N in index.html.
|
||
img.src = 'assets/' + n + '.png?v=' + SPRV;
|
||
}
|
||
|
||
// ------------------------------------------------------------------ crowd ----
|
||
export function buildCrowd() {
|
||
crowd.dots.length = 0;
|
||
const rows = Math.floor((proj(0, 0, 0).y - 26) / 5.5), cols = Math.ceil(W / 8);
|
||
for (let row = 0; row < rows; row++) for (let i = 0; i < cols; i++) crowd.dots.push({
|
||
x: 4 + i * 8 + (row % 2) * 4 + Math.random() * 3, y: 8 + row * 5.5 + Math.random() * 2,
|
||
c: ['#c9b18f','#8f6f4f','#d9c6a5','#5f7f9f','#9f5f5f','#7f9f5f','#e0d0b0','#b0a0c0'][(i * 7 + row) % 8]
|
||
});
|
||
}
|
||
|
||
// -------------------------------------------------------------- primitives ----
|
||
function line(x1, z1, x2, z2, w = 1) {
|
||
const a = proj(x1, 0, z1), b = proj(x2, 0, z2);
|
||
ctx.strokeStyle = UI.cream; ctx.lineWidth = w;
|
||
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
|
||
}
|
||
function quadXZ(x1, z1, x2, z2, color) { // filled court-plane rect (screen trapezoid)
|
||
const a = proj(x1, 0, z1), b = proj(x2, 0, z1), c = proj(x2, 0, z2), d = proj(x1, 0, z2);
|
||
ctx.fillStyle = color; ctx.beginPath();
|
||
ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(c.x, c.y); ctx.lineTo(d.x, d.y); ctx.fill();
|
||
}
|
||
// Rounded panel. ctx.roundRect is native everywhere this game runs (Safari 16.4+), and the square
|
||
// fallback only matters on a browser that could not render the rest of this anyway.
|
||
function panel(x, y, w, h, r, fill, stroke) {
|
||
ctx.beginPath();
|
||
if (ctx.roundRect) ctx.roundRect(x, y, w, h, r); else ctx.rect(x, y, w, h);
|
||
if (fill) { ctx.fillStyle = fill; ctx.fill(); }
|
||
if (stroke) { ctx.strokeStyle = stroke; ctx.stroke(); }
|
||
}
|
||
function drawShadow(px, py, rx, ry, alpha = 0.28) {
|
||
ctx.fillStyle = `rgba(0,0,0,${alpha})`;
|
||
ctx.beginPath(); ctx.ellipse(px, py, rx, Math.max(1, ry), 0, 0, 7); ctx.fill();
|
||
}
|
||
function drawBall(x, y, z, glint = false, bomb = false, flame = false) {
|
||
const sh = proj(x, 0, z);
|
||
drawShadow(sh.x, sh.y, Math.max(1.5, sh.s * 0.09), sh.s * 0.04, 0.3);
|
||
const bp = proj(x, y, z);
|
||
const r = Math.max(1.5, bp.s * 0.09);
|
||
if (bomb) { // black ball with a flickering orange fuse pixel on top
|
||
ctx.fillStyle = '#111'; ctx.beginPath(); ctx.arc(bp.x, bp.y, r, 0, 7); ctx.fill();
|
||
if (Math.floor(G.idleT * 20) % 2) { ctx.fillStyle = '#ff8010'; ctx.fillRect(bp.x - 0.5, bp.y - r - 1, Math.max(1, r * 0.5), Math.max(1, r * 0.6)); }
|
||
return;
|
||
}
|
||
if (flame) { // white-hot core in an orange shell, flickering
|
||
ctx.fillStyle = '#ff8010'; ctx.beginPath(); ctx.arc(bp.x, bp.y, r + (Math.floor(G.idleT * 24) % 2 ? 1 : 0), 0, 7); ctx.fill();
|
||
ctx.fillStyle = '#ffe8a0'; ctx.beginPath(); ctx.arc(bp.x, bp.y, Math.max(1, r * 0.5), 0, 7); ctx.fill();
|
||
return;
|
||
}
|
||
ctx.fillStyle = UI.ball; ctx.beginPath(); ctx.arc(bp.x, bp.y, r, 0, 7); ctx.fill();
|
||
if (glint) { ctx.fillStyle = '#f8f8d0'; ctx.fillRect(bp.x - r * 0.3, bp.y - r * 0.5, Math.max(1, r * 0.5), Math.max(1, r * 0.5)); }
|
||
}
|
||
|
||
// ----------------------------------------------------------------- actors ----
|
||
function drawStatic(name, x, z, hMeters, bob = 0, flip = false) {
|
||
const img = SPR[name]; if (!img) return;
|
||
const p = proj(x, 0, z);
|
||
const h = hMeters * p.s, w = h * img.width / img.height;
|
||
drawShadow(p.x, p.y, w * 0.4, h * 0.04, 0.25);
|
||
if (!flip) return ctx.drawImage(img, p.x - w / 2, p.y - h - bob, w, h);
|
||
ctx.save(); ctx.translate(p.x, 0); ctx.scale(-1, 1); // the art is drawn facing LEFT
|
||
ctx.drawImage(img, -w / 2, p.y - h - bob, w, h);
|
||
ctx.restore();
|
||
}
|
||
function drawBoy(b) {
|
||
// mopping is not sprinting: its own sheet and a slower push cycle. Falls back to the run
|
||
// frames until the mop PNGs have loaded.
|
||
// Three sheets, because a boy WALKING to the next stain is not a boy mopping: x_boymop is the
|
||
// push cycle (used only while he's stopped and working), x_boycarry is him walking upright with
|
||
// the mop shouldered, x_boyrun is the plain tennis-mode ball fetch.
|
||
const mop = b.mop && SPR.x_boymop1;
|
||
const sheet = !mop ? 'x_boyrun' : b.mopT > 0 ? 'x_boymop' : SPR.x_boycarry1 ? 'x_boycarry' : 'x_boymop';
|
||
// Frames step off DISTANCE COVERED, not elapsed time: a time-driven cycle made him moonwalk
|
||
// whenever his speed and the frame rate disagreed. Now the stride owns the ground he covers.
|
||
const stride = TUNE.boyStride * (mop ? 1 : 2.1); // a sprint eats more ground per frame than a push
|
||
const img = SPR[sheet + (Math.floor((b.dist || 0) / stride) % 4 + 1)]; if (!img) return;
|
||
const p = proj(b.x, 0, b.z);
|
||
// scale off the frame's OWN pixel height, not a fixed BOY_H: the sheets are common-scaled per
|
||
// sheet, so a deep mopping lean is genuinely shorter and must not be stretched back upright
|
||
const h = BOY_H * p.s * (img.height / SPRITE_H), w = h * img.width / img.height;
|
||
drawShadow(p.x, p.y, w * 0.3, h * 0.04, 0.25);
|
||
ctx.save(); ctx.translate(p.x, p.y - (mop ? 0 : Math.floor(b.runT * 20) % 2));
|
||
if (b.dir < 0) ctx.scale(-1, 1);
|
||
ctx.drawImage(img, -w / 2, -h, w, h);
|
||
ctx.restore();
|
||
}
|
||
// Frames stepped by DISTANCE WALKED need a POSITIVE modulo. Bodies spawn BEHIND the far baseline at
|
||
// negative z, and JS keeps the sign of the dividend: floor(-1.5 / 0.25) % 4 is -2, so the pick asked
|
||
// for 'z_g-1' — a sprite that does not exist, and drawStatic silently draws nothing. That is the
|
||
// blinking Oliver saw on the way in, on exactly the bodies that step off distance: brutes and bosses.
|
||
const stepFrame = (v, n) => ((Math.floor(v) % n) + n) % n; // 0..n-1, whatever the sign of v
|
||
const ZFRAMES = { a: 4, b: 4, e: 4, f: 4, d: 4, g: 4, h: 4, k: 4, s: 4, c: 4 }; // 4-frame walk cycles where sheets exist; the rest stay 2
|
||
// Bodies whose stride steps off GROUND COVERED instead of the clock. They are the slow heavy ones,
|
||
// and a time-driven cycle on them is pure foot-slide (the defect the brute's rule was written for).
|
||
const ZSTRIDE = { g: TUNE.bruteStride, k: TUNE.rollerStride };
|
||
// The rogue ball machine, on its own generated sheet: a dented hopper cart on wheels with a barrel
|
||
// aimed down-court, and a second frame with the muzzle flashing. Oliver called the first, procedural
|
||
// version "naively painted" — it was four rects, which is fine for fire but not for furniture the
|
||
// player has to recognise and prioritise.
|
||
// The weapon barrel. The DRUM is a sprite (it is an object, and a procedural one would read as
|
||
// "naively painted" the way the machine's first version did); the GLOW is procedural, because light
|
||
// is one of the three things that rule explicitly exempts, along with blood and fire. It pulses, so
|
||
// it separates itself from the static ads and the crowd behind it at 20 px tall.
|
||
function drawBarrel(zb) {
|
||
const img = SPR.z_barrel1;
|
||
const h = TUNE.barrelH * (img ? img.height / SPRITE_H : 1);
|
||
const p = proj(zb.x, h * 0.5, zb.z);
|
||
const k = 0.55 + 0.45 * Math.sin((zb.roll || 0) * 3.1); // the pulse rides the roll, not a clock
|
||
ctx.save();
|
||
// THE PINK IS ALL HERE, and it can be as pink as it likes: this is light painted at draw time and
|
||
// it never goes near the chroma key that eats magenta out of PNGs. Three rings instead of one —
|
||
// a wide soft bloom, a bright body, and a white-hot core — because one flat disc at 20 px tall
|
||
// read as a smudge on the grass rather than as something GLOWING on a dark court.
|
||
const rgb = RGB(UI.pink);
|
||
for (const [rf, af] of [[0.62, 0.16], [0.34, 0.34], [0.17, 0.55]]) {
|
||
ctx.globalAlpha = af * (0.55 + k * 0.65);
|
||
ctx.fillStyle = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
|
||
ctx.beginPath(); ctx.arc(Math.round(p.x), Math.round(p.y), Math.max(2, p.s * rf * (0.85 + k * 0.35)), 0, 7); ctx.fill();
|
||
}
|
||
// ...and a pink pool on the LAWN under it, which is what actually sells "this thing is lit up"
|
||
const gp = proj(zb.x, 0, zb.z);
|
||
ctx.globalAlpha = 0.16 + k * 0.20;
|
||
ctx.beginPath(); ctx.ellipse(gp.x, gp.y, Math.max(3, gp.s * 0.55), Math.max(1.5, gp.s * 0.20), 0, 0, 7); ctx.fill();
|
||
ctx.globalAlpha = 1;
|
||
ctx.restore();
|
||
if (img) {
|
||
// two frames alternating off GROUND COVERED is what makes it read as rolling rather than sliding
|
||
const n = SPR.z_barrel2 && stepFrame((zb.roll || 0) / 0.5, 2) ? 'z_barrel2' : 'z_barrel1';
|
||
drawStatic(n, zb.x, zb.z, TUNE.barrelH * (SPR[n].height / SPRITE_H));
|
||
} else { // until the PNG lands, a lit drum
|
||
ctx.fillStyle = UI.pink;
|
||
const w = Math.max(2, p.s * 0.34), hh = Math.max(3, p.s * 0.44);
|
||
ctx.fillRect(Math.round(p.x - w / 2), Math.round(p.y - hh / 2), Math.round(w), Math.round(hh));
|
||
}
|
||
ctx.globalAlpha = 0.5 + k * 0.5; // the core, brightest at the pulse peak
|
||
ctx.fillStyle = '#fff';
|
||
ctx.beginPath(); ctx.arc(Math.round(p.x), Math.round(p.y), Math.max(1, p.s * 0.05), 0, 7); ctx.fill();
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
|
||
function drawMachine(zb) {
|
||
const firing = zb.windT > 0;
|
||
// It wears its damage like every other multi-hit body on court: dented at two thirds, split open
|
||
// and sparking at one. The healthy sheet keeps the muzzle-flash frame; a damaged machine flashes
|
||
// procedurally instead of needing a firing frame per stage.
|
||
const f = zb.hp / (zb.hpMax || TUNE.machHp);
|
||
const dmg = f <= 0.34 ? 'z_machw2' : f <= 0.67 ? 'z_machw1' : null;
|
||
const name = dmg && SPR[dmg] ? dmg : firing && SPR.z_mach2 ? 'z_mach2' : 'z_mach1';
|
||
const img = SPR[name];
|
||
if (!img) return; // sheet not loaded yet: draw nothing, not a box
|
||
const h = MACH_H * (img.height / SPRITE_H);
|
||
drawStatic(name, zb.x, zb.z, h);
|
||
if (firing) { // the TELEGRAPH still needs to read at 20px tall, so the muzzle glow stays procedural
|
||
const k = 1 - zb.windT / TUNE.bossWind, p = proj(zb.x, h * 0.62, zb.z); // bossWind, not machWind: the boss telegraphs faster
|
||
ctx.globalAlpha = 0.3 + k * 0.7;
|
||
ctx.fillStyle = k > 0.6 ? '#fff2c0' : UI.warm;
|
||
const r = Math.max(1.5, p.s * 0.05 * (0.5 + k));
|
||
ctx.beginPath(); ctx.arc(Math.round(p.x + p.s * 0.1), Math.round(p.y), r, 0, 7); ctx.fill();
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
}
|
||
|
||
const p0 = zb => proj(zb.x, 0, zb.z).s; // projected scale, for effects that need pixels not metres
|
||
function drawZombie(zb) {
|
||
if (zb.variant === 'o') return drawBarrel(zb);
|
||
if (zb.variant === 'm') return drawMachine(zb);
|
||
const rk = zb.variant === 'r';
|
||
const v = rk ? 'c' : zb.variant; // the racket zombie reuses the turned-CPU sprite (already holds a racket)
|
||
let N = ZFRAMES[v] || 2;
|
||
if (!SPR['z_' + v + N]) N = 2; // frames still loading (or sheet rejected): 2-frame fallback
|
||
// same CYCLE frequency as before — more frames subdivide the stride, they don't speed it up
|
||
// The racket zombie holds a position and only moves when the ball makes him, so a time-driven
|
||
// cycle had him flicking between two frames on the spot. His stride steps off ground covered like
|
||
// the brute's — standing still is standing still — and his swing keeps its own frame.
|
||
let frame = rk && zb.pose === 'swing' ? 2
|
||
: rk ? stepFrame((zb.dist || 0) / TUNE.racketStride, N) + 1
|
||
: ZSTRIDE[v] ? stepFrame(zb.z / (ZSTRIDE[v] * 2 / N), N) + 1 // heaviest bodies: step off DISTANCE
|
||
// WALKED, like their wound lurch below,
|
||
// or the feet slide (same cycle length)
|
||
: Math.floor(zb.walkT * (zb.variant === 'f' ? 6 : 2.6) * N / 2) % N + 1;
|
||
const chairDown = zb.variant === 's' && zb.shieldT > 0; // chair dropped — hit it NOW. Its own sprite
|
||
// now (z_sdown); it used to be walk frame 3,
|
||
// which a 4-frame cycle would walk right through
|
||
// the brute towers half again over the crowd, the steward stands between him and it, and a
|
||
// chapter boss towers over the brute. Shared with gameplay, which aims the serve at a fraction
|
||
// of this — if the two tables drifted the ball would arrive at the wrong part of the body.
|
||
let h = zombieH(zb); // NOT const: the headless boss skin shortens it, and the sprite sheet scales it
|
||
// wounded skins: multi-hit enemies show their damage as hp drops (arm off → head gone, still
|
||
// shambling). The blood is NOT painted in — open wounds gush live particles (see bleed() in
|
||
// zombies.js). Fall back to the healthy sprite until the wounded PNG has loaded.
|
||
let name = chairDown && SPR.z_sdown ? 'z_sdown' : 'z_' + v + frame;
|
||
// ONE lookup for every multi-hit body that reads damage against absolute hp. config.ZWOUND is
|
||
// worst-first and indexed by REMAINING hp − 1, which is the brute's old hardcoded stage array
|
||
// generalised; a single-skin body clamps to index 0 and falls out of the same line. This replaced
|
||
// two branches that did the same job against different constants, which is why the ball boy, the
|
||
// bouncer and the roller man each cost a table row here and no code at all.
|
||
const stages = ZWOUND[zb.variant], sIdx = stages ? woundStage(zb, stages.length) : 0;
|
||
if (sIdx) {
|
||
const stage = stages[sIdx - 1];
|
||
if (SPR[stage]) {
|
||
// Every wound stage has its OWN two-frame lurch (…w / …wb = the stride swapped). The heavy
|
||
// bodies step it by DISTANCE WALKED (a time-driven cycle on them was pure foot-slide, and
|
||
// replaced a procedural squash-and-sway that read as exactly what it was: fake); everything
|
||
// else steps at the SAME cadence as its healthy walk, or a wounded body reads as frozen next
|
||
// to the crowd it is walking in. The comedy is in the gait, and the gait has to be drawn.
|
||
const b = stage + 'b', phase = ZSTRIDE[v] ? zb.z / ZSTRIDE[v] : zb.walkT * 5.2;
|
||
name = SPR[b] && stepFrame(phase, 2) ? b : stage;
|
||
} else if (SPR[stages[0]]) name = stages[0]; // a missing asset degrades to the lightest skin that loaded
|
||
}
|
||
// The boss's damage is read as a FRACTION of full health — his hp scales with the run
|
||
// (TUNE.racketHpPerGame), so the old `hp === 2` / `hp <= 1` thresholds would have left a late-run
|
||
// boss looking untouched until the killing blow. Keep these in step with bleed()'s anchors.
|
||
else if (rk) {
|
||
const f = zb.hp / (zb.hpMax || TUNE.racketHp);
|
||
if (f <= 0.34 && SPR.z_cw2) { name = 'z_cw2'; h = 1.52; } // headless: a head shorter, so the shoulders land where a whole zombie's do
|
||
else if (f <= 0.67 && SPR.z_cw1) name = 'z_cw1';
|
||
const b = name + 'b'; // ...and he limps through his wounds too, instead of gliding
|
||
if (name !== 'z_' + v + frame && SPR[b] && stepFrame(zb.walkT * 5.2, 2)) name = b;
|
||
}
|
||
// The chapter boss comes apart through the SAME fraction-of-hpMax rule as the racket zombie, over
|
||
// three stages, so a 34 hp chapter-4 boss visibly degrades all the way down: arm off, then ribs
|
||
// out, then headless and still throwing. Its 2-frame stride steps off DISTANCE COVERED (the same
|
||
// rule the brute uses) — it is the slowest thing on court and a time-driven cycle would foot-slide.
|
||
// The chapter boss picks its frames from its DESIGN (`zb.art`, one row of config.BOSS_ART per
|
||
// chapter, cycling), so a new boss is a sheet plus a data row and never a change here. Damage is a
|
||
// FRACTION of hpMax spread over however many wound stages that design has, and the wounded state
|
||
// stays visible MID-THROW — the one place Oliver found damage disappearing.
|
||
// NEVER name a sprite so it differs from another only by case: macOS is case-insensitive, so
|
||
// writing z_B1.png silently OVERWROTE the 'b' shambler's z_b1.png and a boss turned up in the crowd.
|
||
if (zb.variant === 'B') {
|
||
const A = zb.art || BOSS_ART[0];
|
||
const idx = woundStage(zb, A.wounds.length); // the shared rule — see config.woundStage
|
||
// The stride steps off zb.dist — GROUND COVERED on BOTH axes, which zb.z alone is not: a boss
|
||
// that paces or hops sideways would slide across the court with its feet frozen. (zb.z is the
|
||
// fallback so a boss spawned by an older path still animates.)
|
||
const walked = (zb.dist ?? zb.z) / TUNE.bruteStride;
|
||
const wanted = zb.windT > 0 ? A.throws[Math.min(idx, A.throws.length - 1)]
|
||
: idx ? A.wounds[idx - 1]
|
||
: A.walk[stepFrame(walked, 2)];
|
||
// a design whose sheet has not been generated (or has not loaded) degrades to design 1 rather
|
||
// than vanishing, since drawStatic silently draws nothing for a missing sprite
|
||
const stage = SPR[wanted] ? wanted : BOSS_ART[0].walk[stepFrame(walked, 2)];
|
||
if (SPR[stage]) name = stage;
|
||
}
|
||
// A MISSING FRAME MUST NEVER BLINK A BODY OUT OF EXISTENCE. N above is validated against the LAST
|
||
// frame only, so a set with (say) z_h2 missing but z_h4 present keeps N = 4, the cycle steps onto a
|
||
// name that never loaded, and drawStatic silently draws NOTHING — the body vanishes for that frame
|
||
// and comes back on the next one. That is what Oliver saw on his phone, and a cached 404 (this
|
||
// project's oldest trap) is enough to cause it. Hold frame 1 instead: a body that stops animating
|
||
// is a bug you can see and diagnose; one that flickers in and out reads as the game breaking.
|
||
if (!SPR[name] && SPR['z_' + v + '1']) name = 'z_' + v + '1';
|
||
// ...and only NOW the tour swap, so the height read below comes off the frame actually drawn. The
|
||
// branches above all test the men's names (which always exist), so the wound logic is untouched.
|
||
name = SK(name);
|
||
// Every frame is drawn at ITS OWN height. drawStatic forces the height it is given, so a crouched
|
||
// frame sliced 13 px shorter than its neighbours was being stretched back to full size — the body
|
||
// grew and shrank once per stride. Oliver: "the brute is still pulsating while walking". The rule
|
||
// already existed for the wound stages; it belongs to every frame, and it is the same rule
|
||
// _slice.py's common scaling is built on (see HANDOFF: heights follow the PNG).
|
||
if (SPR[name]) h *= SPR[name].height / SPRITE_H;
|
||
// feeding: a fast bob over the body instead of a stride. Same sprites, different rhythm.
|
||
const bob = zb.feeding ? Math.abs(Math.sin(zb.walkT * 11)) * Math.max(1, p0(zb) * 0.05)
|
||
: Math.floor(zb.walkT * 5.2) % 2;
|
||
// Every side-on body is DRAWN facing left, so one walking right was moonwalking — obvious the
|
||
// moment they close on you across your own half. The front-facing ones (the turned CPU, the
|
||
// runner, the machine, a chapter boss) have no left or right to turn and are left alone.
|
||
drawStatic(name, zb.x, zb.z, h, bob, zb.face > 0 && !'cfmB'.includes(v));
|
||
if (zb.burnT > 0) drawFlames(zb, h);
|
||
if (zb.windT > 0 && zb.variant === 'B') { // the boss telegraphs too: it rears up, glowing, then throws
|
||
const k = 1 - zb.windT / TUNE.bossWind, p = proj(zb.x, h * 0.62, zb.z); // bossWind, not machWind: the boss telegraphs faster
|
||
ctx.globalAlpha = 0.25 + k * 0.55; ctx.fillStyle = k > 0.6 ? '#fff2c0' : UI.bad;
|
||
const r = Math.max(2, p.s * 0.1 * (0.5 + k));
|
||
ctx.beginPath(); ctx.arc(Math.round(p.x), Math.round(p.y), r, 0, 7); ctx.fill();
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
// No HP bar. It was the only floating UI on a body anywhere in the game, it was on screen for
|
||
// almost the whole run (a boss respawns every game you win), and nobody knew what it meant —
|
||
// while the wound SKINS above already encode exactly the same information in the language every
|
||
// other multi-hit enemy uses. The first-sight modal in spawnRacketZombie explains him instead.
|
||
}
|
||
// Fire, drawn procedurally instead of as a sprite sheet: a few vertically-stretched quads whose
|
||
// offsets ride each body's own phase (walkT), so no two burning zombies flicker in sync — and it
|
||
// reads at every burn stage, from fresh yellow flare to guttering smoke, for free.
|
||
function drawFlames(zb, hMeters) {
|
||
const p = proj(zb.x, 0, zb.z), s = p.s;
|
||
const k = Math.max(0, Math.min(1, zb.burnT / TUNE.burnTime)); // 1 = just lit, 0 = about to gutter out
|
||
const col = k > 0.6 ? '#ffc030' : k > 0.3 ? '#ff8010' : '#6b5a52';
|
||
for (let i = 0; i < 5; i++) {
|
||
const ph = G.idleT * (7 + i * 1.7) + zb.walkT * 3.1 + i * 2;
|
||
const fx = p.x + Math.sin(ph) * s * 0.11 + (i - 2) * s * 0.05;
|
||
const fy = p.y - (0.2 + i * 0.15 + 0.25 * k) * hMeters * s - Math.abs(Math.sin(ph * 1.6)) * s * 0.08;
|
||
ctx.fillStyle = i === 0 && k > 0.5 ? '#ffe8a0' : col; // one white-hot core tongue while it rages
|
||
ctx.fillRect(fx | 0, fy | 0, Math.max(1, s * 0.045), Math.max(2, s * (0.10 + 0.07 * Math.abs(Math.sin(ph * 2)))));
|
||
}
|
||
}
|
||
// AIMING and FLYING are two different pictures out of one arc.
|
||
// While you pull, the whole span to the target in full colour is exactly right: it is the
|
||
// information you are deciding on. Carrying that picture unchanged into the flight is what made
|
||
// the shot read as a laser — a bright, saturated, continuous line running from the projectile to
|
||
// the thing it is about to hit is the visual definition of a targeting beam, and no real ball ever
|
||
// shows its own future. So once fired the band is spent: it desaturates toward dust, collapses,
|
||
// and shrinks to a short streak BEHIND the ball, with nothing at all drawn ahead of it.
|
||
// (A variant that put that streak ahead of the ball instead was built, compared and cut.)
|
||
const TAIL = 0.15; // fraction of the arc drawn behind the ball once it is flying
|
||
const DUST = [206, 200, 184];
|
||
const RGB = h => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
|
||
const mix = (a, b, t) => a.map((v, i) => Math.round(v + (b[i] - v) * t));
|
||
|
||
function drawShotBand(a, u0, flying) {
|
||
const left = 1 - u0;
|
||
if (left <= 0.01) return;
|
||
// cool blue-white = this only staggers. It stays READABLE on the dark lawn: you still need to
|
||
// see where you're pointing, you just need to know it won't kill.
|
||
// The LASER re-paints the band it is about to become: blue, and widening down the court instead
|
||
// of tapering, so the gesture SHOWS you the wedge before you commit to it. Oliver asked for the
|
||
// band itself to communicate the shape of the shot.
|
||
const beamW = WEAPONS[slotId(G.weapon)]?.beam ? 1 : 0;
|
||
const hex = beamW ? WEAPONS[slotId(G.weapon)].col
|
||
: a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0';
|
||
// the slice of arc to draw: everything while aiming, a short streak behind the ball in flight
|
||
const lo = flying ? Math.max(0, u0 - TAIL) : 0;
|
||
const hi = flying ? u0 : 1;
|
||
if (hi - lo < 0.001) return;
|
||
// Linear, not squared. Squared made both variants basically gone by a third of the flight, which
|
||
// is not "less like a laser", it is "no streak at all" — and the strobing at release is the thing
|
||
// this whole trail exists to prevent. The SHORT SLICE is what kills the beam; the fade only has
|
||
// to make sure nothing is still burning when the ball lands.
|
||
const decay = flying ? left : 1;
|
||
const col = flying ? mix(RGB(hex), DUST, Math.min(1, u0 * 3)) : RGB(hex); // the colour is spent
|
||
const N = 14;
|
||
const at = t => ({ x: a.x0 + a.v.vx * t, y: a.y0 + a.v.vy * t - 0.5 * a.g * t * t,
|
||
z: a.z0 + a.v.vz * t });
|
||
const uAt = i => lo + (hi - lo) * (i / N);
|
||
// s: 0 at the far end of the slice, 1 at the ball (or at the racket, while aiming) — so the
|
||
// streak is always brightest where the action is and dies off away from it.
|
||
const sAt = i => flying ? i / N : 1 - i / N;
|
||
// The shadow first, under everything: an arc hanging in the air is ambiguous about where it
|
||
// comes down until something pins it to the lawn. This is the whole 3D cue. It follows the same
|
||
// slice — "nothing ahead" has to mean the shadow too, or the ground still gives the future away.
|
||
for (let i = 0; i <= N; i++) {
|
||
const w = at(a.T * uAt(i)), p = proj(w.x, 0, w.z);
|
||
ctx.fillStyle = `rgba(0,0,0,${(0.18 + 0.24 * sAt(i)) * decay})`;
|
||
ctx.beginPath(); ctx.ellipse(p.x, p.y, Math.max(0.8, p.s * 0.07), Math.max(0.5, p.s * 0.028), 0, 0, 7);
|
||
ctx.fill();
|
||
}
|
||
// ...then the band. Width comes from each point's OWN projection scale, so it tapers into the
|
||
// distance like everything else on the court rather than being a flat screen-space stroke.
|
||
ctx.strokeStyle = `rgb(${col[0]},${col[1]},${col[2]})`;
|
||
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
|
||
const head = at(a.T * lo);
|
||
let prev = proj(head.x, head.y, head.z);
|
||
for (let i = 1; i <= N; i++) {
|
||
const w = at(a.T * uAt(i)), p = proj(w.x, w.y, w.z);
|
||
ctx.globalAlpha = (0.30 + 0.55 * a.f) * (0.45 + 0.55 * sAt(i)) * decay * (beamW ? 1.25 : 1);
|
||
// a normal band is brightest at the racket and thins away; the beam does the OPPOSITE, opening
|
||
// out toward the far end into the plane it is going to cut
|
||
ctx.lineWidth = beamW
|
||
? Math.max(1.5, p.s * (0.05 + 0.42 * (uAt(i))))
|
||
: Math.max(1, p.s * (0.04 + a.f * 0.15) * (flying ? 0.75 : 1));
|
||
ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(p.x, p.y); ctx.stroke();
|
||
prev = p;
|
||
}
|
||
ctx.globalAlpha = 1; ctx.lineWidth = 1;
|
||
}
|
||
|
||
// The laser's plane, shared by the aim PREVIEW and the fired shot. ONE triangle, apex at the
|
||
// player's chest, opening out down the court. It is FULLY OPAQUE — solid colour, deep blue at the
|
||
// edges and near-white down the core, dimming with distance. Depth is carried by BRIGHTNESS, not by
|
||
// transparency, so nothing shows through it: a body above the plane is drawn on top of it and a body
|
||
// below is simply hidden behind it, which is as unambiguous as the split can be. (It was a
|
||
// translucent veil; at a readable opacity it washed everything out, and at a subtle one you could
|
||
// not tell above from below at all — the two pulled against each other. Opaque settles it.)
|
||
// `from` is the erase front: 0 draws the whole wedge, 0.5 only its far half, 1 nothing.
|
||
// Returns the length it drew, so the split can use the same geometry.
|
||
function drawBeamWedge(dirX, dirZ, ox, oy, oz, from) {
|
||
const d = Math.hypot(dirX, dirZ) || 1, ux = dirX / d, uz = dirZ / d;
|
||
const px = -uz, pz = ux; // the lane's perpendicular, on the ground
|
||
// The DRAWN length is TUNE.beamLen, straight. It used to be solved here each frame so the far end
|
||
// landed on the middle of the ad strip — which made beamLen a knob that visibly did nothing, since
|
||
// it only governed the kill. One meaning is worth more than one clever behaviour.
|
||
const L = TUNE.beamLen;
|
||
if (from >= 1) return L;
|
||
// it leaves the racket high and settles to the cutting height within the first stretch
|
||
const yAt = u => oy + (TUNE.beamY - oy) * Math.min(1, u / TUNE.beamRise);
|
||
const wAt = u => TUNE.beamW0 + (TUNE.beamW1 - TUNE.beamW0) * u;
|
||
const corner = (u, w) => proj(ox + ux * (u * L) + px * w, yAt(u), oz + uz * (u * L) + pz * w);
|
||
// >= 0.5, not just truthy: the T panel steps numbers by 0.02, so one nudge on this flag set it to
|
||
// 0.02 — still truthy, and the renderer silently switched under Oliver mid-tune. A flag living in
|
||
// a table of numbers has to be robust to being treated as a number.
|
||
if (TUNE.beamBand >= 0.5) { drawBeamBand(ux, uz, ox, oy, oz, L, from); return L; }
|
||
const u0 = Math.max(0, from), w0 = wAt(u0), w1 = wAt(1);
|
||
// THE SHADOW ON THE LAWN, borrowed from the ball's aim band and from the band variant — this is
|
||
// most of why those read as lying in the world. A bright shape hanging in the air is ambiguous
|
||
// about where it actually is until something pins it to the ground.
|
||
for (let i = 0; i <= 14; i++) {
|
||
const u = u0 + (1 - u0) * (i / 14), w = wAt(u);
|
||
const p = proj(ox + ux * (u * L), 0, oz + uz * (u * L));
|
||
ctx.fillStyle = `rgba(0,0,0,${0.16 * (1 - u * 0.55)})`;
|
||
ctx.beginPath(); ctx.ellipse(p.x, p.y, Math.max(0.8, p.s * w * 0.9), Math.max(0.4, p.s * w * 0.3), 0, 0, 7);
|
||
ctx.fill();
|
||
}
|
||
// ONE gradient, along the LENGTH only. There was a second one across the width (near-white core
|
||
// falling to blue at the edges) and it is gone: across a shape this narrow it read as a highlight
|
||
// painted down the middle rather than as depth. The plane is a flat solid colour now, dimming as
|
||
// it runs away from you — that is the whole shading.
|
||
const c1 = corner(u0, -w0), c2 = corner(u0, w0), c3 = corner(1, w1), c4 = corner(1, -w1);
|
||
const mix = (c, m) => `rgb(${Math.round(c[0] * m)},${Math.round(c[1] * m)},${Math.round(c[2] * m)})`;
|
||
const grad = ctx.createLinearGradient((c1.x + c2.x) / 2, (c1.y + c2.y) / 2,
|
||
(c3.x + c4.x) / 2, (c3.y + c4.y) / 2);
|
||
grad.addColorStop(0, mix(BEAM_MID, 1));
|
||
grad.addColorStop(1, mix(BEAM_MID, 0.5));
|
||
ctx.fillStyle = grad;
|
||
ctx.beginPath(); ctx.moveTo(c1.x, c1.y); ctx.lineTo(c2.x, c2.y); ctx.lineTo(c3.x, c3.y); ctx.lineTo(c4.x, c4.y);
|
||
ctx.closePath(); ctx.fill();
|
||
|
||
// ...and the OUTER LINES. It reads as a plane because its edges are drawn, not merely implied by
|
||
// where the fill stops.
|
||
ctx.strokeStyle = 'rgb(196,230,255)'; // the outer lines — width is a knob (beamBorder)
|
||
ctx.lineWidth = TUNE.beamBorder;
|
||
ctx.beginPath();
|
||
ctx.moveTo(c2.x, c2.y); ctx.lineTo(c3.x, c3.y);
|
||
ctx.moveTo(c1.x, c1.y); ctx.lineTo(c4.x, c4.y);
|
||
ctx.stroke();
|
||
if (from > 0) { // the erase front: a bright bar racing away down the lane
|
||
ctx.strokeStyle = 'rgb(255,255,255)'; ctx.lineWidth = 1.5;
|
||
ctx.beginPath(); ctx.moveTo(c1.x, c1.y); ctx.lineTo(c2.x, c2.y); ctx.stroke();
|
||
}
|
||
ctx.lineWidth = 1;
|
||
return L;
|
||
}
|
||
|
||
// The cut line: BLUEISH and faint. Near-white and bold, it read as a HUD marker stamped on the
|
||
// sprite rather than as light crossing a body — it has to whisper, not label.
|
||
const CUT_COL = 'rgba(58,175,255,0.85)'; // the laser icon's blue (#1d99e8), lifted so a flat bar reads
|
||
// The plane's colour. Opaque, one flat blue, dimmed with distance — see drawBeamWedge. BEAM_CORE is
|
||
// still used by the band variant's hot centre line (TUNE.beamBand).
|
||
const BEAM_MID = [63, 116, 255];
|
||
const BEAM_CORE = [206, 238, 255];
|
||
const CUT_MAX = 8; // bodies that get a cut line in one frame (nearest first)
|
||
const CUT_MIN_PX = 14; // ...and only if it is drawn at least this tall
|
||
// NO ctx.filter ANYWHERE. The cut line used to be the sprite redrawn clipped to a band and flattened
|
||
// to one colour by a canvas filter, which put it exactly on the silhouette — and on an iPhone the
|
||
// line did not draw AT ALL and bodies blinked in and out of existence, which is what a filter
|
||
// leaking past its restore() looks like. Not worth it at any price. Plain bars from here.
|
||
// THE BAND VARIANT (TUNE.beamBand = 1). Drawn the way the BALL's aim trajectory is drawn: a path
|
||
// down the lane whose width comes from each point's OWN projection scale, so it narrows into the
|
||
// distance exactly like everything else on the court. That is the difference — the cone is a real
|
||
// projection too, but it GROWS in world width, so on screen it opens out while the court recedes,
|
||
// and the eye reads that as a flat shape stuck to the screen rather than a plane lying in the world.
|
||
function drawBeamBand(ux, uz, ox, oy, oz, L, from) {
|
||
const N = 20;
|
||
const yAt = u => oy + (TUNE.beamY - oy) * Math.min(1, u / TUNE.beamRise);
|
||
const at = u => ({ x: ox + ux * (u * L), y: yAt(u), z: oz + uz * (u * L) });
|
||
const u0 = Math.max(0, from);
|
||
if (u0 >= 1) return;
|
||
const uAt = i => u0 + (1 - u0) * (i / N);
|
||
// the shadow on the lawn first, same as the ball's band: an arc hanging in the air is ambiguous
|
||
// about where it lies until something pins it to the ground
|
||
for (let i = 0; i <= N; i++) {
|
||
const w = at(uAt(i)), p = proj(w.x, 0, w.z);
|
||
ctx.fillStyle = `rgba(0,0,0,${0.20 * (1 - uAt(i) * 0.6)})`;
|
||
ctx.beginPath(); ctx.ellipse(p.x, p.y, Math.max(0.8, p.s * TUNE.beamBandW * 0.9),
|
||
Math.max(0.4, p.s * TUNE.beamBandW * 0.3), 0, 0, 7); ctx.fill();
|
||
}
|
||
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
|
||
// two passes: a wide soft body and a hot core down the middle, both scaled per point
|
||
for (const [wf, col] of [[2.0, BEAM_MID], [0.7, BEAM_CORE]]) {
|
||
let prev = null;
|
||
for (let i = 0; i <= N; i++) {
|
||
const u = uAt(i), w = at(u), p = proj(w.x, w.y, w.z);
|
||
if (prev) {
|
||
const f = 1 - 0.45 * u; // depth by brightness, as the cone does
|
||
ctx.strokeStyle = `rgb(${Math.round(col[0] * f)},${Math.round(col[1] * f)},${Math.round(col[2] * f)})`;
|
||
ctx.lineWidth = Math.max(1, p.s * TUNE.beamBandW * wf);
|
||
ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(p.x, p.y); ctx.stroke();
|
||
}
|
||
prev = p;
|
||
}
|
||
}
|
||
ctx.lineWidth = 1;
|
||
}
|
||
|
||
// THE CUT. The beam is a plane at TUNE.beamY, so a body standing in it is sliced at the height that
|
||
// plane passes through it: everything below stays where it was drawn (and is tinted by the
|
||
// translucent beam laid over it), and everything above is redrawn on TOP of the beam, clipped.
|
||
// drawZombie is a pure draw — it mutates nothing — so calling it a second time is safe.
|
||
function drawBeamCuts(dirX, dirZ, ox, oz, L) {
|
||
const d = Math.hypot(dirX, dirZ) || 1, ux = dirX / d, uz = dirZ / d;
|
||
// BOUNDED: each body in the beam is drawn once MORE (the part above the plane), every frame you
|
||
// are aiming and not only on the shot. Nearest first, capped, and bodies too small on screen for a
|
||
// cut to say anything are skipped.
|
||
let budget = CUT_MAX;
|
||
for (const zb of [...zombies].sort((a, b) => b.z - a.z)) {
|
||
if (budget <= 0) break;
|
||
if (!zb) continue;
|
||
const rx = zb.x - ox, rz = zb.z - oz;
|
||
const along = rx * ux + rz * uz;
|
||
if (along < 0.5 || along > L) continue; // behind him, or past the end
|
||
const off = Math.abs(rx * uz - rz * ux);
|
||
const halfW = TUNE.beamBand >= 0.5 ? TUNE.beamBandW * 2 // must match fireBeam's test, or the cut
|
||
: TUNE.beamW0 + (TUNE.beamW1 - TUNE.beamW0) * (along / L); // marks bodies the shot will miss
|
||
if (off > halfW + 0.8) continue;
|
||
const gp = proj(zb.x, 0, zb.z), h = zombieH(zb);
|
||
// whole pixels: a clip edge on a fractional y crawls under nearest-neighbour scaling
|
||
const cutY = Math.round(proj(zb.x, TUNE.beamY, zb.z).y);
|
||
// a NaN anywhere here would make the clip path invalid, and an invalid clip is the sort of thing
|
||
// that makes a body vanish for a frame — skip rather than draw something undefined
|
||
if (!Number.isFinite(cutY) || !Number.isFinite(h) || !Number.isFinite(gp.s)) continue;
|
||
if (h * gp.s < CUT_MIN_PX) continue;
|
||
budget--;
|
||
// the part ABOVE the plane is redrawn on top of it; everything below stays where it was and is
|
||
// tinted by the translucent beam already laid over it
|
||
ctx.save();
|
||
ctx.beginPath(); ctx.rect(0, 0, W, cutY); ctx.clip();
|
||
drawZombie(zb);
|
||
ctx.restore();
|
||
// ...and THE LINE, only where the plane actually passes through the body (one that the beam
|
||
// clears over the head of, or passes under the feet of, gets none).
|
||
if (cutY < proj(zb.x, h, zb.z).y || cutY > gp.y) continue;
|
||
// Thickness follows perspective — a share of the body's own drawn height, squared, because this
|
||
// camera's scale only spans about 1.5x across the court and a linear falloff was invisible.
|
||
const bandH = Math.max(0.4, h * gp.s * gp.s * 0.0009);
|
||
const hw = h * 0.22 * gp.s; // narrow, so it stays inside the silhouette rather than overshooting
|
||
ctx.fillStyle = CUT_COL;
|
||
ctx.fillRect(Math.round(gp.x - hw), cutY - bandH / 2, Math.round(hw * 2), bandH);
|
||
}
|
||
}
|
||
|
||
// The target mark, drawn AFTER the bodies — it is a mark ON the shambler, so painting it with the
|
||
// rest of the band (which happens before the sprite pass, so a nearer body correctly occludes the
|
||
// arc) hid it completely behind the very thing it was marking. That is why it was never once seen.
|
||
// It sits on the BODY: that is where the ball actually strikes (the hit test is a radius at any
|
||
// height under 1.9 m) and where the wound lands, so a ring on the grass pointed at the one part of
|
||
// a shambler the shot has nothing to do with. Filled and washed out in the band's own colour
|
||
// rather than a hard 2px ring, so it marks without looking like a decal stuck to the lawn.
|
||
function drawAimMark(a, u0, flying) {
|
||
const left = 1 - u0;
|
||
if (left <= 0.01) return;
|
||
const col = a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0';
|
||
const onBody = !!a.zt;
|
||
const m = proj(a.tx, a.ty, a.tz); // a.ty is where the ball ARRIVES — mark and impact are one point
|
||
// it goes with the band once fired: a mark still burning on the target is the other half of the
|
||
// laser look, and the aim it represents is already spent
|
||
ctx.globalAlpha = (onBody ? 0.42 : 0.24) * (flying ? left * left : 1);
|
||
ctx.fillStyle = col;
|
||
ctx.beginPath();
|
||
if (onBody) ctx.ellipse(m.x, m.y, m.s * 0.26, m.s * 0.38, 0, 0, 7); // upright, over the torso
|
||
else ctx.ellipse(m.x, m.y, m.s * 0.30, m.s * 0.12, 0, 0, 7); // flat, lying on the grass
|
||
ctx.fill();
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
|
||
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'];
|
||
// ...and the same mound for something the fire got: cold, dry, grey. A body that burns has nothing
|
||
// left to spray, so it should not leave a wet red heap behind it.
|
||
const ASH_COLS = ['#4a4744', '#3a3835', '#5c5854', '#2e2c2a'];
|
||
function buildBlobs(pl) {
|
||
const rnd = mulberry32((pl.seed | 0) + 1), n = Math.round(6 + pl.size * 5);
|
||
pl.blobs = [];
|
||
for (let k = 0; k < n; k++) {
|
||
const ang = rnd() * 6.2832, rad = Math.sqrt(rnd()); // uniform over the disk
|
||
pl.blobs.push({ nx: Math.cos(ang) * rad, nz: Math.sin(ang) * rad,
|
||
lift: rnd(), pr: 1 + rnd() * 2,
|
||
color: pl.ash ? (rnd() < 0.12 ? '#8a8580' : ASH_COLS[(rnd() * 4) | 0]) // a few pale flecks of bone
|
||
: (rnd() < 0.1 ? '#cfc9b0' : PILE_COLS[(rnd() * 4) | 0]) });
|
||
}
|
||
pl.blobs.sort((a, b) => (b.nz - 1.25 * b.lift) - (a.nz - 1.25 * a.lift)); // back/low first, peak on top
|
||
pl.builtSize = pl.size;
|
||
}
|
||
function drawGorePile(pl) {
|
||
if (!pl.blobs || pl.size > pl.builtSize) { buildBlobs(pl); pl.cv = null; } // rebuild only on growth, never on the mop-shrink
|
||
const base = proj(pl.x, 0, pl.z), s = base.s, sz = pl.size;
|
||
const key = Math.round(s * 4); // proj scale is constant per pile until growth or an orientation change
|
||
if (!pl.cv || pl.cvKey !== key) { // pre-render: 1 drawImage per frame instead of ~26 ellipses (battery)
|
||
const pad = Math.ceil(sz * 0.28 * s + 4);
|
||
const off = document.createElement('canvas');
|
||
off.width = off.height = pad * 2;
|
||
const oc = off.getContext('2d');
|
||
for (const b of pl.blobs) {
|
||
const sx = pad + b.nx * sz * 0.28 * s;
|
||
const sy = pad + b.nz * sz * 0.28 * s * 0.4 - b.lift * sz * 0.14 * s; // depth squished onto ground; lift raises it
|
||
const r = Math.max(1.2, b.pr * s * 0.035);
|
||
oc.fillStyle = b.color;
|
||
oc.beginPath(); oc.ellipse(sx, sy, r, Math.max(1, r * 0.72), 0, 0, 7); oc.fill();
|
||
}
|
||
pl.cv = off; pl.cvKey = key; pl.cvPad = pad;
|
||
}
|
||
ctx.drawImage(pl.cv, base.x - pl.cvPad, base.y - pl.cvPad);
|
||
}
|
||
function drawPiles(near) { // split at the net like zombies so height occludes correctly
|
||
for (const pl of piles.filter(p => near ? p.z > COURT.netZ : p.z <= COURT.netZ).sort((a, b) => a.z - b.z))
|
||
drawGorePile(pl);
|
||
}
|
||
|
||
// THE TOUR SWAP. Everything above builds a sprite NAME the same way it always did; this is the one
|
||
// place the women's set exists. Her frames are the men's name + '_f', and a name with no twin — or
|
||
// one whose PNG has not loaded yet — falls straight through to the men's frame. That fallback is
|
||
// deliberate: a cached 404 on one female frame degrades to a man for a frame, it does not blink the
|
||
// body out of existence (the same rule drawZombie's missing-frame guard is built on).
|
||
const SK = n => (tour.sfx && SPR[n + tour.sfx]) ? n + tour.sfx : n;
|
||
|
||
function drawChar(ch) {
|
||
// No split-step. The receiver used to be slid 0.12 m side to side while waiting on a serve, on top
|
||
// of a swap to the run frames; both are gone. Sliding a sprite sideways is not footwork — it reads
|
||
// as the man skating — and the idle pair's own weight shift is the whole of what he should be doing.
|
||
const p = proj(ch.x, 0, ch.z);
|
||
const hpx = PLAYER_H * p.s;
|
||
// pick sprite
|
||
const pre = ch.side === 'P' ? 'p_' : 'o_';
|
||
let name = pre + (Math.floor(G.idleT * 1.6) % 2 ? 'idle1' : 'idle2'); // idle weight shift
|
||
if (G.phase === 'pretoss' && ch.side === score.server && ch.pose === 'idle') {
|
||
// The two bounce frames are the SAME drawing from one sheet — identical stance, identical legs,
|
||
// and only the free hand's height and reach differ — so the swap cannot read as a flicker. It
|
||
// follows the BALL: hand up when the ball is up at it, hand out and low when the ball is near
|
||
// the ground. BOUNCE_IDLE.apex is measured off that palm, so the ball meets it.
|
||
name = pre + (Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) > 0.5 ? 'bounce2' : 'bounce');
|
||
}
|
||
// THE PULL IS THE WIND-UP — three poses, not a scrubbable animation. Waiting: the bounce stance.
|
||
// Drawing the slingshot: `p_pull`, the racket dropping behind him. Release: `p_smash`, the drive.
|
||
// A four-stage version keyed to pullFrac was built and played, and Oliver cut it back to this:
|
||
// on a phone, at thumb speed, the middle stages are never read — the three states are the beat.
|
||
if (G.aim && G.aim.mag > 0 && ch.side === 'P' && score.server === 'P' && ch.pose === 'idle')
|
||
name = 'p_pull';
|
||
// Four-frame run where the frames exist (the player), two where they don't (the opponent). Same
|
||
// CYCLE rate either way — more frames subdivide the stride, they do not speed it up, exactly as
|
||
// ZFRAMES does for the shamblers.
|
||
const runN = SPR[pre + 'run4'] ? 4 : 2;
|
||
if (ch.pose === 'run') name = pre + 'run' + (Math.floor(ch.runT * 4.5 * runN) % runN + 1);
|
||
// (the receiver used to flip to run1/run2 here as a "split-step" — it read as a man sprinting on
|
||
// the spot, because the run frames are a full leaning stride, not a ready-hop. He idles now.)
|
||
if (ch.pose === 'swing') name = pre + (ch.poseT < 0.15 ? 'swing1' : 'swing2');
|
||
if (ch.pose === 'serve') name = pre + (G.phase === 'toss' ? 'toss' : 'smash');
|
||
if (ch.pose === 'run') p.y -= Math.floor(ch.runT * 18) % 2; // 1px run bob
|
||
const img = SPR[SK(name)];
|
||
drawShadow(p.x, p.y, hpx * 0.22, hpx * 0.06);
|
||
if (img) {
|
||
const k = hpx / SPRITE_H; // sprites share one scale; the idle pose defines it
|
||
const w = img.width * k, h = img.height * k;
|
||
ctx.save();
|
||
ctx.translate(p.x, p.y);
|
||
if (ch.facing < 0) ctx.scale(-1, 1);
|
||
ctx.drawImage(img, -w / 2, -h, w, h);
|
||
ctx.restore();
|
||
} else drawFallbackChar(ch, p, hpx);
|
||
// No blood painted on the player. Oliver: "the player blood looks odd, appears as a painted layer
|
||
// on top" — and he is right, and it also broke the settled rule that blood is never painted into
|
||
// the man. His health reads as bleedPlayer()'s live drip plus the screen-edge wash on impact.
|
||
}
|
||
|
||
// The death. It used to be a red tint and a shake for half a second, which reads as "the sprite is
|
||
// having a moment", not as being eaten. Now: he is dragged to his knees fighting it (p_die1/p_die2
|
||
// alternating), then pulled flat and pressed DOWN into the ground as the ring of bodies closes over
|
||
// him — he is drawn inside the crowd's own z order (see render()), so the ones between him and the
|
||
// camera hide him exactly as they arrive. What is left when he is gone is the mound.
|
||
// Five DRAWN frames, arched back -> knees -> hands -> flat -> a heap, each one genuinely shorter
|
||
// than the last (100/84/60/47/20% of standing height, common-scaled off one sheet). The first pass
|
||
// faked the collapse by scaling one sprite down, which is miniaturising a man, not killing him.
|
||
// Nothing here is scaled: each frame is drawn at ITS OWN height, the same rule the walk cycles use.
|
||
const DIE_AT = [0, 0.15, 0.35, 0.6, 0.8]; // fraction of the way to EATEN_BURST each frame takes over
|
||
function drawDying() {
|
||
const at = G.eatenAt || P, t = G.eatenT;
|
||
if (t >= EATEN_BURST) return; // he is the pile now
|
||
const k = t / EATEN_BURST;
|
||
let i = 0; while (i + 1 < DIE_AT.length && k >= DIE_AT[i + 1]) i++;
|
||
const img = SPR[SK('p_die' + (i + 1))];
|
||
const p = proj(at.x, 0, at.z);
|
||
const j = i < 2 ? 1.6 : i < 4 ? 0.7 : 0; // he stops fighting on the way down
|
||
ctx.save();
|
||
ctx.translate((Math.random() * 2 - 1) * j, (Math.random() * 2 - 1) * j * 0.6);
|
||
if (img) {
|
||
const h = PLAYER_H * p.s * (img.height / SPRITE_H), w = h * img.width / img.height;
|
||
drawShadow(p.x, p.y, w * 0.42, h * 0.06, 0.3);
|
||
ctx.drawImage(img, p.x - w / 2, p.y - h, w, h);
|
||
} else drawFallbackChar(P, p, PLAYER_H * p.s); // sprites not loaded: he still dies
|
||
ctx.restore();
|
||
}
|
||
|
||
function drawFallbackChar(ch, p, h) { // shown only until sprites load
|
||
const you = ch.side === 'P';
|
||
const u = h / 16, x = p.x, y = p.y;
|
||
const skin = '#e8b88a', shirt = you ? '#e8e8e8' : '#d04848', shorts = you ? '#3858a8' : '#404040';
|
||
ctx.fillStyle = skin; ctx.fillRect(x - 2 * u, y - 16 * u, 4 * u, 4 * u);
|
||
ctx.fillStyle = you ? '#804820' : '#282828'; ctx.fillRect(x - 2 * u, y - 16 * u, 4 * u, 1.2 * u);
|
||
ctx.fillStyle = shirt; ctx.fillRect(x - 2.6 * u, y - 12 * u, 5.2 * u, 6 * u);
|
||
ctx.fillStyle = shorts; ctx.fillRect(x - 2.4 * u, y - 6 * u, 4.8 * u, 3 * u);
|
||
ctx.fillStyle = skin; ctx.fillRect(x - 1.8 * u, y - 3 * u, 1.4 * u, 3 * u); ctx.fillRect(x + 0.4 * u, y - 3 * u, 1.4 * u, 3 * u);
|
||
}
|
||
|
||
// the courtside weapon rack along the bottom: PLAIN + the four tins. Tap a tile (or 1–5) to
|
||
// pick which one your next serve throws; empty tins dim out. Geometry is shared with input
|
||
// via rackHit so a tap lands on the tile you see.
|
||
const RACK_BOT = 0; // px below the rack: none — it sits ON the bottom edge. SAFE.bottom still lifts
|
||
// it clear of the home indicator, which is the only gap that has to be there.
|
||
const RACK_GRAB = 7; // px the TOUCH band extends above the drawn rack (below, it runs to the screen edge)
|
||
// The bottom text slot. Named for the draw meter that used to live here; the meter is gone (the
|
||
// arc band carries power now) and the apron below is left to the gesture demo.
|
||
// The rack holds ten now, and ten tiles across a 480px canvas would be ~39 CSS px on a phone —
|
||
// under the thumb target rackHit was widened for in the first place. So it grows a SECOND ROW
|
||
// UPWARD instead, and only once you are actually carrying more than five: the common case is still
|
||
// one row on the bottom edge, exactly as before.
|
||
const rackRows = () => (G.rack.length + 1 > RACK_ROW ? 2 : 1);
|
||
export const rackTop = () => H - TUNE.rackH * rackRows() - RACK_BOT - SAFE.bottom;
|
||
export const METER_Y = () => rackTop() - 17;
|
||
function rackGeom() {
|
||
const h = TUNE.rackH, tw = Math.min(64, (W - 4) / RACK_ROW), rw = tw * RACK_ROW;
|
||
const rows = rackRows();
|
||
return { x0: Math.round((W - rw) / 2), y0: rackTop(), tw, h, rows, n: rows * RACK_ROW, rw };
|
||
}
|
||
export function rackHit(nx, ny) { // normalized [0,1] canvas coords -> slot index, or -1
|
||
if (G.mode !== 'splatter' || G.interlude) return -1; // no rack during the tennis-rules duel
|
||
const g = rackGeom(), cx = nx * W, cy = ny * H;
|
||
// The touch band is deliberately BIGGER than the art: up by RACK_GRAB and all the way down to
|
||
// the screen edge, reclaiming the dead margin below. At 22px tall the tiles were ~30 CSS px on a
|
||
// phone — well under the 44px everyone's thumb expects — which is why switching tins felt fiddly.
|
||
if (cy < g.y0 - RACK_GRAB || cx < g.x0 || cx >= g.x0 + g.rw) return -1;
|
||
// The FIRST five never move: they stay welded to the bottom edge and the second five stack above,
|
||
// so growing the rack doesn't slide the ball out from under a thumb that already knows where it is.
|
||
// Clamped rather than rejected — the RACK_GRAB overhang sits above the top row.
|
||
const fromTop = Math.max(0, Math.min(g.rows - 1, ((cy - g.y0) / g.h) | 0));
|
||
const col = Math.max(0, Math.min(RACK_ROW - 1, ((cx - g.x0) / g.tw) | 0));
|
||
return (g.rows - 1 - fromTop) * RACK_ROW + col;
|
||
}
|
||
function drawRack() {
|
||
const g = rackGeom(), pad = 3;
|
||
// Built from what you are CARRYING, not from three fixed arrays. Slot 0 is BALL unless an
|
||
// overflow weapon is riding it (G.temp), and an empty slot is a blank socket rather than a
|
||
// permanently dimmed weapon you never had.
|
||
const W_ = i => WEAPONS[slotId(i)];
|
||
ctx.save(); ctx.lineWidth = 1;
|
||
for (let i = 0; i < g.n; i++) {
|
||
const w = W_(i), stockI = slotStock(i), temp = i === 0 && G.temp;
|
||
const x = g.x0 + (i % RACK_ROW) * g.tw;
|
||
const y0 = g.y0 + (g.rows - 1 - ((i / RACK_ROW) | 0)) * g.h; // slots 0-4 on the bottom edge, always
|
||
const sel = G.weapon === i, dim = stockI <= 0;
|
||
const label = i === 0 && !temp ? 'BALL' : w ? w.label : '';
|
||
const icon = i === 0 && !temp ? 'w_ball' : w ? w.icon : null;
|
||
// rounded slots: five separate tiles read as five buttons, which is what they are
|
||
panel(x + 1.5, y0 + 1.5, g.tw - 3, g.h - 3, 4,
|
||
sel ? '#123a24' : '#0a2418', // sunk slot
|
||
sel ? UI.pick : '#05160e'); // gold frame when live
|
||
// icon: left, vertically centred, padded off the edge
|
||
const box = g.h - pad * 2, icx = x + pad + box / 2, icy = y0 + g.h / 2;
|
||
if (!icon) { ctx.globalAlpha = 1; continue; } // an empty socket: nothing gathered for it yet
|
||
ctx.globalAlpha = dim ? 0.35 : 1;
|
||
const spr = SPR[icon];
|
||
if (spr) {
|
||
const k = Math.min(box / spr.width, box / spr.height), iw = spr.width * k, ih = spr.height * k;
|
||
ctx.drawImage(spr, icx - iw / 2, icy - ih / 2, iw, ih);
|
||
} else { ctx.fillStyle = UI.ball; ctx.beginPath(); ctx.arc(icx, icy, 3, 0, 7); ctx.fill(); } // fallback until the PNG loads
|
||
// count over label, both centred on the text column right of the icon — aligned as one unit
|
||
const tx = x + pad + box + (g.tw - pad * 2 - box) / 2;
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||
ctx.globalAlpha = dim ? 0.5 : 1;
|
||
ctx.font = F(8); ctx.fillStyle = sel ? UI.pick : '#e8f0dc';
|
||
ctx.fillText(stockI === Infinity ? '∞' : '×' + stockI, tx, y0 + g.h * 0.33); // ball = ∞, else ×count
|
||
ctx.font = F(6, 0); ctx.fillStyle = sel ? '#f2ead6' : temp ? UI.pink : '#8fa89a';
|
||
ctx.fillText(label, tx, y0 + g.h * 0.72); // weapon label, under its count
|
||
ctx.globalAlpha = 1;
|
||
// the burst countdown belongs to whichever slot is holding the armed weapon now, not to slot 4
|
||
if (G.mgT > 0 && slotId(i) === G.gunId) { ctx.fillStyle = UI.pick; ctx.fillRect(x + 2, y0 + g.h - 2, (g.tw - 4) * Math.min(1, G.mgT / (WEAPONS[G.gunId]?.len || TUNE.mgPickupLen)), 1.5); }
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
// the centered milestone 'block': a real pause, front and center. Shared by the sudden-death duel
|
||
// and the SET announcement so both read as the same interruption. Assumes textAlign is already set.
|
||
function drawCard(title, sub1, sub2, color) {
|
||
// 80 tall, not 72: the second sub line ends at H/2+26 and the band used to stop at +30, so it sat
|
||
// all but touching the edge. The band now clears it by 10 px, the same air it has at the top.
|
||
ctx.fillStyle = 'rgba(10,6,8,0.66)'; ctx.fillRect(0, H / 2 - 44, W, 80);
|
||
ctx.textAlign = 'center';
|
||
// the title SHOUTS (Bangers, twice the old size); the subs stay Silkscreen, because they are the
|
||
// detail you read second and a whole card in the loud face is just noise
|
||
ctx.font = B(30); shadowText(title, W / 2, H / 2 - 34, color);
|
||
ctx.font = F(8);
|
||
if (sub1) shadowText(sub1, W / 2, H / 2 + 4, UI.cream);
|
||
if (sub2) shadowText(sub2, W / 2, H / 2 + 18, UI.cream);
|
||
}
|
||
|
||
// Sit in the serve stance doing nothing and the gesture demonstrates itself: a ghost thumb slides
|
||
// BACK from the player while chevrons grow FORWARD up the court, then it releases and repeats.
|
||
// THE APRON IS THIS DEMO'S ROOM, and now it is the only thing in it. The draw meter used to share
|
||
// the space, which was backwards on both counts: a live readout down here is under your thumb the
|
||
// whole time it matters (the arc band carries power now, up-court where you can see it), while
|
||
// the demo only ever plays when your thumb is NOT there. So it gets the whole apron and is drawn
|
||
// at a size you can read at arm's length.
|
||
// One mimed gesture: a dotted track out of the player's feet, an arrowhead at the far end, the
|
||
// ghost thumb sliding along it, and the words. (dx, dy) is the unit direction — the only thing that
|
||
// differs between the two lessons, which is why this is one function and not two near-copies.
|
||
function demoSwipe(dx, dy, label) {
|
||
const cyc = 1.9, t = (G.idleHintT - TUNE.pullHintAfter) % cyc;
|
||
const draw = Math.min(1, t / 1.15); // the stroke itself, then a beat held at full
|
||
const gone = t > 1.45; // released — the thumb lifts off
|
||
const feet = proj(P.x, 0, P.z).y;
|
||
const room = Math.max(30, METER_Y() - 10 - feet); // apron between his feet and the text slot
|
||
const travel = Math.min(46, room * 0.5);
|
||
const ax = Math.round(proj(P.x, 0, P.z).x), ay = Math.round(feet + 12);
|
||
const len = travel * draw;
|
||
ctx.save();
|
||
ctx.globalAlpha = gone ? 0 : 0.85;
|
||
// the dotted track the thumb travels
|
||
ctx.fillStyle = UI.dim;
|
||
for (let d = 7; d < travel + 4; d += 7) ctx.fillRect(ax + dx * d - 1, ay + dy * d, 2, 2);
|
||
// An arrowhead at the far end, pointing the way you actually move. The demo used to grow chevrons
|
||
// UP-court as the pull grew — the shot going the opposite way to the thumb was meant to be the
|
||
// lesson, but an up arrow on screen is an instruction to swipe up, and that gesture does not exist.
|
||
ctx.fillStyle = UI.warm;
|
||
const av = travel + 16, s = 5; // clear of where the thumb lands at full stroke
|
||
const hx = ax + dx * av, hy = ay + dy * av; // arrow tip, and the two barbs behind it
|
||
ctx.beginPath();
|
||
ctx.moveTo(hx + dx * s, hy + dy * s);
|
||
ctx.lineTo(hx - dx * s * 0.7 + dy * s, hy - dy * s * 0.7 + dx * s);
|
||
ctx.lineTo(hx - dx * s * 0.7 - dy * s, hy - dy * s * 0.7 - dx * s);
|
||
ctx.fill();
|
||
// ...and say it in words. The mime alone was too abstract to read as an instruction.
|
||
ctx.font = F(8);
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
||
// The label sits under the TRACK, not under the arrowhead — following the arrow sideways would
|
||
// carry it off the edge of the screen, and clearing the full travel below a horizontal stroke
|
||
// left it floating in the middle of an empty apron with nothing to attach itself to.
|
||
shadowText(label, ax, ay + (dy ? travel + 24 : 18), UI.warm);
|
||
// the ghost thumb
|
||
ctx.globalAlpha = gone ? 0 : 0.9;
|
||
ctx.fillStyle = UI.cream;
|
||
ctx.beginPath(); ctx.arc(ax + dx * len, ay + dy * len, 6, 0, 7); ctx.fill();
|
||
ctx.fillStyle = 'rgba(0,0,0,0.45)';
|
||
ctx.beginPath(); ctx.arc(ax + dx * len, ay + dy * len, 2.5, 0, 7); ctx.fill();
|
||
ctx.restore();
|
||
}
|
||
|
||
// The apron plays the two gestures in alternation — pull, dodge, pull, dodge. Both are one-thumb
|
||
// moves made from the same stance and the direction is the ONLY thing that tells them apart, so
|
||
// showing them in the same place, one after the other, is what makes that legible. The sidestep
|
||
// alternates sides too, or it reads as a move that only goes right.
|
||
function drawPullHint() {
|
||
const n = ((G.idleHintT - TUNE.pullHintAfter) / 1.9) | 0;
|
||
if (n % 2 === 0) { demoSwipe(0, 1, 'PULL DOWN'); return; }
|
||
// ...and it mimes AWAY from whichever edge he is standing against, or the stroke it is teaching
|
||
// runs off the side of the screen. Only the alternation gives way; the lesson does not.
|
||
const px = proj(P.x, 0, P.z).x;
|
||
const dir = px < W * 0.3 ? 1 : px > W * 0.7 ? -1 : (n % 4 === 1 ? -1 : 1);
|
||
demoSwipe(dir, 0, 'SWIPE TO DODGE');
|
||
}
|
||
|
||
// The modal: drawn LAST, over everything, and the world behind it is frozen (gameplay.update
|
||
// early-returns). Unlike drawCard this is a wall — nothing moves until it is tapped away.
|
||
// easeOutBack: overshoots past the target and settles. The arcade snap — nothing in a cabinet
|
||
// menu ever arrived by decelerating politely into place.
|
||
const BACK_C = 1.70158;
|
||
const easeBack = p => 1 + (BACK_C + 1) * (p - 1) ** 3 + BACK_C * (p - 1) ** 2;
|
||
|
||
function drawModal(m) {
|
||
// The entrance (m.in, 0..1, ticked in gameplay's update behind the world freeze): the icon and
|
||
// title fly in from the RIGHT, the body lines cascade in from the LEFT one after another, each
|
||
// on its own staggered easeOutBack. Every x is ROUNDED — a fractional x on a nearest-neighbour
|
||
// scaled image re-snaps its pixel columns every frame, which is the same boiling that once read
|
||
// as HUD flicker. Legacy/test modals with no `in` field draw fully landed.
|
||
const t = m.in === undefined ? 1 : m.in;
|
||
const STAG = 0.08, RUN = 0.62; // per-element delay, and travel time
|
||
const lane = k => { // 0 = off screen, 1 = home (may overshoot)
|
||
const p = Math.max(0, Math.min(1, (t - k * STAG) / RUN));
|
||
return p >= 1 ? 1 : easeBack(p);
|
||
};
|
||
const dx = (k, from) => Math.round(from * (1 - lane(k)) * W * 0.85); // from: -1 left, 1 right
|
||
ctx.fillStyle = `rgba(8,6,10,${(0.82 * Math.min(1, t / 0.35)).toFixed(3)})`; // the dim washes in first
|
||
ctx.fillRect(0, 0, W, H);
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||
const body = m.body || [], img = m.icon && SPR[m.icon];
|
||
// cap the illustration against the viewport, not just the asset: at 270px tall (landscape) a
|
||
// portrait-sized icon pushed the TAP prompt down onto the weapon rack
|
||
const iconH = img ? Math.min(m.iconH || 40, H * 0.28) : 0;
|
||
const blockH = (iconH ? iconH + 12 : 0) + 26 + body.length * 13;
|
||
let y = Math.round(H / 2 - blockH / 2);
|
||
const headX = dx(0, 1); // icon and title travel together, as one masthead
|
||
if (img) {
|
||
const iw = Math.min(W - 24, iconH * img.width / img.height);
|
||
ctx.drawImage(img, Math.round(W / 2 - iw / 2) + headX, y, Math.round(iw), iconH);
|
||
y += iconH + 12;
|
||
}
|
||
ctx.font = B(24); ctx.fillStyle = m.color || UI.warm; // an announcement, so the announcement face
|
||
if (headX) for (const [f, a] of [[0.7, 0.12], [0.35, 0.26]]) { // speed streak: ghosts strung out behind it
|
||
ctx.save(); ctx.globalAlpha = a;
|
||
ctx.fillText(m.title, W / 2 + Math.round(headX * (1 + f)), y + 8);
|
||
ctx.restore();
|
||
}
|
||
ctx.fillText(m.title, W / 2 + headX, y + 8); y += 26;
|
||
ctx.font = F(8, 0);
|
||
body.forEach((l, i) => { shadowText(l, W / 2 + dx(i + 1, -1), y + 6, UI.cream); y += 13; });
|
||
// the prompt only exists once everything has landed — offering the tap mid-flight would be a lie,
|
||
// dismissModal swallows it until then
|
||
if (t >= 1 && Math.floor(G.idleT * 2) % 2) { // the prompt blinks so it never reads as part of the copy
|
||
ctx.font = F(8);
|
||
shadowText(IS_TOUCH ? 'TAP TO CONTINUE' : 'PRESS ANY KEY', W / 2, Math.round(H / 2 + blockH / 2 + 22), UI.warm);
|
||
}
|
||
ctx.textBaseline = 'top';
|
||
}
|
||
|
||
// ------------------------------------------------------------------ frame ----
|
||
const AD_H = 14; // px tall the courtside ad strip is drawn — also the ceiling for centred HUD text
|
||
const FLASH_Y = 68; // the ONE slot every free-floating message uses. Clear of the scoreboard block
|
||
// (6 + 42 + 15 = 63 at its tallest), which the old 60 was not in portrait —
|
||
// there the board is over half the width, so a centred flash landed on it.
|
||
export function render() {
|
||
ctx.setTransform(RES, 0, 0, RES, 0, 0); // logical W x H coordinates at RES density
|
||
// ease the aim camera FIRST, before anything projects — one camera per frame (see proj)
|
||
aimCam += ((G.aim ? 1 : 0) - aimCam) * 0.15;
|
||
if (G.shake) { // bomb kick: jitter the whole world (transform is reset before the HUD)
|
||
const m = G.shake.mag * G.shake.t / 0.35;
|
||
ctx.translate((Math.random() * 2 - 1) * m, (Math.random() * 2 - 1) * m);
|
||
}
|
||
// A scripted PUNCH-IN on a world point, in the same place and for the same reason as the shake:
|
||
// it is a camera move on the drawn frame, not on proj(). A perspective dolly would want camZ,
|
||
// aimCam and every projected HUD anchor to agree with each other for the sake of one 1.15s beat
|
||
// in the transition — this is a flat 2D scale about a point, it only ever crops inward (so no
|
||
// background edge can show), and it costs three lines.
|
||
if (G.zoom) {
|
||
const p = Math.min(1, G.zoom.t / G.zoom.T);
|
||
const k = 1 + (G.zoom.k - 1) * Math.sin(p * Math.PI); // in and back out, no cut at either end
|
||
const q = proj(G.zoom.x, 0, G.zoom.z);
|
||
ctx.translate(q.x, q.y); ctx.scale(k, k); ctx.translate(-q.x, -q.y);
|
||
}
|
||
|
||
const pal = palette(), pt = paletteT();
|
||
// CLEAR THE WHOLE FRAME to the apron colour first. There was no clear at all: the stands cover the
|
||
// top and the out-area quad covers the middle, but the quad only reaches z=30, and below where that
|
||
// projects the canvas was simply TRANSPARENT — you saw the page through it as a duller band across
|
||
// the bottom of the screen. Oversized by 8px so a camera shake can never expose an edge.
|
||
ctx.fillStyle = pal.out;
|
||
ctx.fillRect(-8, -8, W + 16, H + 16);
|
||
pageBackground(pal.out); // ...and the page behind it matches, for the rounded display corners
|
||
// stands: the generated crowd, drawn in columns so it can BOUNCE when it cheers.
|
||
// sit the ad strip flush on the court's back edge (the out-area quad below reaches z=-6);
|
||
// pinning to that projected line keeps the boards clear of the lawn in BOTH orientations
|
||
// (a fixed baseline offset over-/under-shoots because camF differs portrait vs landscape).
|
||
const standsBottom = proj(0, 0, -6).y;
|
||
const crowdH = standsBottom - AD_H + 2;
|
||
// per-court crowd sheet, falling back to the grass one until it loads (or if a court ships
|
||
// without its own): a court with no crowd art must never drop to the dot fallback while the
|
||
// grass sheet is sitting right there.
|
||
const crowdImg = SPR[court.crowd] || 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.
|
||
// ...and its clap frame must come from the SAME sheet as the seated one, or the applause
|
||
// cross-fades between two different crowds.
|
||
const clapImg = crowdImg === SPR['x_crowd'] ? SPR['x_crowd2'] : SPR[court.crowd + '2'];
|
||
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[court.ads] || SPR['x_ads'];
|
||
if (ads) {
|
||
const tw = ads.width * (AD_H / ads.height);
|
||
for (let x = 0; x < W; x += tw) ctx.drawImage(ads, x, standsBottom - AD_H, tw, AD_H);
|
||
} else { ctx.fillStyle = pal.board; ctx.fillRect(0, standsBottom - AD_H, W, AD_H); }
|
||
// the rot dims the stadium: one tint over crowd + boards, fading home during duels
|
||
// the rot dims the stadium — but 0.5 on top of an already-dark crowd sheet buried the stands in
|
||
// splatter mode, where you spend most of the game. 0.26 still reads as "the lights went wrong".
|
||
if (pt > 0) { ctx.fillStyle = `rgba(12,8,20,${0.26 * pt})`; ctx.fillRect(0, 0, W, standsBottom); }
|
||
|
||
// ground + court (Super Tennis lawn); everything outside the doubles court is OUT
|
||
const dw = COURT.dblW / 2;
|
||
quadXZ(-14, -6, 14, 30, pal.out); // out area. NEVER let z approach
|
||
// TUNE.camZ (48): proj() divides by (camZ - z), so at z >= camZ the scale blows up and then goes
|
||
// NEGATIVE, and the quad's far edge folds back above the screen as an inverted triangle. The
|
||
// full-frame clear above is what covers the bottom of the screen; this quad does not need to.
|
||
quadXZ(-dw - 0.15, -0.15, dw + 0.15, COURT.L + 0.15, court.border); // perimeter border line
|
||
quadXZ(-dw, 0, dw, COURT.L, pal.lawn); // playing surface (doubles court)
|
||
// mow stripes: grass only. Clay and acrylic are laid flat, and banding them was the one thing
|
||
// that kept a recoloured court reading as grass however far the palette moved.
|
||
if (court.stripes)
|
||
for (let z = 0; z < COURT.L; z += 2.97) quadXZ(-dw, z, dw, Math.min(z + 1.49, COURT.L), pal.stripe);
|
||
// lines: baselines, doubles + singles sidelines, service box
|
||
line(-dw, 0, dw, 0, 2); line(-dw, COURT.L, dw, COURT.L, 2);
|
||
line(-dw, 0, -dw, COURT.L, 1.5); line(dw, 0, dw, COURT.L, 1.5);
|
||
line(-COURT.W / 2, 0, -COURT.W / 2, COURT.L, 1.5); line(COURT.W / 2, 0, COURT.W / 2, COURT.L, 1.5);
|
||
line(-COURT.W / 2, COURT.svcFar, COURT.W / 2, COURT.svcFar, 1);
|
||
line(-COURT.W / 2, COURT.svcNear, COURT.W / 2, COURT.svcNear, 1);
|
||
line(0, COURT.svcFar, 0, COURT.svcNear, 1);
|
||
line(0, 0, 0, 0.35, 1); line(0, COURT.L - 0.35, 0, COURT.L, 1); // center marks
|
||
drawRunOff(); // light falls off across the apron below the baseline
|
||
|
||
// 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})`
|
||
: d.ash ? `rgba(58,56,54,${d.a})` // burnt out: dark grey ash, not blood
|
||
: `rgba(140,18,18,${d.a})`; // craters burn black-brown
|
||
ctx.beginPath(); ctx.ellipse(p.x, p.y, p.s * d.r * 0.4, p.s * d.r * 0.16, 0, 0, 7); ctx.fill();
|
||
}
|
||
drawPiles(false); // far-side piles (occluded by the net); near-side piles drawn after the net
|
||
|
||
// landing rings: white = incoming ball, yellow/red = your shot (red lands out)
|
||
if (G.phase === 'rally' && ball.active && ball.bounces === 0 && ball.lastHit === 'O') {
|
||
const l = predictLanding();
|
||
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: the shot's actual flight, drawn as a band through the air.
|
||
// It replaces a chevron trail on the ground plus a draw meter across the bottom. The trail was
|
||
// a RAY — it pointed at the target but said nothing about the ball going over the net — and the
|
||
// meter lived in the apron, which is precisely where your thumb is while you pull, so the one
|
||
// readout you needed mid-gesture was under your hand.
|
||
// Now one object carries everything, up-court where you are already looking:
|
||
// WHERE the parabola itself, from previewServe — the same solver the release fires with
|
||
// WHETHER the colour, flipping at the kill threshold (cool blue = this only staggers)
|
||
// HOW MUCH the band's thickness and opacity, which is what a hue cannot say on its own
|
||
// Because the arc terminates at the auto-picked target, a weak pull and a full one at the same
|
||
// shambler draw the SAME curve in different colours — which is the "distance is free" rule made
|
||
// visible instead of merely documented.
|
||
// While pulling: the live preview, whole. After release: the same arc with its tail already
|
||
// eaten, drawn from where the ball has got to. G.aim wins — a new pull replaces the last shot's
|
||
// leftovers rather than drawing two bands at once. Held for the frame because the MARK on the
|
||
// far end has to wait for the bodies to be drawn (see drawAimMark below) and previewServe is a
|
||
// full ballistic solve — worth doing once.
|
||
const band = G.mode === 'splatter' && !G.interlude
|
||
&& (G.phase === 'pretoss' || G.phase === 'toss' || G.phase === 'rally')
|
||
? (G.aim ? previewServe(G.aim) : G.trail) : null;
|
||
const flying = !!band && band === G.trail; // aiming draws the whole arc; a fired one is spent
|
||
const bandU0 = flying ? band.t / band.T : 0;
|
||
// THE LASER PREVIEWS ITSELF. The ray IS the weapon, so an arcing ball-band (and the little ring it
|
||
// puts on a body) is describing a projectile that never exists. While the laser is armed and you
|
||
// are pulling, the preview is the WEDGE you are about to cut — same shape, same widening, just
|
||
// not yet lit.
|
||
const laserAim = G.aim && WEAPONS[slotId(G.weapon)]?.beam && !flying ? band : null;
|
||
if (!laserAim && band) drawShotBand(band, bandU0, flying); // the laser previews itself below, over the court
|
||
|
||
// far-side drawables in z order (far → near), then the net, then the near side
|
||
const farSide = [
|
||
{ z: COURT.netZ - 0.01, f: () => drawStatic('x_ump', UMP_X, COURT.netZ, UMP_H) },
|
||
];
|
||
if (!G.hideO) farSide.push({ z: O.z, f: () => drawChar(O) });
|
||
if (G.boy && G.boy.z <= COURT.netZ) farSide.push({ z: G.boy.z, f: () => drawBoy(G.boy) });
|
||
for (const zb of zombies) if (zb.z <= COURT.netZ) farSide.push({ z: zb.z, f: () => drawZombie(zb) });
|
||
farSide.sort((a, b) => a.z - b.z).forEach(d => d.f());
|
||
|
||
// net
|
||
const nl = proj(-COURT.netW / 2, 0, COURT.netZ), nr = proj(COURT.netW / 2, 0, COURT.netZ);
|
||
const nlt = proj(-COURT.netW / 2, COURT.netH, COURT.netZ), nrt = proj(COURT.netW / 2, COURT.netH, COURT.netZ);
|
||
ctx.fillStyle = 'rgba(20,26,34,0.72)';
|
||
ctx.beginPath(); ctx.moveTo(nl.x, nl.y); ctx.lineTo(nr.x, nr.y); ctx.lineTo(nrt.x, nrt.y); ctx.lineTo(nlt.x, nlt.y); ctx.fill();
|
||
ctx.strokeStyle = '#eeeeee'; ctx.lineWidth = 1.5;
|
||
ctx.beginPath(); ctx.moveTo(nlt.x, nlt.y); ctx.lineTo(nrt.x, nrt.y); ctx.stroke();
|
||
ctx.fillStyle = '#333'; ctx.fillRect(nl.x - 1, nlt.y, 2, nl.y - nlt.y); ctx.fillRect(nr.x - 1, nrt.y, 2, nr.y - nrt.y);
|
||
drawPiles(true); // near-side piles: after the net (they're closer), before the near actors
|
||
if (G.boy && G.boy.z > COURT.netZ) drawBoy(G.boy);
|
||
const nearSide = zombies.filter(zb => zb.z > COURT.netZ).map(zb => ({ z: zb.z, f: () => drawZombie(zb) }));
|
||
if (G.mode === 'eaten' && G.eatenT < EATEN_BURST) // the dying man takes his place in the queue
|
||
nearSide.push({ z: (G.eatenAt || P).z - 0.12, f: drawDying });
|
||
nearSide.sort((a, b) => a.z - b.z).forEach(d => d.f());
|
||
// THE LASER's fired wedge, ERASING ALONG ITS OWN PATH. Drawn HERE — after the net and after every
|
||
// body — because it is light in the air, not a decal on the grass. Drawn before them it went
|
||
// UNDER the net and behind the crowd, which is exactly why it read as lying on the floor.
|
||
// The fired beam, and the AIM PREVIEW, both draw here: after the net and after every body, because
|
||
// this is light in the air. Then the bodies standing in it are re-cut on top of it.
|
||
if (G.beam) {
|
||
const b = G.beam, k = Math.max(0, b.t / TUNE.beamT); // 1 at the flash, 0 as it dies
|
||
const L = drawBeamWedge(b.ux, b.uz, b.x, b.y, b.z, 1 - k);
|
||
drawBeamCuts(b.ux, b.uz, b.x, b.z, L);
|
||
} else if (laserAim) {
|
||
const L = drawBeamWedge(laserAim.v.vx, laserAim.v.vz, P.x, TUNE.beamY + TUNE.beamOriginLift, P.z, 0); // the preview is the same solid plane
|
||
drawBeamCuts(laserAim.v.vx, laserAim.v.vz, P.x, P.z, L); // "already while aiming"
|
||
}
|
||
if (band && !laserAim) drawAimMark(band, bandU0, flying); // ...now that there is a body to mark
|
||
|
||
// server idly bouncing the ball while waiting
|
||
if (G.phase === 'pretoss') {
|
||
const sv = score.server === 'P' ? P : O;
|
||
// The ball goes where the sprite reaches for it, and the two men reach to OPPOSITE SIDES of the
|
||
// screen. Both are right-handed, so both pat the ball with the left hand — but the player is
|
||
// drawn from BEHIND (his left hand is on the viewer's left) and the opponent from the FRONT
|
||
// (his left hand is on the viewer's right). One sign for both put the opponent's ball out by
|
||
// his racket instead of under his palm.
|
||
const mine = sv.side === 'P';
|
||
const bx = sv.x + (mine ? -BOUNCE_IDLE.dx : BOUNCE_IDLE.oDx);
|
||
const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * (mine ? BOUNCE_IDLE.apex : BOUNCE_IDLE.oApex);
|
||
drawBall(bx, by, sv.z);
|
||
}
|
||
// the match ball (and any triple-serve side balls in flight)
|
||
if ((ball.active || G.phase === 'over') && !ball.collected) drawBall(ball.x, ball.y, ball.z, true, ball.bomb, ball.flame);
|
||
for (const e of extras) drawBall(e.x, e.y, e.z);
|
||
// INCOMING hostile balls, marked so they can never be mistaken for one of yours: a red ring and a
|
||
// hard ground shadow, so you can read WHERE it will land while it is still in the air.
|
||
for (const s of shots) {
|
||
const sh = proj(s.x, 0, s.z);
|
||
drawShadow(sh.x, sh.y, Math.max(1.5, sh.s * 0.05), Math.max(1, sh.s * 0.025), 0.4);
|
||
const sp = proj(s.x, s.y, s.z), r = Math.max(2, sp.s * 0.045);
|
||
ctx.fillStyle = UI.bad;
|
||
ctx.beginPath(); ctx.arc(Math.round(sp.x), Math.round(sp.y), r + 1.5, 0, 7); ctx.fill();
|
||
ctx.fillStyle = UI.ball;
|
||
ctx.beginPath(); ctx.arc(Math.round(sp.x), Math.round(sp.y), r, 0, 7); ctx.fill();
|
||
}
|
||
// splatter particles (fire embers and machine smoke ride the same array)
|
||
for (const pt of parts) {
|
||
const pp = proj(pt.x, pt.y, pt.z);
|
||
if (pt.smoke) { // a broken machine's smoke: dark, soft-edged, growing and thinning as it climbs
|
||
const age = Math.min(1, pt.t / 1.8), r = Math.max(2, pp.s * 0.09 * (2 - age));
|
||
ctx.fillStyle = `rgba(46,44,48,${0.62 * age})`;
|
||
ctx.beginPath(); ctx.arc(pp.x | 0, pp.y | 0, r, 0, 7); ctx.fill();
|
||
continue;
|
||
}
|
||
// a ROCKET's exhaust: a soft round puff that fades hot-white -> orange -> grey, drawn additively
|
||
// so the plume glows instead of stippling. This is the difference between a motor and a firework.
|
||
if (pt.jet) {
|
||
const age = Math.min(1, pt.t / 0.6), r = Math.max(1.2, pp.s * 0.028 * (1.8 - age));
|
||
ctx.save(); ctx.globalCompositeOperation = 'lighter';
|
||
ctx.fillStyle = age > 0.72 ? `rgba(255,246,214,${age})`
|
||
: age > 0.38 ? `rgba(255,170,60,${age * 0.8})` : `rgba(120,110,110,${age * 0.5})`;
|
||
ctx.beginPath(); ctx.arc(pp.x, pp.y, r, 0, 7); ctx.fill(); ctx.restore();
|
||
continue;
|
||
}
|
||
if (pt.fine) { // the beacon's thread: ONE pixel, so the ball stays the bright thing
|
||
ctx.globalAlpha = Math.min(1, pt.t * 4);
|
||
ctx.fillStyle = pt.spark; ctx.fillRect(pp.x | 0, pp.y | 0, 1, 1);
|
||
ctx.globalAlpha = 1;
|
||
continue;
|
||
}
|
||
if (pt.ash) { // settling ash from a burnt body
|
||
ctx.fillStyle = pt.dark ? 'rgba(46,44,42,0.9)' : 'rgba(92,88,84,0.9)';
|
||
ctx.fillRect(pp.x | 0, pp.y | 0, 2, 2);
|
||
continue;
|
||
}
|
||
ctx.fillStyle = pt.spark ? pt.spark // a thrown round dragging its colour
|
||
: pt.pink ? (pt.t > 0.2 ? UI.pink : '#ff9df6') // a barrel bursting: debris, not gore
|
||
: pt.fire ? (pt.t > 0.12 ? '#ffc030' : '#ff8010')
|
||
: pt.dark ? 'rgba(96,14,14,0.9)' : 'rgba(170,22,22,0.9)';
|
||
ctx.fillRect(pp.x | 0, pp.y | 0, 2, 2);
|
||
}
|
||
// Machine-gun rounds: they are TENNIS BALLS, fired small and hard, so they are drawn as balls and
|
||
// they take the perspective like everything else. A fixed 4x4 fillRect stayed exactly as big at
|
||
// the far baseline as at your feet, which read as square confetti pinned to the screen rather than
|
||
// as something travelling away from you. Radius is a shade over half the match ball's 0.09.
|
||
// A barrel weapon's round carries its own colour and size (see zombies.firePellet), so a beacon
|
||
// shell is a fat cyan ball and a laser bolt a thin magenta one — told apart at a glance, which
|
||
// matters because they are all the same projectile underneath.
|
||
for (const pe of pellets) {
|
||
const sc = (pe.r ?? TUNE.mgPelletR) / TUNE.mgPelletR;
|
||
const sh = proj(pe.x, 0, pe.z); drawShadow(sh.x, sh.y, Math.max(1, sh.s * 0.04 * sc), Math.max(1, sh.s * 0.02 * sc), 0.25);
|
||
const pp = proj(pe.x, pe.y, pe.z), r = Math.max(1, pp.s * 0.05 * sc);
|
||
if (pe.glow) { // the exotic rounds carry their own light, the plain pellet does not
|
||
ctx.save(); ctx.globalCompositeOperation = 'lighter';
|
||
for (const [rf, af] of [[2.6, 0.20], [1.6, 0.34]]) { // a real bloom, not one flat disc
|
||
ctx.globalAlpha = af * Math.min(1.6, pe.glow);
|
||
ctx.fillStyle = pe.col || UI.ball;
|
||
ctx.beginPath(); ctx.arc(pp.x, pp.y, r * rf, 0, 7); ctx.fill();
|
||
}
|
||
ctx.restore(); ctx.globalAlpha = 1;
|
||
}
|
||
ctx.fillStyle = pe.col || UI.ball; ctx.beginPath(); ctx.arc(pp.x, pp.y, r, 0, 7); ctx.fill();
|
||
// a WHITE-HOT centre, bigger on a glowing round: the ball itself should be the brightest thing
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.beginPath(); ctx.arc(pp.x, pp.y, Math.max(0.6, r * (pe.glow ? 0.62 : 0.45)), 0, 7); ctx.fill();
|
||
}
|
||
// the chapter boss's shockwave: a ring of blood mist blown out through the crowd noise
|
||
if (G.bossBurst) {
|
||
const b = G.bossBurst, k = 1 - b.t / 0.75, p = proj(b.x, 0.5, b.z);
|
||
ctx.strokeStyle = `rgba(190,26,26,${(1 - k) * 0.8})`;
|
||
ctx.lineWidth = Math.max(1.5, p.s * 0.05 * (1 - k));
|
||
ctx.beginPath(); ctx.ellipse(p.x, p.y, p.s * 0.9 * k, p.s * 0.32 * k, 0, 0, 7); ctx.stroke();
|
||
ctx.strokeStyle = `rgba(255,180,170,${(1 - k) * 0.35})`; ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.ellipse(p.x, p.y, p.s * 0.62 * k, p.s * 0.22 * k, 0, 0, 7); ctx.stroke();
|
||
}
|
||
if (G.mode !== 'eaten') drawChar(P); // ...the dying man is drawn with the crowd instead (drawDying)
|
||
// The braced "rambo racket" is NOT drawn any more. It was a procedural racket laid over a sprite
|
||
// that already holds one: straight up-court it projected onto his back as a floating oval, and
|
||
// offset to the side it just stuck out. The pellets streaming up-court are the feedback; the man
|
||
// holding his own racket is the picture.
|
||
|
||
ctx.setTransform(RES, 0, 0, RES, 0, 0); // un-shake: the HUD and overlays stay rock steady
|
||
// bomb bloom: a warm blood-orange wash, not a white-out. At 0.85 white it blinded you on a phone
|
||
// and hid the very gore eruption it was announcing.
|
||
if (G.boomFlash > 0) { ctx.fillStyle = `rgba(255,150,70,${Math.min(1, G.boomFlash / 0.06) * 0.3})`; ctx.fillRect(0, 0, W, H); }
|
||
// took a ranged hit: a red wash from the screen EDGES inward. Deliberately not a full-screen fill —
|
||
// this channel must read as "you are bleeding", not as "something exploded" (that is boomFlash).
|
||
if (G.hurtFlash > 0) {
|
||
const a = Math.min(1, G.hurtFlash / 0.3) * 0.55, b = 26;
|
||
ctx.fillStyle = `rgba(150,10,14,${a.toFixed(3)})`;
|
||
ctx.fillRect(0, 0, W, b); ctx.fillRect(0, H - b, W, b);
|
||
ctx.fillRect(0, 0, b, H); ctx.fillRect(W - b, 0, b, H);
|
||
}
|
||
|
||
// HUD — Wimbledon manual scoreboard as a 16-bit panel: bevelled bottle-green box (light
|
||
// top/left, dark bottom/right), a purple club spine, cream serif names, and the LIVE game
|
||
// sunk into a recessed slot — purple for the server, gold digits. Splatter adds a recessed
|
||
// status well (kills-to-next-point pips + weapon stock). Hard edges, no radius = retro.
|
||
const splat = G.mode !== 'tennis';
|
||
const bd = { face: '#1e5b3a', hi: '#3f9560', lo: '#0e3624', 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) {
|
||
// This row used to be bare text on the court. That was fine over one dark green lawn and is
|
||
// unreadable over a sunlit clay crowd or a floodlit blue one, so it gets its own panel — the
|
||
// same trick the scoreboard art above it already relies on.
|
||
ctx.lineWidth = 1;
|
||
// 6px was the smallest type in the game and it read as texture rather than as words. Both
|
||
// strings are 7 and the panel grew to carry them — they are the only permanent readout of how
|
||
// far into a run you are.
|
||
panel(0.5, bH + 1.5, bW - 1, 14, 3, 'rgba(8,26,18,0.82)', hexA(bd.hi, 0.35));
|
||
// which game you're in, and the sets banked, permanently — the 1.1s GAME N flash and the
|
||
// scoreboard's own per-row sets column were the only places either showed.
|
||
ctx.textAlign = 'right'; ctx.font = F(7, 0); ctx.fillStyle = bd.dim;
|
||
ctx.fillText(`GAME ${G.runGames + 1} · ${G.sets[0]} SET${G.sets[0] === 1 ? '' : 'S'}`, bW - 3, bH + 8);
|
||
}
|
||
if (splat && TUNE.killPips && TUNE.killsPerPoint > 1) { // kills-to-next-point pips, on the panel above
|
||
ctx.textAlign = 'left'; ctx.font = F(7, 0); ctx.fillStyle = bd.dim; ctx.fillText('NEXT', 4, bH + 8);
|
||
for (let k = 0; k < TUNE.killsPerPoint; k++) {
|
||
ctx.beginPath(); ctx.arc(34 + k * 8, bH + 8, 2.4, 0, 7);
|
||
if (k < G.killCount) { ctx.fillStyle = bd.gold; ctx.fill(); }
|
||
else { ctx.strokeStyle = '#5a6a5a'; ctx.lineWidth = 1; ctx.stroke(); }
|
||
}
|
||
}
|
||
ctx.restore();
|
||
ctx.textBaseline = 'top'; ctx.textAlign = 'center';
|
||
if (G.mode === 'splatter' && !G.interlude) drawRack(); // courtside weapon rack (hidden during the tennis-rules duel)
|
||
// phase C: the summary fades UP over the death animation instead of cutting on top of it
|
||
if (G.mode === 'eaten' && G.eatenT > EATEN_SUMMARY) {
|
||
const fade = Math.min(1, (G.eatenT - EATEN_SUMMARY) / 0.6);
|
||
ctx.save(); ctx.globalAlpha = fade;
|
||
ctx.fillStyle = 'rgba(10,4,6,0.72)'; ctx.fillRect(0, 0, W, H);
|
||
// The announcement face, at 26 rather than the card's 30: this is the longest string in the game
|
||
// and at 30 it ran edge to edge on a 270px-wide portrait canvas.
|
||
ctx.font = B(26);
|
||
shadowText('GAME, SET, MASSACRE.', W / 2, H / 2 - 44, UI.bad);
|
||
ctx.font = F(9);
|
||
shadowText('SURVIVED ' + G.runGames + ' GAMES · ' + G.sets[0] + ' SETS', W / 2, H / 2 - 14, UI.cream);
|
||
if (G.newBest) { // a fresh record crows; otherwise show the mark to beat
|
||
const pulse = 0.55 + 0.45 * Math.abs(Math.sin(G.eatenT * 4));
|
||
ctx.font = B(20);
|
||
shadowText('★ NEW BEST ★', W / 2, H / 2 + 2, hexA(UI.warm, pulse));
|
||
} else if (G.best && G.best.games > 0) {
|
||
ctx.font = F(8);
|
||
shadowText('BEST ' + G.best.games + ' GAMES · ' + G.best.sets + ' SETS', W / 2, H / 2 + 4, UI.dim);
|
||
}
|
||
// gated well past the animation so a panic tap can't skip it (main.js gates the restart the same)
|
||
if (G.eatenT > EATEN_RESTART) { ctx.font = F(8); shadowText(IS_TOUCH ? 'TAP TO PLAY AGAIN' : 'PRESS ANY KEY TO PLAY AGAIN', W / 2, H / 2 + 32, UI.warm); }
|
||
ctx.restore();
|
||
}
|
||
// Timed freezes swallow every input until they run out, which reads as the game having hung
|
||
// ("sometimes the controls just feel frozen"). Show the clock instead of hiding it. A modal is
|
||
// excluded on purpose — that one waits for YOU, and says so.
|
||
// 'transition' is excluded: startTransition parks overT at 9999 to hold the point flow while its
|
||
// own scripted timeline runs, and counting that down read as "resuming in 9999".
|
||
const waitT = G.modal || G.mode === 'eaten' || G.mode === 'transition' ? 0
|
||
: Math.max(G.changeover > 0 ? G.changeover : 0,
|
||
G.phase === 'over' ? G.overT : 0,
|
||
G.interlude ? G.interlude.introT : 0);
|
||
if (waitT > 0.5) {
|
||
// Kept clear of the ad strip — centred on H alone it landed right on the boards, which is the
|
||
// busiest band on the screen. The strip's own projected line is the ceiling, so it stays legible
|
||
// whatever the phone's height does to the layout. A 1px shadow, not a box, carries it over the crowd.
|
||
// Plain Silkscreen on purpose — a countdown is information, not an announcement.
|
||
const txt = 'RESUMING IN ' + Math.ceil(waitT);
|
||
const y = Math.min(Math.round(H * 0.34), Math.round(proj(0, 0, -6).y) - AD_H - 16);
|
||
ctx.font = F(10);
|
||
shadowText(txt, W / 2, y, hexA(UI.warm, 0.92));
|
||
}
|
||
if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !(G.mgT > 0) && !G.aim && waitT <= 0.5) { // while pulling, the draw meter owns this slot
|
||
ctx.font = F(8);
|
||
const touch = IS_TOUCH, switchHint = touch ? 'TAP THE RACK TO SWITCH' : '1-0 TO SWITCH';
|
||
const selW = WEAPONS[slotId(G.weapon)], sel = selW ? selW.label : 'PLAIN';
|
||
// "is the selected weapon one you UNLOAD rather than serve" — it used to be `G.weapon === 4`,
|
||
// which is meaningless once the slots hold whatever you gathered
|
||
const gunLike = selW && selW.kind === 'burst'; // the only kind you UNLOAD; a laser is served
|
||
// The DUEL is played under tennis rules — no slingshot, no rack, serve to the box (see the
|
||
// !G.interlude guards in solveShot and the aim reticle). It was still being told to pull back
|
||
// to aim, which is the one thing that does nothing there.
|
||
// after a beat of inaction the apron demo takes over, and it says PULL DOWN in its own words —
|
||
// so this slot shuts up rather than printing the same instruction a second time 40 px below it
|
||
const demo = G.mode === 'splatter' && !G.interlude && !gunLike && G.idleHintT > TUNE.pullHintAfter;
|
||
const txt = G.mode === 'splatter' && !G.interlude
|
||
? (gunLike ? (touch ? `TAP TO UNLOAD THE ${sel}` : `SPACE UNLOADS THE ${sel}`)
|
||
: G.aim ? 'PULL DOWN: FURTHER = HARDER'
|
||
: slotId(G.weapon) !== 'ball' ? `SERVE FIRES: ${sel} · ${switchHint}`
|
||
: demo ? ''
|
||
: touch ? 'PULL DOWN TO AIM · RELEASE TO FIRE' : 'DRAG DOWN TO AIM · SPACE SERVES')
|
||
: G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE')
|
||
: (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE');
|
||
if (txt) shadowText(txt, W / 2, METER_Y(), UI.warm); // same slot the draw meter uses
|
||
if (demo) drawPullHint(); // show the gesture instead of only naming it
|
||
}
|
||
// The free-floating register: no panel, one fixed slot, shouted. PERFECT, GAME N, DOUBLE KILL,
|
||
// RETURNED, CHANGEOVER, weapon labels — everything that used to be plain white 12px text sitting
|
||
// directly on the crowd. It keeps its 1px jitter; the shadow is what makes it legible anywhere.
|
||
if (G.flash) {
|
||
// 18, not 24: at the card's near-size a transient one-liner read as a milestone announcement,
|
||
// and half of what lands in this slot (PERFECT, RETURNED, DOUBLE KILL) is only ever a footnote
|
||
// to something you already watched happen. The register is right; the volume was not.
|
||
ctx.font = B(18);
|
||
shadowText(G.flash.text, W / 2, FLASH_Y + Math.sin(G.flash.t * 20) * 1, '#ffffff');
|
||
}
|
||
// the SET milestone card (the duel's own announcement is a real modal now, not a timed card)
|
||
if (G.card && G.card.t > 0 && !(G.card.wait > 0)) // `wait` holds it OFF the screen (a boss dying under it)
|
||
drawCard(G.card.title, G.card.sub1, G.card.sub2, G.card.color);
|
||
if (TUNE.missHud && G.lastMiss && G.idleT - G.lastMiss.at < 3.5) { // last miss reason, live
|
||
ctx.font = F(8); ctx.fillStyle = '#ff9060';
|
||
ctx.fillText(G.lastMiss.text.toUpperCase(), W / 2, H - 28);
|
||
}
|
||
if (G.logShow) { // scoreboard tap: recent swing diagnostics, screenshot-friendly
|
||
const list = G.missLog.slice(-9), lh = 10, top = 40;
|
||
ctx.fillStyle = 'rgba(8,10,16,0.88)'; ctx.fillRect(6, top, W - 12, list.length * lh + 16);
|
||
ctx.font = F(7, 0); ctx.textAlign = 'left';
|
||
ctx.fillStyle = UI.dim; ctx.fillText('SWING LOG — tap scoreboard to hide', 10, top + 5);
|
||
const maxCh = Math.floor((W - 20) / 4.3);
|
||
list.forEach((e, i) => {
|
||
ctx.fillStyle = e.result === 'HIT' ? '#7fd07f' : '#ff9060';
|
||
ctx.fillText((e.at + 's ' + e.result + ' ' + e.reason).slice(0, maxCh), 10, top + 16 + i * lh);
|
||
});
|
||
}
|
||
if (G.modal) drawModal(G.modal); // last: it is a wall, nothing draws over it
|
||
ctx.textAlign = 'left';
|
||
}
|