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>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-12 09:34:26 +02:00
1 parent c89d4a3ff9
commit c629ae1108
16 files changed
+138 -33

No files matched your search

+49 -16
View File
@@ -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 —