├── SongLoaderPlugin
├── LogSeverity.cs
├── IScriptableObjectResetable.cs
├── packages.config
├── OverrideClasses
│ ├── CustomBeatmapDataSO.cs
│ ├── CustomLevelCollectionSO.cs
│ ├── CustomLevelCollectionForGameplayMode.cs
│ ├── CustomLevelCollectionsForGameplayModes.cs
│ └── CustomLevel.cs
├── Plugin.cs
├── SongLoaderPlugin.sln
├── ScriptableObjectPool.cs
├── Properties
│ └── AssemblyInfo.cs
├── Utils.cs
├── ReflectionUtil.cs
├── NoteHitVolumeChanger.cs
├── CustomSongInfo.cs
├── ProgressBar.cs
├── SongLoaderPlugin.csproj
├── Internals
│ └── Unzip.cs
├── SongLoader.cs
└── SimpleJSON.cs
├── LICENSE
├── README.md
└── .gitignore
/SongLoaderPlugin/LogSeverity.cs:
--------------------------------------------------------------------------------
1 | namespace SongLoaderPlugin
2 | {
3 | public enum LogSeverity
4 | {
5 | Info,
6 | Warn,
7 | Error
8 | }
9 | }
--------------------------------------------------------------------------------
/SongLoaderPlugin/IScriptableObjectResetable.cs:
--------------------------------------------------------------------------------
1 | namespace SongLoaderPlugin
2 | {
3 | public interface IScriptableObjectResetable
4 | {
5 | void Reset();
6 | }
7 | }
--------------------------------------------------------------------------------
/SongLoaderPlugin/packages.config:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/SongLoaderPlugin/OverrideClasses/CustomBeatmapDataSO.cs:
--------------------------------------------------------------------------------
1 | namespace SongLoaderPlugin.OverrideClasses
2 | {
3 | public class CustomBeatmapDataSO : BeatmapDataSO, IScriptableObjectResetable
4 | {
5 | public string jsonData
6 | {
7 | get { return _jsonData; }
8 | }
9 | public void Reset()
10 | {
11 |
12 | }
13 | }
14 | }
--------------------------------------------------------------------------------
/SongLoaderPlugin/OverrideClasses/CustomLevelCollectionSO.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 |
3 | namespace SongLoaderPlugin.OverrideClasses
4 | {
5 | public class CustomLevelCollectionSO : StandardLevelCollectionSO
6 | {
7 | public List LevelList { get; private set; }
8 |
9 | public void Init(StandardLevelSO[] newLevels)
10 | {
11 | LevelList = new List(newLevels);
12 | }
13 | }
14 | }
--------------------------------------------------------------------------------
/SongLoaderPlugin/OverrideClasses/CustomLevelCollectionForGameplayMode.cs:
--------------------------------------------------------------------------------
1 | namespace SongLoaderPlugin.OverrideClasses
2 | {
3 | public class CustomLevelCollectionForGameplayMode : LevelCollectionsForGameplayModes.LevelCollectionForGameplayMode
4 | {
5 | public CustomLevelCollectionForGameplayMode(GameplayMode gameplayMode, StandardLevelCollectionSO newLevelCollection)
6 | {
7 | _levelCollection = newLevelCollection;
8 | _gameplayMode = gameplayMode;
9 | }
10 | }
11 | }
--------------------------------------------------------------------------------
/SongLoaderPlugin/Plugin.cs:
--------------------------------------------------------------------------------
1 | using IllusionPlugin;
2 | using UnityEngine;
3 |
4 | namespace SongLoaderPlugin
5 | {
6 | public class Plugin : IPlugin
7 | {
8 | public string Name
9 | {
10 | get { return "Song Loader Plugin"; }
11 | }
12 |
13 | public string Version
14 | {
15 | get { return "v4.2.2"; }
16 | }
17 |
18 | public void OnApplicationStart()
19 | {
20 |
21 | }
22 |
23 | public void OnApplicationQuit()
24 | {
25 | PlayerPrefs.DeleteKey("lbPatched");
26 | }
27 |
28 | public void OnLevelWasLoaded(int level)
29 | {
30 |
31 | }
32 |
33 | public void OnLevelWasInitialized(int level)
34 | {
35 | if (level != SongLoader.MenuIndex) return;
36 | SongLoader.OnLoad();
37 | }
38 |
39 | public void OnUpdate()
40 | {
41 |
42 | }
43 |
44 | public void OnFixedUpdate()
45 | {
46 |
47 | }
48 | }
49 | }
--------------------------------------------------------------------------------
/SongLoaderPlugin/SongLoaderPlugin.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SongLoaderPlugin", "SongLoaderPlugin.csproj", "{6F9B6801-9F4B-4D1F-805D-271C95733814}"
4 | EndProject
5 | Global
6 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
7 | Debug|Any CPU = Debug|Any CPU
8 | Release|Any CPU = Release|Any CPU
9 | EndGlobalSection
10 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
11 | {6F9B6801-9F4B-4D1F-805D-271C95733814}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
12 | {6F9B6801-9F4B-4D1F-805D-271C95733814}.Debug|Any CPU.Build.0 = Debug|Any CPU
13 | {6F9B6801-9F4B-4D1F-805D-271C95733814}.Release|Any CPU.ActiveCfg = Release|Any CPU
14 | {6F9B6801-9F4B-4D1F-805D-271C95733814}.Release|Any CPU.Build.0 = Release|Any CPU
15 | EndGlobalSection
16 | EndGlobal
17 |
--------------------------------------------------------------------------------
/SongLoaderPlugin/ScriptableObjectPool.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 | using System.Linq;
3 | using UnityEngine;
4 |
5 | namespace SongLoaderPlugin
6 | {
7 | public class ScriptableObjectPool where T : ScriptableObject, IScriptableObjectResetable
8 | {
9 | private readonly List _pool = new List();
10 | private readonly List _createdObj = new List();
11 |
12 | public T Get()
13 | {
14 | if (_pool.Count == 0)
15 | {
16 | var newObj = ScriptableObject.CreateInstance();
17 | _createdObj.Add(newObj);
18 | return newObj;
19 | }
20 |
21 | var fromPool = _pool.First();
22 | _pool.RemoveAt(0);
23 | return fromPool;
24 | }
25 |
26 | public void Return(T obj)
27 | {
28 | obj.Reset();
29 | _pool.Add(obj);
30 | }
31 |
32 | public void ReturnAll()
33 | {
34 | _pool.Clear();
35 | _createdObj.ForEach(x => x.Reset());
36 | _pool.AddRange(_createdObj);
37 | }
38 | }
39 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 xyonico
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.
22 |
--------------------------------------------------------------------------------
/SongLoaderPlugin/OverrideClasses/CustomLevelCollectionsForGameplayModes.cs:
--------------------------------------------------------------------------------
1 | namespace SongLoaderPlugin.OverrideClasses
2 | {
3 | public class CustomLevelCollectionsForGameplayModes : LevelCollectionsForGameplayModes
4 | {
5 | public override StandardLevelSO[] GetLevels(GameplayMode gameplayMode)
6 | {
7 | foreach (var levelCollectionForGameplayMode in _collections)
8 | {
9 | if (levelCollectionForGameplayMode.gameplayMode == gameplayMode)
10 | {
11 | var customLevelCollections = levelCollectionForGameplayMode as CustomLevelCollectionForGameplayMode;
12 | if (customLevelCollections != null)
13 | {
14 | var customLevelCollection = customLevelCollections.levelCollection as CustomLevelCollectionSO;
15 | if (customLevelCollection != null)
16 | {
17 | return customLevelCollection.LevelList.ToArray();
18 | }
19 | }
20 | return levelCollectionForGameplayMode.levelCollection.levels;
21 | }
22 | }
23 | return null;
24 | }
25 |
26 | public void SetCollections(LevelCollectionForGameplayMode[] levelCollectionForGameplayModes)
27 | {
28 | _collections = levelCollectionForGameplayModes;
29 | }
30 | }
31 | }
--------------------------------------------------------------------------------
/SongLoaderPlugin/Properties/AssemblyInfo.cs:
--------------------------------------------------------------------------------
1 | using System.Reflection;
2 | using System.Runtime.InteropServices;
3 |
4 | // General Information about an assembly is controlled through the following
5 | // set of attributes. Change these attribute values to modify the information
6 | // associated with an assembly.
7 | [assembly: AssemblyTitle("SongLoaderPlugin")]
8 | [assembly: AssemblyDescription("")]
9 | [assembly: AssemblyConfiguration("")]
10 | [assembly: AssemblyCompany("")]
11 | [assembly: AssemblyProduct("SongLoaderPlugin")]
12 | [assembly: AssemblyCopyright("Copyright © 2018")]
13 | [assembly: AssemblyTrademark("")]
14 | [assembly: AssemblyCulture("")]
15 |
16 | // Setting ComVisible to false makes the types in this assembly not visible
17 | // to COM components. If you need to access a type in this assembly from
18 | // COM, set the ComVisible attribute to true on that type.
19 | [assembly: ComVisible(false)]
20 |
21 | // The following GUID is for the ID of the typelib if this project is exposed to COM
22 | [assembly: Guid("6F9B6801-9F4B-4D1F-805D-271C95733814")]
23 |
24 | // Version information for an assembly consists of the following four values:
25 | //
26 | // Major Version
27 | // Minor Version
28 | // Build Number
29 | // Revision
30 | //
31 | // You can specify all the values or you can default the Build and Revision Numbers
32 | // by using the '*' as shown below:
33 | // [assembly: AssemblyVersion("1.0.*")]
34 | [assembly: AssemblyVersion("4.2.2")]
35 | [assembly: AssemblyFileVersion("4.2.2")]
--------------------------------------------------------------------------------
/SongLoaderPlugin/Utils.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Security.Cryptography;
3 | using System.Text;
4 | using System.IO;
5 |
6 | namespace SongLoaderPlugin
7 | {
8 | public static class Utils
9 | {
10 | public static TEnum ToEnum(this string strEnumValue, TEnum defaultValue)
11 | {
12 | if (!Enum.IsDefined(typeof(TEnum), strEnumValue))
13 | return defaultValue;
14 |
15 | return (TEnum)Enum.Parse(typeof(TEnum), strEnumValue);
16 | }
17 |
18 | public static string CreateMD5FromString(string input)
19 | {
20 | // Use input string to calculate MD5 hash
21 | using (var md5 = MD5.Create())
22 | {
23 | var inputBytes = Encoding.ASCII.GetBytes(input);
24 | var hashBytes = md5.ComputeHash(inputBytes);
25 |
26 | // Convert the byte array to hexadecimal string
27 | var sb = new StringBuilder();
28 | for (int i = 0; i < hashBytes.Length; i++)
29 | {
30 | sb.Append(hashBytes[i].ToString("X2"));
31 | }
32 | return sb.ToString();
33 | }
34 | }
35 |
36 | public static bool CreateMD5FromFile(string path, out string hash)
37 | {
38 | hash = "";
39 | if (!File.Exists(path)) return false;
40 | using (var md5 = MD5.Create())
41 | {
42 | using (var stream = File.OpenRead(path))
43 | {
44 | var hashBytes = md5.ComputeHash(stream);
45 |
46 | // Convert the byte array to hexadecimal string
47 | var sb = new StringBuilder();
48 | foreach (var hashByte in hashBytes)
49 | {
50 | sb.Append(hashByte.ToString("X2"));
51 | }
52 |
53 | hash = sb.ToString();
54 | return true;
55 | }
56 | }
57 | }
58 | }
59 | }
--------------------------------------------------------------------------------
/SongLoaderPlugin/ReflectionUtil.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Reflection;
3 | using UnityEngine;
4 |
5 | namespace SongLoaderPlugin
6 | {
7 | public static class ReflectionUtil
8 | {
9 | public static void SetPrivateField(this object obj, string fieldName, object value)
10 | {
11 | var prop = obj.GetType().GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance);
12 | prop.SetValue(obj, value);
13 | }
14 |
15 | public static T GetPrivateField(this object obj, string fieldName)
16 | {
17 | var prop = obj.GetType().GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance);
18 | var value = prop.GetValue(obj);
19 | return (T) value;
20 | }
21 |
22 | public static void SetPrivateProperty(this object obj, string propertyName, object value)
23 | {
24 | var prop = obj.GetType().GetProperty(propertyName, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance);
25 | prop.SetValue(obj, value, null);
26 | }
27 |
28 | public static void InvokePrivateMethod(this object obj, string methodName, object[] methodParams)
29 | {
30 | MethodInfo dynMethod = obj.GetType().GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance);
31 | dynMethod.Invoke(obj, methodParams);
32 | }
33 |
34 | public static Component CopyComponent(Component original, Type originalType, Type overridingType,
35 | GameObject destination)
36 | {
37 | var copy = destination.AddComponent(overridingType);
38 | var fields = originalType.GetFields(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance |
39 | BindingFlags.GetField);
40 | foreach (var field in fields)
41 | {
42 | field.SetValue(copy, field.GetValue(original));
43 | }
44 |
45 | return copy;
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/SongLoaderPlugin/NoteHitVolumeChanger.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Diagnostics;
3 | using System.Linq;
4 | using UnityEngine;
5 |
6 | namespace SongLoaderPlugin
7 | {
8 | public static class NoteHitVolumeChanger
9 | {
10 | public static bool PrefabFound { get; private set; }
11 | private static NoteCutSoundEffect _noteCutSoundEffect;
12 | private static float _normalVolume;
13 | private static float _normalMissVolume;
14 |
15 | //Code snippet comes from Taz's NoteHitVolume plugin:
16 | //https://github.com/taz030485/NoteHitVolume/blob/master/NoteHitVolume/NoteHitVolume.cs
17 | public static void SetVolume(float hitVolume, float missVolume)
18 | {
19 | hitVolume = Mathf.Clamp01(hitVolume);
20 | missVolume = Mathf.Clamp01(missVolume);
21 | var pooled = false;
22 | if (_noteCutSoundEffect == null)
23 | {
24 | var noteCutSoundEffectManager = Resources.FindObjectsOfTypeAll().FirstOrDefault();
25 | if (noteCutSoundEffectManager == null) return;
26 | _noteCutSoundEffect =
27 | noteCutSoundEffectManager.GetPrivateField("_noteCutSoundEffectPrefab");
28 | pooled = true;
29 | PrefabFound = true;
30 | }
31 |
32 | if (_normalVolume == 0)
33 | {
34 | _normalVolume = _noteCutSoundEffect.GetPrivateField("_goodCutVolume");
35 | _normalMissVolume = _noteCutSoundEffect.GetPrivateField("_badCutVolume");
36 | }
37 |
38 | var newGoodVolume = _normalVolume * hitVolume;
39 | var newBadVolume = _normalMissVolume * missVolume;
40 | _noteCutSoundEffect.SetPrivateField("_goodCutVolume", newGoodVolume);
41 | _noteCutSoundEffect.SetPrivateField("_badCutVolume", newBadVolume);
42 |
43 | if (pooled)
44 | {
45 | var pool = Resources.FindObjectsOfTypeAll();
46 | foreach (var effect in pool)
47 | {
48 | if (effect.name.Contains("Clone"))
49 | {
50 | effect.SetPrivateField("_goodCutVolume", newGoodVolume);
51 | effect.SetPrivateField("_badCutVolume", newBadVolume);
52 | }
53 | }
54 | }
55 | }
56 | }
57 | }
--------------------------------------------------------------------------------
/SongLoaderPlugin/CustomSongInfo.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.IO;
3 |
4 | namespace SongLoaderPlugin
5 | {
6 | [Serializable]
7 | public class CustomSongInfo
8 | {
9 | public string songName = "Missing name";
10 | public string songSubName = string.Empty;
11 | [Obsolete("This has been renamed to songAuthorName to match game implementation")]
12 | public string authorName = string.Empty;
13 | public string songAuthorName;
14 | public float beatsPerMinute = 100;
15 | public float previewStartTime = 12;
16 | public float previewDuration = 10;
17 | public float songTimeOffset;
18 | public float shuffle;
19 | public float shufflePeriod;
20 | public string environmentName = "DefaultEnvironment";
21 | public string audioPath;
22 | public string coverImagePath = "cover.jpg";
23 | public bool oneSaber;
24 | public float noteHitVolume = 1;
25 | public float noteMissVolume = 1;
26 | public DifficultyLevel[] difficultyLevels;
27 | public string path;
28 | public string levelId;
29 |
30 | [Serializable]
31 | public class DifficultyLevel
32 | {
33 | public string difficulty;
34 | public int difficultyRank;
35 | [Obsolete("audioPath has been moved to the song info. " +
36 | "If the song audioPath is empty, it will try to use the audioPath in the first difficulty it finds.")]
37 | public string audioPath;
38 | public string jsonPath;
39 | public string json;
40 | public float noteJumpMovementSpeed;
41 | }
42 |
43 | public string GetIdentifier()
44 | {
45 | var combinedJson = "";
46 | foreach (var diffLevel in difficultyLevels)
47 | {
48 | if (!File.Exists(path + "/" + diffLevel.jsonPath))
49 | {
50 | continue;
51 | }
52 |
53 | diffLevel.json = File.ReadAllText(path + "/" + diffLevel.jsonPath);
54 | combinedJson += diffLevel.json;
55 | }
56 |
57 | var hash = Utils.CreateMD5FromString(combinedJson);
58 | levelId = hash + "∎" + string.Join("∎", songName, songSubName, GetSongAuthor(), beatsPerMinute.ToString()) + "∎";
59 | return levelId;
60 | }
61 |
62 | public string GetSongAuthor()
63 | {
64 | if (songAuthorName == null)
65 | {
66 | songAuthorName = authorName;
67 | }
68 |
69 | return songAuthorName;
70 | }
71 |
72 | public string GetAudioPath()
73 | {
74 | if (!string.IsNullOrEmpty(audioPath)) return audioPath;
75 |
76 | foreach (var difficultyLevel in difficultyLevels)
77 | {
78 | if (string.IsNullOrEmpty(difficultyLevel.audioPath)) continue;
79 | return difficultyLevel.audioPath;
80 | }
81 |
82 | return null;
83 | }
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # BeatSaberSongLoader
2 | A plugin for adding custom songs into Beat Saber.
3 |
4 | *This mod works on both the Steam and Oculus Store versions.*
5 |
6 | ## Installation Instructions
7 | 1. Download the latest release from here: https://github.com/xyonico/BeatSaberSongLoader/releases
8 | 2. Extract the .zip file into the `Oculus Apps\Software\hyperbolic-magnetism-beat-saber` for Oculus Home OR `steamapps\common\Beat Saber` for Steam. (The one with Beat Saber.exe)
9 |
10 | The Beat Saber folder should look something like this:
11 | * `Beat Saber_Data`
12 | * `CustomSongs`
13 | * `IPA`
14 | * `Plugins`
15 | * `Beat Saber (Patch & Launch)`
16 | * `Beat Saber.exe`
17 | * `IPA.exe`
18 | * `Mono.Cecil.dll`
19 | * `UnityPlayer.dll`
20 | 3. Done!
21 |
22 | ## Usage
23 | 1. Launch Beat Saber through the platform you purchased it on.
24 | 2. Go to 'Solo' -> 'Standard' and your custom song will be available to play at the bottom of the list.
25 |
26 |
27 | ## Installing Custom Songs
28 | The following files must be placed within their own folder inside the "CustomSongs" folder.
29 |
30 | Required files:
31 | 1. cover.jpg (Size 256x256)
32 | -This is the picture shown next to song in the selection screen.
33 | -The name can be whatever you want, make sure its the same as the one found in info.json
34 | -Only supported image types are jpg and png
35 | 2. song.wav / song.ogg
36 | -This is your song you would like to load
37 | -Name must be the same as in info.json
38 | -Only supported audio types are wav and ogg
39 | 3. easy.json / normal.json / hard.json / expert.json
40 | -This is the note chart for each difficulty
41 | -Names must match the "jsonPath" in info.json
42 | -Use a Beat Saber editor to make your own note chart for the song
43 | 4. info.json
44 | -Contains the info for the song
45 |
46 | The following is a template for you to use:
47 | ```json
48 | {
49 | "songName":"YourSongName",
50 | "songSubName":"ft. Name",
51 | "songAuthorName":"AuthorName",
52 | "beatsPerMinute":179.0,
53 | "previewStartTime":12.0,
54 | "previewDuration":10.0,
55 | "audioPath":"YourSong.ogg",
56 | "coverImagePath":"cover.jpg",
57 | "environmentName":"DefaultEnvironment",
58 | "songTimeOffset":-2,
59 | "shuffle":1,
60 | "shufflePeriod":0.2,
61 | "oneSaber":true,
62 | "difficultyLevels": [
63 | { "difficulty":"Expert", "difficultyRank":4, "jsonPath":"expert.json" },
64 | { "difficulty":"Easy", "difficultyRank":0, "jsonPath":"easy.json" }
65 | ]
66 | }
67 | ```
68 | ___
69 |
70 | ### info.json Explanation
71 | ```
72 | "songName" - Name of your song
73 | "songSubName" - Text rendered in smaller letters next to song name. (ft. Artist)
74 | "beatsPerMinute" - BPM of the song you are using
75 | "previewStartTime" - How many seconds into the song the preview should start
76 | "previewDuration" - Time in seconds the song will be previewed in selection screen
77 | "coverImagePath" - Cover image name
78 | "environmentName" - Game environment to be used
79 | "songTimeOffset" - Time in seconds of how early a song should start. Negative numbers for starting the song later
80 | "shuffle" - Time in number of beats how much a note should shift
81 | "shufflePeriod" - Time in number of beats how often a note should shift. Don't ask me why this is a feature, I don't know
82 | "oneSaber" - true or false if it should appear in the one saber list
83 |
84 | All possible environmentNames:
85 | -DefaultEnvironment
86 | -BigMirrorEnvironment
87 | -TriangleEnvironment
88 | -NiceEnvironment
89 |
90 | "difficultyLevels": [
91 | {
92 | "difficulty": This can only be set to Easy, Normal, Hard, Expert or ExpertPlus,
93 | "difficultyRank": Currently unused whole number for ranking difficulty,
94 | "jsonPath": The name of the json file for this specific difficulty
95 | }
96 | ]
97 | ```
98 |
99 | # Keyboard Shortcuts
100 | *(Make sure Beat Saber's window is in focus when using these shortcuts)*
101 | ---
102 | * Press Ctrl+R when in the main menu to do a full refresh. (This means removing deleted songs and updating existing songs)
103 | * Press R when in main menu to do a quick refresh (This will only add new songs in the CustomSongs folder)
104 |
--------------------------------------------------------------------------------
/SongLoaderPlugin/OverrideClasses/CustomLevel.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Globalization;
4 | using System.Linq;
5 | using UnityEngine;
6 |
7 | namespace SongLoaderPlugin.OverrideClasses
8 | {
9 | public class CustomLevel : StandardLevelSO, IScriptableObjectResetable
10 | {
11 | public CustomSongInfo customSongInfo { get; private set; }
12 | public bool AudioClipLoading { get; set; }
13 | public bool BPMAndNoteSpeedFixed { get; private set; }
14 |
15 | public void Init(CustomSongInfo newCustomSongInfo)
16 | {
17 | customSongInfo = newCustomSongInfo;
18 | _levelID = customSongInfo.GetIdentifier();
19 | _songName = customSongInfo.songName;
20 | _songSubName = customSongInfo.songSubName;
21 | _songAuthorName = customSongInfo.GetSongAuthor();
22 | _beatsPerMinute = customSongInfo.beatsPerMinute;
23 | _songTimeOffset = customSongInfo.songTimeOffset;
24 | _shuffle = customSongInfo.shuffle;
25 | _shufflePeriod = customSongInfo.shufflePeriod;
26 | _previewStartTime = customSongInfo.previewStartTime;
27 | _previewDuration = customSongInfo.previewDuration;
28 | _environmentSceneInfo = LoadSceneInfo(customSongInfo.environmentName);
29 | }
30 |
31 | public void SetAudioClip(AudioClip newAudioClip)
32 | {
33 | _audioClip = newAudioClip;
34 | }
35 |
36 | public void SetCoverImage(Sprite newCoverImage)
37 | {
38 | _coverImage = newCoverImage;
39 | }
40 |
41 | public void SetDifficultyBeatmaps(DifficultyBeatmap[] newDifficultyBeatmaps)
42 | {
43 | _difficultyBeatmaps = newDifficultyBeatmaps;
44 | }
45 |
46 | private static SceneInfo LoadSceneInfo(string environmentName)
47 | {
48 | var sceneInfo = Resources.Load("SceneInfo/" + environmentName + "SceneInfo");
49 | return sceneInfo == null ? Resources.Load("SceneInfo/DefaultEnvironmentSceneInfo") : sceneInfo;
50 | }
51 |
52 | public void FixBPMAndGetNoteJumpMovementSpeed()
53 | {
54 | if (BPMAndNoteSpeedFixed) return;
55 | var bpms = new Dictionary {{_beatsPerMinute, 0}};
56 | foreach (var diffLevel in customSongInfo.difficultyLevels)
57 | {
58 | if (string.IsNullOrEmpty(diffLevel.json)) continue;
59 | float bpm, noteSpeed;
60 | GetBPMAndNoteJump(diffLevel.json, out bpm, out noteSpeed);
61 | if (bpm > 0)
62 | {
63 | if (bpms.ContainsKey(bpm))
64 | {
65 | bpms[bpm]++;
66 | }
67 | else
68 | {
69 | bpms.Add(bpm, 1);
70 | }
71 | }
72 |
73 | var diffBeatmap = _difficultyBeatmaps.FirstOrDefault(x =>
74 | diffLevel.difficulty.ToEnum(LevelDifficulty.Normal) == x.difficulty);
75 | var customBeatmap = diffBeatmap as CustomDifficultyBeatmap;
76 | if (customBeatmap == null) continue;
77 | if (customBeatmap.noteJumpMovementSpeed > 0) continue;
78 | customBeatmap.SetNoteJumpMovementSpeed(noteSpeed);
79 | }
80 |
81 | _beatsPerMinute = bpms.OrderByDescending(x => x.Value).First().Key;
82 |
83 | foreach (var difficultyBeatmap in _difficultyBeatmaps)
84 | {
85 | var customBeatmap = difficultyBeatmap as CustomDifficultyBeatmap;
86 | if (customBeatmap == null) continue;
87 | customBeatmap.BeatmapDataSO.SetRequiredDataForLoad(_beatsPerMinute, _shuffle, _shufflePeriod);
88 | customBeatmap.BeatmapDataSO.Load();
89 | }
90 |
91 | BPMAndNoteSpeedFixed = true;
92 | }
93 |
94 | //This is quicker than using a JSON parser
95 | private void GetBPMAndNoteJump(string json, out float bpm, out float noteJumpSpeed)
96 | {
97 | bpm = 0;
98 | noteJumpSpeed = 0;
99 | var split = json.Split(':');
100 | for (var i = 0; i < split.Length; i++)
101 | {
102 | if (split[i].Contains("_beatsPerMinute"))
103 | {
104 | bpm = Convert.ToSingle(split[i + 1].Split(',')[0], CultureInfo.InvariantCulture);
105 | }
106 |
107 | if (split[i].Contains("_noteJumpSpeed"))
108 | {
109 | noteJumpSpeed = Convert.ToSingle(split[i + 1].Split(',')[0], CultureInfo.InvariantCulture);
110 | }
111 | }
112 | }
113 |
114 | public class CustomDifficultyBeatmap : DifficultyBeatmap
115 | {
116 | public CustomDifficultyBeatmap(IStandardLevel parentLevel, LevelDifficulty difficulty, int difficultyRank, float noteJumpMovementSpeed, BeatmapDataSO beatmapData) : base(parentLevel, difficulty, difficultyRank, noteJumpMovementSpeed, beatmapData)
117 | {
118 | }
119 |
120 | public CustomBeatmapDataSO BeatmapDataSO
121 | {
122 | get { return _beatmapData as CustomBeatmapDataSO; }
123 | }
124 |
125 | public void SetNoteJumpMovementSpeed(float newNoteJumpMovementSpeed)
126 | {
127 | _noteJumpMovementSpeed = newNoteJumpMovementSpeed;
128 | }
129 | }
130 |
131 | public void Reset()
132 | {
133 | _audioClip = null;
134 | BPMAndNoteSpeedFixed = false;
135 | }
136 | }
137 | }
--------------------------------------------------------------------------------
/SongLoaderPlugin/ProgressBar.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 | using System.Collections;
3 | using SongLoaderPlugin.OverrideClasses;
4 | using TMPro;
5 | using UnityEngine;
6 | using UnityEngine.SceneManagement;
7 | using UnityEngine.UI;
8 |
9 | namespace SongLoaderPlugin
10 | {
11 | public class ProgressBar : MonoBehaviour
12 | {
13 | private Canvas _canvas;
14 | private TMP_Text _creditText;
15 | private TMP_Text _headerText;
16 | private Image _loadingBackg;
17 | private Image _loadingBar;
18 |
19 | private static readonly Vector3 Position = new Vector3(0, 2.5f, 2.5f);
20 | private static readonly Vector3 Rotation = new Vector3(0, 0, 0);
21 | private static readonly Vector3 Scale = new Vector3(0.01f, 0.01f, 0.01f);
22 |
23 | private static readonly Vector2 CanvasSize = new Vector2(100, 50);
24 |
25 | private static readonly Vector2 CreditPosition = new Vector2(0, 22);
26 | private const string CreditText = "Song Loader Plugin by xyonico";
27 | private const float CreditFontSize = 9f;
28 | private static readonly Vector2 HeaderPosition = new Vector2(0, 15);
29 | private static readonly Vector2 HeaderSize = new Vector2(100, 20);
30 | private const string HeaderText = "Loading songs...";
31 | private const float HeaderFontSize = 15f;
32 |
33 | private static readonly Vector2 LoadingBarSize = new Vector2(100, 10);
34 | private static readonly Color BackgroundColor = new Color(0, 0, 0, 0.2f);
35 |
36 | private bool _showingMessage;
37 |
38 | public static ProgressBar Create()
39 | {
40 | return new GameObject("Progress Bar").AddComponent();
41 | }
42 |
43 | public void ShowMessage(string message, float time)
44 | {
45 | _showingMessage = true;
46 | _headerText.text = message;
47 | _loadingBar.enabled = false;
48 | _loadingBackg.enabled = false;
49 | _canvas.enabled = true;
50 | StartCoroutine(DisableCanvasRoutine(time));
51 | }
52 |
53 | public void ShowMessage(string message)
54 | {
55 | _showingMessage = true;
56 | _headerText.text = message;
57 | _loadingBar.enabled = false;
58 | _loadingBackg.enabled = false;
59 | _canvas.enabled = true;
60 | }
61 |
62 | private void OnEnable()
63 | {
64 | SceneManager.activeSceneChanged += SceneManagerOnActiveSceneChanged;
65 | SongLoader.LoadingStartedEvent += SongLoaderOnLoadingStartedEvent;
66 | SongLoader.SongsLoadedEvent += SongLoaderOnSongsLoadedEvent;
67 | }
68 |
69 | private void OnDisable()
70 | {
71 | SceneManager.activeSceneChanged -= SceneManagerOnActiveSceneChanged;
72 | SongLoader.LoadingStartedEvent -= SongLoaderOnLoadingStartedEvent;
73 | SongLoader.SongsLoadedEvent -= SongLoaderOnSongsLoadedEvent;
74 | }
75 |
76 | private void SceneManagerOnActiveSceneChanged(Scene arg0, Scene arg1)
77 | {
78 | if (arg1.buildIndex == 1)
79 | {
80 | if (_showingMessage)
81 | {
82 | _canvas.enabled = true;
83 | }
84 | }
85 | else
86 | {
87 | _canvas.enabled = false;
88 | }
89 | }
90 |
91 | private void SongLoaderOnLoadingStartedEvent(SongLoader obj)
92 | {
93 | _showingMessage = false;
94 | _headerText.text = HeaderText;
95 | _loadingBar.enabled = true;
96 | _loadingBackg.enabled = true;
97 | _canvas.enabled = true;
98 | }
99 |
100 | private void SongLoaderOnSongsLoadedEvent(SongLoader arg1, List arg2)
101 | {
102 | _showingMessage = false;
103 | _headerText.text = arg2.Count + " songs loaded";
104 | _loadingBar.enabled = false;
105 | _loadingBackg.enabled = false;
106 | StartCoroutine(DisableCanvasRoutine(5f));
107 | }
108 |
109 | private IEnumerator DisableCanvasRoutine(float time)
110 | {
111 | yield return new WaitForSecondsRealtime(time);
112 | _canvas.enabled = false;
113 | _showingMessage = false;
114 | }
115 |
116 | private void Awake()
117 | {
118 | gameObject.transform.position = Position;
119 | gameObject.transform.eulerAngles = Rotation;
120 | gameObject.transform.localScale = Scale;
121 |
122 | _canvas = gameObject.AddComponent