Gore opens in the gore, the theme comes back, and every mode gets a board
Splatter gore no longer makes you win a game of tennis first — the mode you pick is the mode you get, with the man you came to play already turned. The transition itself is untouched and still reachable; it just isn't a toll booth. The menu theme could only ever be started, never brought back, so the first run of a session silenced it for good while the court's ambience followed you into the menu. Starting a bed now un-ducks it too. Three world boards instead of one: kills, opponents felled, and games taken off the CPU, each with its own name prompt and its own row per player. ...and the gore economy stops paying a barrel for every game won past game 16, while the shamblers, the mix and the ball machines keep escalating past the point they all used to stop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9cc3ac54b3
commit
3cf3e029a4
11 files changed
+440
-88
No files matched your search
+25
-3
@@ -161,11 +161,14 @@ function loopBed(file, lowcut, vol, loopEnd = 0) {
|
||||
})
|
||||
.catch(() => beds.delete(file)); // failed start (decode/network) can retry next call
|
||||
}
|
||||
export const ambient = () => loopBed('tennis-ambient-loop.wav', 500, 0.035); // whole-scene atmosphere, from first tap
|
||||
// whole-scene atmosphere, from the first tap. Un-ducks as well as starts, for the same reason
|
||||
// menuMusic does: loopBed no-ops once the bed exists, so a SECOND run would have opened over the
|
||||
// silence the menu left it in.
|
||||
export const ambient = () => { loopBed('tennis-ambient-loop.wav', 500, 0.035); courtDuck(false); };
|
||||
// ...and the splatter bed, same treatment: 5.9 MB of 24-bit WAV down to 287 KB. Its master is
|
||||
// 982597 frames at 48 kHz, which is the un-round 20.47077 s below — an AAC loop needs the SOURCE
|
||||
// length as loopEnd, not the decoder's padded one. See the note on menuMusic.
|
||||
export const zombieMusic = () => loopBed('zombie-mode.m4a', 800, 0.2, 20.47077);
|
||||
export const zombieMusic = () => { loopBed('zombie-mode.m4a', 800, 0.2, 20.47077); zombieDuck(false); };
|
||||
// Menu theme: every menu screen and the death summary. Barely highpassed — this one is MUSIC, not
|
||||
// an atmosphere bed, so the low end is the point. Started by the FIRST TAP (main.js `go`), never at
|
||||
// boot: a title screen that starts playing at you before you have touched it is rude, and on a phone
|
||||
@@ -175,7 +178,19 @@ export const zombieMusic = () => loopBed('zombie-mode.m4a', 800, 0.2, 20.47077);
|
||||
// — which is the loopEnd, because the decoded buffer is NOT (measured 48.0174 s in Chrome; see the
|
||||
// padding note in loopBed). Re-encode the file and you must re-measure that number:
|
||||
// fetch(f).then(r=>r.arrayBuffer()).then(a=>new AudioContext().decodeAudioData(a)).then(b=>b.duration)
|
||||
export const menuMusic = () => loopBed('background-music.m4a', 30, 0.65, 48);
|
||||
// THE MENU OWNS THE THEME, AND THE COURT'S BEDS OWN NOTHING WHILE IT IS UP. This is called from
|
||||
// every stage change, so it is the one place that can say "we are in the menus" — and it has to
|
||||
// SAY it rather than merely start the bed, because loopBed no-ops once the file is playing. That
|
||||
// was the bug (Oliver, 2026-08-15: "after some games, or returning to the main menu via the close
|
||||
// button, the background music does not play any more but you sometimes still hear the court
|
||||
// background noise"): start() ducked the theme to silence, nothing ever un-ducked it, and the
|
||||
// court's ambience — which nothing ducked either — carried on underneath the silent menu. Quitting
|
||||
// a run was the reliable way in, because the death summary is the only path that un-ducked.
|
||||
export const menuMusic = () => {
|
||||
loopBed('background-music.m4a', 30, 0.65, 48);
|
||||
menuDuck(false); // ...and BACK UP, whatever a run left it at
|
||||
courtDuck(true); // the court is not in the room any more
|
||||
};
|
||||
|
||||
// duck a bed to silence (out=true) or back to its base level over `secs` — the sudden-death duel mutes the music
|
||||
function fadeBed(file, out, secs = 0.7) {
|
||||
@@ -191,6 +206,13 @@ export const zombieDuck = out => fadeBed('zombie-mode.m4a', out); // key must ma
|
||||
// The menu theme is never stopped, only faded — the buffer is already decoded, so fading it back up
|
||||
// on the death screen costs nothing and can't miss its cue waiting on a re-fetch.
|
||||
export const menuDuck = out => fadeBed('background-music.m4a', out);
|
||||
// BOTH court beds as one gesture, because "leaving the court" is one event and the two of them
|
||||
// have to move together — the menu was silent AND noisy at the same time when only one did.
|
||||
const courtDuck = out => { fadeBed('tennis-ambient-loop.wav', out); zombieDuck(out); };
|
||||
// Test surface (test.js), same idea as net.js's netTest. `ducked` is the INTENT — the level a bed
|
||||
// is at, or will start at if it is still loading — and it is the whole of what the menu-silence bug
|
||||
// was: a flag nothing ever cleared. Nothing in the game reads this.
|
||||
export const bedTest = { ducked };
|
||||
|
||||
let sysVoice = null;
|
||||
function say(text, delay = 0) { // fallback when a recorded umpire line is missing
|
||||
|
||||
+28
-10
@@ -121,10 +121,17 @@ export const TUNE = {
|
||||
// ceiling after 105 seconds of splatter, i.e. during game 2 — so shamble speed
|
||||
// was never a progression lever at all. g12 matches the old top speed (1.75
|
||||
// m/s), and it climbs to 2.7 m/s by game 22.
|
||||
zSpeedMax: 2.9, // cap on that ramp (top speed = zSpeed * 3.9). runnerVMax still caps runners.
|
||||
// THE LATE GAME HAD STOPPED GETTING HARDER (Oliver, 2026-08-15: "it now gets too easy to just
|
||||
// play through — the splatter gore should be unbelievably hard towards the end"). Every ramp in
|
||||
// this block used to reach its ceiling by about game 25 and sit there, while the barrel economy
|
||||
// above kept climbing — so a run got RICHER after game 25 and never more dangerous. The body
|
||||
// COUNT still caps (zMaxCap is a readability ceiling on a 270 px screen and stays), but what
|
||||
// those bodies are and how fast they arrive now keeps climbing well past it.
|
||||
zSpeedMax: 3.6, // cap on that ramp (top speed = zSpeed * 4.6), reached ~game 28 instead of 22
|
||||
nastyPerGame: 0.03, // share of spawns drawn from the advanced roster, per finished game
|
||||
nastyMax: 0.75, // ...capped here (g14 matches the old 0.42, g25 hits the cap). The old mix was
|
||||
// frozen at exactly 0.42 from game 12 to the end of the run.
|
||||
nastyMax: 1.0, // ...and by game 34 there are NO ordinary shamblers left in the mix at all:
|
||||
// every body that walks out is off the advanced roster. That is the wall the
|
||||
// end of a run is supposed to be.
|
||||
hordeGrow: 0.2, // horde length AND spawn rate both scale by this per set already banked
|
||||
zHordeCap: 34, // hard body ceiling DURING a horde. A horde ignores the director cap on
|
||||
// purpose (that is the point of it), but hordeGrow means a late wave now tries
|
||||
@@ -140,7 +147,11 @@ export const TUNE = {
|
||||
// so it never crosses your baseline and never scores — it is a standing threat you choose when to
|
||||
// deal with, which is the target-priority decision the game was missing.
|
||||
machEvery: 4, // games before the first machine can appear, and games between them after
|
||||
machMax: 2, // machines allowed on court at once
|
||||
machMax: 2, // machines allowed on court at once...
|
||||
machMaxPer: 18, // ...plus one more for every this many games survived, up to machMaxCap. A third
|
||||
machMaxCap: 4, // and fourth gun is the one kind of late pressure that does NOT crowd the
|
||||
// ground: they hold position and shoot, so the court stays readable while the
|
||||
// number of things that can reach you goes up.
|
||||
machHp: 6, // splat-quality hits to wreck one. It is a steel cart, not a body — and it now
|
||||
// shows the damage on the way down (z_machw1 dented, z_machw2 split open)
|
||||
machRoll: 1.6, // m/s it trundles when it repositions. It CANNOT fire while rolling, which is
|
||||
@@ -427,11 +438,16 @@ export const TUNE = {
|
||||
dmgBeam: 8, // what it takes off a BOSS. An ordinary body is not damaged by the beam, it is
|
||||
// DELETED by it (see fireBeam); bosses "must suffer severely" but survive.
|
||||
// ---- the weapon barrel: the only way to get the four weapons that are not on the duel ladder
|
||||
barrelEvery: 3, // games between barrels being rolled on (like the ball machine's machEvery)
|
||||
barrelRamp: 8, // ...and the gap SHRINKS by one for every this many games finished, floored at
|
||||
// every game (g8 every 2, g16+ every game). Late runs live on the barrels: past
|
||||
// the point where every tin is capped, fast tapping was the only way left to
|
||||
// keep up, and "more inventory for strategic use" is the answer to that.
|
||||
// HALVED, AND FLOORED (Oliver, 2026-08-15: "in splatter gore it now gets too easy to just play
|
||||
// through, maybe we should reduce the barrels again — pretty early in the game every won game
|
||||
// gives a barrel"). He is right and the arithmetic says so: at 3/8 the gap hit ONE at game 16 and
|
||||
// stayed there, so the whole back half of a run handed you a weapon for every game you won, which
|
||||
// is the opposite of a difficulty curve. The ramp is still there — the late game does need more
|
||||
// inventory than the early one — it is just slower and it stops.
|
||||
barrelEvery: 5, // games between barrels being rolled on (like the ball machine's machEvery)
|
||||
barrelRamp: 14, // ...and the gap SHRINKS by one for every this many games finished
|
||||
barrelFloor: 3, // ...but never below this. g14 every 4, g28 every 3, and that is as good as it
|
||||
// ever gets — a barrel is a reward for surviving, never an income.
|
||||
barrelRoll: 2.6, // m/s it trundles across the far court — fast enough to have to be led
|
||||
barrelHp: 1, // anything pops it: ball, pellet, bomb, flame, swish. It is a target, not a body
|
||||
barrelH: 1.15, // drawn height (a body is 1.8)
|
||||
@@ -671,7 +687,9 @@ export const MODES = [
|
||||
{ id: 'classic', name: 'CLASSIC TENNIS', sub: 'REAL TENNIS · BEST OF THREE SETS', art: 'mode_classic' },
|
||||
{ id: 'zombie', name: 'ARCADE', sub: 'ONE GAME PER OPPONENT · BLOOD ONLY', art: 'mode_arcade' },
|
||||
{ id: 'cup', name: 'THE TOURNAMENT', sub: 'FOUR ROUNDS · THE CHAMPION WAITS', art: 'mode_cup' },
|
||||
{ id: 'gore', name: 'SPLATTER GORE', sub: 'THE MATCH TURNS · SURVIVE IT', art: 'mode_gore' },
|
||||
// ...and gore no longer opens on a game of tennis (see gameplay.startGore), so its blurb stopped
|
||||
// promising one: the match already turned, you just arrived late
|
||||
{ id: 'gore', name: 'SPLATTER GORE', sub: 'NO WARM-UP · SURVIVE THE DEAD CLUB', art: 'mode_gore' },
|
||||
];
|
||||
|
||||
// THE SETUP SCREEN'S TOP ROW (Oliver's two-step menu, 2026-08-14): three posters, and picking one
|
||||
|
||||
+52
-10
@@ -535,6 +535,16 @@ export function cheer(seconds, vol) {
|
||||
crowd.cheer = Math.max(crowd.cheer, seconds, APPLAUSE_T);
|
||||
}
|
||||
|
||||
// Games between weapon barrels. Exported for the suite, because the shape of this curve IS the
|
||||
// gore mode's economy and it is one line of arithmetic that once quietly became "one per game".
|
||||
// The floor is the point: however long a run gets, a barrel stays something you waited for.
|
||||
export const barrelGap = () => Math.max(TUNE.barrelFloor,
|
||||
TUNE.barrelEvery - Math.floor(G.runGames / TUNE.barrelRamp));
|
||||
// ...and how many ball machines the late court is allowed to hold at once. Same idea from the other
|
||||
// end: the barrels stop growing, this one starts.
|
||||
export const machCap = () => Math.min(TUNE.machMaxCap,
|
||||
TUNE.machMax + Math.floor(G.runGames / TUNE.machMaxPer));
|
||||
|
||||
export function advancePoint(side) { // shared scoring math; returns whether a game was won
|
||||
const i = side === 'P' ? 0 : 1, j = 1 - i;
|
||||
score.pts[i]++;
|
||||
@@ -574,7 +584,11 @@ export function pointTo(side, reason) { // exported for net.js: a remote verdict
|
||||
voice(side === 'P' ? 'game_player' : 'game_opponent', 0.7);
|
||||
cheer(2.8, 0.6);
|
||||
score.server = other(score.server);
|
||||
if (G.classic) { G.runGames++; if (classicGame(side)) return; } // ...then a set, then the match
|
||||
if (G.classic) {
|
||||
G.runGames++;
|
||||
if (side === 'P') G.classicWon++; // the number its board ranks
|
||||
if (classicGame(side)) return; // ...then a set, then the match
|
||||
}
|
||||
} else {
|
||||
voice(scoreKey(), 0.7);
|
||||
if (side === 'P') cheer(1.2, 0.3);
|
||||
@@ -612,11 +626,15 @@ export function endClassic(won) {
|
||||
ball.active = false; G.flash = null; G.modal = null; G.card = null;
|
||||
if (won) { cheer(4, 1); applause(1, 0.2); voice('unbelievable', 0.4); }
|
||||
else { aww(1); voice('game_opponent', 0.3); }
|
||||
// g = GAMES PLAYED, s = SETS YOU WON — this mode's two headline numbers in the record's two slots.
|
||||
// g = GAMES PLAYED (what the stats screen calls LONGEST MATCH), s = GAMES YOU WON — this mode's
|
||||
// two headline numbers in the record's two slots. `s` was SETS until the classic board arrived,
|
||||
// and nothing ever displayed it: the sets line on the death screen is gore's, and read() has
|
||||
// never looked at `s` at all. Games won is the number worth ranking, because every classic win is
|
||||
// two sets and a board of nothing but 2s is a wall of ties.
|
||||
// `dead` carries the RESULT: a match you lost is the run ending badly, a match you won is not,
|
||||
// so the stats screen's DEATHS column reads as matches lost and (runs − deaths) as matches won,
|
||||
// with no extra field to keep in step.
|
||||
const b = endRun(G.runGames, G.sets[0], G.splatT, !won, MODE_CLASSIC);
|
||||
const b = endRun(G.runGames, G.classicWon, G.splatT, !won, MODE_CLASSIC);
|
||||
G.best = b; G.newBest = b.newBest;
|
||||
}
|
||||
|
||||
@@ -671,12 +689,12 @@ export function splatPoint(side, quiet = false) {
|
||||
spawnRacketZombie();
|
||||
// the rogue ball machine gets wheeled out on the same kind of schedule, capped on court
|
||||
if (side === 'P' && G.runGames >= TUNE.machEvery && G.runGames % TUNE.machEvery === 0
|
||||
&& zombies.filter(z => z.variant === 'm').length < TUNE.machMax && !G.bossFight)
|
||||
&& zombies.filter(z => z.variant === 'm').length < machCap() && !G.bossFight)
|
||||
spawnMachine();
|
||||
// ...and a weapon barrel on its own schedule — which TIGHTENS as the run goes on (barrelRamp),
|
||||
// because the late game is where the rack has to carry you. Still one at a time: two glowing
|
||||
// pink things on court would out-shout everything the palette uses to say "this one is dangerous".
|
||||
if (side === 'P' && G.runGames % Math.max(1, TUNE.barrelEvery - Math.floor(G.runGames / TUNE.barrelRamp)) === 0
|
||||
if (side === 'P' && G.runGames % barrelGap() === 0
|
||||
&& !zombies.some(z => z.variant === 'o') && !G.bossFight)
|
||||
spawnBarrel();
|
||||
// The wave does NOT auto-collapse anymore: the survivors stay and must be cleared BY HAND before
|
||||
@@ -924,16 +942,40 @@ function transition(dt) {
|
||||
once('mob', 4.8, () => { spawnZombie(-3.5, -1.6, 'a'); spawnZombie(2.5, -2.1, 'b'); G.shake = { t: 0.5, mag: 6 }; });
|
||||
once('seated', 5.4, () => voice('remain_seated', 0, 0.95));
|
||||
once('go', 8.2, () => {
|
||||
G.mode = 'splatter'; score.server = 'P'; setupPoint(); voice('play', 0.4, 0.9); zombieMusic();
|
||||
enterSplatter();
|
||||
G.shake = { t: 0.7, mag: 9 }; // the floor goes out from under the match
|
||||
// NO modal here anymore — nobody read it (Oliver's testteam, verbatim). The ghost hand teaches
|
||||
// the pull instead: prime the idle clock so the apron demo starts miming IMMEDIATELY rather
|
||||
// than after the usual beat of inaction, and it keeps coming back whenever the player stalls.
|
||||
G.idleHintT = TUNE.pullHintAfter;
|
||||
G.flash = { text: 'THE MATCH HAS CHANGED', t: 2.0 };
|
||||
});
|
||||
}
|
||||
|
||||
// WHERE THE SPLATTER MODE ACTUALLY BEGINS. Shared by the transition's last beat and by the gore
|
||||
// mode's own start, so the two doors cannot land the player in two slightly different games.
|
||||
// NO modal here — nobody read it (Oliver's testteam, verbatim). The ghost hand teaches the pull
|
||||
// instead: the idle clock is primed so the apron demo starts miming IMMEDIATELY rather than after
|
||||
// the usual beat of inaction, and it keeps coming back whenever the player stalls.
|
||||
function enterSplatter() {
|
||||
G.mode = 'splatter'; G.everTransitioned = true;
|
||||
score.server = 'P'; setupPoint();
|
||||
voice('play', 0.4, 0.9); zombieMusic();
|
||||
G.idleHintT = TUNE.pullHintAfter;
|
||||
}
|
||||
|
||||
// THE GORE MODE STARTS IN THE GORE (Oliver, 2026-08-15: "the splatter gore should not have the
|
||||
// normal tennis intro anymore, we should just start right in splatter mode"). The prologue was a
|
||||
// game of real tennis you had to win before the mode you picked would begin — fine as a reveal the
|
||||
// first time, a toll booth every time after it. The transition itself is NOT deleted: it is still
|
||||
// what `forceZombies` plays, it is still the cinematic, and `everTransitioned` above keeps it from
|
||||
// firing behind us. What is gone is it standing between the menu and the mode.
|
||||
// The turned opponent still opens the run — he is the man you came to play, and the mode's whole
|
||||
// premise is that he is already one of them.
|
||||
export function startGore() {
|
||||
if (G.mode !== 'tennis') return;
|
||||
enterSplatter();
|
||||
spawnZombie(O.x, O.z, 'c'); G.hideO = true;
|
||||
G.shake = { t: 0.6, mag: 7 };
|
||||
G.flash = { text: 'THE CLUB IS DEAD', t: 2.0 };
|
||||
}
|
||||
|
||||
export function fault() { // exported for net.js: the receiver's fault call re-arms the server's second serve
|
||||
if (G.serveNum === 1) {
|
||||
if (netJudging()) netSendFault(); // netplay: only the first-serve branch wires — the double
|
||||
|
||||
+42
-16
@@ -5,7 +5,7 @@
|
||||
|
||||
import { TUNE, COURTS, TOURS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, CUP_ROUNDS, matchFoe } from './config.js';
|
||||
import { P, O, ball, score, G, setupPoint, selectCourt, selectTour } from './state.js';
|
||||
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.js';
|
||||
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, startGore, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.js';
|
||||
import { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs } from './beats.js';
|
||||
import { netConnect, netHooks } from './net.js';
|
||||
import { fitViewport, render, proj, RES, SPRV, LOAD, SPR } from './render.js';
|
||||
@@ -283,6 +283,12 @@ document.getElementById('brBack').addEventListener('click', () => {
|
||||
});
|
||||
document.getElementById('cupGo').addEventListener('click', () => start('cup'));
|
||||
|
||||
// Which RECORD BOOK a run mode writes to. The four run modes share three books — the arcade ladder
|
||||
// and the tournament are both zombie tennis and are counted together (see the record-shape note in
|
||||
// stats.endRun). Lived inline in three places before the world board grew tabs of its own.
|
||||
const boardFor = m => m === 'zombie' || m === 'cup' ? stats.MODE_MATCH
|
||||
: m === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
|
||||
|
||||
// what the setup footer says you are about to play, and what it is worth beating
|
||||
function summary() {
|
||||
const t = TOURS.find(x => x.id === pickedTour) || TOURS[0];
|
||||
@@ -291,9 +297,7 @@ function summary() {
|
||||
// the mark to beat is the PICKED MODE's own, because the three of them do not measure the same
|
||||
// thing — see the record-shape note in stats.endRun. The cup banks through endRun as MODE_MATCH
|
||||
// (see cupWon), so its summary reads the same book it writes.
|
||||
const mode = m.id === 'zombie' || m.id === 'cup' ? stats.MODE_MATCH
|
||||
: m.id === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
|
||||
const best = stats.read('all', undefined, mode).longest;
|
||||
const best = stats.read('all', undefined, boardFor(m.id)).longest;
|
||||
const unit = m.id === 'zombie' || m.id === 'cup' ? ' FELLED'
|
||||
: best === 1 ? ' GAME' : ' GAMES';
|
||||
const name = m.id === 'classic'
|
||||
@@ -330,6 +334,7 @@ function start(modeId = pickedMode) {
|
||||
G.classic = modeId === 'classic';
|
||||
// the difficulty rides on G like G.classic does, so a mid-run restart keeps the CPU you chose
|
||||
G.diff = G.classic ? (DIFFS.find(d => d.id === pickedDiff) || DIFFS[1]) : null;
|
||||
if (modeId === 'gore') startGore(); // no tennis prologue: the mode you picked is the mode you get
|
||||
if (modeId === 'zombie') { intoSplatter(); startMatch(); }
|
||||
// ...and the cup carries the draw the bracket screen just made. Null (the #cup back door) still
|
||||
// means "roll it yourself", which is what the mode did before the screen existed.
|
||||
@@ -339,8 +344,7 @@ let stPeriod = 'all', stMode = stats.MODE_GORE;
|
||||
// the stats screen opens on the mode you last PICKED to play — that is the one whose numbers you
|
||||
// came to look at, and it saves a tap on the way in every single time
|
||||
const openStats = () => {
|
||||
stMode = pickedMode === 'zombie' || pickedMode === 'cup' ? stats.MODE_MATCH
|
||||
: pickedMode === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
|
||||
stMode = boardFor(pickedMode);
|
||||
for (const el of document.getElementById('stModes').children) el.classList.toggle('on', el.dataset.m === stMode);
|
||||
stats.drawStats(stPeriod, SPRV, stMode); go('stats');
|
||||
};
|
||||
@@ -348,17 +352,34 @@ const openStats = () => {
|
||||
const goMode = () => { buildTops(); go('mode'); };
|
||||
document.getElementById('mStart').addEventListener('click', goMode);
|
||||
document.getElementById('mStats').addEventListener('click', openStats);
|
||||
document.getElementById('mRanks').addEventListener('click', () => { go('ranks'); stats.drawRanks(); });
|
||||
// the world board opens on the mode you last PICKED, like the stats screen does — that is the one
|
||||
// whose names you came to look at
|
||||
let rkMode = stats.MODE_GORE;
|
||||
const openRanks = () => {
|
||||
rkMode = boardFor(pickedMode);
|
||||
for (const el of document.getElementById('rkModes').children) el.classList.toggle('on', el.dataset.m === rkMode);
|
||||
go('ranks'); stats.drawRanks(null, rkMode);
|
||||
};
|
||||
document.getElementById('mRanks').addEventListener('click', openRanks);
|
||||
document.getElementById('rkModes').addEventListener('click', e => {
|
||||
const b = e.target.closest('button'); if (!b) return;
|
||||
rkMode = b.dataset.m;
|
||||
for (const el of e.currentTarget.children) el.classList.toggle('on', el === b);
|
||||
stats.drawRanks(null, rkMode);
|
||||
});
|
||||
|
||||
// --- THE NAME PROMPT. It appears on exactly one occasion: a run that beat your own best kill
|
||||
// count, on the tap that takes you off the death screen. Anything more often is a form standing
|
||||
// between you and the next match.
|
||||
// --- THE NAME PROMPT. It appears on exactly one occasion: a run that beat your own best on ITS
|
||||
// OWN board (every mode has one now), on the tap that takes you off the death screen. Anything
|
||||
// more often is a form standing between you and the next match.
|
||||
const nbName = document.getElementById('nbName'), nbMsg = document.getElementById('nbMsg');
|
||||
let nbRun = null;
|
||||
function askName(run) {
|
||||
nbRun = run;
|
||||
const B = stats.BOARDS[run.mode] || stats.BOARDS[stats.MODE_GORE];
|
||||
// the prompt names the BOARD's number, not the gore mode's — a tournament run that said
|
||||
// "5 KILLS" would be describing something nobody was playing for
|
||||
document.getElementById('nbScore').innerHTML =
|
||||
`<b>${run.kills.toLocaleString('en-GB')}</b> KILLS · <b>${run.games}</b> GAME${run.games === 1 ? '' : 'S'}`;
|
||||
`<b>${run.score.toLocaleString('en-GB')}</b> ${B.unit} · <b>${run.sub}</b> ${B.subUnit}`;
|
||||
nbName.value = stats.savedName();
|
||||
nbMsg.textContent = 'PUT IT ON THE WORLD BOARD.';
|
||||
document.getElementById('nbSend').disabled = false;
|
||||
@@ -376,10 +397,13 @@ document.getElementById('nbSend').addEventListener('click', async () => {
|
||||
const btn = document.getElementById('nbSend');
|
||||
btn.disabled = true; nbMsg.textContent = 'SENDING…'; // the double-tap posts twice otherwise
|
||||
try {
|
||||
const r = await stats.submit(name, nbRun.kills, nbRun.games);
|
||||
const r = await stats.submit(name, nbRun.score, nbRun.sub, nbRun.mode);
|
||||
rkMode = nbRun.mode; // ...and the board that opens is the one you just wrote your name on
|
||||
for (const el of document.getElementById('rkModes').children)
|
||||
el.classList.toggle('on', el.dataset.m === rkMode);
|
||||
nbRun = null;
|
||||
go('ranks');
|
||||
stats.drawRanks(r.scores); // the board came back with the reply — no second round trip
|
||||
stats.drawRanks(r.scores, rkMode); // the board came back with the reply — no second round trip
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
nbMsg.textContent = e.taken ? 'THAT NAME IS SPOKEN FOR. PICK ANOTHER.'
|
||||
@@ -430,8 +454,8 @@ document.getElementById('hot').addEventListener('pointerdown', () => {
|
||||
// This is laserScene's trick: force the turn and then step the transition out in one go, because
|
||||
// the cinematic is ten seconds you did not come here to watch.
|
||||
function intoSplatter() {
|
||||
if (G.mode === 'tennis') forceZombies();
|
||||
for (let i = 0; i < 900 && G.mode !== 'splatter'; i++) update(1 / 60);
|
||||
startGore(); // the direct door. It used to force the turn and then step the whole 8-second
|
||||
// cinematic out in 900 hidden updates — every sound it makes fired in one frame.
|
||||
G.modal = null; G.horde = null; G.hordeWarn = 0; G.interlude = false; G.changeover = 0; G.clearing = false;
|
||||
G.mgT = 0; G.gunId = null; G.beam = null; G.hp = G.hpMax = TUNE.playerHp;
|
||||
zombies.length = 0; shots.length = 0; pellets.length = 0;
|
||||
@@ -580,7 +604,9 @@ const maybeRestart = () => {
|
||||
// stats.lastRun() survives restartRun on purpose — it lives in the stats module, not on G, which
|
||||
// has just been restored from PRISTINE. Consumed on read, so the prompt appears once.
|
||||
const run = stats.lastRun();
|
||||
if (run && run.newKillBest && run.kills > 0) askName(run);
|
||||
// ...on the BOARD's number, not on kills: a classic match has none, so the gore-shaped test here
|
||||
// silently barred two of the three modes from ever reaching the prompt
|
||||
if (run && run.newKillBest && run.score > 0) askName(run);
|
||||
else go('splash');
|
||||
};
|
||||
cv.addEventListener('pointerdown', maybeRestart);
|
||||
|
||||
+1
-1
@@ -221,7 +221,7 @@ function farB(z) {
|
||||
// Asset cache-buster — bump with main.js?v=N (see the img.src note below). EXPORTED because the
|
||||
// menu screens hang <img> tags on the same files, and two copies of this number is one copy too
|
||||
// many: the one that does not get bumped is the one that serves a cached 404.
|
||||
export const SPRV = 129;
|
||||
export const SPRV = 130;
|
||||
// The boot bar in main.js watches this. Nothing here CHANGES when things load — every sprite was
|
||||
// already requested at import — it just makes the wait visible and holds the menu back until the
|
||||
// last sheet has landed. See the onerror note below for the other half of "sometimes missing".
|
||||
|
||||
@@ -119,6 +119,9 @@ export const G = {
|
||||
// a set, TUNE.classicSets sets is the match. It is the FOUNDATION mode, not a warm-up: if the
|
||||
// core tennis is good, everything built on it is good.
|
||||
classic: false,
|
||||
classicWon: 0, // games YOU have taken off the CPU this classic match. G.sets is sets and
|
||||
// score.games resets every set, so nothing else counted this — and it is what
|
||||
// the classic world board ranks (a 12-2 win beats a 12-10 one).
|
||||
done: null, // {won, t} — the run ENDED without a corpse (a classic match played out).
|
||||
// The one end-of-run state that is not 'eaten': same summary, same gated tap.
|
||||
bench: null, // THE SPRITE BENCH (back room only): {i, stage, dir} — one opponent on an
|
||||
|
||||
+59
-20
@@ -95,6 +95,28 @@ export function multiKill() { run.mk++; }
|
||||
export const MODE_GORE = 'g', MODE_MATCH = 'z', MODE_CLASSIC = 'c';
|
||||
const modeOf = r => r.m || MODE_GORE;
|
||||
|
||||
// ---- WHAT A RUN IS WORTH, per mode. The world board used to be the gore mode's alone, which made
|
||||
// it "the leaderboard" for a game that is three games (Oliver, 2026-08-15: "the world rank is still
|
||||
// about the splatter gore which is not the only thing"). There is now one board per mode, and this
|
||||
// table is the only place that knows what each one ranks — the three do not share a currency (see
|
||||
// the record-shape note in endRun), so a single board would be comparing a 300-kill massacre with a
|
||||
// five-opponent ladder run and calling one of them better.
|
||||
// score the number ranked, off a run record. Ties break on `sub`, then on age.
|
||||
// unit what that number IS, printed on the board and in the name prompt
|
||||
// sub the second column: small, and only ever context for the first
|
||||
// The RECORD's slots are unchanged, which is why no migration is needed — each mode simply reads
|
||||
// the slot it has always written: gore's kills, the match's opponents felled (banked in the games
|
||||
// slot by matchGameWon), classic's games taken off the CPU.
|
||||
export const BOARDS = {
|
||||
[MODE_GORE]: { name: 'SPLATTER GORE', score: r => r.k || 0, sub: r => r.g || 0,
|
||||
unit: 'KILLS', subUnit: 'G', head: 'KILLS · GAMES' },
|
||||
[MODE_MATCH]: { name: 'ZOMBIE TENNIS', score: r => r.g || 0, sub: r => r.s || 0,
|
||||
unit: 'FELLED', subUnit: 'G', head: 'FELLED · GAMES' },
|
||||
[MODE_CLASSIC]: { name: 'CLASSIC', score: r => r.s || 0, sub: r => r.g || 0,
|
||||
unit: 'GAMES WON', subUnit: 'PLAYED', head: 'WON · PLAYED' },
|
||||
};
|
||||
const boardOf = m => BOARDS[m] || BOARDS[MODE_GORE];
|
||||
|
||||
export function endRun(games, sets, secs, dead = true, mode = MODE_GORE) {
|
||||
const all = load();
|
||||
const prev = all.filter(r => modeOf(r) === mode); // you are only ever compared against your own mode
|
||||
@@ -102,22 +124,23 @@ export function endRun(games, sets, secs, dead = true, mode = MODE_GORE) {
|
||||
// TWO bests, because they answer different questions. `newBest` is GAMES and belongs to the death
|
||||
// summary, which has always celebrated how long you lasted. `newKillBest` is KILLS, which is what
|
||||
// the world board ranks — so it is the one that earns you a name-entry prompt.
|
||||
const bestKills = prev.reduce((m, r) => Math.max(m, r.k || 0), 0);
|
||||
const kills = run.k;
|
||||
if (banked || (!run.k && !games)) { run = bag(); return { ...best, newBest: false, newKillBest: false }; }
|
||||
const newBest = games > best.games;
|
||||
// THE WORLD BOARD IS THE GORE MODE'S. It ranks kills in a run, and a match run has exactly one
|
||||
// kill per opponent felled — a five-body match would post a "5 kills" row next to a 300-kill
|
||||
// massacre and mean something completely different by it. No name prompt from a match.
|
||||
// ponytail: when zombie tennis earns a board of its own it ranks FELLED (with points won as the
|
||||
// tiebreak — felled alone is a small integer and would be a wall of ties), and that is a server
|
||||
// change, not this line.
|
||||
const newKillBest = mode === MODE_GORE && kills > bestKills;
|
||||
all.push({ t: Date.now(), g: games, s: sets, d: Math.round(secs || 0), m: mode,
|
||||
k: kills, mk: run.mk, dead: dead ? 1 : 0, z: run.z, w: run.w });
|
||||
// EVERY MODE HAS A BOARD NOW, so every mode can earn the name prompt — against ITS OWN board's
|
||||
// number and its own history (BOARDS above), never against the other two. `newKillBest` keeps its
|
||||
// name because the death screen and main.askName both read it; what it means is "this run beat
|
||||
// your best on the board it belongs to".
|
||||
const row = { t: Date.now(), g: games, s: sets, d: Math.round(secs || 0), m: mode,
|
||||
k: kills, mk: run.mk, dead: dead ? 1 : 0, z: run.z, w: run.w };
|
||||
const B = boardOf(mode);
|
||||
const newKillBest = B.score(row) > prev.reduce((m, r) => Math.max(m, B.score(r)), 0);
|
||||
all.push(row);
|
||||
save(all); // ...the WHOLE record, not the filtered view — that would delete the other mode
|
||||
run = bag(); banked = true;
|
||||
last = { kills, games, newKillBest };
|
||||
// ...and WHICH board it belongs on, plus the two numbers that board prints, so the name prompt
|
||||
// and the submission never have to re-derive them from a record shape they do not own
|
||||
last = { kills, games, newKillBest, mode, score: B.score(row), sub: B.sub(row) };
|
||||
// `games`/`sets` here are THE BEST RUN'S, which is the only thing a caller wants back — the
|
||||
// caller already knows what the run it just handed over did. They used to be trailed by the
|
||||
// ENDED run's own `kills, games`, and the later key won: `G.best.games` was the run that had just
|
||||
@@ -173,8 +196,8 @@ function myId() {
|
||||
let last = null;
|
||||
export function lastRun() { const r = last; last = null; return r; }
|
||||
|
||||
export async function board() {
|
||||
const r = await fetch('api/scores', { cache: 'no-store' });
|
||||
export async function board(mode = MODE_GORE) {
|
||||
const r = await fetch('api/scores?m=' + mode, { cache: 'no-store' });
|
||||
if (!r.ok) throw new Error('board ' + r.status);
|
||||
return (await r.json()).scores || [];
|
||||
}
|
||||
@@ -186,13 +209,15 @@ export async function board() {
|
||||
export const tidyName = s =>
|
||||
String(s).toUpperCase().replace(/[^A-Z0-9 ._-]/g, '').trim().slice(0, 12);
|
||||
|
||||
export async function submit(rawName, kills, games) {
|
||||
// `k` is the BOARD's number and `g` its second column — what those mean is the mode's business
|
||||
// (BOARDS), and the server only ever sorts them. `m` is which board the row goes on.
|
||||
export async function submit(rawName, k, g, mode = MODE_GORE) {
|
||||
const name = tidyName(rawName);
|
||||
if (!name) throw new Error('empty name');
|
||||
try { localStorage.setItem(NAME_KEY, name); } catch {} // type it once, ever
|
||||
const r = await fetch('api/scores', { method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ n: name, k: kills, g: games, id: myId() }) });
|
||||
body: JSON.stringify({ n: name, k, g, m: mode, id: myId() }) });
|
||||
if (r.status === 409) { const e = new Error('name taken'); e.taken = true; throw e; }
|
||||
if (!r.ok) throw new Error('submit ' + r.status);
|
||||
return r.json(); // {ok, rank, scores}
|
||||
@@ -276,18 +301,23 @@ export function drawStats(period, v, mode = MODE_GORE) {
|
||||
|
||||
// The real board. Async, and every failure lands on a line of copy rather than an empty screen —
|
||||
// the server being down must never be indistinguishable from nobody having played.
|
||||
export async function drawRanks(rows) {
|
||||
const el = document.getElementById('rkList');
|
||||
// THREE BOARDS, one screen, same furniture as the stats screen's three modes — and the column
|
||||
// heading changes with the tab, because "300" means bodies here, opponents there and games in the
|
||||
// third and a board that did not say so would be lying by omission.
|
||||
export async function drawRanks(rows, mode = MODE_GORE) {
|
||||
const el = document.getElementById('rkList'), B = boardOf(mode);
|
||||
const hdr = document.getElementById('rkHdr');
|
||||
if (hdr) hdr.innerHTML = `BEST SINGLE RUN<span>${B.head}</span>`;
|
||||
if (!rows) {
|
||||
el.innerHTML = '<p class="none">READING THE BOARD…</p>';
|
||||
try { rows = await board(); }
|
||||
try { rows = await board(mode); }
|
||||
catch { el.innerHTML = '<p class="none">THE BOARD IS UNREACHABLE.</p>'; return; }
|
||||
}
|
||||
if (!rows.length) { el.innerHTML = '<p class="none">NOBODY IS ON THE BOARD YET. BE FIRST.</p>'; return; }
|
||||
if (!rows.length) { el.innerHTML = '<p class="none">NOBODY IS ON THIS BOARD YET. BE FIRST.</p>'; return; }
|
||||
const me = savedName();
|
||||
el.innerHTML = rows.map((r, i) =>
|
||||
`<div class="rkrow${r.n === me ? ' me' : ''}"><b>${n(i + 1)}</b><span>${esc(r.n)}</span>`
|
||||
+ `<i>${n(r.k || 0)} · ${r.g || 0}G</i></div>`).join('');
|
||||
+ `<i>${n(r.k || 0)} · ${r.g || 0}</i></div>`).join('');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ check ----
|
||||
@@ -329,5 +359,14 @@ export function check() {
|
||||
eq(tidyName('<b>hi</b>'), 'BHIB', 'name strips markup');
|
||||
eq(tidyName('ABCDEFGHIJKLMNOPQRSTUVWXYZ').length, 12, 'name capped at 12');
|
||||
eq(tidyName('...'), '...', 'name keeps the allowed punctuation');
|
||||
// ONE BOARD PER MODE. Each ranks its own currency off the slot its own runs already write, so a
|
||||
// ladder run and a massacre are never sorted against each other. The fixture above carries three
|
||||
// gore runs and one match run; the match run is 4 opponents felled over 9 games.
|
||||
const B = BOARDS;
|
||||
eq(B.g.score(fx[0]), 10, 'gore ranks kills');
|
||||
eq(B.z.score(fx[3]), 4, 'zombie tennis ranks opponents felled');
|
||||
eq(B.z.sub(fx[3]), 9, '...with the games it took as the tiebreak');
|
||||
eq(B.c.score({ g: 15, s: 12 }), 12, 'classic ranks the games you took off the CPU');
|
||||
eq(Object.keys(B).sort().join(''), 'cgz', 'three boards, no more and no fewer');
|
||||
return 'ok';
|
||||
}
|
||||
Reference in new issue
Block a user