Runner is glass; battery pass (30fps cap, cached piles, GC fixes)

Runner counter (no new weapon needed — one you don't hold when it shrieks
can't save you): the sprinter is now a GLASS CANNON — any ball contact at
any quality bursts it (main ball and triple-serve side balls; melee and
pellets already killed it). The hold-to-aim auto-lock targets the most
advanced zombie = the charging runner, so touch-and-release is the panic
button. Plus runnerVMax 4.2: the late-game speed ramp is capped so runners
stay humanly reactable.

Battery (iPhone drain):
- TUNE.fps = 30 (T-panel tunable, 0/60 = uncapped): frame-skip cap in the
  RAF loop — halves paint work on a 810x1440 canvas, the dominant cost.
  dt clamp raised 0.033 -> 0.04 so 30fps doesn't run 1% slow-motion.
- Gore piles pre-rendered to per-pile cached canvases (rebuilt on growth /
  orientation change): 1 drawImage per pile per frame instead of ~26
  ellipse paths — late-game courts had ~800 path fills per frame.
- matchMedia('(pointer: coarse)') hoisted out of the render loop (was
  allocating a MediaQueryList every pretoss frame).

Verified: weak (pow .05) ball kills a runner; max-ramp runner v = 4.2
exactly; piles render from cache (screenshot, pl.cv set); frame-cap
arithmetic yields clean every-other-tick at 60Hz RAF (RAF itself is paused
in this pane — logic deterministic); tennis serve benchmark 30/36 across
runs (flub-band; diff is provably tennis-neutral); no console errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-07-12 18:04:51 +02:00
1 parent 1b97905217
commit 70882d2dd4
4 files changed
+25 -11

No files matched your search

+18 -8
View File
@@ -33,6 +33,7 @@ function palette() {
}
// ---------------------------------------------------------------- camera ----
const IS_TOUCH = matchMedia('(pointer: coarse)').matches; // hoisted: a matchMedia call per frame churns GC
export const RES = 3; // physical pixels per logical pixel — 3x so phone screens downscale (crisp) instead of upscaling (soft)
export let W = 480, H = 270;
@@ -181,15 +182,24 @@ function buildBlobs(pl) {
pl.builtSize = pl.size;
}
function drawGorePile(pl) {
if (!pl.blobs || pl.size > pl.builtSize) buildBlobs(pl); // rebuild only on growth, never on the mop-shrink
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;
for (const b of pl.blobs) {
const sx = base.x + b.nx * sz * 0.28 * s;
const sy = base.y + 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);
ctx.fillStyle = b.color;
ctx.beginPath(); ctx.ellipse(sx, sy, r, Math.max(1, r * 0.72), 0, 0, 7); ctx.fill();
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))
@@ -418,7 +428,7 @@ export function render() {
}
if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !(G.mgT > 0)) {
ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace';
const touch = matchMedia('(pointer: coarse)').matches;
const touch = IS_TOUCH;
const stock = G.bombs ? 'BOMB' : G.triples ? 'TRIPLE' : G.flames ? 'FIRE' : null;
const txt = G.mode === 'splatter'
? (G.aim ? (stock ? `RELEASE FIRES THE ${stock} · DRAG BACK TO CANCEL` : 'RELEASE TO SERVE · DRAG BACK TO CANCEL')