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
+98
-1
@@ -20,7 +20,9 @@ anything, so if you add a `return` for an awkward deal, check the body still run
|
||||
the state-machine contracts that have historically produced the show-stoppers. The Dockerfile runs
|
||||
it as a build stage, so a red suite cannot deploy — but finding out from Coolify is the slow way.
|
||||
|
||||
Last updated 2026-08-15 (the batch below: six new opponent families and an 18-rung ladder, the
|
||||
Last updated 2026-08-15 (two sessions: the finetuning pass — gore opens in the gore, the menu theme
|
||||
comes back, one world board per mode, and the barrel economy cut — over the batch below: six new
|
||||
opponent families and an 18-rung ladder, the
|
||||
tournament DRAW screen, three netplay fixes off Oliver's cellular report, the night court, and
|
||||
camera flashes in the stand). **Start with "How to work on this", "OPEN — leftovers" and "LATER —
|
||||
shipping it"** (all three just above the CLOSED sections) — the first tells you how Oliver's feedback
|
||||
@@ -412,6 +414,101 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
|
||||
`RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud
|
||||
FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone.
|
||||
|
||||
## Session log: gore opens in the gore, the theme comes back, three boards (2026-08-15, same day)
|
||||
|
||||
Oliver's finetuning pass on the batch below.
|
||||
|
||||
### THE GORE MODE STARTS IN THE GORE
|
||||
|
||||
*"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 — a reveal the first time, a toll booth every time after.
|
||||
|
||||
`gameplay.startGore()` is the direct door: it lands on the same state the transition's last beat
|
||||
lands on (extracted as `enterSplatter`, so the two doors can never drift), sets `everTransitioned`
|
||||
so the cinematic cannot fire behind it, and spawns the turned opponent as your first body — he is
|
||||
the man you came to play and the mode's premise is that he is already one of them. **The transition
|
||||
is NOT deleted**: it is still what `forceZombies` / `T.forceZombies()` plays, and it is still the
|
||||
best thing in the game to look at. It just no longer stands between the menu and the mode. The mode
|
||||
poster stopped promising a match that turns, because there is no longer one.
|
||||
|
||||
`main.intoSplatter` — the back room's "get me into splatter" hack — now calls `startGore()` too,
|
||||
which deletes its 900 hidden `update()` calls. That loop fast-forwarded the whole eight-second
|
||||
cinematic inside a single frame, firing every sound it makes at once.
|
||||
|
||||
### THE MENU THEME COMES BACK, AND THE COURT STAYS ON THE COURT
|
||||
|
||||
*"after some games — or returning to the main menu via the close button — it does not play the
|
||||
background music anymore but you sometimes still hear the court background noise."*
|
||||
|
||||
Two halves of one bug, and both are `loopBed`'s early return. **It no-ops once a file is playing**,
|
||||
so `menuMusic()` could only ever START the theme and never bring it back from the duck `start()`
|
||||
put it under — the menu was silent for the rest of the session from the first run onward, and
|
||||
quitting was the reliable way in because the death summary is the only path that ever called
|
||||
`menuDuck(false)`. Meanwhile nothing ducked the court's own two beds at all, so the ambience
|
||||
followed you onto the silent menu.
|
||||
|
||||
`menuMusic()` is now the one call that means "we are in the menus": it starts the theme, un-ducks
|
||||
it, and ducks both court beds. `ambient()` and `zombieMusic()` un-duck themselves for the same
|
||||
reason — the SECOND run would otherwise have been played in the silence the menu left them in.
|
||||
**The rule, for anything added here later: a bed's start function must also un-duck it, because
|
||||
starting it is a no-op the second time and ducking it is not.** `audio.bedTest` exposes the duck
|
||||
set to the suite (same idea as `netTest`), which is what makes this testable at all — the beds
|
||||
never load headless, but the INTENT is the thing that was wrong.
|
||||
|
||||
### THREE WORLD BOARDS
|
||||
|
||||
*"the world rank is still about the splatter gore which is not the only thing — we need something
|
||||
similar for the other modes."*
|
||||
|
||||
`stats.BOARDS` is the only place that knows what each board ranks, and the server does not know at
|
||||
all: it sorts `k` then `g` within a mode and nothing crosses.
|
||||
|
||||
| board | ranks | second column |
|
||||
|---|---|---|
|
||||
| SPLATTER | kills in a run | games survived |
|
||||
| TENNIS (arcade + cup) | opponents felled | games |
|
||||
| CLASSIC | games taken off the CPU | games played |
|
||||
|
||||
Classic needed a number worth ranking — every win is exactly two sets, so a board of 2s is a wall
|
||||
of ties. `G.classicWon` counts the games YOU took and rides in the record's `s` slot, which held
|
||||
sets until now and which **nothing has ever displayed** (the sets line on the death screen is
|
||||
gore's; `read()` has never looked at `s`). The name prompt is per-mode too, gated on the board's
|
||||
own number rather than on kills — the old `run.kills > 0` test silently barred classic (no kills)
|
||||
from ever reaching it.
|
||||
|
||||
Server: rows carry `m`, `GET /api/scores?m=g|z|c` filters, POST keeps one row per name **per
|
||||
board** and trims `KEEP` **per board** so a busy mode cannot evict a quiet one. Name ownership
|
||||
stays GLOBAL — one person owns their name on all three, or a stranger could take it on classic
|
||||
while you hold it on gore. Rows written before the split carry no `m` and are gore rows, which is
|
||||
the same convention the client has used since the stats screen split, so there is no migration.
|
||||
|
||||
### THE GORE MODE GETS HARD AGAIN
|
||||
|
||||
*"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. The splatter gore should be
|
||||
unbelievably hard towards the end."*
|
||||
|
||||
He is right and the arithmetic said so. `barrelEvery 3 / barrelRamp 8` drove the gap to **one** at
|
||||
game 16 and left it there, so the entire back half of a run paid out a weapon for every game won —
|
||||
while every difficulty ramp in the file reached its ceiling around game 25 and stopped. A run got
|
||||
richer after game 25 and never more dangerous.
|
||||
|
||||
- **Barrels: 5 / 14, and a new `barrelFloor` of 3.** The ramp survives (the late game does need
|
||||
more inventory than the early one), it is just slower and it STOPS. `gameplay.barrelGap()` is the
|
||||
curve, exported and pinned by a test, because this is exactly the kind of one-liner that quietly
|
||||
becomes an income again.
|
||||
- **`zSpeedMax` 2.9 → 3.6** — shamblers keep gaining speed to ~game 28 instead of 22.
|
||||
- **`nastyMax` 0.75 → 1.0** — by game 34 there are no ordinary shamblers left in the mix at all.
|
||||
- **Ball machines scale**: `machCap()` is 2 + one per `machMaxPer` games, capped at `machMaxCap`.
|
||||
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.
|
||||
|
||||
The body COUNT ceiling (`zMaxCap`) is untouched and stays untouched: it is a readability limit on a
|
||||
270 px screen, not a difficulty dial. What escalates now is what the bodies ARE and how fast they
|
||||
arrive. **Feel-pending: these are dials, and the end of a long run is the one part of this game
|
||||
that cannot be judged from the numbers.**
|
||||
|
||||
## Session log: the roster doubles, the draw is made on screen, and netplay stops locking (2026-08-15)
|
||||
|
||||
Oliver's seven-item batch. All of it is on STAGING and pending his phone verdict; items 3-5 in
|
||||
|
||||
+10
-2
@@ -498,9 +498,17 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
|
||||
<div id="stW"></div>
|
||||
<div class="foot" id="stFoot"></div>
|
||||
</div>
|
||||
<!-- THREE BOARDS, one per game mode — the three do not share a currency, so they cannot share
|
||||
a ranking. Same tab strip as the stats screen's, and stats.drawRanks rewrites the column
|
||||
heading with the tab, because the number means something different on each one. -->
|
||||
<div id="ranks">
|
||||
<div class="hdr"><button class="chev" id="rkBack">‹</button><h2>WORLD BOARD</h2></div>
|
||||
<h3>BEST SINGLE RUN<span>KILLS · GAMES</span></h3>
|
||||
<div class="tabs" id="rkModes">
|
||||
<button class="on" data-m="g">SPLATTER</button>
|
||||
<button data-m="z">TENNIS</button>
|
||||
<button data-m="c">CLASSIC</button>
|
||||
</div>
|
||||
<h3 id="rkHdr">BEST SINGLE RUN<span>KILLS · GAMES</span></h3>
|
||||
<div id="rkList"></div>
|
||||
</div>
|
||||
<!-- THE BACK ROOM. Five taps on the logo (main.js) and nothing else — no button anywhere hints
|
||||
@@ -539,6 +547,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="src/main.js?v=260"></script>
|
||||
<script type="module" src="src/main.js?v=261"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,8 +5,11 @@ It replaced the nginx image because the leaderboard needs ONE writable process a
|
||||
one. Everything nginx was doing here is four lines of headers, so a second container (and a second
|
||||
Coolify resource, and a proxy rule) would have bought separation we have no use for at this size.
|
||||
|
||||
GET /api/scores -> {"scores": [{"n","k","g","t"}, ...]} best first
|
||||
POST /api/scores <- {"n","k","g"} -> {"ok", "rank", "scores"}
|
||||
GET /api/scores?m=g|z|c -> {"m", "scores": [{"n","k","g","t","m"}, ...]} best first
|
||||
POST /api/scores <- {"n","k","g","m"} -> {"ok", "rank", "m", "scores"}
|
||||
|
||||
One board per game mode; `m` defaults to 'g' (splatter gore) at both ends, which is what every row
|
||||
written before the split is.
|
||||
|
||||
Scores live in ONE json file under SPLAT_DATA (default /data, which must be a persistent volume —
|
||||
anything written inside the container image is gone on the next deploy). There is no auth: the
|
||||
@@ -22,12 +25,23 @@ DATA = os.environ.get('SPLAT_DATA') or ('/data' if os.path.isdir('/data') else H
|
||||
FILE = os.path.join(DATA, 'scores.json')
|
||||
PORT = int(os.environ.get('PORT', 80))
|
||||
|
||||
KEEP = 200 # rows kept on disk; the board only ever shows the top slice of this
|
||||
KEEP = 200 # rows kept on disk PER BOARD; the board only ever shows the top slice of this
|
||||
MAX_BODY = 2048 # a submission is ~60 bytes. Anything larger is not one.
|
||||
NAME_MAX = 12
|
||||
# ONE BOARD PER GAME MODE (2026-08-15). The three do not share a currency — gore ranks bodies
|
||||
# killed, zombie tennis ranks opponents felled, classic ranks games taken off the CPU — so a single
|
||||
# board was really the gore mode's with the other two locked out of it. The server does not know or
|
||||
# care what the numbers MEAN (see stats.BOARDS on the client); it sorts them within a mode and
|
||||
# nothing crosses. Rows written before the split carry no mode and ARE gore rows, which is why 'g'
|
||||
# is the default everywhere and no migration is needed.
|
||||
MODES = ('g', 'z', 'c')
|
||||
lock = threading.Lock()
|
||||
|
||||
|
||||
def mode_of(r):
|
||||
return r.get('m') or 'g'
|
||||
|
||||
|
||||
def load():
|
||||
try:
|
||||
with open(FILE) as f:
|
||||
@@ -60,7 +74,10 @@ def clean(payload):
|
||||
if not (0 <= kills <= 1_000_000 and 0 <= games <= 10_000):
|
||||
return None # bounds keep the sort honest and the column narrow
|
||||
who = re.sub(r'[^a-z0-9]', '', str(payload.get('id', '')))[:40]
|
||||
return {'n': name, 'k': kills, 'g': games, 't': int(time.time()), 'id': who}
|
||||
mode = str(payload.get('m', 'g'))
|
||||
if mode not in MODES:
|
||||
mode = 'g' # an unknown board is the default one, never a new one
|
||||
return {'n': name, 'k': kills, 'g': games, 't': int(time.time()), 'id': who, 'm': mode}
|
||||
|
||||
|
||||
def public(rows):
|
||||
@@ -212,8 +229,11 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
if path == '/ws':
|
||||
return self.handle_ws()
|
||||
if path == '/api/scores':
|
||||
want = re.sub(r'[^gzc]', '', (self.path.split('m=')[1].split('&')[0]
|
||||
if 'm=' in self.path else ''))[:1] or 'g'
|
||||
with lock:
|
||||
return self.reply(200, {'scores': public(load())})
|
||||
return self.reply(200, {'m': want,
|
||||
'scores': public([r for r in load() if mode_of(r) == want])})
|
||||
# Don't hand out the source. Nothing secret lives in it — the .env is not in the image and
|
||||
# the client is public JS regardless — but serving server.py and the asset-pipeline scripts
|
||||
# to anyone who guesses the name is untidy, and it publishes the exact validation rules.
|
||||
@@ -238,26 +258,38 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
return self.reply(400, {'error': 'bad score'})
|
||||
with lock:
|
||||
rows = load()
|
||||
mine = [r for r in rows if r.get('n') == row['n']]
|
||||
# A NAME BELONGS TO WHOEVER TOOK IT FIRST. The client mints a random id once and keeps
|
||||
# it; the row remembers it, and a submission under someone else's name with a different
|
||||
# id is refused. That is the whole of the "security" here — enough that a stranger
|
||||
# cannot overwrite your row, and not one line more. Clear your browser storage and you
|
||||
# lose the name, which is the honest cost of having no accounts.
|
||||
if mine and mine[0].get('id') and mine[0]['id'] != row['id']:
|
||||
# A NAME BELONGS TO WHOEVER TOOK IT FIRST, ACROSS ALL THREE BOARDS. The claim is on the
|
||||
# NAME, not on a row: one person owns "OLIVER" everywhere, or a stranger could take it
|
||||
# on the classic board while you hold it on gore. The client mints a random id once and
|
||||
# keeps it; the row remembers it, and a submission under someone else's name with a
|
||||
# different id is refused. That is the whole of the "security" here — enough that a
|
||||
# stranger cannot overwrite your row, and not one line more. Clear your browser storage
|
||||
# and you lose the name, which is the honest cost of having no accounts.
|
||||
owner = next((r for r in rows if r.get('n') == row['n'] and r.get('id')), None)
|
||||
if owner and owner['id'] != row['id']:
|
||||
return self.reply(409, {'error': 'name taken'})
|
||||
# EXACTLY ONE ROW PER NAME, their best. Without this the board is one player's session:
|
||||
# every personal best posts again and a good run buries everyone else under itself.
|
||||
# The client only posts on a personal best, but its idea of "best" is per-device — the
|
||||
# same player on a second phone starts from zero and posts worse runs, so a weaker
|
||||
# submission has to be dropped HERE rather than merely not sorted to the top.
|
||||
# EXACTLY ONE ROW PER NAME PER BOARD, their best on it. Without this the board is one
|
||||
# player's session: every personal best posts again and a good run buries everyone else
|
||||
# under itself. The client only posts on a personal best, but its idea of "best" is
|
||||
# per-device — the same player on a second phone starts from zero and posts worse runs,
|
||||
# so a weaker submission has to be dropped HERE rather than merely not sorted to the top.
|
||||
mine = [r for r in rows if r.get('n') == row['n'] and mode_of(r) == row['m']]
|
||||
if row['k'] > max((r.get('k', 0) for r in mine), default=-1):
|
||||
rows = [r for r in rows if r.get('n') != row['n']]
|
||||
rows = [r for r in rows
|
||||
if not (r.get('n') == row['n'] and mode_of(r) == row['m'])]
|
||||
rows.append(row)
|
||||
rows.sort(key=lambda r: (-r.get('k', 0), -r.get('g', 0), r.get('t', 0)))
|
||||
rows = rows[:KEEP]
|
||||
# sorted and trimmed PER BOARD, so a busy mode can never evict a quiet one
|
||||
kept = []
|
||||
for m in MODES:
|
||||
rs = [r for r in rows if mode_of(r) == m]
|
||||
rs.sort(key=lambda r: (-r.get('k', 0), -r.get('g', 0), r.get('t', 0)))
|
||||
kept += rs[:KEEP]
|
||||
rows = kept
|
||||
save(rows)
|
||||
return self.reply(200, {'ok': True, 'rank': rank_of(rows, row['n']), 'scores': public(rows)})
|
||||
board = [r for r in rows if mode_of(r) == row['m']]
|
||||
board.sort(key=lambda r: (-r.get('k', 0), -r.get('g', 0), r.get('t', 0)))
|
||||
return self.reply(200, {'ok': True, 'm': row['m'],
|
||||
'rank': rank_of(board, row['n']), 'scores': public(board)})
|
||||
|
||||
# ---- static, with the headers the nginx conf used to set ----
|
||||
def end_headers(self):
|
||||
|
||||
+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';
|
||||
}
|
||||
@@ -84,7 +84,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' });
|
||||
// ---- game modules (real code) ----------------------------------------------
|
||||
const state = await import('./src/state.js');
|
||||
const { G, P, O, score, ball, setupPoint } = state;
|
||||
const { update, forceZombies, splatPoint, restartRun, flickTarget, keyPressed, classicGame, serveApexY, assistK, rallyFeel } = await import('./src/gameplay.js');
|
||||
const { update, forceZombies, startGore, splatPoint, restartRun, flickTarget, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap } = await import('./src/gameplay.js');
|
||||
const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN, startNetVs, endNetVs } = await import('./src/beats.js');
|
||||
const { mirror, netMsg, netTest, netTick } = await import('./src/net.js');
|
||||
// the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a
|
||||
@@ -95,7 +95,9 @@ const { killPlayer } = await import('./src/zombies.js');
|
||||
const { drillQueue, drillLegal, arrival, drillMult, DRILL_TYPES } = await import('./src/drill.js');
|
||||
const cfg = await import('./src/config.js');
|
||||
const { TUNE, bossArt, COURT, racketStage } = cfg;
|
||||
const { read: readStats, check: statsCheck } = await import('./src/stats.js');
|
||||
const statsMod = await import('./src/stats.js');
|
||||
const audio = await import('./src/audio.js');
|
||||
const { read: readStats, check: statsCheck } = statsMod;
|
||||
|
||||
const DT = 1 / 60;
|
||||
function freshSplatter() {
|
||||
@@ -849,6 +851,66 @@ test('an ordinary far winner keeps its ordinary ending, and the match plays on (
|
||||
assert.equal(score.pts[0], pts + 2, 'the match plays on');
|
||||
});
|
||||
|
||||
test('the menus own the theme, and hand the court back its silence', () => {
|
||||
// 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." loopBed no-ops once a file is playing, so menuMusic() could only ever START the theme,
|
||||
// never bring it BACK from the duck start() put it under — and nothing ducked the court's own
|
||||
// beds at all. The menu was silent and noisy at once, permanently, from the first run onward.
|
||||
const d = audio.bedTest.ducked, THEME = 'background-music.m4a',
|
||||
AMB = 'tennis-ambient-loop.wav', ZOM = 'zombie-mode.m4a';
|
||||
audio.menuMusic(); // on the menu
|
||||
assert.ok(!d.has(THEME), 'the theme plays on the menu');
|
||||
audio.menuDuck(true); audio.ambient(); audio.zombieMusic(); // ...a run starts
|
||||
assert.ok(d.has(THEME), 'the theme hands the court over');
|
||||
assert.ok(!d.has(AMB) && !d.has(ZOM), 'and the court gets its own beds');
|
||||
audio.menuMusic(); // ...and the run ends, however it ended
|
||||
assert.ok(!d.has(THEME), 'the theme is BACK — this is the bug');
|
||||
assert.ok(d.has(AMB) && d.has(ZOM), '...and the court does not follow you into the menu');
|
||||
audio.ambient(); audio.zombieMusic(); // ...and the NEXT run is not played in silence
|
||||
assert.ok(!d.has(AMB) && !d.has(ZOM), 'a second run sounds like the first');
|
||||
audio.menuMusic();
|
||||
});
|
||||
|
||||
test('SPLATTER GORE opens in the gore — no tennis prologue to win first', () => {
|
||||
// Oliver, 2026-08-15: "it should not have the normal tennis intro anymore, we should just start
|
||||
// right in splatter mode." The transition is not deleted (forceZombies still plays it) — what is
|
||||
// gone is it standing between the menu and the mode.
|
||||
restartRun();
|
||||
assert.equal(G.mode, 'tennis', 'a fresh run starts where every run starts');
|
||||
startGore();
|
||||
assert.equal(G.mode, 'splatter', 'the mode you picked is the mode you get');
|
||||
assert.ok(G.everTransitioned, '...and the prologue can never fire behind it');
|
||||
assert.ok(zombies.some(z => z.variant === 'c'), 'the man you came to play is already one of them');
|
||||
assert.ok(G.hideO, 'so the tennis opponent is gone from the far court');
|
||||
assert.equal(score.server, 'P', 'and the serve is yours');
|
||||
startGore();
|
||||
assert.equal(zombies.filter(z => z.variant === 'c').length, 1, 'a second call is a no-op, not a second corpse');
|
||||
});
|
||||
|
||||
test('the gore economy: barrels stop being income, and the late court keeps getting worse', () => {
|
||||
// "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." At 3/8 the gap hit ONE
|
||||
// at game 16 and stayed, so the back half of a run paid a weapon per game won. The floor is the
|
||||
// fix, and it is the property worth pinning: a barrel is a reward, never an income.
|
||||
const gapAt = g => { G.runGames = g; return barrelGap(); };
|
||||
for (const g of [0, 5, 12, 20, 40, 120])
|
||||
assert.ok(gapAt(g) >= 2, `game ${g}: a barrel is never one-per-game (gap ${gapAt(g)})`);
|
||||
assert.ok(gapAt(40) <= gapAt(0), 'the late game still earns them faster than the early one');
|
||||
assert.equal(gapAt(400), TUNE.barrelFloor, '...down to the floor and no further');
|
||||
// ...and the pressure keeps climbing where the barrels stop
|
||||
const capAt = g => { G.runGames = g; return machCap(); };
|
||||
assert.equal(capAt(0), TUNE.machMax, 'game one gets the old two guns');
|
||||
assert.ok(capAt(40) > capAt(0), 'a long run faces more of them');
|
||||
assert.equal(capAt(400), TUNE.machMaxCap, '...to a ceiling, because the court still has to read');
|
||||
// the two ramps that used to stop dead around game 25 now run past it
|
||||
assert.ok(1 + Math.min(TUNE.zSpeedMax, 30 * TUNE.zSpeedPerGame)
|
||||
> 1 + Math.min(TUNE.zSpeedMax, 22 * TUNE.zSpeedPerGame), 'shamblers keep gaining speed past game 22');
|
||||
assert.equal(Math.min(TUNE.nastyMax, 40 * TUNE.nastyPerGame), 1,
|
||||
'and by the end every body that walks out is off the advanced roster');
|
||||
G.runGames = 0;
|
||||
});
|
||||
|
||||
test('THE TOURNAMENT: bands draw, bodies deepen, rounds hold their body, the champion ends it', () => {
|
||||
const { CUP_ROUNDS } = cfg;
|
||||
_startMatch(true); G.modal = null; G.modalQ.length = 0;
|
||||
@@ -1148,7 +1210,7 @@ test('the toss strike has stakes: flush is fast, a miss is slow, the old free wi
|
||||
|
||||
test('classic tennis: the second set ends the run, with no corpse', () => {
|
||||
G.mode = 'tennis'; G.classic = true;
|
||||
G.sets = [1, 0]; score.games = [6, 0]; G.runGames = 14;
|
||||
G.sets = [1, 0]; score.games = [6, 0]; G.runGames = 14; G.classicWon = 12;
|
||||
assert.equal(classicGame('P'), true, 'the match is over');
|
||||
assert.ok(G.done && G.done.won, 'and you won it');
|
||||
assert.notEqual(G.mode, 'eaten', 'nobody died — this is the other ending');
|
||||
@@ -1156,7 +1218,10 @@ test('classic tennis: the second set ends the run, with no corpse', () => {
|
||||
const r = runs[runs.length - 1];
|
||||
assert.equal(r.m, 'c', 'banked as a classic run');
|
||||
assert.equal(r.dead, 0, 'a match WON is not a death — that is how wins are counted');
|
||||
assert.equal(r.s, 2, 'sets won ride in the second headline slot');
|
||||
// GAMES YOU WON, not sets: it is what classic's world board ranks, because every win is two sets
|
||||
// and a board of nothing but 2s is a wall of ties. Nothing ever displayed the old value.
|
||||
assert.equal(r.s, 12, 'games won ride in the second headline slot');
|
||||
assert.equal(statsMod.BOARDS.c.score(r), 12, "...and that is the classic board's number");
|
||||
});
|
||||
|
||||
test('classic tennis: losing the match ends it the same way, and counts as a loss', () => {
|
||||
|
||||
Reference in new issue
Block a user