The reticle was a chevron trail on the ground and a draw meter across the bottom. The trail was a RAY — it pointed at the target but said nothing about the ball getting over the net — and the meter sat in the apron, which is exactly where your thumb is while you pull, so the one readout that mattered mid-gesture spent the whole gesture underneath your hand. Now the aim IS the flight. previewServe runs the same targeting and the same loft solve hitServe fires with (both extracted, neither duplicated), so the arc you see is bit-identical to the ball you get — verified: vx/vy/vz/g all match to zero at quality 1.0, where the aim error vanishes. A preview drawn from a second copy of the maths is worse than no preview; it lies with confidence. One object now carries all three readings, up-court where you are looking: the parabola is WHERE, the colour is WHETHER (cool blue still means it only staggers), and the band's thickness and opacity are HOW MUCH — which is the part a hue cannot carry on its own. Because the arc ends at the auto-picked target, a weak pull and a full one at the same shambler draw the same curve in different colours: "pulling further does not reach further" made visible. A shadow tracks the arc along the lawn. An arc hanging in the air is ambiguous about where it comes down, and that trail is what pins it. The apron is left to the gesture demo alone, drawn bigger now that it owns the space, with its chevrons lifted clear of the player's legs. Tennis and the duel still serve to the box, not at shamblers — checked both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1167 lines
76 KiB
JavaScript
1167 lines
76 KiB
JavaScript
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
|
||
|
||
import { cv, ctx } from './dom.js';
|
||
import { TUNE, UI, EATEN_GRAB, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, BOUNCE_IDLE, SPRITE_H } from './config.js';
|
||
import { P, O, ball, score, crowd, G, court } from './state.js';
|
||
import { predictLanding, previewServe } from './gameplay.js';
|
||
import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js';
|
||
|
||
// ---------------------------------------------------------------- palette ----
|
||
// tennis palette -> drained splatter palette; the transition lerps between them.
|
||
// Both halves now come from the SELECTED COURT (config.COURTS) — see the court table for why each
|
||
// court carries a pair rather than one palette and a global rot filter.
|
||
const hexLerp = (a, b, t) => {
|
||
const c = h => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
|
||
const [ar, ag, ab] = c(a), [br, bg, bb] = c(b);
|
||
return `rgb(${Math.round(ar + (br - ar) * t)},${Math.round(ag + (bg - ag) * t)},${Math.round(ab + (bb - ab) * t)})`;
|
||
};
|
||
// how far into the rot we are, 0 (tennis) .. 1 (splatter) — duels lerp it back home
|
||
function paletteT() {
|
||
return G.mode === 'tennis' ? 0 : G.mode === 'splatter' ? 1 - G.duelLerp : Math.min(1, G.transT / 6);
|
||
}
|
||
function palette() {
|
||
const t = paletteT();
|
||
if (t === 0) return court.tennis;
|
||
if (t === 1) return court.splatter;
|
||
const m = {};
|
||
for (const k in court.tennis) m[k] = hexLerp(court.tennis[k], court.splatter[k], t);
|
||
return m;
|
||
}
|
||
|
||
// Every string on the canvas goes through this. Bare `monospace` resolves to Courier — a slab
|
||
// SERIF — which is what made the HUD look generic; Silkscreen is the bundled pixel face.
|
||
const F = (sz, b = 1) => `${b ? 700 : 400} ${sz}px Silkscreen, monospace`;
|
||
const hexA = (h, a) => `rgba(${parseInt(h.slice(1, 3), 16)},${parseInt(h.slice(3, 5), 16)},${parseInt(h.slice(5, 7), 16)},${a})`;
|
||
|
||
// ---------------------------------------------------------------- camera ----
|
||
const IS_TOUCH = matchMedia('(pointer: coarse)').matches; // hoisted: a matchMedia call per frame churns GC
|
||
// Physical pixels per logical pixel. On an iPhone the canvas ends up ~390 CSS px wide = 1170 DEVICE
|
||
// px, so RES 3 (810) was still being upscaled 1.44x by the browser. 4 gets us to 1080, near 1:1.
|
||
// THIS IS THE FIRST KNOB TO TURN DOWN if the phone ever stutters: it is the whole fill-rate budget.
|
||
export const RES = 4;
|
||
export let W = 480, H = 270;
|
||
|
||
// Split responsibility, and the split is deliberate:
|
||
// TOP — index.html insets the canvas element below the status bar. SAFE.top stays 0; adding an
|
||
// offset here as well would subtract the notch twice.
|
||
// BOTTOM — the canvas deliberately runs to the PHYSICAL bottom edge so the court art fills the
|
||
// screen (the home indicator is a slim bar, not opaque UI). Only the interactive RACK is
|
||
// lifted clear of it, by SAFE.bottom below.
|
||
export const SAFE = { top: 0, bottom: 0, topCss: 0 };
|
||
// Logical px of slack under a full draw, on top of the rack: the home indicator (~34 CSS px) plus
|
||
// enough that a pull ENDING near the edge is still nowhere near STARTING there. See fitViewport.
|
||
const PULL_ROOM = 30;
|
||
function measureSafe(scale) {
|
||
const el = document.getElementById('safe');
|
||
if (!el) return;
|
||
const cs = getComputedStyle(el);
|
||
SAFE.bottom = Math.round((parseFloat(cs.paddingBottom) || 0) / scale);
|
||
SAFE.topCss = Math.round(parseFloat(cs.paddingTop) || 0); // CSS px — for the page background below
|
||
}
|
||
|
||
// The page background behind the canvas. It shows in exactly two places: the strip above the canvas
|
||
// under the status bar, and the slivers a phone's ROUNDED DISPLAY CORNERS clip out of the canvas
|
||
// itself. A flat dark body therefore drew a visible dark notch where the bottom corners curve in.
|
||
// So: dark down to the top inset, then the LIVE out-area colour for the rest — live because `pal.out`
|
||
// lerps between the tennis and splatter palettes, so any fixed hex would only match in one mode.
|
||
// Written in resolved px (not env()) so the declaration can never be invalid and drop the background.
|
||
let bgLast = '';
|
||
function pageBackground(out) {
|
||
const t = SAFE.topCss;
|
||
const css = `linear-gradient(#10141c 0px, #10141c ${t}px, ${out} ${t}px, ${out} 100%)`;
|
||
if (css === bgLast) return; // only touch the DOM when it actually changed
|
||
bgLast = css;
|
||
// documentElement, NOT body: the ROOT element's background is what paints the viewport canvas.
|
||
// index.html gives html a background, so body's never propagates — setting it there painted only
|
||
// body's own box, which has no height (the canvas is position: fixed) and so painted nothing.
|
||
document.documentElement.style.background = css;
|
||
}
|
||
|
||
// The CSS box is only a PROPOSAL. Added to the homescreen, iOS resolves 100dvh once at launch against
|
||
// a provisional viewport and never re-resolves it — no resize, no layout change, so nothing (not even
|
||
// a ResizeObserver) fires and the canvas stays a strip short of the bottom until you rotate the phone
|
||
// and back. visualViewport is the authority on what is actually visible, so measure that and pin the
|
||
// height ourselves. Re-read on every fit, so a late safe-area inset or a rotation just re-pins it.
|
||
function fitBox() {
|
||
const el = document.getElementById('safe');
|
||
const top = el ? parseFloat(getComputedStyle(el).paddingTop) || 0 : 0; // the inset the canvas starts below
|
||
const want = Math.round((window.visualViewport?.height || innerHeight) - top);
|
||
if (want > 120 && Math.abs(cv.clientHeight - want) > 1) cv.style.height = want + 'px'; // sane, and only when it differs
|
||
}
|
||
|
||
export function fitViewport() { // landscape 480x270; portrait 270 wide and as TALL as the phone is
|
||
fitBox();
|
||
const portrait = (cv.clientHeight || innerHeight) > (cv.clientWidth || innerWidth);
|
||
// Portrait height ADAPTS to the device. A fixed 270x480 is 9:16, but a modern iPhone is taller
|
||
// (~390x844 = 0.46), so width bound the fit and ~150 CSS px of screen went unused at the bottom.
|
||
// The court is positioned by proj() off TUNE.horizon in ABSOLUTE logical px, and the rack/meter hang
|
||
// off `H - ...`, so extra height simply widens the apron between the court and the rack — nothing
|
||
// has to be re-laid-out. Clamped so a freak aspect can't stretch the apron absurdly.
|
||
// Derive the logical size from the box CSS actually gave the canvas, not from innerWidth/innerHeight.
|
||
// The canvas is stretched to 100vw x 100dvh, so matching its logical aspect to that box exactly is
|
||
// what guarantees no letterbox and no distortion, however the phone's toolbars behave.
|
||
const bw = cv.clientWidth || innerWidth, bh = cv.clientHeight || innerHeight;
|
||
const base = portrait ? 270 : 480;
|
||
const other = Math.round(base * (portrait ? bh / Math.max(1, bw) : bh / Math.max(1, bw)));
|
||
const [w, h] = portrait ? [base, Math.max(430, Math.min(680, other))] : [base, Math.max(230, Math.min(340, other))];
|
||
if (cv.width !== w * RES || cv.height !== h * RES) {
|
||
W = w; H = h;
|
||
cv.width = w * RES; cv.height = h * RES;
|
||
// flatter lens: camZ pushed back (config 42, was 36) with camF scaled to keep the PLAYER
|
||
// the same size — far-court zombies render ~25% bigger and actually read as zombies
|
||
// READABILITY PASS. Oliver's wife: "everything is a tad too small" — a far shambler on an iPhone
|
||
// was 26 logical px tall with almost no legible detail. Pushing the camera BACK (camZ) while
|
||
// lengthening the lens (camF) flattens the perspective, so distant bodies grow relative to near
|
||
// ones instead of everything just zooming. camY is raised with it for a steeper look-down (bodies
|
||
// overlap each other less at depth) and `horizon` re-centres the court after both.
|
||
// Net: far shambler 26 -> 30 logical px, the player unchanged, the court occupying the same band.
|
||
// If you retune these, check all four: far size, near size, court top, court bottom.
|
||
TUNE.camZ = portrait ? 48 : 42;
|
||
TUNE.camY = portrait ? 11 : 9;
|
||
TUNE.camF = portrait ? 760 : 406;
|
||
// THE COURT RIDES HIGH ENOUGH TO PULL UNDER HIM. A full draw is TUNE.pullFull CSS px of thumb
|
||
// travel STARTING at his feet, and it has to finish clear of the rack and of the iOS bottom
|
||
// edge — where a swipe down is Reachability and the system takes the gesture out from under
|
||
// you, which no web page can refuse. Positioning the court from the TOP could not promise that:
|
||
// on a 375x812 it left 112 CSS px under him for a 130 px pull, so the wind-up physically did
|
||
// not fit and the thumb was driven onto the edge every single rally.
|
||
// So park his feet a full draw above the rack and let the horizon fall wherever it must. What
|
||
// gets shorter is the stand above the court, and that was only ever crowd.
|
||
// (`horizon` is the live knob — it survives in the T panel until the viewport actually changes.)
|
||
const perCss = h / Math.max(1, bh); // logical px per CSS px (bh is 0 in a hidden tab)
|
||
const apron = TUNE.pullFull * perCss + TUNE.rackH + PULL_ROOM;
|
||
// his scale at his DEEPEST, so the room holds wherever he is standing when you draw
|
||
const feetY = TUNE.camY * TUNE.camF / (TUNE.camZ - P_BOUNDS.zMax);
|
||
TUNE.horizon = portrait ? Math.round(h - apron - feetY) : 40;
|
||
buildCrowd();
|
||
}
|
||
ctx.imageSmoothingEnabled = false; // resets on canvas resize
|
||
// CSS owns the on-screen size and the TOP inset; we match the logical grid and measure the bottom
|
||
// inset in logical px so the rack can lift clear of the home indicator.
|
||
measureSafe((cv.clientWidth || innerWidth) / w);
|
||
}
|
||
// Every signal that the visible area may have changed, plus a short settle salvo — because the case
|
||
// that broke it (a homescreen launch) fires NO signal at all: the viewport iOS hands you at launch is
|
||
// simply wrong and is never corrected. fitViewport is idempotent (fitBox no-ops within 1px, and the
|
||
// logical size early-outs unless it really changed), so re-running it costs nothing.
|
||
addEventListener('resize', fitViewport);
|
||
addEventListener('orientationchange', fitViewport);
|
||
addEventListener('pageshow', fitViewport); // the app was resumed from the bfcache
|
||
window.visualViewport?.addEventListener('resize', fitViewport); // the one iOS does fire when the bars move
|
||
new ResizeObserver(fitViewport).observe(cv); // the box moved, whatever moved it
|
||
for (const ms of [0, 120, 400, 1200]) setTimeout(fitViewport, ms); // ...and the launch that says nothing
|
||
|
||
// The aim camera: while the slingshot is drawn the view rises and leans back a little, which
|
||
// steepens the look down the court. That extra depth is what makes a shambler standing right
|
||
// behind the net a shot you can actually judge. Eased once per frame in render(), never mid-frame
|
||
// — every proj() call within one frame must share the same camera or the scene shears.
|
||
let aimCam = 0;
|
||
export function proj(x, y, z) {
|
||
const d = TUNE.camZ + aimCam * TUNE.aimCamZ - z, s = TUNE.camF / d;
|
||
return { x: W / 2 + x * s, y: TUNE.horizon + (TUNE.camY + aimCam * TUNE.aimCamY - y) * s, s };
|
||
}
|
||
|
||
// The run-off apron below the near baseline. Raising the court to make room for the pull (see
|
||
// fitViewport) left a wide band down there, and `pal.out` is a single flat colour — so the
|
||
// perspective visibly stopped partway down the screen and the rest read as dead margin. This falls
|
||
// the light off toward you, which gives the band depth, frames the rack, and darkens exactly the
|
||
// area the thumb covers anyway. Starts at alpha 0 on the baseline, so there is no seam to see.
|
||
// NOT a near backstop: at camZ 48 with the baseline at z 23.77 a real one would sit between the
|
||
// camera and the player and draw enormous. You are looking OVER it, which is why a broadcast frame
|
||
// never shows one either. Wear and props at the margins are the next layer, not this.
|
||
function drawRunOff() {
|
||
const top = proj(0, 0, COURT.L).y;
|
||
if (top >= H) return; // landscape has no apron at all — the baseline projects off the bottom
|
||
const g = ctx.createLinearGradient(0, top, 0, H);
|
||
g.addColorStop(0, 'rgba(6,10,6,0)');
|
||
g.addColorStop(1, 'rgba(6,10,6,0.72)');
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(0, top, W, H - top);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- sprites ----
|
||
// assets/<name>.png; game falls back to code-drawn figures until sprites load.
|
||
const SPRV = 78; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
|
||
const SPR = {};
|
||
for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_run3','p_run4','p_swing1','p_swing2','p_die1','p_die2','p_die3','p_die4','p_die5',
|
||
'p_pull', // the serve wind-up: the racket dropping behind him while the pull is drawn
|
||
'o_idle1','o_idle2','o_bounce','o_bounce2','o_toss','o_smash','o_run1','o_run2','o_swing1','o_swing2',
|
||
'x_ump','x_boyrun1','x_boyrun2','x_boyrun3','x_boyrun4',
|
||
'x_crowd','x_crowd2','x_ads', // CENTRE COURT (grass) — also the fallback set
|
||
'x_crowd_c','x_crowd_c2','x_ads_c', // TERRE ROUGE (clay)
|
||
'x_crowd_h','x_crowd_h2','x_ads_h', // THE SLAB (hard)
|
||
'x_boymop1','x_boymop2','x_boymop3','x_boymop4', // the push cycle, used only while he is stopped and working
|
||
'x_boycarry1','x_boycarry2','x_boycarry3','x_boycarry4','x_howto','x_cross', // walking with the mop shouldered, and the slingshot tutorial panel
|
||
'z_gw2','z_gw3','z_gw4','z_gwb','z_gw2b','z_gw3b','z_gw4b', // the brute comes apart in stages (4/5 hp = z_gw .. 1/5 = z_gw4), each with a 2-frame lurch (…b)
|
||
'z_boss1','z_boss2','z_bossw1','z_bossw2','z_bossw3', // the CHAPTER BOSS: 2-frame stride + three wound stages (arm off -> ribs out -> headless)
|
||
'z_bosst0','z_bosst1','z_bosst2','z_bosst3', // ...and each of those four states mid-THROW (windT)
|
||
'z_mach1','z_mach2','z_machw1','z_machw2', // the ball machine: idle, firing, dented, split open
|
||
'z_judge1','z_judge2','z_judgew','z_judget','z_judgewt', // boss design 2 (chapter 2, 5, ...)
|
||
'z_chair1','z_chair2','z_chairw','z_chairt','z_chairwt', // boss design 3 (chapter 3, 6, ...)
|
||
'z_a1','z_a2','z_a3','z_a4','z_b1','z_b2','z_b3','z_b4','z_c1','z_c2',
|
||
'z_d1','z_d2','z_d3','z_d4', // the steward: bigger body, hi-vis vest, 4-frame walk
|
||
'z_awb','z_bwb','z_cw1b','z_cw2b','z_dw','z_dwb', // ...and every wound skin's second frame: wounded bodies walk too
|
||
'z_f1','z_f2','z_f3','z_f4','z_g1','z_g2','z_g3','z_g4','z_gw',
|
||
'z_s1','z_s2','z_s3','z_s4','z_sdown', // the chair spectator: 4-frame shuffle + the staggered pose
|
||
'z_c3','z_c4', // ...and the turned CPU walks on four frames too
|
||
'z_cw1','z_cw2','z_aw','z_bw', // wounded skins for multi-hit enemies (racket boss, brute, and now the classic shamblers)
|
||
'w_ball','w_bomb','w_triple','w_fire','w_gun', // weapon-rack icons
|
||
'scoreboard']) {
|
||
const img = new Image();
|
||
img.onload = () => SPR[n] = img;
|
||
// ?v=SPRV, for the same reason index.html versions main.js. python http.server sends no cache
|
||
// headers, so the browser caches sprite responses heuristically — INCLUDING 404s. A brand-new sheet
|
||
// whose name was added to this list before the PNG existed stays permanently "missing" for that
|
||
// browser, and drawZombie silently falls back to another design. That cost a debugging round.
|
||
// BUMP THIS whenever assets/ changes, alongside main.js?v=N in index.html.
|
||
img.src = 'assets/' + n + '.png?v=' + SPRV;
|
||
}
|
||
|
||
// ------------------------------------------------------------------ crowd ----
|
||
export function buildCrowd() {
|
||
crowd.dots.length = 0;
|
||
const rows = Math.floor((proj(0, 0, 0).y - 26) / 5.5), cols = Math.ceil(W / 8);
|
||
for (let row = 0; row < rows; row++) for (let i = 0; i < cols; i++) crowd.dots.push({
|
||
x: 4 + i * 8 + (row % 2) * 4 + Math.random() * 3, y: 8 + row * 5.5 + Math.random() * 2,
|
||
c: ['#c9b18f','#8f6f4f','#d9c6a5','#5f7f9f','#9f5f5f','#7f9f5f','#e0d0b0','#b0a0c0'][(i * 7 + row) % 8]
|
||
});
|
||
}
|
||
|
||
// -------------------------------------------------------------- primitives ----
|
||
function line(x1, z1, x2, z2, w = 1) {
|
||
const a = proj(x1, 0, z1), b = proj(x2, 0, z2);
|
||
ctx.strokeStyle = UI.cream; ctx.lineWidth = w;
|
||
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
|
||
}
|
||
function quadXZ(x1, z1, x2, z2, color) { // filled court-plane rect (screen trapezoid)
|
||
const a = proj(x1, 0, z1), b = proj(x2, 0, z1), c = proj(x2, 0, z2), d = proj(x1, 0, z2);
|
||
ctx.fillStyle = color; ctx.beginPath();
|
||
ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(c.x, c.y); ctx.lineTo(d.x, d.y); ctx.fill();
|
||
}
|
||
// Rounded panel. ctx.roundRect is native everywhere this game runs (Safari 16.4+), and the square
|
||
// fallback only matters on a browser that could not render the rest of this anyway.
|
||
function panel(x, y, w, h, r, fill, stroke) {
|
||
ctx.beginPath();
|
||
if (ctx.roundRect) ctx.roundRect(x, y, w, h, r); else ctx.rect(x, y, w, h);
|
||
if (fill) { ctx.fillStyle = fill; ctx.fill(); }
|
||
if (stroke) { ctx.strokeStyle = stroke; ctx.stroke(); }
|
||
}
|
||
function drawShadow(px, py, rx, ry, alpha = 0.28) {
|
||
ctx.fillStyle = `rgba(0,0,0,${alpha})`;
|
||
ctx.beginPath(); ctx.ellipse(px, py, rx, Math.max(1, ry), 0, 0, 7); ctx.fill();
|
||
}
|
||
function drawBall(x, y, z, glint = false, bomb = false, flame = false) {
|
||
const sh = proj(x, 0, z);
|
||
drawShadow(sh.x, sh.y, Math.max(1.5, sh.s * 0.09), sh.s * 0.04, 0.3);
|
||
const bp = proj(x, y, z);
|
||
const r = Math.max(1.5, bp.s * 0.09);
|
||
if (bomb) { // black ball with a flickering orange fuse pixel on top
|
||
ctx.fillStyle = '#111'; ctx.beginPath(); ctx.arc(bp.x, bp.y, r, 0, 7); ctx.fill();
|
||
if (Math.floor(G.idleT * 20) % 2) { ctx.fillStyle = '#ff8010'; ctx.fillRect(bp.x - 0.5, bp.y - r - 1, Math.max(1, r * 0.5), Math.max(1, r * 0.6)); }
|
||
return;
|
||
}
|
||
if (flame) { // white-hot core in an orange shell, flickering
|
||
ctx.fillStyle = '#ff8010'; ctx.beginPath(); ctx.arc(bp.x, bp.y, r + (Math.floor(G.idleT * 24) % 2 ? 1 : 0), 0, 7); ctx.fill();
|
||
ctx.fillStyle = '#ffe8a0'; ctx.beginPath(); ctx.arc(bp.x, bp.y, Math.max(1, r * 0.5), 0, 7); ctx.fill();
|
||
return;
|
||
}
|
||
ctx.fillStyle = UI.ball; ctx.beginPath(); ctx.arc(bp.x, bp.y, r, 0, 7); ctx.fill();
|
||
if (glint) { ctx.fillStyle = '#f8f8d0'; ctx.fillRect(bp.x - r * 0.3, bp.y - r * 0.5, Math.max(1, r * 0.5), Math.max(1, r * 0.5)); }
|
||
}
|
||
|
||
// ----------------------------------------------------------------- actors ----
|
||
function drawStatic(name, x, z, hMeters, bob = 0, flip = false) {
|
||
const img = SPR[name]; if (!img) return;
|
||
const p = proj(x, 0, z);
|
||
const h = hMeters * p.s, w = h * img.width / img.height;
|
||
drawShadow(p.x, p.y, w * 0.4, h * 0.04, 0.25);
|
||
if (!flip) return ctx.drawImage(img, p.x - w / 2, p.y - h - bob, w, h);
|
||
ctx.save(); ctx.translate(p.x, 0); ctx.scale(-1, 1); // the art is drawn facing LEFT
|
||
ctx.drawImage(img, -w / 2, p.y - h - bob, w, h);
|
||
ctx.restore();
|
||
}
|
||
function drawBoy(b) {
|
||
// mopping is not sprinting: its own sheet and a slower push cycle. Falls back to the run
|
||
// frames until the mop PNGs have loaded.
|
||
// Three sheets, because a boy WALKING to the next stain is not a boy mopping: x_boymop is the
|
||
// push cycle (used only while he's stopped and working), x_boycarry is him walking upright with
|
||
// the mop shouldered, x_boyrun is the plain tennis-mode ball fetch.
|
||
const mop = b.mop && SPR.x_boymop1;
|
||
const sheet = !mop ? 'x_boyrun' : b.mopT > 0 ? 'x_boymop' : SPR.x_boycarry1 ? 'x_boycarry' : 'x_boymop';
|
||
// Frames step off DISTANCE COVERED, not elapsed time: a time-driven cycle made him moonwalk
|
||
// whenever his speed and the frame rate disagreed. Now the stride owns the ground he covers.
|
||
const stride = TUNE.boyStride * (mop ? 1 : 2.1); // a sprint eats more ground per frame than a push
|
||
const img = SPR[sheet + (Math.floor((b.dist || 0) / stride) % 4 + 1)]; if (!img) return;
|
||
const p = proj(b.x, 0, b.z);
|
||
// scale off the frame's OWN pixel height, not a fixed BOY_H: the sheets are common-scaled per
|
||
// sheet, so a deep mopping lean is genuinely shorter and must not be stretched back upright
|
||
const h = BOY_H * p.s * (img.height / SPRITE_H), w = h * img.width / img.height;
|
||
drawShadow(p.x, p.y, w * 0.3, h * 0.04, 0.25);
|
||
ctx.save(); ctx.translate(p.x, p.y - (mop ? 0 : Math.floor(b.runT * 20) % 2));
|
||
if (b.dir < 0) ctx.scale(-1, 1);
|
||
ctx.drawImage(img, -w / 2, -h, w, h);
|
||
ctx.restore();
|
||
}
|
||
// Frames stepped by DISTANCE WALKED need a POSITIVE modulo. Bodies spawn BEHIND the far baseline at
|
||
// negative z, and JS keeps the sign of the dividend: floor(-1.5 / 0.25) % 4 is -2, so the pick asked
|
||
// for 'z_g-1' — a sprite that does not exist, and drawStatic silently draws nothing. That is the
|
||
// blinking Oliver saw on the way in, on exactly the bodies that step off distance: brutes and bosses.
|
||
const stepFrame = (v, n) => ((Math.floor(v) % n) + n) % n; // 0..n-1, whatever the sign of v
|
||
const ZFRAMES = { a: 4, b: 4, f: 4, d: 4, g: 4, s: 4, c: 4 }; // 4-frame walk cycles where sheets exist; the rest stay 2
|
||
// 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.
|
||
function drawMachine(zb) {
|
||
const firing = zb.windT > 0;
|
||
// It wears its damage like every other multi-hit body on court: dented at two thirds, split open
|
||
// and sparking at one. The healthy sheet keeps the muzzle-flash frame; a damaged machine flashes
|
||
// procedurally instead of needing a firing frame per stage.
|
||
const f = zb.hp / (zb.hpMax || TUNE.machHp);
|
||
const dmg = f <= 0.34 ? 'z_machw2' : f <= 0.67 ? 'z_machw1' : null;
|
||
const name = dmg && SPR[dmg] ? dmg : firing && SPR.z_mach2 ? 'z_mach2' : 'z_mach1';
|
||
const img = SPR[name];
|
||
if (!img) return; // sheet not loaded yet: draw nothing, not a box
|
||
const h = MACH_H * (img.height / SPRITE_H);
|
||
drawStatic(name, zb.x, zb.z, h);
|
||
if (firing) { // the TELEGRAPH still needs to read at 20px tall, so the muzzle glow stays procedural
|
||
const k = 1 - zb.windT / TUNE.bossWind, p = proj(zb.x, h * 0.62, zb.z); // bossWind, not machWind: the boss telegraphs faster
|
||
ctx.globalAlpha = 0.3 + k * 0.7;
|
||
ctx.fillStyle = k > 0.6 ? '#fff2c0' : UI.warm;
|
||
const r = Math.max(1.5, p.s * 0.05 * (0.5 + k));
|
||
ctx.beginPath(); ctx.arc(Math.round(p.x + p.s * 0.1), Math.round(p.y), r, 0, 7); ctx.fill();
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
}
|
||
|
||
const p0 = zb => proj(zb.x, 0, zb.z).s; // projected scale, for effects that need pixels not metres
|
||
function drawZombie(zb) {
|
||
if (zb.variant === 'm') return drawMachine(zb);
|
||
const rk = zb.variant === 'r';
|
||
const v = rk ? 'c' : zb.variant; // the racket zombie reuses the turned-CPU sprite (already holds a racket)
|
||
let N = ZFRAMES[v] || 2;
|
||
if (!SPR['z_' + v + N]) N = 2; // frames still loading (or sheet rejected): 2-frame fallback
|
||
// same CYCLE frequency as before — more frames subdivide the stride, they don't speed it up
|
||
// The racket zombie holds a position and only moves when the ball makes him, so a time-driven
|
||
// cycle had him flicking between two frames on the spot. His stride steps off ground covered like
|
||
// the brute's — standing still is standing still — and his swing keeps its own frame.
|
||
let frame = rk && zb.pose === 'swing' ? 2
|
||
: rk ? stepFrame((zb.dist || 0) / TUNE.racketStride, N) + 1
|
||
: zb.variant === 'g' ? stepFrame(zb.z / (TUNE.bruteStride * 2 / N), N) + 1 // heaviest body: step off DISTANCE
|
||
// WALKED, like its 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
|
||
let h = zb.variant === 'g' ? 2.7 : zb.variant === 'd' ? 2.15 : rk ? 1.85 : 1.8; // the brute towers half again over the crowd; the steward stands between him and it
|
||
if (zb.variant === 'B') h = 1.8 * (zb.scale || 1.5); // ...and a chapter boss towers over the brute
|
||
// 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;
|
||
if (zb.variant === 'g' && zb.hp < TUNE.bruteHp) { // one arm → both → head gone → ribs out
|
||
const stage = ['z_gw4', 'z_gw3', 'z_gw2', 'z_gw'][Math.max(0, Math.min(3, zb.hp - 1))];
|
||
if (SPR[stage]) {
|
||
// Every wound stage has its OWN two-frame lurch (…w / …wb = the stride swapped). Stepped by
|
||
// DISTANCE WALKED, not time — the brute is the slowest thing on court, so a time-driven cycle
|
||
// was pure foot-slide. This replaced a procedural squash-and-sway that read as exactly what
|
||
// it was: fake. The comedy is in the gait, and the gait has to be drawn.
|
||
const b = stage + 'b';
|
||
name = SPR[b] && stepFrame(zb.z / TUNE.bruteStride, 2) ? b : stage;
|
||
} else if (SPR.z_gw) name = 'z_gw'; // a missing asset degrades to the last skin that loaded
|
||
}
|
||
else if ('abd'.includes(zb.variant) && zb.hp < (zb.hpMax || 0) && SPR['z_' + zb.variant + 'w']) {
|
||
// A wounded body used to freeze on ONE frame and slide across the court — the wound skins were
|
||
// single stills. Every skin has its …b twin now (the stride swapped), stepped off the same clock
|
||
// as the healthy walk, so losing an arm changes how he walks and not whether he walks.
|
||
const w = 'z_' + zb.variant + 'w';
|
||
// Same CADENCE as the healthy walk (which runs four frames at 5.2/s), or a wounded body reads
|
||
// as frozen next to the crowd it is walking in — it was stepping at half the rate on two frames.
|
||
name = SPR[w + 'b'] && stepFrame(zb.walkT * 5.2, 2) ? w + 'b' : w; // gashed shambler, still lurching
|
||
}
|
||
// The boss's damage is read as a FRACTION of full health — his hp scales with the run
|
||
// (TUNE.racketHpPerGame), so the old `hp === 2` / `hp <= 1` thresholds would have left a late-run
|
||
// boss looking untouched until the killing blow. Keep these in step with bleed()'s anchors.
|
||
else if (rk) {
|
||
const f = zb.hp / (zb.hpMax || TUNE.racketHp);
|
||
if (f <= 0.34 && SPR.z_cw2) { name = 'z_cw2'; h = 1.52; } // headless: a head shorter, so the shoulders land where a whole zombie's do
|
||
else if (f <= 0.67 && SPR.z_cw1) name = 'z_cw1';
|
||
const b = name + 'b'; // ...and he limps through his wounds too, instead of gliding
|
||
if (name !== 'z_' + v + frame && SPR[b] && stepFrame(zb.walkT * 5.2, 2)) name = b;
|
||
}
|
||
// The chapter boss comes apart through the SAME fraction-of-hpMax rule as the racket zombie, over
|
||
// three stages, so a 34 hp chapter-4 boss visibly degrades all the way down: arm off, then ribs
|
||
// out, then headless and still throwing. Its 2-frame stride steps off DISTANCE COVERED (the same
|
||
// rule the brute uses) — it is the slowest thing on court and a time-driven cycle would foot-slide.
|
||
// The chapter boss picks its frames from its DESIGN (`zb.art`, one row of config.BOSS_ART per
|
||
// chapter, cycling), so a new boss is a sheet plus a data row and never a change here. Damage is a
|
||
// FRACTION of hpMax spread over however many wound stages that design has, and the wounded state
|
||
// stays visible MID-THROW — the one place Oliver found damage disappearing.
|
||
// NEVER name a sprite so it differs from another only by case: macOS is case-insensitive, so
|
||
// writing z_B1.png silently OVERWROTE the 'b' shambler's z_b1.png and a boss turned up in the crowd.
|
||
if (zb.variant === 'B') {
|
||
const A = zb.art || BOSS_ART[0];
|
||
const f = zb.hp / (zb.hpMax || 1), nStage = A.wounds.length;
|
||
const idx = f >= 1 ? 0 : Math.min(nStage, Math.max(1, Math.ceil((1 - f) * nStage)));
|
||
const wanted = zb.windT > 0 ? A.throws[Math.min(idx, A.throws.length - 1)]
|
||
: idx ? A.wounds[idx - 1]
|
||
: A.walk[stepFrame(zb.z / TUNE.bruteStride, 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(zb.z / TUNE.bruteStride, 2)];
|
||
if (SPR[stage]) name = stage;
|
||
}
|
||
// Every frame is drawn at ITS OWN height. drawStatic forces the height it is given, so a crouched
|
||
// frame sliced 13 px shorter than its neighbours was being stretched back to full size — the body
|
||
// grew and shrank once per stride. Oliver: "the brute is still pulsating while walking". The rule
|
||
// already existed for the wound stages; it belongs to every frame, and it is the same rule
|
||
// _slice.py's common scaling is built on (see HANDOFF: heights follow the PNG).
|
||
if (SPR[name]) h *= SPR[name].height / SPRITE_H;
|
||
// feeding: a fast bob over the body instead of a stride. Same sprites, different rhythm.
|
||
const bob = zb.feeding ? Math.abs(Math.sin(zb.walkT * 11)) * Math.max(1, p0(zb) * 0.05)
|
||
: Math.floor(zb.walkT * 5.2) % 2;
|
||
// Every side-on body is DRAWN facing left, so one walking right was moonwalking — obvious the
|
||
// moment they close on you across your own half. The front-facing ones (the turned CPU, the
|
||
// runner, the machine, a chapter boss) have no left or right to turn and are left alone.
|
||
drawStatic(name, zb.x, zb.z, h, bob, zb.face > 0 && !'cfmB'.includes(v));
|
||
if (zb.burnT > 0) drawFlames(zb, h);
|
||
if (zb.windT > 0 && zb.variant === 'B') { // the boss telegraphs too: it rears up, glowing, then throws
|
||
const k = 1 - zb.windT / TUNE.bossWind, p = proj(zb.x, h * 0.62, zb.z); // bossWind, not machWind: the boss telegraphs faster
|
||
ctx.globalAlpha = 0.25 + k * 0.55; ctx.fillStyle = k > 0.6 ? '#fff2c0' : UI.bad;
|
||
const r = Math.max(2, p.s * 0.1 * (0.5 + k));
|
||
ctx.beginPath(); ctx.arc(Math.round(p.x), Math.round(p.y), r, 0, 7); ctx.fill();
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
// No HP bar. It was the only floating UI on a body anywhere in the game, it was on screen for
|
||
// almost the whole run (a boss respawns every game you win), and nobody knew what it meant —
|
||
// while the wound SKINS above already encode exactly the same information in the language every
|
||
// other multi-hit enemy uses. The first-sight modal in spawnRacketZombie explains him instead.
|
||
}
|
||
// Fire, drawn procedurally instead of as a sprite sheet: a few vertically-stretched quads whose
|
||
// offsets ride each body's own phase (walkT), so no two burning zombies flicker in sync — and it
|
||
// reads at every burn stage, from fresh yellow flare to guttering smoke, for free.
|
||
function drawFlames(zb, hMeters) {
|
||
const p = proj(zb.x, 0, zb.z), s = p.s;
|
||
const k = Math.max(0, Math.min(1, zb.burnT / TUNE.burnTime)); // 1 = just lit, 0 = about to gutter out
|
||
const col = k > 0.6 ? '#ffc030' : k > 0.3 ? '#ff8010' : '#6b5a52';
|
||
for (let i = 0; i < 5; i++) {
|
||
const ph = G.idleT * (7 + i * 1.7) + zb.walkT * 3.1 + i * 2;
|
||
const fx = p.x + Math.sin(ph) * s * 0.11 + (i - 2) * s * 0.05;
|
||
const fy = p.y - (0.2 + i * 0.15 + 0.25 * k) * hMeters * s - Math.abs(Math.sin(ph * 1.6)) * s * 0.08;
|
||
ctx.fillStyle = i === 0 && k > 0.5 ? '#ffe8a0' : col; // one white-hot core tongue while it rages
|
||
ctx.fillRect(fx | 0, fy | 0, Math.max(1, s * 0.045), Math.max(2, s * (0.10 + 0.07 * Math.abs(Math.sin(ph * 2)))));
|
||
}
|
||
}
|
||
function drawRing(x, z, color) {
|
||
const p = proj(x, 0, z);
|
||
// integer-snapped so the moving ring steps whole pixels instead of shimmering
|
||
const cx = Math.round(p.x), cy = Math.round(p.y);
|
||
const rx = Math.max(2, Math.round(p.s * 0.3)), ry = Math.max(1, Math.round(p.s * 0.12));
|
||
ctx.strokeStyle = color; ctx.lineWidth = 2;
|
||
ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, 7); ctx.stroke();
|
||
}
|
||
|
||
// gore piles: little 3D-ish mounds that accrete where kills cluster. Each pile's blobs are
|
||
// seeded ONCE from p.seed (rebuilt only when it grows), so the pile never shimmers frame to
|
||
// frame and drawing costs one proj() call — blob screen positions offset from that base.
|
||
function mulberry32(a) {
|
||
return () => { a |= 0; a = a + 0x6D2B79F5 | 0;
|
||
let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
||
return ((t ^ t >>> 14) >>> 0) / 4294967296; };
|
||
}
|
||
const PILE_COLS = ['#6e1414', '#8c1212', '#4e0e0e', '#7a2a18'];
|
||
function buildBlobs(pl) {
|
||
const rnd = mulberry32((pl.seed | 0) + 1), n = Math.round(6 + pl.size * 5);
|
||
pl.blobs = [];
|
||
for (let k = 0; k < n; k++) {
|
||
const ang = rnd() * 6.2832, rad = Math.sqrt(rnd()); // uniform over the disk
|
||
pl.blobs.push({ nx: Math.cos(ang) * rad, nz: Math.sin(ang) * rad,
|
||
lift: rnd(), pr: 1 + rnd() * 2, color: rnd() < 0.1 ? '#cfc9b0' : PILE_COLS[(rnd() * 4) | 0] });
|
||
}
|
||
pl.blobs.sort((a, b) => (b.nz - 1.25 * b.lift) - (a.nz - 1.25 * a.lift)); // back/low first, peak on top
|
||
pl.builtSize = pl.size;
|
||
}
|
||
function drawGorePile(pl) {
|
||
if (!pl.blobs || pl.size > pl.builtSize) { buildBlobs(pl); pl.cv = null; } // rebuild only on growth, never on the mop-shrink
|
||
const base = proj(pl.x, 0, pl.z), s = base.s, sz = pl.size;
|
||
const key = Math.round(s * 4); // proj scale is constant per pile until growth or an orientation change
|
||
if (!pl.cv || pl.cvKey !== key) { // pre-render: 1 drawImage per frame instead of ~26 ellipses (battery)
|
||
const pad = Math.ceil(sz * 0.28 * s + 4);
|
||
const off = document.createElement('canvas');
|
||
off.width = off.height = pad * 2;
|
||
const oc = off.getContext('2d');
|
||
for (const b of pl.blobs) {
|
||
const sx = pad + b.nx * sz * 0.28 * s;
|
||
const sy = pad + b.nz * sz * 0.28 * s * 0.4 - b.lift * sz * 0.14 * s; // depth squished onto ground; lift raises it
|
||
const r = Math.max(1.2, b.pr * s * 0.035);
|
||
oc.fillStyle = b.color;
|
||
oc.beginPath(); oc.ellipse(sx, sy, r, Math.max(1, r * 0.72), 0, 0, 7); oc.fill();
|
||
}
|
||
pl.cv = off; pl.cvKey = key; pl.cvPad = pad;
|
||
}
|
||
ctx.drawImage(pl.cv, base.x - pl.cvPad, base.y - pl.cvPad);
|
||
}
|
||
function drawPiles(near) { // split at the net like zombies so height occludes correctly
|
||
for (const pl of piles.filter(p => near ? p.z > COURT.netZ : p.z <= COURT.netZ).sort((a, b) => a.z - b.z))
|
||
drawGorePile(pl);
|
||
}
|
||
|
||
function drawChar(ch) {
|
||
// No split-step. The receiver used to be slid 0.12 m side to side while waiting on a serve, on top
|
||
// of a swap to the run frames; both are gone. Sliding a sprite sideways is not footwork — it reads
|
||
// as the man skating — and the idle pair's own weight shift is the whole of what he should be doing.
|
||
const p = proj(ch.x, 0, ch.z);
|
||
const hpx = PLAYER_H * p.s;
|
||
// pick sprite
|
||
const pre = ch.side === 'P' ? 'p_' : 'o_';
|
||
let name = pre + (Math.floor(G.idleT * 1.6) % 2 ? 'idle1' : 'idle2'); // idle weight shift
|
||
if (G.phase === 'pretoss' && ch.side === score.server && ch.pose === 'idle') {
|
||
// The two bounce frames are the SAME drawing from one sheet — identical stance, identical legs,
|
||
// and only the free hand's height and reach differ — so the swap cannot read as a flicker. It
|
||
// follows the BALL: hand up when the ball is up at it, hand out and low when the ball is near
|
||
// the ground. BOUNCE_IDLE.apex is measured off that palm, so the ball meets it.
|
||
name = pre + (Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) > 0.5 ? 'bounce2' : 'bounce');
|
||
}
|
||
// THE PULL IS THE WIND-UP — three poses, not a scrubbable animation. Waiting: the bounce stance.
|
||
// Drawing the slingshot: `p_pull`, the racket dropping behind him. Release: `p_smash`, the drive.
|
||
// A four-stage version keyed to pullFrac was built and played, and Oliver cut it back to this:
|
||
// on a phone, at thumb speed, the middle stages are never read — the three states are the beat.
|
||
if (G.aim && G.aim.mag > 0 && ch.side === 'P' && score.server === 'P' && ch.pose === 'idle')
|
||
name = 'p_pull';
|
||
// Four-frame run where the frames exist (the player), two where they don't (the opponent). Same
|
||
// CYCLE rate either way — more frames subdivide the stride, they do not speed it up, exactly as
|
||
// ZFRAMES does for the shamblers.
|
||
const runN = SPR[pre + 'run4'] ? 4 : 2;
|
||
if (ch.pose === 'run') name = pre + 'run' + (Math.floor(ch.runT * 4.5 * runN) % runN + 1);
|
||
// (the receiver used to flip to run1/run2 here as a "split-step" — it read as a man sprinting on
|
||
// the spot, because the run frames are a full leaning stride, not a ready-hop. He idles now.)
|
||
if (ch.pose === 'swing') name = pre + (ch.poseT < 0.15 ? 'swing1' : 'swing2');
|
||
if (ch.pose === 'serve') name = pre + (G.phase === 'toss' ? 'toss' : 'smash');
|
||
if (ch.pose === 'run') p.y -= Math.floor(ch.runT * 18) % 2; // 1px run bob
|
||
// Auto-fire: the idle sprite carries a racket at his hip AND drawMgRacket puts one in his hands —
|
||
// two rackets. The run frames are the racket-less art, so he jogs on the spot instead.
|
||
if (ch.side === 'P' && ch.pose === 'idle' && G.mode === 'splatter' && (G.horde || G.mgT > 0))
|
||
name = pre + 'run' + (Math.floor(G.idleT * 3 * runN) % runN + 1);
|
||
const img = SPR[name];
|
||
drawShadow(p.x, p.y, hpx * 0.22, hpx * 0.06);
|
||
if (img) {
|
||
const k = hpx / SPRITE_H; // sprites share one scale; the idle pose defines it
|
||
const w = img.width * k, h = img.height * k;
|
||
ctx.save();
|
||
ctx.translate(p.x, p.y);
|
||
if (ch.facing < 0) ctx.scale(-1, 1);
|
||
ctx.drawImage(img, -w / 2, -h, w, h);
|
||
ctx.restore();
|
||
} else drawFallbackChar(ch, p, hpx);
|
||
// 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['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);
|
||
}
|
||
function drawMgRacket() { // horde: the player braces the racket forward and up-court, machine-gun style
|
||
const hip = proj(P.x + 0.15, 1.05, P.z);
|
||
const tip = proj(P.x, 0.95, P.z - 1.35); // pointing up-court, where the pellets stream out
|
||
ctx.strokeStyle = '#d8d8d8'; ctx.lineWidth = 2;
|
||
ctx.beginPath(); ctx.moveTo(hip.x, hip.y); ctx.lineTo(tip.x, tip.y); ctx.stroke();
|
||
ctx.strokeStyle = '#b07030'; ctx.lineWidth = 1.5; // the head, aimed forward
|
||
ctx.beginPath(); ctx.ellipse(tip.x, tip.y, Math.max(2, tip.s * 0.14), Math.max(3, tip.s * 0.22), 0, 0, 7); ctx.stroke();
|
||
const cool = G.horde ? G.horde.mgT : G.mgCool; // horde or GUN-tin pickup — same racket
|
||
const rate = G.horde ? G.horde.mgRate || TUNE.mgRate : TUNE.mgRate;
|
||
if (cool > 1 / rate - 0.03) { // muzzle flash right after a shot
|
||
ctx.fillStyle = '#fff0a0'; ctx.beginPath(); ctx.arc(tip.x, tip.y, Math.max(2, tip.s * 0.12), 0, 7); ctx.fill();
|
||
}
|
||
}
|
||
|
||
// the courtside weapon rack along the bottom: PLAIN + the four tins. Tap a tile (or 1–5) to
|
||
// pick which one your next serve throws; empty tins dim out. Geometry is shared with input
|
||
// via rackHit so a tap lands on the tile you see.
|
||
const RACK_BOT = 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.
|
||
export const METER_Y = () => H - TUNE.rackH - RACK_BOT - 17 - SAFE.bottom;
|
||
function rackGeom() {
|
||
const h = TUNE.rackH, n = 5, tw = Math.min(64, (W - 4) / n), rw = tw * n;
|
||
return { x0: Math.round((W - rw) / 2), y0: H - h - RACK_BOT - SAFE.bottom, tw, h, n, rw };
|
||
}
|
||
export function rackHit(nx, ny) { // normalized [0,1] canvas coords -> slot index, or -1
|
||
if (G.mode !== 'splatter' || G.interlude) return -1; // no rack during the tennis-rules duel
|
||
const g = rackGeom(), cx = nx * W, cy = ny * H;
|
||
// The touch band is deliberately BIGGER than the art: up by RACK_GRAB and all the way down to
|
||
// the screen edge, reclaiming the dead margin below. At 22px tall the tiles were ~30 CSS px on a
|
||
// phone — well under the 44px everyone's thumb expects — which is why switching tins felt fiddly.
|
||
if (cy < g.y0 - RACK_GRAB || cx < g.x0 || cx >= g.x0 + g.rw) return -1;
|
||
return Math.max(0, Math.min(g.n - 1, ((cx - g.x0) / g.tw) | 0));
|
||
}
|
||
function drawRack() {
|
||
const g = rackGeom(), pad = 3;
|
||
const stock = [Infinity, G.bombs, G.triples, G.flames, G.guns];
|
||
const labels = ['BALL', 'BOMB', 'TRIPLE', 'FIRE', 'GUN'];
|
||
const WICONS = ['w_ball', 'w_bomb', 'w_triple', 'w_fire', 'w_gun']; // generated pixel-art icons
|
||
ctx.save(); ctx.lineWidth = 1;
|
||
for (let i = 0; i < g.n; i++) {
|
||
const x = g.x0 + i * g.tw, sel = G.weapon === i, dim = stock[i] <= 0 && i > 0;
|
||
// rounded slots: five separate tiles read as five buttons, which is what they are
|
||
panel(x + 1.5, g.y0 + 1.5, g.tw - 3, g.h - 3, 4,
|
||
sel ? '#123a24' : '#0a2418', // sunk slot
|
||
sel ? UI.pick : '#05160e'); // gold frame when live
|
||
// icon: left, vertically centred, padded off the edge
|
||
const box = g.h - pad * 2, icx = x + pad + box / 2, icy = g.y0 + g.h / 2;
|
||
ctx.globalAlpha = dim ? 0.35 : 1;
|
||
const spr = SPR[WICONS[i]];
|
||
if (spr) {
|
||
const k = Math.min(box / spr.width, box / spr.height), iw = spr.width * k, ih = spr.height * k;
|
||
ctx.drawImage(spr, icx - iw / 2, icy - ih / 2, iw, ih);
|
||
} else { ctx.fillStyle = UI.ball; ctx.beginPath(); ctx.arc(icx, icy, 3, 0, 7); ctx.fill(); } // fallback until the PNG loads
|
||
// count over label, both centred on the text column right of the icon — aligned as one unit
|
||
const tx = x + pad + box + (g.tw - pad * 2 - box) / 2;
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||
ctx.globalAlpha = dim ? 0.5 : 1;
|
||
ctx.font = F(8); ctx.fillStyle = sel ? UI.pick : '#e8f0dc';
|
||
ctx.fillText(i === 0 ? '∞' : '×' + stock[i], tx, g.y0 + g.h * 0.33); // ball = ∞, else ×count
|
||
ctx.font = F(6, 0); ctx.fillStyle = sel ? '#f2ead6' : '#8fa89a';
|
||
ctx.fillText(labels[i], tx, g.y0 + g.h * 0.72); // weapon label, under its count
|
||
ctx.globalAlpha = 1;
|
||
if (i === 4 && G.mgT > 0) { ctx.fillStyle = UI.pick; ctx.fillRect(x + 2, g.y0 + g.h - 2, (g.tw - 4) * Math.min(1, G.mgT / TUNE.mgPickupLen), 1.5); } // burst countdown
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
// the centered milestone 'block': a real pause, front and center. Shared by the sudden-death duel
|
||
// and the SET announcement so both read as the same interruption. Assumes textAlign is already set.
|
||
function drawCard(title, sub1, sub2, color) {
|
||
ctx.fillStyle = 'rgba(10,6,8,0.66)'; ctx.fillRect(0, H / 2 - 36, W, 64);
|
||
ctx.textAlign = 'center';
|
||
ctx.fillStyle = color; ctx.font = F(16); ctx.fillText(title, W / 2, H / 2 - 26);
|
||
ctx.fillStyle = UI.cream; ctx.font = F(8);
|
||
if (sub1) ctx.fillText(sub1, W / 2, H / 2 - 2);
|
||
if (sub2) ctx.fillText(sub2, W / 2, H / 2 + 12);
|
||
}
|
||
|
||
// Sit in the serve stance doing nothing and the gesture demonstrates itself: a ghost thumb slides
|
||
// BACK from the player while chevrons grow FORWARD up the court, then it releases and repeats.
|
||
// THE APRON IS THIS DEMO'S ROOM, and now it is the only thing in it. The draw meter used to share
|
||
// the space, which was backwards on both counts: a live readout down here is under your thumb the
|
||
// whole time it matters (the arc band carries power now, up-court where you can see it), while
|
||
// the demo only ever plays when your thumb is NOT there. So it gets the whole apron and is drawn
|
||
// at a size you can read at arm's length.
|
||
function drawPullHint() {
|
||
const cyc = 1.9, t = (G.idleHintT - TUNE.pullHintAfter) % cyc;
|
||
const draw = Math.min(1, t / 1.15); // the pull 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, straight down-screen = "pull back"
|
||
ctx.fillStyle = UI.dim;
|
||
for (let d = 7; d < travel + 4; d += 7) ctx.fillRect(ax - 1, ay + d, 2, 2);
|
||
// chevrons growing UP-court as the pull grows — the inverse relationship is the whole lesson.
|
||
// They start above his HEAD, not at his feet: pinned to the apron anchor they were drawn across
|
||
// his own legs, which read as clutter on the sprite rather than as a shot leaving him.
|
||
const head = proj(P.x, 2.4, P.z).y;
|
||
ctx.fillStyle = UI.warm;
|
||
for (let i = 0; i < 3; i++) {
|
||
const u = (i + 1) / 4;
|
||
if (draw < u * 0.9) continue;
|
||
const cy = head - 6 - i * 9, s = 5;
|
||
ctx.beginPath();
|
||
ctx.moveTo(ax, cy - s); ctx.lineTo(ax + s, cy + s * 0.7); ctx.lineTo(ax - s, cy + s * 0.7);
|
||
ctx.fill();
|
||
}
|
||
// the ghost thumb
|
||
ctx.globalAlpha = gone ? 0 : 0.9;
|
||
ctx.fillStyle = UI.cream;
|
||
ctx.beginPath(); ctx.arc(ax, ay + len, 6, 0, 7); ctx.fill();
|
||
ctx.fillStyle = 'rgba(0,0,0,0.45)';
|
||
ctx.beginPath(); ctx.arc(ax, ay + len, 2.5, 0, 7); ctx.fill();
|
||
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) + 22 + 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 = F(14); ctx.fillStyle = m.color || UI.warm;
|
||
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 += 22;
|
||
ctx.font = F(8, 0); ctx.fillStyle = UI.cream;
|
||
body.forEach((l, i) => { ctx.fillText(l, W / 2 + dx(i + 1, -1), y + 6); 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); ctx.fillStyle = UI.warm;
|
||
ctx.fillText(IS_TOUCH ? 'TAP TO CONTINUE' : 'PRESS ANY KEY', W / 2, Math.round(H / 2 + blockH / 2 + 22));
|
||
}
|
||
ctx.textBaseline = 'top';
|
||
}
|
||
|
||
// ------------------------------------------------------------------ frame ----
|
||
const AD_H = 14; // px tall the courtside ad strip is drawn — also the ceiling for centred HUD text
|
||
export function render() {
|
||
ctx.setTransform(RES, 0, 0, RES, 0, 0); // logical W x H coordinates at RES density
|
||
// ease the aim camera FIRST, before anything projects — one camera per frame (see proj)
|
||
aimCam += ((G.aim ? 1 : 0) - aimCam) * 0.15;
|
||
if (G.shake) { // bomb kick: jitter the whole world (transform is reset before the HUD)
|
||
const m = G.shake.mag * G.shake.t / 0.35;
|
||
ctx.translate((Math.random() * 2 - 1) * m, (Math.random() * 2 - 1) * m);
|
||
}
|
||
|
||
const pal = palette(), pt = paletteT();
|
||
// CLEAR THE WHOLE FRAME to the apron colour first. There was no clear at all: the stands cover the
|
||
// top and the out-area quad covers the middle, but the quad only reaches z=30, and below where that
|
||
// projects the canvas was simply TRANSPARENT — you saw the page through it as a duller band across
|
||
// the bottom of the screen. Oversized by 8px so a camera shake can never expose an edge.
|
||
ctx.fillStyle = pal.out;
|
||
ctx.fillRect(-8, -8, W + 16, H + 16);
|
||
pageBackground(pal.out); // ...and the page behind it matches, for the rounded display corners
|
||
// stands: the generated crowd, drawn in columns so it can BOUNCE when it cheers.
|
||
// sit the ad strip flush on the court's back edge (the out-area quad below reaches z=-6);
|
||
// pinning to that projected line keeps the boards clear of the lawn in BOTH orientations
|
||
// (a fixed baseline offset over-/under-shoots because camF differs portrait vs landscape).
|
||
const standsBottom = proj(0, 0, -6).y;
|
||
const crowdH = standsBottom - AD_H + 2;
|
||
// per-court crowd sheet, falling back to the grass one until it loads (or if a court ships
|
||
// without its own): a court with no crowd art must never drop to the dot fallback while the
|
||
// grass sheet is sitting right there.
|
||
const crowdImg = SPR[court.crowd] || SPR['x_crowd'];
|
||
if (crowdImg) {
|
||
// Applause is a real TWO-FRAME animation now: x_crowd is the seated sheet, x_crowd2 the same
|
||
// stand with every arm up. Columns swap between them on their own phase, so the clap ripples
|
||
// across the stands. Crucially there is NO vertical offset any more — the old bounce shifted
|
||
// each column by a FRACTIONAL dy, and nearest-neighbour resampling of a scaled image at a
|
||
// fractional y makes its pixel rows re-snap every frame. That boiling, right beside the
|
||
// scoreboard, is what read as the HUD flickering. Integer-aligned draws don't shimmer.
|
||
// ...and its clap frame must come from the SAME sheet as the seated one, or the applause
|
||
// cross-fades between two different crowds.
|
||
const clapImg = crowdImg === SPR['x_crowd'] ? SPR['x_crowd2'] : SPR[court.crowd + '2'];
|
||
const cols = 16, sw = crowdImg.width / cols, dw = W / cols;
|
||
for (let c = 0; c < cols; c++) {
|
||
const clapping = crowd.cheer > 0 && clapImg && Math.floor(G.idleT * 9 + c * 0.7) % 2;
|
||
const img = clapping ? clapImg : crowdImg;
|
||
ctx.drawImage(img, c * sw, 0, sw, img.height, c * dw, 0, dw + 0.5, crowdH + 2);
|
||
}
|
||
} else { // fallback until the image loads: the old tier stripes + dots
|
||
ctx.fillStyle = pal.stands; ctx.fillRect(0, 0, W, standsBottom);
|
||
for (let r = 0; r < 16; r++) { ctx.fillStyle = r % 2 ? pal.tierA : pal.tierB; ctx.fillRect(0, 6 + r * 5.5, W, 5.5); }
|
||
for (const d of crowd.dots) {
|
||
// dot fallback (pre-sprite): whole-pixel hops only, same no-shimmer rule as above
|
||
const jx = 0;
|
||
const jy = crowd.cheer > 0 && Math.floor(G.idleT * 9 + d.x * 0.2) % 2 ? -2 : 0;
|
||
ctx.fillStyle = d.c; ctx.fillRect(d.x + jx | 0, d.y + jy | 0, 2, 2);
|
||
}
|
||
}
|
||
// courtside advertising: tiled sponsor panels at native aspect (never half-cut text)
|
||
const ads = SPR[court.ads] || SPR['x_ads'];
|
||
if (ads) {
|
||
const tw = ads.width * (AD_H / ads.height);
|
||
for (let x = 0; x < W; x += tw) ctx.drawImage(ads, x, standsBottom - AD_H, tw, AD_H);
|
||
} else { ctx.fillStyle = pal.board; ctx.fillRect(0, standsBottom - AD_H, W, AD_H); }
|
||
// the rot dims the stadium: one tint over crowd + boards, fading home during duels
|
||
// the rot dims the stadium — but 0.5 on top of an already-dark crowd sheet buried the stands in
|
||
// splatter mode, where you spend most of the game. 0.26 still reads as "the lights went wrong".
|
||
if (pt > 0) { ctx.fillStyle = `rgba(12,8,20,${0.26 * pt})`; ctx.fillRect(0, 0, W, standsBottom); }
|
||
|
||
// ground + court (Super Tennis lawn); everything outside the doubles court is OUT
|
||
const dw = COURT.dblW / 2;
|
||
quadXZ(-14, -6, 14, 30, pal.out); // out area. NEVER let z approach
|
||
// TUNE.camZ (48): proj() divides by (camZ - z), so at z >= camZ the scale blows up and then goes
|
||
// NEGATIVE, and the quad's far edge folds back above the screen as an inverted triangle. The
|
||
// full-frame clear above is what covers the bottom of the screen; this quad does not need to.
|
||
quadXZ(-dw - 0.15, -0.15, dw + 0.15, COURT.L + 0.15, court.border); // perimeter border line
|
||
quadXZ(-dw, 0, dw, COURT.L, pal.lawn); // playing surface (doubles court)
|
||
// mow stripes: grass only. Clay and acrylic are laid flat, and banding them was the one thing
|
||
// that kept a recoloured court reading as grass however far the palette moved.
|
||
if (court.stripes)
|
||
for (let z = 0; z < COURT.L; z += 2.97) quadXZ(-dw, z, dw, Math.min(z + 1.49, COURT.L), pal.stripe);
|
||
// lines: baselines, doubles + singles sidelines, service box
|
||
line(-dw, 0, dw, 0, 2); line(-dw, COURT.L, dw, COURT.L, 2);
|
||
line(-dw, 0, -dw, COURT.L, 1.5); line(dw, 0, dw, COURT.L, 1.5);
|
||
line(-COURT.W / 2, 0, -COURT.W / 2, COURT.L, 1.5); line(COURT.W / 2, 0, COURT.W / 2, COURT.L, 1.5);
|
||
line(-COURT.W / 2, COURT.svcFar, COURT.W / 2, COURT.svcFar, 1);
|
||
line(-COURT.W / 2, COURT.svcNear, COURT.W / 2, COURT.svcNear, 1);
|
||
line(0, COURT.svcFar, 0, COURT.svcNear, 1);
|
||
line(0, 0, 0, 0.35, 1); line(0, COURT.L - 0.35, 0, COURT.L, 1); // center marks
|
||
drawRunOff(); // light falls off across the apron below the baseline
|
||
|
||
// gore decals: what remains of the visiting team (flat ground layer, then far-side mounds)
|
||
for (const d of gore) {
|
||
const p = proj(d.x, 0, d.z);
|
||
ctx.fillStyle = d.scorch ? `rgba(28,22,16,${d.a})` : `rgba(140,18,18,${d.a})`; // craters burn black-brown
|
||
ctx.beginPath(); ctx.ellipse(p.x, p.y, p.s * d.r * 0.4, p.s * d.r * 0.16, 0, 0, 7); ctx.fill();
|
||
}
|
||
drawPiles(false); // far-side piles (occluded by the net); near-side piles drawn after the net
|
||
|
||
// landing rings: white = incoming ball, yellow/red = your shot (red lands out)
|
||
if (G.phase === 'rally' && ball.active && ball.bounces === 0 && ball.lastHit === 'O') {
|
||
const l = predictLanding();
|
||
if (l) drawRing(l.x, l.z, 'rgba(255,255,255,0.85)');
|
||
}
|
||
if (G.outMarker) drawRing(G.outMarker.x, G.outMarker.z, G.outMarker.out ? UI.hot : UI.warm);
|
||
// SLINGSHOT AIM: the shot's actual flight, drawn as a band through the air.
|
||
// It replaces a chevron trail on the ground plus a draw meter across the bottom. The trail was
|
||
// a RAY — it pointed at the target but said nothing about the ball going over the net — and the
|
||
// meter lived in the apron, which is precisely where your thumb is while you pull, so the one
|
||
// readout you needed mid-gesture was under your hand.
|
||
// Now one object carries everything, up-court where you are already looking:
|
||
// WHERE the parabola itself, from previewServe — the same solver the release fires with
|
||
// WHETHER the colour, flipping at the kill threshold (cool blue = this only staggers)
|
||
// HOW MUCH the band's thickness and opacity, which is what a hue cannot say on its own
|
||
// Because the arc terminates at the auto-picked target, a weak pull and a full one at the same
|
||
// shambler draw the SAME curve in different colours — which is the "distance is free" rule made
|
||
// visible instead of merely documented.
|
||
if (G.aim && G.mode === 'splatter' && !G.interlude && (G.phase === 'pretoss' || G.phase === 'toss' || G.phase === 'rally')) {
|
||
const pv = previewServe(G.aim);
|
||
if (pv) {
|
||
const kills = pv.q >= TUNE.zSplatQ;
|
||
// cool blue-white = this pull only staggers. It stays READABLE on the dark lawn: you still
|
||
// need to see where you're pointing, you just need to know it won't kill.
|
||
const col = !kills ? '#9db4d0' : pv.zt ? UI.hot : UI.warm;
|
||
const vh = Math.hypot(pv.v.vx, pv.v.vz) || 1;
|
||
const T = Math.hypot(pv.tx - pv.x0, pv.tz - pv.z0) / vh; // flight time to the target
|
||
const N = 20;
|
||
const at = t => ({ x: pv.x0 + pv.v.vx * t, y: pv.y0 + pv.v.vy * t - 0.5 * pv.g * t * t,
|
||
z: pv.z0 + pv.v.vz * t });
|
||
// 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.
|
||
for (let i = 0; i <= N; i++) {
|
||
const w = at(T * i / N), p = proj(w.x, 0, w.z);
|
||
ctx.fillStyle = `rgba(0,0,0,${0.42 - 0.24 * (i / N)})`;
|
||
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. It also fades along its length: solid at the racket, thinning toward the target,
|
||
// so it reads as something leaving your hand and not as a label pinned on the shambler.
|
||
ctx.strokeStyle = col; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
|
||
let prev = proj(pv.x0, pv.y0, pv.z0);
|
||
for (let i = 1; i <= N; i++) {
|
||
const u = i / N, w = at(T * u), p = proj(w.x, w.y, w.z);
|
||
ctx.globalAlpha = (0.30 + 0.55 * pv.f) * (1 - 0.55 * u);
|
||
ctx.lineWidth = Math.max(1, p.s * (0.04 + pv.f * 0.15));
|
||
ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(p.x, p.y); ctx.stroke();
|
||
prev = p;
|
||
}
|
||
ctx.globalAlpha = 1; ctx.lineWidth = 1;
|
||
drawRing(pv.tx, pv.tz, col);
|
||
}
|
||
}
|
||
|
||
// far-side drawables in z order (far → near), then the net, then the near side
|
||
const farSide = [
|
||
{ z: COURT.netZ - 0.01, f: () => drawStatic('x_ump', UMP_X, COURT.netZ, UMP_H) },
|
||
];
|
||
if (!G.hideO) farSide.push({ z: O.z, f: () => drawChar(O) });
|
||
if (G.boy && G.boy.z <= COURT.netZ) farSide.push({ z: G.boy.z, f: () => drawBoy(G.boy) });
|
||
for (const zb of zombies) if (zb.z <= COURT.netZ) farSide.push({ z: zb.z, f: () => drawZombie(zb) });
|
||
farSide.sort((a, b) => a.z - b.z).forEach(d => d.f());
|
||
|
||
// net
|
||
const nl = proj(-COURT.netW / 2, 0, COURT.netZ), nr = proj(COURT.netW / 2, 0, COURT.netZ);
|
||
const nlt = proj(-COURT.netW / 2, COURT.netH, COURT.netZ), nrt = proj(COURT.netW / 2, COURT.netH, COURT.netZ);
|
||
ctx.fillStyle = 'rgba(20,26,34,0.72)';
|
||
ctx.beginPath(); ctx.moveTo(nl.x, nl.y); ctx.lineTo(nr.x, nr.y); ctx.lineTo(nrt.x, nrt.y); ctx.lineTo(nlt.x, nlt.y); ctx.fill();
|
||
ctx.strokeStyle = '#eeeeee'; ctx.lineWidth = 1.5;
|
||
ctx.beginPath(); ctx.moveTo(nlt.x, nlt.y); ctx.lineTo(nrt.x, nrt.y); ctx.stroke();
|
||
ctx.fillStyle = '#333'; ctx.fillRect(nl.x - 1, nlt.y, 2, nl.y - nlt.y); ctx.fillRect(nr.x - 1, nrt.y, 2, nr.y - nrt.y);
|
||
drawPiles(true); // near-side piles: after the net (they're closer), before the near actors
|
||
if (G.boy && G.boy.z > COURT.netZ) drawBoy(G.boy);
|
||
const nearSide = zombies.filter(zb => zb.z > COURT.netZ).map(zb => ({ z: zb.z, f: () => drawZombie(zb) }));
|
||
if (G.mode === 'eaten' && G.eatenT < EATEN_BURST) // the dying man takes his place in the queue
|
||
nearSide.push({ z: (G.eatenAt || P).z - 0.12, f: drawDying });
|
||
nearSide.sort((a, b) => a.z - b.z).forEach(d => d.f());
|
||
|
||
// server idly bouncing the ball while waiting
|
||
if (G.phase === 'pretoss') {
|
||
const sv = score.server === 'P' ? P : O;
|
||
// The ball goes where the sprite reaches for it, and the two men reach to OPPOSITE SIDES of the
|
||
// screen. Both are right-handed, so both pat the ball with the left hand — but the player is
|
||
// drawn from BEHIND (his left hand is on the viewer's left) and the opponent from the FRONT
|
||
// (his left hand is on the viewer's right). One sign for both put the opponent's ball out by
|
||
// his racket instead of under his palm.
|
||
const mine = sv.side === 'P';
|
||
const bx = sv.x + (mine ? -BOUNCE_IDLE.dx : BOUNCE_IDLE.oDx);
|
||
const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * (mine ? BOUNCE_IDLE.apex : BOUNCE_IDLE.oApex);
|
||
drawBall(bx, by, sv.z);
|
||
}
|
||
// the match ball (and any triple-serve side balls in flight)
|
||
if ((ball.active || G.phase === 'over') && !ball.collected) drawBall(ball.x, ball.y, ball.z, true, ball.bomb, ball.flame);
|
||
for (const e of extras) drawBall(e.x, e.y, e.z);
|
||
// INCOMING hostile balls, marked so they can never be mistaken for one of yours: a red ring and a
|
||
// hard ground shadow, so you can read WHERE it will land while it is still in the air.
|
||
for (const s of shots) {
|
||
const sh = proj(s.x, 0, s.z);
|
||
drawShadow(sh.x, sh.y, Math.max(1.5, sh.s * 0.05), Math.max(1, sh.s * 0.025), 0.4);
|
||
const sp = proj(s.x, s.y, s.z), r = Math.max(2, sp.s * 0.045);
|
||
ctx.fillStyle = UI.bad;
|
||
ctx.beginPath(); ctx.arc(Math.round(sp.x), Math.round(sp.y), r + 1.5, 0, 7); ctx.fill();
|
||
ctx.fillStyle = UI.ball;
|
||
ctx.beginPath(); ctx.arc(Math.round(sp.x), Math.round(sp.y), r, 0, 7); ctx.fill();
|
||
}
|
||
// splatter particles (fire embers and machine smoke ride the same array)
|
||
for (const pt of parts) {
|
||
const pp = proj(pt.x, pt.y, pt.z);
|
||
if (pt.smoke) { // a broken machine's smoke: dark, soft-edged, growing and thinning as it climbs
|
||
const age = Math.min(1, pt.t / 1.8), r = Math.max(2, pp.s * 0.09 * (2 - age));
|
||
ctx.fillStyle = `rgba(46,44,48,${0.62 * age})`;
|
||
ctx.beginPath(); ctx.arc(pp.x | 0, pp.y | 0, r, 0, 7); ctx.fill();
|
||
continue;
|
||
}
|
||
ctx.fillStyle = pt.fire ? (pt.t > 0.12 ? '#ffc030' : '#ff8010')
|
||
: pt.dark ? 'rgba(96,14,14,0.9)' : 'rgba(170,22,22,0.9)';
|
||
ctx.fillRect(pp.x | 0, pp.y | 0, 2, 2);
|
||
}
|
||
// Machine-gun 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.
|
||
for (const pe of pellets) {
|
||
const sh = proj(pe.x, 0, pe.z); drawShadow(sh.x, sh.y, Math.max(1, sh.s * 0.04), Math.max(1, sh.s * 0.02), 0.25);
|
||
const pp = proj(pe.x, pe.y, pe.z), r = Math.max(1, pp.s * 0.05);
|
||
ctx.fillStyle = UI.ball; ctx.beginPath(); ctx.arc(pp.x, pp.y, r, 0, 7); ctx.fill();
|
||
ctx.fillStyle = '#fffbe0'; ctx.beginPath(); ctx.arc(pp.x, pp.y, Math.max(0.5, r * 0.45), 0, 7); ctx.fill(); // hot core: it reads sharp, not soft
|
||
}
|
||
// the chapter boss's shockwave: a ring of blood mist blown out through the crowd noise
|
||
if (G.bossBurst) {
|
||
const b = G.bossBurst, k = 1 - b.t / 0.75, p = proj(b.x, 0.5, b.z);
|
||
ctx.strokeStyle = `rgba(190,26,26,${(1 - k) * 0.8})`;
|
||
ctx.lineWidth = Math.max(1.5, p.s * 0.05 * (1 - k));
|
||
ctx.beginPath(); ctx.ellipse(p.x, p.y, p.s * 0.9 * k, p.s * 0.32 * k, 0, 0, 7); ctx.stroke();
|
||
ctx.strokeStyle = `rgba(255,180,170,${(1 - k) * 0.35})`; ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.ellipse(p.x, p.y, p.s * 0.62 * k, p.s * 0.22 * k, 0, 0, 7); ctx.stroke();
|
||
}
|
||
if (G.mode !== 'eaten') drawChar(P); // ...the dying man is drawn with the crowd instead (drawDying)
|
||
if ((G.horde || G.mgT > 0) && G.mode === 'splatter') drawMgRacket(); // rambo racket over the player
|
||
|
||
ctx.setTransform(RES, 0, 0, RES, 0, 0); // un-shake: the HUD and overlays stay rock steady
|
||
// bomb bloom: a warm blood-orange wash, not a white-out. At 0.85 white it blinded you on a phone
|
||
// and hid the very gore eruption it was announcing.
|
||
if (G.boomFlash > 0) { ctx.fillStyle = `rgba(255,150,70,${Math.min(1, G.boomFlash / 0.06) * 0.3})`; ctx.fillRect(0, 0, W, H); }
|
||
// took a ranged hit: a red wash from the screen EDGES inward. Deliberately not a full-screen fill —
|
||
// this channel must read as "you are bleeding", not as "something exploded" (that is boomFlash).
|
||
if (G.hurtFlash > 0) {
|
||
const a = Math.min(1, G.hurtFlash / 0.3) * 0.55, b = 26;
|
||
ctx.fillStyle = `rgba(150,10,14,${a.toFixed(3)})`;
|
||
ctx.fillRect(0, 0, W, b); ctx.fillRect(0, H - b, W, b);
|
||
ctx.fillRect(0, 0, b, H); ctx.fillRect(W - b, 0, b, H);
|
||
}
|
||
|
||
// HUD — Wimbledon manual scoreboard as a 16-bit panel: bevelled bottle-green box (light
|
||
// top/left, dark bottom/right), a purple club spine, cream serif names, and the LIVE game
|
||
// sunk into a recessed slot — purple for the server, gold digits. Splatter adds a recessed
|
||
// status well (kills-to-next-point pips + weapon stock). Hard edges, no radius = retro.
|
||
const splat = G.mode !== 'tennis';
|
||
const bd = { face: '#1e5b3a', hi: '#3f9560', lo: '#0e3624', slot: '#0b2b1c', slotSvr: '#2b1a46',
|
||
spine: '#472a70', spineHi: '#5f3d94', cream: '#f2ead6', dim: '#89a08f', gold: UI.pick, engHi: '#2c7a4f' };
|
||
const R = (x, y, w, h, c) => { ctx.fillStyle = c; ctx.fillRect(x, y, w, h); };
|
||
const ptTxt = i => score.pts[0] >= 3 && score.pts[1] >= 3
|
||
? (score.pts[i] === score.pts[1 - i] ? '40' : score.pts[i] > score.pts[1 - i] ? 'AD' : '40')
|
||
: ['0', '15', '30', '40'][Math.min(score.pts[i], 3)];
|
||
ctx.save(); ctx.translate(6, 6);
|
||
const bW = 150, bH = 42; // fixed: keeps the ornate PNG undistorted (art is 300x84 = 150x42)
|
||
if (SPR.scoreboard) ctx.drawImage(SPR.scoreboard, 0, 0, bW, bH); // generated ornate board backdrop
|
||
else { // code-drawn fallback until the PNG loads
|
||
R(0, 0, bW, bH, bd.face);
|
||
R(0, 0, bW, 1, bd.hi); R(0, 0, 1, bH, bd.hi);
|
||
R(0, bH - 1, bW, 1, bd.lo); R(bW - 1, 0, 1, bH, bd.lo);
|
||
R(1, 1, 5, bH - 2, bd.spine); R(1, 1, 5, 1, bd.spineHi); R(6, 1, 1, bH - 2, bd.lo);
|
||
}
|
||
// live text laid straight onto the art's own regions (spine ~x14, two rows, right slot ~x105-147),
|
||
// Silkscreen pixel font — no code-drawn slots or bevels painted on top of the art.
|
||
ctx.textBaseline = 'middle';
|
||
const names = splat ? ['YOU', 'THEM'] : ['YOU', 'CPU'];
|
||
for (let i = 0; i < 2; i++) {
|
||
const cy = 12 + i * 18, svr = (score.server === 'P') === (i === 0); // rows aligned to the art's two bands
|
||
if (svr) { ctx.fillStyle = bd.gold; ctx.beginPath(); ctx.arc(11, cy, 1.8, 0, 7); ctx.fill(); } // server pip on the purple spine
|
||
ctx.textAlign = 'left'; ctx.font = F(8, svr); ctx.fillStyle = svr ? bd.cream : bd.dim;
|
||
ctx.fillText(names[i], 18, cy);
|
||
ctx.textAlign = 'center';
|
||
if (splat) { ctx.font = F(8, 0); ctx.fillStyle = bd.dim; ctx.fillText(String(G.sets[i]), 74, cy); } // sets
|
||
ctx.font = F(8); ctx.fillStyle = bd.cream; ctx.fillText(String(score.games[i]), 90, cy); // games
|
||
ctx.font = F(8); ctx.fillStyle = svr ? bd.gold : bd.cream; ctx.fillText(ptTxt(i), 126, cy); // points, in the art's slot
|
||
}
|
||
if (splat) {
|
||
// This row used to be bare text on the court. That was fine over one dark green lawn and is
|
||
// unreadable over a sunlit clay crowd or a floodlit blue one, so it gets its own panel — the
|
||
// same trick the scoreboard art above it already relies on.
|
||
ctx.lineWidth = 1;
|
||
panel(0.5, bH + 1.5, bW - 1, 12, 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(6, 0); ctx.fillStyle = bd.dim;
|
||
ctx.fillText(`GAME ${G.runGames + 1} · ${G.sets[0]} SET${G.sets[0] === 1 ? '' : 'S'}`, bW - 3, bH + 7);
|
||
}
|
||
if (splat && TUNE.killPips && TUNE.killsPerPoint > 1) { // kills-to-next-point pips, on the panel above
|
||
ctx.textAlign = 'left'; ctx.font = F(6, 0); ctx.fillStyle = bd.dim; ctx.fillText('NEXT', 4, bH + 7);
|
||
for (let k = 0; k < TUNE.killsPerPoint; k++) {
|
||
ctx.beginPath(); ctx.arc(32 + k * 8, bH + 7, 2.4, 0, 7);
|
||
if (k < G.killCount) { ctx.fillStyle = bd.gold; ctx.fill(); }
|
||
else { ctx.strokeStyle = '#5a6a5a'; ctx.lineWidth = 1; ctx.stroke(); }
|
||
}
|
||
}
|
||
ctx.restore();
|
||
ctx.textBaseline = 'top'; ctx.textAlign = 'center';
|
||
if (G.mode === 'splatter' && !G.interlude) drawRack(); // courtside weapon rack (hidden during the tennis-rules duel)
|
||
// phase C: the summary fades UP over the death animation instead of cutting on top of it
|
||
if (G.mode === 'eaten' && G.eatenT > EATEN_SUMMARY) {
|
||
const fade = Math.min(1, (G.eatenT - EATEN_SUMMARY) / 0.6);
|
||
ctx.save(); ctx.globalAlpha = fade;
|
||
ctx.fillStyle = 'rgba(10,4,6,0.72)'; ctx.fillRect(0, 0, W, H);
|
||
ctx.fillStyle = UI.bad; ctx.font = F(14);
|
||
ctx.fillText('GAME, SET, MASSACRE.', W / 2, H / 2 - 40);
|
||
ctx.fillStyle = UI.cream; ctx.font = F(9);
|
||
ctx.fillText('SURVIVED ' + G.runGames + ' GAMES · ' + G.sets[0] + ' SETS', W / 2, H / 2 - 14);
|
||
if (G.newBest) { // a fresh record crows; otherwise show the mark to beat
|
||
const pulse = 0.55 + 0.45 * Math.abs(Math.sin(G.eatenT * 4));
|
||
ctx.fillStyle = hexA(UI.warm, pulse); ctx.font = F(10);
|
||
ctx.fillText('★ NEW BEST ★', W / 2, H / 2 + 4);
|
||
} else if (G.best && G.best.games > 0) {
|
||
ctx.fillStyle = UI.dim; ctx.font = F(8);
|
||
ctx.fillText('BEST ' + G.best.games + ' GAMES · ' + G.best.sets + ' SETS', W / 2, H / 2 + 4);
|
||
}
|
||
// gated well past the animation so a panic tap can't skip it (main.js gates the restart the same)
|
||
if (G.eatenT > EATEN_RESTART) { ctx.fillStyle = UI.warm; ctx.font = F(8); ctx.fillText('TAP TO PLAY AGAIN', W / 2, H / 2 + 26); }
|
||
ctx.restore();
|
||
}
|
||
// Timed freezes swallow every input until they run out, which reads as the game having hung
|
||
// ("sometimes the controls just feel frozen"). Show the clock instead of hiding it. A modal is
|
||
// excluded on purpose — that one waits for YOU, and says so.
|
||
// 'transition' is excluded: startTransition parks overT at 9999 to hold the point flow while its
|
||
// own scripted timeline runs, and counting that down read as "resuming in 9999".
|
||
const waitT = G.modal || G.mode === 'eaten' || G.mode === 'transition' ? 0
|
||
: Math.max(G.changeover > 0 ? G.changeover : 0,
|
||
G.phase === 'over' ? G.overT : 0,
|
||
G.interlude ? G.interlude.introT : 0);
|
||
if (waitT > 0.5) {
|
||
// Kept clear of the ad strip — centred on H alone it landed right on the boards, which is the
|
||
// busiest band on the screen. The strip's own projected line is the ceiling, so it stays legible
|
||
// whatever the phone's height does to the layout. A 1px shadow, not a box, carries it over the crowd.
|
||
const txt = 'RESUMING IN ' + Math.ceil(waitT);
|
||
const y = Math.min(Math.round(H * 0.34), Math.round(proj(0, 0, -6).y) - AD_H - 16);
|
||
ctx.font = F(10);
|
||
ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillText(txt, W / 2 + 1, y + 1);
|
||
ctx.fillStyle = hexA(UI.warm, 0.92); ctx.fillText(txt, W / 2, y);
|
||
}
|
||
if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !(G.mgT > 0) && !G.aim && waitT <= 0.5) { // while pulling, the draw meter owns this slot
|
||
ctx.fillStyle = UI.warm; ctx.font = F(8);
|
||
const touch = IS_TOUCH, switchHint = touch ? 'TAP THE RACK TO SWITCH' : '1-5 TO SWITCH';
|
||
const sel = ['PLAIN', 'BOMB', 'TRIPLE', 'FIRE', 'GUN'][G.weapon];
|
||
// 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.
|
||
const txt = G.mode === 'splatter' && !G.interlude
|
||
? (G.weapon === 4 ? (touch ? 'TAP TO UNLOAD THE GUN' : 'SPACE UNLOADS THE GUN')
|
||
: G.aim ? 'PULL BACK: FURTHER = HARDER'
|
||
: G.weapon > 0 ? `SERVE FIRES: ${sel} · ${switchHint}`
|
||
: touch ? 'PULL BACK TO AIM · RELEASE TO FIRE' : 'DRAG BACK TO AIM · SPACE SERVES')
|
||
: G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE')
|
||
: (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE');
|
||
ctx.fillText(txt, W / 2, METER_Y()); // same slot the draw meter uses
|
||
// and after a beat of inaction, show the gesture instead of only naming it
|
||
if (G.mode === 'splatter' && !G.interlude && G.weapon !== 4 && G.idleHintT > TUNE.pullHintAfter) drawPullHint();
|
||
}
|
||
if (G.flash) {
|
||
ctx.font = F(12); ctx.fillStyle = '#ffffff';
|
||
ctx.fillText(G.flash.text, W / 2, 60 + Math.sin(G.flash.t * 20) * 1);
|
||
}
|
||
// the SET milestone card (the duel's own announcement is a real modal now, not a timed card)
|
||
if (G.card && G.card.t > 0 && !(G.card.wait > 0)) // `wait` holds it OFF the screen (a boss dying under it)
|
||
drawCard(G.card.title, G.card.sub1, G.card.sub2, G.card.color);
|
||
if (TUNE.missHud && G.lastMiss && G.idleT - G.lastMiss.at < 3.5) { // last miss reason, live
|
||
ctx.font = F(8); ctx.fillStyle = '#ff9060';
|
||
ctx.fillText(G.lastMiss.text.toUpperCase(), W / 2, H - 28);
|
||
}
|
||
if (G.logShow) { // scoreboard tap: recent swing diagnostics, screenshot-friendly
|
||
const list = G.missLog.slice(-9), lh = 10, top = 40;
|
||
ctx.fillStyle = 'rgba(8,10,16,0.88)'; ctx.fillRect(6, top, W - 12, list.length * lh + 16);
|
||
ctx.font = F(7, 0); ctx.textAlign = 'left';
|
||
ctx.fillStyle = UI.dim; ctx.fillText('SWING LOG — tap scoreboard to hide', 10, top + 5);
|
||
const maxCh = Math.floor((W - 20) / 4.3);
|
||
list.forEach((e, i) => {
|
||
ctx.fillStyle = e.result === 'HIT' ? '#7fd07f' : '#ff9060';
|
||
ctx.fillText((e.at + 's ' + e.result + ' ' + e.reason).slice(0, maxCh), 10, top + 16 + i * lh);
|
||
});
|
||
}
|
||
if (G.modal) drawModal(G.modal); // last: it is a wall, nothing draws over it
|
||
ctx.textAlign = 'left';
|
||
}
|