The ladder has names, the bodies play tennis, and the stats screen splits
THE SPRITES. The match opponent borrowed the turned CPU's art — a body drawn to SHAMBLE at the camera with its racket hanging at its side, which is what was playing tennis points against you. It has its own family now, 'r', drawn off the LIVING opponent's poses: a ready stance, a shuffle pair and a real forehand, at every wound stage, men's and women's — 20 frames, six batches. 'c' keeps its shamble untouched, because in the gore mode shambling is exactly what the turned CPU should be doing. The pose comes from racketAI's own `pose`, so the picture cannot drift from the behaviour, and a one-armed opponent still plays tennis instead of reverting to a walk. Two pipeline bugs found doing it, both fixed at the root in assets/: - _fine.py keyed EVERY magenta pixel, not just the background, and the generator paints racket strings in the backdrop colour more often than not — so frame after frame installed a man holding an empty hoop. Background is now the region connected to the border; magenta left INSIDE a body is repainted pale grey rather than punched out. - the flood fill silently did nothing, because Image.fromarray images are read-only and PIL's floodfill swallows the write error. .copy() plus a real error if it ever fills zero pixels. THE LADDER (handoff slice 2). Rung N takes design N's name off the chapter roster — the club's dead staff play you at tennis here and come for you in gore, same joke twice — plus a personality that is DIALS, not new mechanics: pace, width, flub and speed scalings multiplying numbers racketAI already reads. A rung is a data row. Name plate on the walk-on, and the kill files under the rung rather than under the generic body. THE STATS SCREEN follows the mode split: BODY COUNT / THE LADDER / THE RECORD behind a mode strip, each with its own three figures, its own roster heading and its own footer. The weapon shelf and the roster hide in the modes that have neither, rather than printing a line about something the mode cannot do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No files matched your search
@@ -1187,7 +1187,7 @@
|
||||
"state": true,
|
||||
"check": "loose",
|
||||
"anchor": 0,
|
||||
"subject": "Four frames of the FEMALE ZOMBIE TENNIS PLAYER shown in the FIRST reference image — the same dead woman: matted ponytail, red headband, torn red shirt, dark pleated tennis skirt, grey-green corpse skin, sunken eyes, a wooden racket in her hand. The OTHER reference images are a LIVING tennis player and are there ONLY to show the POSES to copy — never the character, never the clothes, never the skin. HER KIT IS FIXED AND MUST BE IDENTICAL IN EVERY FRAME: matted blond hair in a PONYTAIL, a RED CLOTH HEADBAND tied round her forehead, a torn red short-sleeved tennis shirt, a DARK PLEATED TENNIS SKIRT ENDING ABOVE THE KNEE, bare grey-green dead legs below it, white socks and white tennis shoes, and a wooden racket with a pale strung head. Frame 1: the READY STANCE — copy the pose of reference image 2 exactly: knees bent, weight forward on the toes, the racket held UP in front of the body in both hands, chest and face square to the viewer, waiting to return a serve. Frame 2: a SIDE SHUFFLE STEP toward the viewer — copy the legs of reference image 3, left leg driving out, the racket still held up ready in front. Frame 3: the same shuffle with the RIGHT leg driving out instead — identical to frame 2 in every other respect, only the legs swapped. Frame 4: a FOREHAND SWING at the moment of contact — copy the pose of reference image 4 exactly: the body rotated, the arm extended across, the racket head out to the side at waist height, weight on the front foot. Output exactly 4 figures side by side in ONE horizontal row, all the same height. "
|
||||
"subject": "Four frames of the FEMALE ZOMBIE TENNIS PLAYER shown in the FIRST reference image — the same dead woman: matted ponytail, red headband, torn red shirt, dark pleated tennis skirt, grey-green corpse skin, sunken eyes, a wooden racket in her hand. The OTHER reference images are a LIVING tennis player and are there ONLY to show the POSES to copy — never the character, never the clothes, never the skin. HER KIT IS FIXED AND MUST BE IDENTICAL IN EVERY FRAME: matted blond hair in a PONYTAIL, a RED CLOTH HEADBAND tied round her forehead, a torn red short-sleeved tennis shirt, a DARK PLEATED TENNIS SKIRT ENDING ABOVE THE KNEE, bare grey-green dead legs below it, white socks and white tennis shoes, and a wooden racket with a pale strung head. Frame 1: the READY STANCE — copy the pose of reference image 2 exactly: knees bent, weight forward on the toes, the racket held UP in front of the body in both hands, chest and face square to the viewer, waiting to return a serve. Frame 2: a SIDE SHUFFLE STEP toward the viewer — copy the legs of reference image 3, left leg driving out, the racket still held up ready in front. Frame 3: the same shuffle with the RIGHT leg driving out instead — identical to frame 2 in every other respect, only the legs swapped. Frame 4: a FOREHAND SWING at the moment of contact — copy the pose of reference image 4 exactly: the body rotated, the arm extended across, the racket head out to the side at waist height, weight on the front foot. Output exactly 4 figures side by side in ONE horizontal row, all the same height. The four figures MUST NOT TOUCH OR OVERLAP each other in any way — leave a WIDE band of empty magenta between every pair, wider than a figure, including between their rackets and their outstretched arms. "
|
||||
},
|
||||
"racket_f_w1": {
|
||||
"sprites": [
|
||||
|
||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 14 KiB |
@@ -369,16 +369,24 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
|
||||
</div>
|
||||
<!-- BODY COUNT. Every row in here is built by stats.js from the run records in localStorage. -->
|
||||
<div id="stats">
|
||||
<div class="hdr"><button class="chev" id="stBack">‹</button><h2>BODY COUNT</h2></div>
|
||||
<div class="hdr"><button class="chev" id="stBack">‹</button><h2 id="stTitle">BODY COUNT</h2></div>
|
||||
<!-- WHICH MODE, then which period. The three modes do not measure the same thing (see the
|
||||
record-shape note in stats.endRun), so this strip changes the whole screen under it —
|
||||
the title, the three figures, the roster's heading and the footer. -->
|
||||
<div class="tabs" id="stModes">
|
||||
<button class="on" data-m="g">SPLATTER</button>
|
||||
<button data-m="z">TENNIS</button>
|
||||
<button data-m="c">CLASSIC</button>
|
||||
</div>
|
||||
<div class="tabs" id="stTabs">
|
||||
<button class="on" data-p="all">ALL TIME</button>
|
||||
<button data-p="last">LAST MATCH</button>
|
||||
<button data-p="last">LAST RUN</button>
|
||||
<button data-p="7d">7 DAYS</button>
|
||||
</div>
|
||||
<div class="figs" id="stFigs"></div>
|
||||
<h3>BY OPPONENT<span id="stPeriod"></span></h3>
|
||||
<h3 id="stZHdr">BY OPPONENT<span id="stPeriod"></span></h3>
|
||||
<div id="stZ"></div>
|
||||
<h3>BY WEAPON</h3>
|
||||
<h3 id="stWHdr">BY WEAPON</h3>
|
||||
<div id="stW"></div>
|
||||
<div class="foot" id="stFoot"></div>
|
||||
</div>
|
||||
@@ -422,6 +430,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="src/main.js?v=179"></script>
|
||||
<script type="module" src="src/main.js?v=181"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -7,7 +7,7 @@
|
||||
// (beats <-> gameplay and beats <-> zombies are runtime-only import cycles, same pattern as
|
||||
// input <-> gameplay: the imported bindings are only touched inside functions, never at load.)
|
||||
|
||||
import { TUNE, UI, bossArt, COURT, HOME } from './config.js';
|
||||
import { TUNE, UI, bossArt, matchRung, COURT, HOME } from './config.js';
|
||||
import { G, P, O, ball, score, setupPoint, sendBoy } from './state.js';
|
||||
import { voice, zombieDuck, aww } from './audio.js';
|
||||
import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.js';
|
||||
@@ -258,6 +258,11 @@ function matchOpponent() {
|
||||
spawnRacketZombie();
|
||||
const zb = zombies.find(z => z.variant === 'r');
|
||||
zb.dueled = true; // wakes racketAI, same as a fresh duel
|
||||
// WHO THIS ONE IS. The rung carries a name off the chapter roster and a set of dial scalings —
|
||||
// pure data, read where the dials are already read (see matchRung in config). It rides on the
|
||||
// BODY so that killZombie can file the kill under it without asking G what rung we were on.
|
||||
zb.rung = matchRung(G.match.opp);
|
||||
G.card = { title: zb.rung.name, sub1: zb.rung.line, color: UI.hot, t: 2.4 };
|
||||
zb.hp = zb.hpMax = TUNE.matchOppHp; // a FLAT pool: with regicide gone the body is pure
|
||||
// scoreboard (a quarter per point), so a deeper pool would
|
||||
// change nothing — the ladder escalates in racketAI instead
|
||||
@@ -308,7 +313,10 @@ function matchGameWon(zb, banked = false) {
|
||||
G.runGames++; // the ladder metric, same as every mode
|
||||
G.hp = Math.min(G.hpMax, G.hp + TUNE.matchHeal); // a clean game is how you last
|
||||
voice('game_player', 0.3); cheer(2.4, 0.6);
|
||||
G.card = { title: 'GAME.', sub1: 'NEXT OPPONENT', color: UI.pick, t: 2.2 };
|
||||
// GAME is the FLASH and the next opponent's name plate is the CARD. It used to be the other way
|
||||
// round and matchOpponent's plate landed on the same slot a frame later, so 'GAME.' never drew
|
||||
// once — and the plate is the better card anyway: it announces the win by replacing the loser.
|
||||
G.flash = { text: 'GAME.', t: 1.4 };
|
||||
d.opp++;
|
||||
matchOpponent(); // fresh body, its own intro beat
|
||||
d.ending = false;
|
||||
|
||||
@@ -667,6 +667,35 @@ export const BOSS_ART = [
|
||||
export const bossArt = ch => ch % 10 === 0 ? BOSS_ART[BOSS_ART.length - 1]
|
||||
: BOSS_ART[(ch - 1 - Math.floor(ch / 10)) % (BOSS_ART.length - 1)];
|
||||
|
||||
// ---- THE MATCH LADDER. Zombie tennis walks the SAME dead club staff onto the court as the gore
|
||||
// mode's chapter bosses — the roster is the joke, and it is worth telling twice: in gore they come
|
||||
// for you, here they play you at tennis. So rung N takes design N's NAME (bossArt, champion on
|
||||
// ten) and a PERSONALITY, and the body stays the racket zombie's, which is the one drawn to play.
|
||||
//
|
||||
// A personality is DIALS, not new mechanics: every field below multiplies a number racketAI and the
|
||||
// return shaping already read, so a rung is a data row and never a branch. All optional.
|
||||
// pace scale on its returns' speed wide scale on how far off centre they may land
|
||||
// flub scale on its whiff chance speed scale on how fast it covers the ground
|
||||
// line the personality, said once, on the name plate as it walks on
|
||||
const LADDER_TRAITS = [
|
||||
{ line: 'HE MOWED THIS LAWN FOR THIRTY YEARS.', speed: 0.85, pace: 0.9, wide: 0.85 },
|
||||
{ line: 'EVERY BALL OF YOURS IS OUT.', wide: 1.25, flub: 0.8 },
|
||||
{ line: 'HE WILL NOT BE OVERRULED.', speed: 1.1, flub: 0.7 },
|
||||
{ line: 'HE HAS SEEN YOUR BACKHAND.', pace: 1.15, wide: 1.1 },
|
||||
{ line: 'HE IS HERE TO TREAT YOU.', speed: 1.2, pace: 1.1 },
|
||||
{ line: 'THE CROWD LOVES HIM.', flub: 1.2, wide: 1.3 },
|
||||
{ line: 'HE RESTRUNG THIS RACKET HIMSELF.', pace: 1.2, flub: 0.75 },
|
||||
{ line: 'HE CAN HAVE YOU REMOVED.', speed: 1.15, wide: 1.2 },
|
||||
{ line: 'WITH CREAM.', pace: 1.1, speed: 1.1, wide: 1.15 },
|
||||
{ line: 'HE RETURNS EVERYTHING.', pace: 1.25, speed: 1.3, wide: 1.25, flub: 0.5 },
|
||||
];
|
||||
// Rung N's identity. The chapter roster's own walk is reused wholesale (bossArt), so the champion
|
||||
// lands on rung 10 exactly as he lands on chapter 10 and the two rosters can never drift apart.
|
||||
export const matchRung = n => {
|
||||
const art = bossArt(n), i = BOSS_ART.indexOf(art);
|
||||
return { name: art.name, key: 'L' + i, ...LADDER_TRAITS[i] };
|
||||
};
|
||||
|
||||
// The roster, as the BODY COUNT screen reads it. Until now a variant's NAME lived only in the
|
||||
// comment above its hp value — fine while nothing ever had to print one. The key is the `variant`
|
||||
// char the zombie already carries, except that 'a' and 'b' fold together (they are one shambler in
|
||||
@@ -694,6 +723,12 @@ export const ENEMIES = {
|
||||
for (let i = 0; i < BOSS_ART.length; i++) // the chapter bosses, named where they already are
|
||||
ENEMIES['B' + i] = { name: BOSS_ART[i].name, spr: BOSS_ART[i].walk[0], boss: true,
|
||||
sub: i === BOSS_ART.length - 1 ? 'THE FINAL · EVERY TENTH CHAPTER' : 'CHAPTER BOSS' };
|
||||
// ...and the same roster again as MATCH opponents ('L0'..), because that is who walks on in zombie
|
||||
// tennis. Separate keys, not shared ones: the two are different fights with different bodies, they
|
||||
// are counted on different screens now (the mode split in stats.js), and one row cannot mean both.
|
||||
for (let i = 0; i < BOSS_ART.length; i++)
|
||||
ENEMIES['L' + i] = { name: BOSS_ART[i].name, spr: 'z_r1', ladder: true,
|
||||
sub: LADDER_TRAITS[i].line };
|
||||
|
||||
// Which wound stage a body is in: 0 = unhurt, 1..n = the skins, spread evenly over its full health.
|
||||
// This is the rule the chapter boss already used, and it turns out to reproduce the brute's old
|
||||
|
||||
@@ -152,7 +152,9 @@ function doHit(ch, quality, aim) {
|
||||
// at ~27 m/s (baseShotSpeed + 0.55 quality) — crossing to your baseline in ~0.6 s, which is less
|
||||
// time than auto-run needs from the far corner. Opponent one gives you the flight time; the
|
||||
// rungs take it back.
|
||||
s *= Math.min(1, TUNE.matchOppPace + (G.match.opp - 1) * TUNE.matchOppPacePer);
|
||||
// ...and the RUNG's own pace trait rides on top, inside the cap: full pace is still the ceiling,
|
||||
// a rung just gets there sooner or later than the flat ladder would (see matchRung in config).
|
||||
s *= Math.min(1, (TUNE.matchOppPace + (G.match.opp - 1) * TUNE.matchOppPacePer) * (ch.rung?.pace || 1));
|
||||
}
|
||||
if (G.match) {
|
||||
// THE OPPONENT'S WIDTH IS THE LADDER'S THIRD DIAL. Its returns scatter off aimError x (1 - 0.55
|
||||
@@ -160,7 +162,7 @@ function doHit(ch, quality, aim) {
|
||||
// balls". Opponent one keeps its returns inside matchWideBase of the centre line; each rung
|
||||
// earns matchWidePer more width, so the line-painting is the LATE ladder, not the opening one.
|
||||
if (ch.side !== 'P') {
|
||||
const wide = TUNE.matchWideBase + (G.match.opp - 1) * TUNE.matchWidePer;
|
||||
const wide = (TUNE.matchWideBase + (G.match.opp - 1) * TUNE.matchWidePer) * (ch.rung?.wide || 1);
|
||||
tx = Math.max(-wide, Math.min(wide, tx));
|
||||
}
|
||||
// THE PLACEMENT, MADE VISIBLE: your aimed rally hit stamps its landing point, and render draws a
|
||||
@@ -1047,7 +1049,8 @@ function racketAI(rz, dt) {
|
||||
// so a harder opponent is a FASTER one that flubs less. The BASE is boosted too — the duel's
|
||||
// 2.8 m/s lumber was tuned for one sudden-death point against a fresh player, and across a
|
||||
// whole game it read as "very weak and slow": opponent one already moves at matchSpeedBase.
|
||||
const spd = TUNE.racketSpeed * (G.match ? TUNE.matchSpeedBase + (G.match.opp - 1) * TUNE.matchSpeedPer : 1);
|
||||
const spd = TUNE.racketSpeed * (G.match ? (TUNE.matchSpeedBase + (G.match.opp - 1) * TUNE.matchSpeedPer)
|
||||
* (rz.rung?.speed || 1) : 1);
|
||||
const step = Math.min(spd * dt, d);
|
||||
rz.dist = (rz.dist || 0) + step; // ground covered — his walk cycle steps off this, not the clock
|
||||
rz.x += dx / d * step; rz.z += dz / d * step; rz.runT += dt;
|
||||
@@ -1059,7 +1062,8 @@ function racketAI(rz, dt) {
|
||||
&& ball.y < CONTACT.maxY && ball.y > CONTACT.minY) {
|
||||
// ...and the match has its OWN flub base: the duel's 0.2 hands a fifth of all returns away,
|
||||
// which across a game is an opponent that beats itself.
|
||||
const flub = G.match ? TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, G.match.opp - 1) : TUNE.racketFlub;
|
||||
const flub = G.match ? TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, G.match.opp - 1) * (rz.rung?.flub || 1)
|
||||
: TUNE.racketFlub;
|
||||
if (Math.random() < flub) rz.swingCool = 0.5; // whiff — it hesitates (rarer per rung of the ladder)
|
||||
else { rz.swingT = TUNE.swingWindup + TUNE.swingWindow / 2; trySwing(rz, true, TUNE.racketQMax); rz.swingT = -1; rz.swingCool = 0.3; }
|
||||
}
|
||||
|
||||
@@ -174,8 +174,14 @@ function start() {
|
||||
G.classic = pickedMode === 'classic';
|
||||
if (pickedMode === 'zombie') { intoSplatter(); startMatch(); }
|
||||
}
|
||||
let stPeriod = 'all';
|
||||
const openStats = () => { stats.drawStats(stPeriod, SPRV); go('stats'); };
|
||||
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' ? stats.MODE_MATCH : pickedMode === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
|
||||
for (const el of document.getElementById('stModes').children) el.classList.toggle('on', el.dataset.m === stMode);
|
||||
stats.drawStats(stPeriod, SPRV, stMode); go('stats');
|
||||
};
|
||||
document.getElementById('mStart').addEventListener('click', () => go('setup'));
|
||||
document.getElementById('mStats').addEventListener('click', openStats);
|
||||
document.getElementById('mRanks').addEventListener('click', () => { go('ranks'); stats.drawRanks(); });
|
||||
@@ -282,11 +288,18 @@ for (let ch = 1; ch <= 10; ch++) {
|
||||
bossList.appendChild(b);
|
||||
}
|
||||
// the three period tabs are one listener on the strip — the button carries the period in data-p
|
||||
// the two tab strips are one listener each — the button carries its value in a data attribute
|
||||
document.getElementById('stTabs').addEventListener('click', e => {
|
||||
const b = e.target.closest('button'); if (!b) return;
|
||||
stPeriod = b.dataset.p;
|
||||
for (const el of e.currentTarget.children) el.classList.toggle('on', el === b);
|
||||
stats.drawStats(stPeriod, SPRV);
|
||||
stats.drawStats(stPeriod, SPRV, stMode);
|
||||
});
|
||||
document.getElementById('stModes').addEventListener('click', e => {
|
||||
const b = e.target.closest('button'); if (!b) return;
|
||||
stMode = b.dataset.m;
|
||||
for (const el of e.currentTarget.children) el.classList.toggle('on', el === b);
|
||||
stats.drawStats(stPeriod, SPRV, stMode);
|
||||
});
|
||||
refreshSplash();
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ export function proj(x, y, 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 = 92;
|
||||
export const SPRV = 93;
|
||||
// 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".
|
||||
|
||||
@@ -209,19 +209,14 @@ const wIcon = id => id === 'ball' ? 'w_ball' : WEAPONS[id] ? WEAPONS[id].icon :
|
||||
// ----------------------------------------------------------------- screens ----
|
||||
// Both screens are built here rather than in main.js: they are the only readers of the shapes
|
||||
// above, and main.js is the boot file, not a view layer.
|
||||
export function drawStats(period, v) {
|
||||
const d = read(period);
|
||||
document.getElementById('stPeriod').textContent =
|
||||
period === 'last' ? 'LAST MATCH' : period === '7d' ? '7 DAYS' : 'ALL TIME';
|
||||
document.getElementById('stFigs').innerHTML = [
|
||||
['KILLS', n(d.kills)], ['MULTI-KILLS', n(d.multi)], ['DEATHS', n(d.deaths)],
|
||||
].map(([k, val], i) => `<div class="fig${i === 2 ? ' bad' : ''}"><b>${k}</b><i>${val}</i></div>`).join('');
|
||||
|
||||
// sorted by count, and only bodies you have actually met — an empty roster of everything would
|
||||
// read as a checklist of things to go and do, which this screen is not
|
||||
const rows = Object.entries(d.z).filter(([, c]) => c > 0).sort((a, b) => b[1] - a[1]);
|
||||
// ONE SCREEN, THREE MODES. The three do not share a currency — see the record-shape note in
|
||||
// endRun — so they do not share a layout either: gore counts bodies and weapons, zombie tennis
|
||||
// counts NAMED opponents felled, classic tennis counts matches won and lost. What they do share is
|
||||
// the furniture (the period tabs, the figure strip, the roster list, the footer), so this is one
|
||||
// function with three sets of words rather than three screens to keep in step.
|
||||
const roster = (el, rows, v, empty) => {
|
||||
const top = rows.length ? rows[0][1] : 1;
|
||||
document.getElementById('stZ').innerHTML = rows.length ? rows.map(([k, c]) => {
|
||||
el.innerHTML = rows.length ? rows.map(([k, c]) => {
|
||||
// a key with no row is a save written by an older build: name it off the key, stripped, because
|
||||
// this is the one string on the screen that does not come from our own tables
|
||||
const e = ENEMIES[k] || { name: k.replace(/[^A-Za-z0-9]/g, '').toUpperCase() || '?', spr: 'z_a1', sub: '' };
|
||||
@@ -230,15 +225,53 @@ export function drawStats(period, v) {
|
||||
<div class="zb"><b>${e.name}</b><span>${n(c)}</span>
|
||||
<div class="pb"><s style="width:${Math.max(3, c / top * 100)}%"></s></div>
|
||||
<i>${e.sub}</i></div></div>`;
|
||||
}).join('') : '<p class="none">NOTHING YET. GO AND EARN A ROW.</p>';
|
||||
}).join('') : `<p class="none">${empty}</p>`;
|
||||
};
|
||||
|
||||
export function drawStats(period, v, mode = MODE_GORE) {
|
||||
const d = read(period, undefined, mode);
|
||||
const $ = id => document.getElementById(id);
|
||||
$('stPeriod').textContent = period === 'last' ? 'LAST RUN' : period === '7d' ? '7 DAYS' : 'ALL TIME';
|
||||
$('stTitle').textContent = mode === MODE_MATCH ? 'THE LADDER'
|
||||
: mode === MODE_CLASSIC ? 'THE RECORD' : 'BODY COUNT';
|
||||
const figs = mode === MODE_MATCH
|
||||
? [['FELLED', n(d.kills)], ['DEEPEST', n(d.longest)], ['RUNS', n(d.matches)]]
|
||||
: mode === MODE_CLASSIC
|
||||
? [['PLAYED', n(d.matches)], ['WON', n(d.matches - d.deaths)], ['LOST', n(d.deaths)]]
|
||||
: [['KILLS', n(d.kills)], ['MULTI-KILLS', n(d.multi)], ['DEATHS', n(d.deaths)]];
|
||||
// the third figure is the RED one in gore and in classic (deaths, matches lost); in the match it
|
||||
// is just how many runs you have played, so nothing on that strip is bad news
|
||||
const badIdx = mode === MODE_MATCH ? -1 : 2;
|
||||
$('stFigs').innerHTML = figs.map(([k, val], i) =>
|
||||
`<div class="fig${i === badIdx ? ' bad' : ''}"><b>${k}</b><i>${val}</i></div>`).join('');
|
||||
|
||||
// sorted by count, and only bodies you have actually met — an empty roster of everything would
|
||||
// read as a checklist of things to go and do, which this screen is not
|
||||
const rows = Object.entries(d.z).filter(([, c]) => c > 0).sort((a, b) => b[1] - a[1]);
|
||||
$('stZHdr').firstChild.textContent = mode === MODE_MATCH ? 'OPPONENTS FELLED' : 'BY OPPONENT';
|
||||
roster($('stZ'), rows, v, mode === MODE_MATCH ? 'NOBODY HAS GONE DOWN YET.' : 'NOTHING YET. GO AND EARN A ROW.');
|
||||
|
||||
// THE WEAPON SHELF IS THE GORE MODE'S ALONE. Both tennis modes are played with plain balls (the
|
||||
// rack is hidden in both), so the section is hidden rather than printed empty — a shelf that says
|
||||
// NO WEAPONS FIRED for a mode that has none reads as something you failed to do.
|
||||
const ws = Object.entries(d.w).filter(([id, c]) => c > 0 && wIcon(id)).sort((a, b) => b[1] - a[1]);
|
||||
document.getElementById('stW').innerHTML = ws.length ? ws.map(([id, c]) =>
|
||||
// style.display, not the `hidden` attribute: #stW carries a display rule of its own and any
|
||||
// declared display beats [hidden]'s, so the shelf stayed on screen under a mode with no weapons.
|
||||
const off = mode !== MODE_GORE ? 'none' : '';
|
||||
$('stWHdr').style.display = off; $('stW').style.display = off;
|
||||
// ...and CLASSIC TENNIS has no roster either: nothing dies in it. A section printing that is a
|
||||
// line about something the mode does not do, which is worse than the section not being there.
|
||||
const noZ = mode === MODE_CLASSIC ? 'none' : '';
|
||||
$('stZHdr').style.display = noZ; $('stZ').style.display = noZ;
|
||||
$('stW').innerHTML = ws.length ? ws.map(([id, c]) =>
|
||||
`<div class="wtile"><img src="assets/${wIcon(id)}.png?v=${v}" alt=""><b>${n(c)}</b></div>`).join('')
|
||||
: '<p class="none">NO WEAPONS FIRED.</p>';
|
||||
|
||||
document.getElementById('stFoot').innerHTML =
|
||||
`<b>LONGEST RUN</b><i>${d.longest} GAME${d.longest === 1 ? '' : 'S'}</i>`;
|
||||
$('stFoot').innerHTML = mode === MODE_MATCH
|
||||
? `<b>DEEPEST RUN</b><i>${d.longest} FELLED</i>`
|
||||
: mode === MODE_CLASSIC
|
||||
? `<b>LONGEST MATCH</b><i>${d.longest} GAME${d.longest === 1 ? '' : 'S'}</i>`
|
||||
: `<b>LONGEST RUN</b><i>${d.longest} GAME${d.longest === 1 ? '' : 'S'}</i>`;
|
||||
}
|
||||
|
||||
// The real board. Async, and every failure lands on a line of copy rather than an empty screen —
|
||||
|
||||
@@ -491,8 +491,12 @@ function killZombie(i, quiet) {
|
||||
// down, so it earns a row. The barrel is loot and scores nothing anywhere else in the game —
|
||||
// keep that agreement here too. A chapter boss files under its DESIGN, so the screen can name
|
||||
// the one that has been killing you.
|
||||
// A MATCH OPPONENT FILES UNDER ITS RUNG, not under the generic body it wears: the ladder screen
|
||||
// is a list of who you have felled, and 'r' is a body, not a name. zb.rung is set by matchOpponent.
|
||||
if (zb.variant !== 'o')
|
||||
tally(zb.variant === 'B' ? 'B' + Math.max(0, BOSS_ART.indexOf(zb.art)) : zb.variant, CAUSE);
|
||||
tally(zb.rung ? zb.rung.key
|
||||
: zb.variant === 'B' ? 'B' + Math.max(0, BOSS_ART.indexOf(zb.art))
|
||||
: zb.variant, CAUSE);
|
||||
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
|
||||
|
||||