mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
* fix(venue): don't replay the flyover on an arrangement switch; keep the venue off other screens Two bugs from a live career session. 1. CHANGING ARRANGEMENT REPLAYED THE ARRIVAL FLYOVER. changeArrangement() reloads the song through the normal load path, so highway.js re-emits `song:loaded` — same filename, new arrangement. The venue could not tell that from a fresh arrival, so it reset the machine and flew the camera in from the back of the room again, mid-set, every time the player switched lead -> rhythm. The player is already on stage. onSongLoaded now compares the filename. A repeat of the song already on stage keeps the video pipeline running and only re-syncs the mood: the performance restarts, so the loop follows the reset machine with a quiet crossfade, never the intro. A genuinely different song still gets the full teardown + flyover. 2. THE VENUE SHOWED UP ON THE VIRTUOSO HIGHWAY. The venue was gated purely on `isVenueViz()` — the selected visualization, which is a GLOBAL preference and says nothing about what is on screen. Virtuoso borrows the same highway_3d renderer for its practice charts, so with Venue selected it inherited the backdrop: the crowd and the stage behind a chromatic exercise. Selecting Venue is a preference for the PLAYER; it is not a licence to paint the venue over whatever else happens to be using the renderer. The venue is now gated on viz AND screen (`shouldBeActive`), and follows `screen:changed` — it tears down on leaving the player and rebuilds on return. Nothing else changes: stop() already unbinds the videos from the renderer, so deactivating is enough to clear the backdrop. Tests: both decisions exposed as pure predicates and pinned — arrangement switch vs new song (including the first load, and a malformed payload that must not suppress the flyover forever), and the venue's screen scope. The existing syncViz test encoded the OLD contract (activate regardless of screen), so it now states the new one and additionally asserts the venue does NOT activate on virtuoso. Includes a guard test: with Venue selected AND on the player, the venue IS active — without it, every "not active" assertion could pass vacuously. All 8 new/updated assertions fail against the pre-fix source. eslint clean; JS 1199/1199; pytest 2597 passed. * fix(highway): the paused-frame throttle was throttling the whole venue Pausing the song dropped the venue, the crowd and the stage to ~10 fps — "everything around the highway drops fps by a lot". draw() caps paused frames to one per _PAUSED_FRAME_INTERVAL_MS (100ms), on an assumption stated plainly in highway-constants.js: a heavy WebGL renderer "does a full render every frame even while paused. That is pure waste." That was true when a paused chart was a still picture. The venue broke the assumption. Its video backdrop keeps playing and its crowd reacts on a clock of their own, and BOTH are drawn into the same canvas as the notes — so a throttle aimed at static notes throttled the entire room. The scene only got a texture upload 10 times a second while the transport sat paused. Renderers can now declare that their picture is not static while the chart clock is stopped: an optional needsContinuousFrames(). The throttle is skipped only when it returns exactly true, and the probe fails closed — a renderer that doesn't implement it, or one that throws, keeps the throttle unchanged. So the GPU saving that motivated #654 survives everywhere it was actually valid. highway_3d implements it and claims continuous frames ONLY while a crowd video is genuinely rolling (bound, unpaused, not ended, readyState >= 2). With no venue pack — the common case — the paused scene really is static, so it keeps the throttle and the GPU still idles. Tests extend tests/js/highway_pause_throttle.test.js, which guards this code path source-level (the draw loop owns the rAF + WebGL lifecycle and is deliberately not reproduced in a vm — see the file header). The new guards pin that the capability GATES the early return rather than merely being called near it, that the probe fails closed on absent/non-function/throwing/truthy-but-not- true, and that the 3D renderer keys off the real video elements and can still return false. All 3 fail against the pre-fix source. eslint 0 errors; JS 1202/1202; pytest 2597 passed.
704 lines
28 KiB
JavaScript
704 lines
28 KiB
JavaScript
/*
|
|
* fee[dB]ack — Venue crowd video layer (career mode PR1).
|
|
*
|
|
* Crossfades pre-rendered crowd-state loop videos behind the highway based on
|
|
* v3:live-performance-state, plus one-shot reaction stingers. Renders through
|
|
* two video backdrop planes owned by the highway_3d venue background style
|
|
* (window.h3dVenueBackdropSetVideo / window.h3dVenueBackdropSetMix).
|
|
*
|
|
* Inert unless a venue pack manifest is set — by the career plugin via
|
|
* v3VenueCrowd.setManifest(), or (dev only) a JSON manifest in localStorage
|
|
* under feedBack-venue-crowd-dev. With no manifest the static bg plate
|
|
* behaves exactly as before.
|
|
*/
|
|
(function (root) {
|
|
'use strict';
|
|
|
|
// live-performance-hud state → crowd state.
|
|
const CROWD_OF_PERF = {
|
|
smoke: 'bored',
|
|
recovery: 'bored',
|
|
idle: 'neutral',
|
|
steady: 'neutral',
|
|
strong: 'engaged',
|
|
fire: 'ecstatic',
|
|
};
|
|
const CROWD_STATES = ['bored', 'neutral', 'engaged', 'ecstatic'];
|
|
const CROWD_RANK = { bored: 0, neutral: 1, engaged: 2, ecstatic: 3 };
|
|
|
|
const STABLE_MS = 3000; // target must hold this long before a switch
|
|
const DWELL_MS = 8000; // min time between committed switches
|
|
const FADE_MS = 1200; // loop crossfade
|
|
const STINGER_FADE_MS = 400; // stinger fade-in/out
|
|
const STINGER_MIN_GAP_MS = 20000;
|
|
const STREAK_MILESTONES = [25, 50, 100];
|
|
const CANPLAY_TIMEOUT_MS = 4000;
|
|
const DEV_FLAG_KEY = 'feedBack-venue-crowd-dev';
|
|
const SFX_KEY = 'feedBack-venue-crowd-sfx'; // 'on' | 'off' (default off)
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Pure, clock-injected decision logic (unit-tested in
|
|
// tests/js/venue_crowd.test.js — keep DOM-free).
|
|
// ---------------------------------------------------------------------
|
|
|
|
function crowdStateOfPerf(perfState) {
|
|
return CROWD_OF_PERF[String(perfState || '').toLowerCase()] || 'neutral';
|
|
}
|
|
|
|
// Hysteresis: a new target must be observed continuously for STABLE_MS,
|
|
// and at least DWELL_MS must have passed since the last committed switch.
|
|
function createCrowdMachine() {
|
|
let current = 'neutral';
|
|
let candidate = null;
|
|
let candidateSince = 0;
|
|
let lastSwitchAt = -Infinity;
|
|
return {
|
|
get current() { return current; },
|
|
reset() {
|
|
current = "neutral";
|
|
candidate = null;
|
|
lastSwitchAt = -Infinity;
|
|
},
|
|
// Commit a state NOW, bypassing stability/dwell (badge ceremony).
|
|
// Stamping lastSwitchAt makes the dwell window hold the forced
|
|
// state before the real perf machine can reassert.
|
|
force(state, nowMs) {
|
|
if (!CROWD_STATES.includes(state)) return;
|
|
current = state;
|
|
candidate = null;
|
|
lastSwitchAt = nowMs;
|
|
},
|
|
// Feed the latest perf state; returns the new crowd state when a
|
|
// transition commits, else null.
|
|
update(perfState, nowMs) {
|
|
const target = crowdStateOfPerf(perfState);
|
|
if (target === current) {
|
|
candidate = null;
|
|
return null;
|
|
}
|
|
if (target !== candidate) {
|
|
candidate = target;
|
|
candidateSince = nowMs;
|
|
return null;
|
|
}
|
|
if (nowMs - candidateSince < STABLE_MS) return null;
|
|
if (nowMs - lastSwitchAt < DWELL_MS) return null;
|
|
current = target;
|
|
candidate = null;
|
|
lastSwitchAt = nowMs;
|
|
return current;
|
|
},
|
|
};
|
|
}
|
|
|
|
// Cheer when the streak crosses a milestone (rising edge only).
|
|
function stingerForStreak(prevStreak, streak) {
|
|
for (const m of STREAK_MILESTONES) {
|
|
if (prevStreak < m && streak >= m) return 'cheer';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// End-of-song reaction from final accuracy.
|
|
function stingerForAccuracy(accuracyPct) {
|
|
const a = Number(accuracyPct);
|
|
if (!Number.isFinite(a)) return null;
|
|
if (a >= 90) return 'cheer';
|
|
if (a >= 75) return 'clap';
|
|
return null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Video layer controller (browser only).
|
|
// ---------------------------------------------------------------------
|
|
|
|
const machine = createCrowdMachine();
|
|
let _manifest = null; // { loops: {state: url}, stingers: {name: url} }
|
|
let _venueActive = false;
|
|
let _videos = [null, null];
|
|
let _activeLayer = 0; // layer currently showing the loop
|
|
let _mix = 0; // 0 → layer0 visible, 1 → layer1 visible
|
|
let _fadeRaf = 0;
|
|
let _stopGen = 0; // bumped by stop(): invalidates ALL in-flight loads
|
|
let _boundToRenderer = false;
|
|
let _pendingLoop = null; // loop switch deferred by an active stinger
|
|
let _loadingLoop = null; // loop currently waiting on canplaythrough
|
|
let _fadingLoop = null; // loop currently crossfading in (not yet active)
|
|
let _stingerUntilEnded = false;
|
|
let _stingerGen = 0; // identity for ended/timeout handlers
|
|
let _introActive = false;
|
|
let _introGen = 0;
|
|
let _audioEl = null; // crowd ambience during the intro flyover
|
|
let _audioFadeTimer = 0;
|
|
let _lastStingerAt = -Infinity;
|
|
let _prevStreak = 0;
|
|
let _lastAccuracyPct = null; // from perf events; stats:recorded carries none
|
|
// Filename of the song song:loaded last reported. An arrangement switch
|
|
// re-emits song:loaded for the SAME file (changeArrangement reloads through
|
|
// the normal load path), and that must not be mistaken for arriving at the
|
|
// venue with a new song — see onSongLoaded.
|
|
let _lastSongFile = '';
|
|
let _bound = false;
|
|
|
|
function now() { return Date.now(); }
|
|
|
|
function h3d(name) {
|
|
return root && typeof root[name] === 'function' ? root[name] : null;
|
|
}
|
|
|
|
function normalizeManifest(m) {
|
|
if (!m || typeof m !== 'object' || !m.loops) return null;
|
|
const base = typeof m.base === 'string' ? m.base : '';
|
|
const abs = (u) => (typeof u === 'string' && u ? base + u : '');
|
|
const loops = {};
|
|
for (const s of CROWD_STATES) loops[s] = abs(m.loops[s]);
|
|
if (!CROWD_STATES.every((s) => loops[s])) return null;
|
|
const stingers = {};
|
|
for (const k of ['clap', 'cheer']) stingers[k] = abs(m.stingers && m.stingers[k]);
|
|
const intro = {
|
|
video: abs(m.intro && m.intro.video),
|
|
audio: abs(m.intro && m.intro.audio),
|
|
};
|
|
const sfx = {
|
|
up: abs(m.sfx && m.sfx.up),
|
|
down: abs(m.sfx && m.sfx.down),
|
|
};
|
|
return { loops, stingers, intro, sfx };
|
|
}
|
|
|
|
function ensureVideos() {
|
|
if (!_videos[0] && typeof document !== 'undefined') {
|
|
for (let i = 0; i < 2; i++) {
|
|
const v = document.createElement('video');
|
|
// Same autoplay-safe recipe as the highway_3d video bg style:
|
|
// muted + playsInline bypasses gesture requirements; same-origin
|
|
// URLs so VideoTexture never taints.
|
|
v.muted = true;
|
|
v.playsInline = true;
|
|
v.preload = 'auto';
|
|
v.loop = true;
|
|
v.style.display = 'none';
|
|
document.body.appendChild(v);
|
|
_videos[i] = v;
|
|
}
|
|
}
|
|
bindVideosToRenderer();
|
|
}
|
|
|
|
// The highway_3d plugin (and its globals) can register after the venue
|
|
// pack starts — e.g. Venue selected at page load, renderer ready later.
|
|
// Idempotent and retried from start() and the perf-event path so a late
|
|
// renderer still picks the videos up.
|
|
function bindVideosToRenderer() {
|
|
if (_boundToRenderer || !_videos[0]) return;
|
|
const setVideo = h3d('h3dVenueBackdropSetVideo');
|
|
if (!setVideo) return;
|
|
setVideo(0, _videos[0]);
|
|
setVideo(1, _videos[1]);
|
|
_boundToRenderer = true;
|
|
setMix(_mix); // re-push mix the renderer missed while unregistered
|
|
}
|
|
|
|
function setMix(v) {
|
|
_mix = Math.max(0, Math.min(1, v));
|
|
const fn = h3d('h3dVenueBackdropSetMix');
|
|
if (fn) fn(_mix);
|
|
}
|
|
|
|
function cancelFade() {
|
|
if (_fadeRaf && typeof cancelAnimationFrame === 'function') {
|
|
cancelAnimationFrame(_fadeRaf);
|
|
}
|
|
_fadeRaf = 0;
|
|
}
|
|
|
|
function fadeMixTo(target, durationMs, done) {
|
|
cancelFade();
|
|
if (typeof requestAnimationFrame !== 'function') {
|
|
setMix(target);
|
|
if (done) done();
|
|
return;
|
|
}
|
|
const from = _mix;
|
|
const t0 = now();
|
|
const step = () => {
|
|
const k = Math.min(1, (now() - t0) / durationMs);
|
|
setMix(from + (target - from) * k);
|
|
if (k < 1) {
|
|
_fadeRaf = requestAnimationFrame(step);
|
|
} else {
|
|
_fadeRaf = 0;
|
|
if (done) done();
|
|
}
|
|
};
|
|
_fadeRaf = requestAnimationFrame(step);
|
|
}
|
|
|
|
// Load url into the video, resolve when it can play through (or after a
|
|
// timeout — a stalled fetch must not wedge the crowd forever). Tokens are
|
|
// per-element: a later load on the SAME video (a stinger preempting the
|
|
// idle layer) cancels this one, but loads on the other layer don't.
|
|
function loadAndPlay(video, url, loop, cb) {
|
|
const token = (video._fbCrowdToken = (video._fbCrowdToken || 0) + 1);
|
|
const gen = _stopGen;
|
|
let settled = false;
|
|
const settle = (ok) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
// Cleanup must run even for superseded loads or stale listeners
|
|
// accumulate on the two persistent elements; only the callback
|
|
// is gated on still being the current load.
|
|
video.removeEventListener('canplaythrough', onReady);
|
|
video.removeEventListener('error', onError);
|
|
if (token !== video._fbCrowdToken || gen !== _stopGen) return;
|
|
cb(ok);
|
|
};
|
|
const onReady = () => settle(true);
|
|
const onError = () => settle(false);
|
|
video.addEventListener('canplaythrough', onReady);
|
|
video.addEventListener('error', onError);
|
|
video.loop = loop;
|
|
video.src = url;
|
|
video.play().catch(() => { /* browser retries on visibility/gesture */ });
|
|
setTimeout(() => settle(video.readyState >= 3), CANPLAY_TIMEOUT_MS);
|
|
}
|
|
|
|
function idleLayer() { return _activeLayer === 0 ? 1 : 0; }
|
|
|
|
// Crossfade the loop for `state` in on the idle layer.
|
|
function showLoop(state, fadeMs) {
|
|
if (!_manifest || !_videos[0]) return;
|
|
const layer = idleLayer();
|
|
const video = _videos[layer];
|
|
_loadingLoop = state;
|
|
loadAndPlay(video, _manifest.loops[state], true, (ok) => {
|
|
if (_loadingLoop === state) _loadingLoop = null;
|
|
if (!ok || !_venueActive) return;
|
|
_fadingLoop = state;
|
|
fadeMixTo(layer === 1 ? 1 : 0, fadeMs, () => {
|
|
// Preempted mid-fade (stinger claimed this layer while we
|
|
// were still ramping): the layer no longer holds this loop —
|
|
// promoting it would pause the real loop and hand fade-back
|
|
// the wrong target.
|
|
if (_fadingLoop !== state) return;
|
|
_fadingLoop = null;
|
|
const old = _videos[_activeLayer];
|
|
_activeLayer = layer;
|
|
if (old && !old.paused) old.pause();
|
|
});
|
|
});
|
|
}
|
|
|
|
function playStinger(name) {
|
|
if (!_manifest || !_manifest.stingers[name] || !_videos[0]) return;
|
|
if (_stingerUntilEnded) return;
|
|
const t = now();
|
|
if (t - _lastStingerAt < STINGER_MIN_GAP_MS) return;
|
|
_lastStingerAt = t;
|
|
_stingerUntilEnded = true;
|
|
const layer = idleLayer();
|
|
const video = _videos[layer];
|
|
// The stinger reuses the idle layer's element, cancelling any loop
|
|
// load still in flight there — and idleLayer() is still the fading-in
|
|
// layer while a crossfade runs (_activeLayer flips on completion), so
|
|
// a mid-fade loop gets overwritten too. Requeue either for when the
|
|
// stinger ends (the machine already advanced, nothing re-fires it).
|
|
const interrupted = _loadingLoop || _fadingLoop;
|
|
if (interrupted) {
|
|
// Freeze any in-flight crossfade: its ramp would keep pushing the
|
|
// mix toward this layer while the stinger replaces the src (loop
|
|
// vanishing / stinger popping in at full opacity).
|
|
cancelFade();
|
|
_pendingLoop = interrupted;
|
|
_loadingLoop = null;
|
|
_fadingLoop = null;
|
|
}
|
|
// A loop switch deferred (or preempted) by this stinger must play
|
|
// once the stinger is done OR failed — the machine already advanced,
|
|
// so nothing re-triggers it later.
|
|
const flushPending = () => {
|
|
if (!_pendingLoop || !_venueActive) return;
|
|
const pending = _pendingLoop;
|
|
_pendingLoop = null;
|
|
showLoop(pending, FADE_MS);
|
|
};
|
|
const myGen = ++_stingerGen;
|
|
const back = () => {
|
|
// Always detach: a handler left behind by a stop()/manifest swap
|
|
// must not fire into a LATER stinger's lifecycle on this reused
|
|
// element (the gen check below guards that; the boolean alone
|
|
// would pass once a new stinger is active).
|
|
video.removeEventListener('ended', back);
|
|
if (_stingerGen !== myGen || !_stingerUntilEnded) return;
|
|
_stingerUntilEnded = false;
|
|
// Fade back to the loop layer (which kept playing underneath).
|
|
fadeMixTo(_activeLayer === 1 ? 1 : 0, STINGER_FADE_MS);
|
|
flushPending();
|
|
};
|
|
loadAndPlay(video, _manifest.stingers[name], false, (ok) => {
|
|
if (!ok || !_venueActive) {
|
|
_stingerUntilEnded = false;
|
|
flushPending();
|
|
return;
|
|
}
|
|
video.addEventListener('ended', back);
|
|
fadeMixTo(layer === 1 ? 1 : 0, STINGER_FADE_MS);
|
|
// Safety: an `ended` that never fires (decode stall) must not
|
|
// freeze the crowd on a stinger frame.
|
|
setTimeout(back, 15000);
|
|
});
|
|
}
|
|
|
|
function ensureAudio() {
|
|
if (_audioEl || typeof document === 'undefined') return;
|
|
_audioEl = document.createElement('audio');
|
|
_audioEl.preload = 'auto';
|
|
_audioEl.style.display = 'none';
|
|
document.body.appendChild(_audioEl);
|
|
}
|
|
|
|
function fadeAudioOut(durationMs) {
|
|
if (!_audioEl || _audioEl.paused) return;
|
|
if (_audioFadeTimer) return; // already fading
|
|
const from = _audioEl.volume;
|
|
const t0 = now();
|
|
_audioFadeTimer = setInterval(() => {
|
|
const k = Math.min(1, (now() - t0) / durationMs);
|
|
_audioEl.volume = from * (1 - k);
|
|
if (k >= 1) {
|
|
clearInterval(_audioFadeTimer);
|
|
_audioFadeTimer = 0;
|
|
_audioEl.pause();
|
|
}
|
|
}, 50);
|
|
}
|
|
|
|
function stopAudio() {
|
|
if (_audioFadeTimer) { clearInterval(_audioFadeTimer); _audioFadeTimer = 0; }
|
|
if (_audioEl && !_audioEl.paused) _audioEl.pause();
|
|
}
|
|
|
|
// One-shot flyover intro on song load: video flies from the back of the
|
|
// room onto the stage, crowd ambience plays and ducks out as the song
|
|
// starts (song:play) or as the flyover lands, whichever comes first.
|
|
function playIntro() {
|
|
if (!_manifest || !_manifest.intro || !_manifest.intro.video || !_videos[0]) {
|
|
return false;
|
|
}
|
|
const myGen = ++_introGen;
|
|
_introActive = true;
|
|
const layer = idleLayer();
|
|
const video = _videos[layer];
|
|
const land = () => {
|
|
if (_introGen !== myGen || !_introActive) return;
|
|
_introActive = false;
|
|
video.removeEventListener('ended', land);
|
|
fadeAudioOut(1200);
|
|
const pending = _pendingLoop;
|
|
_pendingLoop = null;
|
|
showLoop(pending || machine.current, 400);
|
|
};
|
|
loadAndPlay(video, _manifest.intro.video, false, (ok) => {
|
|
if (_introGen !== myGen) return;
|
|
if (!ok || !_venueActive) {
|
|
// Failed intro must not leave the song loop-less: fall back
|
|
// to the normal loop exactly like the no-intro path.
|
|
_introActive = false;
|
|
if (_venueActive) showLoop(machine.current, FADE_MS);
|
|
return;
|
|
}
|
|
fadeMixTo(layer === 1 ? 1 : 0, 300);
|
|
video.addEventListener('ended', land);
|
|
setTimeout(land, 15000); // decode-stall safety
|
|
if (_manifest.intro.audio) {
|
|
ensureAudio();
|
|
_audioEl.src = _manifest.intro.audio;
|
|
_audioEl.volume = 1;
|
|
// The user's play gesture precedes song:loaded, so autoplay
|
|
// with sound is normally allowed; degrade silently if not.
|
|
_audioEl.play().catch(() => { /* no gesture yet */ });
|
|
// start ducking shortly before the flyover lands
|
|
video.addEventListener('timeupdate', function duck() {
|
|
if (video.duration && video.duration - video.currentTime < 1.5) {
|
|
video.removeEventListener('timeupdate', duck);
|
|
fadeAudioOut(1400);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
return true;
|
|
}
|
|
|
|
let _sfxEl = null;
|
|
|
|
function sfxEnabled() {
|
|
try { return localStorage.getItem(SFX_KEY) === 'on'; } catch (_) { return false; }
|
|
}
|
|
|
|
// One-shot crowd reaction on committed mood transitions (toggleable):
|
|
// up the ladder → cheer, down → boos. Committed transitions are already
|
|
// hysteresis-limited, so this can't spam.
|
|
function playMoodSfx(direction) {
|
|
if (!sfxEnabled() || !_manifest || !_manifest.sfx || _introActive) return;
|
|
const url = direction > 0 ? _manifest.sfx.up : _manifest.sfx.down;
|
|
if (!url || typeof document === 'undefined') return;
|
|
if (!_sfxEl) {
|
|
_sfxEl = document.createElement('audio');
|
|
_sfxEl.preload = 'auto';
|
|
_sfxEl.style.display = 'none';
|
|
document.body.appendChild(_sfxEl);
|
|
}
|
|
_sfxEl.src = url;
|
|
_sfxEl.volume = 0.6;
|
|
_sfxEl.play().catch(() => { /* pre-gesture; skip silently */ });
|
|
}
|
|
|
|
function onSongPlay() {
|
|
// Song audio starting is the hard cue: the ambience must yield.
|
|
fadeAudioOut(1000);
|
|
}
|
|
|
|
function onPerformanceState(e) {
|
|
if (!_venueActive || !_manifest) return;
|
|
bindVideosToRenderer();
|
|
const d = (e && e.detail) || {};
|
|
// Number(null) === 0: HUD reset events (accuracyPct: null) must not
|
|
// wipe the value the end-of-song stinger reads via stats:recorded.
|
|
if (d.accuracyPct != null && Number.isFinite(Number(d.accuracyPct))) {
|
|
_lastAccuracyPct = Number(d.accuracyPct);
|
|
}
|
|
const streak = Number(d.streak) || 0;
|
|
const sting = stingerForStreak(_prevStreak, streak);
|
|
_prevStreak = streak;
|
|
if (sting && !_introActive && CROWD_RANK[machine.current] >= CROWD_RANK.neutral) {
|
|
playStinger(sting);
|
|
}
|
|
const prevRank = CROWD_RANK[machine.current];
|
|
const next = machine.update(d.state, now());
|
|
if (next) {
|
|
playMoodSfx(CROWD_RANK[next] - prevRank);
|
|
// A stinger or the intro owns the idle layer; defer the switch.
|
|
if (_stingerUntilEnded || _introActive) _pendingLoop = next;
|
|
else showLoop(next, FADE_MS);
|
|
}
|
|
}
|
|
|
|
// song:loaded for the SAME file is an arrangement switch, not an arrival at
|
|
// the venue. changeArrangement() reloads through the normal load path, so
|
|
// the event is indistinguishable from a fresh load except by filename.
|
|
function isArrangementSwitch(prevFile, nextFile) {
|
|
return !!nextFile && nextFile === prevFile;
|
|
}
|
|
|
|
function onSongLoaded(song) {
|
|
const file = String((song && song.filename) || '');
|
|
const sameSong = isArrangementSwitch(_lastSongFile, file);
|
|
_lastSongFile = file;
|
|
|
|
machine.reset();
|
|
_prevStreak = 0;
|
|
_lastAccuracyPct = null;
|
|
|
|
// Switching arrangement is NOT arriving at the venue.
|
|
//
|
|
// changeArrangement() reloads the song through the same path as a fresh
|
|
// load, so highway.js emits song:loaded again — same filename, new
|
|
// arrangement. Treated as a new song, that replayed the arrival flyover:
|
|
// the camera flew in from the back of the room again mid-set, every time
|
|
// the player switched from lead to rhythm. The player is already on
|
|
// stage; the room should just carry on.
|
|
//
|
|
// So keep the video pipeline running and only re-sync the mood: the
|
|
// performance restarts, so the loop must follow the reset machine (a
|
|
// quiet crossfade), never the intro.
|
|
if (sameSong) {
|
|
if (_venueActive && _manifest && !_introActive) showLoop(machine.current, FADE_MS);
|
|
return;
|
|
}
|
|
|
|
// A genuinely different song — full teardown.
|
|
// Abort any stinger/pending state from the previous song: its ended
|
|
// handler must not fade back into the old song's layers.
|
|
cancelFade();
|
|
_stingerGen++;
|
|
_introGen++;
|
|
_stingerUntilEnded = false;
|
|
_introActive = false;
|
|
stopAudio();
|
|
_pendingLoop = null;
|
|
_loadingLoop = null;
|
|
_fadingLoop = null;
|
|
if (_venueActive && _manifest) {
|
|
if (!playIntro()) showLoop(machine.current, FADE_MS);
|
|
}
|
|
}
|
|
|
|
function onStatsRecorded() {
|
|
if (!_venueActive || !_manifest) return;
|
|
// stats:recorded carries only {filename, arrangement} — the accuracy
|
|
// comes from the last v3:live-performance-state of the finished song.
|
|
const sting = stingerForAccuracy(_lastAccuracyPct);
|
|
_lastAccuracyPct = null; // one reaction per song
|
|
if (sting) {
|
|
_lastStingerAt = -Infinity; // end-of-song reaction always allowed
|
|
playStinger(sting);
|
|
}
|
|
}
|
|
|
|
function start() {
|
|
ensureVideos();
|
|
if (!_videos[0]) return;
|
|
_prevStreak = 0;
|
|
// Boot straight into the current machine state on the active layer.
|
|
const video = _videos[_activeLayer];
|
|
loadAndPlay(video, _manifest.loops[machine.current], true, (ok) => {
|
|
if (!ok || !_venueActive) return;
|
|
setMix(_activeLayer === 1 ? 1 : 0);
|
|
});
|
|
}
|
|
|
|
function stop() {
|
|
cancelFade();
|
|
_stopGen++;
|
|
_stingerGen++;
|
|
_introGen++;
|
|
_introActive = false;
|
|
stopAudio();
|
|
if (_sfxEl && !_sfxEl.paused) _sfxEl.pause();
|
|
_stingerUntilEnded = false;
|
|
_pendingLoop = null;
|
|
_loadingLoop = null;
|
|
_fadingLoop = null;
|
|
for (const v of _videos) {
|
|
if (v && !v.paused) v.pause();
|
|
}
|
|
// Unbind from the renderer: a paused video still holds its last
|
|
// frame, and the venue style keeps a bound plane visible whenever
|
|
// videoWidth > 0 — without this a removed pack would leave a frozen
|
|
// crowd frame over the static plate. start() re-binds.
|
|
const setVideo = h3d('h3dVenueBackdropSetVideo');
|
|
if (_boundToRenderer && setVideo) {
|
|
setVideo(0, null);
|
|
setVideo(1, null);
|
|
}
|
|
_boundToRenderer = false;
|
|
// Mix and active layer must reset together: mix 0 shows layer 0, so a
|
|
// restart that left _activeLayer at 1 would flash layer 0's stale
|
|
// frame until the new loop loads.
|
|
_activeLayer = 0;
|
|
setMix(0);
|
|
}
|
|
|
|
function setVenueActive(on) {
|
|
const next = !!on;
|
|
if (next === _venueActive) {
|
|
// Re-activation (e.g. viz:renderer:ready after a late plugin
|
|
// load): don't restart the loop, but do retry renderer binding.
|
|
if (next && _manifest) bindVideosToRenderer();
|
|
return;
|
|
}
|
|
_venueActive = next;
|
|
if (_venueActive && _manifest) start();
|
|
else stop();
|
|
}
|
|
|
|
function setManifest(m) {
|
|
const norm = normalizeManifest(m);
|
|
_manifest = norm;
|
|
if (_venueActive) {
|
|
// Full stop first even when replacing pack-for-pack: it bumps
|
|
// _stopGen so an in-flight load from the OLD manifest can't
|
|
// settle and fade a stale URL in after the new pack starts.
|
|
stop();
|
|
if (norm) start();
|
|
}
|
|
}
|
|
|
|
function readDevManifest() {
|
|
try {
|
|
const raw = localStorage.getItem(DEV_FLAG_KEY);
|
|
if (!raw) return null;
|
|
return JSON.parse(raw);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function bindRuntime() {
|
|
if (_bound) return;
|
|
_bound = true;
|
|
const sm = root && root.feedBack;
|
|
if (sm && typeof sm.on === 'function') {
|
|
sm.on('v3:live-performance-state', onPerformanceState);
|
|
sm.on('stats:recorded', onStatsRecorded);
|
|
// A new song must not inherit the previous song's crowd mood
|
|
// through the hysteresis/dwell window.
|
|
sm.on('song:loaded', onSongLoaded);
|
|
sm.on('song:play', onSongPlay);
|
|
}
|
|
const dev = readDevManifest();
|
|
if (dev && !_manifest) setManifest(dev);
|
|
}
|
|
|
|
// Badge-ceremony hook (career passports): the crowd erupts NOW — ecstatic
|
|
// loop bypassing stability/dwell (the dwell window then holds it while
|
|
// the real perf state waits its turn) plus a cheer. Degrades to a no-op
|
|
// without a pack / outside the player, like every other entry point.
|
|
function celebrate() {
|
|
if (!_venueActive || !_manifest || !_videos[0]) return false;
|
|
machine.force('ecstatic', now());
|
|
if (_stingerUntilEnded || _introActive) {
|
|
// A stinger/intro owns the idle layer (likely the end-of-song
|
|
// accuracy cheer — the crowd is already reacting); queue the
|
|
// ecstatic loop for when it ends, same as onPerformanceState.
|
|
_pendingLoop = 'ecstatic';
|
|
} else {
|
|
showLoop('ecstatic', FADE_MS);
|
|
_lastStingerAt = -Infinity; // a badge earn always gets its cheer
|
|
playStinger('cheer');
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function getState() {
|
|
return {
|
|
venueActive: _venueActive,
|
|
hasManifest: !!_manifest,
|
|
crowdState: machine.current,
|
|
activeLayer: _activeLayer,
|
|
mix: _mix,
|
|
stingerActive: _stingerUntilEnded,
|
|
introActive: _introActive,
|
|
};
|
|
}
|
|
|
|
const api = {
|
|
CROWD_STATES,
|
|
STABLE_MS,
|
|
DWELL_MS,
|
|
crowdStateOfPerf,
|
|
createCrowdMachine,
|
|
stingerForStreak,
|
|
stingerForAccuracy,
|
|
normalizeManifest,
|
|
setManifest,
|
|
setVenueActive,
|
|
bindRuntime,
|
|
getState,
|
|
celebrate,
|
|
isArrangementSwitch,
|
|
};
|
|
|
|
if (root) root.v3VenueCrowd = api;
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
|
|
if (typeof document !== 'undefined') {
|
|
// Same defer/DOMContentLoaded dance as venue-scene-3d.js.
|
|
if (document.readyState !== 'complete') {
|
|
document.addEventListener('DOMContentLoaded', bindRuntime);
|
|
} else {
|
|
bindRuntime();
|
|
}
|
|
}
|
|
}(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : null)));
|