The stand stops wobbling: a real clap frame, a wave, and three spectators who have already turned

The wobble was never the animation. x_crowd2 was a RE-ROLL of x_crowd, not a
pose of it — 13-16% of its pixels differed, over faces, torsos, seat backs and
the railing — so swapping 16 columns at 4.5 Hz re-hashed a strip of crowd twice
a second. assets/_crowdpass.py rebuilds the pair: the clap frame is now the
seated frame plus 36 genuine raises, each contained in ONE of render's columns,
so any column swap is seamless by construction and an empty column is invisible
instead of being a wobble. The three courts are one composition in three
palettes, so a raise found in one court's roll is transplanted into the other
two through a colour map learnt from the aligned seated pair.

The same tool paints the crowd, because at ~8 logical px a spectator detail can
only mean chroma and silhouette: 55 shirts and 44 heads of hair in colours the
court does not own, 28 caps, 4 flags above the heads, 8 emptied seats, 8 people
permanently standing — and 3 who are already turned, grey-green, mid-row, who
are also the only three who never applaud.

Movement, one draw path and three behaviours: applause scatters per column, a
WAVE crosses the stand for a moment big enough to deserve one (crowd.pop >=
0.75), and with nobody cheering ONE column stands for a third of every
TUNE.fidgetEvery, so the stand between points is no longer a photograph.

Paparazzi now scale with the moment: cheer() records the size of it (vol was
already exactly that at every call site) and queues shutters proportional to it,
drained at a fifth of the normal gap. pop is held at the loudest while an
applause is live, so a won rally landing inside a match-point ovation cannot
demote the stand to a polite clap — that rule has a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-21 12:58:26 +02:00
1 parent 76ea1d0d79
commit 74afd5988c
14 files changed
+585 -42

No files matched your search

