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:
1 parent
bc10216d0b
commit
9133c0d351
12 files changed
+507
-19
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user