diff --git a/Reactor.Example/ExamplePlugin.cs b/Reactor.Example/ExamplePlugin.cs index 4a768e8..c14e8b9 100644 --- a/Reactor.Example/ExamplePlugin.cs +++ b/Reactor.Example/ExamplePlugin.cs @@ -25,6 +25,8 @@ public partial class ExamplePlugin : BasePlugin public override void Load() { + ReactorCredits.Register(ReactorCredits.AlwaysShow); + this.AddComponent(); _helloStringName = CustomStringName.CreateAndRegister("Hello!"); diff --git a/Reactor/Patches/Miscellaneous/PingTrackerPatch.cs b/Reactor/Patches/Miscellaneous/PingTrackerPatch.cs new file mode 100644 index 0000000..0662758 --- /dev/null +++ b/Reactor/Patches/Miscellaneous/PingTrackerPatch.cs @@ -0,0 +1,21 @@ +using System; +using HarmonyLib; +using Reactor.Utilities; + +namespace Reactor.Patches.Miscellaneous; + +[HarmonyPatch(typeof(PingTracker), nameof(PingTracker.Update))] +internal static class PingTrackerPatch +{ + [HarmonyPostfix] + [HarmonyPriority(Priority.Last)] + public static void Postfix(PingTracker __instance) + { + var extraText = ReactorCredits.GetText(ReactorCredits.Location.PingTracker); + if (extraText != null) + { + if (!__instance.text.text.EndsWith("\n", StringComparison.InvariantCulture)) __instance.text.text += "\n"; + __instance.text.text += extraText; + } + } +} diff --git a/Reactor/Patches/ReactorVersionShower.cs b/Reactor/Patches/ReactorVersionShower.cs index 6ed8f2a..d6b0559 100644 --- a/Reactor/Patches/ReactorVersionShower.cs +++ b/Reactor/Patches/ReactorVersionShower.cs @@ -2,6 +2,7 @@ using BepInEx; using BepInEx.Unity.IL2CPP; using HarmonyLib; +using Reactor.Utilities; using Reactor.Utilities.Extensions; using TMPro; using UnityEngine; @@ -89,20 +90,22 @@ internal static void Initialize() })); } - private static string ToStringWithoutBuild(Version version) - { - return $"{version.Major}.{version.Minor}.{version.Patch}{(version.PreRelease == null ? string.Empty : $"-{version.PreRelease}")}"; - } - /// /// Updates with reactor version and fires . /// public static void UpdateText() { if (Text == null) return; - Text.text = "Reactor " + ReactorPlugin.Version; - Text.text += "\nBepInEx " + ToStringWithoutBuild(Paths.BepInExVersion); + Text.text = "Reactor " + Version.Parse(ReactorPlugin.Version).WithoutBuild(); + Text.text += "\nBepInEx " + Paths.BepInExVersion.WithoutBuild(); Text.text += "\nMods: " + IL2CPPChainloader.Instance.Plugins.Count; + + var creditsText = ReactorCredits.GetText(ReactorCredits.Location.MainMenu); + if (creditsText != null) + { + Text.text += "\n" + creditsText; + } + TextUpdated?.Invoke(Text); } diff --git a/Reactor/Utilities/Extensions/RichTextExtensions.cs b/Reactor/Utilities/Extensions/RichTextExtensions.cs new file mode 100644 index 0000000..156a8c0 --- /dev/null +++ b/Reactor/Utilities/Extensions/RichTextExtensions.cs @@ -0,0 +1,40 @@ +namespace Reactor.Utilities.Extensions; + +/// +/// Provides extension methods for TestMeshPro's Rich Text. +/// +internal static class RichTextExtensions +{ + private static string Wrap(this string text, string tag) + { + return $"<{tag}>{text}"; + } + + private static string Wrap(this string text, string tag, string value) + { + return $"<{tag}={value}>{text}"; + } + + public static string Align(this string text, string value) + { + return text.Wrap("align", value); + } + + public static string Color(this string text, string value) + { + return text.Wrap("color", value); + } + + public static string Size(this string text, string value) + { + return text.Wrap("size", value); + } + + public static string EscapeRichText(this string text) + { + return text + .Replace("", string.Empty) + .Replace("", string.Empty) + .Wrap("noparse"); + } +} diff --git a/Reactor/Utilities/Extensions/VersionExtensions.cs b/Reactor/Utilities/Extensions/VersionExtensions.cs new file mode 100644 index 0000000..947dd80 --- /dev/null +++ b/Reactor/Utilities/Extensions/VersionExtensions.cs @@ -0,0 +1,19 @@ +using SemanticVersioning; + +namespace Reactor.Utilities.Extensions; + +/// +/// Provides extension methods for . +/// +public static class VersionExtensions +{ + /// + /// Gets the provided without the build string (everything after the + symbol like the commit hash is stripped). + /// + /// The . + /// The without build. + public static Version WithoutBuild(this Version version) + { + return new Version(version.Major, version.Minor, version.Patch, version.PreRelease); + } +} diff --git a/Reactor/Utilities/ReactorCredits.cs b/Reactor/Utilities/ReactorCredits.cs new file mode 100644 index 0000000..7a574c8 --- /dev/null +++ b/Reactor/Utilities/ReactorCredits.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using BepInEx.Unity.IL2CPP; +using Reactor.Patches; +using Reactor.Utilities.Extensions; + +namespace Reactor.Utilities; + +/// +/// Provides a way for mods to show their version information in-game. +/// +public static class ReactorCredits +{ + private readonly struct ModIdentifier(string name, string version, Func? shouldShow, bool isPreRelease) + { + private const string NormalColor = "#fff"; + private const string PreReleaseColor = "#f00"; + + public string Name => name; + + public string Text { get; } = $"{name} {version}".EscapeRichText().Color(isPreRelease ? PreReleaseColor : NormalColor); + + public bool ShouldShow(Location location) + { + return shouldShow == AlwaysShow || shouldShow(location); + } + } + + private static readonly List _modIdentifiers = []; + + /// + /// Represents the location of where the credit is shown. + /// + public enum Location + { + /// + /// In the main menu under Reactor/BepInEx versions. + /// + MainMenu, + + /// + /// During game under the ping tracker. + /// + PingTracker, + } + + /// + /// A special value indicating a mod should always show. + /// + public const Func? AlwaysShow = null; + + /// + /// Registers a mod with the , adding it to the list of mods that will be displayed. + /// + /// The user-friendly name of the mod. Can contain spaces or special characters. + /// The version of the mod. + /// If this version is a development or beta version. If true, it will display the mod in red. + /// + /// This function will be called every frame to determine if the mod should be displayed or not. + /// This function should return false if your mod is currently disabled or has no effect on gameplay at the time. + /// If you want the mod to be displayed at all times, you can set this parameter to . + /// + public static void Register(string name, string version, bool isPreRelease, Func? shouldShow) + { + const int MaxLength = 60; + + if (name.Length + version.Length > MaxLength) + { + Error($"Not registering mod \"{name}\" with version \"{version}\" in {nameof(ReactorCredits)} because the combined length of the mod name and version is greater than {MaxLength} characters."); + return; + } + + if (_modIdentifiers.Any(m => m.Name == name)) + { + Error($"Mod \"{name}\" is already registered in {nameof(ReactorCredits)}."); + return; + } + + _modIdentifiers.Add(new ModIdentifier(name, version, shouldShow, isPreRelease)); + + _modIdentifiers.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal)); + + if (!isPreRelease) + { + Info($"Mod \"{name}\" registered in {nameof(ReactorCredits)} with version {version}."); + } + else + { + Warning($"Mod \"{name}\" registered in {nameof(ReactorCredits)} with DEVELOPMENT/BETA version {version}."); + } + + ReactorVersionShower.UpdateText(); + } + + /// + /// Registers a mod with the , adding it to the list of mods that will be displayed. + /// + /// The BepInEx plugin type to get the name and version from. + /// + public static void Register(Func? shouldShow) where T : BasePlugin + { + var pluginInfo = IL2CPPChainloader.Instance.Plugins.Values.SingleOrDefault(p => p.TypeName == typeof(T).FullName) + ?? throw new ArgumentException("Couldn't find the metadata for the provided plugin type", nameof(T)); + + var metadata = pluginInfo.Metadata; + + Register(metadata.Name, metadata.Version.WithoutBuild().Clean(), metadata.Version.IsPreRelease, shouldShow); + } + + internal static string? GetText(Location location) + { + var modTexts = _modIdentifiers.Where(m => m.ShouldShow(location)).Select(m => m.Text).ToArray(); + if (modTexts.Length == 0) return null; + + return location switch + { + Location.MainMenu => string.Join('\n', modTexts), + Location.PingTracker => ("" + string.Join(", ", modTexts)).Size("50%").Align("center"), + _ => throw new ArgumentOutOfRangeException(nameof(location), location, null), + }; + } +}