diff --git a/PROJECT.md b/PROJECT.md index 3f73d0c..182c091 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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. diff --git a/assets/_crowdpass.py b/assets/_crowdpass.py new file mode 100644 index 0000000..79541e6 --- /dev/null +++ b/assets/_crowdpass.py @@ -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) diff --git a/assets/x_crowd.png b/assets/x_crowd.png index 92e386a..7c24358 100644 Binary files a/assets/x_crowd.png and b/assets/x_crowd.png differ diff --git a/assets/x_crowd2.png b/assets/x_crowd2.png index 1428647..603f61f 100644 Binary files a/assets/x_crowd2.png and b/assets/x_crowd2.png differ diff --git a/assets/x_crowd_c.png b/assets/x_crowd_c.png index ccd2e2c..9c17c1c 100644 Binary files a/assets/x_crowd_c.png and b/assets/x_crowd_c.png differ diff --git a/assets/x_crowd_c2.png b/assets/x_crowd_c2.png index 76f783d..8aa8345 100644 Binary files a/assets/x_crowd_c2.png and b/assets/x_crowd_c2.png differ diff --git a/assets/x_crowd_h.png b/assets/x_crowd_h.png index 038b5ed..e57150c 100644 Binary files a/assets/x_crowd_h.png and b/assets/x_crowd_h.png differ diff --git a/assets/x_crowd_h2.png b/assets/x_crowd_h2.png index fb32d81..f05e6f7 100644 Binary files a/assets/x_crowd_h2.png and b/assets/x_crowd_h2.png differ diff --git a/index.html b/index.html index 1906c10..6bd7f93 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 321770f..982064e 100644 --- a/src/config.js +++ b/src/config.js @@ -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). diff --git a/src/gameplay.js b/src/gameplay.js index 4a642fb..cc84eb9 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -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); diff --git a/src/render.js b/src/render.js index f34a8bd..0bb5aef 100644 --- a/src/render.js +++ b/src/render.js @@ -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 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), 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; diff --git a/src/state.js b/src/state.js index 8a6757d..3ec3f26 100644 --- a/src/state.js +++ b/src/state.js @@ -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'; diff --git a/test.js b/test.js index d4f508c..27a0658 100644 --- a/test.js +++ b/test.js @@ -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