mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-13 16:30:09 +00:00
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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
70dbe45e27
commit
78dbb039b7
@@ -1275,6 +1275,7 @@
|
||||
saved 'off'/'full' motion preference on first paint. -->
|
||||
<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-crowd.js"></script>
|
||||
<script defer src="/static/v3/playlists.js"></script>
|
||||
<script defer src="/static/v3/audio-routing.js"></script>
|
||||
<script defer src="/static/v3/live-guitar-tone-source.js"></script>
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
/*
|
||||
* 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; },
|
||||
// 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 _loadToken = 0; // invalidates in-flight canplaythrough waits
|
||||
let _stingerUntilEnded = false;
|
||||
let _lastStingerAt = -Infinity;
|
||||
let _prevStreak = 0;
|
||||
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]);
|
||||
return { loops, stingers };
|
||||
}
|
||||
|
||||
function ensureVideos() {
|
||||
if (_videos[0] || typeof document === 'undefined') return;
|
||||
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;
|
||||
const setVideo = h3d('h3dVenueBackdropSetVideo');
|
||||
if (setVideo) setVideo(i, v);
|
||||
}
|
||||
}
|
||||
|
||||
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).
|
||||
function loadAndPlay(video, url, loop, cb) {
|
||||
const token = ++_loadToken;
|
||||
let settled = false;
|
||||
const settle = (ok) => {
|
||||
if (settled || token !== _loadToken) return;
|
||||
settled = true;
|
||||
video.removeEventListener('canplaythrough', onReady);
|
||||
video.removeEventListener('error', onError);
|
||||
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];
|
||||
loadAndPlay(video, _manifest.loops[state], true, (ok) => {
|
||||
if (!ok || !_venueActive) return;
|
||||
fadeMixTo(layer === 1 ? 1 : 0, fadeMs, () => {
|
||||
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];
|
||||
const back = () => {
|
||||
if (!_stingerUntilEnded) return;
|
||||
_stingerUntilEnded = false;
|
||||
video.removeEventListener('ended', back);
|
||||
// Fade back to the loop layer (which kept playing underneath).
|
||||
fadeMixTo(_activeLayer === 1 ? 1 : 0, STINGER_FADE_MS);
|
||||
};
|
||||
loadAndPlay(video, _manifest.stingers[name], false, (ok) => {
|
||||
if (!ok || !_venueActive) { _stingerUntilEnded = false; 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 onPerformanceState(e) {
|
||||
if (!_venueActive || !_manifest) return;
|
||||
const d = (e && e.detail) || {};
|
||||
const streak = Number(d.streak) || 0;
|
||||
const sting = stingerForStreak(_prevStreak, streak);
|
||||
_prevStreak = streak;
|
||||
if (sting && CROWD_RANK[machine.current] >= CROWD_RANK.neutral) {
|
||||
playStinger(sting);
|
||||
}
|
||||
const next = machine.update(d.state, now());
|
||||
// A stinger owns the idle layer; the hysteresis re-fires soon after.
|
||||
if (next && !_stingerUntilEnded) showLoop(next, FADE_MS);
|
||||
}
|
||||
|
||||
function onStatsRecorded(e) {
|
||||
if (!_venueActive || !_manifest) return;
|
||||
const d = (e && e.detail) || {};
|
||||
const sting = stingerForAccuracy(d.accuracy != null ? d.accuracy : d.accuracyPct);
|
||||
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();
|
||||
_loadToken++;
|
||||
_stingerUntilEnded = false;
|
||||
for (const v of _videos) {
|
||||
if (v && !v.paused) v.pause();
|
||||
}
|
||||
}
|
||||
|
||||
function setVenueActive(on) {
|
||||
const next = !!on;
|
||||
if (next === _venueActive) return;
|
||||
_venueActive = next;
|
||||
if (_venueActive && _manifest) start();
|
||||
else stop();
|
||||
}
|
||||
|
||||
function setManifest(m) {
|
||||
const norm = normalizeManifest(m);
|
||||
_manifest = norm;
|
||||
if (_venueActive) {
|
||||
if (norm) start();
|
||||
else stop();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
const dev = readDevManifest();
|
||||
if (dev && !_manifest) setManifest(dev);
|
||||
}
|
||||
|
||||
function getState() {
|
||||
return {
|
||||
venueActive: _venueActive,
|
||||
hasManifest: !!_manifest,
|
||||
crowdState: machine.current,
|
||||
activeLayer: _activeLayer,
|
||||
mix: _mix,
|
||||
stingerActive: _stingerUntilEnded,
|
||||
};
|
||||
}
|
||||
|
||||
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)));
|
||||
@@ -105,6 +105,17 @@
|
||||
setH3dMood(_lastMood);
|
||||
syncInstrumentPov();
|
||||
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() {
|
||||
@@ -128,6 +139,7 @@
|
||||
_assetsLoaded = false;
|
||||
_loadFailed = false;
|
||||
setH3dActive(false);
|
||||
syncCrowd(false);
|
||||
syncPlaceholderVisibility();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user