mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-15 05:07:15 +00:00
feat(career): badge ceremony — the crowd erupts, the stamp drops (#941)
Earning a genre badge now stages the full moment (career v2, WS1):
- venue-crowd.js gains a public celebrate(): machine.force('ecstatic')
commits instantly (bypassing STABLE_MS/DWELL_MS; the stamped
lastSwitchAt makes the dwell window HOLD the forced state before the
real perf machine reasserts) + a cheer stinger via the same
_lastStingerAt=-Infinity bypass the end-of-song reaction uses. If a
stinger/intro owns the idle layer, the ecstatic loop is queued via
_pendingLoop exactly like onPerformanceState. No-op without a
manifest/active venue.
- career detectNewBadges() calls badgeCeremony(): crowd first, then a
body-appended full-screen overlay 300ms later (it cannot live in
#pp-overlay — #plugin-career is display:none during playback): dimmed
backdrop, the bronze stamp slamming in with a shine sweep, a 42-piece
canvas confetti burst, click-or-4s dismiss.
- prefers-reduced-motion: chime + fbNotify only, no overlay.
Tests: machine.force commit/dwell-hold/bogus-state, celebrate export +
no-manifest no-op, celebrate-called-once-per-badge, crowd-absent and
crowd-throwing degradation.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
45caa86ab8
commit
d26347981c
@@ -8,6 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **Badge ceremony in the venue** — earning a genre badge now stages a moment:
|
||||||
|
the crowd layer erupts (new public `v3VenueCrowd.celebrate()` — instant
|
||||||
|
ecstatic loop bypassing the stability/dwell hysteresis, plus a cheer stinger;
|
||||||
|
a no-op without a venue pack) and a full-screen overlay drops the bronze
|
||||||
|
stamp with a shine sweep and a confetti burst over whatever screen is active
|
||||||
|
(badges land right after `stats:recorded`, while the player is still up).
|
||||||
|
Click or wait ~4s to dismiss; `prefers-reduced-motion` gets the existing
|
||||||
|
chime + notification only. The stamp still slams into the passport book on
|
||||||
|
next open, unchanged.
|
||||||
- **Career passports (backend)** — the badge-journey layer on top of career stars.
|
- **Career passports (backend)** — the badge-journey layer on top of career stars.
|
||||||
New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument
|
New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument
|
||||||
passport walls: genre badges computed on read from `song_stats` × the library's
|
passport walls: genre badges computed on read from `song_stats` × the library's
|
||||||
|
|||||||
@@ -409,3 +409,60 @@
|
|||||||
.pp-slam, .pp-stamp-page::after { opacity: 1; }
|
.pp-slam, .pp-stamp-page::after { opacity: 1; }
|
||||||
.pp-stamp-hidden { opacity: 0.92; }
|
.pp-stamp-hidden { opacity: 0.92; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Badge ceremony (body-level overlay — shows over the player) */
|
||||||
|
.pp-ceremony-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 220;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(3, 7, 18, 0.55);
|
||||||
|
backdrop-filter: blur(1.5px);
|
||||||
|
animation: pp-ceremony-in 0.3s ease-out;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pp-ceremony-out { opacity: 0; transition: opacity 0.3s ease-out; }
|
||||||
|
.pp-confetti { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
|
||||||
|
.pp-ceremony-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.pp-ceremony-stamp {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(239, 230, 208, 0.97);
|
||||||
|
transform: rotate(var(--pp-rot)) scale(1.25);
|
||||||
|
animation: pp-slam 0.55s cubic-bezier(0.2, 0.8, 0.3, 1) 0.15s backwards;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.pp-ceremony-stamp::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -40%;
|
||||||
|
background: linear-gradient(115deg, transparent 42%, rgba(255, 255, 255, 0.55) 50%, transparent 58%);
|
||||||
|
transform: translateX(-120%);
|
||||||
|
animation: pp-shine 1.1s ease-out 0.75s forwards;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
@keyframes pp-shine {
|
||||||
|
to { transform: translateX(120%); }
|
||||||
|
}
|
||||||
|
@keyframes pp-ceremony-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
.pp-ceremony-title {
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #f0e2c3;
|
||||||
|
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
.pp-ceremony-sub { font-size: 0.8rem; color: #d1d5db; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8); }
|
||||||
|
|||||||
+105
-3
@@ -34,6 +34,8 @@
|
|||||||
let _ppRelayTimer = 0;
|
let _ppRelayTimer = 0;
|
||||||
let _ppBook = null; // {inst, gkey} of the open spread
|
let _ppBook = null; // {inst, gkey} of the open spread
|
||||||
let _ppReturnFocus = null; // element to refocus when the book closes
|
let _ppReturnFocus = null; // element to refocus when the book closes
|
||||||
|
let _ppCeremonyQueue = []; // badges awaiting their ceremony overlay
|
||||||
|
let _ppCeremonyActive = false;
|
||||||
let _ppBootstrapped = false;
|
let _ppBootstrapped = false;
|
||||||
let _ppNotified = {}; // badges chimed this session (slam still pending)
|
let _ppNotified = {}; // badges chimed this session (slam still pending)
|
||||||
|
|
||||||
@@ -308,9 +310,10 @@
|
|||||||
lsSet(PP_SEEN_KEY, JSON.stringify(seen));
|
lsSet(PP_SEEN_KEY, JSON.stringify(seen));
|
||||||
}
|
}
|
||||||
|
|
||||||
// New badge → chime + notification once per session; the stamp SLAM plays
|
// New badge → chime + notification + the venue ceremony, once per
|
||||||
// when the passport is next opened (and only then is the badge marked
|
// session; the stamp SLAM plays when the passport is next opened (and
|
||||||
// seen, so a pending slam survives a reload).
|
// only then is the badge marked seen, so a pending slam survives a
|
||||||
|
// reload).
|
||||||
function detectNewBadges(view) {
|
function detectNewBadges(view) {
|
||||||
const seen = seenBadges();
|
const seen = seenBadges();
|
||||||
for (const inst of Object.keys(view.instruments || {})) {
|
for (const inst of Object.keys(view.instruments || {})) {
|
||||||
@@ -326,10 +329,109 @@
|
|||||||
message: `${p.genre} — Bronze, ready to stamp into your ${ppLabel(inst)} passport.`,
|
message: `${p.genre} — Bronze, ready to stamp into your ${ppLabel(inst)} passport.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
badgeCeremony(inst, p);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reducedMotion() {
|
||||||
|
try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (_) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The badge moment: the crowd erupts first (if a venue pack is live —
|
||||||
|
// badges land post-stats:recorded while the player is still on screen),
|
||||||
|
// then a body-level overlay. It CANNOT live in #pp-overlay: #plugin-career
|
||||||
|
// is display:none during playback.
|
||||||
|
function badgeCeremony(inst, p) {
|
||||||
|
// Reduced motion: the chime + fbNotify already delivered the news —
|
||||||
|
// no overlay, and no app-initiated crowd eruption either.
|
||||||
|
if (reducedMotion()) return;
|
||||||
|
const crowd = window.v3VenueCrowd;
|
||||||
|
if (crowd && typeof crowd.celebrate === 'function') {
|
||||||
|
try { crowd.celebrate(); } catch (_) { /* crowd layer optional */ }
|
||||||
|
}
|
||||||
|
if (!document.body || typeof document.createElement !== 'function') return;
|
||||||
|
// Several badges can land in one refresh (first load, drill-snapshot
|
||||||
|
// bootstrap): queue the ceremonies and play them back to back.
|
||||||
|
_ppCeremonyQueue.push({ inst, p });
|
||||||
|
if (!_ppCeremonyActive) setTimeout(drainCeremonies, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drainCeremonies() {
|
||||||
|
if (_ppCeremonyActive) return;
|
||||||
|
const queued = _ppCeremonyQueue.shift();
|
||||||
|
if (!queued) return;
|
||||||
|
_ppCeremonyActive = true;
|
||||||
|
showCeremonyOverlay(queued.inst, queued.p, () => {
|
||||||
|
_ppCeremonyActive = false;
|
||||||
|
setTimeout(drainCeremonies, 250);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCeremonyOverlay(inst, p, done) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.id = 'pp-ceremony';
|
||||||
|
el.className = 'pp-ceremony-overlay';
|
||||||
|
el.innerHTML = `
|
||||||
|
<canvas class="pp-confetti"></canvas>
|
||||||
|
<div class="pp-ceremony-card">
|
||||||
|
<div class="pp-stamp pp-stamp-page pp-ceremony-stamp" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
||||||
|
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
||||||
|
<span class="pp-stamp-tier">BRONZE</span>
|
||||||
|
</div>
|
||||||
|
<div class="pp-ceremony-title">Badge earned</div>
|
||||||
|
<div class="pp-ceremony-sub">${esc(p.genre)} — ${esc(ppLabel(inst))} passport</div>
|
||||||
|
</div>`;
|
||||||
|
let timer = 0;
|
||||||
|
let closed = false;
|
||||||
|
const dismiss = () => {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
el.classList.add('pp-ceremony-out');
|
||||||
|
setTimeout(() => { el.remove(); done(); }, 350);
|
||||||
|
};
|
||||||
|
el.addEventListener('click', dismiss);
|
||||||
|
document.body.appendChild(el);
|
||||||
|
timer = setTimeout(dismiss, 4200);
|
||||||
|
confettiBurst(el.querySelector('.pp-confetti'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function confettiBurst(canvas) {
|
||||||
|
if (!canvas || typeof canvas.getContext !== 'function') return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
canvas.width = canvas.clientWidth;
|
||||||
|
canvas.height = canvas.clientHeight;
|
||||||
|
const colors = ['#d9a253', '#b45309', '#facc15', '#06b6d4', '#e5e7eb'];
|
||||||
|
const parts = Array.from({ length: 42 }, () => ({
|
||||||
|
x: canvas.width / 2 + (Math.random() - 0.5) * 90,
|
||||||
|
y: canvas.height * 0.42,
|
||||||
|
vx: (Math.random() - 0.5) * 9,
|
||||||
|
vy: -(4 + Math.random() * 7),
|
||||||
|
rot: Math.random() * Math.PI,
|
||||||
|
vr: (Math.random() - 0.5) * 0.3,
|
||||||
|
w: 5 + Math.random() * 5,
|
||||||
|
h: 3 + Math.random() * 4,
|
||||||
|
c: colors[(Math.random() * colors.length) | 0],
|
||||||
|
}));
|
||||||
|
let frames = 0;
|
||||||
|
(function tick() {
|
||||||
|
if (!canvas.isConnected || frames++ > 240) return;
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
for (const q of parts) {
|
||||||
|
q.x += q.vx; q.y += q.vy; q.vy += 0.18; q.rot += q.vr;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(q.x, q.y);
|
||||||
|
ctx.rotate(q.rot);
|
||||||
|
ctx.fillStyle = q.c;
|
||||||
|
ctx.fillRect(-q.w / 2, -q.h / 2, q.w, q.h);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
}());
|
||||||
|
}
|
||||||
|
|
||||||
// Relay the Virtuoso drill snapshot (localStorage doc, not the thin bus
|
// Relay the Virtuoso drill snapshot (localStorage doc, not the thin bus
|
||||||
// payload) to the server intake, debounced across event bursts.
|
// payload) to the server intake, debounced across event bursts.
|
||||||
function relayDrillState() {
|
function relayDrillState() {
|
||||||
|
|||||||
@@ -88,6 +88,33 @@ test('detectNewBadges notifies once per badge, never after it is seen', () => {
|
|||||||
assert.equal(w2.notifications.length, 0);
|
assert.equal(w2.notifications.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a new badge triggers the crowd celebrate() exactly once', () => {
|
||||||
|
const w = load();
|
||||||
|
let calls = 0;
|
||||||
|
w.v3VenueCrowd = { celebrate: () => { calls += 1; } };
|
||||||
|
const view = { instruments: { guitar: { passports: [
|
||||||
|
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } };
|
||||||
|
w.__careerPassportTest.detectNewBadges(view);
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
// Same session, same view: no re-celebration.
|
||||||
|
w.__careerPassportTest.detectNewBadges(view);
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ceremony degrades when the crowd layer is absent or throws', () => {
|
||||||
|
const w = load();
|
||||||
|
const view = { instruments: { guitar: { passports: [
|
||||||
|
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } };
|
||||||
|
// No v3VenueCrowd at all (already exercised elsewhere, explicit here).
|
||||||
|
w.__careerPassportTest.detectNewBadges(view);
|
||||||
|
assert.equal(w.notifications.length, 1);
|
||||||
|
// celebrate() throwing must not break detection.
|
||||||
|
const w2 = load();
|
||||||
|
w2.v3VenueCrowd = { celebrate: () => { throw new Error('no pack'); } };
|
||||||
|
w2.__careerPassportTest.detectNewBadges(view);
|
||||||
|
assert.equal(w2.notifications.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
test('seenBadges tolerates corrupt stored values', () => {
|
test('seenBadges tolerates corrupt stored values', () => {
|
||||||
for (const bad of ['null', '[1,2]', '"x"', '{{{']) {
|
for (const bad of ['null', '[1,2]', '"x"', '{{{']) {
|
||||||
const w = load({ 'feedBack-career-badges-seen': bad });
|
const w = load({ 'feedBack-career-badges-seen': bad });
|
||||||
|
|||||||
@@ -59,6 +59,15 @@
|
|||||||
candidate = null;
|
candidate = null;
|
||||||
lastSwitchAt = -Infinity;
|
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
|
// Feed the latest perf state; returns the new crowd state when a
|
||||||
// transition commits, else null.
|
// transition commits, else null.
|
||||||
update(perfState, nowMs) {
|
update(perfState, nowMs) {
|
||||||
@@ -596,6 +605,26 @@
|
|||||||
if (dev && !_manifest) setManifest(dev);
|
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() {
|
function getState() {
|
||||||
return {
|
return {
|
||||||
venueActive: _venueActive,
|
venueActive: _venueActive,
|
||||||
@@ -621,6 +650,7 @@
|
|||||||
setVenueActive,
|
setVenueActive,
|
||||||
bindRuntime,
|
bindRuntime,
|
||||||
getState,
|
getState,
|
||||||
|
celebrate,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (root) root.v3VenueCrowd = api;
|
if (root) root.v3VenueCrowd = api;
|
||||||
|
|||||||
@@ -128,3 +128,22 @@ test('venue-scene-3d activates/deactivates the crowd layer', () => {
|
|||||||
assert.match(src, /syncCrowd\(false\)/);
|
assert.match(src, /syncCrowd\(false\)/);
|
||||||
assert.match(src, /v3VenueCrowd/);
|
assert.match(src, /v3VenueCrowd/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('machine.force commits instantly and dwell holds the forced state', () => {
|
||||||
|
const m = crowd.createCrowdMachine();
|
||||||
|
m.force('ecstatic', 100000);
|
||||||
|
assert.equal(m.current, 'ecstatic');
|
||||||
|
// The real perf state cannot reassert until the dwell window passes.
|
||||||
|
m.update('smoke', 100000 + crowd.STABLE_MS);
|
||||||
|
assert.equal(m.update('smoke', 100000 + crowd.DWELL_MS - 1), null);
|
||||||
|
assert.equal(m.current, 'ecstatic');
|
||||||
|
assert.equal(m.update('smoke', 100000 + crowd.DWELL_MS), 'bored');
|
||||||
|
// Bogus states are ignored.
|
||||||
|
m.force('confused', 200000);
|
||||||
|
assert.equal(m.current, 'bored');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('celebrate() is exported and no-ops without a manifest/active venue', () => {
|
||||||
|
assert.equal(typeof crowd.celebrate, 'function');
|
||||||
|
assert.equal(crowd.celebrate(), false);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user