├── .gitattributes ├── .github └── FUNDING.yml ├── .gitignore ├── BuildTargets.targets ├── LICENSE ├── PerformanceMeter.csproj ├── PerformanceMeter.sln ├── PerformanceMeterController.cs ├── Plugin.cs ├── PluginConfig.cs ├── Properties └── AssemblyInfo.cs ├── README.md ├── Settings.bsml ├── Settings.cs ├── WindowGraph.cs ├── manifest.json └── screenshot.png /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: MCJack123 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore References folder 2 | References/ 3 | bin/ 4 | obj/ 5 | Refs/ 6 | .vs/ 7 | *.csproj.user 8 | -------------------------------------------------------------------------------- /BuildTargets.targets: -------------------------------------------------------------------------------- 1 |  2 | 4 | 5 | 6 | 1.4 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | $(BeatSaberDir)\IPA\Pending\Plugins 39 | 40 | 41 | $(BeatSaberDir)\Plugins 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 0 && endColumn > 0) 157 | AssemblyVersion = firstLineStr.Substring(startColumn, endColumn - startColumn); 158 | else 159 | badParse = true; 160 | } 161 | else 162 | badParse = true; 163 | if (badParse) 164 | { 165 | Log.LogError("Build", "BSMOD03", "", assemblyFile, 0, 0, 0, 0, "Unable to parse the AssemblyVersion from {0}", assemblyFile); 166 | if(ErrorOnMismatch) 167 | return false; 168 | badParse = false; 169 | } 170 | 171 | if (PluginVersion != "E.R.R" && AssemblyVersion != PluginVersion) 172 | { 173 | Log.LogError("Build", "BSMOD01", "", assemblyFile, startLine, startColumn + 1, startLine, endColumn + 1, "PluginVersion {0} in manifest.json does not match AssemblyVersion {1} in AssemblyInfo.cs", PluginVersion, AssemblyVersion, assemblyFile); 174 | Log.LogMessage(MessageImportance.High, "PluginVersion {0} does not match AssemblyVersion {1}", PluginVersion, AssemblyVersion); 175 | if(ErrorOnMismatch) 176 | return false; 177 | } 178 | if (!string.IsNullOrEmpty(endLineStr)) 179 | { 180 | startColumn = endLineStr.IndexOf('"') + 1; 181 | endColumn = endLineStr.LastIndexOf('"'); 182 | if (startColumn > 0 && endColumn > 0) 183 | { 184 | assemblyFileVersion = endLineStr.Substring(startColumn, endColumn - startColumn); 185 | if (AssemblyVersion != assemblyFileVersion) 186 | { 187 | Log.LogWarning("Build", "BSMOD02", "", assemblyFile, endLine, startColumn + 1, endLine, endColumn + 1, "AssemblyVersion {0} does not match AssemblyFileVersion {1} in AssemblyInfo.cs", AssemblyVersion, assemblyFileVersion); 188 | if(ErrorOnMismatch) 189 | return false; 190 | } 191 | 192 | } 193 | else 194 | { 195 | Log.LogWarning("Build", "BSMOD06", "", assemblyFile, 0, 0, 0, 0, "Unable to parse the AssemblyFileVersion from {0}", assemblyFile); 196 | if(ErrorOnMismatch) 197 | return false; 198 | } 199 | } 200 | return true; 201 | } 202 | catch (Exception ex) 203 | { 204 | Log.LogErrorFromException(ex); 205 | return false; 206 | } 207 | ]]> 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | = 7) 280 | { 281 | CommitShortHash = outText.Substring(0, 7); 282 | return true; 283 | } 284 | } 285 | catch (Win32Exception ex) 286 | { 287 | noGitFound = true; 288 | 289 | } 290 | catch (Exception ex) 291 | { 292 | Log.LogErrorFromException(ex); 293 | return true; 294 | } 295 | try 296 | { 297 | string gitPath = Path.GetFullPath(Path.Combine(ProjectDir, ".git")); 298 | string headPath = Path.Combine(gitPath, "HEAD"); 299 | string headContents = null; 300 | if (File.Exists(headPath)) 301 | headContents = File.ReadAllText(headPath); 302 | else 303 | { 304 | gitPath = Path.GetFullPath(Path.Combine(ProjectDir, "..", ".git")); 305 | headPath = Path.Combine(gitPath, "HEAD"); 306 | if (File.Exists(headPath)) 307 | headContents = File.ReadAllText(headPath); 308 | } 309 | headPath = null; 310 | if (!string.IsNullOrEmpty(headContents) && headContents.StartsWith("ref:")) 311 | headPath = Path.Combine(gitPath, headContents.Replace("ref:", "").Trim()); 312 | if (File.Exists(headPath)) 313 | { 314 | headContents = File.ReadAllText(headPath); 315 | if (headContents.Length >= 7) 316 | CommitShortHash = headContents.Substring(0, 7); 317 | } 318 | } 319 | catch { } 320 | if (CommitShortHash == "local") 321 | { 322 | if(noGitFound) 323 | Log.LogMessage(MessageImportance.High, " 'git' command not found, unable to retrieve current commit hash."); 324 | else 325 | Log.LogMessage(MessageImportance.High, " Unable to retrieve current commit hash."); 326 | } 327 | return true; 328 | ]]> 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 354 | 355 | 356 | 357 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2022 JackMacWindows 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /PerformanceMeter.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Debug 5 | AnyCPU 6 | 8.0.30703 7 | 2.0 8 | {68EA3912-8A4C-49C1-9CB7-E385986C469D} 9 | Library 10 | Properties 11 | PerformanceMeter 12 | PerformanceMeter 13 | v4.7.2 14 | 9.0 15 | 512 16 | portable 17 | $(ProjectDir)Refs 18 | $(BeatSaberDir) 19 | $(SolutionDir)Refs 20 | $(MSBuildProjectDirectory)\ 21 | $(AppOutputBase)=X:\$(AssemblyName)\ 22 | 23 | 24 | 25 | true 26 | false 27 | bin\Debug\ 28 | DEBUG;TRACE 29 | prompt 30 | 4 31 | 32 | 33 | true 34 | bin\Release\ 35 | 36 | 37 | prompt 38 | 4 39 | true 40 | 41 | 42 | True 43 | 44 | 45 | True 46 | True 47 | 48 | 49 | 50 | 51 | 52 | 53 | False 54 | $(BeatSaberDir)\Beat Saber_Data\Managed\BeatmapCore.dll 55 | False 56 | 57 | 58 | False 59 | $(BeatSaberDir)\Plugins\BSML.dll 60 | False 61 | 62 | 63 | False 64 | $(BeatSaberDir)\Plugins\BS_Utils.dll 65 | False 66 | 67 | 68 | False 69 | $(BeatSaberDir)\Beat Saber_Data\Managed\GameplayCore.dll 70 | False 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | $(BeatSaberDir)\Beat Saber_Data\Managed\Main.dll 80 | False 81 | 82 | 83 | $(BeatSaberDir)\Beat Saber_Data\Managed\HMLib.dll 84 | False 85 | 86 | 87 | $(BeatSaberDir)\Beat Saber_Data\Managed\HMUI.dll 88 | False 89 | 90 | 91 | $(BeatSaberDir)\Beat Saber_Data\Managed\IPA.Loader.dll 92 | False 93 | 94 | 95 | $(BeatSaberDir)\Beat Saber_Data\Managed\Unity.TextMeshPro.dll 96 | False 97 | 98 | 99 | $(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.dll 100 | False 101 | 102 | 103 | $(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.AssetBundleModule.dll 104 | 105 | 106 | $(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.CoreModule.dll 107 | False 108 | 109 | 110 | $(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.TextRenderingModule.dll 111 | 112 | 113 | $(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.UI.dll 114 | False 115 | 116 | 117 | $(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.UIElementsModule.dll 118 | False 119 | 120 | 121 | $(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.UIModule.dll 122 | False 123 | 124 | 125 | $(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.VRModule.dll 126 | False 127 | 128 | 129 | $(BeatSaberDir)\Beat Saber_Data\Managed\Zenject.dll 130 | False 131 | False 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | Settings.cs 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | -------------------------------------------------------------------------------- /PerformanceMeter.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.30011.22 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PerformanceMeter", "PerformanceMeter.csproj", "{68EA3912-8A4C-49C1-9CB7-E385986C469D}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {68EA3912-8A4C-49C1-9CB7-E385986C469D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {68EA3912-8A4C-49C1-9CB7-E385986C469D}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {68EA3912-8A4C-49C1-9CB7-E385986C469D}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {68EA3912-8A4C-49C1-9CB7-E385986C469D}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ExtensibilityGlobals) = postSolution 23 | SolutionGuid = {6D8E14F9-0C44-4EFB-84D5-C0F56873299D} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /PerformanceMeterController.cs: -------------------------------------------------------------------------------- 1 | /* 2 | * PerformanceMeterController.cs 3 | * PerformanceMeter 4 | * 5 | * This file defines the main functionality of PerformanceMeter. 6 | * 7 | * This code is licensed under the MIT license. 8 | * Copyright (c) 2021-2022 JackMacWindows. 9 | */ 10 | 11 | using System.Reflection; 12 | using System.Collections; 13 | using System.Collections.Generic; 14 | using System.Linq; 15 | using System; 16 | using UnityEngine; 17 | using UnityEngine.UI; 18 | using UnityEngine.XR; 19 | using TMPro; 20 | 21 | namespace PerformanceMeter { 22 | public struct Pair { 23 | public T first; 24 | public U second; 25 | public Pair(T a, U b) {first = a; second = b;} 26 | } 27 | 28 | public class PerformanceMeterController : MonoBehaviour { 29 | public static PerformanceMeterController instance { get; private set; } 30 | 31 | List> energyList = new List>(); 32 | List> secondaryEnergyList = new List>(); 33 | List misses = new List(); 34 | float averageHitValue = 0.0f; 35 | int averageHitValueSize = 0; 36 | float secondaryAverageHitValue = 0.0f; 37 | int secondaryAverageHitValueSize = 0; 38 | ScoreController scoreController; 39 | BeatmapObjectManager objectManager; 40 | IComboController comboController; 41 | GameEnergyCounter energyCounter; 42 | RelativeScoreAndImmediateRankCounter rankCounter; 43 | AudioTimeSyncController audioController; 44 | PauseController pauseController; 45 | GameObject panel; 46 | ILevelEndActions endActions; 47 | bool levelOk = false; 48 | bool levelNotOk = false; 49 | static readonly FieldInfo _beatmapObjectManager = typeof(ScoreController).GetField("_beatmapObjectManager", BindingFlags.NonPublic | BindingFlags.Instance); 50 | static readonly FieldInfo _comboController = typeof(ComboUIController).GetField("_comboController", BindingFlags.NonPublic | BindingFlags.Instance); 51 | static readonly FieldInfo StandardLevelGameplayManager_pauseController = typeof(StandardLevelGameplayManager).GetField("_pauseController", BindingFlags.NonPublic | BindingFlags.Instance); 52 | static readonly FieldInfo MissionLevelGameplayManager_pauseController = typeof(MissionLevelGameplayManager).GetField("_pauseController", BindingFlags.NonPublic | BindingFlags.Instance); 53 | 54 | public void ShowResults() { 55 | if (!levelOk || levelNotOk) 56 | return; 57 | levelOk = false; 58 | levelNotOk = false; 59 | Logger.log.Debug("Found " + energyList.Count() + " primary notes, " + secondaryEnergyList.Count() + " secondary notes"); 60 | 61 | panel = new GameObject("PerformanceMeter"); 62 | panel.transform.Rotate(22.5f, 0, 0, Space.World); 63 | Canvas canvas = panel.AddComponent(); 64 | canvas.renderMode = RenderMode.WorldSpace; 65 | canvas.scaleFactor = 0.01f; 66 | canvas.GetComponent().localPosition = new Vector3(0.0f, 0.4f, 2.25f); 67 | canvas.GetComponent().sizeDelta = new Vector2(1.0f, 0.5f); 68 | canvas.transform.Rotate(22.5f, 0, 0, Space.World); 69 | panel.AddComponent(); 70 | panel.AddComponent(); 71 | 72 | GameObject imageObj = new GameObject("Background"); 73 | imageObj.transform.SetParent(canvas.transform); 74 | imageObj.transform.Rotate(22.5f, 0, 0, Space.World); 75 | Image img = imageObj.AddComponent(); 76 | img.color = new Color(0.1f, 0.1f, 0.1f, 0.25f); 77 | img.GetComponent().localPosition = new Vector3(0.0f, 0.0f, 0.0f); 78 | img.GetComponent().sizeDelta = new Vector2(1.0f, 0.6f); 79 | 80 | GameObject textObj = new GameObject("Label"); 81 | textObj.transform.SetParent(canvas.transform); 82 | textObj.transform.Rotate(22.5f, 0, 0, Space.World); 83 | HMUI.CurvedTextMeshPro text = textObj.AddComponent(); 84 | text.font = Instantiate(Resources.FindObjectsOfTypeAll().First(t => t.name == "Teko-Medium SDF")); 85 | text.fontSize = 9.0f; 86 | text.alignment = TextAlignmentOptions.Right; 87 | text.text = "Performance"; 88 | text.enableAutoSizing = true; 89 | text.transform.localScale = new Vector3(0.005f, 0.005f, 0.005f); 90 | text.GetComponent().localPosition = new Vector3(-0.3f, -0.25f, 0.1f); 91 | text.GetComponent().sizeDelta = new Vector2(75.0f, 25.0f); 92 | 93 | GameObject textObj2 = new GameObject("Label"); 94 | textObj2.transform.SetParent(canvas.transform); 95 | textObj2.transform.Rotate(22.5f, 0, 0, Space.World); 96 | HMUI.CurvedTextMeshPro text2 = textObj2.AddComponent(); 97 | text2.font = Instantiate(Resources.FindObjectsOfTypeAll().First(t => t.name == "Teko-Medium SDF")); 98 | text2.fontSize = 9.0f; 99 | text2.alignment = TextAlignmentOptions.Right; 100 | text2.text = "By JackMacWindows#9776"; 101 | text2.color = new Color(0.3f, 0.3f, 0.3f); 102 | text2.enableAutoSizing = true; 103 | text2.transform.localScale = new Vector3(0.002f, 0.002f, 0.002f); 104 | text2.GetComponent().localPosition = new Vector3(0.31f, -0.2625f, 0.1f); 105 | text2.GetComponent().sizeDelta = new Vector2(175.0f, 16.0f); 106 | 107 | GameObject graphMask = new GameObject("GraphMask"); 108 | graphMask.AddComponent().localPosition = new Vector3(0.0f, 0.45f, 2.275f); 109 | graphMask.GetComponent().sizeDelta = new Vector2(1.0f, 0.45f); 110 | graphMask.transform.Rotate(22.5f, 0, 0, Space.World); 111 | graphMask.transform.SetParent(panel.transform); 112 | graphMask.transform.name = "GraphMask"; 113 | graphMask.AddComponent(); 114 | 115 | GameObject graphObj = new GameObject("GraphContainer"); 116 | graphObj.AddComponent().localPosition = new Vector3(0.0f, 0.45f, 2.275f); 117 | graphObj.GetComponent().sizeDelta = new Vector2(1.0f, 0.45f); 118 | graphObj.transform.Rotate(22.5f, 0, 0, Space.World); 119 | graphObj.transform.SetParent(graphMask.transform); 120 | graphObj.transform.name = "GraphContainer"; 121 | 122 | bool hasPrimary = PluginConfig.Instance.mode != PluginConfig.MeasurementMode.None && energyList.Count > 0; 123 | bool hasSecondary = PluginConfig.Instance.secondaryMode != PluginConfig.MeasurementMode.None && secondaryEnergyList.Count > 0; 124 | 125 | if (!hasPrimary && !hasSecondary) { 126 | GameObject textObj3 = new GameObject("Label"); 127 | textObj3.transform.SetParent(canvas.transform); 128 | textObj3.transform.Rotate(22.5f, 0, 0, Space.World); 129 | HMUI.CurvedTextMeshPro text3 = textObj3.AddComponent(); 130 | text3.font = Instantiate(Resources.FindObjectsOfTypeAll().First(t => t.name == "Teko-Medium SDF")); 131 | text3.fontSize = 48.0f; 132 | text3.alignment = TextAlignmentOptions.Center; 133 | text3.text = "No data available"; 134 | text3.color = new Color(0.3f, 0.3f, 0.3f); 135 | text3.enableAutoSizing = true; 136 | text3.transform.localScale = new Vector3(0.002f, 0.002f, 0.002f); 137 | text3.GetComponent().localPosition = new Vector3(0f, 0f, 0f); 138 | text3.GetComponent().sizeDelta = new Vector2(800.0f, 80.0f); 139 | StartCoroutine(WaitForMenu(graphObj, graphMask)); 140 | return; 141 | } 142 | 143 | float width = 0.0f; 144 | if (hasPrimary && hasSecondary) { 145 | if (energyList.Last().first > secondaryEnergyList.Last().first) 146 | secondaryEnergyList.Add(new Pair(energyList.Last().first, secondaryEnergyList.Last().second)); 147 | else if (secondaryEnergyList.Last().first > energyList.Last().first) 148 | energyList.Add(new Pair(secondaryEnergyList.Last().first, energyList.Last().second)); 149 | } 150 | width = hasPrimary ? energyList.Last().first : secondaryEnergyList.Last().first; 151 | 152 | if (hasPrimary) 153 | graphMask.AddComponent().ShowGraph(energyList, PluginConfig.Instance.mode, width, PluginConfig.Instance.overrideColor, PluginConfig.Instance.sideColor, true); 154 | 155 | if (hasSecondary) 156 | graphMask.AddComponent().ShowGraph(secondaryEnergyList, PluginConfig.Instance.secondaryMode, width, PluginConfig.Instance.overrideSecondaryColor, PluginConfig.Instance.secondarySideColor, false); 157 | 158 | if (PluginConfig.Instance.showMisses) { 159 | var GraphTransform = graphObj.GetComponent(); 160 | var xSize = GraphTransform.sizeDelta.x / width; 161 | var ySize = GraphTransform.sizeDelta.y; 162 | foreach (float pos in misses) { 163 | var xPosition = pos * xSize; 164 | var dotPositionA = new Vector2(xPosition, 0); 165 | var dotPositionB = new Vector2(xPosition, ySize); 166 | var gameObject = new GameObject("DotConnection", typeof(Image)); 167 | gameObject.transform.SetParent(GraphTransform, false); 168 | var image = gameObject.GetComponent(); 169 | image.color = new Color(0.5f, 0.5f, 0.5f, 0.75f); 170 | var rectTransform = gameObject.GetComponent(); 171 | var dir = (dotPositionB - dotPositionA).normalized; 172 | var distance = Vector2.Distance(dotPositionA, dotPositionB); 173 | rectTransform.anchorMin = new Vector2(0, 0); 174 | rectTransform.anchorMax = new Vector2(0, 0); 175 | rectTransform.sizeDelta = new Vector2(distance, 0.0025f); 176 | rectTransform.anchoredPosition = dotPositionA + dir * distance * .5f; 177 | rectTransform.localEulerAngles = new Vector3(0, 0, Mathf.Atan2(dir.y, dir.x) * Mathf.Rad2Deg); 178 | } 179 | } 180 | 181 | StartCoroutine(WaitForMenu(graphObj, graphMask)); 182 | } 183 | 184 | IEnumerator WaitForMenu(GameObject graphObj, GameObject graphMask) { 185 | if (endActions is StandardLevelGameplayManager) { 186 | ResultsViewController resultsController = null; 187 | do { 188 | resultsController = Resources.FindObjectsOfTypeAll().LastOrDefault(); 189 | yield return new WaitForSeconds(0.1f); 190 | } while (resultsController == null); 191 | 192 | resultsController.continueButtonPressedEvent += DismissGraph; 193 | resultsController.restartButtonPressedEvent += DismissGraph; 194 | } else { 195 | MissionResultsViewController resultsController = null; 196 | do { 197 | resultsController = Resources.FindObjectsOfTypeAll().LastOrDefault(); 198 | yield return new WaitForSeconds(0.1f); 199 | } while (resultsController == null); 200 | 201 | resultsController.continueButtonPressedEvent += DismissGraph_Mission; 202 | resultsController.retryButtonPressedEvent += DismissGraph_Mission; 203 | } 204 | Logger.log.Debug("PerformanceMeter menu created successfully"); 205 | StartCoroutine(GraphAnimation(graphObj, graphMask)); 206 | } 207 | 208 | IEnumerator GraphAnimation(GameObject graphObj, GameObject graphMask) { 209 | if (PluginConfig.Instance.animationDuration <= 0f) 210 | yield break; 211 | 212 | float fps = XRDevice.refreshRate; 213 | float steps = fps * PluginConfig.Instance.animationDuration; 214 | Vector3 posDelta = new Vector3(0.5f / steps, 0f, 0f); 215 | Vector2 sizeDelta = new Vector2(1f / steps, 0f); 216 | graphMask.GetComponent().sizeDelta = new Vector2(0.0f, 0.45f); 217 | graphMask.GetComponent().localPosition -= posDelta * steps; 218 | graphObj.GetComponent().localPosition += posDelta * steps; 219 | for (int s = 1; s <= steps; s++) { 220 | if (panel == null) 221 | yield break; 222 | graphMask.GetComponent().sizeDelta += sizeDelta; 223 | graphMask.GetComponent().localPosition += posDelta; 224 | graphObj.GetComponent().localPosition -= posDelta; 225 | yield return new WaitForSeconds(1f / fps); 226 | } 227 | } 228 | 229 | void DismissGraph(ResultsViewController vc) { 230 | if (panel != null) { 231 | panel.SetActive(false); 232 | Destroy(panel, 1); 233 | panel = null; 234 | scoreController = null; 235 | objectManager = null; 236 | comboController = null; 237 | energyCounter = null; 238 | rankCounter = null; 239 | audioController = null; 240 | endActions = null; 241 | pauseController = null; 242 | } 243 | if (vc != null) { 244 | vc.continueButtonPressedEvent -= DismissGraph; 245 | vc.restartButtonPressedEvent -= DismissGraph; 246 | } 247 | } 248 | 249 | void DismissGraph_Mission(MissionResultsViewController vc) { 250 | DismissGraph(null); 251 | if (vc != null) { 252 | vc.continueButtonPressedEvent -= DismissGraph_Mission; 253 | vc.retryButtonPressedEvent -= DismissGraph_Mission; 254 | } 255 | } 256 | 257 | public void GetControllers() { 258 | DismissGraph(null); 259 | levelOk = false; 260 | levelNotOk = false; 261 | averageHitValue = 0.0f; 262 | averageHitValueSize = 0; 263 | secondaryAverageHitValue = 0.0f; 264 | secondaryAverageHitValueSize = 0; 265 | energyList.Clear(); 266 | secondaryEnergyList.Clear(); 267 | misses.Clear(); 268 | 269 | if (PluginConfig.Instance.mode == PluginConfig.MeasurementMode.Energy) 270 | energyList.Add(new Pair(0.0f, 0.5f)); 271 | if (PluginConfig.Instance.secondaryMode == PluginConfig.MeasurementMode.Energy) 272 | secondaryEnergyList.Add(new Pair(0.0f, 0.5f)); 273 | 274 | scoreController = Resources.FindObjectsOfTypeAll().LastOrDefault(); 275 | ComboUIController comboUIController = Resources.FindObjectsOfTypeAll().LastOrDefault(); 276 | energyCounter = Resources.FindObjectsOfTypeAll().LastOrDefault(); 277 | rankCounter = Resources.FindObjectsOfTypeAll().LastOrDefault(); 278 | audioController = Resources.FindObjectsOfTypeAll().LastOrDefault(); 279 | endActions = Resources.FindObjectsOfTypeAll().LastOrDefault(); 280 | if (endActions == null) 281 | endActions = Resources.FindObjectsOfTypeAll().LastOrDefault(); 282 | 283 | if (endActions != null && scoreController != null && energyCounter != null && rankCounter != null && audioController != null && comboUIController != null) { 284 | objectManager = (BeatmapObjectManager)_beatmapObjectManager.GetValue(scoreController); 285 | comboController = (ComboController)_comboController.GetValue(comboUIController); 286 | objectManager.noteWasCutEvent += NoteHit; 287 | objectManager.noteWasMissedEvent += NoteMiss; 288 | comboController.comboBreakingEventHappenedEvent += ComboBreak; 289 | endActions.levelFinishedEvent += LevelFinished; 290 | endActions.levelFailedEvent += LevelFinished; 291 | if (endActions is StandardLevelGameplayManager) pauseController = (PauseController)StandardLevelGameplayManager_pauseController.GetValue(endActions); 292 | else pauseController = (PauseController)MissionLevelGameplayManager_pauseController.GetValue(endActions); 293 | pauseController.didReturnToMenuEvent += LevelExit; 294 | Logger.log.Debug("PerformanceMeter reloaded successfully"); 295 | } else { 296 | Logger.log.Error("Could not reload PerformanceMeter. This may occur when playing online - if so, disregard this message."); 297 | scoreController = null; 298 | objectManager = null; 299 | comboController = null; 300 | energyCounter = null; 301 | rankCounter = null; 302 | audioController = null; 303 | endActions = null; 304 | pauseController = null; 305 | } 306 | } 307 | 308 | private class ScoreFinishEventHandler : ICutScoreBufferDidFinishReceiver { 309 | private PerformanceMeterController controller; 310 | private NoteData data; 311 | private bool secondary; 312 | internal ScoreFinishEventHandler(PerformanceMeterController c, NoteData d, bool s) {controller = c; data = d; secondary = s;} 313 | public void HandleCutScoreBufferDidFinish(CutScoreBuffer cutScoreBuffer) { 314 | if (!secondary) 315 | controller.RecordHitValue(cutScoreBuffer, data, this); 316 | else 317 | controller.RecordHitValueSecondary(cutScoreBuffer, data, this); 318 | } 319 | } 320 | 321 | private void RecordHitValue(CutScoreBuffer score, NoteData data, ScoreFinishEventHandler fn) { 322 | float newEnergy; 323 | switch (PluginConfig.Instance.mode) { 324 | case PluginConfig.MeasurementMode.Energy: 325 | newEnergy = energyCounter.energy; 326 | break; 327 | case PluginConfig.MeasurementMode.PercentModified: 328 | newEnergy = (float)scoreController.modifiedScore / scoreController.immediateMaxPossibleModifiedScore; 329 | break; 330 | case PluginConfig.MeasurementMode.PercentRaw: 331 | newEnergy = rankCounter.relativeScore; 332 | break; 333 | case PluginConfig.MeasurementMode.CutValue: 334 | if (score == null) 335 | return; 336 | 337 | newEnergy = score.cutScore / 115.0f; 338 | break; 339 | case PluginConfig.MeasurementMode.AvgCutValue: 340 | if (score == null) 341 | return; 342 | 343 | averageHitValue = ((averageHitValue * averageHitValueSize) + score.cutScore / 115.0f) / ++averageHitValueSize; 344 | newEnergy = averageHitValue; 345 | break; 346 | default: 347 | Logger.log.Error("An invalid mode was specified! PerformanceMeter will not record scores, resulting in a blank graph. Check the readme for the valid modes."); 348 | return; 349 | } 350 | 351 | if (energyList.Count == 0) 352 | energyList.Add(new Pair(0, newEnergy)); 353 | energyList.Add(new Pair(data.time, newEnergy)); 354 | 355 | if (score != null) 356 | score.UnregisterDidFinishReceiver(fn); 357 | } 358 | 359 | private void RecordHitValueSecondary(CutScoreBuffer score, NoteData data, ScoreFinishEventHandler fn) { 360 | float newEnergy; 361 | switch (PluginConfig.Instance.secondaryMode) { 362 | case PluginConfig.MeasurementMode.None: 363 | return; 364 | case PluginConfig.MeasurementMode.Energy: 365 | newEnergy = energyCounter.energy; 366 | break; 367 | case PluginConfig.MeasurementMode.PercentModified: 368 | newEnergy = (float)scoreController.modifiedScore / scoreController.immediateMaxPossibleModifiedScore; 369 | break; 370 | case PluginConfig.MeasurementMode.PercentRaw: 371 | newEnergy = rankCounter.relativeScore; 372 | break; 373 | case PluginConfig.MeasurementMode.CutValue: 374 | if (score == null) 375 | return; 376 | 377 | newEnergy = score.cutScore / 115.0f; 378 | break; 379 | case PluginConfig.MeasurementMode.AvgCutValue: 380 | if (score == null) 381 | return; 382 | 383 | secondaryAverageHitValue = ((secondaryAverageHitValue * secondaryAverageHitValueSize) + score.cutScore / 115.0f) / ++secondaryAverageHitValueSize; 384 | newEnergy = secondaryAverageHitValue; 385 | break; 386 | default: 387 | Logger.log.Error("An invalid mode was specified! PerformanceMeter will not record scores, resulting in a blank graph. Check the readme for the valid modes."); 388 | return; 389 | } 390 | 391 | if (secondaryEnergyList.Count == 0) 392 | secondaryEnergyList.Add(new Pair(0, newEnergy)); 393 | secondaryEnergyList.Add(new Pair(data.time, newEnergy)); 394 | 395 | if (score != null) 396 | score.UnregisterDidFinishReceiver(fn); 397 | } 398 | 399 | private void NoteHit(NoteController controller, in NoteCutInfo info) { 400 | PluginConfig.MeasurementSide side = PluginConfig.Instance.side; 401 | if (side == PluginConfig.MeasurementSide.Both || (side == PluginConfig.MeasurementSide.Left && info.saberType == SaberType.SaberA) || (side == PluginConfig.MeasurementSide.Right && info.saberType == SaberType.SaberB)) { 402 | if (info.noteData == null) { 403 | RecordHitValue(null, controller.noteData, null); 404 | } else { 405 | ScoreFinishEventHandler handler = new ScoreFinishEventHandler(this, controller.noteData, false); 406 | CutScoreBuffer buf = new CutScoreBuffer(); 407 | buf.Init(info); 408 | buf.RegisterDidFinishReceiver(handler); 409 | } 410 | } 411 | side = PluginConfig.Instance.secondarySide; 412 | if (side == PluginConfig.MeasurementSide.Both || (side == PluginConfig.MeasurementSide.Left && info.saberType == SaberType.SaberA) || (side == PluginConfig.MeasurementSide.Right && info.saberType == SaberType.SaberB)) { 413 | if (info.noteData == null) { 414 | RecordHitValueSecondary(null, controller.noteData, null); 415 | } else { 416 | ScoreFinishEventHandler handler = new ScoreFinishEventHandler(this, controller.noteData, true); 417 | CutScoreBuffer buf = new CutScoreBuffer(); 418 | buf.Init(info); 419 | buf.RegisterDidFinishReceiver(handler); 420 | } 421 | } 422 | } 423 | 424 | private void NoteMiss(NoteController controller) { 425 | RecordHitValue(null, controller.noteData, null); 426 | RecordHitValueSecondary(null, controller.noteData, null); 427 | } 428 | 429 | private void ComboBreak() { 430 | misses.Add(audioController.songTime); 431 | } 432 | 433 | private void LevelFinished() { 434 | if (objectManager != null && energyCounter != null && rankCounter != null && endActions != null) { 435 | levelOk = true; 436 | objectManager.noteWasCutEvent -= NoteHit; 437 | objectManager.noteWasMissedEvent -= NoteMiss; 438 | comboController.comboBreakingEventHappenedEvent -= ComboBreak; 439 | endActions.levelFinishedEvent -= LevelFinished; 440 | endActions.levelFailedEvent -= LevelFinished; 441 | pauseController.didReturnToMenuEvent -= LevelExit; 442 | } 443 | } 444 | 445 | private void LevelExit() { 446 | if (!levelOk) levelNotOk = true; 447 | } 448 | 449 | #region Monobehaviour Messages 450 | /// 451 | /// Only ever called once, mainly used to initialize variables. 452 | /// 453 | private void Awake() { 454 | // For this particular MonoBehaviour, we only want one instance to exist at any time, so store a reference to it in a static property 455 | // and destroy any that are created while one already exists. 456 | if (instance != null) { 457 | Logger.log?.Warn($"Instance of {this.GetType().Name} already exists, destroying."); 458 | DestroyImmediate(this); 459 | return; 460 | } 461 | DontDestroyOnLoad(this); // Don't destroy this object on scene changes 462 | instance = this; 463 | Logger.log?.Debug($"{name}: Awake()"); 464 | } 465 | 466 | /// 467 | /// Called when the script is being destroyed. 468 | /// 469 | private void OnDestroy() { 470 | Logger.log?.Debug($"{name}: OnDestroy()"); 471 | instance = null; // This MonoBehaviour is being destroyed, so set the static instance property to null. 472 | } 473 | #endregion 474 | } 475 | } -------------------------------------------------------------------------------- /Plugin.cs: -------------------------------------------------------------------------------- 1 | /* 2 | * Plugin.cs 3 | * PerformanceMeter 4 | * 5 | * This file defines the entry points of PerformanceMeter. 6 | * 7 | * This code is licensed under the MIT license. 8 | * Copyright (c) 2021-2022 JackMacWindows. 9 | */ 10 | 11 | using IPA; 12 | using IPA.Config.Stores; 13 | using UnityEngine.SceneManagement; 14 | using UnityEngine; 15 | using IPALogger = IPA.Logging.Logger; 16 | using BS_Utils.Utilities; 17 | using BeatSaberMarkupLanguage.Settings; 18 | 19 | namespace PerformanceMeter { 20 | [Plugin(RuntimeOptions.SingleStartInit)] 21 | public class Plugin { 22 | internal static Plugin instance { get; private set; } 23 | internal static string Name => "PerformanceMeter"; 24 | 25 | [Init] 26 | /// 27 | /// Called when the plugin is first loaded by IPA (either when the game starts or when the plugin is enabled if it starts disabled). 28 | /// [Init] methods that use a Constructor or called before regular methods like InitWithConfig. 29 | /// Only use [Init] with one Constructor. 30 | /// 31 | public void Init(IPALogger logger, IPA.Config.Config conf) { 32 | instance = this; 33 | Logger.log = logger; 34 | PluginConfig.Instance = conf.Generated(); 35 | Logger.log.Debug("Logger initialized."); 36 | } 37 | 38 | [OnStart] 39 | public void OnApplicationStart() { 40 | Logger.log.Debug("OnApplicationStart"); 41 | new GameObject("PerformanceMeterController").AddComponent(); 42 | BSEvents.gameSceneLoaded += GameSceneActive; 43 | SceneManager.activeSceneChanged += ActiveSceneChanged; 44 | BSMLSettings.instance.AddSettingsMenu("PerformanceMeter", "PerformanceMeter.Settings", Settings.instance); 45 | } 46 | 47 | [OnExit] 48 | public void OnApplicationQuit() { 49 | Logger.log.Debug("OnApplicationQuit"); 50 | BSEvents.gameSceneActive -= GameSceneActive; 51 | SceneManager.activeSceneChanged -= ActiveSceneChanged; 52 | } 53 | 54 | void GameSceneActive() { 55 | if (PluginConfig.Instance.enabled) 56 | PerformanceMeterController.instance.GetControllers(); 57 | } 58 | 59 | void ActiveSceneChanged(Scene oldScene, Scene newScene) { 60 | if (PluginConfig.Instance.enabled && newScene.name == "MainMenu") 61 | PerformanceMeterController.instance.ShowResults(); 62 | } 63 | } 64 | 65 | internal static class Logger { 66 | internal static IPALogger log { get; set; } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /PluginConfig.cs: -------------------------------------------------------------------------------- 1 | /* 2 | * PluginConfig.cs 3 | * PerformanceMeter 4 | * 5 | * This file defines the configuration of PerformanceMeter. 6 | * 7 | * This code is licensed under the MIT license. 8 | * Copyright (c) 2021-2022 JackMacWindows. 9 | */ 10 | 11 | using System.Runtime.CompilerServices; 12 | using IPA.Config.Data; 13 | using IPA.Config.Stores; 14 | using IPA.Config.Stores.Attributes; 15 | using IPA.Config.Stores.Converters; 16 | using UnityEngine; 17 | 18 | [assembly: InternalsVisibleTo(GeneratedStore.AssemblyVisibilityTarget)] 19 | namespace PerformanceMeter { 20 | internal class IntColorConverter : ValueConverter { 21 | public override Color FromValue(Value value, object parent) { 22 | if (!(value is Integer)) throw new System.ArgumentException("Input value is not an integer"); 23 | long num = (value as Integer).Value; 24 | return new Color(((num >> 16) & 0xFF) / 255.0f, ((num >> 8) & 0xFF) / 255.0f, (num & 0xFF) / 255.0f); 25 | } 26 | 27 | public override Value ToValue(Color obj, object parent) { 28 | return new Integer((long)(obj.r * 255) << 16 | (long)(obj.g * 255) << 8 | (long)(obj.b * 255)); 29 | } 30 | } 31 | 32 | public class PluginConfig { 33 | public static PluginConfig Instance { get; set; } 34 | public virtual bool enabled { get; set; } = true; 35 | [UseConverter(typeof(NumericEnumConverter))] 36 | public virtual MeasurementMode mode { get; set; } = MeasurementMode.Energy; 37 | [UseConverter(typeof(NumericEnumConverter))] 38 | public virtual MeasurementSide side { get; set; } = MeasurementSide.Both; 39 | [UseConverter(typeof(IntColorConverter))] 40 | public Color sideColor { 41 | get { 42 | return side switch { 43 | MeasurementSide.Left => Color.red, 44 | MeasurementSide.Right => Color.blue, 45 | MeasurementSide.Both => Color.white, 46 | _ => Color.white, 47 | }; 48 | } 49 | } 50 | [UseConverter(typeof(NumericEnumConverter))] 51 | public virtual MeasurementMode secondaryMode { get; set; } = MeasurementMode.None; 52 | [UseConverter(typeof(NumericEnumConverter))] 53 | public virtual MeasurementSide secondarySide { get; set; } = MeasurementSide.Both; 54 | [UseConverter(typeof(IntColorConverter))] 55 | public Color secondarySideColor { 56 | get { 57 | return secondarySide switch { 58 | MeasurementSide.Left => Color.red, 59 | MeasurementSide.Right => Color.blue, 60 | MeasurementSide.Both => Color.white, 61 | _ => Color.white, 62 | }; 63 | } 64 | } 65 | public virtual bool showMisses { get; set; } = false; 66 | public virtual float animationDuration { get; set; } = 3.0f; 67 | [UseConverter(typeof(IntColorConverter))] 68 | public virtual Color color { get; set; } = new Color(1f, 0f, 0f); 69 | [UseConverter(typeof(IntColorConverter))] 70 | public virtual Color secondaryColor { get; set; } = new Color(0f, 0f, 1f); 71 | public virtual bool overrideColor { get; set; } = false; 72 | public virtual bool overrideSecondaryColor { get; set; } = false; 73 | 74 | public enum MeasurementMode { 75 | Energy, 76 | PercentModified, 77 | PercentRaw, 78 | CutValue, 79 | AvgCutValue, 80 | None 81 | }; 82 | 83 | public enum MeasurementSide { 84 | Left, 85 | Right, 86 | Both 87 | } 88 | 89 | /// 90 | /// This is called whenever BSIPA reads the config from disk (including when file changes are detected). 91 | /// 92 | public virtual void OnReload() { 93 | // Do stuff after config is read from disk. 94 | } 95 | 96 | /// 97 | /// Call this to force BSIPA to update the config file. This is also called by BSIPA if it detects the file was modified. 98 | /// 99 | public virtual void Changed() { 100 | // Do stuff when the config is changed. 101 | Logger.log.Debug("Updated configuration"); 102 | } 103 | 104 | /// 105 | /// Call this to have BSIPA copy the values from into this config. 106 | /// 107 | public virtual void CopyFrom(PluginConfig other) { 108 | // This instance's members populated from other 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Runtime.CompilerServices; 3 | using System.Runtime.InteropServices; 4 | 5 | // General Information about an assembly is controlled through the following 6 | // set of attributes. Change these attribute values to modify the information 7 | // associated with an assembly. 8 | [assembly: AssemblyTitle("PerformanceMeter")] 9 | [assembly: AssemblyDescription("PerformanceMeter Beat Saber Mod")] 10 | [assembly: AssemblyConfiguration("")] 11 | [assembly: AssemblyCompany("")] 12 | [assembly: AssemblyProduct("PerformanceMeter")] 13 | [assembly: AssemblyCopyright("Copyright © 2021-2022 JackMacWindows")] 14 | [assembly: AssemblyTrademark("")] 15 | [assembly: AssemblyCulture("")] 16 | 17 | // Setting ComVisible to false makes the types in this assembly not visible 18 | // to COM components. If you need to access a type in this assembly from 19 | // COM, set the ComVisible attribute to true on that type. 20 | [assembly: ComVisible(false)] 21 | 22 | // The following GUID is for the ID of the typelib if this project is exposed to COM 23 | [assembly: Guid("b0463183-7261-40f8-8610-07e230da9110")] 24 | 25 | // Version information for an assembly consists of the following four values: 26 | // 27 | // Major Version 28 | // Minor Version 29 | // Build Number 30 | // Revision 31 | // 32 | // You can specify all the values or you can default the Build and Revision Numbers 33 | // by using the '*' as shown below: 34 | // [assembly: AssemblyVersion("1.0.*")] 35 | [assembly: AssemblyVersion("1.4.2")] 36 | [assembly: AssemblyFileVersion("1.4.2")] 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PerformanceMeter 2 | A Beat Saber mod to show a graph of your energy bar, percentage level, or cut value throughout a map on the end screen. 3 | 4 | ![Image](screenshot.png) 5 | 6 | ## Requirements 7 | * Beat Saber 1.20.0 or compatible 8 | * BSIPA 4.2.2 9 | * Beat Saber Utils 1.12.1 10 | * BeatSaberMarkupLanguage 1.6.3 11 | 12 | ## Installation 13 | Simply drop the latest PerformanceMeter.dll plugin file into your Plugins folder, inside the main Beat Saber installation directory. 14 | 15 | ## Usage 16 | Out of the box, PerformanceMeter displays a graph of the energy bar's status from the beginning of the level to the end, whether that's the end of the level or whenever you failed. This is shown on the level complete screen underneath the buttons. The lines are colored depending on the value of the endpoint and the color scheme of the selected mode. 17 | 18 | You can change the type of data PerformanceMeter records in the Mod Settings. See below for more information on how to do this. 19 | 20 | Note: PerformanceMeter only appears in Solo, Party, and Campaign game modes. It does not appear in online matches. 21 | 22 | ## Configuration 23 | ### UI 24 | PerformanceMeter can be configured in the Mod Settings section of the options. Here you can enable/disable PerformanceMeter, change the mode and side for primary and secondary graphs, and toggle showing missed notes. 25 | 26 | As of PerformanceMeter 1.2.0, two graphs can be displayed at the same time. These graphs will be displayed together on the Performance chart. To be able to discern the two graphs, it is recommended that you set the sides of each graph to different values to show them in different colors. Note that changing the sides of Cut Value modes will also change which hand is counted. 27 | 28 | These are the modes available as of 1.3.0: 29 | 30 | #### Energy 31 | This mode records the level of the energy bar on every note hit or miss. 32 | 33 | | Max % | Min % | Color | 34 | |-------|-------|--------| 35 | | 100% | 50% | Green | 36 | | 49% | 25% | Yellow | 37 | | 24% | 0% | Red | 38 | 39 | #### Percentage (Modified) 40 | This mode records the percentage with all modifiers applied on every note hit or miss. 41 | 42 | | Max % | Min % | Color | 43 | |-------|-------|--------| 44 | | 100%+ | 90% | Cyan | 45 | | 89% | 80% | White | 46 | | 79% | 65% | Green | 47 | | 64% | 50% | Yellow | 48 | | 49% | 35% | Orange | 49 | | 34% | 0% | Red | 50 | 51 | #### Percentage (Raw) 52 | This mode records the percentage with no modifiers applied on every note hit or miss. 53 | 54 | | Max % | Min % | Color | 55 | |-------|-------|--------| 56 | | 100% | 90% | Cyan | 57 | | 89% | 80% | White | 58 | | 79% | 65% | Green | 59 | | 64% | 50% | Yellow | 60 | | 49% | 35% | Orange | 61 | | 34% | 0% | Red | 62 | 63 | #### Note Cut Value 64 | This mode records the score given for each note cut. 65 | | Max Score | Min Score | Color | 66 | |-----------|-----------|----------| 67 | | 115 | 115 | White | 68 | | 114 | 101 | Green | 69 | | 100 | 90 | Yellow | 70 | | 89 | 80 | Orange | 71 | | 79 | 60 | Red | 72 | | 59 | 0 | Dark Red | 73 | 74 | #### Average Cut Value 75 | This mode records the average score of all cuts up to the current one on every note hit. 76 | 77 | | Max Score | Min Score | Color | 78 | |-----------|-----------|----------| 79 | | 115 | 115 | White | 80 | | 114 | 101 | Green | 81 | | 100 | 90 | Yellow | 82 | | 89 | 80 | Orange | 83 | | 79 | 60 | Red | 84 | | 59 | 0 | Dark Red | 85 | 86 | More modes may be added in the future. 87 | 88 | ### JSON 89 | PerformanceMeter's configuration file is stored at `UserData\PerformanceMeter.json`. Here you can change some options regarding how PerformanceMeter looks and acts. 90 | 91 | #### `enabled` 92 | This toggles PerformanceMeter on and off. When set to `false`, recording is disabled and the graph will not be shown. 93 | 94 | #### `mode` 95 | This changes what data PerformanceMeter records in-game. These are the mappings between ID and mode statistic: 96 | 97 | | ID | Statistic | 98 | |----|-----------------------| 99 | | 0 | Energy | 100 | | 1 | Percentage (Modified) | 101 | | 2 | Percentage (Raw) | 102 | | 3 | Note Cut Value | 103 | | 4 | Average Cut Value | 104 | | 5 | None | 105 | 106 | #### `side` 107 | This changes the side which PerformanceMeter records data for. This only changes collection for Cut Value-type modes, but it changes the colors for all modes. 108 | 109 | | ID | Side | Color | 110 | |----|-------|-----------| 111 | | 0 | Left | Red | 112 | | 1 | Right | Blue | 113 | | 2 | Both | No change | 114 | 115 | #### `secondaryMode`, `secondarySide` 116 | These function the same as `mode` and `side`, respectively, but display another line to be used with a different mode. 117 | 118 | #### `showMisses` 119 | This enables displaying vertical bars on the graph at each point a note is missed. When set to `false`, no bars will be displayed; when `true`, bars will be shown. 120 | 121 | #### `animationDuration` 122 | This sets how long the graph reveal animation takes to complete. If set to 0, no animation will be played. 123 | 124 | #### `[secondary]Color` 125 | These set the color for each graph. Colors are 24-bit hexadecimal colors (e.g. `0xFF8000` is orange). This setting has no effect unless the corresponding `overrideColor` setting (below) is enabled. 126 | 127 | #### `override[Secondary]Color` 128 | These toggle whether the override color is enabled for each graph. If set to `true`, the color in `[secondary]Color` will be used; otherwise the default color will be used. 129 | 130 | ## Special Thanks 131 | Thanks to @SHv2 for rewriting a significant chunk of the code to improve style and performance. 132 | 133 | ## License 134 | PerformanceMeter is licensed under the MIT license. See LICENSE for more info. -------------------------------------------------------------------------------- /Settings.bsml: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /Settings.cs: -------------------------------------------------------------------------------- 1 | /* 2 | * Settings.cs 3 | * PerformanceMeter 4 | * 5 | * This file defines the settings panel controller. 6 | * 7 | * This code is licensed under the MIT license. 8 | * Copyright (c) 2021-2022 JackMacWindows. 9 | */ 10 | 11 | using BeatSaberMarkupLanguage.Attributes; 12 | using BeatSaberMarkupLanguage.Util; 13 | using System.Collections.Generic; 14 | using System.Linq; 15 | 16 | 17 | namespace PerformanceMeter { 18 | internal class Settings : PersistentSingleton { 19 | [UIValue("mode-options")] 20 | public List modeOptions = new object[] { "Energy", "Percentage (Modified)", "Percentage (Raw)", "Note Cut Value", "Average Cut Value" }.ToList(); 21 | [UIValue("secondary-mode-options")] 22 | public List secondaryModeOptions = new object[] { "Energy", "Percentage (Modified)", "Percentage (Raw)", "Note Cut Value", "Average Cut Value", "None" }.ToList(); 23 | [UIValue("side-options")] 24 | public List sideOptions = new object[] { "Left", "Right", "Both" }.ToList(); 25 | 26 | [UIValue("mode")] 27 | public string listChoice = "Energy"; 28 | 29 | [UIValue("side")] 30 | public string sideChoice = "Both"; 31 | 32 | [UIValue("secondaryMode")] 33 | public string secondaryListChoice = "None"; 34 | 35 | [UIValue("secondarySide")] 36 | public string secondarySideChoice = "Both"; 37 | 38 | [UIValue("enabled")] 39 | public bool _enabled = PluginConfig.Instance.enabled; 40 | 41 | [UIValue("showMisses")] 42 | public bool showMisses = PluginConfig.Instance.showMisses; 43 | 44 | [UIValue("animationDuration")] 45 | public float animationDuration = PluginConfig.Instance.animationDuration; 46 | 47 | [UIValue("overrideColor")] 48 | public bool overrideColor = PluginConfig.Instance.overrideColor; 49 | 50 | [UIValue("overrideSecondaryColor")] 51 | public bool overrideSecondaryColor = PluginConfig.Instance.overrideSecondaryColor; 52 | 53 | [UIValue("color")] 54 | public UnityEngine.Color color = PluginConfig.Instance.color; 55 | 56 | [UIValue("secondaryColor")] 57 | public UnityEngine.Color secondaryColor = PluginConfig.Instance.secondaryColor; 58 | 59 | [UIAction("#apply")] 60 | public void OnApply() { 61 | PluginConfig.Instance.enabled = _enabled; 62 | PluginConfig.Instance.showMisses = showMisses; 63 | PluginConfig.Instance.animationDuration = animationDuration; 64 | PluginConfig.Instance.overrideColor = overrideColor; 65 | PluginConfig.Instance.overrideSecondaryColor = overrideSecondaryColor; 66 | PluginConfig.Instance.color = color; 67 | PluginConfig.Instance.secondaryColor = secondaryColor; 68 | PluginConfig.Instance.mode = (PluginConfig.MeasurementMode)modeOptions.FindIndex(a => a.ToString() == listChoice); 69 | PluginConfig.Instance.secondaryMode = (PluginConfig.MeasurementMode)secondaryModeOptions.FindIndex(a => a.ToString() == secondaryListChoice); 70 | PluginConfig.Instance.side = (PluginConfig.MeasurementSide)sideOptions.FindIndex(a => a.ToString() == sideChoice); 71 | PluginConfig.Instance.secondarySide = (PluginConfig.MeasurementSide)sideOptions.FindIndex(a => a.ToString() == secondarySideChoice); 72 | PluginConfig.Instance.Changed(); 73 | } 74 | 75 | public Settings() { 76 | listChoice = modeOptions[(int)PluginConfig.Instance.mode] as string; 77 | sideChoice = sideOptions[(int)PluginConfig.Instance.side] as string; 78 | secondaryListChoice = secondaryModeOptions[(int)PluginConfig.Instance.secondaryMode] as string; 79 | secondarySideChoice = sideOptions[(int)PluginConfig.Instance.secondarySide] as string; 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /WindowGraph.cs: -------------------------------------------------------------------------------- 1 | /* 2 | ------------------- Code Monkey ------------------- 3 | 4 | Thank you for downloading this package 5 | I hope you find it useful in your projects 6 | If you have any questions let me know 7 | Cheers! 8 | 9 | unitycodemonkey.com 10 | -------------------------------------------------- 11 | */ 12 | 13 | using System; 14 | using System.Collections.Generic; 15 | using UnityEngine; 16 | using UnityEngine.UI; 17 | 18 | namespace PerformanceMeter { 19 | public class WindowGraph : MonoBehaviour { 20 | public RectTransform GraphContainer { get; private set; } 21 | public List LinkObjects { get; private set; } 22 | 23 | public delegate Color ColorMode_Selection(float dotPositionRage); 24 | private ColorMode_Selection dotColor; 25 | 26 | private void Awake() { 27 | GraphContainer = transform.Find("GraphContainer").GetComponent(); 28 | if (GraphContainer == null) 29 | Logger.log.Error("Could not find GraphContainer"); 30 | 31 | LinkObjects = new List(); 32 | } 33 | 34 | public void ShowGraph(List> valueList, PluginConfig.MeasurementMode mode, float xMaximum, bool colorOverride, Color sideColor, bool isPrimaryMode) { 35 | var graphWidth = GraphContainer.sizeDelta.x; 36 | var graphHeight = GraphContainer.sizeDelta.y; 37 | var xStep = graphWidth / xMaximum; 38 | 39 | setColorMode(mode, isPrimaryMode, colorOverride, sideColor); 40 | 41 | var xPosition = valueList[0].first * xStep; 42 | var yPosition = valueList[0].second * graphHeight; 43 | var newPosition = new Vector2(xPosition, yPosition); 44 | var lastPosition = newPosition; 45 | GameObject dotConnectionGameObject; 46 | for (var i = 1; i < valueList.Count; i++) { 47 | xPosition = valueList[i].first * xStep; 48 | yPosition = valueList[i].second * graphHeight; 49 | newPosition = new Vector2(xPosition, yPosition); 50 | dotConnectionGameObject = CreateDotConnection(lastPosition, newPosition, graphHeight); 51 | LinkObjects.Add(dotConnectionGameObject); 52 | lastPosition = newPosition; 53 | } 54 | } 55 | 56 | private GameObject CreateDotConnection(Vector2 dotPositionA, Vector2 dotPositionB, float graphHeight) { 57 | float dotPositionRange = dotPositionB.y / graphHeight; 58 | var gameObject = new GameObject("DotConnection", typeof(Image)); 59 | 60 | gameObject.transform.SetParent(GraphContainer, false); 61 | var image = gameObject.GetComponent(); 62 | 63 | image.color = dotColor(dotPositionRange); 64 | image.enabled = true; 65 | var rectTransform = gameObject.GetComponent(); 66 | var dir = (dotPositionB - dotPositionA).normalized; 67 | var distance = Vector2.Distance(dotPositionA, dotPositionB); 68 | rectTransform.anchorMin = new Vector2(0, 0); 69 | rectTransform.anchorMax = new Vector2(0, 0); 70 | rectTransform.sizeDelta = new Vector2(distance, 0.01f); 71 | rectTransform.anchoredPosition = dotPositionA + dir * distance * .5f; 72 | rectTransform.localEulerAngles = new Vector3(0, 0, Mathf.Atan2(dir.y, dir.x) * Mathf.Rad2Deg); 73 | return gameObject; 74 | } 75 | 76 | private void setColorMode(PluginConfig.MeasurementMode mode, bool isPrimaryMode, bool colorOverride, Color sideColor) { 77 | if (colorOverride) { 78 | dotColor = isPrimaryMode ? ColorMode_Override : ColorMode_SecondaryOverride; 79 | } else if (sideColor != Color.white) { 80 | dotColor = isPrimaryMode ? ColorMode_Side : ColorMode_SecondarySide; 81 | } else { 82 | switch (mode) { 83 | case PluginConfig.MeasurementMode.Energy: 84 | dotColor = ColorMode_Energy; 85 | break; 86 | case PluginConfig.MeasurementMode.PercentModified: 87 | case PluginConfig.MeasurementMode.PercentRaw: 88 | dotColor = ColorMode_PercentModifedRaw; 89 | break; 90 | case PluginConfig.MeasurementMode.CutValue: 91 | case PluginConfig.MeasurementMode.AvgCutValue: 92 | dotColor = ColorMode_CutAvgCut; 93 | break; 94 | } 95 | } 96 | } 97 | 98 | private Color ColorMode_Override(float notUsed) { 99 | return PluginConfig.Instance.color; 100 | } 101 | 102 | private Color ColorMode_SecondaryOverride(float notUsed) { 103 | return PluginConfig.Instance.secondaryColor; 104 | } 105 | private Color ColorMode_Side(float notUsed) { 106 | return PluginConfig.Instance.sideColor; 107 | } 108 | 109 | private Color ColorMode_SecondarySide(float notUsed) { 110 | return PluginConfig.Instance.secondarySideColor; 111 | } 112 | 113 | private Color ColorMode_Energy(float dotPositionRange) { 114 | if (dotPositionRange == 1.0) return Color.white; 115 | else if (dotPositionRange >= 0.5) return Color.green; 116 | else if (dotPositionRange >= 0.25) return Color.yellow; 117 | else return Color.red; 118 | } 119 | 120 | private Color ColorMode_PercentModifedRaw(float dotPositionRange) { 121 | if (dotPositionRange >= 0.9) return Color.cyan; 122 | else if (dotPositionRange >= 0.8) return Color.white; 123 | else if (dotPositionRange >= 0.65) return Color.green; 124 | else if (dotPositionRange >= 0.5) return Color.yellow; 125 | else if (dotPositionRange >= 0.35) return new Color(1.0f, 0.5f, 0.0f, 1.0f); 126 | else return Color.red; 127 | } 128 | 129 | private Color ColorMode_CutAvgCut(float dotPositionRange) { 130 | if (dotPositionRange == 1.0) return Color.white; 131 | else if (dotPositionRange >= 0.87) return Color.green; // ~ 101.0/115.0 132 | else if (dotPositionRange >= 0.78) return Color.yellow; // ~ 90.0/115.0 133 | else if (dotPositionRange >= 0.69) return new Color(1.0f, 0.6f, 0.0f); // ~ 80.0/115.0 134 | else if (dotPositionRange >= 0.52) return Color.red; // ~ 60.0/115.0 135 | else return new Color(0.5f, 0.0f, 0.0f); 136 | } 137 | } 138 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/beat-saber-modding-group/BSIPA-MetadataFileSchema/master/Schema.json", 3 | "id": "PerformanceMeter", 4 | "name": "PerformanceMeter", 5 | "author": "JackMacWindows", 6 | "version": "1.4.2", 7 | "description": "Shows a graph of your energy bar, percentage level, or cut value throughout a map on the end screen.", 8 | "gameVersion": "1.31.0", 9 | "dependsOn": { 10 | "BSIPA": "^4.3.0", 11 | "BS Utils": "^1.12.4", 12 | "BeatSaberMarkupLanguage": "^1.7.6" 13 | }, 14 | "features": [], 15 | "links": { 16 | "project-source": "https://github.com/MCJack123/PerformanceMeter" 17 | } 18 | } -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MCJack123/PerformanceMeter/40d2b3f55865859f8b6bf3e8c2a468a28f5193ab/screenshot.png --------------------------------------------------------------------------------