From d5942e8aad8162ed461072e99615e7c69831bb31 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Wed, 12 Aug 2026 09:37:58 +0200 Subject: [PATCH] The match gets its own board, and it names what is across the net MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second plate, drawn rather than tinted (assets/_board.py — _regen.py cannot do this job: it keys magenta, crops to ink and rescales figures to a common height, and a scoreboard is a full-bleed rectangle with no figure in it). The club's bottle-green board is the club's; the match is played on one nobody has cleaned in a long time, and the ink changes with it — a green 'dim' over dried blood reads as a printing fault. Two column changes go with it: the name slot says KEEPER / JUDGE / CHAMPION instead of THEM (eight characters, `short` on the ladder rows), and the SETS column is gone in the match, where it was a permanent 0 — the bar under the board already carries that mode's progress. Co-Authored-By: Claude Opus 5 --- assets/_board.py | 76 ++++++++++++++++++++++++++++++++++++++++ assets/scoreboard_z.png | Bin 0 -> 13212 bytes index.html | 2 +- src/config.js | 22 ++++++------ src/render.js | 29 +++++++++++---- 5 files changed, 111 insertions(+), 18 deletions(-) create mode 100644 assets/_board.py create mode 100644 assets/scoreboard_z.png diff --git a/assets/_board.py b/assets/_board.py new file mode 100644 index 0000000..7af7b22 --- /dev/null +++ b/assets/_board.py @@ -0,0 +1,76 @@ +# _board.py — draw a SCOREBOARD PLATE. One frame, full-bleed, no figures. +# +# python3 assets/_board.py match # the zombie-tennis board -> assets/scoreboard_z.png +# +# _regen.py cannot do this job: it keys a magenta background, crops to ink and rescales every figure +# to a common 224 height, and a scoreboard is a full-bleed RECTANGLE with no figure in it. So this +# is the same generate-and-install loop with the sprite machinery taken out — generate, resize to +# the exact plate size the HUD draws (render.js draws it at 150x42 from a 300x84 source), quantise +# to flat pixel-art colours, install. +# +# It is deliberately a plate and NOT a tint of the club's board: the two boards say different things +# and one of them is the club's, so the second is drawn rather than filtered (see the settled rule — +# procedural is for blood, fire and smoke, not for art). +import os, subprocess, sys +import numpy as np +from PIL import Image + +HERE = os.path.dirname(os.path.abspath(__file__)) +SCRATCH = os.environ.get('SPLAT_SCRATCH', '/tmp/splatter-regen') +GEN = os.path.expanduser('~/.claude/skills/generate-image/scripts/generate_image.py') +PY = os.environ.get('SPLAT_PY', sys.executable) +MODEL = os.environ.get('SPLAT_MODEL', 'google/gemini-3-pro-image') + +W, H = 300, 84 # the source size render.js halves to 150x42 + +STYLE = ( + "Draw it as 16-BIT PIXEL ART with hard flat colours and hard edges — no gradients, no blur, no " + "anti-aliasing, no photographic texture, no lighting effects. The image is ONE SOLID PLATE that " + "FILLS THE ENTIRE CANVAS EDGE TO EDGE: no background around it, no drop shadow, no border of " + "empty space, no perspective, no rounded corners. Straight on, flat, like a UI panel in a " + "Super Nintendo sports game. ABSOLUTELY NO TEXT, NO LETTERS, NO NUMBERS AND NO LOGOS anywhere. " +) + +BOARDS = { + 'match': dict( + out='scoreboard_z.png', ref='scoreboard.png', + subject=( + "The reference is a tennis club's manual scoreboard panel drawn as a 16-bit UI plate: a " + "bevelled outer frame, a flat field, a coloured vertical spine down the left edge, and a " + "recessed sunken slot in the right third. Draw a DIFFERENT board with EXACTLY the same " + "layout, the same proportions and the same parts in the same places — but it is the " + "board of a court that has gone wrong: the field is a dark dried BLOOD RED-BROWN instead " + "of bottle green, the frame is tarnished pitted BRASS gone dark and green at the edges, " + "the left spine is a deep BONE-WHITE strip, and the recessed right slot is near-black " + "with a faint red glow inside it. Add fine cracks and old dark stains across the field, " + "and a few chipped patches on the frame — a board nobody has cleaned in a long time. " + )), +} + +def install(name): + spec = BOARDS[name] + os.makedirs(SCRATCH, exist_ok=True) + raw = f'{SCRATCH}/board_{name}.png' + ref = os.path.join(HERE, spec['ref']) + # the reference is upscaled: a 300x84 source is small enough that the generator reads it as a + # thumbnail and answers with a thumbnail's worth of detail + Image.open(ref).convert('RGB').resize((W * 4, H * 4), Image.NEAREST).save(f'{SCRATCH}/board_{name}_ref.png') + r = subprocess.run([PY, GEN, spec['subject'] + STYLE, '--model', MODEL, + '--input', f'{SCRATCH}/board_{name}_ref.png', '--output', raw], + capture_output=True, text=True, cwd=os.path.dirname(HERE)) + if not os.path.exists(raw): + print(f'[{name}] FAILED: generator returned no image\n{r.stdout[-500:]}\n{r.stderr[-500:]}') + return False + im = Image.open(raw).convert('RGB').resize((W, H), Image.BOX) # box filter: an honest downscale + # quantise to a small flat palette, the same idea _fine.py ends on — a generated plate arrives + # with hundreds of near-identical shades and reads as a photo of pixel art rather than as pixel art + a = np.array(im) + q = Image.fromarray(a).quantize(colors=24, method=Image.MEDIANCUT, dither=Image.NONE).convert('RGB') + q.convert('RGBA').save(os.path.join(HERE, spec['out'])) + print(f'[{name}] installed {spec["out"]} {W}x{H}') + return True + +if __name__ == '__main__': + names = sys.argv[1:] or list(BOARDS) + ok = all(install(n) for n in names) + sys.exit(0 if ok else 1) diff --git a/assets/scoreboard_z.png b/assets/scoreboard_z.png new file mode 100644 index 0000000000000000000000000000000000000000..ea5d8689f73e854568a219566c18d4fe4c188188 GIT binary patch literal 13212 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 O0000 - + diff --git a/src/config.js b/src/config.js index ddb9a5c..3d88ae3 100644 --- a/src/config.js +++ b/src/config.js @@ -677,17 +677,19 @@ export const bossArt = ch => ch % 10 === 0 ? BOSS_ART[BOSS_ART.length - 1] // 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 +// short 8 characters, because that is the board's name slot — the match board names +// the thing you are playing instead of calling it THEM 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 }, + { short: 'KEEPER', line: 'HE MOWED THIS LAWN FOR THIRTY YEARS.', speed: 0.85, pace: 0.9, wide: 0.85 }, + { short: 'JUDGE', line: 'EVERY BALL OF YOURS IS OUT.', wide: 1.25, flub: 0.8 }, + { short: 'UMPIRE', line: 'HE WILL NOT BE OVERRULED.', speed: 1.1, flub: 0.7 }, + { short: 'COACH', line: 'HE HAS SEEN YOUR BACKHAND.', pace: 1.15, wide: 1.1 }, + { short: 'PHYSIO', line: 'HE IS HERE TO TREAT YOU.', speed: 1.2, pace: 1.1 }, + { short: 'MASCOT', line: 'THE CROWD LOVES HIM.', flub: 1.2, wide: 1.3 }, + { short: 'STRINGER', line: 'HE RESTRUNG THIS RACKET HIMSELF.', pace: 1.2, flub: 0.75 }, + { short: 'DIRECTOR', line: 'HE CAN HAVE YOU REMOVED.', speed: 1.15, wide: 1.2 }, + { short: 'SELLER', line: 'WITH CREAM.', pace: 1.1, speed: 1.1, wide: 1.15 }, + { short: 'CHAMPION', 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. diff --git a/src/render.js b/src/render.js index abac880..9a86b80 100644 --- a/src/render.js +++ b/src/render.js @@ -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 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 = 93; +export const SPRV = 94; // 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". @@ -263,7 +263,7 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash',' 'z_c1_f','z_c2_f','z_c3_f','z_c4_f','z_cw1_f','z_cw2_f','z_cw1b_f','z_cw2b_f', 'z_r1_f','z_r2_f','z_r3_f','z_r4_f', 'z_rw1_f','z_rw1b_f','z_rw1s_f','z_rw2_f','z_rw2b_f','z_rw2s_f', - 'scoreboard']) { + 'scoreboard','scoreboard_z']) { // the club's board, and the one the match is played on const img = new Image(); // ?v=SPRV, for the same reason index.html versions main.js. It is also what buys the year-long // immutable cache in server.py: a versioned URL can never go stale, because changing the file @@ -2057,15 +2057,24 @@ export function render() { // sunk into a recessed slot — purple for the server, gold digits. Splatter adds a recessed // status well (kills-to-next-point pips + weapon stock). Hard edges, no radius = retro. const splat = G.mode !== 'tennis'; - const bd = { face: '#1e5b3a', hi: '#3f9560', lo: '#0e3624', - spine: '#472a70', spineHi: '#5f3d94', cream: '#f2ead6', dim: '#89a08f', gold: UI.pick }; + // ZOMBIE TENNIS HAS ITS OWN BOARD. The club's bottle-green plate is the club's; the match is + // played on a board nobody has cleaned in a long time (assets/_board.py), and its ink has to + // change with it — a green 'dim' over dried blood reads as a printing fault. Falls back to the + // club board and its palette until the plate has loaded. + const zBoard = G.match && SPR.scoreboard_z; + const bd = zBoard + ? { face: '#4a1c1c', hi: '#7a4a2a', lo: '#2a0e0e', + spine: '#d9d2c4', spineHi: '#f2ead6', cream: '#f2ead6', dim: '#a8887c', gold: UI.warm } + : { face: '#1e5b3a', hi: '#3f9560', lo: '#0e3624', + spine: '#472a70', spineHi: '#5f3d94', cream: '#f2ead6', dim: '#89a08f', gold: UI.pick }; const R = (x, y, w, h, c) => { ctx.fillStyle = c; ctx.fillRect(x, y, w, h); }; const ptTxt = i => score.pts[0] >= 3 && score.pts[1] >= 3 ? (score.pts[i] === score.pts[1 - i] ? '40' : score.pts[i] > score.pts[1 - i] ? 'AD' : '40') : ['0', '15', '30', '40'][Math.min(score.pts[i], 3)]; ctx.save(); ctx.translate(6, 6); const bW = 150, bH = 42; // fixed: keeps the ornate PNG undistorted (art is 300x84 = 150x42) - if (SPR.scoreboard) ctx.drawImage(SPR.scoreboard, 0, 0, bW, bH); // generated ornate board backdrop + const plate = zBoard || SPR.scoreboard; + if (plate) ctx.drawImage(plate, 0, 0, bW, bH); // generated ornate board backdrop else { // code-drawn fallback until the PNG loads R(0, 0, bW, bH, bd.face); R(0, 0, bW, 1, bd.hi); R(0, 0, 1, bH, bd.hi); @@ -2075,7 +2084,11 @@ export function render() { // live text laid straight onto the art's own regions (spine ~x14, two rows, right slot ~x105-147), // Silkscreen pixel font — no code-drawn slots or bevels painted on top of the art. ctx.textBaseline = 'middle'; - const names = splat ? ['YOU', 'THEM'] : ['YOU', 'CPU']; + // ...and the match names the thing across the net rather than calling it THEM. Eight characters, + // which is the name slot — see `short` on the ladder rows. + const rung = G.match && zombies.find(z => z.variant === 'r' && z.rung); + const names = G.match ? ['YOU', (rung && rung.rung.short) || 'THEM'] + : splat ? ['YOU', 'THEM'] : ['YOU', 'CPU']; for (let i = 0; i < 2; i++) { const cy = 12 + i * 18, svr = (score.server === 'P') === (i === 0); // rows aligned to the art's two bands if (svr) { ctx.fillStyle = bd.gold; ctx.beginPath(); ctx.arc(11, cy, 1.8, 0, 7); ctx.fill(); } // server pip on the purple spine @@ -2084,7 +2097,9 @@ export function render() { ctx.textAlign = 'center'; // ...and CLASSIC TENNIS shows them too: it is the one mode where a set is the thing you are // actually playing for, and hiding the column left a match-scoring mode reading 0-0 all match. - if (splat || G.classic) { ctx.font = F(8, 0); ctx.fillStyle = bd.dim; ctx.fillText(String(G.sets[i]), 74, cy); } // sets + // the SETS column, and NOT in the match: it banks no sets, so the column was a permanent 0 — + // the bar under the board carries that mode's progress instead (OPPONENT n · m FELLED) + if ((splat || G.classic) && !G.match) { ctx.font = F(8, 0); ctx.fillStyle = bd.dim; ctx.fillText(String(G.sets[i]), 74, cy); } ctx.font = F(8); ctx.fillStyle = bd.cream; ctx.fillText(String(score.games[i]), 90, cy); // games ctx.font = F(8); ctx.fillStyle = svr ? bd.gold : bd.cream; ctx.fillText(ptTxt(i), 126, cy); // points, in the art's slot }