The pulse was drawStatic being told a height instead of reading one. _slice.py common-scales a sheet, so a crouched frame is genuinely shorter — 211 px against its neighbours' 224 — and forcing every frame to the same on-screen height stretched it back up, so the body swelled and shrank once per stride. Worst on the brute because it is the biggest and slowest. The rule already existed for the wound stages; it now applies to every frame, once, which also settles the shamblers and the steward (their cycles vary by up to 9%). A chapter boss now dies in its own beat instead of under the card announcing it: the world drops to a third speed for 1.2 s, the screen kicks, a shockwave ring blows out through 90 extra particles and six stains, and the CHAPTER DOWN card holds off until the pieces have landed — about 1.4 s, measured. Cards can hold off in general now (`wait`). First cut only delayed the card's countdown while still drawing it, which is worse than no delay: caught it in a screenshot before it shipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
85 lines
5.5 KiB
JavaScript
85 lines
5.5 KiB
JavaScript
// Splatter Tennis — boot, main loop, intro overlay, and the window.T test hooks.
|
|
// World units: meters. z axis = court depth, 0 at FAR baseline, 23.77 at NEAR baseline.
|
|
// Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update)
|
|
// render (camera/drawing) · input (keys/touch) · audio (sfx/umpire) · panel (T tuning UI)
|
|
|
|
import { TUNE, COURT, UMP_H, UMP_X, EATEN_RESTART } from './config.js';
|
|
import { P, O, ball, score, G, setupPoint } from './state.js';
|
|
import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight } from './gameplay.js';
|
|
import { fitViewport, render, proj, RES } from './render.js';
|
|
import { voice, ambient } from './audio.js';
|
|
import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, explode, hurtPlayer, killPlayer } from './zombies.js';
|
|
import { cv } from './dom.js';
|
|
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)
|
|
if (TUNE.fps && now - last < 1000 / TUNE.fps - 2) return; // frame cap (battery): skip RAF ticks until it's our beat
|
|
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
|
|
// always the same length in seconds however far the world has been slowed.
|
|
if (G.slowT > 0) { G.slowT -= dt; dt *= TUNE.bossSlowMo; }
|
|
update(dt); render();
|
|
}
|
|
|
|
fitViewport();
|
|
// iOS PWA cold start. Launched from the homescreen, WebKit resolves the viewport AND every env()
|
|
// safe-area inset once, against a provisional geometry, and never revisits them until the geometry
|
|
// is "exercised" — which is why the game only filled the screen after a rotate and back, and why
|
|
// waiting for an event could never work: nothing fires. Toggling viewport-fit off and on is the
|
|
// known WebKit workaround: it forces that recalculation without the user rotating anything.
|
|
// Standalone only, because that is the only mode with the bug.
|
|
if (navigator.standalone || matchMedia('(display-mode: standalone)').matches) {
|
|
const meta = document.querySelector('meta[name=viewport]'), real = meta.content;
|
|
meta.content = real.replace('viewport-fit=cover', 'viewport-fit=auto');
|
|
requestAnimationFrame(() => { meta.content = real; requestAnimationFrame(fitViewport); });
|
|
}
|
|
setupPoint();
|
|
// warm the pixel HUD font so the canvas never paints a system-serif fallback frame
|
|
if (document.fonts) { document.fonts.load('700 8px Silkscreen'); document.fonts.load('400 8px Silkscreen'); }
|
|
|
|
// intro overlay: first tap dismisses it, unlocks audio (iOS needs the gesture) and starts play
|
|
const intro = document.getElementById('intro');
|
|
function dismissIntro() {
|
|
if (!intro.parentNode) return;
|
|
intro.remove(); ambient(); voice('quiet_please'); // low atmosphere bed starts with the first gesture
|
|
}
|
|
intro.addEventListener('pointerdown', e => { e.preventDefault(); dismissIntro(); });
|
|
// which build am I looking at? Push = deploy and the browser cannot see git, so the served file's
|
|
// own timestamp IS the version marker — it changes on every deploy, with nothing to remember to bump.
|
|
// Match it against `git log -1 --format=%cd` when a phone looks suspiciously out of date.
|
|
fetch('main.js', { method: 'HEAD' })
|
|
.then(r => { const t = r.headers.get('last-modified'); if (!t) return;
|
|
document.getElementById('build').textContent = 'BUILD ' + new Date(t).toLocaleString('en-GB').replace(',', ''); })
|
|
.catch(() => {}); // no header, no line — never a broken start screen
|
|
addEventListener('keydown', () => dismissIntro(), { once: true });
|
|
|
|
// eaten: any tap or key restarts — but only once the death animation has played out, so a
|
|
// panic tap during the gore burst can't skip it. A modal owns its own gesture (see input.js).
|
|
const maybeRestart = () => { if (!G.modal && G.mode === 'eaten' && G.eatenT > EATEN_RESTART) location.reload(); }; // ponytail: reload = honest full reset
|
|
cv.addEventListener('pointerdown', maybeRestart);
|
|
addEventListener('keydown', maybeRestart);
|
|
|
|
// hidden shortcut: tap the umpire three times to summon zombie mode early
|
|
let umpTaps = [];
|
|
cv.addEventListener('pointerdown', e => {
|
|
const r = cv.getBoundingClientRect();
|
|
const x = (e.clientX - r.left) / r.width * (cv.width / RES); // logical coords
|
|
const y = (e.clientY - r.top) / r.height * (cv.height / RES);
|
|
if (x >= 6 && x <= 138 && y >= 6 && y <= 36) { G.logShow = !G.logShow; return; } // scoreboard tap = miss-log overlay
|
|
const u = proj(UMP_X, 0, COURT.netZ);
|
|
if (Math.abs(x - u.x) > 16 || y > u.y + 6 || y < u.y - UMP_H * u.s - 8) return;
|
|
const now = performance.now();
|
|
umpTaps = umpTaps.filter(t => now - t < 1600);
|
|
umpTaps.push(now);
|
|
if (umpTaps.length >= 3) { umpTaps = []; forceZombies(); }
|
|
});
|
|
|
|
requestAnimationFrame(frame);
|
|
|
|
// exposed for in-browser sanity checks and simulated playtests
|
|
window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight,
|
|
step: dt => update(dt), render, press: keyPressed,
|
|
get phase() { return G.phase; }, set phase(v) { G.phase = v; } };
|