The leagues, phase 1: the profile clock ticks — xp, levels, the identity screen, the server mirror

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-29 12:22:33 +02:00
1 parent bc10216d0b
commit 9133c0d351
12 files changed
+507 -19

No files matched your search

+4
View File
@@ -9,6 +9,7 @@
import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME, racketStage, CUP_ROUNDS } from './config.js';
import { endRun, MODE_MATCH } from './stats.js';
import { award } from './profile.js';
import { G, P, O, ball, score, setupPoint, sendBoy, other } from './state.js';
import { voice, zombieDuck, aww } from './audio.js';
import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.js';
@@ -848,6 +849,7 @@ function cupGameWon(zb, banked = false) {
return;
}
// the round is won: the corpse goes down, and THEN comes apart (the kill fall, as the deathmatch)
award('round');
d.afterFall = () => {
matchHealBeat();
cup.round++; cup.g = 0;
@@ -874,6 +876,7 @@ function cupWon() {
G.interlude = false;
ball.active = false; G.flash = null; G.modal = null; G.card = null;
cheer(4, 1); voice('unbelievable', 0.4);
award('cup');
const games = score.games[0] + score.games[1];
const b = endRun(CUP_ROUNDS.length, games, G.splatT, false, MODE_MATCH);
G.best = b; G.newBest = b.newBest;
@@ -947,6 +950,7 @@ export function resolveInterlude(side) {
ball.active = false; G.phase = 'over'; G.overT = 2.2;
if (side === 'P') {
cheer(3, 0.7);
award('duelWin');
deliverTin(); // win the point, win the next tin
// ...and the trainer comes out. THE reason to want a duel rather than merely tolerate it — it is
// the only healing in the game, and blood is the only thing you cannot buy back otherwise. It
+7
View File
@@ -8,6 +8,7 @@ import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js';
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
import { updateDrill } from './drill.js';
import { endRun, beginRun, MODE_CLASSIC } from './stats.js';
import { award, beginRunXp } from './profile.js';
import { updateZombies, updateParts, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam , pops, popText } from './zombies.js';
import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog, updateBench, benchKey, tickMatchFall, placeReceiver } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing
import { netSendShot, netSendToss, netTick, netJudging, netSendPoint, netSendFault, netClose } from './net.js'; // runtime-only cycle: net calls back into pointTo/fault/tossBall/showModal
@@ -649,8 +650,13 @@ export const machCap = () => Math.min(TUNE.machMaxCap,
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]++;
// THE ONE XP HOOK for points and games: every mode's point converges here (pointTo, matchPoint,
// splatPoint), so the leagues' clock cannot miss a mode or double-count one. award() itself
// mutes the no-stakes modes (training, drill, bench, test).
if (side === 'P') award('point');
if (score.pts[i] >= 4 && score.pts[i] - score.pts[j] >= 2) {
score.games[i]++; score.pts = [0, 0];
if (side === 'P') award('game');
return true;
}
return false;
@@ -998,6 +1004,7 @@ export function restartRun() {
netClose(); // netplay: resetState wipes G.net below; the socket must not outlive the run it carried
resetZombies(); resetInput(); resetState(); setupPoint();
beginRun(); // ...and the stats bag, which is the only state that does not live on G
beginRunXp(); // ...and the profile's run delta, same rule (the xp itself is already banked)
}
export function forceZombies() {
+86 -6
View File
@@ -14,6 +14,7 @@ import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss
import { cv } from './dom.js';
import { haptic } from './haptics.js';
import * as stats from './stats.js';
import * as profile from './profile.js';
import './input.js';
let last = performance.now();
@@ -80,7 +81,10 @@ const menuUp = () => !intro.hidden;
const remembered = k => { try { return localStorage.getItem(k); } catch { return null; } };
let picked = remembered('court') || COURTS[0].id;
let pickedTour = remembered('tour') || TOURS[0].id;
const go = stage => { intro.dataset.stage = stage; menuMusic(); }; // every stage change is a gesture, so it is also a safe place to (re)start the theme
// every stage change is a gesture, so it is also a safe place to (re)start the theme. The setup
// screen syncs its picker rows on entry: the identity screen writes the same picked* state, and
// rows that only ever toggle on their OWN taps would keep showing the boot-time selection.
const go = stage => { if (stage === 'setup') syncSetup(); intro.dataset.stage = stage; menuMusic(); };
// --- MODE selection in TWO STEPS (config.MENU + config.MODES): three posters on top, and the
// picked one deals its own second step underneath — classic deals the difficulty chips, zombie
@@ -236,6 +240,7 @@ const tourList = document.getElementById('tours');
for (const t of TOURS) {
const b = document.createElement('button');
b.className = t.id === pickedTour ? 'on' : '';
b.dataset.id = t.id; // syncSetup finds the row's buttons by id
// just the word now — the live player beside it is the portrait, and the tile art was a SECOND
// drawing of the same man that no colour on this screen could reach
b.textContent = t.name.replace(' SINGLES', '');
@@ -252,12 +257,15 @@ for (const t of TOURS) {
// way this can show a wrong colour is if the court would show it too.
// The sprite sheets load asynchronously and the menu deliberately parks the game loop, so the first
// draw can land before the art does: retry on a slow poll that stops itself the moment it succeeds.
const lookCv = document.getElementById('look');
const lookCv = document.getElementById('look'), pLookCv = document.getElementById('pLook');
let lookPoll = 0;
function refreshLook() {
const t = TOURS.find(x => x.id === pickedTour) || TOURS[0];
const ok = drawLook(lookCv, t, KITS.find(e => e.id === pickedKit) || KITS[0],
SKINS.find(e => e.id === pickedSkin) || SKINS[0]);
const k = KITS.find(e => e.id === pickedKit) || KITS[0];
const s = SKINS.find(e => e.id === pickedSkin) || SKINS[0];
// both live players — the setup screen's and the identity screen's — are the same man and must
// move together; drawing to whichever is hidden is a no-op-cheap blit
const ok = drawLook(lookCv, t, k, s) & drawLook(pLookCv, t, k, s);
clearTimeout(lookPoll);
if (!ok) lookPoll = setTimeout(refreshLook, 200); // sprites still coming; ask again shortly
}
@@ -271,6 +279,7 @@ const swatches = (rowId, nameId, table, keyName, get, set) => {
for (const e of table) {
const b = document.createElement('button');
b.className = e.id === get() ? 'on' : '';
b.dataset.id = e.id; // syncSetup finds the row's buttons by id
b.style.background = e.sw;
b.title = e.name; // desktop hover; the h3 label is what a phone reads
b.setAttribute('aria-label', e.name);
@@ -291,6 +300,57 @@ swatches('kitRow', 'kitName', KITS, 'kit', () => pickedKit, id => pickedKit = id
swatches('skinRow', 'skinName', SKINS, 'skin', () => pickedSkin, id => pickedSkin = id);
refreshLook();
// The setup screen's rows, brought up to date with picks made elsewhere (the identity screen).
// Selection state lives in picked*; the rows are just its picture and must be repainted on entry.
function syncSetup() {
for (const el of tourList.children) el.classList.toggle('on', el.dataset.id === pickedTour);
for (const [rowId, nameId, table, cur] of [['kitRow', 'kitName', KITS, pickedKit],
['skinRow', 'skinName', SKINS, pickedSkin]]) {
for (const el of document.getElementById(rowId).children)
el.classList.toggle('on', el.dataset.id === cur);
document.getElementById(nameId).textContent = (table.find(e => e.id === cur) || table[0]).name;
}
refreshLook(); summary();
}
// --- THE IDENTITY SCREEN (the leagues, phase 1). First boot only: name + tour + colours, then
// ENTER THE CLUB. It writes the SAME stores the setup screen reads (pickedTour/Kit/Skin, their
// localStorage keys, stats' name key), so the two screens cannot disagree — which is why the rows
// are REBUILT on every visit: their .on states have to reflect taps made on the other screen.
const pName = document.getElementById('pName');
function buildPlayer() {
pName.value = stats.savedName() || profile.zombieName();
const tRow = document.getElementById('pTours');
tRow.innerHTML = '';
for (const t of TOURS) {
const b = document.createElement('button');
b.className = t.id === pickedTour ? 'on' : '';
b.textContent = t.name.replace(' SINGLES', '');
b.addEventListener('click', () => {
pickedTour = t.id;
try { localStorage.setItem('tour', t.id); } catch {}
for (const el of tRow.children) el.classList.toggle('on', el === b);
refreshLook();
});
tRow.appendChild(b);
}
document.getElementById('pKitRow').innerHTML = '';
document.getElementById('pSkinRow').innerHTML = '';
swatches('pKitRow', 'pKitName', KITS, 'kit', () => pickedKit, id => pickedKit = id);
swatches('pSkinRow', 'pSkinName', SKINS, 'skin', () => pickedSkin, id => pickedSkin = id);
refreshLook();
}
document.getElementById('pDice').addEventListener('click', () => { pName.value = profile.zombieName(); });
document.getElementById('pBack').addEventListener('click', () => go('splash')); // setup stays open — it re-asks
document.getElementById('pGo').addEventListener('click', () => {
// an emptied field is not a refusal to have a name — the die decides, like the prefill did
stats.saveName(stats.tidyName(pName.value) || profile.zombieName());
try { localStorage.setItem('tour', pickedTour); } catch {}
profile.prof.setup = 1; profile.save(); profile.push();
buildTops(); go('mode');
});
pName.addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('pGo').click(); });
// --- court rows, built from the table.
// The two meters come out of the court's OWN bounce numbers rather than a pair of hand-written
// ratings, so a physics change moves the bars with it and the screen can never advertise a court
@@ -429,6 +489,11 @@ function summary() {
// has just ended and the count has moved.
function refreshSplash() {
document.getElementById('mKills').textContent = stats.total().kills.toLocaleString('en-GB') + ' KILLS';
// the level strip — the meta clock, re-read whenever the splash comes back because a run has
// just moved it (same rule as the kill count above)
const pr = profile.progress();
document.getElementById('xpLv').textContent = 'LEVEL ' + pr.level;
document.getElementById('xpFill').style.width = Math.round(pr.frac * 100) + '%';
summary();
}
@@ -467,8 +532,14 @@ const openStats = () => {
for (const el of document.getElementById('stModes').children) el.classList.toggle('on', el.dataset.m === stMode);
stats.drawStats(stPeriod, SPRV, stMode); go('stats');
};
// entering a question screen rebuilds it, so the LAST tag always names the run actually played
const goMode = () => { buildTops(); go('mode'); };
// entering a question screen rebuilds it, so the LAST tag always names the run actually played —
// and the FIRST visit ever detours through the identity screen (the leagues): the club wants a
// name before it deals a match. ENTER THE CLUB lands on the mode screen, so the detour costs the
// question it interrupted nothing.
const goMode = () => {
if (!profile.prof.setup) { buildPlayer(); go('player'); return; }
buildTops(); go('mode');
};
document.getElementById('mStart').addEventListener('click', goMode);
document.getElementById('mStats').addEventListener('click', openStats);
// the world board opens on the mode you last PICKED, like the stats screen does — that is the one
@@ -669,6 +740,9 @@ document.getElementById('stModes').addEventListener('click', e => {
stats.drawStats(stPeriod, SPRV, stMode);
});
refreshSplash();
// the profile mirror: adopt a server copy that is ahead (a wiped phone, a second device), then
// let the strip show whatever came back. Fire-and-forget — the menu never waits on the network.
profile.pull().then(refreshSplash);
// THE BOOT GATE. render.js fires all ~230 sprite requests at import; this only watches them and
// keeps the splash's buttons out of reach until the last one has landed and both faces are ready.
@@ -731,7 +805,11 @@ addEventListener('keydown', e => {
// one that actually fires on iOS, where beforeunload/unload do not. endRun is idempotent and
// no-ops on an empty run, so closing the tab at the menu writes nothing.
addEventListener('pagehide', () => {
profile.push(); // mirror whatever the run has earned — keepalive lets it survive the unload
if (G.mode !== 'splatter') return;
if (G.drill) return; // the drill banks nothing — its kills leak into the gore
// bag otherwise (killPlayer's drill guard covers death,
// not the tab-close path)
if (G.match && G.match.train) return; // the training court banks nothing, walked out on or not
// ...tagged with the mode that was being played, and carrying THAT mode's two headline numbers —
// see the record-shape note in stats.endRun. A match abandoned mid-rally is a match run.
@@ -749,6 +827,7 @@ const maybeRestart = () => {
const ready = G.done ? G.done.t > DONE_RESTART : G.mode === 'eaten' && G.eatenT > EATEN_RESTART;
if (G.modal || !ready) return;
restartRun();
profile.push(); // the run's xp is banked — mirror it while the moment is quiet
refreshSplash(); // the run just ended: the splash count has moved
intro.hidden = false;
quitChip.hidden = true; // the door out goes with the run it belonged to
@@ -773,6 +852,7 @@ document.getElementById('qNo').addEventListener('click', () => { qAsk.hidden = t
document.getElementById('qYes').addEventListener('click', () => {
qAsk.hidden = true; paused = false; quitChip.hidden = true;
restartRun();
profile.push(); // a quit banks no run, but the xp already awarded is real — mirror it
refreshSplash();
intro.hidden = false;
go('splash');
+146
View File
@@ -0,0 +1,146 @@
// profile.js — THE META-PROGRESSION PROFILE: xp, level, league, and its server mirror.
//
// The plan of record is PROJECT.md "THE LEAGUES". Phase 1 (this file): the clock ticks and is
// visible; nothing reads the level back into gameplay yet. The profile is the ONE persistent
// object the leagues hang off: xp/level (you), league + champion flag (the world), and the setup
// flag that gates the first-boot identity screen. Name, tour, kit and skin deliberately KEEP
// their existing homes (stats' NAME_KEY, the picker localStorage keys) — this file only mirrors
// them to the server so a wiped phone gets its whole identity back, not just its number.
//
// XP comes from ACTIVE PLAY EVENTS, never wall-clock (settled): losing pays too, training pays
// nothing. award() carries the no-stakes guard itself — drill, bench, test mode, the training
// court — so a new call site cannot forget it.
//
// The server mirror is a BACKUP, not an account: localStorage is the authority while it exists,
// and pull() adopts the mirror only when it is AHEAD on xp (a wiped phone, a second device).
// Same trust stance as the world board: the client is public JS, the id is the only claim.
import { API, UI } from './config.js';
import { G } from './state.js';
import { myId, savedName, saveName } from './stats.js';
const KEY = 'splatterProfile';
function load() {
try {
const p = JSON.parse(localStorage.getItem(KEY) || 'null');
if (p && typeof p === 'object') {
return { v: 1, xp: Math.max(0, p.xp | 0), lg: Math.min(5, Math.max(1, p.lg | 0 || 1)),
ch: p.ch ? 1 : 0, setup: p.setup ? 1 : 0 };
}
} catch {}
return { v: 1, xp: 0, lg: 1, ch: 0, setup: 0 };
}
export const prof = load();
export function save() {
try { localStorage.setItem(KEY, JSON.stringify(prof)); } catch {}
}
// ---------------------------------------------------------------- the curve ----
// Fast early, slow late (settled): the first levels land inside the first run — the hook — and
// the late ones each take sessions. Cumulative xp to REACH level l; one constant and one power,
// because a table would be twenty numbers saying the same thing.
export const LEVEL_CAP = 50;
export const xpFor = l => Math.round(80 * Math.pow(Math.max(0, l - 1), 1.8));
export function levelAt(xp) {
let l = 1;
while (l < LEVEL_CAP && xp >= xpFor(l + 1)) l++;
return l;
}
// { level, into, need, frac } — everything a bar needs, one call. frac is 1 at the cap.
export function progress(xp = prof.xp) {
const level = levelAt(xp);
if (level >= LEVEL_CAP) return { level, into: 0, need: 0, frac: 1 };
const a = xpFor(level), b = xpFor(level + 1);
return { level, into: xp - a, need: b - a, frac: (xp - a) / (b - a) };
}
// ---------------------------------------------------------------- awards ----
// What one event pays. Sized so a decent run lands ~1-2k xp: kills are plentiful and cheap,
// points are rarer, a game is the headline. Rebalancing is this table and nothing else.
export const XP = { kill: 2, point: 8, game: 40, duelWin: 60, round: 150, cup: 400 };
// The run's own delta, for the death screen ("+340 XP · LEVEL 7 → 8"). Module-local like the
// stats bag, and for the same reason: G is restored from PRISTINE by restartRun and the summary
// has to survive exactly that. beginRunXp() is called beside stats.beginRun().
let runXp = 0, runFrom = levelAt(prof.xp);
export function beginRunXp() { runXp = 0; runFrom = levelAt(prof.xp); }
export const runDelta = () => ({ xp: runXp, from: runFrom, to: levelAt(prof.xp) });
// ONE guard for every no-stakes mode, in the shared function — a call site cannot forget it.
const muted = () => G.drill || G.bench || G.testMode || (G.match && G.match.train);
export function award(kind, n = 1) {
if (muted()) return 0;
const amt = (XP[kind] || 0) * n;
if (!amt) return 0;
const before = levelAt(prof.xp);
prof.xp += amt; runXp += amt;
save();
const after = levelAt(prof.xp);
// the level-up announces as a CARD, never a modal — it can land on a live court (popup policy)
if (after > before && !G.modal)
G.card = { title: 'LEVEL ' + after, sub1: null, color: UI.pick, t: 2.4 };
return amt;
}
// ---------------------------------------------------------------- the mirror ----
// POST is fire-and-forget with keepalive so the pagehide path can use the same call. The server
// refuses to go backward on xp (see server.py), so a stale device cannot clobber the investment.
export async function push() {
try {
const id = myId();
if (!id) return; // private mode: no claim, no mirror
const grab = k => { try { return localStorage.getItem(k) || ''; } catch { return ''; } };
await fetch(API + 'profile', { method: 'POST', keepalive: true,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, p: { ...prof, n: savedName(),
tour: grab('tour'), kit: grab('kit'), skin: grab('skin') } }) });
} catch {}
}
// Adopt the mirror when it is ahead — and bring the identity back with it, but never overwrite
// an identity this device already has (localStorage is the authority while it exists).
export async function pull() {
try {
const id = myId();
if (!id) return;
const r = await fetch(API + 'profile?id=' + id, { cache: 'no-store' });
if (!r.ok) return;
const p = (await r.json()).p;
if (!p) return;
if ((p.xp | 0) > prof.xp) {
prof.xp = p.xp | 0;
prof.lg = Math.min(5, Math.max(prof.lg, p.lg | 0 || 1));
prof.ch = prof.ch || (p.ch ? 1 : 0);
prof.setup = prof.setup || (p.setup ? 1 : 0);
runFrom = levelAt(prof.xp); // the adopted xp is history, not this run's delta
save();
}
if (!savedName() && p.n) saveName(p.n);
for (const k of ['tour', 'kit', 'skin'])
try { if (!localStorage.getItem(k) && p[k]) localStorage.setItem(k, p[k]); } catch {}
} catch {}
}
// ---------------------------------------------------------------- zombie names ----
// The first-boot prefill and the dice button. Two lists beat one, and the 12-char board limit
// (server NAME_MAX) is enforced by re-rolling, not by truncating a name into gibberish.
const NAME_A = ['ACE', 'DEUCE', 'SMASH', 'VOLLEY', 'GRIM', 'ROT', 'BONE', 'CRYPT', 'GRAVE',
'GHOUL', 'FESTER', 'MOLDY', 'PUTRID', 'RIGOR', 'TOMB', 'SLICE', 'LOB',
'MAGGOT', 'MORBID', 'RANCID', 'SOGGY', 'LIMP'];
const NAME_B = ['FANG', 'DECAY', 'MARROW', 'CADAVER', 'SLUDGE', 'GRISTLE', 'KNUCKLE', 'SOCKET',
'GIBLET', 'WORM', 'SHANK', 'SPLEEN', 'MOLAR', 'STUMP', 'CRAWLER', 'RATTLE',
'DRIBBLE', 'WHIFF'];
export function zombieName() {
for (let i = 0; i < 40; i++) {
const n = NAME_A[(Math.random() * NAME_A.length) | 0] + ' '
+ NAME_B[(Math.random() * NAME_B.length) | 0];
if (n.length <= 12) return n;
}
return 'BONE FANG';
}
+15 -1
View File
@@ -7,6 +7,7 @@ import { RAISERS, FRAMES } from './crowd.js';
import { predictLanding, previewServe, slotId, slotStock, serveApexY, rallyFeel, swayOf } from './gameplay.js';
import { drillQueue, drillLegal, drillMult } from './drill.js';
import { zombies, gore, piles, parts, pellets, extras, shots, pops } from './zombies.js';
import { runDelta, progress } from './profile.js';
// ---------------------------------------------------------------- palette ----
// tennis palette -> drained splatter palette; the transition lerps between them.
@@ -2905,10 +2906,23 @@ export function render() {
: G.classic ? 'LONGEST MATCH ' + G.best.games + ' GAMES'
: 'BEST ' + G.best.games + ' GAMES · ' + G.best.sets + ' SETS', W / 2, H / 2 + 4, UI.dim);
}
// THE RUN'S XP — the moment meta-progress absorbs the permadeath (the leagues, phase 1).
// Muted modes never award, so a training rep or a drill shows nothing here by construction.
const xd = runDelta();
if (xd.xp > 0) {
const pr = progress();
ctx.font = F(8);
shadowText('+' + xd.xp + ' XP' + (xd.to > xd.from ? ' · LEVEL ' + xd.from + ' → ' + xd.to
: ' · LEVEL ' + pr.level),
W / 2, H / 2 + 18, xd.to > xd.from ? UI.pick : UI.cream);
const bw = 120, bx = Math.round(W / 2 - bw / 2), by = Math.round(H / 2 + 30);
ctx.fillStyle = 'rgba(255,255,255,0.14)'; ctx.fillRect(bx, by, bw, 4);
ctx.fillStyle = UI.warm; ctx.fillRect(bx, by, Math.round(bw * pr.frac), 4);
}
// gated well past the animation so a panic tap can't skip it (main.js gates the restart the same)
if (dead ? G.eatenT > EATEN_RESTART : G.done.t > DONE_RESTART) {
ctx.font = F(8);
shadowText(IS_TOUCH ? 'TAP TO PLAY AGAIN' : 'PRESS ANY KEY TO PLAY AGAIN', W / 2, H / 2 + 32, UI.warm);
shadowText(IS_TOUCH ? 'TAP TO PLAY AGAIN' : 'PRESS ANY KEY TO PLAY AGAIN', W / 2, H / 2 + 44, UI.warm);
}
ctx.restore();
}
+1 -1
View File
@@ -182,7 +182,7 @@ export const savedName = () => { try { return localStorage.getItem(NAME_KEY) ||
// Your claim on your name. Minted once, never shown, never leaves this device except to prove a
// submission is yours — see server.py. It is not a login and it protects nothing but the row: lose
// your browser storage and you lose the name, which is what having no accounts costs.
function myId() {
export function myId() { // exported 2026-08-29: the profile mirror keys on the same claim
try {
let v = localStorage.getItem(ID_KEY);
if (!v) { v = Array.from(crypto.getRandomValues(new Uint8Array(12)))
+4 -1
View File
@@ -11,6 +11,7 @@ import { drillKill, drillCaught } from './drill.js';
import { splatPoint, cheer, spentBall, aside, grantWeapon, endBurst, sliceCarom } from './gameplay.js';
import { resolveInterlude, endBossFight } from './beats.js'; // runtime-only cycle, like the gameplay one
import { tally, multiKill, endRun, MODE_MATCH } from './stats.js';
import { award } from './profile.js';
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer
@@ -498,10 +499,12 @@ function killZombie(i, quiet) {
// the one that has been killing you.
// A MATCH OPPONENT FILES UNDER WHO IT IS, not under the generic body it wears: the screen is a
// list of who you have felled, and 'r' is a body, not a name. zb.foe is set by matchOpponent.
if (zb.variant !== 'o')
if (zb.variant !== 'o') {
tally(zb.foe ? zb.foe.key
: zb.variant === 'B' ? 'B' + Math.max(0, BOSS_ART.indexOf(zb.art))
: zb.variant, CAUSE);
award('kill'); // the leagues' clock, at the same single funnel — award() mutes no-stakes modes
}
zombies.splice(i, 1);
// THE DRILL RESOLVES HERE — after the splice, and WITHOUT returning. Both halves are load-bearing:
// splice first, because drillKill can draw a new combination on the spot (a double fault