diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e68fdf..0d6f57d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### 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. New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument passport walls: genre badges computed on read from `song_stats` × the library's diff --git a/plugins/career/assets/career.css b/plugins/career/assets/career.css index b6c09b9..7859dea 100644 --- a/plugins/career/assets/career.css +++ b/plugins/career/assets/career.css @@ -409,3 +409,60 @@ .pp-slam, .pp-stamp-page::after { opacity: 1; } .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); } diff --git a/plugins/career/screen.js b/plugins/career/screen.js index a6ffe88..0dd8423 100644 --- a/plugins/career/screen.js +++ b/plugins/career/screen.js @@ -34,6 +34,8 @@ let _ppRelayTimer = 0; let _ppBook = null; // {inst, gkey} of the open spread let _ppReturnFocus = null; // element to refocus when the book closes + let _ppCeremonyQueue = []; // badges awaiting their ceremony overlay + let _ppCeremonyActive = false; let _ppBootstrapped = false; let _ppNotified = {}; // badges chimed this session (slam still pending) @@ -308,9 +310,10 @@ lsSet(PP_SEEN_KEY, JSON.stringify(seen)); } - // New badge → chime + notification once per session; the stamp SLAM plays - // when the passport is next opened (and only then is the badge marked - // seen, so a pending slam survives a reload). + // New badge → chime + notification + the venue ceremony, once per + // session; the stamp SLAM plays when the passport is next opened (and + // only then is the badge marked seen, so a pending slam survives a + // reload). function detectNewBadges(view) { const seen = seenBadges(); for (const inst of Object.keys(view.instruments || {})) { @@ -326,10 +329,109 @@ 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 = ` + +
+
+ ${esc(p.genre.toUpperCase())} + BRONZE +
+
Badge earned
+
${esc(p.genre)} — ${esc(ppLabel(inst))} passport
+
`; + 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 // payload) to the server intake, debounced across event bursts. function relayDrillState() { diff --git a/plugins/career/tests/passports.test.js b/plugins/career/tests/passports.test.js index dc39963..94c3fd8 100644 --- a/plugins/career/tests/passports.test.js +++ b/plugins/career/tests/passports.test.js @@ -88,6 +88,33 @@ test('detectNewBadges notifies once per badge, never after it is seen', () => { 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', () => { for (const bad of ['null', '[1,2]', '"x"', '{{{']) { const w = load({ 'feedBack-career-badges-seen': bad }); diff --git a/static/v3/venue-crowd.js b/static/v3/venue-crowd.js index aae8453..dea8789 100644 --- a/static/v3/venue-crowd.js +++ b/static/v3/venue-crowd.js @@ -59,6 +59,15 @@ 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) { @@ -596,6 +605,26 @@ 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, @@ -621,6 +650,7 @@ setVenueActive, bindRuntime, getState, + celebrate, }; if (root) root.v3VenueCrowd = api; diff --git a/tests/js/venue_crowd.test.js b/tests/js/venue_crowd.test.js index 301ae37..50a73b4 100644 --- a/tests/js/venue_crowd.test.js +++ b/tests/js/venue_crowd.test.js @@ -128,3 +128,22 @@ test('venue-scene-3d activates/deactivates the crowd layer', () => { assert.match(src, /syncCrowd\(false\)/); 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); +});