The stand stops wobbling: a real clap frame, a wave, and three spectators who have already turned
The wobble was never the animation. x_crowd2 was a RE-ROLL of x_crowd, not a pose of it — 13-16% of its pixels differed, over faces, torsos, seat backs and the railing — so swapping 16 columns at 4.5 Hz re-hashed a strip of crowd twice a second. assets/_crowdpass.py rebuilds the pair: the clap frame is now the seated frame plus 36 genuine raises, each contained in ONE of render's columns, so any column swap is seamless by construction and an empty column is invisible instead of being a wobble. The three courts are one composition in three palettes, so a raise found in one court's roll is transplanted into the other two through a colour map learnt from the aligned seated pair. The same tool paints the crowd, because at ~8 logical px a spectator detail can only mean chroma and silhouette: 55 shirts and 44 heads of hair in colours the court does not own, 28 caps, 4 flags above the heads, 8 emptied seats, 8 people permanently standing — and 3 who are already turned, grey-green, mid-row, who are also the only three who never applaud. Movement, one draw path and three behaviours: applause scatters per column, a WAVE crosses the stand for a moment big enough to deserve one (crowd.pop >= 0.75), and with nobody cheering ONE column stands for a third of every TUNE.fidgetEvery, so the stand between points is no longer a photograph. Paparazzi now scale with the moment: cheer() records the size of it (vol was already exactly that at every call site) and queues shutters proportional to it, drained at a fifth of the normal gap. pop is held at the loudest while an applause is live, so a won rally landing inside a match-point ovation cannot demote the stand to a polite clap — that rule has a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
76ea1d0d79
commit
74afd5988c
14 files changed
+585
-42
No files matched your search
@@ -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).
|
||||
|
||||
@@ -589,6 +589,13 @@ export function aside(name, delay = 0.2) {
|
||||
|
||||
export function cheer(seconds, vol) {
|
||||
sfx('tennis-applaus', Math.min(1.5, vol * 2.2)); // recorded crowd; vol scales with the size of the moment (gain > 1 amplifies)
|
||||
// HOW BIG THIS ONE WAS. vol is already the size of the moment everywhere it is called from (a
|
||||
// won rally 0.25, a game 0.6, a match or an EPIC 1.0), so the stand reads it rather than growing
|
||||
// a second scale of its own: past 0.6 the wave rolls, and the photographers in the stand fire a
|
||||
// burst proportional to it. Held at the LOUDEST while an applause is live — a small cheer landing
|
||||
// inside a big one must not quietly demote it.
|
||||
crowd.pop = Math.max(crowd.cheer > 0 ? crowd.pop : 0, Math.min(1, vol));
|
||||
crowd.burst += Math.round(crowd.pop * 7);
|
||||
// floored at the CLIP LENGTH: the sample always plays out in full, so the stand must stay on its
|
||||
// feet until it stops. `seconds` still lets a big moment run longer than the audio.
|
||||
crowd.cheer = Math.max(crowd.cheer, seconds, APPLAUSE_T);
|
||||
|
||||
+58
-20
@@ -234,7 +234,7 @@ function farB(z) {
|
||||
// Asset cache-buster — bump with main.js?v=N (see the img.src note below). EXPORTED because the
|
||||
// menu screens hang <img> tags on the same files, and two copies of this number is one copy too
|
||||
// many: the one that does not get bumped is the one that serves a cached 404.
|
||||
export const SPRV = 142;
|
||||
export const SPRV = 143;
|
||||
// The boot bar in main.js watches this. Nothing here CHANGES when things load — every sprite was
|
||||
// already requested at import — it just makes the wait visible and holds the menu back until the
|
||||
// last sheet has landed. See the onerror note below for the other half of "sometimes missing".
|
||||
@@ -389,9 +389,14 @@ let flashNext = 0;
|
||||
function tickFlashes(standsBottom) {
|
||||
const now = G.idleT, top = Math.max(4, standsBottom - AD_H - 4);
|
||||
if (now >= flashNext) {
|
||||
// the gap is exponential-ish so they never look metronomic; cheer halves the mean wait
|
||||
const mean = TUNE.flashEvery / (crowd.cheer > 0 ? 2.2 : 1);
|
||||
flashNext = now + mean * (0.35 + Math.random() * 1.6);
|
||||
// THE BIGGER THE MOMENT, THE MORE SHUTTERS. cheer() queues crowd.burst shutters proportional to
|
||||
// its own vol, and they fire back to back at a fifth of the normal gap — a match point empties
|
||||
// the stand's cameras at once, a won rally gets one or two. Outside a burst the rate still
|
||||
// doubles while the crowd is up. The gap is exponential-ish so they never look metronomic.
|
||||
const rush = crowd.burst > 0;
|
||||
if (rush) crowd.burst--;
|
||||
const mean = TUNE.flashEvery / (1 + (crowd.cheer > 0 ? 1.2 + 2.4 * crowd.pop : 0));
|
||||
flashNext = now + (rush ? mean * 0.13 : mean * (0.35 + Math.random() * 1.6));
|
||||
flashes.push({ x: 6 + Math.random() * (W - 12), y: 6 + Math.random() * (top - 6),
|
||||
// small. The first cut was r 2.2-4.2 and its bloom drew a 6 px disc that read as
|
||||
// a LAMP in the stand rather than a shutter going off half a second ago
|
||||
@@ -420,6 +425,30 @@ function tickFlashes(standsBottom) {
|
||||
// is a black rectangle and the applause stops reading at all.
|
||||
const standDim = pt => Math.min(0.85, (court.dim || 0) + 0.26 * pt);
|
||||
|
||||
// THE STAND MOVES IN COLUMNS, and the sheets are cut to match: `assets/_crowdpass.py` guarantees
|
||||
// the clap frame is pixel-identical to the seated one EXCEPT inside blobs that each sit inside ONE
|
||||
// of these columns. That is what makes any column swap seamless — a column with nobody raising in
|
||||
// it draws exactly what was already there. CHANGE THIS AND THE SHEETS MUST BE RECUT (same constant,
|
||||
// COLS, at the top of that script).
|
||||
const COLS_CROWD = 16;
|
||||
function drawClapColumns(clapImg, standsBottom, pt, on) {
|
||||
const up = [];
|
||||
for (let i = 0; i < COLS_CROWD; i++) if (on(i)) up.push(i);
|
||||
if (!up.length) return; // most idle frames: no clip, no save, nothing drawn
|
||||
const crowdH = standsBottom - AD_H + 2;
|
||||
const sw = clapImg.width / COLS_CROWD, cw = W / COLS_CROWD;
|
||||
const d = standDim(pt), tint = d > 0 ? `rgba(12,8,20,${d})` : null;
|
||||
ctx.save(); ctx.beginPath(); ctx.rect(0, 0, W, standsBottom - AD_H); ctx.clip();
|
||||
for (const i of up) {
|
||||
// integer-aligned draws, no vertical offset: fractional-y nearest-neighbour resampling is the
|
||||
// boiling that once read as HUD flicker. Re-tinted per column so a raised column does not pop
|
||||
// brighter than the rotted stand around it.
|
||||
ctx.drawImage(clapImg, i * sw, 0, sw, clapImg.height, i * cw, 0, cw + 0.5, crowdH + 2);
|
||||
if (tint) { ctx.fillStyle = tint; ctx.fillRect(i * cw, 0, cw + 0.5, crowdH + 2); }
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- backdrop ----
|
||||
// THE STATIC SCENE, PRE-COMPOSED. Stands, ads, rot tint, out area, court, lines and the run-off
|
||||
// apron are pixel-identical frame to frame except while a palette lerps (the 6 s transition, the
|
||||
@@ -524,23 +553,32 @@ function drawBackdrop(pal, pt) {
|
||||
ctx.drawImage(bg, -8, -8, W + 16, H + 16);
|
||||
const standsBottom = proj(0, 0, -6).y;
|
||||
// Applause is a real TWO-FRAME animation: x_crowd is the seated sheet (cached above), <crowd>2
|
||||
// the same stand with every arm up. Columns swap on their own phase so the clap ripples across
|
||||
// the stands — integer-aligned draws, no vertical offset, because fractional-y nearest-neighbour
|
||||
// resampling is the boiling that once read as HUD flicker. Clipped above the ad strip so the
|
||||
// redrawn columns never paint over the boards, and re-tinted per column so a clapping column
|
||||
// does not pop brighter than the rotted stand around it.
|
||||
// the same stand with about forty of those people, and only those people, on their feet.
|
||||
const clapImg = crowdImg && (crowdImg === SPR['x_crowd'] ? SPR['x_crowd2'] : SPR[court.crowd + '2']);
|
||||
if (crowd.cheer > 0 && crowdImg && clapImg) {
|
||||
const crowdH = standsBottom - AD_H + 2;
|
||||
const cols = 16, sw = clapImg.width / cols, cw = W / cols;
|
||||
const d = standDim(pt), tint = d > 0 ? `rgba(12,8,20,${d})` : null;
|
||||
ctx.save(); ctx.beginPath(); ctx.rect(0, 0, W, standsBottom - AD_H); ctx.clip();
|
||||
for (let i = 0; i < cols; i++) {
|
||||
if (!(Math.floor(G.idleT * 9 + i * 0.7) % 2)) continue;
|
||||
ctx.drawImage(clapImg, i * sw, 0, sw, clapImg.height, i * cw, 0, cw + 0.5, crowdH + 2);
|
||||
if (tint) { ctx.fillStyle = tint; ctx.fillRect(i * cw, 0, cw + 0.5, crowdH + 2); }
|
||||
}
|
||||
ctx.restore();
|
||||
if (crowdImg && clapImg) {
|
||||
// WHICH COLUMNS ARE ON THEIR FEET. Three behaviours, one draw:
|
||||
// applause — every column on its own phase, a scatter of people rising and sitting;
|
||||
// the WAVE — a travelling band, for a moment big enough to deserve one (crowd.pop);
|
||||
// a FIDGET — one column, briefly, between points, so an idle stand is not a photograph.
|
||||
// The 4.5 Hz toggle this replaced was not the problem (see _crowdpass.py): the old clap sheet
|
||||
// was a re-roll, so a swapped column re-hashed the whole crowd instead of raising arms.
|
||||
// 0.75, not 0.6: a game won calls cheer(2.4, 0.6) and a wave on every held serve is a wave
|
||||
// nobody looks up for. This leaves it to the set, the match, and the ones the umpire calls
|
||||
// unbelievable.
|
||||
const big = crowd.cheer > 0 && crowd.pop >= 0.75;
|
||||
const on = crowd.cheer > 0
|
||||
? (big
|
||||
// one band, ~2.5 columns wide, crossing the stand about every 1.4 s
|
||||
? i => ((G.idleT / 1.4 - i / COLS_CROWD) % 1 + 1) % 1 < 0.16
|
||||
: i => Math.floor(G.idleT * 7 + i * 0.7) % 2 === 1)
|
||||
// ...and when nobody is cheering, ONE column every fidgetEvery seconds, for a third of it.
|
||||
: i => {
|
||||
const slot = Math.floor(G.idleT / TUNE.fidgetEvery);
|
||||
// *5: coprime with 16, so it visits every column — but in steps of five, where *7919
|
||||
// (i.e. *15) walked one seat sideways each time and read as a march.
|
||||
return i === ((slot * 5 + 3) % COLS_CROWD) && (G.idleT / TUNE.fidgetEvery) % 1 < 0.34;
|
||||
};
|
||||
drawClapColumns(clapImg, standsBottom, pt, on);
|
||||
}
|
||||
tickFlashes(standsBottom); // ...and somebody in there is taking pictures
|
||||
return standsBottom;
|
||||
|
||||
+5
-2
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user