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:
1 parent
1b97905217
commit
70882d2dd4
4 files changed
+23
-9
No files matched your search
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
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 = 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 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);
|
||||
ctx.fillStyle = b.color;
|
||||
ctx.beginPath(); ctx.ellipse(sx, sy, r, Math.max(1, r * 0.72), 0, 0, 7); ctx.fill();
|
||||
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')
|
||||
|
||||
+3
-2
@@ -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); }
|
||||
|
||||
Reference in new issue
Block a user