diff --git a/.gitignore b/.gitignore index 6b6b1fb..4c38cdd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .env scores.json scores.json.tmp +assets/_gen/ +assets/_refcheck/ +assets/__pycache__/ diff --git a/assets/_regen.py b/assets/_regen.py index 1ce8b81..25a8447 100644 --- a/assets/_regen.py +++ b/assets/_regen.py @@ -14,7 +14,7 @@ # Failures leave the sprites alone and print why, so a batch can just be re-run. import json, os, shutil, subprocess, sys import numpy as np -from PIL import Image +from PIL import Image, ImageDraw HERE = os.path.dirname(os.path.abspath(__file__)) SCRATCH = os.environ.get('SPLAT_SCRATCH', '/tmp/splatter-regen') @@ -49,9 +49,17 @@ CANVAS = ( "MAGENTA AND PINK ARE THE BACKGROUND COLOUR AND MAY NOT APPEAR ANYWHERE ON A FIGURE: no magenta " "clothing, no pink skin, and racket strings are drawn as fine pale grey-white crosshatch over " "the figure's own body or clothes — NEVER left as the background colour showing through. " - "Keep at least 200 " - "pixels of empty magenta between figures and at least 150 pixels of empty magenta at every edge " - "of the canvas: no figure or limb may touch or run off an edge. No shadow, no ground line, no text. " + # MARGINS ARE A PROPORTION, NEVER A PIXEL COUNT. This asked for "150 pixels at every edge" for + # months and the brute came back clipped in three rolls out of four: the generator chooses its + # own output resolution, so an absolute pixel margin means nothing to it — it composed figures at + # 92% of a canvas height it had picked itself. The reference sheet shows 29% margins and that is + # ignored too, so this is the one place the rule can be stated. Fractions it can act on. + "THE FIGURES MUST BE SMALL IN THE FRAME. Draw them at about two thirds of the canvas height, " + "centred, with a wide band of empty magenta above the highest point of the tallest figure " + "(including any raised racket), below the feet, and beyond the outermost figure on each side — " + "roughly a sixth of the canvas on every side. No figure, limb, hair or racket may touch or run " + "off an edge, and the figures must be clearly separated from each other by empty magenta, never " + "overlapping or touching. No shadow, no ground line, no text. " "The canvas is a WIDE LANDSCAPE STRIP and every figure stands side by side in ONE SINGLE HORIZONTAL " "ROW on the same ground line — never a grid, never two rows, never stacked." ) @@ -92,12 +100,46 @@ def batches(): return json.load(f) # ---------------------------------------------------------------- helpers ---- +def fill_holes(im): + """A REFERENCE MAY NOT SHOW A RACKET AS A MAGENTA HOLE. Older sprites were cut before _fine.py + learned to keep enclosed background, so their string beds are TRANSPARENT — o_smash has 3425 such + pixels, o_toss 1786, z_c1 675. Pasted onto the magenta reference canvas those holes come back as + magenta, so every one of those sheets showed the generator a racket whose strings ARE the backdrop + colour, while the prompt spent three sentences forbidding exactly that. It copied the picture, + which is what this project already knows a model does. Fixing it in _fine.py only cleaned the way + IN; this is the way back OUT. + + Filled as CROSSHATCH, not as a flat patch, for the same reason: the prompt asks for "fine pale + grey-white crosshatch" and a reference that shows it beats a sentence that describes it. + + ponytail: every enclosed hole gets the string bed, including the odd gap between an arm and a + torso — so a body can show a small woven patch on its hip. It is a handful of pixels against a + racket-sized win, and the alternative is classifying holes by shape. Revisit only if a roll comes + back with fabric where skin should be.""" + a = np.array(im) + t = a[..., 3] < 128 + # PAD BEFORE FLOODING. _fine.py can seed at (0,0) because its sheet has a wide magenta border; + # an installed SPRITE is cropped tight, so the figure touches all four edges and its surround is + # split into pockets that (0,0) cannot reach. Seeding there alone called 10,732 px of ordinary + # background a "hole" and painted the whole bounding box with string bed. + m = Image.fromarray(np.where(np.pad(t, 1, constant_values=True), 255, 0).astype('uint8'), 'L').copy() + ImageDraw.floodfill(m, (0, 0), 128) # .copy() — see _fine.py, fromarray is read-only + holes = t & (np.array(m)[1:-1, 1:-1] == 255) + if not holes.any(): return im + a[holes] = (186, 186, 186, 255) + y, x = np.indices(a.shape[:2]) + for step, tone in ((3, 148), (4, 214)): # two offset threads = a woven bed, not a grey disc + w = holes & ((x + y * (step - 2)) % step == 0) + a[w, 0] = a[w, 1] = a[w, 2] = tone + return Image.fromarray(a, 'RGBA') + + def ref_sheet(names, out, scale=2.0, gap=220, pad=150, frames=1): """THE REFERENCE'S ASPECT IS THE ANSWER'S ASPECT. One tall reference figure makes a portrait reference canvas, and the generator then answers in portrait — it laid four figures out as a 2x2 GRID, which _fine.py cannot cut (it segments columns). So the canvas is widened to a landscape strip with room for every frame that was asked for, and the figures are spread across it.""" - ims = [Image.open(os.path.join(HERE, n + '.png')).convert('RGBA') for n in names] + ims = [fill_holes(Image.open(os.path.join(HERE, n + '.png')).convert('RGBA')) for n in names] ims = [i.resize((max(1, int(i.width * scale)), max(1, int(i.height * scale))), Image.NEAREST) for i in ims] w = sum(i.width for i in ims) + gap * (len(ims) - 1) + pad * 2 h = max(i.height for i in ims) + pad * 2 @@ -450,9 +492,54 @@ def audit(B, names): print('\nFAILS THE GATE:', ', '.join(bad) if bad else 'none') return not bad +# --verify asks the question no gate here has ever asked: IS THE ART ALREADY ON DISK WHAT IT CLAIMS +# TO BE? Every check in this file fires on a NEW roll and says nothing about installed frames — and a +# chained batch draws its reference from installed frames. So a stage that was wrong the day it +# shipped becomes the reference for the stage below it, and the stage below that, silently and +# forever. The brute's z_brw1fall row is the proof: four copies of ONE standing lean zombie sold as a +# fat man collapsing, differing from each other by 0.011-0.055 where the sequence they were posed +# from differs by 0.53-0.89. Nothing noticed for as long as it has been in the repo. +# +# Two questions, both reusing measures that were already here: +# DUP does this group of frames differ from each other at all? A pose sequence that does not is +# one drawing pasted N times. Real sequences score 0.5+; a stride pair, the tightest legal +# case, still clears 0.12 whole-body. +# PAL is it painted in the anchor's colours? Same dominant-colour distance the palette gate uses. +DUP_MIN = 0.12 +def verify(B, names): + print(f"{'batch':16s} {'frames':>6s} {'DUP':>7s} {'PAL':>6s} verdict") + bad = [] + for n in names: + spec = B[n] + frames = [s for s in spec['sprites'] if os.path.exists(os.path.join(HERE, s + '.png'))] + if len(frames) < len(spec['sprites']): + print(f'{n:16s} {"":>6s} {"":>7s} {"":>6s} MISSING art'); bad.append(n); continue + paths = [os.path.join(HERE, f + '.png') for f in frames] + dup = max((_d(*_masks(a, b)[2:4]) for i, a in enumerate(paths) for b in paths[i + 1:]), + default=1.0) + anchor = spec.get('palette_anchor') + pal = 0.0 + if anchor and os.path.exists(os.path.join(HERE, anchor + '.png')): + ua, _ = dom_colours(os.path.join(HERE, anchor + '.png')) + for p in paths: + uo, wo = dom_colours(p) + if len(ua) and len(uo): + pal = max(pal, float((np.sqrt(((uo[:, None, :] - ua[None, :, :]) ** 2).sum(2)) + .min(1) * wo).sum())) + why = ('ONE DRAWING REPEATED' if dup < DUP_MIN else + f'palette off {anchor}' if pal > spec.get('palette_max', 10) else None) + print(f'{n:16s} {len(frames):6d} {dup:7.3f} {pal:6.1f} {why or "ok"}') + if why: bad.append(n) + print('\nDEAD ART (regenerate before anything is drawn from it):', + ' '.join(bad) if bad else 'none') + return not bad + if __name__ == '__main__': args = sys.argv[1:] B = batches() + if '--verify' in args: + names = [a for a in args if not a.startswith('--')] or list(B) + sys.exit(0 if verify(B, names) else 1) if '--audit' in args: names = [a for a in args if not a.startswith('--')] or [k for k, v in B.items() if v.get('stride')] sys.exit(0 if audit(B, names) else 1) diff --git a/assets/_zsheet.py b/assets/_zsheet.py new file mode 100644 index 0000000..0a4cff7 --- /dev/null +++ b/assets/_zsheet.py @@ -0,0 +1,120 @@ +# _zsheet.py — ONE generated multi-row sheet -> a whole zombie family of sprites. +# +# python3 assets/_zsheet.py assets/_refcheck/brute_all3.png br --out assets/_refcheck/sprites +# python3 assets/_zsheet.py assets/_refcheck/brute_all3.png br --install +# +# WHY THIS EXISTS. _regen.py generates a family as nine separate sheets, and nine separate rolls is +# nine chances for the character to drift — which is exactly what it did, all day. Asking for the +# WHOLE family in one image fixes that by construction: one roll, one character, one light, one +# palette. What was missing was the way back down — a generated sheet is 9 rows of figures and +# _fine.py cuts a single row. This is that step, and nothing else. +# +# TWO THINGS ARE GLOBAL, and both were bugs when they were not: +# SCALE _fine.py anchors one scale per sheet, so nine bands would each be scaled to their own +# tallest figure — the serve strike would come out the same height as the idle stance and +# every pose would pop, because the game sizes a sprite off its own PNG height. The scale +# is taken ONCE, from the standing frame of the play row, and every band uses it. +# PALETTE taken once from the largest figure in the whole sheet, so a wound row cannot quantise +# itself onto a slightly different set of colours than the healthy row it follows. +import os, sys +import numpy as np +from PIL import Image + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _fine import key, block_size, frames, split_piece # the proven keyer and column cutter + +HERE = os.path.dirname(os.path.abspath(__file__)) +TARGET_H = 224 +MIN_BAND = 40 + +# The family layout, exactly config.famFrames' order. The wound rows carry no separate stance — +# the shuffle doubles as one — which is why they are 3 and not 4. +def layout(k): + P = ['bounce', 'toss', 'serve'] + F = ['fall1', 'fall2', 'fall3', 'rise'] + rows = [[f'z_{k}1', f'z_{k}2', f'z_{k}3', f'z_{k}4'], + [f'z_{k}{s}' for s in P], + [f'z_{k}{s}' for s in F]] + for st in (1, 2): + rows += [[f'z_{k}w{st}', f'z_{k}w{st}b', f'z_{k}w{st}s'], + [f'z_{k}w{st}{s}' for s in P], + [f'z_{k}w{st}{s}' for s in F]] + return rows + + +def bands(a): + """Row bands = runs of rows that contain ink. Rows are what a grid has and _fine.py refuses; + here they are the point, so they are found first and each band is handed over as its own row.""" + rows = (a[..., 3] > 0).any(1) + out, s = [], None + for y, v in enumerate(rows): + if v and s is None: s = y + elif not v and s is not None: + if y - s >= MIN_BAND: out.append((s, y)) + s = None + if s is not None and len(rows) - s >= MIN_BAND: out.append((s, len(rows))) + return out + + +def cut(sheet, fam, outdir): + want = layout(fam) + src = Image.open(sheet).convert('RGB') + a = key(src) + # THE ENCLOSED MAGENTA IS THE RACKET, AND HERE IT IS MEANT TO GO. _fine.py repaints magenta that + # is trapped inside a figure as pale grey, because the generator used to fill string beds with + # the backdrop colour BY ACCIDENT and keying them left men holding an empty hoop. This sheet is + # the opposite case: the prompt asks for the string bed to be exactly the background magenta so + # it cuts to transparency, and it obeyed. So every magenta pixel goes, connected to the border + # or not. That is safe only because nothing else on the figure is magenta — check a new sheet. + n = np.array(src).astype(int) + lo = np.minimum(n[..., 0], n[..., 2]) + a[(lo > 140) & (n[..., 1] < lo - 55), 3] = 0 + b = block_size(a) + bs = bands(a) + if len(bs) != len(want): + sys.exit(f'ERROR: found {len(bs)} row bands, expected {len(want)}') + + crops = [] + for (y0, y1), row in zip(bs, want): + band = a[y0:y1] + spans = frames(band, [], len(row)) + if len(spans) != len(row): + sys.exit(f'ERROR: row {row[0]}: found {len(spans)} figures, expected {len(row)}') + for x0, x1 in spans: + sub = band[:, x0:x1] + ys = np.where((sub[..., 3] > 0).any(1))[0] + xs = np.where((sub[..., 3] > 0).any(0))[0] + c = sub[ys.min():ys.max() + 1, xs.min():xs.max() + 1] + if split_piece(c): + sys.exit(f'ERROR: a frame in row {row[0]} holds two stacked bodies') + crops.append(c) + + flat = [n for row in want for n in row] + # ONE scale, off the play row's standing frame; ONE palette, off the biggest figure anywhere. + k = TARGET_H / (crops[0].shape[0] / b) + big = max(crops, key=lambda c: (c[..., 3] > 0).sum()) + px = big[big[..., 3] > 200][:, :3] + pal = np.unique((px // 8) * 8, axis=0) + + os.makedirs(outdir, exist_ok=True) + for c, name in zip(crops, flat): + h, w = c.shape[:2] + snap = Image.fromarray(c, 'RGBA').resize((max(1, round(w / b)), max(1, round(h / b))), Image.BOX) + im = snap.resize((max(1, round(snap.width * k)), max(1, round(snap.height * k))), Image.BOX) + arr = np.array(im).astype(int) + solid = arr[..., 3] > 110 + f = arr[..., :3].reshape(-1, 3) + arr[..., :3] = pal[((f[:, None, :] - pal[None, :, :]) ** 2).sum(2).argmin(1)].reshape(arr.shape[0], arr.shape[1], 3) + arr[..., 3] = np.where(solid, 255, 0) + Image.fromarray(arr.astype('uint8'), 'RGBA').save(os.path.join(outdir, name + '.png')) + print(f'{len(flat)} frames, art block {b}, scale {k:.3f}, palette {len(pal)} colours -> {outdir}') + for (y0, y1), row in zip(bs, want): + hs = [Image.open(os.path.join(outdir, n + '.png')).height for n in row] + print(f' {row[0]:14s} {len(row)} frames heights {hs}') + + +if __name__ == '__main__': + args = [a for a in sys.argv[1:] if not a.startswith('--')] + out = HERE if '--install' in sys.argv else None + if '--out' in sys.argv: out = sys.argv[sys.argv.index('--out') + 1] + cut(args[0], args[1], out or os.path.join(HERE, '_refcheck', 'sprites')) diff --git a/assets/z_br1.png b/assets/z_br1.png index 0a2d978..06f3b13 100644 Binary files a/assets/z_br1.png and b/assets/z_br1.png differ diff --git a/assets/z_br2.png b/assets/z_br2.png index 0477af5..b814c25 100644 Binary files a/assets/z_br2.png and b/assets/z_br2.png differ diff --git a/assets/z_br3.png b/assets/z_br3.png index 52dd159..85c4eb2 100644 Binary files a/assets/z_br3.png and b/assets/z_br3.png differ diff --git a/assets/z_br4.png b/assets/z_br4.png index 475b973..973dd46 100644 Binary files a/assets/z_br4.png and b/assets/z_br4.png differ diff --git a/assets/z_brbounce.png b/assets/z_brbounce.png index cd27266..713d94b 100644 Binary files a/assets/z_brbounce.png and b/assets/z_brbounce.png differ diff --git a/assets/z_brfall1.png b/assets/z_brfall1.png index c7b04df..73f1429 100644 Binary files a/assets/z_brfall1.png and b/assets/z_brfall1.png differ diff --git a/assets/z_brfall2.png b/assets/z_brfall2.png index 41cc54c..0e4633d 100644 Binary files a/assets/z_brfall2.png and b/assets/z_brfall2.png differ diff --git a/assets/z_brfall3.png b/assets/z_brfall3.png index 962a79c..2b53db8 100644 Binary files a/assets/z_brfall3.png and b/assets/z_brfall3.png differ diff --git a/assets/z_brrise.png b/assets/z_brrise.png index eade522..daab3fc 100644 Binary files a/assets/z_brrise.png and b/assets/z_brrise.png differ diff --git a/assets/z_brserve.png b/assets/z_brserve.png index a759d71..65ddad7 100644 Binary files a/assets/z_brserve.png and b/assets/z_brserve.png differ diff --git a/assets/z_brtoss.png b/assets/z_brtoss.png index d16410b..49c7c45 100644 Binary files a/assets/z_brtoss.png and b/assets/z_brtoss.png differ diff --git a/assets/z_brw1.png b/assets/z_brw1.png index ba2cc3e..9d9644e 100644 Binary files a/assets/z_brw1.png and b/assets/z_brw1.png differ diff --git a/assets/z_brw1b.png b/assets/z_brw1b.png index 7a76926..320f1e3 100644 Binary files a/assets/z_brw1b.png and b/assets/z_brw1b.png differ diff --git a/assets/z_brw1bounce.png b/assets/z_brw1bounce.png index 5853711..d874c1e 100644 Binary files a/assets/z_brw1bounce.png and b/assets/z_brw1bounce.png differ diff --git a/assets/z_brw1fall1.png b/assets/z_brw1fall1.png index e8d1a71..1b7aebb 100644 Binary files a/assets/z_brw1fall1.png and b/assets/z_brw1fall1.png differ diff --git a/assets/z_brw1fall2.png b/assets/z_brw1fall2.png index 2712bc4..1762d26 100644 Binary files a/assets/z_brw1fall2.png and b/assets/z_brw1fall2.png differ diff --git a/assets/z_brw1fall3.png b/assets/z_brw1fall3.png index 38c2024..2443705 100644 Binary files a/assets/z_brw1fall3.png and b/assets/z_brw1fall3.png differ diff --git a/assets/z_brw1rise.png b/assets/z_brw1rise.png index e168bba..d9dfd62 100644 Binary files a/assets/z_brw1rise.png and b/assets/z_brw1rise.png differ diff --git a/assets/z_brw1s.png b/assets/z_brw1s.png index 3930775..df8717e 100644 Binary files a/assets/z_brw1s.png and b/assets/z_brw1s.png differ diff --git a/assets/z_brw1serve.png b/assets/z_brw1serve.png index 70e4567..050a432 100644 Binary files a/assets/z_brw1serve.png and b/assets/z_brw1serve.png differ diff --git a/assets/z_brw1toss.png b/assets/z_brw1toss.png index 652bf55..62b924c 100644 Binary files a/assets/z_brw1toss.png and b/assets/z_brw1toss.png differ diff --git a/assets/z_brw2.png b/assets/z_brw2.png index ba2cc3e..83c94bb 100644 Binary files a/assets/z_brw2.png and b/assets/z_brw2.png differ diff --git a/assets/z_brw2b.png b/assets/z_brw2b.png index 7a76926..ebe3507 100644 Binary files a/assets/z_brw2b.png and b/assets/z_brw2b.png differ diff --git a/assets/z_brw2bounce.png b/assets/z_brw2bounce.png index 64efbb7..fdaedec 100644 Binary files a/assets/z_brw2bounce.png and b/assets/z_brw2bounce.png differ diff --git a/assets/z_brw2fall1.png b/assets/z_brw2fall1.png index 920a5c5..37fa161 100644 Binary files a/assets/z_brw2fall1.png and b/assets/z_brw2fall1.png differ diff --git a/assets/z_brw2fall2.png b/assets/z_brw2fall2.png index 86aa31b..7aaa7ca 100644 Binary files a/assets/z_brw2fall2.png and b/assets/z_brw2fall2.png differ diff --git a/assets/z_brw2fall3.png b/assets/z_brw2fall3.png index 240079d..c7e5cef 100644 Binary files a/assets/z_brw2fall3.png and b/assets/z_brw2fall3.png differ diff --git a/assets/z_brw2rise.png b/assets/z_brw2rise.png index 859c892..cc75f59 100644 Binary files a/assets/z_brw2rise.png and b/assets/z_brw2rise.png differ diff --git a/assets/z_brw2s.png b/assets/z_brw2s.png index 3930775..6b95d7f 100644 Binary files a/assets/z_brw2s.png and b/assets/z_brw2s.png differ diff --git a/assets/z_brw2serve.png b/assets/z_brw2serve.png index d2dd036..f1a683b 100644 Binary files a/assets/z_brw2serve.png and b/assets/z_brw2serve.png differ diff --git a/assets/z_brw2toss.png b/assets/z_brw2toss.png index 81807d2..ea6cd1c 100644 Binary files a/assets/z_brw2toss.png and b/assets/z_brw2toss.png differ diff --git a/index.html b/index.html index 70a2f03..313f7c9 100644 --- a/index.html +++ b/index.html @@ -431,6 +431,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/config.js b/src/config.js index 367aecd..0906f7e 100644 --- a/src/config.js +++ b/src/config.js @@ -684,7 +684,7 @@ export const bossArt = ch => ch % 10 === 0 ? BOSS_ART[BOSS_ART.length - 1] // 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 OPPONENTS = [ - { short: 'KEEPER', line: 'HE MOWED THIS LAWN FOR THIRTY YEARS.', speed: 0.85, pace: 0.9, wide: 0.85 }, + { short: 'KEEPER', line: 'HE MOWED THIS LAWN FOR THIRTY YEARS.', speed: 0.85, pace: 0.9, wide: 0.85, fam: 'br' }, { 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 }, @@ -734,7 +734,7 @@ export const BENCH_STAGES = ['HEALTHY', 'ARM OFF', 'HEADLESS']; // that (15 to 48 away from z_br1, where a good frame scores under 8.5) and refuses them, which is // the gate working, not the gate being wrong. Re-registering it is one entry here; see the note at // the top of _master.py for the fix its next attempt needs. -export const ZFAMS = ['r']; +export const ZFAMS = ['r', 'br']; const POSES = ['bounce', 'toss', 'serve', 'fall1', 'fall2', 'fall3', 'rise']; export const famFrames = f => [ `z_${f}1`, `z_${f}2`, `z_${f}3`, `z_${f}4`, // stance, shuffle A, shuffle B, forehand diff --git a/src/render.js b/src/render.js index b8e7b0d..c27c5a7 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 = 101; +export const SPRV = 102; // 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".