From 3410e3324e05d2979d3f3142c48d2ab4df4036b2 Mon Sep 17 00:00:00 2001 From: pleb Date: Sat, 25 Apr 2026 11:15:52 -0700 Subject: [PATCH] Add pb display --- index.css | 42 +++++++++++++++++++++++++++ index.html | 5 ++++ index.js | 67 +++++++++++++++++++++++++++++++++++++++---- src/client/index.ts | 70 +++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 173 insertions(+), 11 deletions(-) diff --git a/index.css b/index.css index 5a06bfa..3330c61 100644 --- a/index.css +++ b/index.css @@ -317,6 +317,48 @@ span:empty { max-width: 44rem; } +.personal-best-row { + display: none; + align-items: center; + gap: 0.45rem; + font-size: 1.3rem; + font-weight: 700; + opacity: 0.92; + line-height: 1.2; +} + +#friendScores.has-personal-best .personal-best-row { + display: flex; +} + +.personal-best-label { + font-size: 1rem; + font-weight: 700; + opacity: 0.75; + letter-spacing: 0.04em; +} + +.personal-best-acc { + font-feature-settings: "tnum"; + font-variant-numeric: tabular-nums; +} + +.personal-best-delta { + font-size: 1.15rem; + font-weight: 700; + font-feature-settings: "tnum"; + font-variant-numeric: tabular-nums; + min-width: 4.2ch; +} + +.personal-best-delta.personal-best-delta--ahead { + color: #3ddc97; +} + +.personal-best-delta.personal-best-delta--behind { + color: #ff6b6b; +} + #friendScoresHeader { font-size: 1.3rem; font-weight: 700; diff --git a/index.html b/index.html index ab02353..e56002e 100644 --- a/index.html +++ b/index.html @@ -39,6 +39,11 @@
+
+ PB + + +
frenz
    No map loaded
    diff --git a/index.js b/index.js index 0c2734a..34d91bc 100644 --- a/index.js +++ b/index.js @@ -246,6 +246,9 @@ var beatSaberPlus = { switch (data._event) { case "gameState": document.body.dataset.gameState = data.gameStateChanged; + if (data.gameStateChanged === "Menu") { + resetPersonalBestDeltaPlaceholder(); + } break; case "mapInfo": void updateMapInfo(data.mapInfoChanged); @@ -475,6 +478,9 @@ var friendScoresEmpty = must("friendScoresEmpty"); var friendScoresHeaderText = must("friendScoresHeaderText"); var friendScoresPlayerAvatar = must("friendScoresPlayerAvatar"); var friendScoresHeaderImg = must("friendScoresHeaderImg"); +var personalBestAcc = must("personalBestAcc"); +var personalBestDelta = must("personalBestDelta"); +var currentPbAccuracyPercent = null; var cachedConfiguredPlayerAvatarKey = ""; var cachedConfiguredPlayerAvatarSrc = "images/unknown.svg"; async function refreshConfiguredPlayerAvatar() { @@ -497,11 +503,42 @@ async function refreshConfiguredPlayerAvatar() { cachedConfiguredPlayerAvatarSrc = profile?.avatar?.trim() || "images/unknown.svg"; friendScoresPlayerAvatar.src = cachedConfiguredPlayerAvatarSrc; } +function applyPersonalBestRow(pbPercent) { + currentPbAccuracyPercent = pbPercent; + friendScoresPanel.classList.toggle("has-personal-best", pbPercent !== null); + if (pbPercent === null) { + personalBestAcc.textContent = ""; + personalBestDelta.textContent = ""; + personalBestDelta.classList.remove("personal-best-delta--ahead", "personal-best-delta--behind"); + return; + } + personalBestAcc.textContent = `${pbPercent.toFixed(2)}%`; + resetPersonalBestDeltaPlaceholder(); +} +function resetPersonalBestDeltaPlaceholder() { + if (currentPbAccuracyPercent === null) return; + personalBestDelta.textContent = "\u2014"; + personalBestDelta.classList.remove("personal-best-delta--ahead", "personal-best-delta--behind"); +} +function updatePersonalBestDelta(liveAccuracyPercent) { + if (currentPbAccuracyPercent === null) return; + const delta = liveAccuracyPercent - currentPbAccuracyPercent; + personalBestDelta.textContent = `${delta >= 0 ? "+" : ""}${delta.toFixed(2)}%`; + personalBestDelta.classList.toggle("personal-best-delta--ahead", delta > 5e-4); + personalBestDelta.classList.toggle("personal-best-delta--behind", delta < -5e-4); + if (Math.abs(delta) <= 5e-4) { + personalBestDelta.classList.remove("personal-best-delta--ahead", "personal-best-delta--behind"); + } +} function updateScore(score) { - if (!settings.score) return; - accuracy.textContent = (score.accuracy * 100).toFixed(1); - mistakes.textContent = score.missCount ? String(score.missCount) : ""; - accuracy.classList.toggle("failed", score.currentHealth === 0); + if (settings.score) { + accuracy.textContent = (score.accuracy * 100).toFixed(1); + mistakes.textContent = score.missCount ? String(score.missCount) : ""; + accuracy.classList.toggle("failed", score.currentHealth === 0); + } + if (settings.friends) { + updatePersonalBestDelta(score.accuracy * 100); + } } function avatarFromScore(score) { if (typeof score.player === "object" && score.player?.avatar) { @@ -511,6 +548,7 @@ function avatarFromScore(score) { return url || null; } function clearFriendScores(message) { + applyPersonalBestRow(null); friendScoresList.replaceChildren(); friendScoresEmpty.textContent = message; friendScoresHeaderText.textContent = "frenz?"; @@ -548,6 +586,7 @@ function friendsRelationListKey(playerId) { } function beginFriendScoresForNewMapContext() { friendScoreRequestId += 1; + applyPersonalBestRow(null); if (!settings.friends) return; if (!currentMapHash) { clearFriendScores("No map loaded"); @@ -578,6 +617,7 @@ async function refreshMapFriendScores() { clearFriendScores("Waiting for BeatLeader player id"); return; } + applyPersonalBestRow(null); friendScoresList.replaceChildren(); friendScoresPanel.classList.remove("has-items"); friendScoresPanel.classList.add("is-loading"); @@ -594,10 +634,12 @@ async function refreshMapFriendScores() { friendsRelationCache = fetched; return fetched; })(); - const [leaderboards, friends] = await Promise.all([ + const [leaderboards, friends, selfProfile] = await Promise.all([ fetchBLLeaderboardsByHash(hash), - friendsPromise + friendsPromise, + fetchBeatLeaderPlayer(playerId) ]); + const myBeatLeaderId = selfProfile?.id ?? playerId; if (requestId !== friendScoreRequestId) return; if (leaderboards.length === 0) { clearFriendScores("No BeatLeader leaderboards found"); @@ -620,6 +662,15 @@ async function refreshMapFriendScores() { } const scores = await fetchAllMapScoresByHash(hash, forPlayMode); if (requestId !== friendScoreRequestId) return; + let playerPbAcc = null; + for (const score of scores) { + const scorePlayerId = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null); + const playerKey = scorePlayerId == null ? "" : String(scorePlayerId); + if (!playerKey || playerKey !== String(myBeatLeaderId)) continue; + const acc = normalizeAccuracy(score.accuracy ?? score.acc); + if (acc === null) continue; + if (playerPbAcc === null || acc > playerPbAcc) playerPbAcc = acc; + } const bestByPlayer = /* @__PURE__ */ new Map(); for (const score of scores) { const scorePlayerId = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null); @@ -640,6 +691,10 @@ async function refreshMapFriendScores() { }); } } + if (playerPbAcc !== null) { + bestByPlayer.delete(String(myBeatLeaderId)); + } + applyPersonalBestRow(playerPbAcc); const sorted = Array.from(bestByPlayer.values()).sort((a, b) => b.acc - a.acc); renderFriendScores(sorted); } catch { diff --git a/src/client/index.ts b/src/client/index.ts index 2a17775..13d0255 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -72,6 +72,9 @@ const beatSaberPlus = { switch (data._event) { case "gameState": document.body.dataset.gameState = data.gameStateChanged; + if (data.gameStateChanged === "Menu") { + resetPersonalBestDeltaPlaceholder(); + } break; case "mapInfo": void updateMapInfo(data.mapInfoChanged); @@ -342,6 +345,11 @@ const friendScoresEmpty = must("friendScoresEmpty"); const friendScoresHeaderText = must("friendScoresHeaderText"); const friendScoresPlayerAvatar = must("friendScoresPlayerAvatar"); const friendScoresHeaderImg = must("friendScoresHeaderImg"); +const personalBestAcc = must("personalBestAcc"); +const personalBestDelta = must("personalBestDelta"); + +/** BeatLeader PB (percent) for the current map + difficulty; null if none or friends panel inactive. */ +let currentPbAccuracyPercent: number | null = null; let cachedConfiguredPlayerAvatarKey = ""; let cachedConfiguredPlayerAvatarSrc = "images/unknown.svg"; @@ -367,11 +375,45 @@ async function refreshConfiguredPlayerAvatar() { friendScoresPlayerAvatar.src = cachedConfiguredPlayerAvatarSrc; } +function applyPersonalBestRow(pbPercent: number | null) { + currentPbAccuracyPercent = pbPercent; + friendScoresPanel.classList.toggle("has-personal-best", pbPercent !== null); + if (pbPercent === null) { + personalBestAcc.textContent = ""; + personalBestDelta.textContent = ""; + personalBestDelta.classList.remove("personal-best-delta--ahead", "personal-best-delta--behind"); + return; + } + personalBestAcc.textContent = `${pbPercent.toFixed(2)}%`; + resetPersonalBestDeltaPlaceholder(); +} + +function resetPersonalBestDeltaPlaceholder() { + if (currentPbAccuracyPercent === null) return; + personalBestDelta.textContent = "—"; + personalBestDelta.classList.remove("personal-best-delta--ahead", "personal-best-delta--behind"); +} + +function updatePersonalBestDelta(liveAccuracyPercent: number) { + if (currentPbAccuracyPercent === null) return; + const delta = liveAccuracyPercent - currentPbAccuracyPercent; + personalBestDelta.textContent = `${delta >= 0 ? "+" : ""}${delta.toFixed(2)}%`; + personalBestDelta.classList.toggle("personal-best-delta--ahead", delta > 0.0005); + personalBestDelta.classList.toggle("personal-best-delta--behind", delta < -0.0005); + if (Math.abs(delta) <= 0.0005) { + personalBestDelta.classList.remove("personal-best-delta--ahead", "personal-best-delta--behind"); + } +} + function updateScore(score: Score) { - if (!settings.score) return; - accuracy.textContent = (score.accuracy * 100).toFixed(1); - mistakes.textContent = score.missCount ? String(score.missCount) : ""; - accuracy.classList.toggle("failed", score.currentHealth === 0); + if (settings.score) { + accuracy.textContent = (score.accuracy * 100).toFixed(1); + mistakes.textContent = score.missCount ? String(score.missCount) : ""; + accuracy.classList.toggle("failed", score.currentHealth === 0); + } + if (settings.friends) { + updatePersonalBestDelta(score.accuracy * 100); + } } function avatarFromScore(score: BeatLeaderScore): string | null { @@ -383,6 +425,7 @@ function avatarFromScore(score: BeatLeaderScore): string | null { } function clearFriendScores(message: string) { + applyPersonalBestRow(null); friendScoresList.replaceChildren(); friendScoresEmpty.textContent = message; friendScoresHeaderText.textContent = "frenz?"; @@ -427,6 +470,7 @@ function friendsRelationListKey(playerId: string): string { */ function beginFriendScoresForNewMapContext() { friendScoreRequestId += 1; + applyPersonalBestRow(null); if (!settings.friends) return; if (!currentMapHash) { clearFriendScores("No map loaded"); @@ -458,6 +502,7 @@ async function refreshMapFriendScores() { clearFriendScores("Waiting for BeatLeader player id"); return; } + applyPersonalBestRow(null); friendScoresList.replaceChildren(); friendScoresPanel.classList.remove("has-items"); friendScoresPanel.classList.add("is-loading"); @@ -474,10 +519,12 @@ async function refreshMapFriendScores() { friendsRelationCache = fetched; return fetched; })(); - const [leaderboards, friends] = await Promise.all([ + const [leaderboards, friends, selfProfile] = await Promise.all([ fetchBLLeaderboardsByHash(hash), friendsPromise, + fetchBeatLeaderPlayer(playerId), ]); + const myBeatLeaderId = selfProfile?.id ?? playerId; if (requestId !== friendScoreRequestId) return; if (leaderboards.length === 0) { clearFriendScores("No BeatLeader leaderboards found"); @@ -501,6 +548,15 @@ async function refreshMapFriendScores() { } const scores = await fetchAllMapScoresByHash(hash, forPlayMode); if (requestId !== friendScoreRequestId) return; + let playerPbAcc: number | null = null; + for (const score of scores) { + const scorePlayerId = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null); + const playerKey = scorePlayerId == null ? "" : String(scorePlayerId); + if (!playerKey || playerKey !== String(myBeatLeaderId)) continue; + const acc = normalizeAccuracy(score.accuracy ?? score.acc); + if (acc === null) continue; + if (playerPbAcc === null || acc > playerPbAcc) playerPbAcc = acc; + } const bestByPlayer = new Map(); for (const score of scores) { const scorePlayerId = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null); @@ -523,6 +579,10 @@ async function refreshMapFriendScores() { }); } } + if (playerPbAcc !== null) { + bestByPlayer.delete(String(myBeatLeaderId)); + } + applyPersonalBestRow(playerPbAcc); const sorted = Array.from(bestByPlayer.values()).sort((a, b) => b.acc - a.acc); renderFriendScores(sorted); } catch {