mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-24 05:41:53 +00:00
1113 lines
49 KiB
JavaScript
1113 lines
49 KiB
JavaScript
// slopsmith-plugin-minigames — SDK + hub controller.
|
|
//
|
|
// This file does two things:
|
|
// 1) Publishes window.slopsmithMinigames — the SDK that individual
|
|
// minigame plugins call (register, start/end, scoring, ui, persistence).
|
|
// 2) Mounts a hub UI in screen.html that lists every registered minigame
|
|
// and the shared profile/leaderboards.
|
|
//
|
|
// Plugin load order is alphabetical, so minigame plugins (e.g. flappy_bend)
|
|
// load BEFORE this script. They should register via a tiny shim that queues
|
|
// to `window.__slopsmithMinigamesPending` if the SDK isn't up yet — we drain
|
|
// the queue on init and also fire `slopsmith-minigames-ready` once ready.
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
if (window.slopsmithMinigames && window.slopsmithMinigames.__alive) {
|
|
return; // hot-reload guard
|
|
}
|
|
|
|
const PLUGIN_ID = 'minigames';
|
|
const API_BASE = `/api/plugins/${PLUGIN_ID}`;
|
|
|
|
// ── YIN pitch tracker ─────────────────────────────────────────────────
|
|
// Compact YIN (de Cheveigné & Kawahara, 2002). Sufficient for monophonic
|
|
// electric-guitar pitch tracking down to ~80 Hz. Returns:
|
|
// { freqHz, confidence } — confidence in [0, 1]
|
|
// or null if no confident pitch was found in the buffer.
|
|
// scratchD / scratchCmnd are optional preallocated Float32Arrays of length
|
|
// ≥ halfN. Pass them from the createContinuous closure to avoid per-callback
|
|
// allocations. If omitted, yinDetect allocates its own (slower but safe).
|
|
function yinDetect(buf, sampleRate, opts, scratchD, scratchCmnd) {
|
|
const threshold = (opts && opts.threshold) || 0.15;
|
|
const minHz = (opts && opts.minHz) || 70;
|
|
const maxHz = (opts && opts.maxHz) || 1500;
|
|
const N = buf.length;
|
|
const halfN = (N / 2) | 0;
|
|
const tauMin = Math.max(2, Math.floor(sampleRate / maxHz));
|
|
const tauMax = Math.min(halfN - 1, Math.floor(sampleRate / minHz));
|
|
|
|
// Difference function d(τ). Only compute up to tauMax — values beyond
|
|
// tauMax can never be selected, so computing them is wasteful O(N^2) work.
|
|
// Reuse scratch buffer if provided.
|
|
const d = (scratchD && scratchD.length >= halfN) ? scratchD : new Float32Array(halfN);
|
|
for (let tau = 1; tau <= tauMax; tau++) {
|
|
let sum = 0;
|
|
for (let i = 0; i < halfN; i++) {
|
|
const diff = buf[i] - buf[i + tau];
|
|
sum += diff * diff;
|
|
}
|
|
d[tau] = sum;
|
|
}
|
|
// Cumulative mean normalized difference (CMND). Only compute up to tauMax
|
|
// since the threshold search only reads cmnd[tauMin..tauMax]. Reuse scratch.
|
|
const cmnd = (scratchCmnd && scratchCmnd.length >= halfN) ? scratchCmnd : new Float32Array(halfN);
|
|
cmnd[0] = 1;
|
|
let running = 0;
|
|
for (let tau = 1; tau <= tauMax; tau++) {
|
|
running += d[tau];
|
|
cmnd[tau] = (d[tau] * tau) / (running || 1);
|
|
}
|
|
|
|
// Absolute threshold — first τ where CMND drops below threshold AND
|
|
// is a local minimum.
|
|
let tauEstimate = -1;
|
|
for (let tau = tauMin; tau <= tauMax; tau++) {
|
|
if (cmnd[tau] < threshold) {
|
|
while (tau + 1 <= tauMax && cmnd[tau + 1] < cmnd[tau]) tau++;
|
|
tauEstimate = tau;
|
|
break;
|
|
}
|
|
}
|
|
if (tauEstimate < 0) return null;
|
|
|
|
// Parabolic interpolation around the minimum for sub-sample τ.
|
|
let betterTau = tauEstimate;
|
|
if (tauEstimate > 0 && tauEstimate < tauMax) {
|
|
const s0 = cmnd[tauEstimate - 1];
|
|
const s1 = cmnd[tauEstimate];
|
|
const s2 = cmnd[tauEstimate + 1];
|
|
const denom = 2 * (s0 - 2 * s1 + s2);
|
|
if (denom !== 0) {
|
|
betterTau = tauEstimate + (s0 - s2) / denom;
|
|
}
|
|
}
|
|
|
|
const freqHz = sampleRate / betterTau;
|
|
if (freqHz < minHz || freqHz > maxHz) return null;
|
|
// Confidence = 1 - CMND at the chosen τ, clamped.
|
|
const conf = Math.max(0, Math.min(1, 1 - cmnd[tauEstimate]));
|
|
return { freqHz, confidence: conf };
|
|
}
|
|
|
|
// ── scoring.createContinuous ──────────────────────────────────────────
|
|
// Self-contained (does not use createNoteDetector). Opens its own
|
|
// getUserMedia stream and runs YIN on a 2048-sample window. Emits
|
|
// 'pitch' events with { freqHz, midiFloat, cents, confidence, tMs,
|
|
// expectedBaseFreqHz }. `cents` is relative to expectedBaseFreqHz if
|
|
// provided, otherwise relative to freqHz itself (so always 0).
|
|
function createContinuous(opts) {
|
|
// Coerce expectedBaseFreqHz to a finite positive number, or null.
|
|
// A truthy-but-invalid value (NaN, Infinity, negative, string) would make
|
|
// Math.log2(freq / expectedBaseFreqHz) return NaN and corrupt every emitted
|
|
// pitch event.
|
|
const _rawBase = opts && opts.expectedBaseFreqHz;
|
|
const expectedBaseFreqHz = (typeof _rawBase === 'number' && isFinite(_rawBase) && _rawBase > 0)
|
|
? _rawBase
|
|
: null;
|
|
const rawSmoothing = opts && opts.smoothingMs;
|
|
const smoothingMs = (typeof rawSmoothing === 'number' && isFinite(rawSmoothing) && rawSmoothing > 0)
|
|
? rawSmoothing
|
|
: 30;
|
|
const handlers = { pitch: [], end: [] };
|
|
let audioCtx = null;
|
|
let mediaStream = null;
|
|
let source = null;
|
|
let processor = null;
|
|
let stopped = false;
|
|
let lastFreq = 0;
|
|
let lastConf = 0;
|
|
let startError = null;
|
|
const ringSize = 2048;
|
|
const ring = new Float32Array(ringSize);
|
|
// Preallocated buffers for YIN — reused every audio callback to avoid
|
|
// per-callback GC pressure from Float32Array allocations inside yinDetect.
|
|
const yinWindow = new Float32Array(ringSize);
|
|
const yinHalfN = ringSize >> 1; // 1024 — matches halfN in yinDetect
|
|
const yinD = new Float32Array(yinHalfN);
|
|
const yinCmnd = new Float32Array(yinHalfN);
|
|
let ringWrite = 0;
|
|
// Desktop-engine bridge path. On slopsmith-desktop the native JUCE engine
|
|
// owns the input device (often an exclusive ASIO device the browser's
|
|
// getUserMedia can't see), so a renderer getUserMedia stream lands on the
|
|
// wrong/silent Windows-default device. When the bridge is present we pull
|
|
// post-noise-gate frames from the engine instead — same device the player
|
|
// and note_detect read from. getUserMedia stays as the web fallback.
|
|
let bridgePoll = null; // setTimeout handle for the engine poll loop
|
|
let usingBridge = false;
|
|
let bridgeGotFrame = false; // first non-empty frame seen (vs downlevel addon)
|
|
let bridgeSampleRate = 48000; // queried once from the engine
|
|
|
|
const handle = {
|
|
on(event, cb) { (handlers[event] || (handlers[event] = [])).push(cb); return handle; },
|
|
stop,
|
|
isRunning: () => !stopped && (!!audioCtx || usingBridge),
|
|
};
|
|
|
|
function emit(event, payload) {
|
|
(handlers[event] || []).forEach(cb => { try { cb(payload); } catch (e) { console.error(e); } });
|
|
}
|
|
|
|
// Shared analysis used by both the mic and engine-bridge sources. `win` is a
|
|
// ringSize-length Float32Array (oldest→newest); `dtMs` is the elapsed time
|
|
// since the previous frame, driving the log-freq EMA smoothing. RMS-gate →
|
|
// YIN → smooth → emit('pitch'). Keeping this single function guarantees the
|
|
// two sources emit byte-identical frame shapes to consumers.
|
|
function analyzeWindow(win, sampleRate, dtMs) {
|
|
let rms = 0;
|
|
for (let i = 0; i < ringSize; i++) rms += win[i] * win[i];
|
|
rms = Math.sqrt(rms / ringSize);
|
|
// -60 dBFS RMS gate (≈ 0.001 full-scale): on silence YIN's difference
|
|
// function collapses and reports a spurious high-freq lock, so skip it.
|
|
const res = (rms > 0.001)
|
|
? yinDetect(win, sampleRate, { minHz: 70, maxHz: 1500 }, yinD, yinCmnd)
|
|
: null;
|
|
const nowMs = performance.now();
|
|
if (res && res.confidence > 0.3) {
|
|
const alpha = Math.min(1, (dtMs > 0 ? dtMs : 1) / smoothingMs);
|
|
if (lastFreq > 0) {
|
|
lastFreq = Math.exp(Math.log(lastFreq) * (1 - alpha) + Math.log(res.freqHz) * alpha);
|
|
} else {
|
|
lastFreq = res.freqHz;
|
|
}
|
|
lastConf = res.confidence;
|
|
} else {
|
|
// Fade confidence rather than zeroing — lets the consumer gate "no
|
|
// input" on confidence falling below its own threshold.
|
|
lastConf *= 0.85;
|
|
}
|
|
const freq = lastFreq;
|
|
const midiFloat = freq > 0 ? 69 + 12 * Math.log2(freq / 440) : 0;
|
|
const cents = (expectedBaseFreqHz && freq > 0)
|
|
? 1200 * Math.log2(freq / expectedBaseFreqHz)
|
|
: 0;
|
|
emit('pitch', {
|
|
freqHz: freq,
|
|
midiFloat,
|
|
cents,
|
|
confidence: lastConf,
|
|
tMs: nowMs,
|
|
expectedBaseFreqHz,
|
|
});
|
|
}
|
|
|
|
// getUserMedia source — used on the web build, and on desktop only when the
|
|
// engine bridge is absent. NOTE: on desktop this lands on the Windows-default
|
|
// device, which is usually NOT the guitar (see the bridge path above); the
|
|
// dispatcher in start() prefers the bridge for that reason.
|
|
async function startMic() {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false },
|
|
video: false,
|
|
});
|
|
if (stopped) {
|
|
stream.getTracks().forEach(t => t.stop());
|
|
return;
|
|
}
|
|
mediaStream = stream;
|
|
if (stopped) {
|
|
stream.getTracks().forEach(t => t.stop());
|
|
return;
|
|
}
|
|
const AC = window.AudioContext || window.webkitAudioContext;
|
|
audioCtx = new AC();
|
|
// Some browsers (Chrome, Safari) start AudioContexts in "suspended"
|
|
// state when constructed outside a direct user-gesture call stack.
|
|
// resume() must be called explicitly; ignore errors (e.g. the context
|
|
// is already running — not an error state we need to surface).
|
|
await audioCtx.resume().catch(() => {});
|
|
if (stopped) {
|
|
audioCtx.close().catch(() => {});
|
|
audioCtx = null;
|
|
stream.getTracks().forEach(t => t.stop());
|
|
return;
|
|
}
|
|
source = audioCtx.createMediaStreamSource(stream);
|
|
// ScriptProcessor is deprecated but universally supported and lets
|
|
// us pull raw frames synchronously — fine for v1. Migration to
|
|
// AudioWorklet is straightforward later.
|
|
const bufSize = 1024;
|
|
processor = audioCtx.createScriptProcessor(bufSize, 1, 1);
|
|
processor.onaudioprocess = (e) => {
|
|
if (stopped) return; // race: stop() called while onaudioprocess was queued
|
|
const inBuf = e.inputBuffer.getChannelData(0);
|
|
// Copy into ring.
|
|
for (let i = 0; i < inBuf.length; i++) {
|
|
ring[ringWrite] = inBuf[i];
|
|
ringWrite = (ringWrite + 1) % ringSize;
|
|
}
|
|
// Build a contiguous window for YIN: from oldest sample to newest.
|
|
// Write into the preallocated yinWindow to avoid per-callback allocs.
|
|
for (let i = 0; i < ringSize; i++) {
|
|
yinWindow[i] = ring[(ringWrite + i) % ringSize];
|
|
}
|
|
// Mic frame interval ≈ bufSize/sampleRate; feed it as the EMA dt.
|
|
analyzeWindow(yinWindow, audioCtx.sampleRate, bufSize / audioCtx.sampleRate * 1000);
|
|
};
|
|
source.connect(processor);
|
|
// Must connect processor → destination for onaudioprocess to fire.
|
|
// Route through a muted gain node so we don't actually output mic.
|
|
const mute = audioCtx.createGain();
|
|
mute.gain.value = 0;
|
|
processor.connect(mute);
|
|
mute.connect(audioCtx.destination);
|
|
} catch (err) {
|
|
console.error('[minigames] continuous scoring failed to start:', err);
|
|
// Do NOT emit 'end' here — stop() will emit one. Tagging the
|
|
// stop() reason via a captured variable keeps subscribers from
|
|
// seeing two events for the same shutdown.
|
|
startError = err;
|
|
stop();
|
|
}
|
|
}
|
|
|
|
function stop() {
|
|
if (stopped) return;
|
|
stopped = true;
|
|
if (bridgePoll) { try { clearTimeout(bridgePoll); } catch (e) {} bridgePoll = null; }
|
|
usingBridge = false;
|
|
try { if (processor) processor.disconnect(); } catch (e) {}
|
|
try { if (source) source.disconnect(); } catch (e) {}
|
|
try { if (mediaStream) mediaStream.getTracks().forEach(t => t.stop()); } catch (e) {}
|
|
try { if (audioCtx) audioCtx.close().catch(() => {}); } catch (e) {}
|
|
audioCtx = mediaStream = source = processor = null;
|
|
emit('end', startError
|
|
? { reason: 'error', error: startError }
|
|
: { reason: 'stopped' });
|
|
}
|
|
|
|
// Desktop source: pull post-noise-gate frames from the native JUCE engine
|
|
// (the device the user actually plays through) and run the same YIN. Polled
|
|
// via a self-rescheduling setTimeout — NOT setInterval — so a slow IPC
|
|
// round-trip can't stack callbacks. Falls back to the mic path if the addon
|
|
// is downlevel (getRawAudioFrame resolves an empty array).
|
|
async function startBridge(audio) {
|
|
try {
|
|
// The minigame needs live input; make sure the engine is capturing.
|
|
if (typeof audio.isAudioRunning === 'function') {
|
|
const running = await audio.isAudioRunning();
|
|
if (!running && typeof audio.startAudio === 'function') await audio.startAudio();
|
|
}
|
|
} catch (e) { /* best-effort; poll anyway */ }
|
|
try {
|
|
const sr = await audio.getSampleRate();
|
|
if (typeof sr === 'number' && sr > 0) bridgeSampleRate = sr;
|
|
} catch (e) { /* keep 48000 default */ }
|
|
if (stopped) return;
|
|
usingBridge = true;
|
|
let lastT = performance.now();
|
|
const bridgeStartT = performance.now();
|
|
const POLL_MS = 25; // ~40 Hz — matches the mic path's cadence
|
|
const BRIDGE_WARMUP_MS = 1500; // grace for startAudio()/device spin-up
|
|
// before concluding there's no frame tap
|
|
|
|
const tick = async () => {
|
|
if (stopped) return;
|
|
let frame = null;
|
|
try { frame = await audio.getRawAudioFrame(ringSize); } catch (e) { /* transient IPC hiccup */ }
|
|
if (stopped) return;
|
|
if (!frame || frame.length === 0) {
|
|
// Empty/failed poll. startAudio() above may still be spinning the
|
|
// input device up, so the first frames can legitimately be empty —
|
|
// only conclude the addon has no engine frame tap (downlevel) after a
|
|
// warm-up grace during which no frame ever arrived. A thrown poll is
|
|
// transient and simply retries until that grace elapses.
|
|
if (!bridgeGotFrame && (performance.now() - bridgeStartT) > BRIDGE_WARMUP_MS) {
|
|
usingBridge = false;
|
|
console.warn('[minigames] engine frame tap unavailable — using getUserMedia');
|
|
startMic();
|
|
return;
|
|
}
|
|
} else {
|
|
bridgeGotFrame = true;
|
|
// Copy the newest ringSize samples into yinWindow (left-zero-pad if the
|
|
// engine handed back fewer, e.g. a cold-start partial ring).
|
|
const n = frame.length;
|
|
if (n >= ringSize) {
|
|
for (let i = 0; i < ringSize; i++) yinWindow[i] = frame[n - ringSize + i];
|
|
} else {
|
|
const pad = ringSize - n;
|
|
for (let i = 0; i < pad; i++) yinWindow[i] = 0;
|
|
for (let i = 0; i < n; i++) yinWindow[pad + i] = frame[i];
|
|
}
|
|
const now = performance.now();
|
|
const dt = now - lastT;
|
|
lastT = now;
|
|
analyzeWindow(yinWindow, bridgeSampleRate, dt);
|
|
}
|
|
// Guard the reschedule: a consumer's 'pitch' handler can call stop()
|
|
// synchronously inside analyzeWindow, so re-check before re-arming.
|
|
if (!stopped) bridgePoll = setTimeout(tick, POLL_MS);
|
|
};
|
|
tick();
|
|
}
|
|
|
|
// Prefer the desktop engine bridge (correct, user-configured input device);
|
|
// fall back to getUserMedia on the web build or a downlevel addon.
|
|
function start() {
|
|
const audio = window.slopsmithDesktop && window.slopsmithDesktop.audio;
|
|
if (audio && typeof audio.getRawAudioFrame === 'function') {
|
|
startBridge(audio);
|
|
} else {
|
|
startMic();
|
|
}
|
|
}
|
|
|
|
start();
|
|
return handle;
|
|
}
|
|
|
|
// ── scoring.createDiscrete / createChord ──────────────────────────────
|
|
// Both wrap window.createNoteDetector from slopsmith-plugin-notedetect.
|
|
// For v1 they are thin event re-emitters — minigames using them must
|
|
// run alongside a chart (createNoteDetector needs a highway). Chart-free
|
|
// discrete scoring is out of scope until the scoring-core extraction
|
|
// PR lands.
|
|
function _wrapNoteDetector(opts) {
|
|
const handlers = { hit: [], miss: [], end: [] };
|
|
const fn = window.createNoteDetector;
|
|
if (typeof fn !== 'function') {
|
|
console.warn('[minigames] window.createNoteDetector unavailable — install slopsmith-plugin-notedetect for discrete/chord scoring.');
|
|
let _unavailStopped = false;
|
|
return {
|
|
on(event, cb) { (handlers[event] || (handlers[event] = [])).push(cb); return this; },
|
|
stop() {
|
|
if (_unavailStopped) return;
|
|
_unavailStopped = true;
|
|
handlers.end.forEach(cb => { try { cb({ reason: 'unavailable' }); } catch (e) { console.error(e); } });
|
|
},
|
|
isRunning: () => false,
|
|
};
|
|
}
|
|
const inst = fn(opts || {});
|
|
const root = (typeof inst.getRoot === 'function') ? inst.getRoot() : window;
|
|
const onHit = (e) => handlers.hit.forEach(cb => { try { cb(e.detail); } catch (err) { console.error(err); } });
|
|
const onMiss = (e) => handlers.miss.forEach(cb => { try { cb(e.detail); } catch (err) { console.error(err); } });
|
|
root.addEventListener('notedetect:hit', onHit);
|
|
root.addEventListener('notedetect:miss', onMiss);
|
|
let stopped = false;
|
|
if (typeof inst.enable === 'function') {
|
|
try { inst.enable(); } catch (e) { console.error(e); }
|
|
}
|
|
return {
|
|
on(event, cb) { (handlers[event] || (handlers[event] = [])).push(cb); return this; },
|
|
stop() {
|
|
if (stopped) return;
|
|
stopped = true;
|
|
root.removeEventListener('notedetect:hit', onHit);
|
|
root.removeEventListener('notedetect:miss', onMiss);
|
|
try { inst.destroy && inst.destroy(); } catch (e) {}
|
|
handlers.end.forEach(cb => { try { cb({ reason: 'stopped' }); } catch (e) { console.error(e); } });
|
|
},
|
|
isRunning: () => !stopped,
|
|
noteDetector: inst,
|
|
};
|
|
}
|
|
|
|
// ── UI helpers ────────────────────────────────────────────────────────
|
|
function mountHUD(content) {
|
|
const hud = document.getElementById('mg-stage-hud');
|
|
if (!hud) return;
|
|
if (content instanceof Node) {
|
|
hud.innerHTML = '';
|
|
hud.appendChild(content);
|
|
} else if (typeof content === 'string') {
|
|
hud.innerHTML = content;
|
|
}
|
|
}
|
|
|
|
function runSummary(result) {
|
|
const root = document.getElementById('mg-summary');
|
|
if (!root) return;
|
|
const spec = registered.get(result.gameId);
|
|
// Prefer the caller-supplied resolvedTitle (manifest-resolved) over the
|
|
// JS spec title so minigames that register a minimal spec still show the
|
|
// correct display name in the post-game summary.
|
|
document.getElementById('mg-summary-game').textContent =
|
|
result.resolvedTitle || (spec ? spec.title || spec.id : '') || (result.gameId || '');
|
|
document.getElementById('mg-summary-score').textContent = String(Math.floor(Math.max(0, Number(result.score) || 0)));
|
|
document.getElementById('mg-summary-xp').textContent = '+' + String(Math.floor(Math.max(0, Number(result.xpGained) || 0))) + ' dB';
|
|
document.getElementById('mg-summary-best').textContent = String(Math.floor(Math.max(0, Number(result.best) || 0)));
|
|
|
|
const extra = document.getElementById('mg-summary-extra');
|
|
if (result.extra) {
|
|
// Trust boundary: result.extra (summaryHtml) is produced by the game
|
|
// plugin's own end() call — it is first-party, same-origin code, not
|
|
// user-supplied content. innerHTML is intentional here so games can
|
|
// render formatted post-run stats (tables, bold numbers, etc.). If this
|
|
// field ever flows from an untrusted source we must sanitize first.
|
|
extra.innerHTML = result.extra;
|
|
} else {
|
|
extra.textContent = '';
|
|
}
|
|
|
|
root.classList.remove('hidden');
|
|
|
|
const closeBtn = document.getElementById('mg-summary-close');
|
|
const againBtn = document.getElementById('mg-summary-again');
|
|
|
|
const closeSummary = () => {
|
|
root.classList.add('hidden');
|
|
document.removeEventListener('keydown', onKey);
|
|
// Clear the module-level reference so teardownActiveSession() knows
|
|
// there is no stale keydown listener left to clean up.
|
|
_summaryCleanup = null;
|
|
};
|
|
const onKey = (e) => {
|
|
if (e.key === 'Escape') { closeSummary(); renderHub(); }
|
|
};
|
|
document.addEventListener('keydown', onKey);
|
|
// Register the cleanup so teardownActiveSession() can remove the listener
|
|
// if the user navigates away while the summary is visible.
|
|
_summaryCleanup = closeSummary;
|
|
// Move focus into the modal for keyboard/screen-reader users.
|
|
if (closeBtn) setTimeout(() => closeBtn.focus(), 0);
|
|
|
|
closeBtn.onclick = () => {
|
|
closeSummary();
|
|
// Re-render hub so updated XP / best appears.
|
|
renderHub();
|
|
};
|
|
againBtn.onclick = () => {
|
|
closeSummary();
|
|
if (spec) start(spec.id, result.lastOpts || {});
|
|
else renderHub();
|
|
};
|
|
}
|
|
|
|
// Modifier picker — modal with one row per modifier from the spec.
|
|
// Returns a Promise that resolves to {modifiers} or rejects on cancel.
|
|
function modifierPicker(spec) {
|
|
return new Promise((resolve, reject) => {
|
|
const root = document.getElementById('mg-picker');
|
|
if (!root) { reject(new Error('picker DOM missing')); return; }
|
|
document.getElementById('mg-picker-title').textContent = spec.title || spec.id;
|
|
document.getElementById('mg-picker-tagline').textContent = spec.tagline || '';
|
|
const body = document.getElementById('mg-picker-body');
|
|
body.innerHTML = '';
|
|
|
|
const selected = Object.create(null);
|
|
(spec.modifiers || []).forEach(mod => {
|
|
selected[mod.id] = mod.default;
|
|
const row = document.createElement('div');
|
|
row.innerHTML = `
|
|
<div class="text-xs uppercase tracking-widest text-fb-textDim mb-2">${escapeHtml(mod.label || mod.id)}</div>
|
|
<div class="flex gap-2 flex-wrap" data-mod-id="${escapeHtml(mod.id)}"></div>
|
|
`;
|
|
const btnRow = row.querySelector('[data-mod-id]');
|
|
(mod.values || []).forEach(v => {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'px-3 py-1.5 rounded text-sm bg-fb-bg/60 text-fb-textDim hover:bg-fb-border';
|
|
btn.textContent = String(v);
|
|
btn.dataset.value = String(v);
|
|
if (v === mod.default) btn.classList.add('!bg-accent', '!text-white');
|
|
btn.onclick = () => {
|
|
selected[mod.id] = v;
|
|
btnRow.querySelectorAll('button').forEach(b => b.classList.remove('!bg-accent', '!text-white'));
|
|
btn.classList.add('!bg-accent', '!text-white');
|
|
};
|
|
btnRow.appendChild(btn);
|
|
});
|
|
body.appendChild(row);
|
|
});
|
|
|
|
// Track selector for chart-free games that ship tracks.
|
|
if (Array.isArray(spec.availableTracks) && spec.availableTracks.length) {
|
|
selected.__track = spec.availableTracks[0].id;
|
|
const row = document.createElement('div');
|
|
row.innerHTML = `
|
|
<div class="text-xs uppercase tracking-widest text-fb-textDim mb-2">Track</div>
|
|
<div class="flex gap-2 flex-wrap" data-mod-id="__track"></div>
|
|
`;
|
|
const btnRow = row.querySelector('[data-mod-id]');
|
|
spec.availableTracks.forEach((t, i) => {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'px-3 py-1.5 rounded text-sm bg-fb-bg/60 text-fb-textDim hover:bg-fb-border';
|
|
btn.textContent = t.title || t.id;
|
|
if (i === 0) btn.classList.add('!bg-accent', '!text-white');
|
|
btn.onclick = () => {
|
|
selected.__track = t.id;
|
|
btnRow.querySelectorAll('button').forEach(b => b.classList.remove('!bg-accent', '!text-white'));
|
|
btn.classList.add('!bg-accent', '!text-white');
|
|
};
|
|
btnRow.appendChild(btn);
|
|
});
|
|
body.appendChild(row);
|
|
}
|
|
|
|
root.classList.remove('hidden');
|
|
|
|
const cancelBtn = document.getElementById('mg-picker-cancel');
|
|
const startBtn = document.getElementById('mg-picker-start');
|
|
|
|
const onKey = (e) => {
|
|
if (e.key === 'Escape') { cleanup(); reject(new Error('cancelled')); }
|
|
};
|
|
const cleanup = () => {
|
|
root.classList.add('hidden');
|
|
document.removeEventListener('keydown', onKey);
|
|
};
|
|
document.addEventListener('keydown', onKey);
|
|
// Move focus into the modal so keyboard/screen-reader users can proceed.
|
|
if (startBtn) setTimeout(() => startBtn.focus(), 0);
|
|
|
|
cancelBtn.onclick = () => { cleanup(); reject(new Error('cancelled')); };
|
|
// Convert the null-proto `selected` map into a plain object. Using
|
|
// Object.fromEntries avoids the Object.assign({}, ...) footgun where
|
|
// a modifier id of '__proto__' would invoke the setter on the target
|
|
// and mutate its prototype — fromEntries always creates own data props.
|
|
startBtn.onclick = () => {
|
|
cleanup();
|
|
resolve({ modifiers: Object.fromEntries(Object.entries(selected)) });
|
|
};
|
|
});
|
|
}
|
|
|
|
// ── Persistence client ────────────────────────────────────────────────
|
|
async function submitRun(payload) {
|
|
const r = await fetch(`${API_BASE}/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!r.ok) throw new Error(`submitRun failed: ${r.status}`);
|
|
return r.json();
|
|
}
|
|
async function getLeaderboard(gameId, opts) {
|
|
const params = new URLSearchParams();
|
|
if (gameId) params.set('game_id', gameId);
|
|
if (opts && opts.scope) params.set('scope', opts.scope);
|
|
if (opts && opts.limit) params.set('limit', String(opts.limit));
|
|
const r = await fetch(`${API_BASE}/runs?${params}`);
|
|
if (!r.ok) throw new Error(`getLeaderboard failed: ${r.status}`);
|
|
return r.json();
|
|
}
|
|
async function getProfile() {
|
|
const r = await fetch(`${API_BASE}/profile`);
|
|
if (!r.ok) throw new Error(`getProfile failed: ${r.status}`);
|
|
return r.json();
|
|
}
|
|
async function resetProfile() {
|
|
const r = await fetch(`${API_BASE}/profile/reset`, { method: 'POST' });
|
|
if (!r.ok) throw new Error(`resetProfile failed: ${r.status}`);
|
|
return r.json();
|
|
}
|
|
async function getServerRegistry() {
|
|
const r = await fetch(`${API_BASE}/registry`);
|
|
if (!r.ok) throw new Error(`registry failed: ${r.status}`);
|
|
return r.json();
|
|
}
|
|
|
|
// ── Scheduler (AudioContext-relative would be ideal; v1 uses perf.now) ─
|
|
const _timers = new Set();
|
|
const scheduler = {
|
|
every(ms, cb) {
|
|
const id = setInterval(cb, ms);
|
|
_timers.add(id);
|
|
return id;
|
|
},
|
|
in(ms, cb) {
|
|
const id = setTimeout(() => { _timers.delete(id); cb(); }, ms);
|
|
_timers.add(id);
|
|
return id;
|
|
},
|
|
cancel(id) {
|
|
clearTimeout(id);
|
|
clearInterval(id);
|
|
_timers.delete(id);
|
|
},
|
|
now: () => performance.now(),
|
|
};
|
|
|
|
// ── Registry ──────────────────────────────────────────────────────────
|
|
const registered = new Map();
|
|
function register(spec) {
|
|
if (!spec || typeof spec.id !== 'string' || !spec.id) {
|
|
console.warn('[minigames] register() called with missing or non-string spec.id');
|
|
return;
|
|
}
|
|
if (typeof spec.start !== 'function') {
|
|
console.warn('[minigames] register(%s): spec.start must be a function', spec.id);
|
|
return;
|
|
}
|
|
registered.set(spec.id, spec);
|
|
// If the hub is mounted, re-render. Otherwise it'll pick this up on next mount.
|
|
if (document.getElementById('mg-grid')) renderHub();
|
|
}
|
|
|
|
// ── Lifecycle ─────────────────────────────────────────────────────────
|
|
let active = null; // { spec, modifiers, resolvedTitle, startedAt, lastOpts }
|
|
let starting = false; // true while start() is in-flight (before active is set)
|
|
// Monotonically incrementing generation counter. Incremented by
|
|
// teardownActiveSession() so any in-flight _startInner() can detect that it
|
|
// has been superseded and bail after each await without mounting the stage
|
|
// or calling spec.start() for a screen the user has already left.
|
|
let _startGeneration = 0;
|
|
// When runSummary() opens the post-run modal it registers a document-level
|
|
// keydown listener. This variable holds the cleanup function so that
|
|
// teardownActiveSession() can remove the listener even when active is null
|
|
// (i.e. the run is over and the user navigates away while the summary is open).
|
|
let _summaryCleanup = null;
|
|
|
|
async function start(gameId, opts) {
|
|
// Guard against concurrent start() calls: `active` is set only after the
|
|
// async registry fetch + modifier picker, so a naive `if (active)` check
|
|
// would allow two calls to enter the async section simultaneously (e.g.
|
|
// a double-tap on a tile). `starting` flips synchronously on entry.
|
|
if (active || starting) { console.warn('[minigames] a game is already active or starting; ignoring start(%s)', gameId); return; }
|
|
starting = true;
|
|
const myGeneration = ++_startGeneration;
|
|
try {
|
|
return await _startInner(gameId, opts, myGeneration);
|
|
} finally {
|
|
starting = false;
|
|
}
|
|
}
|
|
|
|
async function _startInner(gameId, opts, myGeneration) {
|
|
const spec = registered.get(gameId);
|
|
if (!spec) { console.warn('[minigames] no such minigame:', gameId); return; }
|
|
|
|
// Always fetch the server registry so the resolved title/tagline can be
|
|
// used in both the stage header and the run summary — even when modifiers
|
|
// are supplied directly (e.g. Play Again with cached opts).
|
|
const reg = await getServerRegistry().catch(() => ({ minigames: [] }));
|
|
// Bail if teardownActiveSession() fired while we were awaiting.
|
|
if (myGeneration !== _startGeneration) { return; }
|
|
const manifestSpec = (reg.minigames || []).find(m => m.plugin_id === gameId) || {};
|
|
const resolvedTitle = manifestSpec.title || spec.title || gameId;
|
|
|
|
let modifiers = opts && opts.modifiers;
|
|
if (!modifiers) {
|
|
// Build a picker config from the server-side manifest entry (which
|
|
// includes modifiers + unlocks). Manifest fields take precedence;
|
|
// JS spec fields serve as fallback when the manifest omits them.
|
|
const tracks = manifestSpec.availableTracks || spec.availableTracks || null;
|
|
try {
|
|
const pick = await modifierPicker({
|
|
id: gameId,
|
|
title: resolvedTitle,
|
|
tagline: manifestSpec.tagline || spec.tagline || '',
|
|
modifiers: manifestSpec.modifiers || spec.modifiers || [],
|
|
availableTracks: tracks,
|
|
});
|
|
modifiers = pick.modifiers;
|
|
} catch (e) {
|
|
return; // cancelled
|
|
}
|
|
// Bail if teardownActiveSession() fired while we were in the picker.
|
|
if (myGeneration !== _startGeneration) { return; }
|
|
}
|
|
|
|
// Show the stage chrome.
|
|
const stage = document.getElementById('mg-stage');
|
|
const body = document.getElementById('mg-stage-body');
|
|
const title = document.getElementById('mg-stage-title');
|
|
const instr = document.getElementById('mg-stage-instrument');
|
|
const quit = document.getElementById('mg-stage-quit');
|
|
if (!stage || !body || !title || !instr || !quit) {
|
|
console.warn('[minigames] start(%s): stage DOM not mounted — cannot launch', gameId);
|
|
return;
|
|
}
|
|
stage.classList.remove('hidden');
|
|
body.innerHTML = '';
|
|
title.textContent = resolvedTitle;
|
|
instr.textContent = '';
|
|
mountHUD('');
|
|
|
|
const container = document.createElement('div');
|
|
container.className = 'mg-game-root';
|
|
body.appendChild(container);
|
|
|
|
active = { spec, modifiers, resolvedTitle, startedAt: performance.now(), lastOpts: { modifiers } };
|
|
// Compute elapsed time at quit so the recorded duration reflects actual play
|
|
// time rather than always logging 0ms (which would bypass end()'s fallback).
|
|
quit.onclick = () => end({
|
|
score: 0,
|
|
durationMs: active ? Math.round(performance.now() - active.startedAt) : 0,
|
|
modifiers,
|
|
meta: { reason: 'quit' },
|
|
});
|
|
|
|
try {
|
|
await spec.start({
|
|
container,
|
|
modifiers,
|
|
// Convenience pass-through for the SDK so games don't have to
|
|
// touch window.slopsmithMinigames inside their start handler.
|
|
sdk: window.slopsmithMinigames,
|
|
});
|
|
} catch (e) {
|
|
console.error('[minigames] minigame start() threw:', e);
|
|
// Compute elapsed time so failed starts record meaningful duration rather
|
|
// than always logging 0ms and bypassing end()'s fallback calculation.
|
|
const errDuration = active ? Math.round(performance.now() - active.startedAt) : 0;
|
|
await end({ score: 0, durationMs: errDuration, modifiers, meta: { reason: 'error', error: String(e) } });
|
|
}
|
|
}
|
|
|
|
async function end(result) {
|
|
if (!active) return;
|
|
const { spec, modifiers, resolvedTitle, startedAt, lastOpts } = active;
|
|
active = null;
|
|
|
|
// Tolerate callers that pass no result (or null/undefined) — normalise to {}.
|
|
const res = (result && typeof result === 'object') ? result : {};
|
|
|
|
// Let the game tear itself down first.
|
|
// Note: `await spec.stop && spec.stop()` has a precedence bug — it awaits
|
|
// the function reference, not the call. Explicit check + await is correct.
|
|
try { if (typeof spec.stop === 'function') await spec.stop(); } catch (e) { console.error(e); }
|
|
// Also stop any timers started via scheduler.
|
|
_timers.forEach(t => { clearTimeout(t); clearInterval(t); });
|
|
_timers.clear();
|
|
|
|
const _rawDur = Number(res.durationMs);
|
|
const durationMs = (Number.isFinite(_rawDur) && _rawDur >= 0)
|
|
? Math.floor(_rawDur)
|
|
: Math.round(performance.now() - startedAt);
|
|
const score = Math.max(0, Math.floor(Number(res.score) || 0));
|
|
// Coerce meta to a plain object — the backend expects a JSON object/dict;
|
|
// a non-object (string, array, null) would cause a Pydantic 422.
|
|
const rawMeta = res.meta;
|
|
const meta = (rawMeta && typeof rawMeta === 'object' && !Array.isArray(rawMeta))
|
|
? rawMeta
|
|
: {};
|
|
|
|
document.getElementById('mg-stage').classList.add('hidden');
|
|
document.getElementById('mg-stage-body').innerHTML = '';
|
|
|
|
let xpGained = 0;
|
|
// Default best to this run's score so the summary shows a sensible value
|
|
// even if the server is unreachable or the leaderboard fetch fails.
|
|
let best = score;
|
|
try {
|
|
const submitted = await submitRun({
|
|
game_id: spec.id,
|
|
score,
|
|
duration_ms: durationMs,
|
|
modifiers,
|
|
meta,
|
|
});
|
|
xpGained = submitted.xp_gained;
|
|
// Best score: fetch leaderboard top entry.
|
|
const lb = await getLeaderboard(spec.id, { limit: 1 });
|
|
best = (lb.runs && lb.runs[0] && lb.runs[0].score) || score;
|
|
} catch (e) {
|
|
console.error('[minigames] run submission failed:', e);
|
|
}
|
|
|
|
runSummary({
|
|
gameId: spec.id,
|
|
resolvedTitle,
|
|
score,
|
|
xpGained,
|
|
best,
|
|
extra: res.summaryHtml || '',
|
|
lastOpts,
|
|
});
|
|
// Refresh profile strip.
|
|
renderProfileStrip().catch(() => {});
|
|
}
|
|
|
|
// ── Hub controller ────────────────────────────────────────────────────
|
|
function escapeHtml(s) {
|
|
return String(s == null ? '' : s)
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
|
|
// Serialise hub renders. Concurrent calls were causing duplicate tiles
|
|
// because each call ran `grid.innerHTML = ''` then awaited a fetch then
|
|
// appended its tile, and the async gap let other in-flight renders also
|
|
// clear-then-append, multiplying the tile count by the number of races.
|
|
// We let at most one render run at a time and coalesce trailing calls
|
|
// into a single follow-up.
|
|
let _hubRenderInFlight = false;
|
|
let _hubRenderQueued = false;
|
|
async function renderHub() {
|
|
if (_hubRenderInFlight) { _hubRenderQueued = true; return; }
|
|
_hubRenderInFlight = true;
|
|
try {
|
|
await _renderHubOnce();
|
|
} catch (e) {
|
|
// _renderHubOnce() failing is non-fatal — the hub just won't update.
|
|
// Swallow here so all fire-and-forget callsites don't need .catch().
|
|
console.error('[minigames] renderHub failed:', e);
|
|
} finally {
|
|
_hubRenderInFlight = false;
|
|
if (_hubRenderQueued) {
|
|
_hubRenderQueued = false;
|
|
// Fire-and-forget; errors are swallowed above.
|
|
renderHub().catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
|
|
async function _renderHubOnce() {
|
|
const grid = document.getElementById('mg-grid');
|
|
const empty = document.getElementById('mg-empty');
|
|
if (!grid || !empty) return;
|
|
|
|
// Fetch profile and server registry together. Registry fields (title,
|
|
// tagline, thumbnail) take precedence over the JS-registered spec so
|
|
// minigames that only register the minimal lifecycle spec still render
|
|
// correctly — the canonical display metadata lives in plugin.json.
|
|
const [profileResp, registryResp] = await Promise.all([
|
|
getProfile().catch(() => ({})),
|
|
getServerRegistry().catch(() => ({ minigames: [] })),
|
|
]);
|
|
await renderProfileStrip(profileResp).catch(() => {});
|
|
const perGame = (profileResp.totals && profileResp.totals.per_game) || {};
|
|
|
|
// Build a lookup from plugin_id → manifest entry for merging below.
|
|
// Use Object.create(null) to prevent prototype-pollution if a plugin id
|
|
// were ever '__proto__' or another special key.
|
|
const manifestByGame = Object.create(null);
|
|
(registryResp.minigames || []).forEach(m => { manifestByGame[m.plugin_id] = m; });
|
|
|
|
const list = Array.from(registered.values());
|
|
grid.innerHTML = '';
|
|
if (!list.length) {
|
|
empty.classList.remove('hidden');
|
|
return;
|
|
}
|
|
empty.classList.add('hidden');
|
|
|
|
list.forEach(spec => {
|
|
// Merge manifest fields so display metadata from plugin.json takes
|
|
// precedence over anything in the JS registration (manifest is the
|
|
// authoritative source; JS spec is a runtime fallback).
|
|
const mSpec = manifestByGame[spec.id] || {};
|
|
const title = mSpec.title || spec.title || spec.id;
|
|
const tagline = mSpec.tagline || spec.tagline || '';
|
|
const thumbnail = mSpec.thumbnail || spec.thumbnail || null;
|
|
|
|
const tile = document.createElement('div');
|
|
// Reuse the library's .song-card class so minigame tiles look
|
|
// identical to song cards (square art on top, text block below,
|
|
// matching hover/focus behaviour).
|
|
tile.className = 'song-card';
|
|
tile.tabIndex = 0;
|
|
tile.setAttribute('role', 'button');
|
|
tile.setAttribute('aria-label', title);
|
|
const stats = perGame[spec.id] || { runs: 0, best_score: 0 };
|
|
// Thumbnails are served via the minigame plugin's own asset route
|
|
// (the Slopsmith plugin loader only serves manifest-declared files,
|
|
// so each minigame that ships extra assets must expose /assets/).
|
|
// Thumbnails are served by the minigame plugin's own /assets/ route;
|
|
// not every plugin ships one, so fall back to the placeholder on 404.
|
|
const art = thumbnail
|
|
? `<div class="card-art"><img src="/api/plugins/${encodeURIComponent(spec.id)}/assets/${encodeURIComponent(thumbnail)}" alt="${escapeHtml(title)}" onerror="this.parentElement.innerHTML='<span class=\\'placeholder\\'>🎮</span>'"></div>`
|
|
: `<div class="card-art"><span class="placeholder">🎮</span></div>`;
|
|
tile.innerHTML = `
|
|
${art}
|
|
<div class="p-4">
|
|
<div class="font-semibold text-white truncate">${escapeHtml(title)}</div>
|
|
<div class="text-sm text-gray-400 mt-0.5 line-clamp-2 min-h-[2.5em]">${escapeHtml(tagline)}</div>
|
|
<div class="mt-3 flex items-center justify-between text-xs text-gray-500">
|
|
<span>Runs <b class="text-gray-300">${Number(stats.runs) || 0}</b></span>
|
|
<span>Best <b class="text-gray-300">${Number(stats.best_score) || 0}</b></span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
tile.onclick = () => start(spec.id);
|
|
tile.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
start(spec.id);
|
|
}
|
|
});
|
|
grid.appendChild(tile);
|
|
});
|
|
}
|
|
|
|
// Accepts an already-fetched profile so callers can avoid a redundant
|
|
// network round-trip when they already have the profile in hand.
|
|
// Progression (spec 010): the strip shows lifetime Decibels earned (the
|
|
// profile `xp` field mirrors the unified core store) — the level meter is
|
|
// gone from the UI, but the backend xp/level fields stay for unlocks.
|
|
async function renderProfileStrip(profile) {
|
|
const xp = document.getElementById('mg-profile-xp');
|
|
if (!xp) return;
|
|
const p = profile || (await getProfile());
|
|
xp.textContent = `${Number(p.xp || 0).toLocaleString()} dB`;
|
|
}
|
|
|
|
// ── SDK object ────────────────────────────────────────────────────────
|
|
const sdk = {
|
|
__alive: true,
|
|
register,
|
|
start,
|
|
end,
|
|
scoring: {
|
|
createContinuous,
|
|
createDiscrete: (opts) => _wrapNoteDetector(opts),
|
|
createChord: (opts) => _wrapNoteDetector(opts),
|
|
},
|
|
ui: { mountHUD, runSummary, modifierPicker },
|
|
submitRun,
|
|
getLeaderboard,
|
|
getProfile,
|
|
resetProfile,
|
|
scheduler,
|
|
// Read-only access to the registry for the hub-from-anywhere case.
|
|
listRegistered: () => Array.from(registered.values()),
|
|
};
|
|
|
|
window.slopsmithMinigames = sdk;
|
|
// Drain queue of plugins that loaded before us.
|
|
(window.__slopsmithMinigamesPending || []).forEach(register);
|
|
window.__slopsmithMinigamesPending = null;
|
|
window.dispatchEvent(new CustomEvent('slopsmith-minigames-ready'));
|
|
|
|
// ── Wire hub render to screen lifecycle ───────────────────────────────
|
|
// Slopsmith mounts plugin screens with id "plugin-<plugin_id>" and
|
|
// routes there via showScreen() / window.slopsmith.navigate().
|
|
const SCREEN_ID = `plugin-${PLUGIN_ID}`;
|
|
// Non-scoring teardown: called when navigation happens mid-run so that
|
|
// microphone streams, timers, and stage DOM are cleaned up without submitting
|
|
// a zero-score run.
|
|
async function teardownActiveSession(reason) {
|
|
if (!active && !starting && !_summaryCleanup) return;
|
|
// Increment the generation so any in-flight _startInner() bails at its
|
|
// next await point without mounting the stage or calling spec.start().
|
|
_startGeneration++;
|
|
const spec = active && active.spec;
|
|
active = null;
|
|
starting = false;
|
|
// Dismiss the summary modal and remove its document-level keydown listener
|
|
// if the user navigated away while the post-run summary was open.
|
|
if (_summaryCleanup) { try { _summaryCleanup(); } catch (e) {} }
|
|
try { if (spec && typeof spec.stop === 'function') await spec.stop(); } catch (e) { console.error('[minigames] teardown spec.stop failed:', e); }
|
|
_timers.forEach(t => { clearTimeout(t); clearInterval(t); });
|
|
_timers.clear();
|
|
const stage = document.getElementById('mg-stage');
|
|
if (stage) stage.classList.add('hidden');
|
|
const stageBody = document.getElementById('mg-stage-body');
|
|
if (stageBody) stageBody.innerHTML = '';
|
|
const summaryEl = document.getElementById('mg-summary');
|
|
if (summaryEl) summaryEl.classList.add('hidden');
|
|
console.info('[minigames] active session torn down (reason=' + reason + ')');
|
|
}
|
|
|
|
if (window.slopsmith && typeof window.slopsmith.on === 'function') {
|
|
window.slopsmith.on('screen:changed', (e) => {
|
|
const id = e && e.detail && e.detail.id;
|
|
if (id === SCREEN_ID) {
|
|
renderHub();
|
|
} else if (active || starting || _summaryCleanup) {
|
|
// Teardown covers: active run, in-flight startup, and post-run summary
|
|
// left open while the user navigated away.
|
|
//
|
|
// Exception: a "chart-backed" minigame (spec.usesPlayer) navigates to
|
|
// the player ON PURPOSE and drives it — it overlays its own HUD there
|
|
// and ends the run itself via sdk.end() (and handles abandonment FROM
|
|
// the player from inside the game). Don't tear it down for that one hop.
|
|
// Navigating to any OTHER screen still tears down here as usual — which
|
|
// covers abandoning the pre-run setup UI before the player ever loads.
|
|
if (id === 'player' && active && active.spec && active.spec.usesPlayer) {
|
|
return;
|
|
}
|
|
teardownActiveSession('screen-changed').catch((err) => {
|
|
console.error('[minigames] teardown failed:', err);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
// Initial render in case the DOM is already mounted (hot reload / direct load).
|
|
if (document.getElementById('mg-grid')) {
|
|
renderHub();
|
|
}
|
|
|
|
// ── Inject a top-level nav link (not the plugin dropdown) ─────────────
|
|
// The user wants Minigames alongside Library / Favorites / Upload /
|
|
// Settings — not buried in the "Plugins" dropdown. We removed `nav`
|
|
// from plugin.json so the auto-injected dropdown item is gone; this
|
|
// block adds a top-level link instead, and is idempotent so plugin
|
|
// reloads or hot-refreshes don't double-add.
|
|
// Each anchor is added independently — the mobile menu DOM may not exist
|
|
// on the first call (e.g. plugin loads before the mobile nav is rendered),
|
|
// so we must NOT short-circuit on "desktop already exists". We retry on
|
|
// a short cadence so the mobile link gets added when its anchor appears.
|
|
function installNavLink() {
|
|
const navigateToHub = (e) => {
|
|
if (e) e.preventDefault();
|
|
if (window.slopsmith && typeof window.slopsmith.navigate === 'function') {
|
|
window.slopsmith.navigate(SCREEN_ID);
|
|
} else if (typeof window.showScreen === 'function') {
|
|
window.showScreen(SCREEN_ID);
|
|
}
|
|
};
|
|
|
|
// Desktop: insert just before the plugin dropdown span (visual order
|
|
// becomes Library / Favorites / Upload / Minigames / [Plugins…] /
|
|
// Settings).
|
|
if (!document.getElementById('mg-nav-link-desktop')) {
|
|
const pluginsAnchor = document.getElementById('nav-plugins');
|
|
if (pluginsAnchor && pluginsAnchor.parentElement) {
|
|
const a = document.createElement('a');
|
|
a.id = 'mg-nav-link-desktop';
|
|
a.href = '#';
|
|
a.className = 'text-sm text-gray-400 hover:text-white transition';
|
|
a.textContent = 'Minigames';
|
|
a.addEventListener('click', navigateToHub);
|
|
pluginsAnchor.parentElement.insertBefore(a, pluginsAnchor);
|
|
}
|
|
}
|
|
|
|
// Mobile: insert just before the mobile plugin block (which itself
|
|
// sits inside #mobile-menu).
|
|
if (!document.getElementById('mg-nav-link-mobile')) {
|
|
const mobileAnchor = document.getElementById('mobile-nav-plugins');
|
|
if (mobileAnchor && mobileAnchor.parentElement) {
|
|
const m = document.createElement('a');
|
|
m.id = 'mg-nav-link-mobile';
|
|
m.href = '#';
|
|
m.className = 'text-gray-400 hover:text-white';
|
|
m.textContent = 'Minigames';
|
|
m.addEventListener('click', (e) => {
|
|
navigateToHub(e);
|
|
const mobileMenu = document.getElementById('mobile-menu');
|
|
if (mobileMenu) mobileMenu.classList.add('hidden');
|
|
});
|
|
mobileAnchor.parentElement.insertBefore(m, mobileAnchor);
|
|
}
|
|
}
|
|
}
|
|
installNavLink();
|
|
// The slopsmith plugin loader rebuilds the dropdown when plugins
|
|
// hot-reload — re-install on a short delay then settle.
|
|
setTimeout(installNavLink, 250);
|
|
setTimeout(installNavLink, 1500);
|
|
|
|
// Re-install the nav link whenever the nav containers are mutated (e.g.
|
|
// after loadPlugins() clears and rebuilds #nav-plugins / #mobile-nav-plugins).
|
|
// We watch the desktop nav parent and the body for the mobile-menu insertion,
|
|
// debouncing with rAF so rapid successive mutations fire a single re-install.
|
|
(function watchNavMutations() {
|
|
if (typeof MutationObserver === 'undefined') return;
|
|
let _rafPending = false;
|
|
const onMutation = () => {
|
|
if (_rafPending) return;
|
|
_rafPending = true;
|
|
requestAnimationFrame(() => {
|
|
_rafPending = false;
|
|
// Only re-install when our link is actually gone (idempotent guard
|
|
// inside installNavLink checks id existence, but this outer check
|
|
// avoids calling the function on unrelated mutations).
|
|
if (!document.getElementById('mg-nav-link-desktop') ||
|
|
!document.getElementById('mg-nav-link-mobile')) {
|
|
installNavLink();
|
|
}
|
|
});
|
|
};
|
|
const obs = new MutationObserver(onMutation);
|
|
// Observe the top-level nav bar and the body (mobile menu is inserted lazily).
|
|
const nav = document.querySelector('nav') || document.body;
|
|
obs.observe(nav, { childList: true, subtree: true });
|
|
})();
|
|
})();
|