The crowd sheet becomes an animation sheet: three frames, and the stand sits back

Oliver: "when clapping I barely see any movement at all now.. also all of the
art could be colored a little more dim as it is perspective wise farther in the
background and should not steal too much attention" — and then, "can't we make
an animation sprite sheet of the new crowd?", which is the answer to the first
half of the first one.

A third roll (_crowdroll.py mid) takes the arms-up frame as its reference and
asks for the same standing people with their hands brought together in front of
their faces. It drifts by 53% of its pixels and that does not matter: the same
filter keeps only what was stand and became person. 100 arm blobs in pose 1, 74
in pose 2, 134 spectators once merged, 28 of them owning BOTH poses — those 28
cycle between two raised poses, which is a clap; the rest alternate with the
seated frame, which is arms going up and down.

The point is the cycle, not the pose. A raiser is ~8x6 logical px on a phone and
one that appears and then holds for a second is not something the eye catches;
arms moving at 3.5 Hz is. Measured: 2-3% of the stand band now changes every
90 ms during a won game, against near zero before.

The sheets are one file per court now — three frames stacked, graded as a single
image so they cannot drift apart in colour — and x_crowd2/_c2/_h2 are deleted.

TUNE.standBack 0.22 dims the crowd and the boards on every court before the
night court's own dim and the splatter rot, in the one function both the cached
backdrop and the live raisers already read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-21 13:59:02 +02:00
1 parent 1ad2898530
commit 0250e9bd20
14 files changed
+333 -198

No files matched your search

