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 0000000..ea5d868 Binary files /dev/null and b/assets/scoreboard_z.png differ diff --git a/index.html b/index.html index 5955b09..97a3b69 100644 --- a/index.html +++ b/index.html @@ -430,6 +430,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + 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 }