Paint the stands once

- The opaque menu no longer has the whole court rendered behind it at full
  resolution — minutes of battery burn nobody could see.
- Stands, ads, rot tint, court, lines and the run-off are one cached blit,
  rebuilt only when the palette actually moves; the clap ripple stays live.
- RES derives from devicePixelRatio (capped at 4), so a 2x phone stops
  painting double the pixels it can show.
- One shared noise buffer for every synthesized sound — applause alone was
  allocating ~200 fresh buffers per cheer.
- The touch-steer path stops re-measuring a fixed canvas rect per move event.

Skipped as not-quick: the z-sort closure churn and swap-remove splices —
real but small, and each is a behavior-preserving refactor for another day.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-06 09:36:17 +02:00
1 parent f1f0d05a61
commit e07196f247
4 files changed
+170 -126

No files matched your search

+32 -21
View File
@@ -147,11 +147,22 @@ export function say(text, delay = 0) { // fallback when a recorded umpire line i
} catch (e) {} } catch (e) {}
} }
function noiseBuf(ac, len) { // ONE noise buffer, shared. Every swish/pock/thud/splat/clap used to fill a fresh Float32 buffer
const b = ac.createBuffer(1, ac.sampleRate * len, ac.sampleRate), d = b.getChannelData(0); // (thousands of Math.random() calls, ~30 KB) per sound — during a horde the gun did that a dozen
// times a second, and applause() did it ~200 times per cheer. Callers play a random slice instead
// (src.start's offset + duration), so repeats never phase-align and the node still stops on time.
const NOISE_LEN = 1.5;
let noise = null, noiseAc = null;
function noiseBuf(ac) {
if (noiseAc !== ac) { // a fresh context invalidates the old buffer
noise = ac.createBuffer(1, ac.sampleRate * NOISE_LEN, ac.sampleRate);
const d = noise.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
return b; noiseAc = ac;
} }
return noise;
}
const nOff = len => Math.random() * (NOISE_LEN - len); // a random slice into the shared buffer
// Filtered noise sweep. Default = the racket cutting air (rising, short) — the sound of a whiff. // Filtered noise sweep. Default = the racket cutting air (rising, short) — the sound of a whiff.
// The sweep is parameterised because a DESCENDING, longer one is a panel swooshing in and settling: // The sweep is parameterised because a DESCENDING, longer one is a panel swooshing in and settling:
@@ -159,13 +170,13 @@ function noiseBuf(ac, len) {
export function swish(vol = 0.3, f0 = 1600, f1 = 3200, dur = 0.16) { export function swish(vol = 0.3, f0 = 1600, f1 = 3200, dur = 0.16) {
try { try {
const ac = audio(), t = ac.currentTime; const ac = audio(), t = ac.currentTime;
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, dur); const src = ac.createBufferSource(); src.buffer = noiseBuf(ac);
const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.setValueAtTime(f0, t); const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.setValueAtTime(f0, t);
bp.frequency.exponentialRampToValueAtTime(f1, t + dur * 0.75); bp.Q.value = 1.5; bp.frequency.exponentialRampToValueAtTime(f1, t + dur * 0.75); bp.Q.value = 1.5;
const g = ac.createGain(); const g = ac.createGain();
g.gain.setValueAtTime(0.001, t); g.gain.exponentialRampToValueAtTime(vol, t + dur * 0.3); g.gain.setValueAtTime(0.001, t); g.gain.exponentialRampToValueAtTime(vol, t + dur * 0.3);
g.gain.exponentialRampToValueAtTime(0.001, t + dur); g.gain.exponentialRampToValueAtTime(0.001, t + dur);
src.connect(bp).connect(g).connect(ac.destination); src.start(); src.connect(bp).connect(g).connect(ac.destination); src.start(t, nOff(dur), dur);
} catch (e) {} } catch (e) {}
} }
export const whoosh = (vol = 0.34) => swish(vol, 4200, 700, 0.3); // a panel flying in and settling export const whoosh = (vol = 0.34) => swish(vol, 4200, 700, 0.3); // a panel flying in and settling
@@ -173,11 +184,11 @@ export const whoosh = (vol = 0.34) => swish(vol, 4200, 700, 0.3); // a panel fly
export function pock(vol = 0.8, freq = 900) { // racket contact / bounce thock export function pock(vol = 0.8, freq = 900) { // racket contact / bounce thock
try { try {
const ac = audio(), t = ac.currentTime; const ac = audio(), t = ac.currentTime;
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, 0.06); const src = ac.createBufferSource(); src.buffer = noiseBuf(ac);
const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = freq; bp.Q.value = 1.2; const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = freq; bp.Q.value = 1.2;
const g = ac.createGain(); const g = ac.createGain();
g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.07); g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.07);
src.connect(bp).connect(g).connect(ac.destination); src.start(); src.connect(bp).connect(g).connect(ac.destination); src.start(t, nOff(0.06), 0.06);
const o = ac.createOscillator(), og = ac.createGain(); // low thump body const o = ac.createOscillator(), og = ac.createGain(); // low thump body
o.frequency.setValueAtTime(180, t); o.frequency.exponentialRampToValueAtTime(70, t + 0.08); o.frequency.setValueAtTime(180, t); o.frequency.exponentialRampToValueAtTime(70, t + 0.08);
og.gain.setValueAtTime(vol * 0.5, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.09); og.gain.setValueAtTime(vol * 0.5, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.09);
@@ -197,13 +208,13 @@ export function thud(vol = 0.6, size = 1) {
const dur = 0.15 * k; const dur = 0.15 * k;
// the slap. The band FALLS, where pock's sits still — a collapsing resonance is the wet part, // the slap. The band FALLS, where pock's sits still — a collapsing resonance is the wet part,
// and it is the same trick splat() uses, just shorter and much less of it. // and it is the same trick splat() uses, just shorter and much less of it.
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, dur); const src = ac.createBufferSource(); src.buffer = noiseBuf(ac);
const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 3.4; const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 3.4;
bp.frequency.setValueAtTime(780 / k, t); bp.frequency.setValueAtTime(780 / k, t);
bp.frequency.exponentialRampToValueAtTime(190 / k, t + dur); bp.frequency.exponentialRampToValueAtTime(190 / k, t + dur);
const g = ac.createGain(); const g = ac.createGain();
g.gain.setValueAtTime(vol * 0.8, t); g.gain.exponentialRampToValueAtTime(0.001, t + dur); g.gain.setValueAtTime(vol * 0.8, t); g.gain.exponentialRampToValueAtTime(0.001, t + dur);
src.connect(bp).connect(g).connect(ac.destination); src.start(t); src.connect(bp).connect(g).connect(ac.destination); src.start(t, nOff(dur), dur);
// the weight behind it — THIS is what actually carries the size difference // the weight behind it — THIS is what actually carries the size difference
const o = ac.createOscillator(), og = ac.createGain(); const o = ac.createOscillator(), og = ac.createGain();
o.frequency.setValueAtTime(150 / k, t); o.frequency.exponentialRampToValueAtTime(52 / k, t + 0.11 * k); o.frequency.setValueAtTime(150 / k, t); o.frequency.exponentialRampToValueAtTime(52 / k, t + 0.11 * k);
@@ -211,12 +222,12 @@ export function thud(vol = 0.6, size = 1) {
o.connect(og).connect(ac.destination); o.start(t); o.stop(t + 0.15 * k); o.connect(og).connect(ac.destination); o.start(t); o.stop(t + 0.15 * k);
// one short bright tick just behind the slap: something gave. Quiet on purpose — this is a hit, // one short bright tick just behind the slap: something gave. Quiet on purpose — this is a hit,
// not a burst, and at splat()'s spatter level every rally would sound like a kill. // not a burst, and at splat()'s spatter level every rally would sound like a kill.
const d = 0.03 * k, sp = ac.createBufferSource(); sp.buffer = noiseBuf(ac, 0.07 * k); const d = 0.03 * k, sp = ac.createBufferSource(); sp.buffer = noiseBuf(ac);
const hp = ac.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 1100; const hp = ac.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 1100;
const spg = ac.createGain(); const spg = ac.createGain();
spg.gain.setValueAtTime(vol * 0.18, t + d); spg.gain.setValueAtTime(vol * 0.18, t + d);
spg.gain.exponentialRampToValueAtTime(0.001, t + d + 0.07 * k); spg.gain.exponentialRampToValueAtTime(0.001, t + d + 0.07 * k);
sp.connect(hp).connect(spg).connect(ac.destination); sp.start(t + d); sp.connect(hp).connect(spg).connect(ac.destination); sp.start(t + d, nOff(0.07 * k), 0.07 * k);
} catch (e) {} } catch (e) {}
} }
@@ -231,26 +242,26 @@ export function splat(vol = 0.7, size = 1) {
const dur = 0.22 * k; const dur = 0.22 * k;
// The wetness lives HERE. The old filter was a plain lowpass with no resonance, which is a dry // The wetness lives HERE. The old filter was a plain lowpass with no resonance, which is a dry
// hiss with the top taken off; a RESONANT band collapsing down the spectrum is a squelch. // hiss with the top taken off; a RESONANT band collapsing down the spectrum is a squelch.
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, dur); const src = ac.createBufferSource(); src.buffer = noiseBuf(ac);
const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 3.2; const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 3.2;
bp.frequency.setValueAtTime(1100 / k, t); bp.frequency.setValueAtTime(1100 / k, t);
bp.frequency.exponentialRampToValueAtTime(130 / k, t + dur * 0.85); bp.frequency.exponentialRampToValueAtTime(130 / k, t + dur * 0.85);
const g = ac.createGain(); const g = ac.createGain();
g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.001, t + dur); g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.001, t + dur);
src.connect(bp).connect(g).connect(ac.destination); src.start(t); src.connect(bp).connect(g).connect(ac.destination); src.start(t, nOff(dur), dur);
const o = ac.createOscillator(), og = ac.createGain(); // body thud, heavier on a heavier body const o = ac.createOscillator(), og = ac.createGain(); // body thud, heavier on a heavier body
o.frequency.setValueAtTime(120 / k, t); o.frequency.exponentialRampToValueAtTime(45 / k, t + 0.15 * k); o.frequency.setValueAtTime(120 / k, t); o.frequency.exponentialRampToValueAtTime(45 / k, t + 0.15 * k);
og.gain.setValueAtTime(vol * 0.7, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.16 * k); og.gain.setValueAtTime(vol * 0.7, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.16 * k);
o.connect(og).connect(ac.destination); o.start(t); o.stop(t + 0.18 * k); o.connect(og).connect(ac.destination); o.start(t); o.stop(t + 0.18 * k);
// ...and what came out of it lands a moment later. This second, brighter burst is most of what // ...and what came out of it lands a moment later. This second, brighter burst is most of what
// reads as "wet" — one clean hit is a thud, a thud followed by spatter is a body. // reads as "wet" — one clean hit is a thud, a thud followed by spatter is a body.
const d = 0.06 * k, sp = ac.createBufferSource(); sp.buffer = noiseBuf(ac, 0.18 * k); const d = 0.06 * k, sp = ac.createBufferSource(); sp.buffer = noiseBuf(ac);
const hp = ac.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 900; const hp = ac.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 900;
const spg = ac.createGain(); const spg = ac.createGain();
spg.gain.setValueAtTime(0.001, t + d); spg.gain.setValueAtTime(0.001, t + d);
spg.gain.exponentialRampToValueAtTime(vol * 0.3, t + d + 0.02); spg.gain.exponentialRampToValueAtTime(vol * 0.3, t + d + 0.02);
spg.gain.exponentialRampToValueAtTime(0.001, t + d + 0.18 * k); spg.gain.exponentialRampToValueAtTime(0.001, t + d + 0.18 * k);
sp.connect(hp).connect(spg).connect(ac.destination); sp.start(t + d); sp.connect(hp).connect(spg).connect(ac.destination); sp.start(t + d, nOff(0.18 * k), 0.18 * k);
} catch (e) {} } catch (e) {}
} }
@@ -261,12 +272,12 @@ export function boom(vol = 0.9) { // bomb ball — sub-bass thump under a filter
o.frequency.setValueAtTime(80, t); o.frequency.exponentialRampToValueAtTime(30, t + 0.35); o.frequency.setValueAtTime(80, t); o.frequency.exponentialRampToValueAtTime(30, t + 0.35);
og.gain.setValueAtTime(vol, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.4); og.gain.setValueAtTime(vol, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
o.connect(og).connect(ac.destination); o.start(t); o.stop(t + 0.42); o.connect(og).connect(ac.destination); o.start(t); o.stop(t + 0.42);
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, 0.4); // the blast const src = ac.createBufferSource(); src.buffer = noiseBuf(ac); // the blast
const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; const lp = ac.createBiquadFilter(); lp.type = 'lowpass';
lp.frequency.setValueAtTime(2200, t); lp.frequency.exponentialRampToValueAtTime(200, t + 0.35); lp.frequency.setValueAtTime(2200, t); lp.frequency.exponentialRampToValueAtTime(200, t + 0.35);
const g = ac.createGain(); const g = ac.createGain();
g.gain.setValueAtTime(vol * 0.8, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.4); g.gain.setValueAtTime(vol * 0.8, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
src.connect(lp).connect(g).connect(ac.destination); src.start(t); src.connect(lp).connect(g).connect(ac.destination); src.start(t, nOff(0.4), 0.4);
} catch (e) {} } catch (e) {}
} }
@@ -276,11 +287,11 @@ export function boom(vol = 0.9) { // bomb ball — sub-bass thump under a filter
export function zap(vol = 0.5) { export function zap(vol = 0.5) {
try { try {
const ac = audio(), t = ac.currentTime, dur = 0.42; const ac = audio(), t = ac.currentTime, dur = 0.42;
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, 0.09); // the crack: air leaving const src = ac.createBufferSource(); src.buffer = noiseBuf(ac); // the crack: air leaving
const hp = ac.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 2200; const hp = ac.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 2200;
const ng = ac.createGain(); const ng = ac.createGain();
ng.gain.setValueAtTime(vol * 0.85, t); ng.gain.exponentialRampToValueAtTime(0.001, t + 0.09); ng.gain.setValueAtTime(vol * 0.85, t); ng.gain.exponentialRampToValueAtTime(0.001, t + 0.09);
src.connect(hp).connect(ng).connect(ac.destination); src.start(t); src.connect(hp).connect(ng).connect(ac.destination); src.start(t, nOff(0.09), 0.09);
// the body: two detuned saws DIVING through a resonant lowpass. The direction is the whole // the body: two detuned saws DIVING through a resonant lowpass. The direction is the whole
// reading — a rising sweep is a charge-up, and this thing never charges, it discharges. // reading — a rising sweep is a charge-up, and this thing never charges, it discharges.
const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.Q.value = 12; const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.Q.value = 12;
@@ -344,13 +355,13 @@ export function applause(seconds = 2.4, vol = 0.5) { // ponytail: synthesized cl
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const t = t0 + Math.random() * seconds; const t = t0 + Math.random() * seconds;
const env = Math.min(1, 4 * (1 - Math.abs(2 * (t - t0) / seconds - 1))); // fade in/out const env = Math.min(1, 4 * (1 - Math.abs(2 * (t - t0) / seconds - 1))); // fade in/out
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, 0.025); const src = ac.createBufferSource(); src.buffer = noiseBuf(ac);
const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; const bp = ac.createBiquadFilter(); bp.type = 'bandpass';
bp.frequency.value = 1000 + Math.random() * 1600; bp.Q.value = 2.5; bp.frequency.value = 1000 + Math.random() * 1600; bp.Q.value = 2.5;
const g = ac.createGain(); const g = ac.createGain();
g.gain.setValueAtTime(vol * env * (0.3 + Math.random() * 0.7), t); g.gain.setValueAtTime(vol * env * (0.3 + Math.random() * 0.7), t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.03); g.gain.exponentialRampToValueAtTime(0.001, t + 0.03);
src.connect(bp).connect(g).connect(ac.destination); src.start(t); src.connect(bp).connect(g).connect(ac.destination); src.start(t, nOff(0.025), 0.025);
} }
} catch (e) {} } catch (e) {}
} }
+4 -2
View File
@@ -77,7 +77,9 @@ cv.addEventListener('touchstart', e => {
if (slot >= 0) { selectWeapon(slot); continue; } if (slot >= 0) { selectWeapon(slot); continue; }
} }
if (!stick) { if (!stick) {
stick = { id: t.identifier, x0: t.clientX, y0: t.clientY, t0: performance.now(), // `r` rides along: the canvas is position: fixed, so its rect only moves on viewport changes
// — re-measuring it per touchmove (60-120 Hz on iOS) was layout work for a constant answer
stick = { id: t.identifier, x0: t.clientX, y0: t.clientY, t0: performance.now(), r,
moved: false, aimable: canAimServe() }; // context locked at touch start — a movement drag never morphs into a serve moved: false, aimable: canAimServe() }; // context locked at touch start — a movement drag never morphs into a serve
// the reticle appears the moment the thumb lands, but mag 0 aims at nothing — you have to pull // the reticle appears the moment the thumb lands, but mag 0 aims at nothing — you have to pull
if (stick.aimable) G.aim = { fx: 0, fy: 0, mag: 0 }; if (stick.aimable) G.aim = { fx: 0, fy: 0, mag: 0 };
@@ -118,7 +120,7 @@ cv.addEventListener('touchmove', e => {
} else G.aim = { fx: 0, fy: 0, mag: 0 }; } else G.aim = { fx: 0, fy: 0, mag: 0 };
continue; continue;
} }
const r = cv.getBoundingClientRect(); const r = stick.r;
steerPX = (t.clientX - r.left) / r.width * 2 - 1; steerPX = (t.clientX - r.left) / r.width * 2 - 1;
touchAxis.x = Math.abs(dx) > DEAD ? Math.sign(dx) : 0; touchAxis.x = Math.abs(dx) > DEAD ? Math.sign(dx) : 0;
touchAxis.z = Math.abs(dy) > DEAD ? Math.sign(dy) : 0; touchAxis.z = Math.abs(dy) > DEAD ? Math.sign(dy) : 0;
+4
View File
@@ -21,6 +21,10 @@ let paused = false;
function frame(now) { function frame(now) {
requestAnimationFrame(frame); // schedule FIRST: one bad frame must never kill the loop (a thrown error froze the game for good) requestAnimationFrame(frame); // schedule FIRST: one bad frame must never kill the loop (a thrown error froze the game for good)
if (TUNE.fps && now - last < 1000 / TUNE.fps - 2) return; // frame cap (battery): skip RAF ticks until it's our beat if (TUNE.fps && now - last < 1000 / TUNE.fps - 2) return; // frame cap (battery): skip RAF ticks until it's our beat
// THE MENU IS OPAQUE (#intro: fixed, inset 0, solid background). Painting the whole court at RES
// density behind it — for the minutes a phone idles on the splash and stats screens — was pure
// battery burn nobody could see. The world holds still too; nothing on it predates the menu.
if (menuUp()) { last = now; return; }
if (paused) { last = now; render(); return; } if (paused) { last = now; render(); return; }
let dt = Math.min(0.04, (now - last) / 1000); last = now; let dt = Math.min(0.04, (now - last) / 1000); last = now;
// A chapter boss dies in slow motion. The clock that ENDS it runs at real speed, so the effect is // A chapter boss dies in slow motion. The clock that ENDS it runs at real speed, so the effect is
+130 -103
View File
@@ -52,10 +52,13 @@ function shadowText(txt, x, y, fill) {
// ---------------------------------------------------------------- camera ---- // ---------------------------------------------------------------- camera ----
const IS_TOUCH = matchMedia('(pointer: coarse)').matches; // hoisted: a matchMedia call per frame churns GC 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 // Physical pixels per logical pixel, derived from the device instead of pinned at the iPhone's 4.
// px, so RES 3 (810) was still being upscaled 1.44x by the browser. 4 gets us to 1080, near 1:1. // 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. // 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 const RES = Math.max(2, Math.min(4, Math.ceil((devicePixelRatio || 3) * 1.35)));
export let W = 480, H = 270; export let W = 480, H = 270;
// Split responsibility, and the split is deliberate: // Split responsibility, and the split is deliberate:
@@ -178,23 +181,6 @@ export function proj(x, y, z) {
return { x: W / 2 + x * s, y: TUNE.horizon + (TUNE.camY + aimCam * TUNE.aimCamY - y) * s, s }; 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 ---- // ---------------------------------------------------------------- sprites ----
// assets/<name>.png; game falls back to code-drawn figures until sprites load. // assets/<name>.png; game falls back to code-drawn figures until sprites load.
@@ -286,17 +272,128 @@ export function buildCrowd() {
}); });
} }
// -------------------------------------------------------------- primitives ---- // ---------------------------------------------------------------- backdrop ----
function line(x1, z1, x2, z2, w = 1) { // 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); const a = proj(x1, 0, z1), b = proj(x2, 0, z2);
ctx.strokeStyle = UI.cream; ctx.lineWidth = w; c.strokeStyle = UI.cream; c.lineWidth = w;
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); 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); }
} }
function quadXZ(x1, z1, x2, z2, color) { // filled court-plane rect (screen trapezoid) // courtside advertising: tiled sponsor panels at native aspect (never half-cut text)
const a = proj(x1, 0, z1), b = proj(x2, 0, z1), c = proj(x2, 0, z2), d = proj(x1, 0, z2); const ads = SPR[court.ads] || SPR['x_ads'];
ctx.fillStyle = color; ctx.beginPath(); if (ads) {
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(); 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)].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 // 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. // fallback only matters on a browser that could not render the rest of this anyway.
function panel(x, y, w, h, r, fill, stroke) { function panel(x, y, w, h, r, fill, stroke) {
@@ -1228,81 +1325,11 @@ export function render() {
} }
const pal = palette(), pt = paletteT(); const pal = palette(), pt = paletteT();
// CLEAR THE WHOLE FRAME to the apron colour first. There was no clear at all: the stands cover the pageBackground(pal.out); // the page behind the canvas matches, for the rounded display corners
// top and the out-area quad covers the middle, but the quad only reaches z=30, and below where that // stands, ads, rot tint, court, lines and the run-off, as one cached blit (see paintBackdrop);
// projects the canvas was simply TRANSPARENT — you saw the page through it as a duller band across // the clap ripple is overlaid live inside. The ad strip sits flush on the court's back edge (the
// the bottom of the screen. Oversized by 8px so a camera shake can never expose an edge. // out-area quad reaches z=-6), which keeps the boards clear of the lawn in BOTH orientations.
ctx.fillStyle = pal.out; const standsBottom = drawBackdrop(pal, pt);
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) // gore decals: what remains of the visiting team (flat ground layer, then far-side mounds)
for (const d of gore) { for (const d of gore) {