The rack had five hardcoded slots where the index WAS the weapon, spelled out in
five places: SLOTS, slotStock, WDESC, drawRack's three parallel arrays,
deliverTin's w+1, and bare wsel === 1|2|3 / G.weapon === 4 tests across five
files. config.WEAPONS is the one table now, G.rack holds up to four {id, n} in
the order you GATHERED them, and slot 0 stays BALL. Everything reads through
slotId/slotStock/slotEntry, so nothing else knows how the rack is stored.
A fifth weapon is always allowed, per Oliver: it rides slot 0 as G.temp until
spent, then slot 0 goes back to the infinite ball. A barrel arms on the spot and
is spent quickly, so refusing it would mean a barrel you shot did nothing. And a
spent weapon LEAVES its slot -- with gathered slots, an exhausted weapon holding
one is a quarter of the rack you can never pick up into. (The old rack kept its
empties dimmed, which was right when a slot meant "BOMB goes here".)
Four new weapons, and they are all the ONE existing pellet loop. firePellet(w)
stamps the WEAPONS row onto the round -- r, dmg, hp, pierce, boom, ignite, col,
speed, life -- so the beacon cannon, flamethrower, laser and rocket needed no new
array and no new update; the break after the first body was the only thing making
a pellet single-target. All four go through the same isBoss branch, which is why
none can silently no-op on a boss the way three of five once did. Measured:
gun/beacon/laser/rocket reach past 16 m, the flamer is a ~6 m cone that ignites.
G.mgT stays the mode timer for all of them, so the eighteen !(G.mgT > 0) guards
across five files did not have to change. kind:'burst' auto-fires at its own rate
for len seconds; kind:'shot' holds the same braced stance and fires one per tap.
The barrels reuse the ball machine's shape: a rolling, shootable non-body. All
three of the registrations that fail silently are done -- blocksClear (loot must
not gate the hand-clear), the contact guard (furniture cannot eat you), and
hitR/GORE_MUL/zombieH. It scores nothing.
The pink had to become procedural. The chroma key deletes every pixel where red
and blue both beat green by 40, so a magenta drum and a magenta laser pistol both
came back as hollow outlines -- 19% and 15% solid against 50-56% for the icons in
other colours. The drum is grey steel with hazard chevrons and every bit of its
pink is the halo drawBarrel paints; the laser went electric blue. Both are solid
now (92% and 58%), and the rule is written down in HANDOFF.
tinTriples 3 -> 10 as asked, and the four dead TUNE ammo constants are gone --
nothing read them once the table landed, which is a panel value you can tune all
day for no effect.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
190 lines
11 KiB
JavaScript
190 lines
11 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, COURTS, UMP_H, UMP_X, EATEN_RESTART } from './config.js';
|
|
import { P, O, ball, score, G, setupPoint, selectCourt } from './state.js';
|
|
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight } from './gameplay.js';
|
|
import { fitViewport, render, proj, RES } from './render.js';
|
|
import { voice, ambient, menuMusic, menuDuck } from './audio.js';
|
|
import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, explode, hurtPlayer, killPlayer , spawnBarrel } 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'); }
|
|
|
|
// The overlay is a three-stage machine: splash -> {courts, tutorial}. The title screen IS the menu —
|
|
// a separate menu stage behind it showed the same logo on the same backdrop and read as the title
|
|
// screen failing to advance. One overlay throughout, swapped by data-stage, so the court never
|
|
// flashes between screens; HIDDEN during play rather than removed, so a restart puts it back.
|
|
const intro = document.getElementById('intro');
|
|
const menuUp = () => !intro.hidden;
|
|
const lastCourt = (() => { try { return localStorage.getItem('court'); } catch { return null; } })();
|
|
let picked = lastCourt || COURTS[0].id;
|
|
const go = stage => { intro.dataset.stage = stage; menuMusic(); }; // every stage change is a gesture, so it is also a safe place to (re)start the theme
|
|
|
|
// --- court rows, built from the table
|
|
const courtList = document.getElementById('courtList');
|
|
for (const c of COURTS) {
|
|
const b = document.createElement('button');
|
|
b.className = 'court' + (c.id === picked ? ' on' : ''); // your last court comes up pre-picked
|
|
b.innerHTML = `<img src="assets/${c.tile}.png?v=81" alt=""><span><b>${c.name}</b><i>${c.sub}</i></span>`;
|
|
// Tapping a tile SELECTS it and nothing more. It used to start the match on the spot, which gave
|
|
// you no way to look at the three of them and no way back out of the screen.
|
|
b.addEventListener('click', () => {
|
|
picked = c.id;
|
|
for (const el of courtList.children) el.classList.toggle('on', el === b);
|
|
});
|
|
courtList.appendChild(b);
|
|
}
|
|
|
|
function start() {
|
|
if (!menuUp()) return;
|
|
selectCourt(picked);
|
|
menuDuck(true); // the theme belongs to the menus — hand the court over to the ambience
|
|
intro.hidden = true; ambient(); voice('quiet_please'); // low atmosphere bed starts with the first gesture
|
|
}
|
|
document.getElementById('mStart').addEventListener('click', () => go('courts'));
|
|
document.getElementById('mTut').addEventListener('click', () => { tutPage = 0; drawTut(); go('tutorial'); });
|
|
document.getElementById('cStart').addEventListener('click', start);
|
|
document.getElementById('cBack').addEventListener('click', () => go('splash'));
|
|
|
|
// --- the tutorial, narrated by Marvin the mop boy. Pure data: a page is a Marvin pose, an optional
|
|
// picture already in assets/, and his lines.
|
|
const TUT = [
|
|
{ pose: 1, say: ["I'm <em>MARVIN</em>. I mop the court.",
|
|
"Lately there is a great deal more to mop.",
|
|
"Let me show you how this goes."] },
|
|
{ pose: 2, shot: 'tut_serve', say: ["First it is just <em>TENNIS</em>.",
|
|
"<em>TAP</em> once to toss, tap again to serve.",
|
|
"<em>FLICK</em> toward where you want it.",
|
|
"<em>DRAG</em> to run — though he mostly runs himself."] },
|
|
{ pose: 4, say: ["Then, at some point, the other fellow...",
|
|
"stops being the other fellow.",
|
|
"Nobody stops the match. Nobody ever does."] },
|
|
{ pose: 2, shot: 'tut_aim', say: ["Now you do not rally. You <em>AIM</em>.",
|
|
"<em>PULL BACK</em> from your man and let go.",
|
|
"The further you pull, the harder it lands.",
|
|
"A soft one only makes them cross."] },
|
|
{ pose: 2, shot: 'tut_rack', say: ["I bring tins out to the rack down there.",
|
|
"<em>BOMBS</em>, <em>TRIPLES</em>, <em>FIRE</em>, and the <em>GUN</em>.",
|
|
"Tap the rack to load one, then serve it.",
|
|
"Do try not to hit me with them."] },
|
|
{ pose: 3, shot: 'tut_duel', say: ["Sometimes the umpire calls order back.",
|
|
"One point, tennis rules, against one of them.",
|
|
"The lines are live again. Do not miss."] },
|
|
{ pose: 1, say: ["That is the lot. Mind the deep ones.",
|
|
"And if it all goes wrong —",
|
|
"I will be along with the mop."] },
|
|
];
|
|
let tutPage = 0;
|
|
const tSay = document.getElementById('tSay'), tMarvin = document.getElementById('tMarvin');
|
|
const tShot = document.getElementById('tShot');
|
|
const tDots = document.getElementById('tDots'), tNext = document.getElementById('tNext');
|
|
function drawTut() {
|
|
const p = TUT[tutPage];
|
|
tMarvin.src = `assets/marvin${p.pose}.png?v=92`;
|
|
tShot.style.display = p.shot ? 'block' : 'none';
|
|
if (p.shot) tShot.src = `assets/${p.shot}.png?v=93`;
|
|
tSay.innerHTML = p.say.map(l => `<p>${l}</p>`).join('');
|
|
tDots.textContent = TUT.map((_, i) => i === tutPage ? '●' : '·').join('');
|
|
tNext.textContent = tutPage === TUT.length - 1 ? 'DONE' : 'NEXT';
|
|
}
|
|
tNext.addEventListener('click', () => {
|
|
if (tutPage < TUT.length - 1) { tutPage++; drawTut(); } else go('splash');
|
|
});
|
|
document.getElementById('tBack').addEventListener('click', () => {
|
|
if (tutPage > 0) { tutPage--; drawTut(); } else go('splash');
|
|
});
|
|
|
|
// THE FIRST TAP ANYWHERE ON THE OVERLAY STARTS THE THEME, and does nothing else — the screens have
|
|
// real buttons now. This is the gesture the autoplay policy is waiting for, which is why the theme
|
|
// is not started at boot. menuMusic itself is a no-op once the bed exists (loopBed's beds.has
|
|
// guard), so coming back from a run picks up the track already playing rather than restarting it.
|
|
intro.addEventListener('pointerdown', () => menuMusic());
|
|
// 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
|
|
// Keyboard, per stage. On the splash Enter/Space starts, T opens the tutorial; on the court screen
|
|
// 1..N picks and Enter starts; Escape backs out to the splash.
|
|
addEventListener('keydown', e => {
|
|
if (!menuUp()) return;
|
|
menuMusic(); // a key is a gesture too — unlock the theme on a desktop that never taps
|
|
const stage = intro.dataset.stage;
|
|
if (e.key === 'Escape') return go('splash');
|
|
if (stage === 'splash') {
|
|
if (e.key === 'Enter' || e.key === ' ') go('courts');
|
|
else if (e.key === 't' || e.key === 'T') { tutPage = 0; drawTut(); go('tutorial'); }
|
|
return;
|
|
}
|
|
if (stage === 'tutorial') { if (e.key === 'Enter' || e.key === ' ') tNext.click(); return; }
|
|
const n = parseInt(e.key, 10);
|
|
if (n >= 1 && n <= COURTS.length) courtList.children[n - 1].click();
|
|
else if (e.key === 'Enter' || e.key === ' ') start();
|
|
});
|
|
|
|
// eaten: any tap or key returns to the title — 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).
|
|
// NOT a page reload any more: that reset everything honestly but also tore down the AudioContext, so
|
|
// the theme restarted from silence. restartRun puts every bag back from a pristine copy, and the
|
|
// bed that has been playing under the death summary simply carries on into the title screen.
|
|
const maybeRestart = () => {
|
|
if (G.modal || G.mode !== 'eaten' || G.eatenT <= EATEN_RESTART) return;
|
|
restartRun();
|
|
intro.hidden = false; go('splash');
|
|
};
|
|
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, spawnBarrel, grantWeapon, 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; } };
|