diff --git a/audio.js b/audio.js index affe1e3..ec32698 100644 --- a/audio.js +++ b/audio.js @@ -147,11 +147,22 @@ export function say(text, delay = 0) { // fallback when a recorded umpire line i } catch (e) {} } -function noiseBuf(ac, len) { - const b = ac.createBuffer(1, ac.sampleRate * len, ac.sampleRate), d = b.getChannelData(0); - for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; - return b; +// ONE noise buffer, shared. Every swish/pock/thud/splat/clap used to fill a fresh Float32 buffer +// (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; + 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. // 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) { try { 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); bp.frequency.exponentialRampToValueAtTime(f1, t + dur * 0.75); bp.Q.value = 1.5; const g = ac.createGain(); g.gain.setValueAtTime(0.001, t); g.gain.exponentialRampToValueAtTime(vol, t + dur * 0.3); 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) {} } 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 try { 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 g = ac.createGain(); 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 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); @@ -197,13 +208,13 @@ export function thud(vol = 0.6, size = 1) { const dur = 0.15 * k; // 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. - 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; bp.frequency.setValueAtTime(780 / k, t); bp.frequency.exponentialRampToValueAtTime(190 / k, t + dur); const g = ac.createGain(); 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 const o = ac.createOscillator(), og = ac.createGain(); 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); // 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. - 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 spg = ac.createGain(); spg.gain.setValueAtTime(vol * 0.18, t + d); 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) {} } @@ -231,26 +242,26 @@ export function splat(vol = 0.7, size = 1) { const dur = 0.22 * k; // 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. - 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; bp.frequency.setValueAtTime(1100 / k, t); bp.frequency.exponentialRampToValueAtTime(130 / k, t + dur * 0.85); const g = ac.createGain(); 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 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); 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 // 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 spg = ac.createGain(); spg.gain.setValueAtTime(0.001, t + d); spg.gain.exponentialRampToValueAtTime(vol * 0.3, t + d + 0.02); 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) {} } @@ -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); 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); - 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'; lp.frequency.setValueAtTime(2200, t); lp.frequency.exponentialRampToValueAtTime(200, t + 0.35); const g = ac.createGain(); 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) {} } @@ -276,11 +287,11 @@ export function boom(vol = 0.9) { // bomb ball — sub-bass thump under a filter export function zap(vol = 0.5) { try { 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 ng = ac.createGain(); 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 // 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; @@ -344,13 +355,13 @@ export function applause(seconds = 2.4, vol = 0.5) { // ponytail: synthesized cl for (let i = 0; i < n; i++) { const t = t0 + Math.random() * seconds; 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'; bp.frequency.value = 1000 + Math.random() * 1600; bp.Q.value = 2.5; const g = ac.createGain(); g.gain.setValueAtTime(vol * env * (0.3 + Math.random() * 0.7), t); 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) {} } diff --git a/input.js b/input.js index 59d286d..bcbd6b0 100644 --- a/input.js +++ b/input.js @@ -77,7 +77,9 @@ cv.addEventListener('touchstart', e => { if (slot >= 0) { selectWeapon(slot); continue; } } 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 // 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 }; @@ -118,7 +120,7 @@ cv.addEventListener('touchmove', e => { } else G.aim = { fx: 0, fy: 0, mag: 0 }; continue; } - const r = cv.getBoundingClientRect(); + const r = stick.r; steerPX = (t.clientX - r.left) / r.width * 2 - 1; touchAxis.x = Math.abs(dx) > DEAD ? Math.sign(dx) : 0; touchAxis.z = Math.abs(dy) > DEAD ? Math.sign(dy) : 0; diff --git a/main.js b/main.js index 9a7038a..3095d5e 100644 --- a/main.js +++ b/main.js @@ -21,6 +21,10 @@ let paused = false; function frame(now) { 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 + // 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; } 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 diff --git a/render.js b/render.js index d2e716a..22474c0 100644 --- a/render.js +++ b/render.js @@ -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/.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), 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) {