The crowd is drawn again, in detail — and the stand now moves one spectator at a time

Oliver on the first pass: "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 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."

The paint pass is deleted — recolouring blobs of an old drawing is not drawing,
and the reason it looked necessary was a wrong conclusion: the roll scripts read
OPENROUTER_API_KEY from the repo's .env, so the art could be rolled all along.

_crowdroll.py rolls the crowd itself: individually drawn spectators with hats,
sunglasses, drinks, cameras, flags and banners, empty seats, stairways, and
three zombies sitting in the middle of it behaving like furniture. ONE roll now
serves all three courts through a per-channel histogram match onto each court's
old palette, so anything the art gains shows up on clay and at night too.

_crowdpass.py keeps only what was empty stand in the seated roll and became a
person in the clap roll — the raised arms, 1.2% of the sheet — so the clap frame
cannot drift by construction, and writes a box around each pair of arms into
src/crowd.js. render.js raises ONE SPECTATOR AT A TIME out of that table, each
on their own period: the 16-column strip swap moved a seventeenth of the crowd
as a block, which is what read as machinery. crowd.pop is now a head count, so
the same code covers a dozen people and the whole stand, and past 0.75 the wave
travels person by person.

aww() sets a small crowd.pop of its own — the sample is a groan that settles
into applause, and the stand has to match what you can hear. flashEvery 5.0 ->
1.9, because at a five-second mean over a stand this wide nobody ever saw one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-21 13:30:34 +02:00
1 parent 74afd5988c
commit 5f53a686c8
14 files changed
+499 -398

No files matched your search

+73 -22
View File
@@ -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
+136 -336
View File
@@ -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 <crowd>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__':
+125
View File
@@ -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')
Binary file not shown.

Before

Width:  |  Height:  |  Size: 477 KiB

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 434 KiB

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 KiB

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 380 KiB

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 383 KiB

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 KiB

After

Width:  |  Height:  |  Size: 375 KiB

+1 -1
View File
@@ -661,6 +661,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div>
</div>
</div>
<script type="module" src="src/main.js?v=298"></script>
<script type="module" src="src/main.js?v=299"></script>
</body>
</html>
+12 -1
View File
@@ -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
+5 -3
View File
@@ -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
+100
View File
@@ -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 <crowd>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],
];
+47 -35
View File
@@ -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 <img> 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), <crowd>2
// the same stand with about forty of those people, and only those people, on their feet.
// Applause is a real animation: <crowd>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;