+101 -18
View File
@@ -20,9 +20,11 @@ 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-20 (THE SHELL: a Capacitor iOS project, haptics on the existing sfx call
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
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
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.
See "THE FRIEND ACROSS THE NET WEARS YOUR KIT". Before that 2026-08-18: the store model in LATER revised: free download + €2.99 unlock-everything
IAP with the free-tier gates, superseding paid-upfront; before that 2026-08-15, two sessions: the
@@ -345,12 +347,19 @@ 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).
- **Crowd applause is a real two-frame swap** (`x_crowd` seated / `x_crowd2` arms up), per column on
its own phase, 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 board art is 99.9% opaque; nothing was ever showing through it).
**If you touch one crowd frame you must apply the identical curve to the other**, or the swap
starts flashing brightness. `assets/_sheet_crowd1.png` is the pre-brightening original.
- **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.
## Settled feel decisions — do not regress silently
- **Far bodies draw off a flattened scale curve; the camera holds still.** `TUNE.farK` 0.25
@@ -2298,6 +2307,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. |
**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
@@ -2538,6 +2548,69 @@ 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 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
with a client side animation right now.. that feels odd. also add with a little bit of humor"* — and,
mid-build: *"the crowd could be more detailed (and I don't [mean] the technical resolution of the
image)"*.
**The wobble was never the animation.** `x_crowd2` was a RE-ROLL of `x_crowd`, not a pose of it:
13-16% of its pixels differed, spread over faces, torsos, seat backs and the railing. Swapping 16
columns between them at 4.5 Hz therefore re-hashed a strip of crowd twice a second. Every knob you
could turn on the animation was the wrong knob.
`assets/_crowdpass.py` rebuilds the pair instead, and establishes the invariant in "Architecture &
invariants". Three things fall out of the fact that **the three courts are one composition in three
palettes, pixel-aligned**:
- the art pass finds its people ONCE (on the clay sheet, the cleanest — grass carries the brightening
curve's noise) and applies the same masks to all three, so the three crowds stay the same people;
- a raise that exists in only ONE court's roll is **transplanted** into the other two through a colour
map learnt from the aligned seated pair (`colour_map`). Per court the honest raises number about
fifteen; pooled, 44 people can get to their feet. Eight of them are baked STANDING into both frames;
- everything the art pass paints is excluded from the clap set, so no painted pixel can ever flicker.
That ordering is load-bearing — the clap set is chosen FIRST and reserved, because a crowd this
heavily painted leaves nothing that is allowed to move if you paint first.
**RESOLUTION IS NOT THE PROBLEM AND ADDING PIXELS WOULD NOT HELP.** Measured: the sheet is 1408x768
and the stand band is ~1080x784 device px on a 3x phone, i.e. already ~1:1 vertically and 0.77x
horizontally (in landscape it is 1.36x wide by 0.54x tall — the sheet is stretched to whatever band
exists, so spectators are anamorphic, differently per orientation; see OPEN). At that size a
spectator is ~8 logical px, so "more detailed" can only mean **chroma and silhouette**, which is what
the pass adds: 55 shirts and 44 heads of hair in colours the court does not own, 28 of those as caps,
4 flags on poles above the heads, 8 emptied seats so no row ends in a straight line of identical
heads, and 8 people permanently standing.
**HUMOUR, and it costs nothing:** three spectators are already TURNED (grey-green skin, mid-row,
nobody around them reacts) — and because a painted pixel is never in the clap set, they are also the
only three in the stand who never applaud. It is a hint at the parked stands-turn-into-zombies step,
not that step.
**MOVEMENT — one draw path, three behaviours** (`drawClapColumns`):
- **applause**: every column on its own phase, 3.5 Hz — a scatter of people rising and sitting;
- **THE WAVE**: a band ~2.5 columns wide crossing the stand every 1.4 s, and only when `crowd.pop >=
0.75` — the set, the match, and the ones the umpire calls unbelievable. A game won is `cheer(2.4,
0.6)` and a wave on every held serve is a wave nobody looks up for;
- **the FIDGET**: with nobody cheering, ONE column stands for a third of every `TUNE.fidgetEvery`
(4.5 s). Between points the stand used to be a photograph. The column walks `slot * 5 + 3` — five
is coprime with 16 so it visits every column, where the first cut (`* 7919`, i.e. `* 15`) moved one
seat sideways each time and read as a march.
**PAPARAZZI SCALE WITH THE MOMENT** (Oliver: "increase paparazzi burst during bigger events").
`cheer(seconds, vol)` now records `crowd.pop` (the size of the moment, 0..1 — vol was already exactly
that at every call site) and queues `crowd.burst` shutters proportional to it; `tickFlashes` drains
that queue at a fifth of the normal gap, and outside a burst the rate rides `pop` too. **`pop` is
held at the loudest while an applause is live** — a won rally landing inside a match-point ovation
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.
## THE SHELL — CAPACITOR, HAPTICS, PORTRAIT, SWIPE-BACK (2026-08-20)
The first three bullets of LATER item 2 ("What the shell buys GAMEPLAY-wise") are built. **The web
@@ -3632,42 +3705,52 @@ Nothing here is broken; these are known gaps and unverified risks.
**If the game ever stutters, `RES` back to 3 is the first move** — not reducing zombie counts.
2. **The game-20 horde fix is unplayed.** The crowd-scaled swish (1.00 s at one body -> 0.22 s at
fourteen) is measured but not yet felt. If it now trivialises hordes, `meleeCoolMin` is the knob.
3. **`p_idle1`/`p_idle2` and most `o_*` frames are 2x-upscaled, not re-sliced.** They are on the right
3. **The crowd sheet is stretched, not fitted.** It is drawn to fill whatever stand band the
viewport leaves, so on portrait a spectator is squeezed to 0.77x his width and in landscape he is
1.36x wide by 0.54x tall — anamorphic, differently per orientation, and nearest-neighbour on top
(one source column in four is simply dropped on a 3x phone, which is the hash on the faces). Fixes
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.
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
through the signature matcher, which assigned those frames elsewhere.
4. **Boss designs are asymmetric — still, for two of the six.** Designs 1 (GROUNDSKEEPER), 4 (HEAD
5. **Boss designs are asymmetric — still, for two of the six.** Designs 1 (GROUNDSKEEPER), 4 (HEAD
COACH), 5 (PHYSIO) and 6 (SPONSOR MASCOT) have 3 wound stages and 4 throw frames each; designs 2
(LINE JUDGE) and 3 (CHAIR UMPIRE) still have 1 wound skin and 2 throw frames. Legal — the
`BOSS_ART` indices are clamped — but a chapter-2 boss degrades in one visible step instead of three.
Fixing them is now just two batches in the shape of `boss_coach_wounds` / `boss_coach_throw`.
5. ~~**Only 3 boss designs, cycling.**~~ CLOSED 2026-08-03 — six now, so a chapter does not repeat a
6. ~~**Only 3 boss designs, cycling.**~~ CLOSED 2026-08-03 — six now, so a chapter does not repeat a
design until chapter 7. A seventh is still a sheet plus one row in `BOSS_ART`.
6. **Landscape is under-maintained.** The readability pass (camZ/camY/camF/horizon), the adaptive
7. **Landscape is under-maintained.** The readability pass (camZ/camY/camF/horizon), the adaptive
height and the safe-area work are all portrait-only; landscape still runs the old camera and a fixed
480x270. Portrait is the stated primary target, but landscape has drifted and is untested.
**RETIRED IN THE APP, 2026-08-20** — `Info.plist` lists one orientation, so the shell never rotates
and landscape is officially not-a-surface there. The WEB build can still be turned sideways (iOS
Safari has no orientation lock), so this stays open, at a lower stake: it is now a browser-only
cosmetic gap rather than something a store reviewer will see.
7. **`drawCard` (the chapter/set cards) still cuts in** with no entrance, while modals swoosh
8. **`drawCard` (the chapter/set cards) still cuts in** with no entrance, while modals swoosh
(`TUNE.modalIn`). Offered and not yet requested.
8. **A chapter can be skipped.** `G.bossDue` takes the max, so going a long stretch without ever
9. **A chapter can be skipped.** `G.bossDue` takes the max, so going a long stretch without ever
clearing the court means you face the current chapter, not a backlog. Deliberate; noted in case it
ever reads as a bug.
9. **The runner's four frames were fixed BY HAND and must be regenerated.** `z_f1..4` are currently
10. **The runner's four frames were fixed BY HAND and must be regenerated.** `z_f1..4` are currently
hip-registered (padded to one width with the hip on the centre line) and frames 3 and 4 are
mirrored copies, because all four were drawn leaning the same way and lifting the same leg. That
is exactly the post-processing Oliver ruled out — it shipped before he stated the rule. The batch
to write asks for a four-frame front-on sprint whose stride and lean ALTERNATE, and the numbers to
check afterwards are the per-frame hip centroid (it was 13 px of lateral jump at 12 frames a
second) and which leg leads.
10. **Step 6 stays PARKED** by Oliver's decision: the crowd turning into zombies (spawning from the
11. **Step 6 stays PARKED** by Oliver's decision, and the 2026-08-21 art pass did NOT spend it — the
three already-turned spectators in the seated sheet are a hint, not the mechanic. Step 6 is: the
crowd turning into zombies (spawning from the
stands — attacks from behind, high spectacle, `x_crowd` sheets already exist) and the ball-boy zombie
that catches your shots. Recorded so the ideas are not lost, not as a to-do.
11. **`ump_no_deal` does not exist.** Oliver asked for its removal; `ump_no_appeal` was removed as the
12. **`ump_no_deal` does not exist.** Oliver asked for its removal; `ump_no_appeal` was removed as the
closest match. Worth one confirmation that no other line is still unwanted.
12. **Four sounds are missing and need RESEARCH first** (find/choose the source before implementing —
13. **Four sounds are missing and need RESEARCH first** (find/choose the source before implementing —
every existing sfx is either synthesised in `audio.js` or a wav in `assets/audio/`):
- the **ball machine** while it moves — a squeaking wheel/castor, so the furniture announces
itself. It already telegraphs its shot (`machWind` 0.9 s); this is the *approach* cue.
+386
View File
@@ -0,0 +1,386 @@
# _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.
#
# 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.
#
# 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.
#
# 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.
#
# 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.
#
# 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
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
# 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
# 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)
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 modal(px):
v = int(np.bincount(pack(px)).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 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)
sizes = ndimage.sum(np.ones_like(lab), lab, range(1, n + 1))
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):
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
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 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 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."""
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 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()}
# 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)')
# 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')
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}%)')
if __name__ == '__main__':
main('--review' in sys.argv)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 611 KiB

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 405 KiB

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 KiB

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 KiB

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 429 KiB

After

Width:  |  Height:  |  Size: 372 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=297"></script>
<script type="module" src="src/main.js?v=298"></script>
</body>
</html>
+4
View File
@@ -291,6 +291,10 @@ export const TUNE = {
// photograph being taken.
flashEvery: 5.0,
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
// deliberately slow and single-column: a stand that fidgets constantly is a stand you look AT.
fidgetEvery: 4.5,
// ---- damage. Bodies with hp (racket boss 'r', brute 'g') take damage in these units, so a big
// boss is just a bigger hp number and every weapon stays proportional. Fractions are legal: they
// bank on zb.chip and only whole points come off hp (the wound SKINS switch on hp thresholds).
+7
View File
@@ -589,6 +589,13 @@ export function aside(name, delay = 0.2) {
export function cheer(seconds, vol) {
sfx('tennis-applaus', Math.min(1.5, vol * 2.2)); // recorded crowd; vol scales with the size of the moment (gain > 1 amplifies)
// HOW BIG THIS ONE WAS. vol is already the size of the moment everywhere it is called from (a
// won rally 0.25, a game 0.6, a match or an EPIC 1.0), so the stand reads it rather than growing
// a second scale of its own: past 0.6 the wave rolls, and the photographers in the stand fire a
// burst proportional to it. Held at the LOUDEST while an applause is live — a small cheer landing
// inside a big one must not quietly demote it.
crowd.pop = Math.max(crowd.cheer > 0 ? crowd.pop : 0, Math.min(1, vol));
crowd.burst += Math.round(crowd.pop * 7);
// floored at the CLIP LENGTH: the sample always plays out in full, so the stand must stay on its
// feet until it stops. `seconds` still lets a big moment run longer than the audio.
crowd.cheer = Math.max(crowd.cheer, seconds, APPLAUSE_T);
+58 -20
View File
@@ -234,7 +234,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 = 142;
export const SPRV = 143;
// 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".
@@ -389,9 +389,14 @@ let flashNext = 0;
function tickFlashes(standsBottom) {
const now = G.idleT, top = Math.max(4, standsBottom - AD_H - 4);
if (now >= flashNext) {
// the gap is exponential-ish so they never look metronomic; cheer halves the mean wait
const mean = TUNE.flashEvery / (crowd.cheer > 0 ? 2.2 : 1);
flashNext = now + mean * (0.35 + Math.random() * 1.6);
// THE BIGGER THE MOMENT, THE MORE SHUTTERS. cheer() queues crowd.burst shutters proportional to
// its own vol, and they fire back to back at a fifth of the normal gap — a match point empties
// the stand's cameras at once, a won rally gets one or two. Outside a burst the rate still
// doubles while the crowd is up. The gap is exponential-ish so they never look metronomic.
const rush = crowd.burst > 0;
if (rush) crowd.burst--;
const mean = TUNE.flashEvery / (1 + (crowd.cheer > 0 ? 1.2 + 2.4 * crowd.pop : 0));
flashNext = now + (rush ? mean * 0.13 : mean * (0.35 + Math.random() * 1.6));
flashes.push({ x: 6 + Math.random() * (W - 12), y: 6 + Math.random() * (top - 6),
// small. The first cut was r 2.2-4.2 and its bloom drew a 6 px disc that read as
// a LAMP in the stand rather than a shutter going off half a second ago
@@ -420,6 +425,30 @@ function tickFlashes(standsBottom) {
// is a black rectangle and the applause stops reading at all.
const standDim = pt => Math.min(0.85, (court.dim || 0) + 0.26 * pt);
// THE STAND MOVES IN COLUMNS, and the sheets are cut to match: `assets/_crowdpass.py` guarantees
// the clap frame is pixel-identical to the seated one EXCEPT inside blobs that each sit inside ONE
// 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
const crowdH = standsBottom - AD_H + 2;
const sw = clapImg.width / COLS_CROWD, cw = W / COLS_CROWD;
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); }
}
ctx.restore();
}
// ---------------------------------------------------------------- backdrop ----
// THE STATIC SCENE, PRE-COMPOSED. Stands, ads, rot tint, out area, court, lines and the run-off
// apron are pixel-identical frame to frame except while a palette lerps (the 6 s transition, the
@@ -524,23 +553,32 @@ 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 every arm up. Columns swap on their own phase so the clap ripples across
// the stands — integer-aligned draws, no vertical offset, because fractional-y nearest-neighbour
// resampling is the boiling that once read as HUD flicker. Clipped above the ad strip so the
// redrawn columns never paint over the boards, and re-tinted per column so a clapping column
// does not pop brighter than the rotted stand around it.
// the same stand with about forty of those people, and only those people, on their feet.
const clapImg = crowdImg && (crowdImg === SPR['x_crowd'] ? SPR['x_crowd2'] : SPR[court.crowd + '2']);
if (crowd.cheer > 0 && crowdImg && clapImg) {
const crowdH = standsBottom - AD_H + 2;
const cols = 16, sw = clapImg.width / cols, cw = W / cols;
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 (let i = 0; i < cols; i++) {
if (!(Math.floor(G.idleT * 9 + i * 0.7) % 2)) continue;
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); }
}
ctx.restore();
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.
// 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 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;
};
drawClapColumns(clapImg, standsBottom, pt, on);
}
tickFlashes(standsBottom); // ...and somebody in there is taking pictures
return standsBottom;
+5 -2
View File
@@ -19,7 +19,10 @@ export const ball = { x: 0, y: 1, z: 20, vx: 0, vy: 0, vz: 0, g: 9.2,
// the WEAPONS row itself is out of scope. Lives on the ball so PRISTINE resets it for free.
wid: 'ball' };
export const score = { pts: [0, 0], games: [0, 0], server: 'P' }; // pts index 0=P 1=O
export const crowd = { cheer: 0, dots: [] }; // dots are (re)built by render.buildCrowd
// `pop` is the SIZE of the moment the stand is reacting to (cheer's own vol, 0..1) and is only
// meaningful while cheer runs: it drives the wave, the paparazzi rate and how many shutters go off
// at once. `burst` is that queue of shutters, drained by render.tickFlashes.
export const crowd = { cheer: 0, pop: 0, burst: 0, dots: [] }; // dots are (re)built by render.buildCrowd
// The court you picked on the start screen. Everything visual reads `court` directly; the two
// bounce numbers are COPIED INTO TUNE instead, so the T panel still edits the live physics and
@@ -192,7 +195,7 @@ export function resetState() {
Object.assign(G, p.G); Object.assign(P, p.P); Object.assign(O, p.O);
Object.assign(ball, p.ball); Object.assign(score, p.score);
G.hp = G.hpMax = TUNE.playerHp; // re-read: the T panel may have moved it since boot
crowd.cheer = 0;
crowd.cheer = crowd.pop = crowd.burst = 0;
}
export const other = s => s === 'P' ? 'O' : 'P';
+23 -1
View File
@@ -89,7 +89,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' });
// ---- game modules (real code) ----------------------------------------------
const state = await import('./src/state.js');
const { G, P, O, score, ball, setupPoint } = state;
const { update, forceZombies, startGore, splatPoint, restartRun, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe } = await import('./src/gameplay.js');
const { update, forceZombies, startGore, splatPoint, restartRun, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer } = await import('./src/gameplay.js');
const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN, startNetVs, endNetVs } = await import('./src/beats.js');
const { mirror, netMsg, netTest, netTick } = await import('./src/net.js');
// the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a
@@ -1899,5 +1899,27 @@ test('haptics: a blast that kills a dozen bodies is felt twice, not thirteen tim
assert.equal(buzz.length, 2, 'one heavy for the blast, one medium for the kills — not one per body');
});
test('the stand reads the SIZE of the moment, and a small cheer inside a big one cannot demote it', () => {
// crowd.pop is what decides whether the wave rolls and how many cameras go off (render.js), and
// every one of its inputs is a vol somebody passed to cheer(). The trap it is written against:
// a won rally landing in the middle of a match-point ovation must not drop the stand back to a
// polite clap, because cheer() is called from a dozen places and they overlap constantly.
const { crowd } = state;
crowd.cheer = crowd.pop = crowd.burst = 0;
cheer(0.8, 0.25); // a rally
assert.equal(crowd.pop, 0.25);
assert.ok(crowd.burst >= 1, 'even a small moment gets a shutter or two');
cheer(4, 1); // ...and then the match
assert.equal(crowd.pop, 1, 'the bigger moment owns the stand');
const queued = crowd.burst;
cheer(0.8, 0.25); // a rally INSIDE the ovation
assert.equal(crowd.pop, 1, 'a small cheer inside a live one may not demote it');
assert.ok(crowd.burst > queued, 'it still adds shutters, at the big moment\'s rate');
crowd.cheer = 0; // the ovation ends
cheer(0.8, 0.25);
assert.equal(crowd.pop, 0.25, 'once the stand has sat down, the next moment sets its own size');
crowd.cheer = crowd.pop = crowd.burst = 0;
});
console.log(`\n${passed} tests passed`);
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report