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

Oliver on the first pass: "the new lighter areas read as rather sloppy artwork.
please regenerate the crowd art and make it more detailed. also the animation
also looks pretty artificial. the flashes from the fotographs are too rare and
the whole thing somehow does not translate well to the other courts. also when
the opponent makes a point there is an aww-sample (which has an applaus trail)
so the crowd should still clap but more subtle."

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

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

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

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

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

No files matched your search

+12 -1
View File
@@ -1,5 +1,7 @@
// All sound: WebAudio context, synthesized SFX, recorded umpire lines, speech fallback.
import { crowd } from './state.js'; // the aww carries an applause trail, and the stand has to match it
let AC = null;
export const audio = () => {
if (!AC) AC = new (window.AudioContext || window.webkitAudioContext)();
@@ -139,7 +141,16 @@ export function sfx(name, vol = 1, opts = {}) {
}
// the crowd's groan — stretched a touch and pushed through the PA reverb for a longer, wetter sigh
export function aww(vol = 1) { sfx('tennis-aww', vol * 0.5, { reverb: true }); }
export function aww(vol = 1) {
sfx('tennis-aww', vol * 0.5, { reverb: true });
// THE SAMPLE HAS AN APPLAUSE TRAIL: tennis-aww is a groan that settles into scattered clapping,
// and a stand that stays seated through it contradicts what you can hear (Oliver, 2026-08-21).
// So the picture follows the sound — but a TENTH of a won point, which at this size is a dozen
// people standing briefly rather than the whole stand. Held at the loudest while an applause is
// already live, exactly as cheer() does: a lost rally inside an ovation may not demote it.
crowd.pop = Math.max(crowd.cheer > 0 ? crowd.pop : 0, 0.1 + 0.06 * vol);
crowd.cheer = Math.max(crowd.cheer, 3.4);
}
// Looping background beds (scene atmosphere, mode music): each starts once, highpass 'lowcut' + quiet gain.
const beds = new Map(); // file -> {ac, g, vol} once playing (null while loading); see fadeBed
+5 -3
View File
@@ -285,11 +285,13 @@ export const TUNE = {
// acknowledged that the match had changed; now he comments on it, disapprovingly.
racketHpPerGame: 0.12, // the racket zombie stiffens as the run goes on (floored into whole hp)
// CAMERA FLASHES IN THE STAND (render.tickFlashes). MEAN seconds between them — the actual gap is
// jittered 0.35x..1.95x so they never tick, and halved again while the crowd is cheering. Turn
// flashEvery UP to quieten them; it is the one knob. Life is deliberately shorter than a frame
// jittered 0.35x..1.95x so they never tick, and shortens with the size of whatever the crowd is
// reacting to (crowd.pop). Turn flashEvery UP to quieten them; it is the one knob. It was 5.0
// and Oliver could not see them at all — "the flashes from the fotographs are too rare" — which
// at a mean of five seconds, jittered, in a stand this wide, is not surprising. Life is deliberately shorter than a frame
// pair at 60 fps would need: a flash you can look AT is a light, a flash you only catch is a
// photograph being taken.
flashEvery: 5.0,
flashEvery: 1.9,
flashLife: 0.16,
// ...and how often ONE column of the stand gets to its feet on its own when nothing is being
// cheered (render's drawClapColumns). Between points the crowd used to be a photograph. This is
+100
View File
@@ -0,0 +1,100 @@
// GENERATED by assets/_crowdpass.py — do not edit by hand, re-run the tool.
// The spectators who can stand up: [x, y, w, h] as fractions of the crowd sheet, sorted
// left to right. Each box holds ONE person whose arms are raised in <crowd>2 and nobody
// else; everything outside these boxes is identical in the two sheets, which is what lets
// render.js raise them one at a time with no seam. See "THE CLAP FRAME" in PROJECT.md.
export const RAISERS = [
[-0.0014, 0.8451, 0.0327, 0.0521],
[0.0021, 0.7148, 0.0256, 0.0286],
[0.0071, 0.9128, 0.0369, 0.0456],
[0.0234, 0.5560, 0.0163, 0.0417],
[0.0391, 0.6419, 0.0384, 0.0234],
[0.0533, 0.7669, 0.0241, 0.0247],
[0.0611, 0.5586, 0.0661, 0.0404],
[0.0646, 0.7135, 0.0369, 0.0299],
[0.0689, 0.0521, 0.0227, 0.0221],
[0.0859, 0.7669, 0.0249, 0.0247],
[0.1016, 0.2643, 0.0256, 0.0312],
[0.1236, 0.7214, 0.0376, 0.0221],
[0.1399, 0.8516, 0.0249, 0.0273],
[0.1435, 0.1224, 0.0334, 0.0221],
[0.1577, 0.1732, 0.0199, 0.0586],
[0.1896, 0.7669, 0.0213, 0.0247],
[0.1932, 0.1732, 0.0170, 0.0508],
[0.1996, 0.0742, 0.0312, 0.0443],
[0.2017, 0.5990, 0.0298, 0.0299],
[0.2067, 0.7148, 0.0263, 0.0286],
[0.2358, 0.8594, 0.0462, 0.0234],
[0.2401, 0.7669, 0.0241, 0.0247],
[0.2599, 0.5586, 0.0518, 0.0599],
[0.2628, 0.1732, 0.0178, 0.0521],
[0.2898, 0.7135, 0.0376, 0.0299],
[0.2976, 0.8086, 0.0384, 0.0286],
[0.2997, 0.0326, 0.0362, 0.0651],
[0.3026, 0.7669, 0.0419, 0.0286],
[0.3068, 0.8828, 0.1044, 0.0755],
[0.3189, 0.1732, 0.0185, 0.0482],
[0.3643, 0.4883, 0.0170, 0.0781],
[0.3686, 0.7148, 0.0312, 0.0286],
[0.3885, 0.4883, 0.0178, 0.0729],
[0.3899, 0.7669, 0.0192, 0.0247],
[0.3956, 0.1732, 0.0149, 0.0508],
[0.3984, 0.8503, 0.0284, 0.0234],
[0.4062, 0.2604, 0.0206, 0.0247],
[0.4062, 0.5755, 0.0206, 0.0234],
[0.4062, 0.6146, 0.0277, 0.0247],
[0.4233, 0.7148, 0.0376, 0.0286],
[0.4233, 0.9271, 0.0540, 0.0352],
[0.4268, 0.1914, 0.0107, 0.0417],
[0.4276, 0.8438, 0.0369, 0.0534],
[0.4645, 0.0286, 0.0376, 0.0612],
[0.4737, 0.5534, 0.0284, 0.0456],
[0.4858, 0.9232, 0.0206, 0.0286],
[0.4901, 0.6315, 0.0455, 0.0208],
[0.4901, 0.7669, 0.0533, 0.0247],
[0.4979, 0.7148, 0.0355, 0.0286],
[0.5064, 0.5755, 0.0213, 0.0234],
[0.5398, 0.5586, 0.0206, 0.0404],
[0.5568, 0.7148, 0.0376, 0.0286],
[0.5604, 0.9128, 0.0831, 0.0495],
[0.5625, 0.1497, 0.0391, 0.0312],
[0.5973, 0.5586, 0.0298, 0.0247],
[0.6023, 0.2643, 0.0249, 0.0208],
[0.6186, 0.8516, 0.0582, 0.0299],
[0.6271, 0.7669, 0.0163, 0.0247],
[0.6293, 0.7214, 0.0476, 0.0221],
[0.6435, 0.1224, 0.0341, 0.0221],
[0.6562, 0.7526, 0.0249, 0.0391],
[0.6584, 0.1732, 0.0192, 0.0586],
[0.6854, 0.7565, 0.0540, 0.0352],
[0.6925, 0.1732, 0.0178, 0.0508],
[0.6974, 0.9271, 0.0462, 0.0352],
[0.6996, 0.0742, 0.0341, 0.0404],
[0.7060, 0.8529, 0.0256, 0.0260],
[0.7266, 0.5586, 0.0426, 0.0430],
[0.7393, 0.7669, 0.0249, 0.0286],
[0.7401, 0.5977, 0.0249, 0.0208],
[0.7521, 0.9128, 0.1207, 0.0456],
[0.7585, 0.0430, 0.0398, 0.0247],
[0.7621, 0.1732, 0.0156, 0.0521],
[0.7692, 0.5755, 0.0249, 0.0221],
[0.7898, 0.7135, 0.0376, 0.0299],
[0.7997, 0.0326, 0.0362, 0.0651],
[0.8068, 0.7669, 0.0419, 0.0286],
[0.8232, 0.1758, 0.0142, 0.0469],
[0.8523, 0.7669, 0.0213, 0.0247],
[0.8636, 0.7148, 0.0348, 0.0286],
[0.8643, 0.4883, 0.0170, 0.0742],
[0.8885, 0.4883, 0.0178, 0.0729],
[0.8928, 0.1732, 0.0170, 0.0508],
[0.8984, 0.8503, 0.0284, 0.0234],
[0.9062, 0.2604, 0.0206, 0.0247],
[0.9062, 0.5755, 0.0206, 0.0221],
[0.9233, 0.7148, 0.0376, 0.0286],
[0.9268, 0.1901, 0.0149, 0.0430],
[0.9276, 0.8438, 0.0369, 0.0534],
[0.9318, 0.6914, 0.0334, 0.0208],
[0.9318, 0.9271, 0.0455, 0.0352],
[0.9645, 0.0286, 0.0369, 0.0612],
[0.9737, 0.5586, 0.0241, 0.0404],
];
+47 -35
View File
@@ -3,6 +3,7 @@
import { cv, ctx } from './dom.js';
import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS, HEAD_CROP, CUP_ROUNDS, TINT, SHOT_DEPTH } from './config.js';
import { P, O, ball, score, crowd, G, court, tour, kit, skin } from './state.js';
import { RAISERS } from './crowd.js';
import { predictLanding, previewServe, slotId, slotStock, serveApexY, rallyFeel, swayOf } from './gameplay.js';
import { drillQueue, drillLegal, drillMult } from './drill.js';
import { zombies, gore, piles, parts, pellets, extras, shots, pops } from './zombies.js';
@@ -234,7 +235,7 @@ function farB(z) {
// Asset cache-buster — bump with main.js?v=N (see the img.src note below). EXPORTED because the
// menu screens hang <img> tags on the same files, and two copies of this number is one copy too
// many: the one that does not get bumped is the one that serves a cached 404.
export const SPRV = 143;
export const SPRV = 144;
// The boot bar in main.js watches this. Nothing here CHANGES when things load — every sprite was
// already requested at import — it just makes the wait visible and holds the menu back until the
// last sheet has landed. See the onerror note below for the other half of "sometimes missing".
@@ -430,23 +431,34 @@ const standDim = pt => Math.min(0.85, (court.dim || 0) + 0.26 * pt);
// of these columns. That is what makes any column swap seamless — a column with nobody raising in
// it draws exactly what was already there. CHANGE THIS AND THE SHEETS MUST BE RECUT (same constant,
// COLS, at the top of that script).
const COLS_CROWD = 16;
function drawClapColumns(clapImg, standsBottom, pt, on) {
const up = [];
for (let i = 0; i < COLS_CROWD; i++) if (on(i)) up.push(i);
if (!up.length) return; // most idle frames: no clip, no save, nothing drawn
// THE STAND MOVES ONE SPECTATOR AT A TIME. `crowd.js` (generated by assets/_crowdpass.py) lists a
// box around every pair of raised arms in the clap sheet, and those arms are the ONLY thing that
// differs between the two sheets — so drawing one box over the cached stand raises one person and
// touches nobody else. The 16-column strip swap this replaced moved a seventeenth of the crowd as a
// block, which is what made it read as machinery rather than as people (Oliver: "the animation also
// looks pretty artificial").
// The sub-rect is drawn at EXACTLY the scale paintBackdrop used for the whole sheet (kx/ky below),
// which is what guarantees the raised arms land on the same pixel grid as the body under them.
const rnd = i => ((i * 9301 + 49297) % 233280) / 233280; // one stable pseudo-random per spectator
function drawRaisers(clapImg, standsBottom, pt, up) {
const crowdH = standsBottom - AD_H + 2;
const sw = clapImg.width / COLS_CROWD, cw = W / COLS_CROWD;
const kx = W / clapImg.width, ky = (crowdH + 2) / clapImg.height;
const d = standDim(pt), tint = d > 0 ? `rgba(12,8,20,${d})` : null;
ctx.save(); ctx.beginPath(); ctx.rect(0, 0, W, standsBottom - AD_H); ctx.clip();
for (const i of up) {
// integer-aligned draws, no vertical offset: fractional-y nearest-neighbour resampling is the
// boiling that once read as HUD flicker. Re-tinted per column so a raised column does not pop
// brighter than the rotted stand around it.
ctx.drawImage(clapImg, i * sw, 0, sw, clapImg.height, i * cw, 0, cw + 0.5, crowdH + 2);
if (tint) { ctx.fillStyle = tint; ctx.fillRect(i * cw, 0, cw + 0.5, crowdH + 2); }
let open = false;
for (let i = 0; i < RAISERS.length; i++) {
const r = RAISERS[i];
if (!up(i, r[0])) continue;
if (!open) { // most frames raise nobody: no clip, no save, nothing drawn
open = true;
ctx.save(); ctx.beginPath(); ctx.rect(0, 0, W, standsBottom - AD_H); ctx.clip();
}
const sx = r[0] * clapImg.width, sy = r[1] * clapImg.height;
const sw = r[2] * clapImg.width, sh = r[3] * clapImg.height;
ctx.drawImage(clapImg, sx, sy, sw, sh, sx * kx, sy * ky, sw * kx + 0.5, sh * ky + 0.5);
// re-tinted like the cached stand, so a raised pair of arms cannot pop brighter than the rot
if (tint) { ctx.fillStyle = tint; ctx.fillRect(sx * kx, sy * ky, sw * kx + 0.5, sh * ky + 0.5); }
}
ctx.restore();
if (open) ctx.restore();
}
// ---------------------------------------------------------------- backdrop ----
@@ -552,33 +564,33 @@ function drawBackdrop(pal, pt) {
}
ctx.drawImage(bg, -8, -8, W + 16, H + 16);
const standsBottom = proj(0, 0, -6).y;
// Applause is a real TWO-FRAME animation: x_crowd is the seated sheet (cached above), <crowd>2
// the same stand with about forty of those people, and only those people, on their feet.
// Applause is a real animation: <crowd>2 is the seated stand with 93 pairs of arms raised into
// the wall above the rows, and each of those people is raised on their own clock.
const clapImg = crowdImg && (crowdImg === SPR['x_crowd'] ? SPR['x_crowd2'] : SPR[court.crowd + '2']);
if (crowdImg && clapImg) {
// WHICH COLUMNS ARE ON THEIR FEET. Three behaviours, one draw:
// applause — every column on its own phase, a scatter of people rising and sitting;
// the WAVE — a travelling band, for a moment big enough to deserve one (crowd.pop);
// a FIDGET — one column, briefly, between points, so an idle stand is not a photograph.
// The 4.5 Hz toggle this replaced was not the problem (see _crowdpass.py): the old clap sheet
// was a re-roll, so a swapped column re-hashed the whole crowd instead of raising arms.
// WHO IS ON THEIR FEET. Three behaviours, one draw:
// applause — HOW MANY of them join scales with the size of the moment, each on their own
// period, so a groan for a lost point is a dozen people and a match point is the whole
// stand. That is what makes the same machinery do "subtle" (Oliver, on the aww sample:
// "the crowd should still clap but more subtle");
// the WAVE — a front travelling across the stand, person by person, for a moment big enough;
// a FIDGET — one person, briefly, between points, so an idle stand is not a photograph.
const pop = crowd.cheer > 0 ? crowd.pop : 0;
// 0.75, not 0.6: a game won calls cheer(2.4, 0.6) and a wave on every held serve is a wave
// nobody looks up for. This leaves it to the set, the match, and the ones the umpire calls
// unbelievable.
const big = crowd.cheer > 0 && crowd.pop >= 0.75;
const on = crowd.cheer > 0
? (big
// one band, ~2.5 columns wide, crossing the stand about every 1.4 s
? i => ((G.idleT / 1.4 - i / COLS_CROWD) % 1 + 1) % 1 < 0.16
: i => Math.floor(G.idleT * 7 + i * 0.7) % 2 === 1)
// ...and when nobody is cheering, ONE column every fidgetEvery seconds, for a third of it.
: i => {
const up = pop >= 0.75
? (i, x) => ((G.idleT / 1.4 - x * 0.8 - rnd(i) * 0.03) % 1 + 1) % 1 < 0.18
: pop > 0
// rnd(i) picks WHO (a stable draw per spectator, so the same people join a small moment),
// rnd(i+7) staggers WHEN, and the period varies per person — a stand clapping in unison is
// a machine.
? i => rnd(i) < pop && ((G.idleT / (1.5 + rnd(i + 7) * 1.3) + rnd(i + 3)) % 1) < 0.45
: (i) => {
const slot = Math.floor(G.idleT / TUNE.fidgetEvery);
// *5: coprime with 16, so it visits every column — but in steps of five, where *7919
// (i.e. *15) walked one seat sideways each time and read as a march.
return i === ((slot * 5 + 3) % COLS_CROWD) && (G.idleT / TUNE.fidgetEvery) % 1 < 0.34;
return i === (slot * 37) % RAISERS.length && (G.idleT / TUNE.fidgetEvery) % 1 < 0.34;
};
drawClapColumns(clapImg, standsBottom, pt, on);
drawRaisers(clapImg, standsBottom, pt, up);
}
tickFlashes(standsBottom); // ...and somebody in there is taking pictures
return standsBottom;