+52 -20
View File
@@ -21,8 +21,8 @@ the state-machine contracts that have historically produced the show-stoppers. T
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 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
every frame is the seated sheet plus that pose's raised arms and nothing else, the sheet is a real
three-frame animation sheet, 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
@@ -348,24 +348,36 @@ 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 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 CROWD SHEET IS AN ANIMATION SHEET, and every frame of it is frame 0 plus arms.** One file
per court, `FRAMES` frames stacked vertically: 0 seated (what the cached backdrop draws), 1 arms
raised high, 2 hands together in front of the face. `assets/_crowdpass.py` builds 1 and 2 by
copying, out of two further rolls, ONLY the pixels that were STAND in the seated roll and became
PERSON — about 1.2% of the sheet each. Not a raiser's face, not his shirt, not his seat: those are
frame 0's own pixels and cannot drift. **`src/crowd.js`** (generated) boxes each of the 134
spectators who can move, with a mask of which raised frames they appear in — 28 own both, and
those 28 cycle between the two poses, which is a clap rather than an appearance. render.js
animates **one spectator at a time** out of that table.
It was NOT true before 2026-08-21: the clap sheet was a separate roll differing 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 a crowd frame, and never edit `src/crowd.js`.** Re-roll with `_crowdroll.py`,
then re-run `_crowdpass.py`, which rebuilds every frame, all 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".
- **A raiser is ~8x6 logical px, so a pose that appears and holds is invisible.** What reads as
applause is the CYCLE — arms high, hands together, up, down, at about 3.5 Hz — and how many people
are doing it at once (`crowd.pop`). Oliver on the first per-person cut: "when clapping I barely see
any movement at all now."
- **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."
roll under three per-channel histogram matches onto the courts' old palettes, applied to the whole
stack at once so the frames cannot drift apart in colour. They were three separate rolls until
2026-08-21, and the cost of that was exactly what Oliver reported: "the whole thing somehow does
not translate well to the other courts, there the difference is barely visible."
- **The stand sits BACK.** `TUNE.standBack` (0.22) dims the crowd and the boards on every court,
before the night court's own `dim` and the splatter rot. Oliver: "all of the art could be colored
a little more dim as it is perspective wise farther in the background and should not steal too
much attention." It is the only thing on screen that is thirty metres away.
`assets/_sheet_crowd1.png` is the pre-brightening original of the FIRST crowd, kept for history.
## Settled feel decisions — do not regress silently
@@ -2314,7 +2326,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 — 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. |
| The CROWD — the art itself | `_crowdroll.py` (three rolls: `seat`, then `clap` from it, then `mid` from that) -> `_crowdpass.py` (throws the drift away, keeps the arms, stacks the frames, grades the three courts, writes `src/crowd.js`). Every frame of all three courts comes 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
@@ -2595,6 +2607,26 @@ never the right tool. It is deleted. The art is rolled.
- **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 clapping was invisible, and the stand was too loud (2026-08-21, same day)
*"when clapping I barely see any movement at all now.. also all of the art could be colored a little
more dim as it is perspective wise farther in the background and should not steal too much
attention."* Then: *"can't we make an animation sprite sheet of the new crowd?"* — which is the right
question and the answer to the first half of the first one.
- **A THIRD ROLL, and the sheets became animation sheets.** `_crowdroll.py mid` takes the arms-up
frame as its reference and asks for the same standing people with their hands brought together in
front of their faces. It drifts harder than the first (53% of pixels) and it does not matter: the
same filter keeps only stand-turned-person. 100 arm blobs in pose 1, 74 in pose 2, 134 spectators
once merged, **28 of them with both poses** — those 28 cycle between two raised poses, which is a
clap; the rest alternate with the seated frame, which is arms going up and down. Either way the
stand is now MOVING rather than holding, and that is what the eye catches at 8x6 logical px.
Measured: 2-3% of the stand band changes every 90 ms during a won game, against near zero before.
- **One file per court instead of two.** Three frames stacked vertically, graded as one image so the
frames cannot drift apart in colour, and `x_crowd2`/`x_crowd_c2`/`x_crowd_h2` are deleted.
- **`TUNE.standBack` 0.22** — the whole stand, on every court, pushed back before anything else is
added. One knob, applied in the one place both the cached backdrop and the live raisers read.
## 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
+63 -39
View File
@@ -4,9 +4,11 @@
# 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
#
# INPUT assets/_sheet_crowd3.png + _sheet_crowd3b.png — the two rolls (see `_crowdroll.py`),
# tracked, because _refcheck is scratch and these are the only copy of the art
# OUTPUT assets/x_crowd{,2}.png · x_crowd_c{,2}.png · x_crowd_h{,2}.png · src/crowd.js
# INPUT assets/_sheet_crowd3.png (seated) + _sheet_crowd3b.png (arms high) + _sheet_crowd3c.png
# (hands together, mid-clap) — the three rolls (see `_crowdroll.py`), tracked, because
# _refcheck is scratch and these are the only copy of the art
# OUTPUT assets/x_crowd.png · x_crowd_c.png · x_crowd_h.png — ONE STACKED SPRITE SHEET PER COURT,
# three frames tall — plus src/crowd.js
#
# 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
@@ -19,12 +21,19 @@
# 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 SHEET IS AN ANIMATION SHEET: frame 0 seated, frame 1 arms high, frame 2 hands together in
# front of the face. A spectator with material in BOTH raised frames can be cycled between them —
# that is a clap, not an appearance — and 26 of them can. The rest alternate with frame 0, which is
# arms going up and down: also motion, which is the point. Oliver: "when clapping I barely see any
# movement at all now", and then "can't we make an animation sprite sheet of the new crowd?"
#
# 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
# 1. every raised frame is the SEATED frame plus that pose's arms, and nothing else;
# 2. every box in src/crowd.js 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.
# Boxes that overlap are merged into one entry (and their frame masks OR'd together): two neighbours
# who would always rise together are one person as far as the animation is concerned, and pretending
# otherwise reads as a glitch.
#
# 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
@@ -106,8 +115,10 @@ def raisers(A, B):
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]
contains half of a neighbour's arms would raise that neighbour too, on the wrong clock. Boxes
carry a frame mask, and merging ORs it — which is also how a spectator ends up owning both
raised poses and therefore a real clap."""
boxes = [b[:4] + [b[4]] for b in boxes]
changed = True
while changed:
changed = False
@@ -115,7 +126,8 @@ def merge(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[i] = [min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3]),
a[4] | b[4]]
boxes.pop(j)
changed = True
return boxes
@@ -142,45 +154,57 @@ def save(seated, pa, clap, pb):
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)
def write_table(boxes, h, w, frames):
"""src/crowd.js — the raisers as fractions of ONE FRAME of the sheet, sorted left to right so a
wave can walk them in order, each with the mask of the frames its arms appear in."""
rows = sorted(((x0 - PAD) / w, (y0 - PAD) / h, (x1 - x0 + 2 * PAD) / w, (y1 - y0 + 2 * PAD) / h, m)
for y0, x0, y1, x1, m in boxes)
body = ',\n '.join('[' + ', '.join(f'{v:.4f}' for v in r[:4]) + f', {r[4]}]' 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'
'// The crowd sheet is FRAMES frames stacked vertically: 0 seated (the backdrop), 1 arms\n'
'// high, 2 hands together in front of the face. RAISERS lists the spectators who can move:\n'
'// [x, y, w, h] as fractions of ONE FRAME, then a bitmask of the raised frames that person\n'
'// actually appears in (1 = arms high, 2 = mid-clap, 3 = both, and 3 is a real clap cycle).\n'
'// Each box holds ONE person and nobody else, and outside these boxes every frame is\n'
'// pixel-identical to frame 0 — which is what lets render.js animate them one at a time\n'
'// with no seam. See "THE CROWD SHEET" in PROJECT.md.\n'
f'export const FRAMES = {frames};\n'
f'export const RAISERS = [\n {body},\n];\n')
return len(rows)
return rows
def main(review):
A = np.asarray(Image.open(os.path.join(HERE, '_sheet_crowd3.png')).convert('RGB'))
B = np.asarray(Image.open(os.path.join(HERE, '_sheet_crowd3b.png')).convert('RGB'))
arms, lab = raisers(A, B)
poses = [np.asarray(Image.open(os.path.join(HERE, n)).convert('RGB'))
for n in ('_sheet_crowd3b.png', '_sheet_crowd3c.png')]
h, w = A.shape[:2]
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}%)')
frames, boxes = [A], []
for bit, P in enumerate(poses):
arms, lab = raisers(A, P)
f = 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
f[y0:y1, x0:x1][m] = P[y0:y1, x0:x1][m]
frames.append(f)
boxes += [b[:4] + [1 << bit] for b in arms] # b[4] is the label id; the box carries a FRAME MASK
drift = (np.abs(A.astype(int) - P.astype(int)).sum(2) > 30).mean() * 100
moved = (np.abs(A.astype(int) - f.astype(int)).sum(2) > 30).mean() * 100
print(f' pose {bit + 1}: {len(arms)} arm blobs, {moved:.1f}% of the sheet '
f'(the roll itself drifted {drift:.1f}%)')
boxes = merge(boxes)
rows = write_table(boxes, h, w, len(frames))
both = sum(1 for r in rows if r[4] == 3)
print(f'{len(rows)} spectators can move, {both} of them with both poses (a real clap)')
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(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)}')
stack = np.concatenate(frames, 0)
for a_name, _ in COURTS:
lut = grade(A, old_sheet(a_name)) # the LOOK is judged on the seated frame...
pa = os.path.join(REFCHECK if review else HERE, a_name + ('_new.png' if review else '.png'))
# ...and applied to the whole stack at once, so the frames cannot drift apart in colour
Image.fromarray(lut(stack)).quantize(colors=PAL, method=Image.MEDIANCUT).save(pa, optimize=True)
print(f' {os.path.basename(pa)} ({stack.shape[1]}x{stack.shape[0]}, {len(frames)} frames)')
if __name__ == '__main__':
+20 -2
View File
@@ -74,6 +74,22 @@ SEAT = (
"Bright neutral daylight, evenly lit across the whole picture. " + STYLE
)
# FRAME 3 — the other half of the clap. Rolled FROM the arms-up frame, not from the seated one, so
# it is the SAME forty people standing: what changes is where their hands are. Two raised poses per
# spectator is what turns "his arms appeared" into "he is clapping" (Oliver: "can't we make an
# animation sprite sheet of the new crowd?").
MID = (
"This reference image is a stadium grandstand: most spectators are seated, and about forty are "
"standing with both arms raised straight above their heads. 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: "
"every spectator who is standing with their arms up has now brought their HANDS TOGETHER IN "
"FRONT OF THEIR FACE at chin height, elbows bent and pointing out to the sides, in the middle of "
"a clap. They are the same standing people in the same places; only their arms have come down "
"and in. Their hands and elbows still rise above the row in front of them. "
"EVERYONE WHO WAS SEATED IS COMPLETELY UNCHANGED, identical to the reference. " + 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 "
@@ -125,6 +141,8 @@ if __name__ == '__main__':
ref = sys.argv[2] if len(sys.argv) > 2 else os.path.join(HERE, '_sheet_crowd3.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'))
roll(CLAP, os.path.join(HERE, '_sheet_crowd3.png'), os.path.join(OUT, 'crowd_clap.png'))
elif what == 'mid':
roll(MID, os.path.join(HERE, '_sheet_crowd3b.png'), os.path.join(OUT, 'crowd_mid.png'))
else:
sys.exit('usage: _crowdroll.py seat|clap')
sys.exit('usage: _crowdroll.py seat|clap|mid')
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 379 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 380 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

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=299"></script>
<script type="module" src="src/main.js?v=300"></script>
</body>
</html>
+5
View File
@@ -297,6 +297,11 @@ export const TUNE = {
// 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,
// HOW FAR BACK THE STAND SITS. A flat dim over the crowd and the boards, on every court, before
// the night court's own `dim` and the splatter rot are added on top. Oliver: "all of the art could
// be colored a little more dim as it is perspective wise farther in the background and should not
// steal too much attention." The crowd is the only thing on screen that is 30 m away.
standBack: 0.22,
// ---- 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).
+142 -97
View File
@@ -1,100 +1,145 @@
// 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.
// The crowd sheet is FRAMES frames stacked vertically: 0 seated (the backdrop), 1 arms
// high, 2 hands together in front of the face. RAISERS lists the spectators who can move:
// [x, y, w, h] as fractions of ONE FRAME, then a bitmask of the raised frames that person
// actually appears in (1 = arms high, 2 = mid-clap, 3 = both, and 3 is a real clap cycle).
// Each box holds ONE person and nobody else, and outside these boxes every frame is
// pixel-identical to frame 0 — which is what lets render.js animate them one at a time
// with no seam. See "THE CROWD SHEET" in PROJECT.md.
export const FRAMES = 3;
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],
[-0.0014, 0.8451, 0.0327, 0.0521, 3],
[0.0021, 0.6458, 0.0291, 0.0247, 2],
[0.0021, 0.7148, 0.0256, 0.0286, 1],
[0.0071, 0.9128, 0.0369, 0.0456, 1],
[0.0234, 0.5560, 0.1037, 0.0625, 3],
[0.0355, 0.6419, 0.0419, 0.0286, 3],
[0.0533, 0.7669, 0.0241, 0.0247, 1],
[0.0646, 0.7135, 0.0369, 0.0299, 1],
[0.0689, 0.0521, 0.0227, 0.0221, 1],
[0.0689, 0.0977, 0.0291, 0.0208, 2],
[0.0859, 0.7669, 0.0249, 0.0247, 1],
[0.1016, 0.2630, 0.0256, 0.0326, 3],
[0.1065, 0.8086, 0.0206, 0.0273, 2],
[0.1236, 0.7214, 0.0376, 0.0221, 1],
[0.1321, 0.0911, 0.0277, 0.0312, 2],
[0.1399, 0.8516, 0.0249, 0.0273, 3],
[0.1435, 0.1224, 0.0334, 0.0221, 1],
[0.1577, 0.1732, 0.0199, 0.0586, 1],
[0.1605, 0.5990, 0.0213, 0.0299, 2],
[0.1641, 0.8190, 0.0589, 0.0286, 2],
[0.1690, 0.0898, 0.0284, 0.0286, 2],
[0.1690, 0.3086, 0.0249, 0.0299, 2],
[0.1896, 0.7669, 0.0213, 0.0247, 1],
[0.1932, 0.1732, 0.0170, 0.0508, 1],
[0.1982, 0.5612, 0.0334, 0.0260, 2],
[0.1996, 0.0742, 0.0312, 0.0443, 3],
[0.2017, 0.5990, 0.0298, 0.0299, 3],
[0.2067, 0.3008, 0.0206, 0.0312, 2],
[0.2067, 0.7148, 0.0263, 0.0286, 1],
[0.2358, 0.0820, 0.0334, 0.0365, 2],
[0.2358, 0.6458, 0.0291, 0.0247, 2],
[0.2358, 0.8086, 0.0291, 0.0391, 2],
[0.2358, 0.8594, 0.0462, 0.0365, 3],
[0.2401, 0.7669, 0.0241, 0.0247, 1],
[0.2599, 0.5586, 0.0518, 0.0599, 1],
[0.2628, 0.1732, 0.0178, 0.0521, 1],
[0.2734, 0.6458, 0.0249, 0.0247, 2],
[0.2898, 0.7135, 0.0376, 0.0299, 1],
[0.2976, 0.8086, 0.0384, 0.0286, 3],
[0.2997, 0.0326, 0.0362, 0.0651, 1],
[0.3026, 0.2253, 0.0241, 0.0247, 2],
[0.3026, 0.7669, 0.0419, 0.0286, 1],
[0.3068, 0.6458, 0.0206, 0.0247, 2],
[0.3068, 0.8828, 0.1044, 0.0755, 3],
[0.3189, 0.1732, 0.0185, 0.0482, 1],
[0.3643, 0.4883, 0.0170, 0.0781, 3],
[0.3686, 0.7148, 0.0312, 0.0286, 1],
[0.3693, 0.2878, 0.0320, 0.0260, 2],
[0.3693, 0.8138, 0.0291, 0.0378, 2],
[0.3729, 0.0977, 0.0249, 0.0208, 2],
[0.3771, 0.1732, 0.0334, 0.0885, 3],
[0.3885, 0.4883, 0.0178, 0.0729, 1],
[0.3899, 0.7669, 0.0192, 0.0247, 1],
[0.3984, 0.8503, 0.0284, 0.0234, 3],
[0.4062, 0.2604, 0.0206, 0.0247, 3],
[0.4062, 0.5755, 0.0334, 0.0638, 3],
[0.4070, 0.8086, 0.0199, 0.0273, 2],
[0.4233, 0.7148, 0.0376, 0.0286, 1],
[0.4233, 0.9271, 0.0540, 0.0352, 1],
[0.4268, 0.1914, 0.0107, 0.0417, 1],
[0.4276, 0.8438, 0.0369, 0.0534, 3],
[0.4354, 0.6523, 0.0298, 0.0286, 2],
[0.4645, 0.0286, 0.0376, 0.0612, 1],
[0.4730, 0.8216, 0.0241, 0.0260, 2],
[0.4737, 0.5534, 0.0284, 0.0456, 1],
[0.4858, 0.9232, 0.0206, 0.0286, 1],
[0.4901, 0.6315, 0.0455, 0.0443, 3],
[0.4901, 0.7669, 0.0533, 0.0247, 1],
[0.4979, 0.7148, 0.0355, 0.0286, 1],
[0.5064, 0.5755, 0.0213, 0.0234, 1],
[0.5355, 0.8086, 0.0249, 0.0286, 2],
[0.5398, 0.5586, 0.0206, 0.0404, 1],
[0.5568, 0.7148, 0.0376, 0.0286, 1],
[0.5604, 0.9128, 0.0831, 0.0495, 1],
[0.5625, 0.1497, 0.0391, 0.0312, 3],
[0.5689, 0.0977, 0.0291, 0.0208, 2],
[0.5973, 0.5586, 0.0298, 0.0247, 1],
[0.6023, 0.2630, 0.0249, 0.0221, 3],
[0.6101, 0.1185, 0.0341, 0.0339, 2],
[0.6186, 0.8516, 0.0582, 0.0299, 3],
[0.6271, 0.7669, 0.0163, 0.0247, 1],
[0.6293, 0.7214, 0.0476, 0.0221, 1],
[0.6314, 0.0846, 0.0305, 0.0378, 2],
[0.6435, 0.1224, 0.0341, 0.0221, 1],
[0.6562, 0.7526, 0.0249, 0.0391, 1],
[0.6584, 0.1732, 0.0192, 0.0586, 1],
[0.6648, 0.5612, 0.0291, 0.0260, 2],
[0.6690, 0.0898, 0.0284, 0.0286, 2],
[0.6690, 0.3021, 0.0249, 0.0365, 2],
[0.6854, 0.7565, 0.0540, 0.0352, 1],
[0.6925, 0.1732, 0.0178, 0.0508, 1],
[0.6974, 0.9271, 0.0462, 0.0352, 1],
[0.6996, 0.0742, 0.0341, 0.0443, 3],
[0.7024, 0.6458, 0.0249, 0.0286, 2],
[0.7060, 0.8529, 0.0256, 0.0260, 3],
[0.7266, 0.5586, 0.0426, 0.0430, 1],
[0.7351, 0.0820, 0.0341, 0.0365, 2],
[0.7358, 0.6458, 0.0291, 0.0247, 2],
[0.7393, 0.7669, 0.0249, 0.0286, 1],
[0.7401, 0.5977, 0.0249, 0.0208, 1],
[0.7401, 0.8398, 0.0327, 0.0273, 2],
[0.7521, 0.9128, 0.1207, 0.0456, 1],
[0.7585, 0.0430, 0.0398, 0.0247, 1],
[0.7621, 0.1732, 0.0156, 0.0521, 1],
[0.7692, 0.5755, 0.0249, 0.0221, 1],
[0.7898, 0.7135, 0.0376, 0.0299, 1],
[0.7997, 0.0326, 0.0362, 0.0651, 1],
[0.8026, 0.2253, 0.0241, 0.0247, 2],
[0.8026, 0.8086, 0.0334, 0.0417, 2],
[0.8068, 0.6458, 0.0241, 0.0247, 2],
[0.8068, 0.7669, 0.0419, 0.0286, 1],
[0.8232, 0.1758, 0.0142, 0.0469, 1],
[0.8374, 0.5221, 0.0227, 0.0378, 2],
[0.8438, 0.1185, 0.0291, 0.0260, 2],
[0.8523, 0.7669, 0.0213, 0.0247, 1],
[0.8636, 0.7148, 0.0348, 0.0286, 1],
[0.8643, 0.4883, 0.0419, 0.0781, 3],
[0.8693, 0.2878, 0.0284, 0.0260, 2],
[0.8693, 0.8138, 0.0291, 0.0378, 2],
[0.8729, 0.0977, 0.0249, 0.0208, 2],
[0.8771, 0.1732, 0.0327, 0.0885, 3],
[0.8984, 0.8503, 0.0284, 0.0234, 3],
[0.9062, 0.2604, 0.0206, 0.0247, 3],
[0.9062, 0.5755, 0.0341, 0.0638, 3],
[0.9070, 0.8086, 0.0199, 0.0273, 2],
[0.9233, 0.7148, 0.0376, 0.0286, 1],
[0.9268, 0.1901, 0.0149, 0.0430, 1],
[0.9276, 0.8438, 0.0369, 0.0534, 3],
[0.9318, 0.6914, 0.0334, 0.0208, 1],
[0.9318, 0.9271, 0.0455, 0.0352, 1],
[0.9645, 0.0286, 0.0369, 0.0612, 1],
[0.9695, 0.8594, 0.0320, 0.0234, 2],
[0.9730, 0.8216, 0.0241, 0.0365, 2],
[0.9737, 0.5586, 0.0241, 0.0404, 1],
];
+50 -39
View File
@@ -3,7 +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 { RAISERS, FRAMES } 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';
@@ -235,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 = 144;
export const SPRV = 145;
// 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".
@@ -258,9 +258,11 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','
'n_run1','n_run2',
'o_run1','o_run2', // TWO frames, a true leg-swap pair — the whole 2026-08-14 tuning session's verdict: fewer, better-chosen frames at a calm rate beat every 6/8-frame roll ('this looks better than anything before')
'x_ump','x_boyrun1','x_boyrun2','x_boyrun3','x_boyrun4',
'x_crowd','x_crowd2','x_ads', // CENTRE COURT (grass) — also the fallback set
'x_crowd_c','x_crowd_c2','x_ads_c', // TERRE ROUGE (clay)
'x_crowd_h','x_crowd_h2','x_ads_h', // THE SLAB (hard)
'x_crowd','x_ads', // CENTRE COURT (grass) — also the fallback set
'x_crowd_c','x_ads_c', // TERRE ROUGE (clay)
'x_crowd_h','x_ads_h', // THE SLAB (hard)
// ...each crowd sheet is FRAMES frames stacked vertically (seated / arms high /
// mid-clap). See src/crowd.js, and drawRaisers below.
'x_boymop1','x_boymop2','x_boymop3','x_boymop4', // the push cycle, used only while he is stopped and working
'x_boycarry1','x_boycarry2','x_boycarry3','x_boycarry4','x_howto','x_cross', // walking with the mop shouldered, and the slingshot tutorial panel
'x_hand', // the ghost tutorial hand (drawGhostHand) — generated art, Oliver's reference pose
@@ -418,13 +420,14 @@ function tickFlashes(standsBottom) {
ctx.globalAlpha = 1;
}
// How dark the STAND is: the court's own night (`dim` on its COURTS row — 0 everywhere but the
// night court) plus the rot the splatter palette brings with it. ONE function because it is applied
// How dark the STAND is: `TUNE.standBack` (every court, always — the stand is scenery a long way
// behind the court and should not compete with it) plus the court's own night (`dim` on its COURTS
// row, 0 everywhere but the night court) plus the rot the splatter palette brings with it. ONE function because it is applied
// in two places and they must agree exactly — the cached sheet and the live clap columns drawn over
// it. Two copies of this number is a stand whose clapping columns pop brighter than the rest of it,
// which is the same class of bug as the crowd frames drifting apart. Capped: past ~0.85 the stand
// is a black rectangle and the applause stops reading at all.
const standDim = pt => Math.min(0.85, (court.dim || 0) + 0.26 * pt);
const standDim = pt => Math.min(0.88, TUNE.standBack + (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
@@ -440,21 +443,20 @@ const standDim = pt => Math.min(0.85, (court.dim || 0) + 0.26 * pt);
// 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 kx = W / clapImg.width, ky = (crowdH + 2) / clapImg.height;
function drawRaisers(img, standsBottom, pt, frameOf) {
const fh = img.height / FRAMES, crowdH = standsBottom - AD_H + 2;
const kx = W / img.width, ky = (crowdH + 2) / fh;
const d = standDim(pt), tint = d > 0 ? `rgba(12,8,20,${d})` : null;
let open = false;
for (let i = 0; i < RAISERS.length; i++) {
const r = RAISERS[i];
if (!up(i, r[0])) continue;
const r = RAISERS[i], f = frameOf(i, r[0], r[4]);
if (!f) continue; // frame 0 IS the backdrop: nothing to draw
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);
const sx = r[0] * img.width, sy = r[1] * fh, sw = r[2] * img.width, sh = r[3] * fh;
ctx.drawImage(img, sx, sy + f * fh, 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); }
}
@@ -487,7 +489,9 @@ function paintBackdrop(c, pal, pt) {
// per-column split only ever existed for the clap ripple, which drawBackdrop overlays live.
const standsBottom = proj(0, 0, -6).y, crowdH = standsBottom - AD_H + 2;
const crowdImg = SPR[court.crowd] || SPR['x_crowd'];
if (crowdImg) c.drawImage(crowdImg, 0, 0, W, crowdH + 2);
// FRAME 0 of the stacked sheet: the whole stand, seated. The raised frames below it in the same
// image are never drawn whole — drawRaisers picks one spectator's box out of them.
if (crowdImg) c.drawImage(crowdImg, 0, 0, crowdImg.width, crowdImg.height / FRAMES, 0, 0, W, crowdH + 2);
else { // fallback until the image loads: the old tier stripes + dots (static — boot gates play anyway)
c.fillStyle = pal.stands; c.fillRect(0, 0, W, standsBottom);
for (let r = 0; r < 16; r++) { c.fillStyle = r % 2 ? pal.tierA : pal.tierB; c.fillRect(0, 6 + r * 5.5, W, 5.5); }
@@ -564,33 +568,40 @@ function drawBackdrop(pal, pt) {
}
ctx.drawImage(bg, -8, -8, W + 16, H + 16);
const standsBottom = proj(0, 0, -6).y;
// 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) {
// 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;
// THE CROWD SHEET IS AN ANIMATION SHEET (assets/_crowdpass.py): frame 0 is the seated stand that
// the backdrop above is drawn from, frame 1 is 100 pairs of arms raised over it, frame 2 is those
// arms brought down and in, mid-clap. Only the arms differ, so ONE SPECTATOR can be redrawn over
// the cached stand without touching anybody near him.
if (crowdImg) {
// WHO IS MOVING, AND IN WHICH POSE. Three behaviours, one draw:
// applause — how many join scales with the size of the moment, and each of them CYCLES: the
// 28 spectators who own both raised poses clap between them, the rest go up and down. A
// raiser is ~8x6 logical px on a phone, so a pose that appears once and holds is not
// something the eye catches — Oliver, "when clapping I barely see any movement at all now";
// the WAVE — a front travelling across the stand, person by person, arms high;
// 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 up = pop >= 0.75
? (i, x) => ((G.idleT / 1.4 - x * 0.8 - rnd(i) * 0.03) % 1 + 1) % 1 < 0.18
const joins = 0.2 + 0.8 * pop;
const beat = (i) => Math.floor(G.idleT * 7 + rnd(i + 11) * 6) % 2;
// one pose, or the other, or the seated frame — whichever this spectator actually has
const pose = (i, mask, b) => mask === 3 ? (b ? 2 : 1) : (b ? 0 : mask);
const frameOf = pop >= 0.75
// 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 umpire's "unbelievable".
? (i, x, mask) => ((G.idleT / 1.4 - x * 0.8 - rnd(i) * 0.03) % 1 + 1) % 1 < 0.18
? pose(i, mask, beat(i)) : 0
: 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) => {
// rnd(i) picks WHO (a stable draw, so the same people come in for a small moment), rnd(i+3)
// and a period of his own stagger WHEN — a stand clapping in unison is a machine.
? (i, x, mask) => rnd(i) < joins
&& ((G.idleT / (1.5 + rnd(i + 7) * 1.3) + rnd(i + 3)) % 1) < 0.5
? pose(i, mask, beat(i)) : 0
: (i, x, mask) => {
const slot = Math.floor(G.idleT / TUNE.fidgetEvery);
return i === (slot * 37) % RAISERS.length && (G.idleT / TUNE.fidgetEvery) % 1 < 0.34;
return i === (slot * 37) % RAISERS.length && (G.idleT / TUNE.fidgetEvery) % 1 < 0.34
? pose(i, mask, beat(i)) : 0;
};
drawRaisers(clapImg, standsBottom, pt, up);
drawRaisers(crowdImg, standsBottom, pt, frameOf);
}
tickFlashes(standsBottom); // ...and somebody in there is taking pictures
return standsBottom;