Oliver: the tennis-family sprites are optimised for movement, not gore — the card should lead with the zombie. Each family already anchors on a horde body (_famroll.FAMS reads its character from one), so _heads.py now cuts the icon from THAT sprite instead of the fam_ sheet's tennis stance: the face on the card and the monster on court are the same person, in the gorier print. No resize, no requantise on the way: drawCard scales the PNG itself with smoothing off and only reads the aspect, so the crop ships as a native-size window onto art that is already flat pixel art. The top-down head detector survives for the four bodies it works on; seven get hand boxes, because the horde bodies carry PROPS — the keeper's ball launcher and the physio's stretcher ride above their heads, the director holds a walkie-talkie level with his, and the brute's and champion's shoulders are wide enough that a full-silhouette crop at head depth came out 2.2:1 and starved the card title of room. SPRV 113 -> 114. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2535 lines
170 KiB
JavaScript
2535 lines
170 KiB
JavaScript
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
|
||
|
||
import { cv, ctx } from './dom.js';
|
||
import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_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, TOSS, HEAD_CROP, CUP_ROUNDS } from './config.js';
|
||
import { P, O, ball, score, crowd, G, court, tour } from './state.js';
|
||
import { predictLanding, previewServe, slotId, slotStock, serveApexY } from './gameplay.js';
|
||
import { drillQueue, drillLegal, drillMult } from './drill.js';
|
||
import { zombies, gore, piles, parts, pellets, extras, shots, pops } 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, derived from the device instead of pinned at the iPhone's 4.
|
||
// On a 3x iPhone the canvas ends up ~390 CSS px wide = 1170 DEVICE px, so RES 3 (810) was upscaled
|
||
// 1.44x by the browser and 4 gets us to 1080, near 1:1 — but a flat 4 made every 2x phone paint
|
||
// nearly double the pixels it can show, on exactly the hardware least able to afford it. The same
|
||
// near-1:1 logic per tier: dpr 3 -> 4, dpr 2 -> 3, dpr 1 -> 2.
|
||
// THIS IS THE FIRST KNOB TO TURN DOWN if the phone ever stutters: it is the whole fill-rate budget.
|
||
// `let`, not const: TUNE.lowPower caps it at 3 live (fitViewport re-reads on every fit) — importers
|
||
// see the change through the module binding.
|
||
const RES_AUTO = Math.max(2, Math.min(4, Math.ceil((devicePixelRatio || 3) * 1.35)));
|
||
export let RES = RES_AUTO;
|
||
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.
|
||
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();
|
||
// battery saver: RES 4 -> 3 is −44% of pixels painted per frame, by far the biggest lever there
|
||
// is. Sprites are 224px sources, so at 3 they still draw at-or-below 1:1 — softer than 4, never
|
||
// mushy. main.js re-calls fitViewport when the panel flips the knob.
|
||
RES = TUNE.lowPower >= 0.5 ? Math.min(3, RES_AUTO) : RES_AUTO;
|
||
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.
|
||
// PORTRAIT WENT 48 -> 49. THE THREE NUMBERS ARE NOT INDEPENDENT, which is what the "check all
|
||
// four" note above is really about: holding the near player's drawn size fixed means
|
||
// camF = s_player * (camZ - P_BOUNDS.zMax), and holding the court's on-screen depth fixed means
|
||
// camY = depth / (s_player - camF/camZ). So 49 gives 795 and 11.23, and `horizon` below
|
||
// re-derives itself and drops 51 -> 43. Net: the far opponent 33.1 -> 33.9 logical px, the player
|
||
// unchanged at 64.9, the court band within a pixel of where it was.
|
||
// ONE METRE IS DELIBERATE. Pushing the lens further does keep growing the far body, but it spends
|
||
// the court to do it — measured at camZ 55 the umpire is already clipped off the right edge, and
|
||
// by 70 the sidelines run near-parallel and the whole thing reads top-down instead of like a
|
||
// broadcast camera. It also narrows the player's lateral room, because xVisible() clamps him to
|
||
// what fits on screen and a wider court fits less.
|
||
TUNE.camZ = portrait ? 49 : 42;
|
||
TUNE.camY = portrait ? 11.23 : 9;
|
||
TUNE.camF = portrait ? 795 : 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;
|
||
// The ball-follow lean (see config farCamY/farCamZ): eased once per frame like aimCam. fOff is the
|
||
// compensation that pivots the move about the near player's deepest stance — without it any camY
|
||
// or camZ change slides his feet toward the rack (14 logical px for 0.4 m of camY), and the apron
|
||
// math in fitViewport promised that pull room. Recomputed in render() whenever farCam moves.
|
||
let farCam = 0, fOff = 0;
|
||
export function proj(x, y, z) {
|
||
const d = TUNE.camZ + aimCam * TUNE.aimCamZ + farCam * TUNE.farCamZ - z, s = TUNE.camF / d;
|
||
return { x: W / 2 + x * s, y: TUNE.horizon + fOff + (TUNE.camY + aimCam * TUNE.aimCamY + farCam * TUNE.farCamY - y) * s, s };
|
||
}
|
||
// FAR-BODY READABILITY (config farK): the flattened scale curve BODIES draw with, while the court
|
||
// keeps the true projection. Anchored at the player's deepest stance, clamped so nothing nearer
|
||
// ever shrinks. It is a pure function of z so every anchor that hangs off a body (flames, boss HP,
|
||
// muzzle glow) agrees with the sprite by passing a boosted METRES height through the same proj()
|
||
// it always used — boosting only the drawn frame left the HP bar sitting on the boss's hat.
|
||
function farB(z) {
|
||
if (!TUNE.farK) return 1;
|
||
const s = proj(0, 0, z).s, r = proj(0, 0, P_BOUNDS.zMax).s;
|
||
return s > 0 && s < r ? Math.pow(r / s, TUNE.farK) : 1;
|
||
}
|
||
|
||
|
||
// ---------------------------------------------------------------- sprites ----
|
||
// assets/<name>.png; game falls back to code-drawn figures until sprites load.
|
||
// Asset cache-buster — bump with main.js?v=N (see the img.src note below). EXPORTED because the
|
||
// menu screens hang <img> tags on the same files, and two copies of this number is one copy too
|
||
// many: the one that does not get bumped is the one that serves a cached 404.
|
||
export const SPRV = 114;
|
||
// The boot bar in main.js watches this. Nothing here CHANGES when things load — every sprite was
|
||
// already requested at import — it just makes the wait visible and holds the menu back until the
|
||
// last sheet has landed. See the onerror note below for the other half of "sometimes missing".
|
||
export const LOAD = { done: 0, total: 0 };
|
||
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_run5','p_run6','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_swing1','o_swing2',
|
||
'o_run1','o_run2','o_run3','o_run4','o_run5','o_run6','o_run7','o_run8', // the rolled 8-frame cycle (_runroll.py)
|
||
'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
|
||
'x_hand', // the ghost tutorial hand (drawGhostHand) — generated art, Oliver's reference pose
|
||
'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, then the second lap)
|
||
'z_chair1','z_chair2','z_chairw','z_chairt','z_chairwt', // boss design 3 (chapter 3, then the second lap)
|
||
// designs 4-9 and the final boss, 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',
|
||
// designs 7-9 and THE CHAMPION (the final boss, every tenth chapter — see bossArt)
|
||
'z_string1','z_string2','z_stringw1','z_stringw2','z_stringw3','z_stringt0','z_stringt1','z_stringt2','z_stringt3',
|
||
'z_dir1','z_dir2','z_dirw1','z_dirw2','z_dirw3','z_dirt0','z_dirt1','z_dirt2','z_dirt3',
|
||
'z_berry1','z_berry2','z_berryw1','z_berryw2','z_berryw3','z_berryt0','z_berryt1','z_berryt2','z_berryt3',
|
||
'z_champ1','z_champ2','z_champw1','z_champw2','z_champw3','z_champt0','z_champt1','z_champt2','z_champt3',
|
||
// 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)
|
||
// THE MATCH OPPONENT, drawn to play tennis rather than to shamble: ready stance,
|
||
// shuffle pair, forehand — and the same three poses again at each wound stage
|
||
// (…s is the swing). Its own family, so the turned CPU above keeps its shamble.
|
||
// EVERY MATCH-OPPONENT FAMILY, derived from config.ZFAMS — 31 names each, so a new
|
||
// opponent generated by _master.py --variant is ONE row in that table and not 31
|
||
// filenames typed out here (each of which is a chance to typo one and have a body
|
||
// vanish for a frame — the oldest bug in this file).
|
||
...ZFAMS.flatMap(famFrames),
|
||
// the card's 4K-cut head icons (assets/_heads.py). 'r' has no fam sheet — his
|
||
// card keeps the HEAD_CROP fallback, so no file and no 404 for him.
|
||
...ZFAMS.filter(k => k !== 'r').map(k => 'z_' + k + 'head'),
|
||
'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.
|
||
'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_run5_f','p_run6_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_run3_f','o_run4_f','o_run5_f','o_run6_f','o_run7_f','o_run8_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',
|
||
'z_r1_f','z_r2_f','z_r3_f','z_r4_f',
|
||
'z_rw1_f','z_rw1b_f','z_rw1s_f','z_rw2_f','z_rw2b_f','z_rw2s_f',
|
||
'scoreboard','scoreboard_z']) { // the club's board, and the one the match is played on
|
||
const img = new Image();
|
||
// ?v=SPRV, for the same reason index.html versions main.js. It is also what buys the year-long
|
||
// immutable cache in server.py: a versioned URL can never go stale, because changing the file
|
||
// changes the URL. 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.
|
||
const url = 'assets/' + n + '.png?v=' + SPRV;
|
||
// ONE RETRY. A dropped request used to be permanent for the whole session — the sprite stayed
|
||
// undefined, the zombie drew as some other design, and the stats screen showed a broken <img>.
|
||
// That is the "sometimes the graphics don't load" report. onerror still has to count on the
|
||
// second failure, or one genuinely missing file hangs the boot bar forever.
|
||
let retried = false;
|
||
LOAD.total++;
|
||
img.onload = () => { SPR[n] = brighten(n, img); LOAD.done++; };
|
||
img.onerror = () => { if (retried) LOAD.done++; else { retried = true; img.src = url + '&r=1'; } };
|
||
img.src = url;
|
||
}
|
||
|
||
// OPPONENT BODIES DRAW BRIGHTER, without touching a single PNG (Oliver: "make the opponent
|
||
// sprites brighter in game — filter or something — without changing the spritesheet files").
|
||
// Baked ONCE at load into an offscreen copy: an additive self-blend at alpha (b-1) is an exact
|
||
// brightness multiply with no per-pixel loop and no per-frame ctx.filter (which iOS Safari's
|
||
// canvas doesn't reliably support). Covers every far-court body — the zombie families and the
|
||
// tennis CPU — and leaves the player, the boys, the furniture and the HUD art untouched.
|
||
// TUNE.oppBright is read at LOAD, so retuning it needs a reload, not just the panel.
|
||
function brighten(n, img) {
|
||
if (!(n[0] === 'z' && n[1] === '_') && !(n[0] === 'o' && n[1] === '_')) return img;
|
||
const b = TUNE.oppBright || 1;
|
||
if (b === 1) return img;
|
||
const c = document.createElement('canvas');
|
||
c.width = img.width; c.height = img.height;
|
||
const g = c.getContext('2d');
|
||
g.drawImage(img, 0, 0);
|
||
g.globalCompositeOperation = 'lighter';
|
||
g.globalAlpha = b - 1;
|
||
g.drawImage(img, 0, 0);
|
||
g.globalCompositeOperation = 'destination-in'; // re-clip: additive blending fattened the alpha
|
||
g.globalAlpha = 1;
|
||
g.drawImage(img, 0, 0);
|
||
return c;
|
||
}
|
||
|
||
// ------------------------------------------------------------------ crowd ----
|
||
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]
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------- backdrop ----
|
||
// THE STATIC SCENE, PRE-COMPOSED. Stands, ads, rot tint, out area, court, lines and the run-off
|
||
// apron are pixel-identical frame to frame except while a palette lerps (the 6 s transition, the
|
||
// duel's 0.8 s fade) — and repainting all ~40 draw ops per frame was the biggest steady-state cost
|
||
// after raw resolution. One offscreen canvas, rebuilt only when the cache key actually moves, blit
|
||
// as a single drawImage. The crowd's clap ripple stays LIVE, overlaid on top while cheering.
|
||
// Oversized by 8 px a side for the same reason the old full-frame clear was: a camera shake must
|
||
// never expose an edge.
|
||
let bg = null, bgKey = '';
|
||
function paintBackdrop(c, pal, pt) {
|
||
const line = (x1, z1, x2, z2, w = 1) => {
|
||
const a = proj(x1, 0, z1), b = proj(x2, 0, z2);
|
||
c.strokeStyle = UI.cream; c.lineWidth = w;
|
||
c.beginPath(); c.moveTo(a.x, a.y); c.lineTo(b.x, b.y); c.stroke();
|
||
};
|
||
const quadXZ = (x1, z1, x2, z2, color) => { // filled court-plane rect (screen trapezoid)
|
||
const a = proj(x1, 0, z1), b = proj(x2, 0, z1), d = proj(x2, 0, z2), e = proj(x1, 0, z2);
|
||
c.fillStyle = color; c.beginPath();
|
||
c.moveTo(a.x, a.y); c.lineTo(b.x, b.y); c.lineTo(d.x, d.y); c.lineTo(e.x, e.y); c.fill();
|
||
};
|
||
c.fillStyle = pal.out;
|
||
c.fillRect(-8, -8, W + 16, H + 16);
|
||
// stands. Seated is one uniform stretch of the sheet, so it is ONE drawImage here — the
|
||
// per-column split only ever existed for the clap ripple, which drawBackdrop overlays live.
|
||
const standsBottom = proj(0, 0, -6).y, crowdH = standsBottom - AD_H + 2;
|
||
const crowdImg = SPR[court.crowd] || SPR['x_crowd'];
|
||
if (crowdImg) c.drawImage(crowdImg, 0, 0, W, crowdH + 2);
|
||
else { // fallback until the image loads: the old tier stripes + dots (static — boot gates play anyway)
|
||
c.fillStyle = pal.stands; c.fillRect(0, 0, W, standsBottom);
|
||
for (let r = 0; r < 16; r++) { c.fillStyle = r % 2 ? pal.tierA : pal.tierB; c.fillRect(0, 6 + r * 5.5, W, 5.5); }
|
||
for (const d of crowd.dots) { c.fillStyle = d.c; c.fillRect(d.x | 0, d.y | 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) c.drawImage(ads, x, standsBottom - AD_H, tw, AD_H);
|
||
} else { c.fillStyle = pal.board; c.fillRect(0, standsBottom - AD_H, W, AD_H); }
|
||
// the rot dims the stadium: one tint over crowd + boards, fading home during duels. 0.26, not
|
||
// 0.5: on an already-dark crowd sheet 0.5 buried the stands; 0.26 still reads as "the lights
|
||
// went wrong".
|
||
if (pt > 0) { c.fillStyle = `rgba(12,8,20,${0.26 * pt})`; c.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
|
||
// The run-off apron below the near baseline: light falling 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. NOT a near backstop — you are looking OVER that, which is
|
||
// why a broadcast frame never shows one either.
|
||
const top = proj(0, 0, COURT.L).y;
|
||
if (top < H) { // landscape has no apron at all — the baseline projects off the bottom
|
||
const g = c.createLinearGradient(0, top, 0, H);
|
||
g.addColorStop(0, 'rgba(6,10,6,0)');
|
||
g.addColorStop(1, 'rgba(6,10,6,0.72)');
|
||
c.fillStyle = g;
|
||
c.fillRect(0, top, W, H - top);
|
||
}
|
||
}
|
||
|
||
// blit the cached backdrop (rebuilding if the key moved), then overlay the live clap ripple.
|
||
// Returns standsBottom for the overlays render() draws above the stands.
|
||
function drawBackdrop(pal, pt) {
|
||
const crowdImg = SPR[court.crowd] || SPR['x_crowd'];
|
||
// the key: everything paintBackdrop's output depends on. pt quantised to 1/100 — hexLerp rounds
|
||
// to whole rgb anyway, so the steps are invisible; the aim-camera terms are 0 unless the two
|
||
// aimCam knobs are actually in use, so an ordinary pull never invalidates the cache.
|
||
const key = [W, H, court.id, pt.toFixed(2), !!crowdImg, !!(SPR[court.ads] || SPR['x_ads']),
|
||
TUNE.horizon, TUNE.camZ, TUNE.camY, TUNE.camF,
|
||
(aimCam * TUNE.aimCamY).toFixed(2), (aimCam * TUNE.aimCamZ).toFixed(2),
|
||
// the lean moves the cached court too — repaints happen only while farCam eases,
|
||
// then the key settles (farCam snaps to exactly 0/1)
|
||
(farCam * TUNE.farCamY).toFixed(2), (farCam * TUNE.farCamZ).toFixed(2)].join('|');
|
||
if (key !== bgKey) {
|
||
bgKey = key;
|
||
if (!bg) bg = document.createElement('canvas');
|
||
if (bg.width !== (W + 16) * RES || bg.height !== (H + 16) * RES) {
|
||
bg.width = (W + 16) * RES; bg.height = (H + 16) * RES;
|
||
}
|
||
const c = bg.getContext('2d');
|
||
c.setTransform(RES, 0, 0, RES, 8 * RES, 8 * RES);
|
||
c.imageSmoothingEnabled = false;
|
||
paintBackdrop(c, pal, pt);
|
||
}
|
||
ctx.drawImage(bg, -8, -8, W + 16, H + 16);
|
||
const standsBottom = proj(0, 0, -6).y;
|
||
// Applause is a real TWO-FRAME animation: x_crowd is the seated sheet (cached above), <crowd>2
|
||
// the same stand with every arm up. Columns swap on their own phase so the clap ripples across
|
||
// the stands — integer-aligned draws, no vertical offset, because fractional-y nearest-neighbour
|
||
// resampling is the boiling that once read as HUD flicker. Clipped above the ad strip so the
|
||
// redrawn columns never paint over the boards, and re-tinted per column so a clapping column
|
||
// does not pop brighter than the rotted stand around it.
|
||
const clapImg = crowdImg && (crowdImg === SPR['x_crowd'] ? SPR['x_crowd2'] : SPR[court.crowd + '2']);
|
||
if (crowd.cheer > 0 && crowdImg && clapImg) {
|
||
const crowdH = standsBottom - AD_H + 2;
|
||
const cols = 16, sw = clapImg.width / cols, cw = W / cols;
|
||
const tint = pt > 0 ? `rgba(12,8,20,${0.26 * pt})` : null;
|
||
ctx.save(); ctx.beginPath(); ctx.rect(0, 0, W, standsBottom - AD_H); ctx.clip();
|
||
for (let i = 0; i < cols; i++) {
|
||
if (!(Math.floor(G.idleT * 9 + i * 0.7) % 2)) continue;
|
||
ctx.drawImage(clapImg, i * sw, 0, sw, clapImg.height, i * cw, 0, cw + 0.5, crowdH + 2);
|
||
if (tint) { ctx.fillStyle = tint; ctx.fillRect(i * cw, 0, cw + 0.5, crowdH + 2); }
|
||
}
|
||
ctx.restore();
|
||
}
|
||
return standsBottom;
|
||
}
|
||
|
||
// -------------------------------------------------------------- primitives ----
|
||
// 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) { // a burning ball, not an orange one: an additive halo, then shell, then white core
|
||
const fl = Math.floor(G.idleT * 24) % 2 ? 1 : 0;
|
||
ctx.save(); ctx.globalCompositeOperation = 'lighter';
|
||
for (const [rf, af] of [[3.1, 0.16], [2.0, 0.26]]) { // the halo is what makes it read as heat
|
||
ctx.globalAlpha = af; ctx.fillStyle = '#ff7a10';
|
||
ctx.beginPath(); ctx.arc(bp.x, bp.y, r * rf + fl, 0, 7); ctx.fill();
|
||
}
|
||
ctx.restore(); ctx.globalAlpha = 1;
|
||
ctx.fillStyle = '#ff8010'; ctx.beginPath(); ctx.arc(bp.x, bp.y, r * 1.25 + fl, 0, 7); ctx.fill();
|
||
ctx.fillStyle = '#ffc030'; ctx.beginPath(); ctx.arc(bp.x, bp.y, Math.max(1, r * 0.8), 0, 7); ctx.fill();
|
||
ctx.fillStyle = '#fffbe0'; ctx.beginPath(); ctx.arc(bp.x, bp.y, Math.max(1, r * 0.42), 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 ----
|
||
// `o.sink` lowers the frame by a fraction of its own drawn height, and `o.shadow: false` drops the
|
||
// contact ellipse. Both exist for the PRONE frame of a fall — see the note at drawZombie's call.
|
||
function drawStatic(name, x, z, hMeters, bob = 0, flip = false, o = {}) {
|
||
const img = SPR[name]; if (!img) return;
|
||
const p = proj(x, 0, z);
|
||
const h = hMeters * p.s, w = h * img.width / img.height;
|
||
const y = p.y - h - bob + h * (o.sink || 0);
|
||
if (o.shadow !== false) drawShadow(p.x, p.y, w * 0.4, h * 0.04, 0.25);
|
||
if (!flip) return ctx.drawImage(img, p.x - w / 2, y, w, h);
|
||
ctx.save(); ctx.translate(p.x, 0); ctx.scale(-1, 1); // the art is drawn facing LEFT
|
||
ctx.drawImage(img, -w / 2, y, 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) * farB(b.z), 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 B = farB(zb.z); // readability boost — drum, glow anchor and rolling frames share it
|
||
const h = TUNE.barrelH * (img ? img.height / SPRITE_H : 1) * B;
|
||
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) * B);
|
||
} 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) * farB(zb.z); // readability boost — muzzle glow rides h too
|
||
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 = Math.max(0, 1 - zb.windT / (zb.windMax || TUNE.machWind)), p = proj(zb.x, h * 0.62, zb.z); // fade over the ACTUAL wind
|
||
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';
|
||
// THE MATCH OPPONENT HAS ITS OWN SPRITES NOW. It used to borrow the turned CPU's ('c') — a body
|
||
// drawn to SHAMBLE at the camera with its racket hanging at its side, which is what was playing
|
||
// tennis points against you. The 'r' family is drawn off the LIVING opponent's poses instead (a
|
||
// ready stance, a shuffle pair and a real forehand, at every wound stage). 'c' keeps its shamble
|
||
// untouched, because in the gore mode shambling is exactly what the turned CPU should be doing.
|
||
// WHICH FAMILY THIS BODY WEARS. `fam` rides on the zombie (spawnRacketZombie leaves it unset, so
|
||
// the club pro is 'r'), which is what lets a generated variant be a different opponent rather than
|
||
// a different game: every name below is built from it. Falls back to the turned CPU's shamble
|
||
// while a family's sheets have not landed.
|
||
const fam = zb.fam || 'r';
|
||
const v = rk ? (SPR['z_' + fam + '1'] ? fam : 'c') : zb.variant;
|
||
const rNew = rk && v !== 'c';
|
||
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.
|
||
// The 'r' family's own cycle: 1 = the ready stance, held whenever racketAI is not running him;
|
||
// 2/3 = the shuffle; 4 = the forehand. `pose` is racketAI's own signal, so the picture and the
|
||
// behaviour cannot drift.
|
||
let frame = rNew ? (zb.pose === 'swing' ? 4
|
||
: zb.pose === 'run' ? stepFrame((zb.dist || 0) / TUNE.racketStride, 2) + 2 : 1)
|
||
: 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 st = racketStage(zb); // config's rule — the fall beat reads the same one
|
||
// NO HEIGHT OVERRIDE FOR THE HEADLESS STAGE. There used to be `if (st === 2) h = 1.52` here, to
|
||
// take a head off the top — and it was double-counting, because the ART already accounts for the
|
||
// missing head and the line above already scales every frame by its own PNG height. Oliver, from
|
||
// the bench: "every headless mode shrinks the whole opponent a little." Measured: the drawn body
|
||
// went 34.0 px healthy -> 33.8 arm off -> 27.8 headless on ALL EIGHT families, a flat -18%, and
|
||
// the FEET shrank 16-19% with it. A body that loses its head does not get smaller feet.
|
||
// Why the correction went stale: a headless sprite is still a full-height frame, because the
|
||
// generator draws a gushing neck stump whose blood plume fills the space the head occupied —
|
||
// 18.3% of the frame sits above z_rw2's shoulders against 10.7% above z_rw1's head. Shrinking the
|
||
// whole frame on top of that scaled the torso and the legs too. It was also wrong for the
|
||
// borrowed 'c' fallback skins (z_cw2 is a SHORTER PNG, which the per-frame scaling handles):
|
||
// their body drew at 1.30 m against the healthy 1.78 m.
|
||
// Residual: the plume is not exactly a head, so the headless body still lands within about -8% to
|
||
// +2% of the wounded one, varying by family (r -8.5%, gk +1.8%). That is art variance, not a
|
||
// code-imposed shrink, and the place to fix it is _zsheet.py — levelling the wound rows on the
|
||
// SHOULDER line, the same way it already levels the walk frames on total height.
|
||
// NOTE the HP bar and the aim mark read config.zombieH directly, so they were floating above a
|
||
// shrunken body for as long as this override existed; removing it re-aligns them for free.
|
||
if (st && rNew) {
|
||
// every wound stage carries the SAME three poses as the healthy body, so a one-armed
|
||
// opponent still plays tennis rather than reverting to a shamble the moment you wound it
|
||
// ...stepped off GROUND COVERED, the same clock the healthy cycle two blocks up uses. It was
|
||
// walkT — a wall clock — so a wounded opponent's legs cycled at a fixed cadence however fast
|
||
// it was actually moving, which is the foot-slide this project banned for the heavy bodies.
|
||
const base = 'z_' + fam + 'w' + st;
|
||
const want = zb.pose === 'swing' ? base + 's'
|
||
: zb.pose === 'run' && SPR[base + 'b'] && stepFrame((zb.dist || 0) / TUNE.racketStride, 2) ? base + 'b'
|
||
: base;
|
||
if (SPR[want]) name = want; else if (SPR[base]) name = base;
|
||
} else if (st) { // the old borrowed 'c' skins, while they are all there is
|
||
if (st === 2 && SPR.z_cw2) name = 'z_cw2';
|
||
else if (st === 1 && 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;
|
||
// retT is the CHAMPION'S RETURN — the same throw frame, held briefly, so a ball he sends back
|
||
// has a stroke behind it instead of arriving out of a body that never moved.
|
||
const wanted = zb.windT > 0 || zb.retT > 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';
|
||
// THE BENCH OVERRIDE, last of all: it names a frame outright, so states the game cannot reach
|
||
// yet (the serve, the fall) can be looked at the day they are generated rather than the day they
|
||
// are wired up. Only the back room's sprite bench ever sets this.
|
||
if (zb.showFrame) {
|
||
// a wound stage that has not been drawn yet degrades to the HEALTHY frame rather than to the
|
||
// walk cycle — the bench prints what it actually drew, so the substitution is visible
|
||
const heal = zb.showFrame.replace(new RegExp('^z_' + fam + 'w\\d'), 'z_' + fam);
|
||
if (SPR[zb.showFrame]) name = zb.showFrame;
|
||
else if (SPR[heal]) name = heal;
|
||
}
|
||
// ...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 PROJECT.md: heights follow the PNG).
|
||
if (SPR[name]) h *= SPR[name].height / SPRITE_H;
|
||
// the readability boost, AFTER every art-driven height rule: h is in metres here, so the flames,
|
||
// the boss glow and the HP bar below all inherit it through the same proj() as the sprite
|
||
h *= farB(zb.z);
|
||
// feeding: a fast bob over the body instead of a stride. Same sprites, different rhythm.
|
||
// ...and the SHAMBLE BOB is the same rule again, which it was breaking twice over. It was
|
||
// `Math.floor(zb.walkT * 5.2) % 2` — a 1 px lift on a WALL CLOCK, applied to every body every
|
||
// frame whether it was moving or not. So a held pose bobbed forever (the sprite bench shows a
|
||
// FOREHAND that will not sit still), a collapsing body bobbed while it lay on the ground, and at
|
||
// RES 4 that 1 logical px is 4 device px flicking at 2.6 Hz. Two fixes, both the house rule:
|
||
// it only happens while the body is actually WALKING, and it steps off GROUND COVERED.
|
||
const still = zb.pose === 'idle' || zb.pose === 'swing' || zb.pose === 'serve';
|
||
const bob = zb.feeding ? Math.abs(Math.sin(zb.walkT * 11)) * Math.max(1, p0(zb) * 0.05)
|
||
: still ? 0
|
||
: Math.floor((zb.dist ?? zb.z) / 0.42) % 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.
|
||
zb.drawn = name; // WHICH FRAME THIS ACTUALLY IS. The sprite bench prints it, so a frame that looks
|
||
// wrong on the court names its own file instead of being hunted for afterwards.
|
||
// THE PRONE FRAME LIES ON ITS RACKET, NOT ON THE GROUND. Every frame is bottom-aligned on the
|
||
// ground point, and in the fall3 art the lowest ink is the racket hoop thrown out beside the body —
|
||
// so the torso hangs above the line and the corpse reads as levitating, propped on its own racket.
|
||
// Oliver, from the bench: "the player basically levitates over the ground held by his racket."
|
||
// Measured on z_shw1fall3 (249x85): the body's underside is at y67 and rows 68-84 are the racket,
|
||
// so the body floats a fifth of the frame's height. TUNE.proneSink drops it by that fraction, which
|
||
// puts the body line where fall2 leaves its knees; the racket then lies across the line, which is
|
||
// where a dropped racket belongs. The suffix-tolerant test is for the tour swap (SK appends to the
|
||
// name, so a plain /fall3$/ would miss z_..fall3_f).
|
||
// ...AND NO CONTACT SHADOW WHILE IT FALLS. The ellipse is sized off the DRAWN WIDTH, and a prone
|
||
// frame is three times wider than a standing one — so the widest, darkest shadow in the game was
|
||
// being painted under the one body that is not standing on it, which is most of why it read as
|
||
// floating. Suppressed for the whole beat, not just the prone frame: a body already off its feet
|
||
// has no business casting a standing man's contact patch.
|
||
// THE TWO ART GENERATIONS FACE OPPOSITE WAYS, so they flip on opposite signs. The old shamblers are
|
||
// drawn facing LEFT — one walking right is mirrored, which is what `face > 0` is for. THE MATCH-
|
||
// OPPONENT FAMILIES ARE DRAWN FACING RIGHT, and were being mirrored by the same test, so every one
|
||
// of them walked backwards: on court and in the bench alike, since racketAI and updateBench both set
|
||
// face as the sign of x movement.
|
||
// MEASURED, not guessed, and the racket is what measures it. Its string bed keys out to an enclosed
|
||
// transparent hole, so the hoop locates itself: in all seven families, in BOTH walk frames, the
|
||
// hoop's x centroid sits ~50 px to the RIGHT of the body's (sh 101 vs 53, lj 103 vs 52, br 102
|
||
// vs 48). Oliver's own test — "if this racket is facing to the right I would assume that he also
|
||
// walks to the right" — and a racket thrown out to the right is a body heading right.
|
||
// The front-facing bodies ('c' turned CPU, 'f' sprinter, 'm' machine, 'B' boss) are excluded from
|
||
// flipping entirely: mirroring a body with no left or right only swaps which hand holds the racket.
|
||
// ...AND THE WOUND WALK FRAMES ARE MIRRORED RELATIVE TO THE HEALTHY ONES. Measured the same way,
|
||
// on the master and on every family: healthy stride +0.38..+0.42 (racket right), wound stride
|
||
// -0.39..-0.45 (racket left). The wounded body is drawn facing the OTHER WAY, and it has been since
|
||
// the master was made, so the reference carries it and all seven families copied it. One flip rule
|
||
// therefore cannot serve both rows — fixing the healthy walk (which is where Oliver first saw it)
|
||
// inverted the wound walk, which is what he saw next.
|
||
// The test is the WALK FRAMES ONLY (`w1`, `w1b`, `w2`, `w2b`), because they are the only frames whose
|
||
// orientation tracks travel. The wound FOREHAND (`w1s`) measures racket-right like the healthy one,
|
||
// and the serve/fall poses put the racket overhead or flailing where it says nothing about direction
|
||
// — so they stay on the healthy convention rather than being dragged along by their row.
|
||
// `_famref.py` pins these sides, so a future roll that mirrors a row fails the gate instead of
|
||
// silently reaching the court. If that gate ever fires, fix the ART and delete this branch.
|
||
const prone = /fall3(_|$)/.test(name);
|
||
const artFacesRight = !rNew || !/w\db?(_[a-z0-9]+)?$/.test(name);
|
||
const flip = (artFacesRight ? zb.face < 0 : zb.face > 0) && !'cfmB'.includes(v);
|
||
drawStatic(name, zb.x, zb.z, h, bob, flip,
|
||
{ sink: prone ? TUNE.proneSink : 0, shadow: !G.matchFall });
|
||
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 = Math.max(0, 1 - zb.windT / (zb.windMax || TUNE.bossWind)), p = proj(zb.x, h * 0.62, zb.z); // fade over the design's own wind
|
||
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 ON A SHAMBLER — that one was cut and stays cut: it was the only floating UI on a body
|
||
// anywhere in the game, it was up for almost the whole run, and the wound SKINS already encode the
|
||
// same thing in the language every other multi-hit enemy uses. A CHAPTER BOSS is the exception,
|
||
// and for the reason the rule exists: it does NOT re-skin as it takes damage, its pool runs to
|
||
// dozens of points, and the fight is long enough that "am I getting anywhere" is a real question
|
||
// with no other answer on screen. One boss, on screen only during its own fight.
|
||
if (v === 'B' && zb.hpMax) drawBossHp(zb, h);
|
||
}
|
||
|
||
// THE MATCH'S TIMING, DRAWN. Oliver: "the tennis timing mechanic would need to be visualized
|
||
// somehow (what is the possible tap window what is the sweet spot)". Two cues, both riding numbers
|
||
// the mechanics already use, so the picture cannot drift from the physics:
|
||
//
|
||
// THE APPROACH RING (the rally tap): a ring around the incoming ball whose radius tracks its
|
||
// distance to the sweet contact range — the rings kiss exactly when the ball is where the tap
|
||
// lands a clean hit (matchRingSweet, the centre of the tapped-points benchmark's 2.2..3.2 m), and
|
||
// the ring turns gold inside the band. The landing ring set this precedent: a marker that
|
||
// contracts toward its moment IS a timing cue, no legend needed.
|
||
function drawApproachRing() {
|
||
// ...in CLASSIC TENNIS TOO. This was `!G.match`, so the mode whose entire pitch is "if the core
|
||
// tennis is good everything else is good" was the one mode without the timing cue that makes the
|
||
// core tennis legible. Reported three times before it was believed. The gore mode is left out on
|
||
// purpose: there is no rally there, the ball comes back off bodies.
|
||
if (!(G.match || G.mode === 'tennis') || G.phase !== 'rally' || !ball.active || ball.lastHit !== 'O') return;
|
||
const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); // the CONTACT test's own metric
|
||
if (d > TUNE.matchRingFar || ball.vz < 0) return; // far away, or already going back
|
||
const p = proj(ball.x, ball.y, ball.z);
|
||
if (p.s <= 0) return;
|
||
const inBand = Math.abs(d - TUNE.matchRingSweet) < TUNE.matchRingBand;
|
||
const rSweet = Math.max(6, p.s * 0.055); // the fixed target ring
|
||
const r = rSweet * Math.max(1, d / TUNE.matchRingSweet); // the closing ring — they kiss at sweet
|
||
ctx.lineWidth = 1.5;
|
||
ctx.strokeStyle = inBand ? UI.warm : 'rgba(255,255,255,0.55)';
|
||
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, 7); ctx.stroke();
|
||
ctx.strokeStyle = inBand ? UI.warm : 'rgba(255,255,255,0.3)';
|
||
ctx.beginPath(); ctx.arc(p.x, p.y, rSweet, 0, 7); ctx.stroke();
|
||
}
|
||
|
||
// THE APEX BAND (the serve tap): a thin guide beside the tossed ball with a gold band spanning the
|
||
// perfect window (serveApexY ± TUNE.apexSnap — the SAME number keyPressed scores as perfect, so
|
||
// tightening the mechanic tightens the picture) and a wider white zone for the playable one. A
|
||
// tick rides the live ball height. The band makes the strike a target you can see coming.
|
||
function drawApexBand() {
|
||
if (!(G.match || G.mode === 'tennis') || G.phase !== 'toss' || score.server !== 'P') return;
|
||
const gx = ball.x + 0.55; // beside the toss, out of the ball's way
|
||
const apex = serveApexY();
|
||
const yTop = proj(gx, apex + 0.45, ball.z), yBot = proj(gx, 1.2, ball.z);
|
||
if (yTop.s <= 0) return;
|
||
const x = Math.round(yTop.x);
|
||
ctx.lineWidth = 1;
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.35)'; // the guide
|
||
ctx.beginPath(); ctx.moveTo(x, yTop.y); ctx.lineTo(x, yBot.y); ctx.stroke();
|
||
const bTop = proj(gx, apex + TUNE.apexSnap, ball.z).y, bBot = proj(gx, apex - TUNE.apexSnap, ball.z).y;
|
||
ctx.fillStyle = hexA(UI.warm, 0.8); // the perfect window, gold
|
||
ctx.fillRect(x - 3, Math.round(bTop), 6, Math.max(2, Math.round(bBot - bTop)));
|
||
const tick = proj(gx, ball.y, ball.z).y; // where the ball is NOW
|
||
ctx.fillStyle = '#fff';
|
||
ctx.fillRect(x - 5, Math.round(tick), 10, 2);
|
||
}
|
||
|
||
// THE TRIAGE READOUT: score, multiplier, best — and the calibration marker. There is no strip and
|
||
// no called list any more; the court is the puzzle, so the HUD only carries what the court cannot
|
||
// say: how much the run is worth and how hot the streak is.
|
||
// THE SPRITE BENCH'S READOUT. The one thing a contact sheet cannot give you: what the game is
|
||
// ACTUALLY drawing, right now, at the size it plays at — including the filename, so a frame that
|
||
// looks wrong on the lawn names itself instead of being hunted for afterwards.
|
||
// THE BENCH'S OWN BUTTONS. Arrow keys are not a control scheme on the device this game is played
|
||
// on, so every family and every wound stage was unreachable from a phone — which is where "the
|
||
// walking looks wrong on some of them" has to be checked. Geometry lives here, next to the drawing,
|
||
// and input.js hit-tests the same function: one source, so a moved button cannot stop responding
|
||
// (the rack learned this the hard way — see rackGeom).
|
||
export function benchButtons() {
|
||
// FOUR across now: FALL plays the real collapse-and-rise beat (beats.benchFall), which is a SEQUENCE
|
||
// and not a pose, so it could not be one of the stepped stops.
|
||
const w = Math.floor((W - 28) / 4), h = 22, y = H - SAFE.bottom - h - 6;
|
||
return [
|
||
{ k: 'ArrowUp', k2: 'ArrowDown', label: 'BODY', x: 8, y, w, h },
|
||
{ k: 'ArrowLeft', k2: 'ArrowRight', label: 'WOUND', x: 8 + (w + 4), y, w, h },
|
||
{ k: 'Space', k2: 'Space', label: 'POSE', x: 8 + (w + 4) * 2, y, w, h },
|
||
{ k: 'KeyF', k2: 'KeyF', label: 'FALL', x: 8 + (w + 4) * 3, y, w, h },
|
||
];
|
||
}
|
||
|
||
function drawBenchButtons() {
|
||
for (const b of benchButtons()) {
|
||
panel(b.x, b.y, b.w, b.h, 4, 'rgba(12,14,20,0.82)', UI.dim);
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = F(8);
|
||
// '<' and '>' on the two-way ones say the tap SIDE matters, which is the whole control:
|
||
// left half steps back, right half steps forward.
|
||
shadowText(b.k === b.k2 ? b.label : '\u2039 ' + b.label + ' \u203a',
|
||
b.x + b.w / 2, b.y + b.h / 2 + 1, UI.cream);
|
||
}
|
||
ctx.textBaseline = 'alphabetic';
|
||
}
|
||
|
||
function drawBenchHud() {
|
||
const b = G.bench; if (!b) return;
|
||
drawBenchButtons();
|
||
const zb = zombies.find(z => z.variant === 'r');
|
||
const y = CARD_TOP + 4;
|
||
ctx.textAlign = 'center';
|
||
ctx.font = B(20);
|
||
shadowText((ZFAMS.length > 1 ? ZFAMS[b.fam].toUpperCase() + ' · ' : '') + BENCH[b.i].name,
|
||
W / 2, y, UI.cream);
|
||
ctx.font = F(8);
|
||
// ...and say so when the stop has no wound variant. The serve and the fall exist for the HEALTHY
|
||
// body only so far, so a stage label there would be claiming art that has not been drawn — and the
|
||
// whole reason this screen exists is to stop the art and the wiring lying to each other.
|
||
// FELL BACK means the stage asked for a frame that has not been drawn and got the healthy one —
|
||
// said out loud, because a bench that quietly substitutes art is worse than no bench.
|
||
const fell = zb && zb.showFrame && zb.drawn && zb.drawn !== zb.showFrame;
|
||
shadowText(BENCH_STAGES[b.stage] + ' · ' + (b.i + 1) + '/' + BENCH.length
|
||
+ (fell ? ' · FELL BACK' : ''), W / 2, y + 24, fell ? UI.bad : UI.warm);
|
||
// the file on screen, and the hp fraction the wound stage was read from — the two numbers you
|
||
// need to tell "the art is wrong" from "the wiring picked the wrong art"
|
||
ctx.font = F(8);
|
||
shadowText(zb ? zb.drawn || '—' : 'NO BODY', W / 2, y + 40, UI.pick);
|
||
if (zb) shadowText('hp ' + zb.hp + '/' + zb.hpMax, W / 2, y + 54, UI.dim);
|
||
ctx.font = F(7, 0);
|
||
// WHILE THE FALL RUNS, the hint line becomes the beat's stopwatch — refining the pause means
|
||
// watching a number next to the picture, not counting frames afterwards. The frame name two lines
|
||
// up already says WHICH picture, so this only has to say how long it has been up.
|
||
const mf = G.matchFall;
|
||
shadowText(mf ? 'FALL ' + mf.t.toFixed(2) + 's · TAP FALL AGAIN TO REPLAY'
|
||
: IS_TOUCH ? 'TAP: NEXT STATE · FALL: THE REAL COLLAPSE + RISE'
|
||
: 'SPACE: NEXT STATE ←/→: WOUND STAGE' + (ZFAMS.length > 1 ? ' ↑/↓: OPPONENT' : '') + ' F: FALL',
|
||
W / 2, H - 26, mf ? UI.warm : UI.dim);
|
||
}
|
||
|
||
function drawDrillHud() {
|
||
const d = G.drill; if (!d) return;
|
||
const y = CARD_TOP + 4;
|
||
ctx.textAlign = 'center';
|
||
ctx.font = B(24);
|
||
shadowText(String(d.score), W / 2, y, UI.cream);
|
||
const m = drillMult(d);
|
||
ctx.font = B(14);
|
||
// the multiplier is the streak made visible — gold once it is working, dim while it rebuilds
|
||
shadowText('x' + m + ' · ' + d.streak + ' STREAK', W / 2, y + 26, m > 1 ? UI.warm : UI.dim);
|
||
ctx.font = F(8);
|
||
shadowText('BEST ' + Math.max(d.best, d.score), W / 2, y + 44, UI.dim);
|
||
if (d.msg && d.msg.t > 0) { ctx.font = B(20); shadowText(d.msg.text, W / 2, y + 60, d.msg.bad ? UI.bad : UI.pick); }
|
||
// THE MARKERS ARE TRAINING WHEELS THAT TAKE THEMSELVES OFF. Every LEGAL kill is marked — the
|
||
// whole slack set, not the bare head: the rule accepts any near-tie, so a single chevron was
|
||
// stricter than the rule it explained and every kill made it jump between tied bodies. Marking
|
||
// the set is also stable by construction (arrival differences never change while bodies walk;
|
||
// the set only moves on a kill or an arrival).
|
||
// They HIDE once the streak shows you have the read (drillMarkOff) and come back on any fault —
|
||
// which answers "would a player get this without the marker": the marker teaches, the streak
|
||
// decides when you have learned it, and unlearning (a fault) brings the teacher back.
|
||
if (TUNE.drillMark >= 0.5 && d.streak < TUNE.drillMarkOff) {
|
||
const legal = drillLegal();
|
||
for (let i = 0; i < legal.length; i++) {
|
||
const zb = legal[i];
|
||
const p = proj(zb.x, zombieH(zb) * farB(zb.z) + 0.5, zb.z); // hover clear of the BOOSTED head
|
||
if (p.s <= 0) continue;
|
||
const bob = Math.sin(G.idleT * 6) * 2;
|
||
const px = Math.round(p.x), py = Math.round(p.y + bob);
|
||
ctx.globalAlpha = i === 0 ? 1 : 0.45; // the head is bright; a tied body is a quiet "also fine"
|
||
ctx.fillStyle = UI.warm;
|
||
ctx.beginPath();
|
||
ctx.moveTo(px - 5, py - 7); ctx.lineTo(px + 5, py - 7); ctx.lineTo(px, py);
|
||
ctx.closePath(); ctx.fill();
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
// YOUR BLOOD, COUNTED. The vignette is the mood and it stays, but Oliver: "you do not immediately
|
||
// know when it is really urgent" — a wash that deepens smoothly cannot answer "how many more can I
|
||
// take", and in the match (8 hp) that is the whole question. So: one pip per hit you have left, in
|
||
// the top-right corner beside the scoreboard, in the same recessed-panel language the game/sets bar
|
||
// under the board already uses. Full pips are blood red, spent ones are an empty socket, and the
|
||
// LAST TWO breathe — the one state where the number matters more than the picture.
|
||
// ponytail: pips, not a bar. hpMax is 4 (gore) or 8 (match); a bar would be a smooth readout of a
|
||
// small integer, which is exactly the ambiguity being fixed. Switch to a bar if a pool ever passes ~12.
|
||
function drawBlood() {
|
||
if (G.mode !== 'splatter' || !G.hpMax) return;
|
||
const n = G.hpMax, hp = Math.max(0, Math.min(n, G.hp));
|
||
const r = 2.6, gap = n > 6 ? 7 : 9, pw = (n - 1) * gap + r * 2;
|
||
const w = Math.max(46, pw + 10), h = 24, x = W - 6 - w, y = 6;
|
||
panel(x + 0.5, y + 0.5, w - 1, h - 1, 3, 'rgba(26,6,8,0.82)', 'rgba(163,74,66,0.45)');
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||
ctx.font = F(7, 0); ctx.fillStyle = hp <= 2 ? UI.bad : '#89a08f';
|
||
ctx.fillText('BLOOD', x + w / 2, y + 7);
|
||
// the low-blood breath: the whole row pulses once you are two hits from the end
|
||
const pulse = hp > 0 && hp <= 2 ? 0.55 + 0.45 * Math.abs(Math.sin(G.idleT * 5)) : 1;
|
||
const x0 = x + w / 2 - pw / 2 + r;
|
||
for (let i = 0; i < n; i++) {
|
||
ctx.beginPath(); ctx.arc(x0 + i * gap, y + 16, r, 0, 7);
|
||
if (i < hp) { ctx.fillStyle = hexA('#c8232a', pulse); ctx.fill(); }
|
||
else { ctx.strokeStyle = 'rgba(120,60,58,0.7)'; ctx.lineWidth = 1; ctx.stroke(); }
|
||
}
|
||
ctx.textBaseline = 'top';
|
||
}
|
||
|
||
// The boss's remaining life, over its head, in the scoreboard's own 16-bit language: a dark sunk
|
||
// well, a bevel, and a fill that runs gold -> orange -> red as the pool empties, so the colour says
|
||
// how the fight is going without reading the length. Integer-snapped and a FIXED pixel size — this
|
||
// is HUD, and a bar that grew as the boss walked toward you would read as the boss healing.
|
||
function drawBossHp(zb, hMeters) {
|
||
// 1.06 sat ON the hat — a bar touching the sprite reads as part of the costume. It hangs clear
|
||
// now, and it hangs from its own BOTTOM edge so the gap is the gap you see rather than the gap
|
||
// minus the bar. The anchor is the body's own height, so it stays put as the boss scales up by
|
||
// chapter instead of drifting into the crowd.
|
||
const p = proj(zb.x, hMeters * 1.3, zb.z);
|
||
if (p.s <= 0) return; // behind the camera: see drawAimMark
|
||
const k = Math.max(0, Math.min(1, zb.hp / zb.hpMax));
|
||
const w = 46, h = 5, x = Math.round(p.x - w / 2), y = Math.round(p.y) - h;
|
||
ctx.fillStyle = 'rgba(6,4,6,0.85)'; ctx.fillRect(x - 1, y - 1, w + 2, h + 2); // frame
|
||
ctx.fillStyle = '#2a1518'; ctx.fillRect(x, y, w, h); // empty well
|
||
if (k > 0) {
|
||
ctx.fillStyle = k > 0.5 ? UI.pick : k > 0.22 ? '#ff8010' : UI.bad;
|
||
ctx.fillRect(x, y, Math.max(1, Math.round(w * k)), h);
|
||
ctx.fillStyle = 'rgba(255,255,255,0.28)'; ctx.fillRect(x, y, Math.max(1, Math.round(w * k)), 1); // bevel
|
||
}
|
||
}
|
||
// 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;
|
||
if (s <= 0) return;
|
||
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';
|
||
// A BODY ON FIRE SHOULD LOOK LIKE ONE. Five thin tongues over the torso read as a pilot light;
|
||
// this is now a column that engulfs — nine tongues spread across the full silhouette, up to
|
||
// shoulder height, over an additive heat bloom that is the thing you actually see from across
|
||
// the court. The bloom dies with k, so a guttering body cools to smoke exactly as before.
|
||
if (k > 0.15) {
|
||
ctx.save(); ctx.globalCompositeOperation = 'lighter';
|
||
const by = p.y - hMeters * s * 0.42, br = hMeters * s * 0.42;
|
||
for (const [rf, af] of [[1.25, 0.13], [0.72, 0.20]]) {
|
||
ctx.globalAlpha = af * k; ctx.fillStyle = k > 0.45 ? '#ff9a20' : '#c05a10';
|
||
ctx.beginPath(); ctx.arc(p.x, by, br * rf, 0, 7); ctx.fill();
|
||
}
|
||
ctx.restore(); ctx.globalAlpha = 1;
|
||
}
|
||
for (let i = 0; i < 9; 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 - 4) * s * 0.042;
|
||
const fy = p.y - (0.18 + i * 0.085 + 0.42 * k) * hMeters * s - Math.abs(Math.sin(ph * 1.6)) * s * 0.10;
|
||
ctx.fillStyle = i % 4 === 0 && k > 0.5 ? '#fff2c0' : col; // white-hot core tongues while it rages
|
||
ctx.fillRect(fx | 0, fy | 0, Math.max(1, s * 0.05), Math.max(2, s * (0.13 + 0.10 * 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;
|
||
// CHALK WHITE = this one is SLICED. The band has always had one threshold (kills / only staggers)
|
||
// and the perfect window sat inside the killing half, invisible: quality saturates at pullFull, so
|
||
// an overpull drew exactly the band a perfect pull did. White is the third state, and the only
|
||
// place white appears on the court — nothing else in the palette is near it.
|
||
const hex = beamW ? WEAPONS[slotId(G.weapon)].col
|
||
: a.pf ? '#f2f2e6'
|
||
: 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.pf ? '#f2f2e6' : 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
|
||
// NOTHING BEHIND THE CAMERA DRAWS. proj's scale is camF/(camZ - z), so a point at or past the
|
||
// camera plane comes back with s <= 0 — x and y are mirrored nonsense and the radii below go
|
||
// NEGATIVE, which ctx.ellipse throws on (IndexSizeError). A throw here killed the REST of the
|
||
// frame: no HUD, no rack, no message band, every frame it happened. A solver can hand us such a
|
||
// point (a target off the back of the world), so the projection is checked, not the aim.
|
||
if (m.s <= 0) return;
|
||
// 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();
|
||
// ...and a SECOND ring outside it when the shot is sliced: the fill says where the ball lands,
|
||
// the ring says it will not stop there. Drawn on the mark rather than as a third HUD element
|
||
// because your eye is already on this body — that is what you are aiming at.
|
||
if (a.pf && onBody) {
|
||
ctx.globalAlpha = 0.75 * (flying ? left * left : 1);
|
||
ctx.strokeStyle = col; ctx.lineWidth = Math.max(1, m.s * 0.02);
|
||
ctx.beginPath(); ctx.ellipse(m.x, m.y, m.s * 0.36, m.s * 0.50, 0, 0, 7); ctx.stroke();
|
||
}
|
||
ctx.globalAlpha = 1; ctx.lineWidth = 1;
|
||
}
|
||
|
||
function drawRing(x, z, color, k = 1) { // k: radius scale — the landing ring contracts on time-to-touchdown
|
||
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 * k)), ry = Math.max(1, Math.round(p.s * 0.12 * k));
|
||
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;
|
||
|
||
// BOUNCE_IDLE.dx/apex are measured off the DRAWN PALM (see config), so a tour whose player reaches
|
||
// differently needs its own pair. Only the women's row carries them; ?? falls back to the men's.
|
||
const bounceDx = () => tour.dx ?? BOUNCE_IDLE.dx;
|
||
const bounceApex = () => tour.apex ?? BOUNCE_IDLE.apex;
|
||
|
||
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);
|
||
// the readability boost is what makes the MATCH OPPONENT legible: at the far baseline he was
|
||
// ~34 logical px of faithful anatomy, which is mush. The near player anchors the curve (farB
|
||
// clamps at his deepest stance), so P draws within a hair of his old size everywhere he stands.
|
||
const hpx = PLAYER_H * p.s * farB(ch.z);
|
||
// 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';
|
||
// The run cycle at whatever count the character's sheet carries: the _runroll.py cycles are 6
|
||
// (one call, one row, one character — see assets/_runroll.py for why the old per-frame art
|
||
// flickered); un-rolled characters fall back to 4 or 2.
|
||
const runN = SPR[pre + 'run8'] ? 8 : SPR[pre + 'run6'] ? 6 : SPR[pre + 'run4'] ? 4 : 2;
|
||
// The rolled 6/8-frame cycles run at their own STRIDE RATE (TUNE.runHz, cycles/s). The old 4.5
|
||
// was tuned on 2-4 near-duplicate frames, where it read as vigour — on eight real phases it was
|
||
// a strobe ("very flashy and unnatural"), most of a full stride every display frame.
|
||
if (ch.pose === 'run') name = pre + 'run' + (Math.floor(ch.runT * (runN >= 6 ? TUNE.runHz : 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);
|
||
const rackTop = () => H - TUNE.rackH * rackRows() - RACK_BOT - SAFE.bottom;
|
||
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 };
|
||
}
|
||
// A NEAR-MISS AT THE RACK IS DEAD, NOT A SHOT. A tap that lands a thumb-width off a tile used to
|
||
// return -1 — free court — and input turned it into Space, which INSTANTLY armed whatever burst
|
||
// weapon was selected. So reaching for the flamer and missing by 10px fired the gun: the exact
|
||
// "selecting X activated Y" report, made likely by 22px tiles, a second row the thumb hasn't
|
||
// learned yet, and a selection that can move under you (a won duel re-equips the tin it tops up).
|
||
const RACK_MISS = 18; // px of dead zone around the band: no selection, no swing, no burst
|
||
export function rackHit(nx, ny) { // normalized [0,1] canvas coords -> slot index; -1 free court, -2 dead near-miss
|
||
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 cy >= g.y0 - RACK_GRAB - RACK_MISS && cx >= g.x0 - RACK_MISS && cx < g.x0 + g.rw + RACK_MISS
|
||
? -2 : -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.
|
||
// The LIVE one has to read at a glance ("the active weapon should be more pronounced"):
|
||
// a visibly brighter well and a 2px gold frame, not the same hairline every other tile gets.
|
||
if (sel) ctx.lineWidth = 2;
|
||
panel(x + 1.5, y0 + 1.5, g.tw - 3, g.h - 3, 4,
|
||
sel ? '#1c5230' : '#0a2418', // sunk slot — the live one sits visibly lit
|
||
sel ? UI.pick : '#05160e'); // gold frame when live
|
||
if (sel) ctx.lineWidth = 1;
|
||
// THE BURST COUNTDOWN IS THE TILE ITSELF. It was a 1.5 px bar along the tile's bottom edge —
|
||
// which is both the rounded frame AND the very bottom of a phone screen, so it was invisible
|
||
// ("the progress bar is not visible anymore because of the lower border"). The armed slot's
|
||
// WELL drains instead: a lit wash across the whole tile, emptying to the left as the window
|
||
// runs out. Clipped to the tile's own rounded path (panel leaves it current) so it can never
|
||
// square off the corners, and drawn UNDER the icon and labels, which stay fully readable.
|
||
if (G.mgT > 0 && slotId(i) === G.gunId) {
|
||
const frac = Math.min(1, G.mgT / (WEAPONS[G.gunId]?.len || TUNE.mgPickupLen));
|
||
ctx.save(); ctx.clip();
|
||
ctx.fillStyle = hexA(UI.pick, 0.34);
|
||
ctx.fillRect(x + 1.5, y0 + 1.5, (g.tw - 3) * frac, g.h - 3);
|
||
ctx.restore();
|
||
}
|
||
// a slot that just came out of a barrel announces itself: a pink pulse for a moment, WITHOUT
|
||
// stealing the selection (grabbing the gun mid-plan was the complaint — see popBarrel)
|
||
if (G.rackGlow && G.rackGlow.slot === i) {
|
||
const p = 0.35 + 0.55 * Math.abs(Math.sin(G.rackGlow.t * 7));
|
||
ctx.save(); ctx.lineWidth = 2;
|
||
panel(x + 1.5, y0 + 1.5, g.tw - 3, g.h - 3, 4, null, hexA(UI.pink, p));
|
||
ctx.restore();
|
||
}
|
||
// 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;
|
||
}
|
||
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.
|
||
// ---- THE MESSAGE BAND. Every transient centred message — the milestone card, the RESUMING
|
||
// countdown, the one-line flash — has ONE definite home: over the CROWD, below the scoreboard
|
||
// block, above the ad strip. Nothing transient sits on the court anymore — a card at centre court
|
||
// covered the very play it was announcing, non-blocking or not.
|
||
const AD_TOP = () => Math.round(proj(0, 0, -6).y) - AD_H; // top of the courtside ad strip
|
||
// The band no longer has ROWS — it has an AXIS. Card, countdown and flash all centre their ink on
|
||
// CARD_MID; only one of the three is ever up (see cardUp/flashUp), so they cannot collide, and the
|
||
// eye learns one place to look instead of three stacked slots. The icon sits LEFT of the text
|
||
// rather than above it: stacked, an icon card ran 102 px tall and its lower third hung over live
|
||
// court; side-by-side it is flat, sits fully over the crowd, and the art draws BIGGER than the
|
||
// stacked layout gave it. CARD_H is the MAXIMUM plate height — the plate itself is sized to what
|
||
// it actually holds (see plate()), so a one-line countdown gets a slim strip instead of a
|
||
// three-line box with nothing in two thirds of it.
|
||
const CARD_TOP = 68, CARD_H = 62, CARD_PADY = 9; // px of breathing room above and below the ink
|
||
const CARD_MID = CARD_TOP + CARD_H / 2; // the axis everything in the band sits on
|
||
|
||
// THE CARD IS A PHYSICAL THING that gets carried on and carried off, courtside-sign style: it
|
||
// swipes in from the left, holds, and leaves to the right. Entry overshoots and settles back
|
||
// (easeOutBack) so it arrives with weight; the exit accelerates away (easeInCubic) so it reads as
|
||
// leaving rather than fading. The whole card moves, plate included — a sign travels, its backing
|
||
// does not stay bolted across the screen while the type slides out from under it.
|
||
const CARD_IN = 0.44, CARD_OUT = 0.24; // seconds; clamped below so a short card still gets both.
|
||
// The entry is the SLOWER of the two on purpose: at 0.3 the overshoot covered the whole width in
|
||
// about four frames, so the smear happened off-screen and only the exit ever showed a trail.
|
||
// x offset for something that lives for T0 seconds and has t left. Anything with a countdown can
|
||
// ride the band this way — the card does, and so does the RESUMING clock.
|
||
function slideX(T0, t) {
|
||
const tIn = Math.min(CARD_IN, T0 / 3), tOut = Math.min(CARD_OUT, T0 / 3);
|
||
const el = T0 - t;
|
||
if (el < tIn) { const k = Math.max(0, el) / tIn - 1, s = 1 + 2.7 * k * k * k + 1.7 * k * k; return -W * (1 - s); }
|
||
if (t < tOut) { const k = 1 - t / tOut; return W * k * k * k; }
|
||
return 0;
|
||
}
|
||
|
||
// MOTION BLUR: ghost copies sampled BACK IN TIME along the card's own easing curve, so the smear
|
||
// stretches exactly as far as the card actually travelled — long through the entry snap, short as
|
||
// it settles, and nothing at all while it holds. Sampling the curve beats differencing frames:
|
||
// it looks identical at 60 fps and at the calm-state 20, and needs no per-card state.
|
||
const CARD_GHOSTS = 4, CARD_GHOST_DT = 0.026; // 4 copies over ~100 ms of history — the trail length
|
||
// The RESUMING clock's own T0, latched on the frame it takes the band and cleared when it leaves.
|
||
// A card has a fixed life to slide against; the clock's is whatever was left when the band freed up.
|
||
let cdT0 = 0;
|
||
function slideDraw(T0, t, body) {
|
||
const dx = slideX(T0, t);
|
||
for (let i = CARD_GHOSTS; i >= 1; i--) {
|
||
const gx = slideX(T0, t + i * CARD_GHOST_DT);
|
||
if (Math.abs(gx - dx) < 1.5) continue; // holding: no trail to draw
|
||
ctx.globalAlpha = 0.42 / i;
|
||
body(gx);
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
body(dx);
|
||
}
|
||
|
||
function drawCard(c) {
|
||
const T0 = c.T0 || (c.T0 = c.t); // recorded on the first drawn frame — `wait` holds
|
||
slideDraw(T0, c.t, dx => cardBody(c, dx)); // `t` frozen until then, so this is exact
|
||
}
|
||
|
||
function cardBody(c, dx) {
|
||
const { title, sub1, sub2, color, icon } = c;
|
||
const img = icon && SPR[icon];
|
||
ctx.save(); ctx.translate(Math.round(dx), 0);
|
||
// TWO layouts, by whether there is art. The WEAPON card is one centred row — art, name, amount,
|
||
// measured as a single group and centred as one. No prose: the tin's own picture says what it is
|
||
// and the count says how much, and the sentence explaining it was a line nobody read mid-run.
|
||
ctx.textAlign = 'center';
|
||
if (img) {
|
||
const k = Math.min(44 / img.height, 46 / img.width);
|
||
const iw = Math.round(img.width * k), ih = Math.round(img.height * k);
|
||
ctx.font = B(26); const nameW = ctx.measureText(title).width;
|
||
ctx.font = B(20); const amtW = sub1 ? 8 + ctx.measureText(sub1).width : 0;
|
||
let x = Math.round((W - (iw + 10 + nameW + amtW)) / 2);
|
||
const s = inkSpan([[title, B(26), 0]]);
|
||
plate(Math.max(ih, s.bot - s.top)); // the art is usually the taller of the two
|
||
ctx.imageSmoothingEnabled = false;
|
||
ctx.drawImage(img, x, Math.round(CARD_MID - ih / 2), iw, ih);
|
||
ctx.textAlign = 'left'; x += iw + 10;
|
||
ctx.font = B(26); shadowText(title, x, inkCentre([[title, B(26), 0]]), color);
|
||
if (sub1) { ctx.font = B(20); shadowText(sub1, x + nameW + 8, inkCentre([[sub1, B(20), 0]]), UI.cream); }
|
||
ctx.textAlign = 'center'; ctx.restore();
|
||
return;
|
||
}
|
||
// the title still SHOUTS (Bangers) but at 22, not 30 — up here it shares the screen with the
|
||
// scoreboard and the ads, and 30 read as oversized the moment it left centre court. The stack
|
||
// keeps its own line rhythm (0/30/44) and is centred as ONE block, so a one-line card and a
|
||
// three-line card sit on the same axis instead of both hanging off the band's top edge.
|
||
// ...and the title FITS. Cards carry whole sentences now ("SWING TO RETURN — OR SWISH IT AWAY")
|
||
// and boss names of any length, and Bangers 22 ran a long one off both edges of a phone. Measure
|
||
// once and shrink to the band; 22 stays 22 for everything that already fit.
|
||
// THE OPPONENT CARD CARRIES ITS HEAD (Oliver: the punchline plus a picture of his head). The
|
||
// icon prefers the 4K-cut z_<fam>head.png (assets/_heads.py — the installed 224 px frames gave
|
||
// ~65 px heads drawn at 144 device px, which is the "very low res" he reported) and degrades
|
||
// to the HEAD_CROP crop of the healthy frame, then to the plain text card. Never separate art:
|
||
// both sources are the body already on court.
|
||
const headImg = c.head && SPR['z_' + c.head + 'head'];
|
||
const hd = headImg ? { img: headImg, w: headImg.width, h: headImg.height }
|
||
: c.head && HEAD_CROP[c.head] && SPR['z_' + c.head + '1']
|
||
? { img: SPR['z_' + c.head + '1'], box: HEAD_CROP[c.head],
|
||
w: HEAD_CROP[c.head].w, h: HEAD_CROP[c.head].h }
|
||
: null;
|
||
const ih = hd ? CARD_H - 14 : 0, iw = hd ? Math.round(hd.w * ih / hd.h) : 0;
|
||
const room = W - 24 - (hd ? iw + 8 : 0);
|
||
ctx.font = B(22);
|
||
const tw = ctx.measureText(title).width;
|
||
const tSize = tw > room ? Math.max(13, Math.floor(22 * room / tw)) : 22;
|
||
const lines = [[title, B(tSize), 0, color]];
|
||
if (sub1) lines.push([sub1, F(8), 30, UI.cream]);
|
||
if (sub2) lines.push([sub2, F(8), 44, UI.cream]);
|
||
const s = inkSpan(lines);
|
||
plate(Math.max(s.bot - s.top, ih));
|
||
const y = Math.round(CARD_MID - (s.top + s.bot) / 2);
|
||
const cx = Math.round(W / 2 + (hd ? (iw + 8) / 2 : 0));
|
||
if (hd) {
|
||
let blockW = 0;
|
||
for (const [t, f] of lines) { ctx.font = f; blockW = Math.max(blockW, ctx.measureText(t).width); }
|
||
ctx.imageSmoothingEnabled = false;
|
||
const hx = Math.round(cx - blockW / 2 - 8 - iw), hy = Math.round(CARD_MID - ih / 2);
|
||
if (hd.box) ctx.drawImage(hd.img, hd.box.x, hd.box.y, hd.box.w, hd.box.h, hx, hy, iw, ih);
|
||
else ctx.drawImage(hd.img, hx, hy, iw, ih);
|
||
}
|
||
for (const [t, f, rel, col] of lines) { ctx.font = f; shadowText(t, cx, y + rel, col); }
|
||
ctx.textAlign = 'center'; ctx.restore(); // the band's neighbours (flash, countdown) assume centred
|
||
}
|
||
// The plate, sized to the `h` px of content it carries and centred on the axis. Capped at CARD_H:
|
||
// the three-line SET card is what that number was tuned for and it still fills it exactly.
|
||
function plate(h) {
|
||
const ph = Math.min(CARD_H, Math.round(h) + CARD_PADY * 2);
|
||
ctx.fillStyle = 'rgba(10,6,8,0.72)';
|
||
ctx.fillRect(0, Math.round(CARD_MID - ph / 2), W, ph);
|
||
}
|
||
// The measured INK extent of a stack of [text, font, relativeY]. Off ink, not off the font box:
|
||
// Bangers carries a lot of leading, so a nominal 'middle' baseline (or a fixed offset tuned on one
|
||
// string) hangs the caps visibly high. textBaseline is 'top' here, so ascent is negative — ink runs
|
||
// rel-ascent .. rel+descent. Everything in the band sizes and centres itself off this.
|
||
function inkSpan(lines) {
|
||
let top = Infinity, bot = -Infinity;
|
||
for (const [t, f, rel] of lines) {
|
||
ctx.font = f; const m = ctx.measureText(t);
|
||
top = Math.min(top, rel - m.actualBoundingBoxAscent);
|
||
bot = Math.max(bot, rel + m.actualBoundingBoxDescent);
|
||
}
|
||
return { top, bot };
|
||
}
|
||
function inkCentre(lines) { const s = inkSpan(lines); return Math.round(CARD_MID - (s.top + s.bot) / 2); }
|
||
|
||
// A GHOST HAND, fingertip at (x, y) — the tutorial finger every gesture hint shares. A drawn
|
||
// pointing hand rather than the old abstract dot: testers did not read a sliding circle as "your
|
||
// finger goes here". `press` (0..1) dips it toward the glass, which is what makes a mimed tap
|
||
// read as a tap and not as a cursor hovering. Pure canvas paths, no sprite: it is HUD chrome in
|
||
// the UI palette, same family as the arrowheads and the dotted track, not a body on the court.
|
||
function drawGhostHand(x, y, press = 0, hPx = 34) {
|
||
const img = SPR.x_hand, yy = Math.round(y) - 3 + press * 4;
|
||
if (img) { // real art now (assets/x_hand.png, drawn to Oliver's reference pose)
|
||
const w = Math.round(img.width * hPx / img.height);
|
||
ctx.imageSmoothingEnabled = false;
|
||
// 0.368: the index fingertip's x as a fraction of the PNG's width, MEASURED off the sprite
|
||
// (topmost opaque row's centroid) so the tip lands exactly on the point being taught
|
||
ctx.drawImage(img, Math.round(x - w * 0.368), yy, w, hPx);
|
||
return;
|
||
}
|
||
// fallback while the sprite loads (or a cached 404 — this project's oldest trap): the drawn hand
|
||
ctx.save();
|
||
ctx.translate(Math.round(x), yy); ctx.scale(1.4, 1.4); ctx.rotate(-0.15);
|
||
ctx.fillStyle = UI.cream; ctx.strokeStyle = 'rgba(8,12,10,0.8)'; ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.roundRect(-2.5, 0, 5, 15, 2.5); ctx.fill(); ctx.stroke(); // index finger, tip at origin
|
||
ctx.beginPath(); ctx.roundRect(-2.5, 12, 13, 11, 5); ctx.fill(); ctx.stroke(); // the palm behind it
|
||
for (const [bx, by, br] of [[4.5, 13.2, 2.4], [7.4, 13.7, 2.2], [9.9, 14.4, 2.0]]) {
|
||
ctx.beginPath(); ctx.arc(bx, by, br, 0, 7); ctx.fill(); ctx.stroke(); // curled knuckles
|
||
}
|
||
ctx.beginPath(); ctx.ellipse(-3.9, 17.5, 2.6, 4.4, 0.6, 0, 7); ctx.fill(); ctx.stroke(); // thumb
|
||
ctx.restore();
|
||
}
|
||
// the contact ring a mimed tap leaves on the glass: grows and fades under the fingertip
|
||
function drawTapRing(x, y, p) {
|
||
ctx.save();
|
||
ctx.globalAlpha *= Math.max(0, 0.9 * (1 - p));
|
||
ctx.strokeStyle = UI.warm; ctx.lineWidth = 2;
|
||
ctx.beginPath(); ctx.arc(x, y, 4 + p * 16, 0, 7); ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
// Sit in the serve stance doing nothing and the gesture demonstrates itself: a ghost hand 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(10); // hint type is 10 now, one size up — arm's-length readable was the ask
|
||
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.
|
||
// (offsets clear the bigger hand: its palm hangs ~32 px below the fingertip now)
|
||
shadowText(label, ax, ay + (dy ? travel + 36 : 38), UI.warm);
|
||
// the ghost hand riding the stroke, pressed into the glass for the whole draw
|
||
ctx.globalAlpha = gone ? 0 : 0.9;
|
||
drawGhostHand(ax + dx * len, ay + dy * len, 1);
|
||
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 TENNIS lesson, same ghost hand. The serve hints (an idle tap demo on the apron, an
|
||
// apex rider on a dropped toss) lived here too and were DELETED on Oliver's report — a ghost
|
||
// finger over the running serve animation read as clutter, and the zombie duel serves fine with
|
||
// no teacher at all. Only the return lesson below survives.
|
||
// two returns passed untouched: the hand presses harder as the ball closes on the player
|
||
function drawHitHint() {
|
||
const p0 = proj(P.x, 0, P.z);
|
||
const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); // the same distance trySwing tests
|
||
const press = Math.max(0, Math.min(1, 1 - (d - 1.6) / 2.2));
|
||
const hx = Math.round(p0.x) + 26, hy = Math.round(p0.y) - 52; // over his shoulder, off the sprite
|
||
ctx.save(); ctx.globalAlpha = 0.9;
|
||
if (press >= 1) drawTapRing(hx, hy, (G.idleT * 2) % 1);
|
||
drawGhostHand(hx, hy, press);
|
||
ctx.font = F(10); ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
||
shadowText('TAP AS IT ARRIVES', hx - 26, hy + 44, UI.warm);
|
||
ctx.restore();
|
||
}
|
||
|
||
// The modal: drawn LAST, over everything, and the world behind it is frozen (gameplay.update
|
||
// early-returns). Unlike drawCard this is a wall — nothing moves until it is tapped away.
|
||
// easeOutBack: overshoots past the target and settles. The arcade snap — nothing in a cabinet
|
||
// menu ever arrived by decelerating politely into place.
|
||
const BACK_C = 1.70158;
|
||
const easeBack = p => 1 + (BACK_C + 1) * (p - 1) ** 3 + BACK_C * (p - 1) ** 2;
|
||
|
||
function drawModal(m) {
|
||
// The entrance (m.in, 0..1, ticked in gameplay's update behind the world freeze): the icon and
|
||
// title fly in from the RIGHT, the body lines cascade in from the LEFT one after another, each
|
||
// on its own staggered easeOutBack. Every x is ROUNDED — a fractional x on a nearest-neighbour
|
||
// scaled image re-snaps its pixel columns every frame, which is the same boiling that once read
|
||
// as HUD flicker. Legacy/test modals with no `in` field draw fully landed.
|
||
const t = m.in === undefined ? 1 : m.in;
|
||
const STAG = 0.08, RUN = 0.62; // per-element delay, and travel time
|
||
const lane = k => { // 0 = off screen, 1 = home (may overshoot)
|
||
const p = Math.max(0, Math.min(1, (t - k * STAG) / RUN));
|
||
return p >= 1 ? 1 : easeBack(p);
|
||
};
|
||
const dx = (k, from) => Math.round(from * (1 - lane(k)) * W * 0.85); // from: -1 left, 1 right
|
||
ctx.fillStyle = `rgba(8,6,10,${(0.82 * Math.min(1, t / 0.35)).toFixed(3)})`; // the dim washes in first
|
||
ctx.fillRect(0, 0, W, H);
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||
const body = m.body || [], img = m.icon && SPR[m.icon];
|
||
// cap the illustration against the viewport, not just the asset: at 270px tall (landscape) a
|
||
// portrait-sized icon pushed the TAP prompt down onto the weapon rack
|
||
const iconH = img ? Math.min(m.iconH || 40, H * 0.28) : 0;
|
||
const blockH = (iconH ? iconH + 12 : 0) + 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 ballHist = []; // hot-ball ghost trail positions (render-owned, see the streak block in render)
|
||
const AD_H = 14; // px tall the courtside ad strip is drawn — also the ceiling for centred HUD text
|
||
// (FLASH_Y is gone — the flash centres on CARD_MID with everything else in the band now.)
|
||
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;
|
||
// ...and the ball-follow lean with it, tennis rallies only: the gore modes fire a ball down-court
|
||
// every second and the camera would pump. Slower ease than the aim (a broadcast lean, not a snap).
|
||
farCam += (((G.match || G.mode === 'tennis') && ball.active && ball.z < COURT.netZ ? 1 : 0) - farCam) * 0.06;
|
||
if (farCam < 0.001) farCam = 0; else if (farCam > 0.999) farCam = 1; // settle exactly, so the
|
||
// backdrop cache key stops moving at either end
|
||
// pivot the lean about the player's deepest stance — see the fOff note at proj()
|
||
const fs0 = TUNE.camF / (TUNE.camZ - P_BOUNDS.zMax);
|
||
const fs1 = TUNE.camF / (TUNE.camZ + farCam * TUNE.farCamZ - P_BOUNDS.zMax);
|
||
fOff = TUNE.camY * fs0 - (TUNE.camY + farCam * TUNE.farCamY) * fs1;
|
||
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();
|
||
pageBackground(pal.out); // the page behind the canvas matches, for the rounded display corners
|
||
// stands, ads, rot tint, court, lines and the run-off, as one cached blit (see paintBackdrop);
|
||
// the clap ripple is overlaid live inside. The ad strip sits flush on the court's back edge (the
|
||
// out-area quad reaches z=-6), which keeps the boards clear of the lawn in BOTH orientations.
|
||
drawBackdrop(pal, pt);
|
||
// (a random floodlight stutter darkened the stands here for one build — on the phone it read as
|
||
// the crowd area flickering black, i.e. as a rendering bug, not as ambience. Gone.)
|
||
|
||
// 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);
|
||
if (p.s <= 0) continue; // behind the camera: see drawAimMark
|
||
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
|
||
|
||
// THE PLACEMENT RING (match): where your aimed rally shot is going, drawn the moment you flick.
|
||
// Gold, fading over 0.8 s — flick, watch the ring bite the corner, learn what the gesture does.
|
||
if (G.placeMark) {
|
||
// THE RING IS THE FLICK'S RECEIPT. Its POSITION says where the ball is going; its SIZE and
|
||
// COLOUR say how much of your flick was actually applied, because reach scales with timing
|
||
// (see doHit) and that would otherwise be an invisible rule. Tight and hot = you earned the
|
||
// full width; wide and dim = you got a racket on it and little else.
|
||
const q = G.placeMark.q ?? 1;
|
||
ctx.globalAlpha = Math.min(1, G.placeMark.t / 0.5) * (0.55 + 0.45 * q);
|
||
drawRing(G.placeMark.x, G.placeMark.z, q > 0.62 ? UI.hot : UI.warm, 1.35 - 0.6 * q);
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
// landing ring: white = incoming ball (a PREDICTION, gone the moment the ball touches down —
|
||
// the ring that lingered where the ball had already landed confused testers and is removed)
|
||
if (G.phase === 'rally' && ball.active && ball.bounces === 0 && ball.lastHit === 'O') {
|
||
const l = predictLanding();
|
||
// the ring CONTRACTS toward touchdown (predictLanding's closed-form t), so the static marker
|
||
// becomes a timing cue: wide while the ball is high, snapping to its true size as it arrives
|
||
if (l) drawRing(l.x, l.z, 'rgba(255,255,255,0.85)', Math.min(2.2, 0.55 + l.t * 1.5));
|
||
}
|
||
// 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 = [
|
||
// the umpire wears the same boost as a body standing at his depth, or he'd read as the one
|
||
// shrunken figure on a court of boosted ones
|
||
{ z: COURT.netZ - 0.01, f: () => drawStatic('x_ump', UMP_X, COURT.netZ, UMP_H * farB(COURT.netZ)) },
|
||
];
|
||
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();
|
||
const rip = G.netRipple;
|
||
if (rip) { // net cord: a damped travelling wave centred where the ball clipped the tape (0.4 s)
|
||
const px = proj(rip.x, COURT.netH, COURT.netZ).x;
|
||
for (let i = 0; i <= 24; i++) {
|
||
const x = nlt.x + (nrt.x - nlt.x) * i / 24;
|
||
const y = nlt.y + (nrt.y - nlt.y) * i / 24
|
||
+ Math.sin((x - px) * 0.3 + rip.t * 34) * 1.1 * (rip.t / 0.4) * Math.exp(-Math.abs(x - px) * 0.05); // ~1 px at the clip — a wobble, not a wave
|
||
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
|
||
}
|
||
} else { 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.
|
||
// ...and the offset FOLLOWS THE FACING, because drawChar mirrors the whole sprite when a server
|
||
// faces left — dodging during the bounce swapped his hands over and left the ball hanging off
|
||
// the racket side. One `* sv.facing` fixes it for both players and both tours; it was wrong in
|
||
// the men's world too, it just took a second body to notice.
|
||
const mine = sv.side === 'P';
|
||
const bx = sv.x + sv.facing * (mine ? -bounceDx() : BOUNCE_IDLE.oDx);
|
||
const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * (mine ? bounceApex() : BOUNCE_IDLE.oApex);
|
||
drawBall(bx, by, sv.z);
|
||
}
|
||
// hot-ball ghost streak: a fast rally ball drags three fading ghosts behind it. It gated on
|
||
// pow > 0.8 alone, which in practice only the gore slingshot ever reached — tennis tap swings
|
||
// live below it, so both tennis modes played without the streak (Oliver's sidequest). PACE now
|
||
// qualifies a ball too: a flat serve or a clean winner streaks in every mode, a lob stays clean.
|
||
const hot = ball.active && G.phase === 'rally' && !ball.collected
|
||
&& ((ball.pow || 0) > 0.8 || Math.hypot(ball.vx, ball.vz) > TUNE.trailSpeed);
|
||
if (hot) { ballHist.push({ x: ball.x, y: ball.y, z: ball.z }); if (ballHist.length > 7) ballHist.shift(); }
|
||
else if (ballHist.length) ballHist.length = 0;
|
||
if (hot) {
|
||
for (let i = 0; i < 3; i++) {
|
||
const h = ballHist[ballHist.length - 2 - i * 2]; if (!h) break;
|
||
const p = proj(h.x, h.y, h.z);
|
||
// 0.26 was tuned against the gore mode's dark rot palette; on the bright tennis lawn it
|
||
// vanished entirely — "i see no ball trails" (Oliver, in the tournament)
|
||
ctx.globalAlpha = 0.38 - i * 0.11;
|
||
ctx.fillStyle = UI.ball;
|
||
ctx.beginPath(); ctx.arc(p.x, p.y, Math.max(1.5, p.s * 0.09 * (0.9 - i * 0.18)), 0, 7); ctx.fill();
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
// 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 && proj(G.bossBurst.x, 0.5, G.bossBurst.z).s > 0) { // behind the camera: see drawAimMark
|
||
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)
|
||
// world-anchored reaction pops (zombies.popText): DOUBLE KILL rises off the corpse, the praise
|
||
// word lands on the impact. Drawn IN the world pass, before the un-shake, so they ride the kick
|
||
// of the moment they belong to. Small, brief, and where you are already looking — the reactions
|
||
// left the announcement band because up there they read as one more banner shouting over play.
|
||
for (const p of pops) {
|
||
const f = p.t / p.T, pr = proj(p.x, p.y, p.z);
|
||
ctx.save();
|
||
ctx.globalAlpha = Math.min(1, f * 3); // holds, then a quick fade at the end
|
||
ctx.font = B(13); ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
||
shadowText(p.text, Math.round(pr.x), Math.round(pr.y - (1 - f) * 14), p.col || '#ffffff');
|
||
ctx.restore();
|
||
}
|
||
// (a PERFECT-contact spark ring lived here for one build — Oliver's verdict: too much. The
|
||
// hitstop, the click and the praise word already carry the moment.)
|
||
// 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); }
|
||
// THE BLOOD VIGNETTE — the hp channel, worn permanently. The old edge wash flashed for a third
|
||
// of a second and vanished, so between hits the screen said you were fine; Oliver asked for the
|
||
// opposite: "a blood red vignette which gets worse with every hit". It reads G.hp directly — a
|
||
// radial darkening of the frame's edges whose depth IS your damage fraction — and the hit itself
|
||
// rides on top as a brief brighter pulse (hurtFlash, kept). No HP bar anywhere, still: the
|
||
// vignette is the same drawn-on-the-man philosophy, one layer out.
|
||
{
|
||
const dmg = 1 - Math.max(0, G.hp) / (G.hpMax || 1);
|
||
const pulse = G.hurtFlash > 0 ? Math.min(1, G.hurtFlash / 0.3) * 0.3 : 0;
|
||
if (dmg > 0 || pulse > 0) {
|
||
// FOUR EDGE FADES, not a radial. A radial gradient in a tall portrait canvas reaches the
|
||
// corners but arrives at the side edges at fraction ZERO — Oliver saw "a slight red pulsing
|
||
// from below" because the bottom edge was the only place the circle got close. Linear fades
|
||
// hug the actual rectangle on any aspect. sqrt(dmg) front-loads the curve (the match pool is
|
||
// 8, so linear made hit one an invisible 10%), and the inset DEEPENS with damage so wounds
|
||
// shrink the clean centre.
|
||
const a = Math.min(0.85, Math.sqrt(dmg) * 0.8 + pulse);
|
||
const col = k => `rgba(120,8,12,${(a * k).toFixed(3)})`;
|
||
const D = Math.round(Math.min(W, H) * (0.2 + 0.32 * dmg));
|
||
for (const [x0, y0, x1, y1, w, h, gx, gy] of [
|
||
[0, 0, 0, D, W, D, 0, 1], // top
|
||
[0, H, 0, H - D, W, D, 0, -1], // bottom
|
||
[0, 0, D, 0, D, H, 1, 0], // left
|
||
[W, 0, W - D, 0, D, H, -1, 0], // right
|
||
]) {
|
||
const g = ctx.createLinearGradient(x0, y0, x1, y1);
|
||
g.addColorStop(0, col(1)); g.addColorStop(1, col(0));
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(gx < 0 ? W - D : 0, gy < 0 ? H - D : 0, w, h);
|
||
}
|
||
}
|
||
}
|
||
|
||
// HUD — Wimbledon manual scoreboard as a 16-bit panel: bevelled bottle-green box (light
|
||
// top/left, dark bottom/right), a purple club spine, cream serif names, and the LIVE game
|
||
// sunk into a recessed slot — purple for the server, gold digits. Splatter adds a recessed
|
||
// status well (kills-to-next-point pips + weapon stock). Hard edges, no radius = retro.
|
||
const benchOn = !!G.bench; // an art test has no score; the board would be the biggest thing on
|
||
// screen arguing with the one frame you came here to look at
|
||
const splat = G.mode !== 'tennis';
|
||
// ZOMBIE TENNIS HAS ITS OWN BOARD. The club's bottle-green plate is the club's; the match is
|
||
// played on a board nobody has cleaned in a long time (assets/_board.py), and its ink has to
|
||
// change with it — a green 'dim' over dried blood reads as a printing fault. Falls back to the
|
||
// club board and its palette until the plate has loaded.
|
||
const zBoard = G.match && SPR.scoreboard_z;
|
||
const bd = zBoard
|
||
? { face: '#4a1c1c', hi: '#7a4a2a', lo: '#2a0e0e',
|
||
spine: '#d9d2c4', spineHi: '#f2ead6', cream: '#f2ead6', dim: '#a8887c', gold: UI.warm }
|
||
: { face: '#1e5b3a', hi: '#3f9560', lo: '#0e3624',
|
||
spine: '#472a70', spineHi: '#5f3d94', cream: '#f2ead6', dim: '#89a08f', gold: UI.pick };
|
||
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)
|
||
const plate = zBoard || SPR.scoreboard;
|
||
if (benchOn) { /* an art test has no score — not even the empty plate */ }
|
||
else if (plate) ctx.drawImage(plate, 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';
|
||
// ...and the match names the thing across the net rather than calling it THEM. Eight characters,
|
||
// which is the name slot — see `short` on the opponent rows in config.
|
||
const foe = G.match && zombies.find(z => z.variant === 'r' && z.foe);
|
||
const names = G.match ? ['YOU', (foe && foe.foe.short) || 'THEM']
|
||
: splat ? ['YOU', 'THEM'] : ['YOU', 'CPU'];
|
||
for (let i = 0; i < 2 && !benchOn; 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';
|
||
// ...and CLASSIC TENNIS shows them too: it is the one mode where a set is the thing you are
|
||
// actually playing for, and hiding the column left a match-scoring mode reading 0-0 all match.
|
||
// the SETS column, and NOT in the match: it banks no sets, so the column was a permanent 0 —
|
||
// the bar under the board carries that mode's progress instead (OPPONENT n · m FELLED)
|
||
if ((splat || G.classic) && !G.match) { ctx.font = F(8, 0); ctx.fillStyle = bd.dim; ctx.fillText(String(G.sets[i]), 74, cy); }
|
||
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 || G.classic) && !benchOn) {
|
||
// 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;
|
||
// ...and in the MATCH the run is not measured in games at all (blood-only scoring): the ladder
|
||
// rung you are on and the bodies behind you are what the player is playing for, so this slot
|
||
// prints those instead. Same slot, same size — the mode changes the words, not the furniture.
|
||
// ...and in CLASSIC it says how far through the MATCH you are, which is the one thing the
|
||
// board itself cannot: which set this is, and how many it takes to win.
|
||
// the cup names the ROUND — its opp is a drawn rung, so the deathmatch arithmetic lied here
|
||
ctx.fillText(G.match && G.match.cup
|
||
? `${CUP_ROUNDS[G.match.cup.round].name} · ${G.match.cup.round} FELLED`
|
||
: G.match ? `OPPONENT ${G.match.opp} · ${G.match.opp - 1} FELLED`
|
||
: G.classic ? `SET ${G.sets[0] + G.sets[1] + 1} · FIRST TO ${TUNE.classicSets}`
|
||
: `GAME ${G.runGames + 1} · ${G.sets[0]} SET${G.sets[0] === 1 ? '' : 'S'}`, bW - 3, bH + 8);
|
||
}
|
||
// ...and NOT during a match: nothing on that court is killed for points, so the pips were both a
|
||
// lie and a collision — 'NEXT ○○' ran straight into the OPPONENT line sharing the panel.
|
||
if (splat && !G.match && !G.classic && !benchOn && 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.bench) drawBlood(); // how many more hits you have left, beside the board — the vignette says "worse", not "how many"
|
||
if (G.mode === 'splatter' && !G.interlude && !G.drill && !G.bench && !G.done) drawRack(); // ...and never under a trophy screen // courtside weapon rack (hidden during the tennis-rules duel AND the drill, which is played with plain balls)
|
||
drawDrillHud(); // the triage readout — no-op unless a drill is running
|
||
drawBenchHud(); // ...and the sprite bench's, same rule
|
||
drawApproachRing(); drawApexBand(); // the match's timing, drawn — no-ops unless a match is running
|
||
// THE END OF A RUN, whichever way it ended. Three modes end three different ways and they share
|
||
// one screen — a wash, a headline, the run's own two numbers, the mark to beat and the gated tap
|
||
// — because only the WORDS differ, and three copies of this drift apart the first time one moves.
|
||
// eaten + gore GAME, SET, MASSACRE. n games, n sets
|
||
// eaten + match BLED OUT. n opponents felled, n games
|
||
// done + classic MATCH. / BEATEN. the set score
|
||
// phase C for the death: the summary fades UP over the animation instead of cutting on top of it.
|
||
{
|
||
const m = G.match && G.match.felled !== undefined ? G.match : null;
|
||
const dead = G.mode === 'eaten' && G.eatenT > EATEN_SUMMARY, done = G.done && G.done.t > 0.5;
|
||
if (dead || done) {
|
||
const clk = dead ? G.eatenT - EATEN_SUMMARY : G.done.t - 0.5;
|
||
const fade = Math.min(1, clk / 0.6);
|
||
const won = !dead && G.done.won;
|
||
ctx.save(); ctx.globalAlpha = fade;
|
||
ctx.fillStyle = won ? 'rgba(6,10,8,0.72)' : '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.
|
||
const title = dead ? (m ? 'BLED OUT.' : 'GAME, SET, MASSACRE.')
|
||
: G.done.cup ? 'CHAMPION OF THE DEAD CLUB.' : won ? 'GAME, SET, MATCH.' : 'BEATEN.';
|
||
const line = dead
|
||
? (m ? 'FELLED ' + m.felled + ' OPPONENT' + (m.felled === 1 ? '' : 'S') + ' · ' + m.games + ' GAMES'
|
||
: 'SURVIVED ' + G.runGames + ' GAMES · ' + G.sets[0] + ' SETS')
|
||
: G.done.cup ? 'THE CHAMPION FELL · ' + ((G.match && G.match.games) || G.runGames) + ' GAMES'
|
||
: G.sets[0] + '–' + G.sets[1] + ' IN SETS · ' + G.runGames + ' GAMES';
|
||
ctx.font = B(26); shadowText(title, W / 2, H / 2 - 44, won ? UI.pick : UI.bad);
|
||
ctx.font = F(9); shadowText(line, 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(clk * 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(m ? 'BEST ' + G.best.games + ' FELLED'
|
||
: G.classic ? 'LONGEST MATCH ' + G.best.games + ' GAMES'
|
||
: '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 (dead ? G.eatenT > EATEN_RESTART : G.done.t > DONE_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 (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !G.bench && !(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 && !G.done && !gunLike && G.idleHintT > TUNE.pullHintAfter;
|
||
// NO GHOST HAND ON THE TENNIS SERVE — not the idle tap demo, not the apex rider. Oliver: "this
|
||
// ghost finger over the running serve animation is distracting. this must be removed. it is
|
||
// not in zombie tennis mode" — and the duel serving fine without a teacher is the argument.
|
||
// The rally-return hint (drawHitHint) stays: it answers a different struggle and he did not
|
||
// flag it. drawTapHint/drawApexHint were deleted with their counters.
|
||
// The gesture-teaching strings ('PULL DOWN TO AIM…', 'TAP TO TOSS…') are GONE — the ghost hand
|
||
// is the teacher now, and saying the same thing twice in two registers was clutter. This slot
|
||
// only carries STATE the mime cannot: which weapon the serve fires, and a second serve.
|
||
const txt = G.mode === 'splatter' && !G.interlude
|
||
? (gunLike ? (touch ? `TAP TO UNLOAD THE ${sel}` : `SPACE UNLOADS THE ${sel}`)
|
||
: !G.aim && slotId(G.weapon) !== 'ball' ? `SERVE FIRES: ${sel} · ${switchHint}`
|
||
: '')
|
||
: G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE')
|
||
: '';
|
||
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 one surviving tennis struggle hint: rides the exact failure it answers
|
||
if (G.mode === 'tennis' && G.phase === 'rally' && G.passStreak >= 2 && ball.active && ball.lastHit === 'O')
|
||
drawHitHint();
|
||
// 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.
|
||
// ...and neither the flash nor the card draws UNDER a modal: the popup owns the screen, and a
|
||
// band message half-visible through its dim was clutter saying nothing. Their clocks are frozen
|
||
// behind the freeze too (see update), so both play out in full once the popup is dismissed.
|
||
// ONE MESSAGE AT A TIME. A flash stacked above a card was two announcements shouting over each
|
||
// other in the same band; the CARD wins because it is the bigger event (a set, a chapter, a
|
||
// weapon won) and the flash under it is always a footnote to something you already watched.
|
||
// The flash's clock keeps running, so it expires quietly rather than surfacing late.
|
||
const cardUp = G.card && !G.modal && G.card.t > 0 && !(G.card.wait > 0);
|
||
const flashUp = G.flash && !G.modal && !cardUp;
|
||
if (flashUp) {
|
||
// 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.
|
||
// On the band's axis, not on its top edge: the flash is the only band citizen with no plate,
|
||
// so hanging it where a plate's first line would start left it sitting visibly high with
|
||
// nothing under it. Same ink-centring every card uses — one axis for all three registers.
|
||
ctx.font = B(18);
|
||
shadowText(G.flash.text, W / 2, inkCentre([[G.flash.text, B(18), 0]]) + 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 (cardUp) drawCard(G.card); // `wait` holds it OFF the screen (a boss dying under it)
|
||
// THE RESUMING CLOCK is a band citizen too — same plate, same swipe, same smear. It used to
|
||
// float bare over the net in its own face and position, which was the last message in the game
|
||
// that looked like it came from a different game. It is the LOWEST priority of the three: a card
|
||
// outranks it (the card explains the very pause it is counting) and so does a flash (short, and
|
||
// always about something that just happened). It swipes in the moment the band frees up.
|
||
// The 0.5 gate is on STARTING only — once it is on the band it plays down to zero, or the
|
||
// swipe-out (0.24 s) would be cut off before it ever ran.
|
||
if (waitT > 0 && (cdT0 || waitT > 0.5) && !cardUp && !flashUp) {
|
||
cdT0 = cdT0 || waitT; // its own T0: the clock left when the band came free
|
||
const n = Math.ceil(waitT);
|
||
slideDraw(cdT0, waitT, dx => cardBody(
|
||
G.changeover > 0 ? { title: 'CHANGEOVER', sub1: 'RESUMING IN ' + n, color: '#ffffff' }
|
||
: { title: 'RESUMING IN ' + n, color: '#ffffff' }, dx));
|
||
} else cdT0 = 0;
|
||
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';
|
||
}
|