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
+173 -129

No files matched your search

+132 -105
View File
@@ -52,10 +52,13 @@ function shadowText(txt, x, y, fill) {
// ---------------------------------------------------------------- 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.
// Physical pixels per logical pixel, derived from the device instead of pinned at the iPhone's 4.
// On a 3x iPhone the canvas ends up ~390 CSS px wide = 1170 DEVICE px, so RES 3 (810) was upscaled
// 1.44x by the browser and 4 gets us to 1080, near 1:1 — but a flat 4 made every 2x phone paint
// nearly double the pixels it can show, on exactly the hardware least able to afford it. The same
// near-1:1 logic per tier: dpr 3 -> 4, dpr 2 -> 3, dpr 1 -> 2.
// THIS IS THE FIRST KNOB TO TURN DOWN if the phone ever stutters: it is the whole fill-rate budget.
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;
// 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 };
}
// 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.
@@ -286,17 +272,128 @@ export function buildCrowd() {
});
}
// ---------------------------------------------------------------- backdrop ----
// THE STATIC SCENE, PRE-COMPOSED. Stands, ads, rot tint, out area, court, lines and the run-off
// apron are pixel-identical frame to frame except while a palette lerps (the 6 s transition, the
// duel's 0.8 s fade) — and repainting all ~40 draw ops per frame was the biggest steady-state cost
// after raw resolution. One offscreen canvas, rebuilt only when the cache key actually moves, blit
// as a single drawImage. The crowd's clap ripple stays LIVE, overlaid on top while cheering.
// Oversized by 8 px a side for the same reason the old full-frame clear was: a camera shake must
// never expose an edge.
let bg = null, bgKey = '';
function paintBackdrop(c, pal, pt) {
const line = (x1, z1, x2, z2, w = 1) => {
const a = proj(x1, 0, z1), b = proj(x2, 0, z2);
c.strokeStyle = UI.cream; c.lineWidth = w;
c.beginPath(); c.moveTo(a.x, a.y); c.lineTo(b.x, b.y); c.stroke();
};
const quadXZ = (x1, z1, x2, z2, color) => { // filled court-plane rect (screen trapezoid)
const a = proj(x1, 0, z1), b = proj(x2, 0, z1), d = proj(x2, 0, z2), e = proj(x1, 0, z2);
c.fillStyle = color; c.beginPath();
c.moveTo(a.x, a.y); c.lineTo(b.x, b.y); c.lineTo(d.x, d.y); c.lineTo(e.x, e.y); c.fill();
};
c.fillStyle = pal.out;
c.fillRect(-8, -8, W + 16, H + 16);
// stands. Seated is one uniform stretch of the sheet, so it is ONE drawImage here — the
// per-column split only ever existed for the clap ripple, which drawBackdrop overlays live.
const standsBottom = proj(0, 0, -6).y, crowdH = standsBottom - AD_H + 2;
const crowdImg = SPR[court.crowd] || SPR['x_crowd'];
if (crowdImg) c.drawImage(crowdImg, 0, 0, W, crowdH + 2);
else { // fallback until the image loads: the old tier stripes + dots (static — boot gates play anyway)
c.fillStyle = pal.stands; c.fillRect(0, 0, W, standsBottom);
for (let r = 0; r < 16; r++) { c.fillStyle = r % 2 ? pal.tierA : pal.tierB; c.fillRect(0, 6 + r * 5.5, W, 5.5); }
for (const d of crowd.dots) { c.fillStyle = d.c; c.fillRect(d.x | 0, d.y | 0, 2, 2); }
}
// courtside advertising: tiled sponsor panels at native aspect (never half-cut text)
const ads = SPR[court.ads] || SPR['x_ads'];
if (ads) {
const tw = ads.width * (AD_H / ads.height);
for (let x = 0; x < W; x += tw) c.drawImage(ads, x, standsBottom - AD_H, tw, AD_H);
} else { c.fillStyle = pal.board; c.fillRect(0, standsBottom - AD_H, W, AD_H); }
// the rot dims the stadium: one tint over crowd + boards, fading home during duels. 0.26, not
// 0.5: on an already-dark crowd sheet 0.5 buried the stands; 0.26 still reads as "the lights
// went wrong".
if (pt > 0) { c.fillStyle = `rgba(12,8,20,${0.26 * pt})`; c.fillRect(0, 0, W, standsBottom); }
// ground + court (Super Tennis lawn); everything outside the doubles court is OUT
const dw = COURT.dblW / 2;
quadXZ(-14, -6, 14, 30, pal.out); // out area. NEVER let z approach
// TUNE.camZ (48): proj() divides by (camZ - z), so at z >= camZ the scale blows up and then goes
// NEGATIVE, and the quad's far edge folds back above the screen as an inverted triangle. The
// full-frame clear above is what covers the bottom of the screen; this quad does not need to.
quadXZ(-dw - 0.15, -0.15, dw + 0.15, COURT.L + 0.15, court.border); // perimeter border line
quadXZ(-dw, 0, dw, COURT.L, pal.lawn); // playing surface (doubles court)
// mow stripes: grass only. Clay and acrylic are laid flat, and banding them was the one thing
// that kept a recoloured court reading as grass however far the palette moved.
if (court.stripes)
for (let z = 0; z < COURT.L; z += 2.97) quadXZ(-dw, z, dw, Math.min(z + 1.49, COURT.L), pal.stripe);
// lines: baselines, doubles + singles sidelines, service box
line(-dw, 0, dw, 0, 2); line(-dw, COURT.L, dw, COURT.L, 2);
line(-dw, 0, -dw, COURT.L, 1.5); line(dw, 0, dw, COURT.L, 1.5);
line(-COURT.W / 2, 0, -COURT.W / 2, COURT.L, 1.5); line(COURT.W / 2, 0, COURT.W / 2, COURT.L, 1.5);
line(-COURT.W / 2, COURT.svcFar, COURT.W / 2, COURT.svcFar, 1);
line(-COURT.W / 2, COURT.svcNear, COURT.W / 2, COURT.svcNear, 1);
line(0, COURT.svcFar, 0, COURT.svcNear, 1);
line(0, 0, 0, 0.35, 1); line(0, COURT.L - 0.35, 0, COURT.L, 1); // center marks
// The run-off apron below the near baseline: light falling off toward you, which gives the band
// depth, frames the rack, and darkens exactly the area the thumb covers anyway. Starts at alpha 0
// on the baseline, so there is no seam. NOT a near backstop — you are looking OVER that, which is
// why a broadcast frame never shows one either.
const top = proj(0, 0, COURT.L).y;
if (top < H) { // landscape has no apron at all — the baseline projects off the bottom
const g = c.createLinearGradient(0, top, 0, H);
g.addColorStop(0, 'rgba(6,10,6,0)');
g.addColorStop(1, 'rgba(6,10,6,0.72)');
c.fillStyle = g;
c.fillRect(0, top, W, H - top);
}
}
// blit the cached backdrop (rebuilding if the key moved), then overlay the live clap ripple.
// Returns standsBottom for the overlays render() draws above the stands.
function drawBackdrop(pal, pt) {
const crowdImg = SPR[court.crowd] || SPR['x_crowd'];
// the key: everything paintBackdrop's output depends on. pt quantised to 1/100 — hexLerp rounds
// to whole rgb anyway, so the steps are invisible; the aim-camera terms are 0 unless the two
// aimCam knobs are actually in use, so an ordinary pull never invalidates the cache.
const key = [W, H, court.id, pt.toFixed(2), !!crowdImg, !!(SPR[court.ads] || SPR['x_ads']),
TUNE.horizon, TUNE.camZ, TUNE.camY, TUNE.camF,
(aimCam * TUNE.aimCamY).toFixed(2), (aimCam * TUNE.aimCamZ).toFixed(2)].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 ----
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) {
@@ -1228,81 +1325,11 @@ export function render() {
}
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
pageBackground(pal.out); // the page behind the canvas matches, for the rounded display corners
// stands, ads, rot tint, court, lines and the run-off, as one cached blit (see paintBackdrop);
// the clap ripple is overlaid live inside. The ad strip sits flush on the court's back edge (the
// out-area quad reaches z=-6), which keeps the boards clear of the lawn in BOTH orientations.
const standsBottom = drawBackdrop(pal, pt);
// gore decals: what remains of the visiting team (flat ground layer, then far-side mounds)
for (const d of gore) {