diff --git a/config.js b/config.js index 9e7785c..98a4351 100644 --- a/config.js +++ b/config.js @@ -39,7 +39,9 @@ export const TUNE = { hordeRate: 2.5, // zombies spawned per second during a horde mgRate: 6, // machine-gun pellets fired per second during a horde mgPickupLen: 8, // s the machine-gun tin (4th delivery) auto-fires outside hordes + fps: 30, // frame cap — pixel art reads fine at 30 and it halves battery/heat; set 60 to compare runnerSpeed: 3.2, // runner ('f') speed multiplier over the shamble + runnerVMax: 4.2, // hard runner speed cap — the late-game ramp must stay humanly reactable bruteHp: 2, // splat-quality hits (or pellets) a brute ('g') soaks before it dies shieldDown: 1.5, // s a shield zombie's ('s') chair stays dropped after a block — the kill window meleeRange: 1.7, // m — a racket swish cleaves shamblers this close to you diff --git a/main.js b/main.js index d68308f..aee52fb 100644 --- a/main.js +++ b/main.js @@ -15,7 +15,8 @@ import './input.js'; let last = performance.now(); function frame(now) { requestAnimationFrame(frame); // schedule FIRST: one bad frame must never kill the loop (a thrown error froze the game for good) - const dt = Math.min(0.033, (now - last) / 1000); last = now; + if (TUNE.fps && now - last < 1000 / TUNE.fps - 2) return; // frame cap (battery): skip RAF ticks until it's our beat + const dt = Math.min(0.04, (now - last) / 1000); last = now; update(dt); render(); } diff --git a/render.js b/render.js index 4d8df19..d9d5945 100644 --- a/render.js +++ b/render.js @@ -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') diff --git a/zombies.js b/zombies.js index 2202bab..d849b51 100644 --- a/zombies.js +++ b/zombies.js @@ -20,7 +20,7 @@ let spawnT = 1.5, moanT = 3; export function spawnZombie(x, z, variant) { const ramp = 1 + Math.min(1.5, G.splatT / 70); // they get faster the longer you survive const zb = { x, z, v: TUNE.zSpeed * ramp * (0.8 + Math.random() * 0.4), walkT: Math.random() * 9, variant }; - if (variant === 'f') { zb.v *= TUNE.runnerSpeed; shriek(); } // the shriek is the fairness contract + if (variant === 'f') { zb.v = Math.min(TUNE.runnerVMax, zb.v * TUNE.runnerSpeed); shriek(); } // shriek = the fairness contract; capped so late runners stay reactable if (variant === 'g') { zb.v *= 0.55; zb.hp = TUNE.bruteHp; } // slow, but soaks hits if (variant === 's') zb.shieldT = 0; // chair up; > 0 = seconds of stagger left zombies.push(zb); @@ -243,6 +243,7 @@ export function updateZombies(dt) { ball.vx *= 0.4; ball.vz *= -0.2; ball.lastHit = 'X'; // ball caroms off the chair, spent continue; } + if (zb.variant === 'f') { splatter(i); continue; } // the sprinter is GLASS — any touch, any quality, it bursts // chair-down shield zombies (and anything hit from behind) obey the normal rules: if ((ball.pow || 0) >= TUNE.zSplatQ) { splatter(i); continue; } zb.z -= TUNE.zPushback; // weak sitters only stagger them @@ -280,7 +281,7 @@ export function updateZombies(dt) { if (!zb || zb.variant === 'r') continue; // side balls pass the boss; !zb: kills can sweep mid-loop if (Math.hypot(zb.x - e.x, zb.z - e.z) < (zb.variant === 'g' ? 0.75 : 0.55)) { if (zb.variant === 's' && e.vz < 0 && !(zb.shieldT > 0)) { zb.shieldT = TUNE.shieldDown; pock(0.9, 1800); } // chair blocks these too - else if (e.pow >= TUNE.zSplatQ) { + else if (e.pow >= TUNE.zSplatQ || zb.variant === 'f') { if (zb.variant === 'g' && --zb.hp > 0) splat(0.4); // wounds the brute else killZombie(j, !!G.horde); } else { zb.z -= TUNE.zPushback; pock(0.5, 300); }