diff --git a/PROJECT.md b/PROJECT.md index 709e83e..eef3e90 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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 diff --git a/assets/_crowdpass.py b/assets/_crowdpass.py index e37f61e..97833ae 100644 --- a/assets/_crowdpass.py +++ b/assets/_crowdpass.py @@ -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 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__': diff --git a/assets/_crowdroll.py b/assets/_crowdroll.py index 31e7fb8..fb76375 100644 --- a/assets/_crowdroll.py +++ b/assets/_crowdroll.py @@ -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') diff --git a/assets/_sheet_crowd3c.png b/assets/_sheet_crowd3c.png new file mode 100644 index 0000000..5e5d33a Binary files /dev/null and b/assets/_sheet_crowd3c.png differ diff --git a/assets/x_crowd.png b/assets/x_crowd.png index 7843dac..f3bddce 100644 Binary files a/assets/x_crowd.png and b/assets/x_crowd.png differ diff --git a/assets/x_crowd2.png b/assets/x_crowd2.png deleted file mode 100644 index 59fb714..0000000 Binary files a/assets/x_crowd2.png and /dev/null differ diff --git a/assets/x_crowd_c.png b/assets/x_crowd_c.png index 87d58d5..26eb549 100644 Binary files a/assets/x_crowd_c.png and b/assets/x_crowd_c.png differ diff --git a/assets/x_crowd_c2.png b/assets/x_crowd_c2.png deleted file mode 100644 index 9790585..0000000 Binary files a/assets/x_crowd_c2.png and /dev/null differ diff --git a/assets/x_crowd_h.png b/assets/x_crowd_h.png index f4947ed..dda0b20 100644 Binary files a/assets/x_crowd_h.png and b/assets/x_crowd_h.png differ diff --git a/assets/x_crowd_h2.png b/assets/x_crowd_h2.png deleted file mode 100644 index 6dda164..0000000 Binary files a/assets/x_crowd_h2.png and /dev/null differ diff --git a/index.html b/index.html index 736a5e7..b299884 100644 --- a/index.html +++ b/index.html @@ -661,6 +661,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/config.js b/src/config.js index b57a939..5016283 100644 --- a/src/config.js +++ b/src/config.js @@ -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). diff --git a/src/crowd.js b/src/crowd.js index baa7e41..534a704 100644 --- a/src/crowd.js +++ b/src/crowd.js @@ -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 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], ]; diff --git a/src/render.js b/src/render.js index 0ec1877..bce94c2 100644 --- a/src/render.js +++ b/src/render.js @@ -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 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: 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;