diff --git a/PROJECT.md b/PROJECT.md index 182c091..709e83e 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -20,9 +20,10 @@ anything, so if you add a `return` for an awkward deal, check the body still run the state-machine contracts that have historically produced the show-stoppers. The Dockerfile runs it as a build stage, so a red suite cannot deploy — but finding out from Coolify is the slow way. -Last updated 2026-08-21 (THE STAND: the clap frame rebuilt as a real pose of the seated one, which -is what the "wobble" was; an art pass for chroma and silhouette; the wave, the idle fidget, and -paparazzi that scale with the moment. See "THE STAND STOPS WOBBLING". Before that 2026-08-20 (THE SHELL: a Capacitor iOS project, haptics on the existing sfx call +Last updated 2026-08-21 (THE CROWD: rolled again, in far more detail, once for all three courts; +the clap frame is now the seated sheet plus the raised arms and nothing else, and the stand animates +ONE SPECTATOR AT A TIME off `src/crowd.js`. The wave, the idle fidget, paparazzi that scale with the +moment, and an aww that still claps. See "THE CROWD, ROLLED AGAIN". Before that 2026-08-20 (THE SHELL: a Capacitor iOS project, haptics on the existing sfx call sites, portrait enforced in Info.plist, and swipe-left-to-go-back in the menus — the web build unchanged and still primary. See "THE SHELL"). Before that, same day: the friend across the net gets his OWN sheets — the player's kit, drawn from the front, so his pick dyes the same garment yours does; the setup screen shows that front too. @@ -347,19 +348,25 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu `mopRound` walks him to a stain, holds him there for `TUNE.boyMopDwell` while the push cycle animates in place, then moves him on. Three sheets: `x_boymop` (stopped, working), `x_boycarry` (walking with the mop shouldered), `x_boyrun` (tennis-mode fetch). -- **THE CLAP FRAME IS THE SEATED FRAME PLUS FORTY PAIRS OF ARMS — nothing else may differ.** - `x_crowd2` is built FROM `x_crowd` by `assets/_crowdpass.py`: identical pixel for pixel except - inside blobs that are genuinely arms-up and that each sit **inside one of render's 16 columns** - (`COLS_CROWD`, and `COLS` in that script — change one and you must recut the sheets). That is - what makes a column swap seamless, and what lets the same machinery drive three behaviours - (applause / wave / idle fidget) off one sheet. It was NOT true before 2026-08-21: the clap sheet - was a re-roll that differed from the seated one in 13-16% of its pixels, everywhere, so a swapped - column re-hashed the crowd rather than raising arms — Oliver, "the crowd wobbles with a client - side animation right now.. that feels odd." **Never hand-edit one crowd frame.** Edit the seated - sheet and re-run the tool, which rebuilds its partner. Draws stay integer-aligned with **no - vertical offset**: a fractional `dy` on a scaled image re-snaps its pixel rows every frame under - nearest-neighbour — that boiling beside the HUD was the "scoreboard flicker". - `assets/_sheet_crowd1.png` is the pre-brightening original. +- **THE CLAP FRAME IS THE SEATED FRAME PLUS THE ARMS, and that is meant literally.** `x_crowd2` is + built FROM `x_crowd` by `assets/_crowdpass.py`, which copies only the pixels that were STAND in + the seated roll and became PERSON in the clap roll — 93 pairs of raised arms and hands, 1.2% of + the sheet. Not the raiser's face, not his shirt, not his seat: those are the seated sheet's own + pixels and cannot drift. Each pair of arms has a box in **`src/crowd.js`** (generated), and + render.js raises **one spectator at a time** out of that table. It was NOT true before 2026-08-21: + the clap sheet was a separate roll that differed from the seated one in 13-16% of its pixels, + everywhere, so the animation re-hashed the crowd rather than raising arms — Oliver, "the crowd + wobbles with a client side animation right now.. that feels odd." **Never hand-edit one crowd + frame, and never edit `src/crowd.js`.** Re-roll with `_crowdroll.py`, then re-run `_crowdpass.py`, + which rebuilds the pair, the three court grades and the table together. Draws stay integer-aligned + with **no vertical offset**: a fractional `dy` on a scaled image re-snaps its pixel rows every + frame under nearest-neighbour — that boiling beside the HUD was the "scoreboard flicker". +- **The three courts are ONE crowd, graded.** `x_crowd`, `x_crowd_c` and `x_crowd_h` are the same + roll under three per-channel histogram matches onto the courts' old palettes, so anything the + crowd art gains shows up on all three. They were three separate rolls until 2026-08-21, and the + cost of that was exactly what Oliver reported of the first pass: "the whole thing somehow does not + translate well to the other courts, there the difference is barely visible." + `assets/_sheet_crowd1.png` is the pre-brightening original of the FIRST crowd, kept for history. ## Settled feel decisions — do not regress silently - **Far bodies draw off a flattened scale curve; the camera holds still.** `TUNE.farK` 0.25 @@ -2307,7 +2314,7 @@ it is the WRONG tool, superseded by the reference-sheet flow. Route by job: | THE SAME CHARACTER IN SOMEONE ELSE'S CLOTHES, every pose at once | `_frontroll.py` — famroll's trick pointed at the tennis poses: image 1 is the ten `o_*` frames composed from disk, image 2 is the body to dress them in. Two calls made the whole netplay family. Ten `_regen` batches would have been ten chances for the man to drift. | | Single-sheet remaster, boss roster, any non-family sprite | `_regen.py` + `_regen_batches.json` — the only jobs it is still for. | | Standalone art (logo, panels) | `_key.py`; coarse legacy sheets, `_slice.py`. | -| The CROWD sheets — either frame, any court | `_crowdpass.py`. It owns the seated/clap pair as ONE artefact; there is no such thing as editing half of it. Not idempotent: `git checkout assets/x_crowd*.png` before every run. | +| The CROWD — the art itself | `_crowdroll.py` (two rolls: the seated master, then the same picture with arms up) -> `_crowdpass.py` (throws the drift away, keeps the arms, grades the three courts, writes `src/crowd.js`). Both frames of all three courts come out of one run; there is no such thing as editing half of it. | **And when installed sprites look WORSE than the `_refcheck/fam_*.png` sheet they came from, the suspect is the CUT, not the roll** — the sheets on disk are the evidence, and a re-cut is free (see @@ -2548,6 +2555,46 @@ The gates, and the numbers they were calibrated on: **SUPERSEDED 2026-08-12 — see the next section.** Those 31 frames were not art: `--verify` found 27 of them dead. The brute is now live (`ZFAMS` has `'br'`, the KEEPER wears it) off ONE sheet. +## THE CROWD, ROLLED AGAIN — AND ONE SPECTATOR AT A TIME (2026-08-21, same day) + +Oliver on the pass below: *"the still look a little rough.. and now the new lighter areas read as +rather sloppy artwork. please regenerate the crowd art and make it more detailed. also the animation +also looks pretty artificial. the flashes from the fotographs are too rare and the the whole thing +somehow does not translate well to the other courts. also when the opponent makes a point there is +an aww-sample (which has an applaus trail) so the crowd should still clap but more subtle."* + +All five, and the first one retracts a claim the section below made: **there IS a key** — the roll +scripts read `OPENROUTER_API_KEY` from the repo's own `.env`, not from the environment, so "no API +key in the environment" was a wrong conclusion, and painting detail onto an old sheet with numpy was +never the right tool. It is deleted. The art is rolled. + +- **THE ART IS A NEW ROLL** (`assets/_crowdroll.py`, two calls). Individually drawn spectators — + hair, hats, sunglasses, drinks, cameras, small flags, plain banners, empty seats, stairways, a few + already on their feet, and three zombies sitting in the middle of the crowd behaving like + furniture. The prompt states the art resolution in FIGURE HEIGHTS ("about 90 pixels of height + instead of the reference's 40"), which is the one lesson the sprite rolls paid for: the generator + picks its own canvas, so a pixel count means nothing to it and a proportion does. The old sheet + goes in as image 1 for LAYOUT ONLY — same camera, same tiers, same railing. +- **ONE ROLL, THREE COURTS.** Each court is a per-channel histogram match onto its own old sheet, so + clay is still terracotta and the night court is still night, but the crowd underneath is one + drawing. The same LUT grades both frames of a pair, which is what keeps them a pair. +- **THE ANIMATION IS PER SPECTATOR.** The clap roll drifts by 29% of its pixels; `_crowdpass.py` + keeps only the blobs that were empty stand and became a person — the raised arms, 1.2% of the + sheet — and writes a box around each into `src/crowd.js`. render.js draws ONE BOX AT A TIME, so a + single spectator's arms go up. The 16-column strip swap it replaced moved a seventeenth of the + crowd as a block, which is what read as machinery ("the animation also looks pretty artificial"). + Boxes that overlap are merged into one entry: two neighbours who always rise together are one + person as far as the animation is concerned. +- **THE SIZE OF THE MOMENT IS NOW A HEAD COUNT.** `crowd.pop` decides HOW MANY of the 93 join in + (each on their own period, staggered by a stable per-person draw), so the same code does the whole + range: a groan is a dozen people, a game is half the stand, and past 0.75 it becomes the wave — + which is now a front travelling person by person rather than column by column. +- **THE AWW CLAPS.** `tennis-aww` is a groan that settles into scattered applause, so a stand that + sits through it contradicts the sound. `aww()` now sets a small `crowd.pop` itself — in audio.js, + because it is the SAMPLE that makes this true and every current and future caller should get it. +- **MORE PHOTOGRAPHERS.** `TUNE.flashEvery` 5.0 -> 1.9. At a five-second mean, jittered, spread over + a stand this wide, Oliver never saw one. + ## THE STAND STOPS WOBBLING, AND STARTS BEING A CROWD (2026-08-21) *"the crowd spritesheet could use some love (resolution and movement wise)... also the crowd wobbles @@ -2606,10 +2653,13 @@ held at the loudest while an applause is live** — a won rally landing inside a must not demote the stand to a polite clap, and `cheer()` is called from a dozen places that overlap. That is the one new state-machine rule here, and it has a test. -**What this did NOT do:** the crowd is still the same drawing. A genuinely richer stand — more -distinct silhouettes, people doing different things — needs a re-roll of the sheet, which this -session could not do (no API key in the environment) and which would cost the clap animation -outright: the raises are lifted from the OLD roll, and a new composition has nothing to lift from. +**SUPERSEDED THE SAME DAY — see the section above.** Two things in this one were wrong. The paint +pass ("55 shirts and 44 heads of hair...") is gone: Oliver, *"the new lighter areas read as rather +sloppy artwork"*, and he is right — recolouring blobs of somebody else's drawing is not drawing. And +"no API key in the environment" was a wrong conclusion that made the paint pass look necessary: the +key is in the repo's `.env`, which is where the roll scripts have always read it from. What survives +from this session is the diagnosis (the frame pair, not the animation), `crowd.pop`, the paparazzi +scaling and the fidget. ## THE SHELL — CAPACITOR, HAPTICS, PORTRAIT, SWIPE-BACK (2026-08-20) @@ -3712,7 +3762,8 @@ Nothing here is broken; these are known gaps and unverified risks. are (a) author the sheet at the aspect portrait actually uses, or (b) tile it at its own aspect the way `render()` already tiles the ad strip. **Deliberately not done 2026-08-21**: at ~8 logical px per person nobody sees a 25% narrow spectator, and the same effort spent on chroma and silhouette - was worth more. Revisit only if the crowd is ever re-rolled at a chosen size. + was worth more, and the 2026-08-21 re-roll went out at the same 1408x768 rather than spending + the session on a fit nobody can see. Revisit only alongside a re-roll at a chosen size. 4. **`p_idle1`/`p_idle2` and most `o_*` frames are 2x-upscaled, not re-sliced.** They are on the right scale but gained no detail in the remaster, and they are the two most-seen poses in the game. Fix by hand-slicing `_sheet_player2/3` and `_sheet_opp2/3` at `IDLE_TARGET_H` 224 rather than going diff --git a/assets/_crowdpass.py b/assets/_crowdpass.py index 79541e6..ccd4184 100644 --- a/assets/_crowdpass.py +++ b/assets/_crowdpass.py @@ -1,385 +1,185 @@ -# _crowdpass.py — the crowd sheets: an art pass on the SEATED frame, and an honest CLAP frame -# built from it. One tool, because the two jobs share one invariant and must not be done apart. +# _crowdpass.py — turn the two crowd ROLLS into the six sheets the game ships, plus the table of +# who in them can stand up. # # uv run --with pillow --with numpy --with scipy python assets/_crowdpass.py # install # uv run --with pillow --with numpy --with scipy python assets/_crowdpass.py --review # to _refcheck # -# WHY THIS EXISTS. `x_crowd2` was never "the same crowd with its arms up" — it was a RE-ROLL that -# happens to look similar: 13-16% of its pixels differ from the seated sheet, spread over faces, -# torsos, seat backs and the railing. render() swaps 16 columns between the two at 4.5 Hz, so what -# the stand actually did was re-hash itself twice a second. Oliver: "the crowd wobbles with a client -# side animation right now.. that feels odd." It was not the animation. It was the frame pair. +# INPUT assets/_refcheck/crowd_seat.png + crowd_clap.png (see `_crowdroll.py`) +# OUTPUT assets/x_crowd{,2}.png · x_crowd_c{,2}.png · x_crowd_h{,2}.png · src/crowd.js # -# THE INVARIANT THIS TOOL ESTABLISHES, which render.js now leans on: -# the clap sheet is the seated sheet, EXCEPT inside a set of blobs that are (a) genuinely arms-up -# and (b) each contained in ONE of the 16 columns render draws. -# Everything outside those blobs is pixel-identical, so ANY column-aligned draw from the clap sheet -# — the applause ripple, the wave, a lone fidget between points — is seamless by construction, and a -# column with nobody raising in it is simply invisible instead of being a wobble. +# THE PROBLEM THIS SOLVES. Ask a generator for "the same picture with some arms raised" and about a +# third of the pixels come back different — faces, seat backs, the railing, everything. Swap any of +# that in and the stand re-hashes itself rather than applauding, which is exactly what the shipped +# sheets used to do: Oliver, "the crowd wobbles with a client side animation right now.. that feels +# odd." So the drift is thrown away here and only the arms are kept. # -# ORDER MATTERS: the clap set is chosen FIRST and reserved, then the art pass paints around it. The -# other way round, a crowd this heavily painted leaves nothing left that is allowed to move. +# AND "THE ARMS" IS MEANT LITERALLY. The clap frame is the seated frame plus the pixels that were +# STAND in the seated roll and became PERSON in the clap roll — arms and hands raised into the empty +# wall above a row, and nothing else. Not the raiser's face, not his shirt, not his seat. It cannot +# drift, because 98.8% of the sheet is not copied at all: it IS the seated sheet. # -# THE THREE SHEETS ARE ONE COMPOSITION. x_crowd, x_crowd_c and x_crowd_h are the same crowd in three -# palettes, pixel-aligned. Two things fall out of that, and both are load-bearing here: -# - the art pass finds its people ONCE, on the clay sheet (the cleanest of the three — grass -# carries the brightening curve's noise), and applies the same masks to all three; -# - a raise that exists in only ONE court's clap roll can be TRANSPLANTED into the other two -# through a colour map learnt from the aligned seated pair. Per court the honest raises number -# about fifteen. Pooled, the stand has fifty people who can put their arms up. +# TWO INVARIANTS, and render.js leans on both: +# 1. the clap sheet is the seated sheet EXCEPT inside the boxes listed in src/crowd.js; +# 2. every one of those boxes is a WHOLE PERSON, so the game animates people ONE AT A TIME — a +# raiser's box drawn over the seated stand moves that spectator and nobody else. +# Boxes that overlap are merged into one entry: two neighbours who would always rise together are +# one person as far as the animation is concerned, and pretending otherwise reads as a glitch. # -# RE-RUNNING: this reads the sheets on disk and overwrites them, so it is NOT idempotent — the art -# pass would apply twice. `git checkout assets/x_crowd*.png` first, always. -import os, sys +# THE THREE COURTS ARE ONE ROLL, GRADED. Each court's look is a per-channel histogram match onto the +# OLD sheet for that court, so terracotta clay, night grass and blue hard court all come back — but +# the crowd underneath is one drawing, so anything added to it shows up on every court. (Before this +# the three were separate rolls; Oliver: "the whole thing somehow does not translate well to the +# other courts, there the difference is barely visible".) THE SAME LUT IS APPLIED TO BOTH FRAMES OF +# A PAIR — grading them apart would move identical pixels to different colours and put the wobble +# straight back. +import os, subprocess, sys import numpy as np from PIL import Image from scipy import ndimage HERE = os.path.dirname(os.path.abspath(__file__)) -COLS = 16 # render.js draws the stand in this many columns. Must match. -SEED = 20260821 -PAL = 128 # colours a sheet is quantised to, at load and on save +ROOT = os.path.join(HERE, '..') +REFCHECK = os.path.join(HERE, '_refcheck') +PAL = 128 # colours a sheet is quantised to on save +# The court LOOKS are matched to the sheets as they were BEFORE any of this — a pinned commit, not +# HEAD, so re-running the tool after installing its own output cannot drift the palettes. +GRADE_REF = '76ea1d0' -# court key -> (seated sheet, clap sheet). '' is centre court (grass), the fallback set. -COURTS = {'': ('x_crowd', 'x_crowd2'), 'c': ('x_crowd_c', 'x_crowd_c2'), 'h': ('x_crowd_h', 'x_crowd_h2')} -REF = 'c' # the court the people are found on -SKIN = (243, 152, 80) # ...and the clay sheet's skin tone, which is how they are found -SKIN_D = 150 # manhattan distance that still counts as skin +COURTS = [('x_crowd', 'x_crowd2'), ('x_crowd_c', 'x_crowd_c2'), ('x_crowd_h', 'x_crowd_h2')] -# WHAT THE PASS ADDS. Oliver: "the crowd could be more detailed (and I don't [mean] the technical -# resolution of the image)". At the size a phone draws this a spectator is ~8 logical px, so detail -# is not finer pixels — it is CHROMA and SILHOUETTE. Every one of these sheets is one hue end to -# end, every head is the same dark blob at the same height, and nothing breaks the top line of a -# row. So: shirts and hair in colours that are not the court's, caps, flags standing above the -# heads, people permanently on their feet, gaps in the rows — and three who have already turned. -ACCENTS = [(214, 58, 52), (240, 198, 58), (66, 148, 222), (238, 238, 228), (118, 202, 92), - (232, 120, 186), (250, 140, 40), (150, 110, 230), (54, 196, 186), (196, 40, 96), - (250, 236, 180), (90, 96, 210)] -HAIRS = [(228, 196, 110), (188, 92, 42), (208, 208, 200), (36, 28, 32), (150, 66, 96)] -N_ACCENT = 70 # shirts recoloured -N_HAIR = 48 # heads whose hair stops being the same brown as everyone else's -N_CAP = 28 # ...of which this many get the top of it in a loud colour instead: a cap -N_FLAG = 10 # little flags on poles, standing above the heads -N_STAND = 8 # spectators baked permanently onto their feet, arms up (borrowed from the clap roll) -N_TURNED = 3 # spectators who are already turned (grey-green, and they never clap) -N_GAP = 8 # ...and seats emptied, so no row ends in a straight line of identical heads -PER_COL = 5 # clappers allowed per column — a column standing as one is a wall, not applause -TURNED_SKIN = (108, 134, 84) +MIN_AREA, MAX_AREA = 150, 3000 # an arm blob: one pair of raised arms, or one arm +ARM_H, ARM_W = (10, 70), (8, 90) # ...and its shape, in px of the 1408x768 sheet +BAND = 8 # rows the background colour is sampled over +PAD = 3 # px of margin around a raiser's box -def load(name): - """Quantised at LOAD, so every sheet in this tool has one small palette and colour identity is - exact — which is what the transplant map and the seamless-composite invariant both rest on.""" - im = Image.open(os.path.join(HERE, name + '.png')).convert('RGB') - return np.array(im.quantize(colors=PAL, method=Image.MEDIANCUT).convert('RGB')) - - -def pack(rgb): - a = rgb.reshape(-1, 3).astype(int) - return (a[:, 0] << 16) + (a[:, 1] << 8) + a[:, 2] +def old_sheet(name): + """The pre-roll sheet for a court, read out of git — the grade's target, and nothing else.""" + p = subprocess.run(['git', 'show', f'{GRADE_REF}:assets/{name}.png'], cwd=ROOT, capture_output=True) + if p.returncode: + sys.exit(f'cannot read {GRADE_REF}:assets/{name}.png — {p.stderr.decode()[:200]}') + tmp = os.path.join(REFCHECK, f'_old_{name}.png') + open(tmp, 'wb').write(p.stdout) + return np.asarray(Image.open(tmp).convert('RGB')) def modal(px): - v = int(np.bincount(pack(px)).argmax()) + a = px.reshape(-1, 3).astype(int) + v = int(np.bincount((a[:, 0] << 16) + (a[:, 1] << 8) + a[:, 2]).argmax()) return np.array([v >> 16, (v >> 8) & 255, v & 255]) -def background(ref): - """The stand behind the people: per 12-row band, whatever colour that band is mostly made of. - Without this, 'the modal colour under his chin' is the WALL for anybody sitting in a gap, and - the accent pass paints rectangles of wall instead of shirts.""" - bg = np.zeros(ref.shape[:2], bool) - for y in range(0, ref.shape[0], 12): - band = ref[y:y + 12] - bg[y:y + 12] = np.abs(band.astype(int) - modal(band)).sum(2) < 80 - return bg +def stand(im): + """The STAND itself — wall, steps, seat backs — as whatever each band of rows is mostly made of. + Being able to say "this pixel is empty stand" is the whole trick: a raise is empty stand that + turned into a person.""" + m = np.zeros(im.shape[:2], bool) + for y in range(0, im.shape[0], BAND): + band = im[y:y + BAND] + m[y:y + BAND] = np.abs(band.astype(int) - modal(band)).sum(2) < 70 + return m -def solid(m, minw=8, minh=7, fill=0.45): - """The largest lump in a mask, if it is actually a lump. Without this every mask latches onto - the 1-2 px highlight along a seat rail — which paints the stand with bright horizontal WIRES, - the first version's most obvious tell — because a rail edge is neither skin nor background.""" - lab, n = ndimage.label(m) - if not n: - return None - sizes = ndimage.sum(np.ones_like(lab), lab, range(1, n + 1)) - i = int(sizes.argmax()) + 1 - sl = ndimage.find_objects(lab)[i - 1] - h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start - if h < minh or w < minw or sizes[i - 1] < h * w * fill: - return None - return lab == i - - -def recolour(rgb, m, target): - """Repaint a blob, keeping its own shading — the per-pixel luminance ratio rides on top of the - target, so a folded sleeve stays a folded sleeve and each court keeps its own light.""" - px = rgb[m].astype(float) - l = px.mean(1) - r = np.clip(l / max(1.0, l.mean()), 0.5, 1.5)[:, None] - rgb[m] = np.clip(np.array(target, float) * r, 0, 255).astype(np.uint8) - - -# ------------------------------------------------------------------ the clap set ---- -def raisers(A0, B0): - """The blobs where somebody's arms genuinely went up between the two rolls of one court.""" - d = np.abs(A0.astype(int) - B0.astype(int)).sum(2) > 30 - lab, n = ndimage.label(ndimage.binary_closing(d, np.ones((3, 3))), np.ones((3, 3))) - colw = A0.shape[1] / COLS - lumA, lumB = A0.mean(2), B0.mean(2) - out = [] - for i, sl in enumerate(ndimage.find_objects(lab), 1): - m = lab[sl] == i - area = int(m.sum()) - if not (150 <= area <= 12000): - continue - if int(sl[1].start // colw) != int((sl[1].stop - 1) // colw): - continue # straddles a column seam — cannot be drawn cleanly - # ARMS UP, not A DIFFERENT PERSON: the change has to ADD bright pixels at the TOP of the - # blob, where the wall was. A re-rolled face is just as different and scores nothing. - top = np.zeros_like(m) - top[:max(1, int(m.shape[0] * 0.45))] = True - up = ((lumB[sl] - lumA[sl] > 24) & m & top).sum() / area - if up < 0.035: - continue - full = np.zeros(lab.shape, bool) - full[sl] = m - out.append((float(up), int(sl[1].start // colw), full)) - return out - - -def colour_map(src, dst): - """src and dst are two SEATED sheets of the same crowd in different light. Returns a 2**24 LUT - taking a src colour to the colour that court paints the same pixels — so an arm raised in the - clay roll can be lifted into the night court and still be lit like the night court.""" - ks, kd = pack(src), pack(dst) - us, si = np.unique(ks, return_inverse=True) - ud, di = np.unique(kd, return_inverse=True) - hits = np.bincount(si * len(ud) + di, minlength=len(us) * len(ud)).reshape(len(us), -1) - best = ud[hits.argmax(1)] - lut = {int(s): int(b) for s, b in zip(us, best)} - srgb = np.stack([us >> 16, (us >> 8) & 255, us & 255], 1) - - def apply(px): # px: (n,3) src colours -> (n,3) dst colours - k = pack(px) - out = np.empty(len(k), int) - for i, key in enumerate(k): - v = lut.get(int(key)) - if v is None: # a colour only the raised arms use: nearest known - v = lut[int(us[np.abs(srgb - np.array([key >> 16, (key >> 8) & 255, key & 255])).sum(1).argmin()])] - out[i] = v - return np.stack([out >> 16, (out >> 8) & 255, out & 255], 1).astype(np.uint8) - return apply - - -# ------------------------------------------------------------------- the art pass ---- -def find_people(ref): - """Every spectator the reference sheet will admit to, as (head bbox, cell bbox) + a skin mask. - - Skin is a third of this sheet and neighbouring faces touch, so this finds SOME of the people, - not all of them — which is all it has to do: the pass needs a hundred spots, not a census.""" - med = np.stack([ndimage.median_filter(ref[:, :, i], size=3) for i in range(3)], 2) - skin = np.abs(med.astype(int) - np.array(SKIN)).sum(2) < SKIN_D - lab, n = ndimage.label(skin) +def raisers(A, B): + """The arms. Blobs of stand-turned-person, shaped like a pair of raised arms, sitting directly + on top of somebody who is in the seated sheet — which is what stops a re-rolled hat or a shifted + railing highlight being mistaken for applause.""" + new = stand(A) & ~stand(B) + lab, n = ndimage.label(ndimage.binary_closing(new, np.ones((3, 3))), np.ones((3, 3))) sizes = ndimage.sum(np.ones_like(lab), lab, range(1, n + 1)) + bgA = stand(A) out = [] for i, sl in enumerate(ndimage.find_objects(lab)): h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start - if not (200 <= sizes[i] <= 2200 and 0.55 <= w / max(1, h) <= 2.0 and 14 <= h <= 44 and 14 <= w <= 50): + if not (MIN_AREA <= sizes[i] <= MAX_AREA): continue - cell = (slice(max(0, sl[0].start - 4), min(ref.shape[0], sl[0].start + int(h * 2.6))), - slice(max(0, sl[1].start - 5), min(ref.shape[1], sl[1].stop + 5))) - out.append((sl, cell)) - return out, skin + if not (ARM_H[0] <= h <= ARM_H[1] and ARM_W[0] <= w <= ARM_W[1]): + continue + # IT HAS TO BE ON SOMEBODY. The strip just below the blob must be a spectator in the SEATED + # sheet; arms that rise out of empty stand belong to nobody and read as floating hands. + if bgA[sl[0].stop:min(A.shape[0], sl[0].stop + 6), sl[1]].mean() > 0.55: + continue + out.append([sl[0].start, sl[1].start, sl[0].stop, sl[1].stop, i + 1]) + return out, lab -def build_jobs(ref, reserved, rng): - """The art pass, decided once on the reference sheet. Returns paint jobs, not pixels — each - court then applies them to its own palette. A job of (mask, None) is an emptied seat.""" - people, skin = find_people(ref) - bg = background(ref) - used = ndimage.binary_dilation(reserved, iterations=2) - jobs = [] - - def take(m, pad=2): - if m.sum() < 60 or (m & used).any(): - return False - used[:] |= ndimage.binary_dilation(m, iterations=pad) - return True - - order = list(rng.permutation(len(people))) - - def pick(n, minx, miny, fn): - got, placed = 0, [] - for i in order: - if got >= n: - break - sl, cell = people[i] - cy, cx = (sl[0].start + sl[0].stop) / 2, (sl[1].start + sl[1].stop) / 2 - if any(abs(cx - x) < minx and abs(cy - y) < miny for x, y in placed): - continue - if fn(sl, cell, got): - placed.append((cx, cy)) - got += 1 - return got - - # SHIRTS. The modal colour under the chin that is neither more skin nor the stand behind him. - def shirt(sl, cell, i): - body = (slice(sl[0].stop + 1, cell[0].stop), cell[1]) - cloth = ~skin[body] & ~bg[body] - if cloth.sum() < 110: - return False - m = np.zeros(ref.shape[:2], bool) - m[body] = cloth & (np.abs(ref[body].astype(int) - modal(ref[body][cloth])).sum(2) < 70) - m = solid(m, 9, 8) - if m is None or not take(m): - return False - jobs.append((m, ACCENTS[i % len(ACCENTS)])) - return True - - # HAIR, and the top of it as a CAP. Hair is what sits on the head and is neither skin nor stand. - def hair(sl, cell, i): - box = (slice(max(0, sl[0].start - 8), sl[0].start + (sl[0].stop - sl[0].start) // 2), - slice(max(0, sl[1].start - 2), sl[1].stop + 2)) - m = np.zeros(ref.shape[:2], bool) - m[box] = ~skin[box] & ~bg[box] - m = solid(m, 10, 6, 0.5) - if m is None or not take(m, 1): - return False - if i < N_CAP: # a cap is the top of the hair in a loud colour - cut = np.zeros(ref.shape[:2], bool) - cut[box[0].start:box[0].start + max(3, (box[0].stop - box[0].start) // 2), box[1]] = True - jobs.append((m & ~cut, HAIRS[i % len(HAIRS)])) - jobs.append((m & cut, ACCENTS[(i * 5 + 3) % len(ACCENTS)])) - else: - jobs.append((m, HAIRS[i % len(HAIRS)])) - return True - - # THE ONES WHO HAVE ALREADY TURNED. Grey-green skin, in the middle of a row, and nobody around - # them reacts — and they are the only ones in the stand not applauding, because a painted pixel - # is never in the clap set. (A hint at the parked stands-turn-into-zombies step, not that step: - # it costs no code at all, only pixels.) - def turned(sl, cell, i): - m = np.zeros(ref.shape[:2], bool) - m[cell] = skin[cell] # head AND the hands below it: all of him turns - if solid(m, 12, 12, 0.35) is None or not take(m): - return False - jobs.append((m, TURNED_SKIN)) - return True - - # FLAGS. The one thing in this stand that breaks the top line of a row: a pole and a rag, up - # where until now there was nothing but wall. - def flag(sl, cell, i): - h = sl[0].stop - sl[0].start - y1, x = sl[0].start - 2, (sl[1].start + sl[1].stop) // 2 - y0 = y1 - int(h * 1.25) - if y0 < 4: - return False - pole = np.zeros(ref.shape[:2], bool) - pole[y0:y1, x:x + 3] = True - rag = np.zeros(ref.shape[:2], bool) - rag[y0:y0 + int(h * 0.75), x + 3:x + 3 + int(h * 1.1)] = True - if not (bg[y0:y1, x:x + 3 + int(h * 1.1)].mean() > 0.75 and take(pole | rag)): - return False # only where the stand behind him is empty - jobs.append((pole, (58, 42, 34))) - jobs.append((rag, ACCENTS[(i * 3) % len(ACCENTS)])) - return True - - # GAPS. A row ending in a straight line of identical heads is what makes this stand read as - # wallpaper instead of as people. Erased, and filled with the stand from directly above the row - # — a colour the sheet itself supplies, not one guessed here. - def gap(sl, cell, i): - h = sl[0].stop - sl[0].start - box = (slice(max(0, sl[0].start - 3), sl[0].start + int(h * 1.7)), - slice(max(0, sl[1].start - 4), sl[1].stop + 4)) - if bg[max(0, box[0].start - 26):box[0].stop, max(0, box[1].start - 44):box[1].stop + 44].sum() < 900: - return False # not enough visible stand nearby to fill it with - m = np.zeros(ref.shape[:2], bool) - m[box] = True - if not take(m, 3): - return False - jobs.append((m, None)) - return True - - n = {'shirts': pick(N_ACCENT, 74, 40, shirt), 'hair': pick(N_HAIR, 74, 40, hair), - 'flags': pick(N_FLAG, 150, 999, flag), 'turned': pick(N_TURNED, 300, 150, turned), - 'gaps': pick(N_GAP, 170, 90, gap)} - return jobs, n, bg +def merge(boxes): + """Two raisers whose boxes overlap are ONE entry: the game draws a box at a time, so a box that + contains half of a neighbour's arms would raise that neighbour too, on the wrong clock.""" + boxes = [b[:4] for b in boxes] + changed = True + while changed: + changed = False + for i in range(len(boxes)): + for j in range(len(boxes) - 1, i, -1): + a, b = boxes[i], boxes[j] + if a[0] - PAD < b[2] and b[0] - PAD < a[2] and a[1] - PAD < b[3] and b[1] - PAD < a[3]: + boxes[i] = [min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3])] + boxes.pop(j) + changed = True + return boxes -def art_pass(rgb, jobs, bg): - for m, colour in jobs: - if colour is None: - # AN EMPTIED SEAT, FILLED ROW BY ROW. The stand is banded — wall, seat back, step — - # so one modal colour for the whole hole paints a flat rectangle straight across the - # bands and reads as a bug. Each row takes the stand colour from its OWN row instead. - ys, xs = np.where(m.any(1))[0], np.where(m.any(0))[0] - side = slice(max(0, xs[0] - 70), xs[-1] + 70) - for y in ys: - near = rgb[y, side][bg[y, side]] - if len(near) > 8: - rgb[y][m[y]] = modal(near) - else: - recolour(rgb, m, colour) +def grade(src, ref): + """Per-channel histogram match: the roll's colours redistributed onto a court's own look. + Returns a function, so the pair can be graded with ONE transform.""" + luts = [] + for ch in range(3): + s, r = src[:, :, ch].ravel(), ref[:, :, ch].ravel() + sv, sc = np.unique(s, return_counts=True) + rv, rc = np.unique(r, return_counts=True) + mapped = np.interp(np.cumsum(sc) / s.size, np.cumsum(rc) / r.size, rv) + # colours the seated frame never uses (the clap frame has a few) borrow their neighbours' + luts.append(np.interp(np.arange(256), sv, mapped).astype(np.uint8)) + return lambda im: np.stack([luts[c][im[:, :, c]] for c in range(3)], 2) def save(seated, pa, clap, pb): - """ONE shared palette for the pair. Quantising them apart would move identical pixels to - different colours and put the wobble straight back — see the invariant at the top.""" + """ONE shared palette for the pair — see the invariant at the top.""" sa = Image.fromarray(seated).quantize(colors=PAL, method=Image.MEDIANCUT) Image.fromarray(clap).quantize(palette=sa, dither=Image.NONE).save(pb, optimize=True) sa.save(pa, optimize=True) +def write_table(boxes, h, w): + """src/crowd.js — the raisers as fractions of the sheet, sorted left to right so a wave can walk + them in order.""" + rows = sorted(((x0 - PAD) / w, (y0 - PAD) / h, (x1 - x0 + 2 * PAD) / w, (y1 - y0 + 2 * PAD) / h) + for y0, x0, y1, x1 in boxes) + body = ',\n '.join('[' + ', '.join(f'{v:.4f}' for v in r) + ']' for r in rows) + open(os.path.join(ROOT, 'src', 'crowd.js'), 'w').write( + '// GENERATED by assets/_crowdpass.py — do not edit by hand, re-run the tool.\n' + '// The spectators who can stand up: [x, y, w, h] as fractions of the crowd sheet, sorted\n' + '// left to right. Each box holds ONE person whose arms are raised in 2 and nobody\n' + '// else; everything outside these boxes is identical in the two sheets, which is what lets\n' + '// render.js raise them one at a time with no seam. See "THE CLAP FRAME" in PROJECT.md.\n' + f'export const RAISERS = [\n {body},\n];\n') + return len(rows) + + def main(review): - rng = np.random.default_rng(SEED) - seat = {k: load(v[0]) for k, v in COURTS.items()} - clap = {k: load(v[1]) for k, v in COURTS.items()} + A = np.asarray(Image.open(os.path.join(REFCHECK, 'crowd_seat.png')).convert('RGB')) + B = np.asarray(Image.open(os.path.join(REFCHECK, 'crowd_clap.png')).convert('RGB')) + arms, lab = raisers(A, B) + h, w = A.shape[:2] - # 1. THE CLAP SET, pooled over all three rolls and transplanted into every court. - pool = [] - for k in COURTS: - pool += [(up, col, m, k) for up, col, m in raisers(seat[k], clap[k])] - pool.sort(key=lambda p: -p[0]) - taken = np.zeros(seat[REF].shape[:2], bool) - per_col, picked = {}, [] - for up, col, m, k in pool: - if (m & taken).any() or per_col.get(col, 0) >= PER_COL: - continue # same person found twice, or a full column - per_col[col] = per_col.get(col, 0) + 1 - taken |= ndimage.binary_dilation(m, iterations=2) - picked.append((m, k)) - stand_at = set(range(1, len(picked), max(2, len(picked) // N_STAND))) # a few are on their feet for good - stand_at = set(list(stand_at)[:N_STAND]) - standing = [p for i, p in enumerate(picked) if i in stand_at] - clapping = [p for i, p in enumerate(picked) if i not in stand_at] - print(f'clap set: {len(clapping)} raisers over {len(per_col)}/{COLS} columns, ' - f'{len(standing)} baked standing (pooled from {len(pool)} candidates)') + clap = A.copy() + for y0, x0, y1, x1, i in arms: # the BLOB, not its box: only the arms move + m = lab[y0:y1, x0:x1] == i + clap[y0:y1, x0:x1][m] = B[y0:y1, x0:x1][m] + boxes = merge(arms) + was = (np.abs(A.astype(int) - B.astype(int)).sum(2) > 30).mean() * 100 + now = (np.abs(A.astype(int) - clap.astype(int)).sum(2) > 30).mean() * 100 + n = write_table(boxes, h, w) + print(f'{n} raisers kept; the clap frame moves {now:.1f}% of the sheet (the roll moved {was:.1f}%)') - # 2. THE PAINT, everywhere the clap set is not. - jobs, n, bg = build_jobs(seat[REF], taken, rng) - print(f'people painted: {n}') - - maps = {(s, d): colour_map(seat[s], seat[d]) for s in COURTS for d in COURTS if s != d} - for k, (a_name, b_name) in COURTS.items(): - A0, B0 = seat[k], clap[k] - def lift(m, src): # this court's pixels for a raise found in src - return B0[m] if src == k else maps[(src, k)](clap[src][m]) - out = A0.copy() - for m, src in standing: - out[m] = lift(m, src) - art_pass(out, jobs, bg) - cl = out.copy() - for m, src in clapping: - cl[m] = lift(m, src) - was = (np.abs(A0.astype(int) - B0.astype(int)).sum(2) > 30).mean() * 100 - now = (np.abs(out.astype(int) - cl.astype(int)).sum(2) > 30).mean() * 100 - pa, pb = os.path.join(HERE, a_name + '.png'), os.path.join(HERE, b_name + '.png') + for a_name, b_name in COURTS: + lut = grade(A, old_sheet(a_name)) + pa, pb = (os.path.join(HERE, x + '.png') for x in (a_name, b_name)) if review: - pa, pb = (os.path.join(HERE, '_refcheck', x + '_new.png') for x in (a_name, b_name)) - os.makedirs(os.path.dirname(pa), exist_ok=True) - save(out, pa, cl, pb) - print(f'{a_name}: clap frame moves {now:.1f}% of pixels (the re-roll moved {was:.1f}%)') + pa, pb = (os.path.join(REFCHECK, x + '_new.png') for x in (a_name, b_name)) + save(lut(A), pa, lut(clap), pb) + print(f' {os.path.basename(pa)} + {os.path.basename(pb)}') if __name__ == '__main__': diff --git a/assets/_crowdroll.py b/assets/_crowdroll.py new file mode 100644 index 0000000..42af81f --- /dev/null +++ b/assets/_crowdroll.py @@ -0,0 +1,125 @@ +# _crowdroll.py — roll the crowd sheet itself: the SEATED master, and the arms-up frame of it. +# +# uv run --with pillow --with requests python assets/_crowdroll.py seat # -> _refcheck/crowd_seat.png +# uv run --with pillow --with requests python assets/_crowdroll.py clap # from the seated master +# +# Then `_crowdpass.py` turns the two into an honest pair and grades them into the three courts. +# +# WHY A SECOND SCRIPT. `_regen.py` cuts SPRITES out of a magenta field — every line of its canvas +# block is about isolating figures on a key colour. The crowd is the opposite job: one continuous +# backdrop image that is drawn edge to edge with nothing keyed out, so it needs its own prompt and +# its own aspect. The one thing it borrows is the lesson those rolls paid for: SAY THE ART +# RESOLUTION IN FIGURE HEIGHTS, never in pixels — the generator picks its own canvas, so "90 pixels +# tall" is a proportion it can act on and "1408 wide" is not. +# +# The seated roll takes the OLD sheet as image 1 for LAYOUT ONLY (tiers, railing, camera angle, +# people-per-row) and is told in as many words that the drawing itself is to be redone finer. The +# clap roll takes the NEW seated master as image 1 and asks for the same picture with some of those +# people on their feet — it WILL drift, which is fine and expected: `_crowdpass.py` keeps only the +# blobs where arms genuinely went up and throws the drift away. +import base64, os, sys +import requests +from PIL import Image + +HERE = os.path.dirname(os.path.abspath(__file__)) +OUT = os.path.join(HERE, '_refcheck') +MODEL = os.environ.get('SPLAT_MODEL', 'google/gemini-3-pro-image') +SIZE = (1408, 768) # what the game ships; the roll comes back bigger and is boxed down to it + + +def key(): + if os.environ.get('OPENROUTER_API_KEY'): + return os.environ['OPENROUTER_API_KEY'] + for line in open(os.path.join(HERE, '..', '.env')): + if line.startswith('OPENROUTER_API_KEY='): + return line.split('=', 1)[1].strip().strip('"').strip("'") + sys.exit('no OPENROUTER_API_KEY') + + +# The stand is drawn STRETCHED into whatever band the viewport leaves, and at that size a spectator +# is ~8-14 logical px. So the prompt spends everything on the two things that survive: how many +# DIFFERENT people there are, and what breaks the top line of a row. +STYLE = ( + "16-bit SNES-era pixel art, crisp hard-edged pixels, a limited palette, dark outlines separating " + "figures, flat blocks of colour with a little dithered shading. No photographic blur, no soft " + "anti-aliased haze, no gradients, no lens effects, no letters, words, numbers or logos anywhere " + "in the image. " +) + +SEAT = ( + "Pixel art of a PACKED TENNIS STADIUM GRANDSTAND seen straight on from the court, filling the " + "entire frame edge to edge: a wall of spectators in tiered rows, one above the other. " + "USE THE REFERENCE IMAGE FOR LAYOUT ONLY — the same head-on camera, the same tiers of seating, " + "the same horizontal railing running across the picture, the same darker band of wall above the " + "top row, the same rows running right off both edges with no border or margin anywhere. " + "REDRAW EVERYTHING ELSE AT MUCH FINER PIXEL RESOLUTION WITH FAR MORE DETAIL than the reference: " + "build each spectator out of roughly 90 pixels of height instead of the reference's 40, and " + "spend all of it on making them DIFFERENT PEOPLE. " + "Every spectator is an individual: a wide range of skin tones; hair that is short, long, bald, " + "tied back, curly, grey, blond, ginger, black; beards; caps and sun hats in bright colours; " + "sunglasses; visors; headbands. Clothing in many different saturated colours — tennis whites, " + "striped polos, jerseys, summer dresses, jackets tied over shoulders, vest tops. " + "They are DOING things: some clap, some hold drinks or ice creams, some hold a camera with a " + "long lens up to one eye, some wave a small flag on a stick, some hold up a plain hand-painted " + "banner of flat colour with NO writing on it, some lean together talking, some shade their eyes, " + "a few stand with both arms raised. " + "Break the rows up: scattered EMPTY SEATS with the seat back showing, and a couple of vertical " + "stairway gaps between blocks of seating. Not every head at the same height. " + "THREE OF THE SPECTATORS ARE ZOMBIES, sitting in the middle of the crowd among everybody else: " + "grey-green skin, sunken dark eye sockets, slack jaw, slumped and staring — and the ordinary " + "people sitting next to them are behaving completely normally, paying them no attention. " + "Bright neutral daylight, evenly lit across the whole picture. " + STYLE +) + +CLAP = ( + "This reference image is a stadium grandstand full of seated spectators. Redraw it EXACTLY — the " + "same crowd, the same people in the same seats, the same clothes, hair, hats and colours, the " + "same rows, railing, empty seats and stairways, the same lighting, pixel for pixel — WITH ONE " + "CHANGE: about forty of the spectators, scattered evenly all over the picture, are now ON THEIR " + "FEET APPLAUDING with both arms raised above their heads, hands together, clapping. " + "Each of those forty is the SAME PERSON as in the reference — same face, same hair, same " + "clothing — just standing and reaching up, so their head and hands rise above the row in front. " + "EVERYONE ELSE IN THE PICTURE IS COMPLETELY UNCHANGED, identical to the reference. " + "The three grey-green zombie spectators do NOT move and do NOT applaud. " + STYLE +) + + +def u(p): + return 'data:image/png;base64,' + base64.b64encode(open(p, 'rb').read()).decode() + + +def roll(prompt, ref, out): + r = requests.post('https://openrouter.ai/api/v1/images', + headers={'Authorization': f'Bearer {key()}', 'Content-Type': 'application/json'}, + # 4K and 16:9 spelled out: the endpoint defaults to 1K and snaps the aspect to an enum. + # 16:9 is 1.78 against the sheet's 1.83 — a 3% squeeze on the way down, invisible at the + # size the stand is drawn. + json={'model': MODEL, 'prompt': prompt, 'resolution': '4K', 'aspect_ratio': '16:9', + 'input_references': [{'type': 'image_url', 'image_url': {'url': u(ref)}}]}, + timeout=1800) + if r.status_code != 200: + sys.exit(f'HTTP {r.status_code} {r.text[:300]}') + d = r.json().get('data') or [] + if not d: + sys.exit('no image returned') + b = d[0].get('b64_json') or d[0].get('image_url', {}).get('url', '').split(',')[-1] + raw = out.replace('.png', '_raw.png') + open(raw, 'wb').write(base64.b64decode(b)) + im = Image.open(raw).convert('RGB') + # BOX, not NEAREST: coming DOWN from 4K, averaging is what keeps the fine detail the prompt paid + # for. The sheet is nearest-neighboured on the way back up by the game, which is the only place + # hard pixel edges matter. + im.resize(SIZE, Image.BOX).save(out) + print(f'{out} (roll was {im.size})', flush=True) + + +if __name__ == '__main__': + os.makedirs(OUT, exist_ok=True) + what = sys.argv[1] if len(sys.argv) > 1 else 'seat' + if what == 'seat': + ref = sys.argv[2] if len(sys.argv) > 2 else os.path.join(OUT, 'crowd_layout.png') + roll(SEAT, ref, os.path.join(OUT, 'crowd_seat.png')) + elif what == 'clap': + roll(CLAP, os.path.join(OUT, 'crowd_seat.png'), os.path.join(OUT, 'crowd_clap.png')) + else: + sys.exit('usage: _crowdroll.py seat|clap') diff --git a/assets/x_crowd.png b/assets/x_crowd.png index 7c24358..7843dac 100644 Binary files a/assets/x_crowd.png and b/assets/x_crowd.png differ diff --git a/assets/x_crowd2.png b/assets/x_crowd2.png index 603f61f..59fb714 100644 Binary files a/assets/x_crowd2.png and b/assets/x_crowd2.png differ diff --git a/assets/x_crowd_c.png b/assets/x_crowd_c.png index 9c17c1c..87d58d5 100644 Binary files a/assets/x_crowd_c.png and b/assets/x_crowd_c.png differ diff --git a/assets/x_crowd_c2.png b/assets/x_crowd_c2.png index 8aa8345..9790585 100644 Binary files a/assets/x_crowd_c2.png and b/assets/x_crowd_c2.png differ diff --git a/assets/x_crowd_h.png b/assets/x_crowd_h.png index e57150c..f4947ed 100644 Binary files a/assets/x_crowd_h.png and b/assets/x_crowd_h.png differ diff --git a/assets/x_crowd_h2.png b/assets/x_crowd_h2.png index f05e6f7..6dda164 100644 Binary files a/assets/x_crowd_h2.png and b/assets/x_crowd_h2.png differ diff --git a/index.html b/index.html index 6bd7f93..736a5e7 100644 --- a/index.html +++ b/index.html @@ -661,6 +661,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/audio.js b/src/audio.js index 7a37b9a..10dba2f 100644 --- a/src/audio.js +++ b/src/audio.js @@ -1,5 +1,7 @@ // All sound: WebAudio context, synthesized SFX, recorded umpire lines, speech fallback. +import { crowd } from './state.js'; // the aww carries an applause trail, and the stand has to match it + let AC = null; export const audio = () => { if (!AC) AC = new (window.AudioContext || window.webkitAudioContext)(); @@ -139,7 +141,16 @@ export function sfx(name, vol = 1, opts = {}) { } // the crowd's groan — stretched a touch and pushed through the PA reverb for a longer, wetter sigh -export function aww(vol = 1) { sfx('tennis-aww', vol * 0.5, { reverb: true }); } +export function aww(vol = 1) { + sfx('tennis-aww', vol * 0.5, { reverb: true }); + // THE SAMPLE HAS AN APPLAUSE TRAIL: tennis-aww is a groan that settles into scattered clapping, + // and a stand that stays seated through it contradicts what you can hear (Oliver, 2026-08-21). + // So the picture follows the sound — but a TENTH of a won point, which at this size is a dozen + // people standing briefly rather than the whole stand. Held at the loudest while an applause is + // already live, exactly as cheer() does: a lost rally inside an ovation may not demote it. + crowd.pop = Math.max(crowd.cheer > 0 ? crowd.pop : 0, 0.1 + 0.06 * vol); + crowd.cheer = Math.max(crowd.cheer, 3.4); +} // Looping background beds (scene atmosphere, mode music): each starts once, highpass 'lowcut' + quiet gain. const beds = new Map(); // file -> {ac, g, vol} once playing (null while loading); see fadeBed diff --git a/src/config.js b/src/config.js index 982064e..b57a939 100644 --- a/src/config.js +++ b/src/config.js @@ -285,11 +285,13 @@ export const TUNE = { // acknowledged that the match had changed; now he comments on it, disapprovingly. racketHpPerGame: 0.12, // the racket zombie stiffens as the run goes on (floored into whole hp) // CAMERA FLASHES IN THE STAND (render.tickFlashes). MEAN seconds between them — the actual gap is - // jittered 0.35x..1.95x so they never tick, and halved again while the crowd is cheering. Turn - // flashEvery UP to quieten them; it is the one knob. Life is deliberately shorter than a frame + // jittered 0.35x..1.95x so they never tick, and shortens with the size of whatever the crowd is + // reacting to (crowd.pop). Turn flashEvery UP to quieten them; it is the one knob. It was 5.0 + // and Oliver could not see them at all — "the flashes from the fotographs are too rare" — which + // at a mean of five seconds, jittered, in a stand this wide, is not surprising. Life is deliberately shorter than a frame // pair at 60 fps would need: a flash you can look AT is a light, a flash you only catch is a // photograph being taken. - flashEvery: 5.0, + flashEvery: 1.9, flashLife: 0.16, // ...and how often ONE column of the stand gets to its feet on its own when nothing is being // cheered (render's drawClapColumns). Between points the crowd used to be a photograph. This is diff --git a/src/crowd.js b/src/crowd.js new file mode 100644 index 0000000..baa7e41 --- /dev/null +++ b/src/crowd.js @@ -0,0 +1,100 @@ +// GENERATED by assets/_crowdpass.py — do not edit by hand, re-run the tool. +// The spectators who can stand up: [x, y, w, h] as fractions of the crowd sheet, sorted +// left to right. Each box holds ONE person whose arms are raised in 2 and nobody +// else; everything outside these boxes is identical in the two sheets, which is what lets +// render.js raise them one at a time with no seam. See "THE CLAP FRAME" in PROJECT.md. +export const RAISERS = [ + [-0.0014, 0.8451, 0.0327, 0.0521], + [0.0021, 0.7148, 0.0256, 0.0286], + [0.0071, 0.9128, 0.0369, 0.0456], + [0.0234, 0.5560, 0.0163, 0.0417], + [0.0391, 0.6419, 0.0384, 0.0234], + [0.0533, 0.7669, 0.0241, 0.0247], + [0.0611, 0.5586, 0.0661, 0.0404], + [0.0646, 0.7135, 0.0369, 0.0299], + [0.0689, 0.0521, 0.0227, 0.0221], + [0.0859, 0.7669, 0.0249, 0.0247], + [0.1016, 0.2643, 0.0256, 0.0312], + [0.1236, 0.7214, 0.0376, 0.0221], + [0.1399, 0.8516, 0.0249, 0.0273], + [0.1435, 0.1224, 0.0334, 0.0221], + [0.1577, 0.1732, 0.0199, 0.0586], + [0.1896, 0.7669, 0.0213, 0.0247], + [0.1932, 0.1732, 0.0170, 0.0508], + [0.1996, 0.0742, 0.0312, 0.0443], + [0.2017, 0.5990, 0.0298, 0.0299], + [0.2067, 0.7148, 0.0263, 0.0286], + [0.2358, 0.8594, 0.0462, 0.0234], + [0.2401, 0.7669, 0.0241, 0.0247], + [0.2599, 0.5586, 0.0518, 0.0599], + [0.2628, 0.1732, 0.0178, 0.0521], + [0.2898, 0.7135, 0.0376, 0.0299], + [0.2976, 0.8086, 0.0384, 0.0286], + [0.2997, 0.0326, 0.0362, 0.0651], + [0.3026, 0.7669, 0.0419, 0.0286], + [0.3068, 0.8828, 0.1044, 0.0755], + [0.3189, 0.1732, 0.0185, 0.0482], + [0.3643, 0.4883, 0.0170, 0.0781], + [0.3686, 0.7148, 0.0312, 0.0286], + [0.3885, 0.4883, 0.0178, 0.0729], + [0.3899, 0.7669, 0.0192, 0.0247], + [0.3956, 0.1732, 0.0149, 0.0508], + [0.3984, 0.8503, 0.0284, 0.0234], + [0.4062, 0.2604, 0.0206, 0.0247], + [0.4062, 0.5755, 0.0206, 0.0234], + [0.4062, 0.6146, 0.0277, 0.0247], + [0.4233, 0.7148, 0.0376, 0.0286], + [0.4233, 0.9271, 0.0540, 0.0352], + [0.4268, 0.1914, 0.0107, 0.0417], + [0.4276, 0.8438, 0.0369, 0.0534], + [0.4645, 0.0286, 0.0376, 0.0612], + [0.4737, 0.5534, 0.0284, 0.0456], + [0.4858, 0.9232, 0.0206, 0.0286], + [0.4901, 0.6315, 0.0455, 0.0208], + [0.4901, 0.7669, 0.0533, 0.0247], + [0.4979, 0.7148, 0.0355, 0.0286], + [0.5064, 0.5755, 0.0213, 0.0234], + [0.5398, 0.5586, 0.0206, 0.0404], + [0.5568, 0.7148, 0.0376, 0.0286], + [0.5604, 0.9128, 0.0831, 0.0495], + [0.5625, 0.1497, 0.0391, 0.0312], + [0.5973, 0.5586, 0.0298, 0.0247], + [0.6023, 0.2643, 0.0249, 0.0208], + [0.6186, 0.8516, 0.0582, 0.0299], + [0.6271, 0.7669, 0.0163, 0.0247], + [0.6293, 0.7214, 0.0476, 0.0221], + [0.6435, 0.1224, 0.0341, 0.0221], + [0.6562, 0.7526, 0.0249, 0.0391], + [0.6584, 0.1732, 0.0192, 0.0586], + [0.6854, 0.7565, 0.0540, 0.0352], + [0.6925, 0.1732, 0.0178, 0.0508], + [0.6974, 0.9271, 0.0462, 0.0352], + [0.6996, 0.0742, 0.0341, 0.0404], + [0.7060, 0.8529, 0.0256, 0.0260], + [0.7266, 0.5586, 0.0426, 0.0430], + [0.7393, 0.7669, 0.0249, 0.0286], + [0.7401, 0.5977, 0.0249, 0.0208], + [0.7521, 0.9128, 0.1207, 0.0456], + [0.7585, 0.0430, 0.0398, 0.0247], + [0.7621, 0.1732, 0.0156, 0.0521], + [0.7692, 0.5755, 0.0249, 0.0221], + [0.7898, 0.7135, 0.0376, 0.0299], + [0.7997, 0.0326, 0.0362, 0.0651], + [0.8068, 0.7669, 0.0419, 0.0286], + [0.8232, 0.1758, 0.0142, 0.0469], + [0.8523, 0.7669, 0.0213, 0.0247], + [0.8636, 0.7148, 0.0348, 0.0286], + [0.8643, 0.4883, 0.0170, 0.0742], + [0.8885, 0.4883, 0.0178, 0.0729], + [0.8928, 0.1732, 0.0170, 0.0508], + [0.8984, 0.8503, 0.0284, 0.0234], + [0.9062, 0.2604, 0.0206, 0.0247], + [0.9062, 0.5755, 0.0206, 0.0221], + [0.9233, 0.7148, 0.0376, 0.0286], + [0.9268, 0.1901, 0.0149, 0.0430], + [0.9276, 0.8438, 0.0369, 0.0534], + [0.9318, 0.6914, 0.0334, 0.0208], + [0.9318, 0.9271, 0.0455, 0.0352], + [0.9645, 0.0286, 0.0369, 0.0612], + [0.9737, 0.5586, 0.0241, 0.0404], +]; diff --git a/src/render.js b/src/render.js index 0bb5aef..0ec1877 100644 --- a/src/render.js +++ b/src/render.js @@ -3,6 +3,7 @@ import { cv, ctx } from './dom.js'; import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS, HEAD_CROP, CUP_ROUNDS, TINT, SHOT_DEPTH } from './config.js'; import { P, O, ball, score, crowd, G, court, tour, kit, skin } from './state.js'; +import { RAISERS } from './crowd.js'; import { predictLanding, previewServe, slotId, slotStock, serveApexY, rallyFeel, swayOf } from './gameplay.js'; import { drillQueue, drillLegal, drillMult } from './drill.js'; import { zombies, gore, piles, parts, pellets, extras, shots, pops } from './zombies.js'; @@ -234,7 +235,7 @@ function farB(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 = 143; +export const SPRV = 144; // 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". @@ -430,23 +431,34 @@ const standDim = pt => Math.min(0.85, (court.dim || 0) + 0.26 * pt); // of these columns. That is what makes any column swap seamless — a column with nobody raising in // it draws exactly what was already there. CHANGE THIS AND THE SHEETS MUST BE RECUT (same constant, // COLS, at the top of that script). -const COLS_CROWD = 16; -function drawClapColumns(clapImg, standsBottom, pt, on) { - const up = []; - for (let i = 0; i < COLS_CROWD; i++) if (on(i)) up.push(i); - if (!up.length) return; // most idle frames: no clip, no save, nothing drawn +// THE STAND MOVES ONE SPECTATOR AT A TIME. `crowd.js` (generated by assets/_crowdpass.py) lists a +// box around every pair of raised arms in the clap sheet, and those arms are the ONLY thing that +// differs between the two sheets — so drawing one box over the cached stand raises one person and +// touches nobody else. The 16-column strip swap this replaced moved a seventeenth of the crowd as a +// block, which is what made it read as machinery rather than as people (Oliver: "the animation also +// looks pretty artificial"). +// The sub-rect is drawn at EXACTLY the scale paintBackdrop used for the whole sheet (kx/ky below), +// which is what guarantees the raised arms land on the same pixel grid as the body under them. +const rnd = i => ((i * 9301 + 49297) % 233280) / 233280; // one stable pseudo-random per spectator +function drawRaisers(clapImg, standsBottom, pt, up) { const crowdH = standsBottom - AD_H + 2; - const sw = clapImg.width / COLS_CROWD, cw = W / COLS_CROWD; + const kx = W / clapImg.width, ky = (crowdH + 2) / clapImg.height; const d = standDim(pt), tint = d > 0 ? `rgba(12,8,20,${d})` : null; - ctx.save(); ctx.beginPath(); ctx.rect(0, 0, W, standsBottom - AD_H); ctx.clip(); - for (const i of up) { - // integer-aligned draws, no vertical offset: fractional-y nearest-neighbour resampling is the - // boiling that once read as HUD flicker. Re-tinted per column so a raised column does not pop - // brighter than the rotted stand around it. - ctx.drawImage(clapImg, i * sw, 0, sw, clapImg.height, i * cw, 0, cw + 0.5, crowdH + 2); - if (tint) { ctx.fillStyle = tint; ctx.fillRect(i * cw, 0, cw + 0.5, crowdH + 2); } + let open = false; + for (let i = 0; i < RAISERS.length; i++) { + const r = RAISERS[i]; + if (!up(i, r[0])) continue; + if (!open) { // most frames raise nobody: no clip, no save, nothing drawn + open = true; + ctx.save(); ctx.beginPath(); ctx.rect(0, 0, W, standsBottom - AD_H); ctx.clip(); + } + const sx = r[0] * clapImg.width, sy = r[1] * clapImg.height; + const sw = r[2] * clapImg.width, sh = r[3] * clapImg.height; + ctx.drawImage(clapImg, sx, sy, sw, sh, sx * kx, sy * ky, sw * kx + 0.5, sh * ky + 0.5); + // re-tinted like the cached stand, so a raised pair of arms cannot pop brighter than the rot + if (tint) { ctx.fillStyle = tint; ctx.fillRect(sx * kx, sy * ky, sw * kx + 0.5, sh * ky + 0.5); } } - ctx.restore(); + if (open) ctx.restore(); } // ---------------------------------------------------------------- backdrop ---- @@ -552,33 +564,33 @@ function drawBackdrop(pal, pt) { } ctx.drawImage(bg, -8, -8, W + 16, H + 16); const standsBottom = proj(0, 0, -6).y; - // Applause is a real TWO-FRAME animation: x_crowd is the seated sheet (cached above), 2 - // the same stand with about forty of those people, and only those people, on their feet. + // Applause is a real animation: 2 is the seated stand with 93 pairs of arms raised into + // the wall above the rows, and each of those people is raised on their own clock. const clapImg = crowdImg && (crowdImg === SPR['x_crowd'] ? SPR['x_crowd2'] : SPR[court.crowd + '2']); if (crowdImg && clapImg) { - // WHICH COLUMNS ARE ON THEIR FEET. Three behaviours, one draw: - // applause — every column on its own phase, a scatter of people rising and sitting; - // the WAVE — a travelling band, for a moment big enough to deserve one (crowd.pop); - // a FIDGET — one column, briefly, between points, so an idle stand is not a photograph. - // The 4.5 Hz toggle this replaced was not the problem (see _crowdpass.py): the old clap sheet - // was a re-roll, so a swapped column re-hashed the whole crowd instead of raising arms. + // WHO IS ON THEIR FEET. Three behaviours, one draw: + // applause — HOW MANY of them join scales with the size of the moment, each on their own + // period, so a groan for a lost point is a dozen people and a match point is the whole + // stand. That is what makes the same machinery do "subtle" (Oliver, on the aww sample: + // "the crowd should still clap but more subtle"); + // the WAVE — a front travelling across the stand, person by person, for a moment big enough; + // a FIDGET — one person, briefly, between points, so an idle stand is not a photograph. + const pop = crowd.cheer > 0 ? crowd.pop : 0; // 0.75, not 0.6: a game won calls cheer(2.4, 0.6) and a wave on every held serve is a wave // nobody looks up for. This leaves it to the set, the match, and the ones the umpire calls // unbelievable. - const big = crowd.cheer > 0 && crowd.pop >= 0.75; - const on = crowd.cheer > 0 - ? (big - // one band, ~2.5 columns wide, crossing the stand about every 1.4 s - ? i => ((G.idleT / 1.4 - i / COLS_CROWD) % 1 + 1) % 1 < 0.16 - : i => Math.floor(G.idleT * 7 + i * 0.7) % 2 === 1) - // ...and when nobody is cheering, ONE column every fidgetEvery seconds, for a third of it. - : i => { + const up = pop >= 0.75 + ? (i, x) => ((G.idleT / 1.4 - x * 0.8 - rnd(i) * 0.03) % 1 + 1) % 1 < 0.18 + : pop > 0 + // rnd(i) picks WHO (a stable draw per spectator, so the same people join a small moment), + // rnd(i+7) staggers WHEN, and the period varies per person — a stand clapping in unison is + // a machine. + ? i => rnd(i) < pop && ((G.idleT / (1.5 + rnd(i + 7) * 1.3) + rnd(i + 3)) % 1) < 0.45 + : (i) => { const slot = Math.floor(G.idleT / TUNE.fidgetEvery); - // *5: coprime with 16, so it visits every column — but in steps of five, where *7919 - // (i.e. *15) walked one seat sideways each time and read as a march. - return i === ((slot * 5 + 3) % COLS_CROWD) && (G.idleT / TUNE.fidgetEvery) % 1 < 0.34; + return i === (slot * 37) % RAISERS.length && (G.idleT / TUNE.fidgetEvery) % 1 < 0.34; }; - drawClapColumns(clapImg, standsBottom, pt, on); + drawRaisers(clapImg, standsBottom, pt, up); } tickFlashes(standsBottom); // ...and somebody in there is taking pictures return standsBottom;