mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-14 20:57:12 +00:00
feat(venue): reactive crowd video layer behind the 3D highway (career mode 1/3) (#905)
* feat(venue): reactive crowd video layer behind the 3D highway (career mode PR1) Two crossfading video backdrop planes in the highway_3d venue background style, driven by a new venue-crowd.js state machine that maps v3:live-performance-state to crowd states (bored/neutral/engaged/ecstatic) with 3s stability + 8s dwell hysteresis, plus one-shot reaction stingers on streak milestones and end-of-song accuracy. Inert without a venue pack manifest (career plugin, PR2) or the feedBack-venue-crowd-dev flag — the static bg plate behaves exactly as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(venue-crowd): retry renderer binding + preserve mid-stinger transitions Codex preflight P2s: (1) videos created before highway_3d registered its globals never reached the backdrop planes — binding is now idempotent and retried from start/perf-event/re-activation paths; (2) a crowd-state switch committing while a stinger played was dropped because the machine had already advanced — it is now deferred and played when the stinger ends. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(venue-crowd): per-video load tokens + unbind renderer on stop Codex preflight round 2: (1) the global load token let a stinger cancel a committed loop load on the other layer — tokens are now per-element, and a stinger preempting an in-flight loop on its own layer requeues that loop for when the stinger ends; (2) setManifest(null)/deactivate left the last crowd frame bound and visible over the static plate — stop() now unbinds both layers from the renderer and zeroes the mix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(venue-crowd): flush deferred loop on stinger failure, source accuracy from perf events Codex preflight round 3: (1) a failed/timed-out stinger left a deferred loop switch queued forever; the failure path now flushes it. (2) stats:recorded only carries {filename, arrangement}, so the end-of-song reaction now uses the accuracyPct from the song's last v3:live-performance-state event (a real percentage) instead of a field that never existed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(venue-crowd): requeue mid-crossfade loops preempted by stingers; hard-stop on manifest swap Codex preflight round 4: (1) idleLayer() still points at the fading-in layer during a crossfade, so a stinger firing mid-fade overwrote the new loop with nothing requeued — the fading loop is now tracked and requeued like an in-flight load; (2) swapping venue packs while active now goes through stop() so _stopGen invalidates the old manifest's in-flight loads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(venue-crowd): generation-gate stinger handlers; recrop on video size change Codex preflight round 5: (1) an ended/timeout handler orphaned by stop() could fire into a later stinger's lifecycle on the reused element — handlers now detach unconditionally and carry a generation token; (2) the renderer only re-applied cover-crop on camera aspect changes, so a src swap with a different intrinsic size kept stale repeat/offset — it now recrops when videoWidth/Height change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(venue-crowd): bail loop-fade completion when a stinger preempted the layer Codex preflight round 6: the loop crossfade's completion callback could still run between a stinger's start and its canplaythrough, promoting the stinger's layer to active and pausing the real loop — it now bails when the fading loop was preempted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(venue-crowd): keep rear video layer opaque during crossfades Two half-transparent layers let the static bg plate bleed through (~25% at mid-fade) — visible as a flash of the old still image on every state transition. The crossfade is now always the front layer fading over an opaque rear layer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(venue-crowd): reset active layer with mix on stop Codex preflight: stop() zeroed the mix but left _activeLayer at 1, so a restart flashed layer 0's stale frame until the new loop loaded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(venue-crowd): reset crowd mood to neutral on song load Codex preflight: a song ending in ecstatic/bored left the next song's crowd stuck in that mood until the hysteresis window passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(venue-crowd): cancel in-flight fade when a stinger preempts it Codex preflight: the orphaned ramp kept pushing the mix toward the layer whose src the stinger had just replaced. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(venue-crowd): don't let null accuracy resets wipe the end-of-song value Codex preflight: Number(null) is 0, so idle HUD resets overwrote _lastAccuracyPct before stats:recorded consumed it, suppressing the end-of-song stinger. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(venue-crowd): abort stale stinger state on song load Codex preflight: a stinger straddling a song change could fade back into the previous song's layer or flush its pending loop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(venue-crowd): always detach load listeners, gate only the callback Codex preflight: superseded loads left canplaythrough/error listeners attached to the persistent video elements — unbounded growth over a session. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(venue-crowd): flyover intro with crowd-ambience ducking On song:loaded, an optional pack intro plays once: a camera flyover video (idle layer, one-shot) with bar-crowd ambience audio that ducks out on song:play, near the flyover's landing, or at handoff — whichever first. Machine commits and stingers defer during the intro; stop()/song-change abort it. Packs without an intro behave as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(venue-crowd): fall back to the loop when the intro fails to load Codex preflight: a failed/timed-out intro left the song with no crowd loop at all. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
8d3db5f42c
commit
e779c72396
@@ -2418,6 +2418,13 @@
|
|||||||
let _venueSceneAssetsLoaded = false;
|
let _venueSceneAssetsLoaded = false;
|
||||||
let _venueSceneLoadFailed = false;
|
let _venueSceneLoadFailed = false;
|
||||||
const _venueTextureCache = new Map();
|
const _venueTextureCache = new Map();
|
||||||
|
// Crowd video layers (career mode). venue-crowd.js owns the <video>
|
||||||
|
// elements and the crossfade timing; the renderer only maps them onto
|
||||||
|
// two planes in front of the static plate. _venueCrowdRev bumps on any
|
||||||
|
// element (re)assignment so update() knows to rebind textures.
|
||||||
|
const _venueCrowdVideos = [null, null];
|
||||||
|
let _venueCrowdMix = 0;
|
||||||
|
let _venueCrowdRev = 0;
|
||||||
|
|
||||||
function _bgVenueMoodCoeffs(state) {
|
function _bgVenueMoodCoeffs(state) {
|
||||||
const s = String(state || 'idle').toLowerCase();
|
const s = String(state || 'idle').toLowerCase();
|
||||||
@@ -2909,6 +2916,20 @@
|
|||||||
window.h3dVenueSceneSetMood = (state) => {
|
window.h3dVenueSceneSetMood = (state) => {
|
||||||
_venueMoodState = String(state || 'idle').toLowerCase();
|
_venueMoodState = String(state || 'idle').toLowerCase();
|
||||||
};
|
};
|
||||||
|
// Crowd video layers (career mode) — see venue-crowd.js. Layer 0/1 are
|
||||||
|
// two coplanar backdrop planes; mix selects between them (0 → layer 0,
|
||||||
|
// 1 → layer 1) so the caller can crossfade loop videos.
|
||||||
|
window.h3dVenueBackdropSetVideo = (layer, videoEl) => {
|
||||||
|
const i = layer ? 1 : 0;
|
||||||
|
const el = videoEl || null;
|
||||||
|
if (_venueCrowdVideos[i] === el) return;
|
||||||
|
_venueCrowdVideos[i] = el;
|
||||||
|
_venueCrowdRev++;
|
||||||
|
};
|
||||||
|
window.h3dVenueBackdropSetMix = (mix) => {
|
||||||
|
const v = Number(mix);
|
||||||
|
_venueCrowdMix = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0;
|
||||||
|
};
|
||||||
window.h3dVenueSceneSetInstrumentPov = (input) => {
|
window.h3dVenueSceneSetInstrumentPov = (input) => {
|
||||||
const next = _venueResolvePovFromInput(input);
|
const next = _venueResolvePovFromInput(input);
|
||||||
if (_venueInstrumentPov === next) return;
|
if (_venueInstrumentPov === next) return;
|
||||||
@@ -3371,6 +3392,40 @@
|
|||||||
() => _venueMarkFailed('failed to load small-club bg plate'),
|
() => _venueMarkFailed('failed to load small-club bg plate'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Crowd video planes (career mode): two crossfading layers
|
||||||
|
// just in front of the static plate (which stays mounted as
|
||||||
|
// the no-pack / load-failure fallback). Textures bind lazily
|
||||||
|
// in update() when venue-crowd.js assigns video elements.
|
||||||
|
state.crowd = { layers: [], rev: -1 };
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const geo = new T.PlaneGeometry(1, 1);
|
||||||
|
const mat = new T.MeshBasicMaterial({
|
||||||
|
color: 0xffffff, transparent: true, opacity: 0,
|
||||||
|
depthWrite: false, fog: false,
|
||||||
|
});
|
||||||
|
const mesh = new T.Mesh(geo, mat);
|
||||||
|
mesh.visible = false;
|
||||||
|
// Layer 1 sits nearest so three.js's back-to-front
|
||||||
|
// transparent sort draws it after layer 0.
|
||||||
|
const layer = {
|
||||||
|
mesh, geo, mat, tex: null, videoEl: null,
|
||||||
|
cam: settings.cam,
|
||||||
|
distance: BG_BACKDROP_DISTANCE * (i === 0 ? 1.04 : 1.03),
|
||||||
|
lastAspect: 0, lastVisibleHeight: 0,
|
||||||
|
};
|
||||||
|
layer.applyCoverCrop = function () {
|
||||||
|
if (!layer.videoEl || !layer.tex) return;
|
||||||
|
_bgCoverCrop(
|
||||||
|
layer.tex,
|
||||||
|
layer.videoEl.videoWidth || 0,
|
||||||
|
layer.videoEl.videoHeight || 0,
|
||||||
|
layer.cam.aspect,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
scene.add(mesh);
|
||||||
|
state.crowd.layers.push(layer);
|
||||||
|
}
|
||||||
|
|
||||||
const hazeGeo = new T.PlaneGeometry(280 * K, 40 * K);
|
const hazeGeo = new T.PlaneGeometry(280 * K, 40 * K);
|
||||||
const hazeMat = new T.MeshBasicMaterial({
|
const hazeMat = new T.MeshBasicMaterial({
|
||||||
color: 0x101820, transparent: true, opacity: coeffs.haze,
|
color: 0x101820, transparent: true, opacity: coeffs.haze,
|
||||||
@@ -3402,6 +3457,64 @@
|
|||||||
s.haze.mat.opacity = (s.haze.baseOp || VENUE_HAZE_STEADY)
|
s.haze.mat.opacity = (s.haze.baseOp || VENUE_HAZE_STEADY)
|
||||||
* (coeffs.haze / VENUE_HAZE_STEADY);
|
* (coeffs.haze / VENUE_HAZE_STEADY);
|
||||||
}
|
}
|
||||||
|
if (s.crowd) {
|
||||||
|
// Rebind VideoTextures when venue-crowd.js (re)assigns
|
||||||
|
// elements. VideoTexture samples the element every frame,
|
||||||
|
// so a src change on the same element needs no rebind.
|
||||||
|
if (s.crowd.rev !== _venueCrowdRev) {
|
||||||
|
s.crowd.rev = _venueCrowdRev;
|
||||||
|
s.crowd.layers.forEach((layer, i) => {
|
||||||
|
const el = _venueCrowdVideos[i];
|
||||||
|
if (layer.videoEl === el) return;
|
||||||
|
if (layer.tex) { layer.mat.map = null; layer.tex.dispose(); layer.tex = null; }
|
||||||
|
layer.videoEl = el;
|
||||||
|
layer.lastAspect = 0; // force refit + recrop
|
||||||
|
if (el) {
|
||||||
|
const tex = new T.VideoTexture(el);
|
||||||
|
tex.colorSpace = T.SRGBColorSpace;
|
||||||
|
tex.wrapS = T.ClampToEdgeWrapping;
|
||||||
|
tex.wrapT = T.ClampToEdgeWrapping;
|
||||||
|
tex.minFilter = T.LinearFilter;
|
||||||
|
tex.magFilter = T.LinearFilter;
|
||||||
|
tex.generateMipmaps = false;
|
||||||
|
layer.tex = tex;
|
||||||
|
layer.mat.map = tex;
|
||||||
|
}
|
||||||
|
layer.mat.needsUpdate = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const warm = coeffs.warmth;
|
||||||
|
s.crowd.layers.forEach((layer, i) => {
|
||||||
|
const el = layer.videoEl;
|
||||||
|
// videoWidth === 0 until metadata lands — showing the
|
||||||
|
// plane before that paints a black flash over the plate.
|
||||||
|
const ready = !!el && el.videoWidth > 0;
|
||||||
|
// venue-crowd.js swaps src on the same element (loop ↔
|
||||||
|
// stinger); a new intrinsic size needs a fresh
|
||||||
|
// cover-crop, which _bgFitBackdropPlane only reapplies
|
||||||
|
// on camera aspect changes.
|
||||||
|
if (ready && (layer.lastVidW !== el.videoWidth ||
|
||||||
|
layer.lastVidH !== el.videoHeight)) {
|
||||||
|
layer.lastVidW = el.videoWidth;
|
||||||
|
layer.lastVidH = el.videoHeight;
|
||||||
|
layer.applyCoverCrop();
|
||||||
|
}
|
||||||
|
// Layer 0 (rear) stays fully opaque whenever any of the
|
||||||
|
// fade involves it: two half-transparent layers would
|
||||||
|
// let the static plate behind bleed through (~25% at
|
||||||
|
// mid-fade). The crossfade is therefore layer 1 (front)
|
||||||
|
// fading over an opaque layer 0 — in both directions.
|
||||||
|
const opacity = i === 0
|
||||||
|
? (_venueCrowdMix < 0.999 ? 1 : 0)
|
||||||
|
: _venueCrowdMix;
|
||||||
|
layer.mat.opacity = opacity;
|
||||||
|
layer.mesh.visible = ready && opacity > 0.01;
|
||||||
|
if (layer.mesh.visible) {
|
||||||
|
layer.mat.color.setRGB(warm, warm * 0.98, warm * 0.95);
|
||||||
|
_bgFitBackdropPlane(layer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
teardown(s) {
|
teardown(s) {
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
@@ -3416,6 +3529,19 @@
|
|||||||
p.mat.dispose?.();
|
p.mat.dispose?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Crowd planes: this style owns the VideoTextures; the
|
||||||
|
// <video> elements belong to venue-crowd.js and survive.
|
||||||
|
if (s.crowd) {
|
||||||
|
for (const layer of s.crowd.layers) {
|
||||||
|
layer.mesh?.parent?.remove(layer.mesh);
|
||||||
|
layer.geo?.dispose?.();
|
||||||
|
if (layer.mat) {
|
||||||
|
layer.mat.map = null;
|
||||||
|
layer.mat.dispose?.();
|
||||||
|
}
|
||||||
|
layer.tex?.dispose?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
// Dispose the cached plate textures too — the module-level cache
|
// Dispose the cached plate textures too — the module-level cache
|
||||||
// otherwise keeps every loaded POV plate GPU-resident for the
|
// otherwise keeps every loaded POV plate GPU-resident for the
|
||||||
// page lifetime (steady VRAM growth across POV/arrangement swaps).
|
// page lifetime (steady VRAM growth across POV/arrangement swaps).
|
||||||
|
|||||||
@@ -1275,6 +1275,7 @@
|
|||||||
saved 'off'/'full' motion preference on first paint. -->
|
saved 'off'/'full' motion preference on first paint. -->
|
||||||
<script defer src="/static/v3/venue-mood-fx.js"></script>
|
<script defer src="/static/v3/venue-mood-fx.js"></script>
|
||||||
<script defer src="/static/v3/venue-scene-3d.js"></script>
|
<script defer src="/static/v3/venue-scene-3d.js"></script>
|
||||||
|
<script defer src="/static/v3/venue-crowd.js"></script>
|
||||||
<script defer src="/static/v3/playlists.js"></script>
|
<script defer src="/static/v3/playlists.js"></script>
|
||||||
<script defer src="/static/v3/audio-routing.js"></script>
|
<script defer src="/static/v3/audio-routing.js"></script>
|
||||||
<script defer src="/static/v3/live-guitar-tone-source.js"></script>
|
<script defer src="/static/v3/live-guitar-tone-source.js"></script>
|
||||||
|
|||||||
@@ -0,0 +1,605 @@
|
|||||||
|
/*
|
||||||
|
* 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';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// 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;
|
||||||
|
},
|
||||||
|
// 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
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
return { loops, stingers, intro };
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 next = machine.update(d.state, now());
|
||||||
|
if (next) {
|
||||||
|
// A stinger or the intro owns the idle layer; defer the switch.
|
||||||
|
if (_stingerUntilEnded || _introActive) _pendingLoop = next;
|
||||||
|
else showLoop(next, FADE_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSongLoaded() {
|
||||||
|
machine.reset();
|
||||||
|
_prevStreak = 0;
|
||||||
|
_lastAccuracyPct = null;
|
||||||
|
// 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();
|
||||||
|
_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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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)));
|
||||||
@@ -96,6 +96,7 @@
|
|||||||
if (_active) {
|
if (_active) {
|
||||||
syncInstrumentPov();
|
syncInstrumentPov();
|
||||||
syncVenueMotion();
|
syncVenueMotion();
|
||||||
|
syncCrowd(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_active = true;
|
_active = true;
|
||||||
@@ -105,6 +106,17 @@
|
|||||||
setH3dMood(_lastMood);
|
setH3dMood(_lastMood);
|
||||||
syncInstrumentPov();
|
syncInstrumentPov();
|
||||||
syncVenueMotion();
|
syncVenueMotion();
|
||||||
|
syncCrowd(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCrowd(on) {
|
||||||
|
// Reactive crowd video layer (career mode) — inert without a pack.
|
||||||
|
try {
|
||||||
|
if (root && root.v3VenueCrowd &&
|
||||||
|
typeof root.v3VenueCrowd.setVenueActive === 'function') {
|
||||||
|
root.v3VenueCrowd.setVenueActive(!!on);
|
||||||
|
}
|
||||||
|
} catch (_) { /* visual-only */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncVenueMotion() {
|
function syncVenueMotion() {
|
||||||
@@ -128,6 +140,7 @@
|
|||||||
_assetsLoaded = false;
|
_assetsLoaded = false;
|
||||||
_loadFailed = false;
|
_loadFailed = false;
|
||||||
setH3dActive(false);
|
setH3dActive(false);
|
||||||
|
syncCrowd(false);
|
||||||
syncPlaceholderVisibility();
|
syncPlaceholderVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const crowd = require('../../static/v3/venue-crowd.js');
|
||||||
|
|
||||||
|
const H3D_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||||
|
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||||
|
const SCENE_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'venue-scene-3d.js');
|
||||||
|
|
||||||
|
test('perf state → crowd state mapping', () => {
|
||||||
|
assert.equal(crowd.crowdStateOfPerf('smoke'), 'bored');
|
||||||
|
assert.equal(crowd.crowdStateOfPerf('recovery'), 'bored');
|
||||||
|
assert.equal(crowd.crowdStateOfPerf('idle'), 'neutral');
|
||||||
|
assert.equal(crowd.crowdStateOfPerf('steady'), 'neutral');
|
||||||
|
assert.equal(crowd.crowdStateOfPerf('strong'), 'engaged');
|
||||||
|
assert.equal(crowd.crowdStateOfPerf('fire'), 'ecstatic');
|
||||||
|
assert.equal(crowd.crowdStateOfPerf('FIRE'), 'ecstatic');
|
||||||
|
assert.equal(crowd.crowdStateOfPerf('bogus'), 'neutral');
|
||||||
|
assert.equal(crowd.crowdStateOfPerf(undefined), 'neutral');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('machine: target must be stable for STABLE_MS before committing', () => {
|
||||||
|
const m = crowd.createCrowdMachine();
|
||||||
|
assert.equal(m.current, 'neutral');
|
||||||
|
assert.equal(m.update('fire', 0), null);
|
||||||
|
assert.equal(m.update('fire', crowd.STABLE_MS - 1), null);
|
||||||
|
assert.equal(m.update('fire', crowd.STABLE_MS), 'ecstatic');
|
||||||
|
assert.equal(m.current, 'ecstatic');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('machine: flapping target restarts the stability window', () => {
|
||||||
|
const m = crowd.createCrowdMachine();
|
||||||
|
m.update('fire', 0);
|
||||||
|
// Target changes → candidate resets.
|
||||||
|
m.update('strong', 1000);
|
||||||
|
assert.equal(m.update('strong', 1000 + crowd.STABLE_MS - 1), null);
|
||||||
|
assert.equal(m.update('strong', 1000 + crowd.STABLE_MS), 'engaged');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('machine: returning to current state clears the candidate', () => {
|
||||||
|
const m = crowd.createCrowdMachine();
|
||||||
|
m.update('fire', 0);
|
||||||
|
m.update('steady', 1000); // back to neutral (current) — candidate dropped
|
||||||
|
// 'fire' again must wait a full stability window from scratch.
|
||||||
|
assert.equal(m.update('fire', 2000), null);
|
||||||
|
assert.equal(m.update('fire', 2000 + crowd.STABLE_MS - 1), null);
|
||||||
|
assert.equal(m.update('fire', 2000 + crowd.STABLE_MS), 'ecstatic');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('machine: DWELL_MS enforced between switches', () => {
|
||||||
|
const m = crowd.createCrowdMachine();
|
||||||
|
m.update('fire', 0);
|
||||||
|
assert.equal(m.update('fire', crowd.STABLE_MS), 'ecstatic'); // switch at t=3000
|
||||||
|
const t = crowd.STABLE_MS;
|
||||||
|
// Immediately drop to smoke: stable window passes but dwell hasn't.
|
||||||
|
m.update('smoke', t + 1);
|
||||||
|
assert.equal(m.update('smoke', t + 1 + crowd.STABLE_MS), null);
|
||||||
|
// After the dwell expires the pending candidate commits.
|
||||||
|
assert.equal(m.update('smoke', t + crowd.DWELL_MS), 'bored');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('machine: multi-step jumps allowed (bored → ecstatic)', () => {
|
||||||
|
const m = crowd.createCrowdMachine();
|
||||||
|
m.update('smoke', 0);
|
||||||
|
assert.equal(m.update('smoke', crowd.STABLE_MS), 'bored');
|
||||||
|
const t = crowd.DWELL_MS + 1000;
|
||||||
|
m.update('fire', t);
|
||||||
|
assert.equal(m.update('fire', t + crowd.STABLE_MS), 'ecstatic');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stingerForStreak fires on rising milestone crossings only', () => {
|
||||||
|
assert.equal(crowd.stingerForStreak(24, 25), 'cheer');
|
||||||
|
assert.equal(crowd.stingerForStreak(0, 100), 'cheer');
|
||||||
|
assert.equal(crowd.stingerForStreak(25, 26), null);
|
||||||
|
assert.equal(crowd.stingerForStreak(50, 50), null);
|
||||||
|
assert.equal(crowd.stingerForStreak(30, 0), null); // streak reset
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stingerForAccuracy thresholds', () => {
|
||||||
|
assert.equal(crowd.stingerForAccuracy(95), 'cheer');
|
||||||
|
assert.equal(crowd.stingerForAccuracy(90), 'cheer');
|
||||||
|
assert.equal(crowd.stingerForAccuracy(80), 'clap');
|
||||||
|
assert.equal(crowd.stingerForAccuracy(75), 'clap');
|
||||||
|
assert.equal(crowd.stingerForAccuracy(60), null);
|
||||||
|
assert.equal(crowd.stingerForAccuracy('nope'), null);
|
||||||
|
assert.equal(crowd.stingerForAccuracy(undefined), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeManifest requires all four loops, resolves base', () => {
|
||||||
|
assert.equal(crowd.normalizeManifest(null), null);
|
||||||
|
assert.equal(crowd.normalizeManifest({}), null);
|
||||||
|
assert.equal(crowd.normalizeManifest({ loops: { bored: 'b.mp4' } }), null);
|
||||||
|
const m = crowd.normalizeManifest({
|
||||||
|
base: '/api/plugins/career/venues/bar/',
|
||||||
|
loops: { bored: 'bored.mp4', neutral: 'neutral.mp4', engaged: 'engaged.mp4', ecstatic: 'ecstatic.mp4' },
|
||||||
|
stingers: { cheer: 'cheer.mp4' },
|
||||||
|
});
|
||||||
|
assert.equal(m.loops.ecstatic, '/api/plugins/career/venues/bar/ecstatic.mp4');
|
||||||
|
assert.equal(m.stingers.cheer, '/api/plugins/career/venues/bar/cheer.mp4');
|
||||||
|
assert.equal(m.stingers.clap, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('highway_3d exposes the crowd backdrop globals', () => {
|
||||||
|
const src = fs.readFileSync(H3D_JS, 'utf8');
|
||||||
|
assert.match(src, /window\.h3dVenueBackdropSetVideo\s*=/);
|
||||||
|
assert.match(src, /window\.h3dVenueBackdropSetMix\s*=/);
|
||||||
|
// The venue style must own crowd plane teardown (VideoTexture dispose).
|
||||||
|
assert.match(src, /_venueCrowdVideos/);
|
||||||
|
assert.match(src, /_venueCrowdMix/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('index.html loads venue-crowd.js deferred, after venue-scene-3d.js', () => {
|
||||||
|
const html = fs.readFileSync(INDEX_HTML, 'utf8');
|
||||||
|
const crowdIdx = html.indexOf('/static/v3/venue-crowd.js');
|
||||||
|
const sceneIdx = html.indexOf('/static/v3/venue-scene-3d.js');
|
||||||
|
assert.ok(crowdIdx > 0, 'venue-crowd.js script tag missing');
|
||||||
|
assert.ok(crowdIdx > sceneIdx, 'venue-crowd.js must load after venue-scene-3d.js');
|
||||||
|
assert.match(html, /<script defer src="\/static\/v3\/venue-crowd\.js"><\/script>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('venue-scene-3d activates/deactivates the crowd layer', () => {
|
||||||
|
const src = fs.readFileSync(SCENE_JS, 'utf8');
|
||||||
|
assert.match(src, /syncCrowd\(true\)/);
|
||||||
|
assert.match(src, /syncCrowd\(false\)/);
|
||||||
|
assert.match(src, /v3VenueCrowd/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user