diff --git a/Stickers/Integrations/BTKUI/BTKUILibExtensions.cs b/Stickers/Integrations/BTKUI/BTKUILibExtensions.cs new file mode 100644 index 0000000..1e16c93 --- /dev/null +++ b/Stickers/Integrations/BTKUI/BTKUILibExtensions.cs @@ -0,0 +1,103 @@ +using System.Reflection; +using BTKUILib; +using BTKUILib.UIObjects; +using BTKUILib.UIObjects.Components; +using BTKUILib.UIObjects.Objects; +using JetBrains.Annotations; +using MelonLoader; +using UnityEngine; + +namespace NAK.Stickers.Integrations; + +[PublicAPI] +public static class BTKUILibExtensions +{ + #region Icon Utils + + public static Stream GetIconStream(string iconName) + { + Assembly assembly = Assembly.GetExecutingAssembly(); + string assemblyName = assembly.GetName().Name; + return assembly.GetManifestResourceStream($"{assemblyName}.Resources.{iconName}"); + } + + #endregion Icon Utils + + #region Enum Utils + + private static class EnumUtils + { + public static string[] GetPrettyEnumNames() where T : Enum + { + return Enum.GetNames(typeof(T)).Select(PrettyFormatEnumName).ToArray(); + } + + private static string PrettyFormatEnumName(string name) + { + // adds spaces before capital letters (excluding the first letter) + return System.Text.RegularExpressions.Regex.Replace(name, "(\\B[A-Z])", " $1"); + } + + public static int GetEnumIndex(T value) where T : Enum + { + return Array.IndexOf(Enum.GetValues(typeof(T)), value); + } + } + + #endregion Enum Utils + + #region Melon Preference Extensions + + public static ToggleButton AddMelonToggle(this Category category, MelonPreferences_Entry entry) + { + ToggleButton toggle = category.AddToggle(entry.DisplayName, entry.Description, entry.Value); + toggle.OnValueUpdated += b => entry.Value = b; + return toggle; + } + + public static SliderFloat AddMelonSlider(this Category category, MelonPreferences_Entry entry, float min, + float max, int decimalPlaces = 2, bool allowReset = true) + { + SliderFloat slider = category.AddSlider(entry.DisplayName, entry.Description, + Mathf.Clamp(entry.Value, min, max), min, max, decimalPlaces, entry.DefaultValue, allowReset); + slider.OnValueUpdated += f => entry.Value = f; + return slider; + } + + public static Button AddMelonStringInput(this Category category, MelonPreferences_Entry entry, string buttonIcon = "", ButtonStyle buttonStyle = ButtonStyle.TextOnly) + { + Button button = category.AddButton(entry.DisplayName, buttonIcon, entry.Description, buttonStyle); + button.OnPress += () => QuickMenuAPI.OpenKeyboard(entry.Value, s => entry.Value = s); + return button; + } + + public static Button AddMelonNumberInput(this Category category, MelonPreferences_Entry entry, string buttonIcon = "", ButtonStyle buttonStyle = ButtonStyle.TextOnly) + { + Button button = category.AddButton(entry.DisplayName, buttonIcon, entry.Description, buttonStyle); + button.OnPress += () => QuickMenuAPI.OpenNumberInput(entry.DisplayName, entry.Value, f => entry.Value = f); + return button; + } + + public static Category AddMelonCategory(this Page page, MelonPreferences_Entry entry, bool showHeader = true) + { + Category category = page.AddCategory(entry.DisplayName, showHeader, true, !entry.Value); + category.OnCollapse += b => entry.Value = !b; // more intuitive if pref value of true means category open + return category; + } + + public static MultiSelection CreateMelonMultiSelection(MelonPreferences_Entry entry) where TEnum : Enum + { + MultiSelection multiSelection = new( + entry.DisplayName, + EnumUtils.GetPrettyEnumNames(), + EnumUtils.GetEnumIndex(entry.Value) + ) + { + OnOptionUpdated = i => entry.Value = (TEnum)Enum.Parse(typeof(TEnum), Enum.GetNames(typeof(TEnum))[i]) + }; + + return multiSelection; + } + + #endregion Melon Preference Extensions +} \ No newline at end of file diff --git a/Stickers/Integrations/BTKUI/BtkUiAddon.cs b/Stickers/Integrations/BTKUI/BtkUiAddon.cs deleted file mode 100644 index fafe92b..0000000 --- a/Stickers/Integrations/BTKUI/BtkUiAddon.cs +++ /dev/null @@ -1,80 +0,0 @@ -using ABI_RC.Core.Player; -using BTKUILib; -using BTKUILib.UIObjects; - -namespace NAK.Stickers.Integrations; - -public static partial class BtkUiAddon -{ - private static Page _rootPage; - private static string _rootPageElementID; - - private static bool _isOurTabOpened; - private static Action _onOurTabOpened; - - public static void Initialize() - { - Prepare_Icons(); - Setup_AvatarScaleModTab(); - //Setup_PlayerSelectPage(); - } - - #region Initialization - - private static void Prepare_Icons() - { - // All icons used - https://www.flaticon.com/authors/gohsantosadrive - QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-alphabet", GetIconStream("Gohsantosadrive_Icons.Stickers-alphabet.png")); - QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-eraser", GetIconStream("Gohsantosadrive_Icons.Stickers-eraser.png")); - QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-folder", GetIconStream("Gohsantosadrive_Icons.Stickers-folder.png")); - QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-headset", GetIconStream("Gohsantosadrive_Icons.Stickers-headset.png")); - QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-magic-wand", GetIconStream("Gohsantosadrive_Icons.Stickers-magic-wand.png")); - QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-pencil", GetIconStream("Gohsantosadrive_Icons.Stickers-pencil.png")); - QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-puzzle", GetIconStream("Gohsantosadrive_Icons.Stickers-puzzle.png")); - QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-rubbish-bin", GetIconStream("Gohsantosadrive_Icons.Stickers-rubbish-bin.png")); - } - - private static void Setup_AvatarScaleModTab() - { - _rootPage = new Page(ModSettings.ModName, ModSettings.SM_SettingsCategory, true, "Stickers-puzzle") - { - MenuTitle = ModSettings.SM_SettingsCategory, - MenuSubtitle = "Stickers! Double-click the tab to quickly toggle Sticker Mode.", - }; - - _rootPageElementID = _rootPage.ElementID; - QuickMenuAPI.OnTabChange += OnTabChange; - // QuickMenuAPI.UserJoin += OnUserJoinLeave; - // QuickMenuAPI.UserLeave += OnUserJoinLeave; - // QuickMenuAPI.OnWorldLeave += OnWorldLeave; - - Setup_StickersModCategory(_rootPage); - Setup_StickerSelectionCategory(_rootPage); - Setup_DebugOptionsCategory(_rootPage); - } - - #endregion - - #region Double-Click Place Sticker - - private static DateTime lastTime = DateTime.Now; - - private static void OnTabChange(string newTab, string previousTab) - { - _isOurTabOpened = newTab == _rootPageElementID; - if (_isOurTabOpened) - { - _onOurTabOpened?.Invoke(); - TimeSpan timeDifference = DateTime.Now - lastTime; - if (timeDifference.TotalSeconds <= 0.5) - { - //AvatarScaleManager.Instance.Setting_UniversalScaling = false; - StickerSystem.Instance.IsInStickerMode = !StickerSystem.Instance.IsInStickerMode; - return; - } - } - lastTime = DateTime.Now; - } - - #endregion Double-Click Place Sticker -} diff --git a/Stickers/Integrations/BTKUI/BtkUiAddon_CAT_DebugOptions.cs b/Stickers/Integrations/BTKUI/BtkUiAddon_CAT_DebugOptions.cs deleted file mode 100644 index 85f1c14..0000000 --- a/Stickers/Integrations/BTKUI/BtkUiAddon_CAT_DebugOptions.cs +++ /dev/null @@ -1,15 +0,0 @@ -using BTKUILib.UIObjects; - -namespace NAK.Stickers.Integrations -{ - public static partial class BtkUiAddon - { - private static void Setup_DebugOptionsCategory(Page page) - { - Category debugCategory = AddMelonCategory(ref page, ModSettings.Hidden_Foldout_DebugCategory); - - AddMelonToggle(ref debugCategory, ModSettings.Debug_NetworkInbound); - AddMelonToggle(ref debugCategory, ModSettings.Debug_NetworkOutbound); - } - } -} \ No newline at end of file diff --git a/Stickers/Integrations/BTKUI/BtkUiAddon_CAT_StickerSelection.cs b/Stickers/Integrations/BTKUI/BtkUiAddon_CAT_StickerSelection.cs deleted file mode 100644 index ff50a8a..0000000 --- a/Stickers/Integrations/BTKUI/BtkUiAddon_CAT_StickerSelection.cs +++ /dev/null @@ -1,356 +0,0 @@ -using BTKUILib; -using BTKUILib.UIObjects; -using BTKUILib.UIObjects.Components; -using MTJobSystem; -using NAK.Stickers.Networking; -using NAK.Stickers.Utilities; - -namespace NAK.Stickers.Integrations -{ - public static partial class BtkUiAddon - { - private static readonly object _cacheLock = new(); - - private static bool _initialClearCacheFolder; - private static Category _stickerSelectionCategory; - internal static readonly Dictionary _cachedImages = new(); - - public static string GetBtkUiCachePath(string stickerName) - { - if (_cachedImages.TryGetValue(stickerName, out ImageInfo imageInfo)) - return "coui://uiresources/GameUI/mods/BTKUI/images/" + ModSettings.ModName + "/UserImages/" + imageInfo.cacheName + ".png"; - return string.Empty; - } - - internal class ImageInfo - { - public string filePath; - public string cacheName; // BTKUI cache path - public bool wasFoundThisPass; - public bool hasChanged; - public DateTime lastModified; - public Button button; - } - - #region Setup - - private static void Setup_StickerSelectionCategory(Page page) - { - _onOurTabOpened += UpdateStickerSelectionAsync; - _stickerSelectionCategory = AddMelonCategory(ref page, ModSettings.Hidden_Foldout_SelectionCategory); - ModNetwork.OnTextureOutboundStateChanged += OnSendingTextureOverNetworkChanged; // disable buttons when sending texture over network - StickerSystem.OnImageLoadFailed += OnLoadImageFailed; - GetInitialImageInfo(); - } - - private static void GetInitialImageInfo() - { - Task.Run(() => - { - try - { - string path = StickerSystem.GetStickersFolderPath(); - if (!Directory.Exists(path)) - { - StickerMod.Logger.Warning("Stickers folder not found."); - return; - } - - var stickerFiles = Directory.EnumerateFiles(path, "*.png"); - var currentFiles = new HashSet(stickerFiles); - - lock (_cacheLock) - { - if (!_initialClearCacheFolder) - { - _initialClearCacheFolder = true; - DeleteOldIcons(ModSettings.ModName); - } - - var keysToRemove = new List(); - - foreach (var stickerFile in currentFiles) - { - string stickerName = Path.GetFileNameWithoutExtension(stickerFile); - - if (_cachedImages.TryGetValue(stickerName, out ImageInfo imageInfo)) - { - imageInfo.wasFoundThisPass = true; - DateTime lastModified = File.GetLastWriteTime(stickerFile); - if (lastModified == imageInfo.lastModified) continue; - imageInfo.hasChanged = true; - imageInfo.lastModified = lastModified; - } - else - { - _cachedImages[stickerName] = new ImageInfo - { - filePath = stickerFile, - wasFoundThisPass = true, - hasChanged = true, - lastModified = File.GetLastWriteTime(stickerFile) - }; - } - } - - foreach (var kvp in _cachedImages) - { - var imageName = kvp.Key; - ImageInfo imageInfo = kvp.Value; - - if (!imageInfo.wasFoundThisPass) - { - MainThreadInvoke(() => - { - if (imageInfo.button != null) - { - imageInfo.button.Delete(); - imageInfo.button = null; - } - }); - - if (!string.IsNullOrEmpty(imageInfo.cacheName)) - { - DeleteOldIcon(ModSettings.ModName, imageInfo.cacheName); - imageInfo.cacheName = string.Empty; - } - - keysToRemove.Add(imageName); - } - else if (imageInfo.hasChanged) - { - imageInfo.hasChanged = false; - - if (!string.IsNullOrEmpty(imageInfo.cacheName)) - { - DeleteOldIcon(ModSettings.ModName, imageInfo.cacheName); - imageInfo.cacheName = string.Empty; - } - - MemoryStream imageStream = LoadStreamFromFile(imageInfo.filePath); - if (imageStream == null) continue; - - try - { - if (imageStream.Length > 256 * 1024) - ImageUtility.Resize(ref imageStream, 256, 256); - } - catch (Exception e) - { - StickerMod.Logger.Warning($"Failed to resize image: {e.Message}"); - } - - imageInfo.cacheName = $"{imageName}_{Guid.NewGuid()}"; - - PrepareIconFromMemoryStream(ModSettings.ModName, imageInfo.cacheName, imageStream); - } - - imageInfo.wasFoundThisPass = false; - } - - foreach (var key in keysToRemove) - _cachedImages.Remove(key); - } - } - catch (Exception e) - { - StickerMod.Logger.Error($"Failed to update sticker selection: {e.Message}"); - } - }); - } - - private static void UpdateStickerSelectionAsync() - { - Task.Run(() => - { - try - { - string path = StickerSystem.GetStickersFolderPath(); - if (!Directory.Exists(path)) - { - StickerMod.Logger.Warning("Stickers folder not found."); - return; - } - - var stickerFiles = Directory.EnumerateFiles(path, "*.png"); - var currentFiles = new HashSet(stickerFiles); - - lock (_cacheLock) - { - if (!_initialClearCacheFolder) - { - _initialClearCacheFolder = true; - DeleteOldIcons(ModSettings.ModName); - } - - var keysToRemove = new List(); - - foreach (var stickerFile in currentFiles) - { - string stickerName = Path.GetFileNameWithoutExtension(stickerFile); - - if (_cachedImages.TryGetValue(stickerName, out ImageInfo imageInfo)) - { - imageInfo.wasFoundThisPass = true; - if (imageInfo.button == null) - { - imageInfo.hasChanged = true; - continue; - } - DateTime lastModified = File.GetLastWriteTime(stickerFile); - if (lastModified == imageInfo.lastModified) continue; - imageInfo.hasChanged = true; - imageInfo.lastModified = lastModified; - } - else - { - _cachedImages[stickerName] = new ImageInfo - { - filePath = stickerFile, - wasFoundThisPass = true, - hasChanged = true, - lastModified = File.GetLastWriteTime(stickerFile) - }; - } - } - - foreach (var kvp in _cachedImages) - { - var imageName = kvp.Key; - ImageInfo imageInfo = kvp.Value; - - if (!imageInfo.wasFoundThisPass) - { - MainThreadInvoke(() => - { - if (imageInfo.button != null) - { - imageInfo.button.Delete(); - imageInfo.button = null; - } - }); - - if (!string.IsNullOrEmpty(imageInfo.cacheName)) - { - DeleteOldIcon(ModSettings.ModName, imageInfo.cacheName); - imageInfo.cacheName = string.Empty; - } - - keysToRemove.Add(imageName); - } - else if (imageInfo.hasChanged) - { - imageInfo.hasChanged = false; - - if (!string.IsNullOrEmpty(imageInfo.cacheName)) - { - DeleteOldIcon(ModSettings.ModName, imageInfo.cacheName); - imageInfo.cacheName = string.Empty; - } - - MemoryStream imageStream = LoadStreamFromFile(imageInfo.filePath); - if (imageStream == null) continue; - - try - { - if (imageStream.Length > 256 * 1024) - ImageUtility.Resize(ref imageStream, 256, 256); - } - catch (Exception e) - { - StickerMod.Logger.Warning($"Failed to resize image: {e.Message}"); - } - - imageInfo.cacheName = $"{imageName}_{Guid.NewGuid()}"; - - PrepareIconFromMemoryStream(ModSettings.ModName, imageInfo.cacheName, imageStream); - - MainThreadInvoke(() => - { - if (imageInfo.button != null) - imageInfo.button.ButtonIcon = "UserImages/" + imageInfo.cacheName; - else - { - imageInfo.button = _stickerSelectionCategory.AddButton(imageName, "UserImages/" + imageInfo.cacheName, $"Select {imageName}.", ButtonStyle.TextWithIcon); - imageInfo.button.OnPress += () => OnStickerButtonClick(imageInfo); - } - }); - } - - imageInfo.wasFoundThisPass = false; - } - - foreach (var key in keysToRemove) - _cachedImages.Remove(key); - - MainThreadInvoke(() => - { - _stickerSelectionCategory.CategoryName = $"{ModSettings.SM_SelectionCategory} ({_cachedImages.Count})"; - }); - } - } - catch (Exception e) - { - StickerMod.Logger.Error($"Failed to update sticker selection: {e.Message}"); - } - }); - } - - private static MemoryStream LoadStreamFromFile(string filePath) - { - if (!File.Exists(filePath)) - return null; - - try - { - //StickerMod.Logger.Msg($"Loaded sticker stream from {filePath}"); - - using FileStream fileStream = new(filePath, FileMode.Open, FileAccess.Read); - - MemoryStream memoryStream = new(); - fileStream.CopyTo(memoryStream); - memoryStream.Position = 0; // Ensure the position is reset before returning - return memoryStream; - } - catch (Exception e) - { - StickerMod.Logger.Warning($"Failed to load stream from {filePath}: {e.Message}"); - return null; - } - } - private static void MainThreadInvoke(Action action) - => MTJobManager.RunOnMainThread("fuck", action.Invoke); - - #endregion Setup - - #region Button Actions - - private static void OnStickerButtonClick(ImageInfo imageInfo) - { - // i wish i could highlight it - StickerSystem.Instance.LoadImage(imageInfo.button.ButtonText); - } - - #endregion Button Actions - - #region Callbacks - - private static void OnSendingTextureOverNetworkChanged(bool isSending) - { - MTJobManager.RunAsyncOnMainThread("fuck2", () => - { - // go through all buttons and disable them - if (_isOurTabOpened && isSending) QuickMenuAPI.ShowAlertToast("Sending Sticker over Mod Network...", 2); - foreach ((_, ImageInfo value) in _cachedImages) value.button.Disabled = isSending; - }); - } - - private static void OnLoadImageFailed(string reason) - { - if (!_isOurTabOpened) return; - QuickMenuAPI.ShowAlertToast(reason, 2); - } - - #endregion Callbacks - } -} \ No newline at end of file diff --git a/Stickers/Integrations/BTKUI/BtkuiAddon_Utils.cs b/Stickers/Integrations/BTKUI/BtkuiAddon_Utils.cs deleted file mode 100644 index a66fedb..0000000 --- a/Stickers/Integrations/BTKUI/BtkuiAddon_Utils.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System.Reflection; -using System.Security.Cryptography; -using BTKUILib; -using BTKUILib.UIObjects; -using BTKUILib.UIObjects.Components; -using MelonLoader; -using UnityEngine; - -namespace NAK.Stickers.Integrations; - -public static partial class BtkUiAddon -{ - #region Melon Preference Helpers - - private static ToggleButton AddMelonToggle(ref Category category, MelonPreferences_Entry entry) - { - ToggleButton toggle = category.AddToggle(entry.DisplayName, entry.Description, entry.Value); - toggle.OnValueUpdated += b => entry.Value = b; - return toggle; - } - - private static SliderFloat AddMelonSlider(ref Category category, MelonPreferences_Entry entry, float min, - float max, int decimalPlaces = 2, bool allowReset = true) - { - SliderFloat slider = category.AddSlider(entry.DisplayName, entry.Description, - Mathf.Clamp(entry.Value, min, max), min, max, decimalPlaces, entry.DefaultValue, allowReset); - slider.OnValueUpdated += f => entry.Value = f; - return slider; - } - - private static Button AddMelonStringInput(ref Category category, MelonPreferences_Entry entry, string buttonIcon = "", ButtonStyle buttonStyle = ButtonStyle.TextOnly) - { - Button button = category.AddButton(entry.DisplayName, buttonIcon, entry.Description, buttonStyle); - button.OnPress += () => QuickMenuAPI.OpenKeyboard(entry.Value, s => entry.Value = s); - return button; - } - - private static Button AddMelonNumberInput(ref Category category, MelonPreferences_Entry entry, string buttonIcon = "", ButtonStyle buttonStyle = ButtonStyle.TextOnly) - { - Button button = category.AddButton(entry.DisplayName, buttonIcon, entry.Description, buttonStyle); - button.OnPress += () => QuickMenuAPI.OpenNumberInput(entry.DisplayName, entry.Value, f => entry.Value = f); - return button; - } - - private static Category AddMelonCategory(ref Page page, MelonPreferences_Entry entry, bool showHeader = true) - { - Category category = page.AddCategory(entry.DisplayName, showHeader, true, entry.Value); - category.OnCollapse += b => entry.Value = b; - return category; - } - - #endregion Melon Preference Helpers - - #region Icon Utils - - private static Stream GetIconStream(string iconName) - { - Assembly assembly = Assembly.GetExecutingAssembly(); - string assemblyName = assembly.GetName().Name; - return assembly.GetManifestResourceStream($"{assemblyName}.Resources.{iconName}"); - } - - #endregion Icon Utils - - public static void PrepareIconFromMemoryStream(string modName, string iconName, MemoryStream destination) - { - if (destination == null) - { - StickerMod.Logger.Error("Mod " + modName + " attempted to prepare " + iconName + " but the resource stream was null! Yell at the mod author to fix this!"); - } - else - { - modName = UIUtils.GetCleanString(modName); - string path1 = @"ChilloutVR_Data\StreamingAssets\Cohtml\UIResources\GameUI\mods\BTKUI\images\" + modName + @"\UserImages"; - if (!Directory.Exists(path1)) Directory.CreateDirectory(path1); - string path2 = path1 + "\\" + iconName + ".png"; - File.WriteAllBytes(path2, destination.ToArray()); - } - } - - private static void DeleteOldIcon(string modName, string iconName) - { - string directoryPath = Path.Combine("ChilloutVR_Data", "StreamingAssets", "Cohtml", "UIResources", "GameUI", "mods", "BTKUI", "images", modName, "UserImages"); - string oldIconPath = Path.Combine(directoryPath, $"{iconName}.png"); - if (!File.Exists(oldIconPath)) - return; - - File.Delete(oldIconPath); - //StickerMod.Logger.Msg($"Deleted old icon: {oldIconPath}"); - } - - private static void DeleteOldIcons(string modName) - { - string directoryPath = Path.Combine("ChilloutVR_Data", "StreamingAssets", "Cohtml", "UIResources", "GameUI", "mods", "BTKUI", "images", modName, "UserImages"); - if (!Directory.Exists(directoryPath)) - return; - - foreach (string file in Directory.EnumerateFiles(directoryPath, "*.png")) - { - File.Delete(file); - //StickerMod.Logger.Msg($"Deleted old icon: {file}"); - } - } -} \ No newline at end of file diff --git a/Stickers/Integrations/BTKUI/UIAddon.Category.DebugOptions.cs b/Stickers/Integrations/BTKUI/UIAddon.Category.DebugOptions.cs new file mode 100644 index 0000000..0a8fc32 --- /dev/null +++ b/Stickers/Integrations/BTKUI/UIAddon.Category.DebugOptions.cs @@ -0,0 +1,13 @@ +using BTKUILib.UIObjects; + +namespace NAK.Stickers.Integrations; + +public static partial class BTKUIAddon +{ + private static void Setup_DebugOptionsCategory() + { + Category debugCategory = _rootPage.AddMelonCategory(ModSettings.Hidden_Foldout_DebugCategory); + debugCategory.AddMelonToggle(ModSettings.Debug_NetworkInbound); + debugCategory.AddMelonToggle(ModSettings.Debug_NetworkOutbound); + } +} \ No newline at end of file diff --git a/Stickers/Integrations/BTKUI/BtkUiAddon_CAT_StickersMod.cs b/Stickers/Integrations/BTKUI/UIAddon.Category.StickersMod.cs similarity index 76% rename from Stickers/Integrations/BTKUI/BtkUiAddon_CAT_StickersMod.cs rename to Stickers/Integrations/BTKUI/UIAddon.Category.StickersMod.cs index 3681fd1..03743bc 100644 --- a/Stickers/Integrations/BTKUI/BtkUiAddon_CAT_StickersMod.cs +++ b/Stickers/Integrations/BTKUI/UIAddon.Category.StickersMod.cs @@ -2,44 +2,24 @@ using BTKUILib.UIObjects; using BTKUILib.UIObjects.Components; using BTKUILib.UIObjects.Objects; -using UnityEngine; namespace NAK.Stickers.Integrations; -public static partial class BtkUiAddon +public static partial class BTKUIAddon { private static Category _ourCategory; private static readonly MultiSelection _sfxSelection = - new( - "Sticker SFX", - new[] { "Little Big Planet", "Source Engine", "None" }, - (int)ModSettings.Entry_SelectedSFX.Value - ) - { - OnOptionUpdated = i => ModSettings.Entry_SelectedSFX.Value = (ModSettings.SFXType)i - }; - + BTKUILibExtensions.CreateMelonMultiSelection(ModSettings.Entry_SelectedSFX); + private static readonly MultiSelection _desktopKeybindSelection = - new( - "Desktop Keybind", - Enum.GetNames(typeof(ModSettings.KeyBind)), - Array.IndexOf(Enum.GetValues(typeof(ModSettings.KeyBind)), ModSettings.Entry_PlaceBinding.Value) - ) - { - OnOptionUpdated = i => - { - string[] options = Enum.GetNames(typeof(ModSettings.KeyBind)); - ModSettings.Entry_PlaceBinding.Value = (ModSettings.KeyBind)Enum.Parse(typeof(ModSettings.KeyBind), options[i]); - } - }; + BTKUILibExtensions.CreateMelonMultiSelection(ModSettings.Entry_PlaceBinding); #region Category Setup - private static void Setup_StickersModCategory(Page page) + private static void Setup_StickersModCategory() { - //_ourCategory = page.AddCategory(ModSettings.Stickers_SettingsCategory, ModSettings.ModName, true, true, false); - _ourCategory = AddMelonCategory(ref page, ModSettings.Hidden_Foldout_SettingsCategory); + _ourCategory = _rootPage.AddMelonCategory(ModSettings.Hidden_Foldout_SettingsCategory); Button placeStickersButton = _ourCategory.AddButton("Place Stickers", "Stickers-magic-wand", "Place stickers via raycast.", ButtonStyle.TextWithIcon); placeStickersButton.OnPress += OnPlaceStickersButtonClick; @@ -55,6 +35,8 @@ public static partial class BtkUiAddon Button openMultiSelectionButton = _ourCategory.AddButton("Sticker SFX", "Stickers-headset", "Choose the SFX used when a sticker is placed.", ButtonStyle.TextWithIcon); openMultiSelectionButton.OnPress += () => QuickMenuAPI.OpenMultiSelect(_sfxSelection); + + _ourCategory.AddMelonToggle(ModSettings.Entry_HapticsOnPlace); ToggleButton toggleDesktopKeybindButton = _ourCategory.AddToggle("Use Desktop Keybind", "Should the Desktop keybind be active.", ModSettings.Entry_UsePlaceBinding.Value); Button openDesktopKeybindButton = _ourCategory.AddButton("Desktop Keybind", "Stickers-alphabet", "Choose the key binding to place stickers.", ButtonStyle.TextWithIcon); diff --git a/Stickers/Integrations/BTKUI/UIAddon.Main.cs b/Stickers/Integrations/BTKUI/UIAddon.Main.cs new file mode 100644 index 0000000..8bce9c0 --- /dev/null +++ b/Stickers/Integrations/BTKUI/UIAddon.Main.cs @@ -0,0 +1,92 @@ +using BTKUILib; +using BTKUILib.UIObjects; +using NAK.Stickers.Networking; +using NAK.Stickers.Utilities; + +namespace NAK.Stickers.Integrations; + +public static partial class BTKUIAddon +{ + private static Page _rootPage; + private static string _rootPageElementID; + + private static bool _isOurTabOpened; + + public static void Initialize() + { + Setup_Icons(); + Setup_StickerModTab(); + Setup_PlayerOptionsPage(); + } + + #region Initialization + + private static void Setup_Icons() + { + // All icons used - https://www.flaticon.com/authors/gohsantosadrive + QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-alphabet", BTKUILibExtensions.GetIconStream("Gohsantosadrive_Icons.Stickers-alphabet.png")); + QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-eraser", BTKUILibExtensions.GetIconStream("Gohsantosadrive_Icons.Stickers-eraser.png")); + QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-folder", BTKUILibExtensions.GetIconStream("Gohsantosadrive_Icons.Stickers-folder.png")); + QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-headset", BTKUILibExtensions.GetIconStream("Gohsantosadrive_Icons.Stickers-headset.png")); + QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-magnifying-glass", BTKUILibExtensions.GetIconStream("Gohsantosadrive_Icons.Stickers-magnifying-glass.png")); + QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-magic-wand", BTKUILibExtensions.GetIconStream("Gohsantosadrive_Icons.Stickers-magic-wand.png")); + QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-pencil", BTKUILibExtensions.GetIconStream("Gohsantosadrive_Icons.Stickers-pencil.png")); + QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-puzzle", BTKUILibExtensions.GetIconStream("Gohsantosadrive_Icons.Stickers-puzzle.png")); + QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-rubbish-bin", BTKUILibExtensions.GetIconStream("Gohsantosadrive_Icons.Stickers-rubbish-bin.png")); + } + + private static void Setup_StickerModTab() + { + _rootPage = new Page(ModSettings.ModName, ModSettings.SM_SettingsCategory, true, "Stickers-puzzle") + { + MenuTitle = ModSettings.SM_SettingsCategory, + MenuSubtitle = "Stickers! Double-click the tab to quickly toggle Sticker Mode.", + }; + + _rootPageElementID = _rootPage.ElementID; + + QuickMenuAPI.OnTabChange += OnTabChange; + ModNetwork.OnTextureOutboundStateChanged += (isSending) => + { + if (_isOurTabOpened && isSending) QuickMenuAPI.ShowAlertToast("Sending Sticker over Mod Network...", 2); + //_rootPage.Disabled = isSending; // TODO: fix being able to select stickers while sending + }; + + StickerSystem.OnStickerLoaded += (slotIndex, imageRelativePath) => + { + if (_isOurTabOpened) QuickMenuAPI.ShowAlertToast($"Sticker loaded: {imageRelativePath}", 2); + _stickerSelectionButtons[slotIndex].ButtonIcon = StickerCache.GetBtkUiIconName(imageRelativePath); + }; + + StickerSystem.OnStickerLoadFailed += (slotIndex, error) => + { + if (_isOurTabOpened) QuickMenuAPI.ShowAlertToast(error, 3); + }; + + Setup_StickersModCategory(); + Setup_StickerSelectionCategory(); + Setup_DebugOptionsCategory(); + } + + #endregion + + #region Double-Click Place Sticker + + private static DateTime lastTime = DateTime.Now; + + private static void OnTabChange(string newTab, string previousTab) + { + _isOurTabOpened = newTab == _rootPageElementID; + if (!_isOurTabOpened) return; + + TimeSpan timeDifference = DateTime.Now - lastTime; + if (timeDifference.TotalSeconds <= 0.5) + { + StickerSystem.Instance.IsInStickerMode = !StickerSystem.Instance.IsInStickerMode; + return; + } + lastTime = DateTime.Now; + } + + #endregion Double-Click Place Sticker +} \ No newline at end of file diff --git a/Stickers/Integrations/BTKUI/UIAddon.Page.PlayerOptions.cs b/Stickers/Integrations/BTKUI/UIAddon.Page.PlayerOptions.cs new file mode 100644 index 0000000..f44fc40 --- /dev/null +++ b/Stickers/Integrations/BTKUI/UIAddon.Page.PlayerOptions.cs @@ -0,0 +1,53 @@ +using BTKUILib; +using BTKUILib.UIObjects; +using BTKUILib.UIObjects.Components; +using NAK.Stickers.Networking; + +namespace NAK.Stickers.Integrations; + +public static partial class BTKUIAddon +{ + #region Setup + + private static ToggleButton _disallowForSessionButton; + + private static void Setup_PlayerOptionsPage() + { + Category category = QuickMenuAPI.PlayerSelectPage.AddCategory(ModSettings.SM_SettingsCategory, ModSettings.ModName); + + //Button identifyButton = category.AddButton("Identify Stickers", "Stickers-magnifying-glass", "Identify this players stickers by making them flash."); + //identifyButton.OnPress += OnPressIdentifyPlayerStickersButton; + + Button clearStickersButton = category.AddButton("Clear Stickers", "Stickers-eraser", "Clear this players stickers."); + clearStickersButton.OnPress += OnPressClearSelectedPlayerStickersButton; + + _disallowForSessionButton = category.AddToggle("Block for Session", "Disallow this player from using stickers for this session. This setting will not persist through restarts.", false); + _disallowForSessionButton.OnValueUpdated += OnToggleDisallowForSessionButton; + QuickMenuAPI.OnPlayerSelected += (_, id) => { _disallowForSessionButton.ToggleValue = ModNetwork.IsPlayerACriminal(id); }; + } + + #endregion Setup + + #region Callbacks + + // private static void OnPressIdentifyPlayerStickersButton() + // { + // if (string.IsNullOrEmpty(QuickMenuAPI.SelectedPlayerID)) return; + // StickerSystem.Instance.OnStickerIdentifyReceived(QuickMenuAPI.SelectedPlayerID); + // } + + private static void OnPressClearSelectedPlayerStickersButton() + { + if (string.IsNullOrEmpty(QuickMenuAPI.SelectedPlayerID)) return; + StickerSystem.Instance.OnStickerClearAllReceived(QuickMenuAPI.SelectedPlayerID); + } + + private static void OnToggleDisallowForSessionButton(bool isOn) + { + if (string.IsNullOrEmpty(QuickMenuAPI.SelectedPlayerID)) return; + ModNetwork.HandleDisallowForSession(QuickMenuAPI.SelectedPlayerID, isOn); + } + + + #endregion Callbacks +} \ No newline at end of file diff --git a/Stickers/Integrations/BTKUI/UIAddon.Page.StickerSelect.cs b/Stickers/Integrations/BTKUI/UIAddon.Page.StickerSelect.cs new file mode 100644 index 0000000..1874ccb --- /dev/null +++ b/Stickers/Integrations/BTKUI/UIAddon.Page.StickerSelect.cs @@ -0,0 +1,270 @@ +using BTKUILib; +using BTKUILib.UIObjects; +using BTKUILib.UIObjects.Components; +using MTJobSystem; +using NAK.Stickers.Utilities; +using UnityEngine; + +namespace NAK.Stickers.Integrations; + +public static partial class BTKUIAddon +{ + #region Constants and Fields + + private static readonly HashSet SUPPORTED_IMAGE_EXTENSIONS = new() { ".png", ".jpg", ".jpeg" }; + + private static Page _ourDirectoryBrowserPage; + + private static Category _fileCategory; + private static Category _folderCategory; + + private static Button[] _fileButtons = new Button[80]; // 100 files, will resize if needed + private static Button[] _folderButtons = new Button[20]; // 20 folders, will resize if needed + + private static readonly Button[] _stickerSelectionButtons = new Button[4]; + private static float _stickerSelectionButtonDoubleClickTime; + + private static DirectoryInfo _curDirectoryInfo; + private static string _initialDirectory; + private static int _curSelectedSticker; + + private static readonly object _isPopulatingLock = new(); + private static bool _isPopulating; + internal static bool IsPopulatingPage { + get { lock (_isPopulatingLock) return _isPopulating; } + private set { lock (_isPopulatingLock) _isPopulating = value; } + } + + #endregion Constants and Fields + + #region Page Setup + + private static void Setup_StickerSelectionCategory() + { + _initialDirectory = StickerSystem.GetStickersFolderPath(); + _curDirectoryInfo = new DirectoryInfo(_initialDirectory); + + // Create page + _ourDirectoryBrowserPage = Page.GetOrCreatePage(ModSettings.ModName, "Directory Browser"); + QuickMenuAPI.AddRootPage(_ourDirectoryBrowserPage); + + // Setup categories + _folderCategory = _ourDirectoryBrowserPage.AddCategory("Subdirectories"); + _fileCategory = _ourDirectoryBrowserPage.AddCategory("Images"); + + SetupFolderButtons(); + SetupFileButtons(); + SetupStickerSelectionButtons(); + + _ourDirectoryBrowserPage.OnPageOpen += OnPageOpen; + _ourDirectoryBrowserPage.OnPageClosed += OnPageClosed; + } + + private static void SetupFolderButtons(int startIndex = 0) + { + for (int i = startIndex; i < _folderButtons.Length; i++) + { + Button button = _folderCategory.AddButton("A", "Stickers-folder", "A"); + button.OnPress += () => + { + if (IsPopulatingPage) return; + _curDirectoryInfo = new DirectoryInfo(Path.Combine(_curDirectoryInfo.FullName, button.ButtonTooltip[5..])); + _ourDirectoryBrowserPage.OpenPage(false, true); + }; + _folderButtons[i] = button; + } + } + + private static void SetupFileButtons(int startIndex = 0) + { + for (int i = startIndex; i < _fileButtons.Length; i++) + { + Button button = _fileCategory.AddButton(string.Empty, "Stickers-folder", "A", ButtonStyle.FullSizeImage); + button.Hidden = true; + button.OnPress += () => + { + string absolutePath = Path.Combine(_curDirectoryInfo.FullName, button.ButtonTooltip[5..]); + string relativePath = Path.GetRelativePath(_initialDirectory, absolutePath); + StickerSystem.Instance.LoadImage(relativePath, _curSelectedSticker); + }; + _fileButtons[i] = button; + } + } + + private static void SetupStickerSelectionButtons() + { + Category stickerSelection = _rootPage.AddMelonCategory(ModSettings.Hidden_Foldout_SelectionCategory); + + for (int i = 0; i < _stickerSelectionButtons.Length; i++) + { + Button button = stickerSelection.AddButton(string.Empty, "Stickers-puzzle", "Click to select sticker for placement. Double-click or hold to select from Stickers folder.", ButtonStyle.FullSizeImage); + var curIndex = i; + button.OnPress += () => SelectStickerAtSlot(curIndex); + button.OnHeld += () => OpenStickerSelectionForSlot(curIndex); + _stickerSelectionButtons[i] = button; + + // initial setup + button.ButtonIcon = StickerCache.GetBtkUiIconName(ModSettings.Hidden_SelectedStickerNames.Value[i]); + } + } + + #endregion Page Setup + + #region Private Methods + + private static void OnPageOpen() + { + if (IsPopulatingPage) return; // btkui bug, page open is called twice when using OnHeld + IsPopulatingPage = true; + + _ourDirectoryBrowserPage.PageDisplayName = _curDirectoryInfo.Name; + + HideAllButtons(_folderButtons); + HideAllButtons(_fileButtons); + + // Populate the page + Task.Run(PopulateMenuItems); + } + + private static void OnPageClosed() + { + if (_curDirectoryInfo.FullName != _initialDirectory) + _curDirectoryInfo = new DirectoryInfo(Path.Combine(_curDirectoryInfo.FullName, @"..\")); + } + + private static void HideAllButtons(Button[] buttons) + { + foreach (Button button in buttons) + { + if (button == null) break; // Array resized, excess buttons are generating + //if (button.Hidden) break; // Reached the end of the visible buttons + button.Hidden = true; + } + } + + private static void SelectStickerAtSlot(int index) + { + if (_curSelectedSticker != index) + { + _curSelectedSticker = index; + _stickerSelectionButtonDoubleClickTime = 0f; + } + + StickerSystem.Instance.SelectedStickerSlot = index; + + // double-click to open (otherwise just hold) + if (Time.time - _stickerSelectionButtonDoubleClickTime < 0.5f) + { + OpenStickerSelectionForSlot(index); + _stickerSelectionButtonDoubleClickTime = 0f; + return; + } + _stickerSelectionButtonDoubleClickTime = Time.time; + } + + private static void OpenStickerSelectionForSlot(int index) + { + if (IsPopulatingPage) return; + _curSelectedSticker = index; + _curDirectoryInfo = new DirectoryInfo(_initialDirectory); + _ourDirectoryBrowserPage.OpenPage(false, true); + } + + private static void PopulateMenuItems() + { + // StickerMod.Logger.Msg("Populating menu items."); + try + { + var directories = _curDirectoryInfo.GetDirectories(); + var files = _curDirectoryInfo.GetFiles(); + + MTJobManager.RunOnMainThread("PopulateMenuItems", () => + { + // resize the arrays to the max amount of buttons + int difference = directories.Length - _folderButtons.Length; + if (difference > 0) + { + Array.Resize(ref _folderButtons, directories.Length); + SetupFolderButtons(difference); + StickerMod.Logger.Msg($"Resized folder buttons to {directories.Length}"); + } + + difference = files.Length - _fileButtons.Length; + if (difference > 0) + { + Array.Resize(ref _fileButtons, files.Length); + SetupFileButtons(difference); + StickerMod.Logger.Msg($"Resized file buttons to {files.Length}"); + } + + _folderCategory.Hidden = directories.Length == 0; + _folderCategory.CategoryName = $"Subdirectories ({directories.Length})"; + _fileCategory.Hidden = files.Length == 0; + _fileCategory.CategoryName = $"Images ({files.Length})"; + }); + + PopulateFolders(directories); + PopulateFiles(files); + } + catch (Exception e) + { + StickerMod.Logger.Error($"Failed to populate menu items: {e.Message}"); + } + finally + { + IsPopulatingPage = false; + } + } + + private static void PopulateFolders(IReadOnlyList directories) + { + for (int i = 0; i < _folderButtons.Length; i++) + { + Button button = _folderButtons[i]; + if (i >= directories.Count) + break; + + button.ButtonText = directories[i].Name; + button.ButtonTooltip = $"Open {directories[i].Name}"; + MTJobManager.RunOnMainThread("PopulateMenuItems", () => button.Hidden = false); + + if (i <= 16) Thread.Sleep(10); // For the pop-in effect + } + } + + private static void PopulateFiles(IReadOnlyList files) + { + for (int i = 0; i < _fileButtons.Length; i++) + { + Button button = _fileButtons[i]; + if (i >= files.Count) + break; + + FileInfo fileInfo = files[i]; + + if (!SUPPORTED_IMAGE_EXTENSIONS.Contains(fileInfo.Extension.ToLower())) + continue; + + string relativePath = Path.GetRelativePath(_initialDirectory, fileInfo.FullName); + string relativePathWithoutExtension = relativePath[..^fileInfo.Extension.Length]; + + button.ButtonTooltip = $"Load {fileInfo.Name}"; // Do not change "Load " prefix, we extract file name + + if (StickerCache.IsThumbnailAvailable(relativePathWithoutExtension)) + { + button.ButtonIcon = StickerCache.GetBtkUiIconName(relativePath); + } + else + { + button.ButtonIcon = string.Empty; + StickerCache.EnqueueThumbnailGeneration(fileInfo, button); + } + + MTJobManager.RunOnMainThread("PopulateMenuItems", () => button.Hidden = false); + + if (i <= 16) Thread.Sleep(10); // For the pop-in effect + } + } + + #endregion Icon Utils +} \ No newline at end of file diff --git a/Stickers/Main.cs b/Stickers/Main.cs index bb4be55..310e198 100644 --- a/Stickers/Main.cs +++ b/Stickers/Main.cs @@ -22,10 +22,11 @@ public class StickerMod : MelonMod ApplyPatches(typeof(Patches.PlayerSetupPatches)); ApplyPatches(typeof(Patches.ControllerRayPatches)); + ApplyPatches(typeof(Patches.ShaderFilterHelperPatches)); LoadAssetBundle(); - InitializeIntegration(nameof(BTKUILib), BtkUiAddon.Initialize); // quick menu ui + InitializeIntegration(nameof(BTKUILib), BTKUIAddon.Initialize); // quick menu ui } public override void OnUpdate() @@ -36,7 +37,7 @@ public class StickerMod : MelonMod if (!Input.GetKeyDown((KeyCode)ModSettings.Entry_PlaceBinding.Value)) return; - StickerSystem.Instance.PlaceStickerFromTransform(PlayerSetup.Instance.activeCam.transform); + StickerSystem.Instance.PlaceStickerFromControllerRay(PlayerSetup.Instance.activeCam.transform); } public override void OnApplicationQuit() @@ -80,11 +81,13 @@ public class StickerMod : MelonMod private const string SourceSFX_PlayerSprayer = "Assets/Mods/Stickers/Source_sound_player_sprayer.wav"; private const string LittleBigPlanetSFX_StickerPlace = "Assets/Mods/Stickers/LBP_Sticker_Place.wav"; + private const string FactorioSFX_AlertDestroyed = "Assets/Mods/Stickers/Factorio_alert_destroyed.wav"; internal static Shader DecalSimpleShader; internal static Shader DecalWriterShader; internal static AudioClip SourceSFXPlayerSprayer; - internal static AudioClip LittleBigPlanetStickerPlace; + internal static AudioClip LittleBigPlanetSFXStickerPlace; + internal static AudioClip FactorioSFXAlertDestroyed; private void LoadAssetBundle() { @@ -127,14 +130,22 @@ public class StickerMod : MelonMod SourceSFXPlayerSprayer.hideFlags |= HideFlags.DontUnloadUnusedAsset; LoggerInstance.Msg($"Loaded {SourceSFX_PlayerSprayer}!"); - LittleBigPlanetStickerPlace = assetBundle.LoadAsset(LittleBigPlanetSFX_StickerPlace); - if (LittleBigPlanetStickerPlace == null) { + LittleBigPlanetSFXStickerPlace = assetBundle.LoadAsset(LittleBigPlanetSFX_StickerPlace); + if (LittleBigPlanetSFXStickerPlace == null) { LoggerInstance.Error($"Failed to load {LittleBigPlanetSFX_StickerPlace}! Prefab is null!"); return; } - LittleBigPlanetStickerPlace.hideFlags |= HideFlags.DontUnloadUnusedAsset; + LittleBigPlanetSFXStickerPlace.hideFlags |= HideFlags.DontUnloadUnusedAsset; LoggerInstance.Msg($"Loaded {LittleBigPlanetSFX_StickerPlace}!"); + FactorioSFXAlertDestroyed = assetBundle.LoadAsset(FactorioSFX_AlertDestroyed); + if (FactorioSFXAlertDestroyed == null) { + LoggerInstance.Error($"Failed to load {FactorioSFX_AlertDestroyed}! Prefab is null!"); + return; + } + FactorioSFXAlertDestroyed.hideFlags |= HideFlags.DontUnloadUnusedAsset; + LoggerInstance.Msg($"Loaded {FactorioSFX_AlertDestroyed}!"); + // load LoggerInstance.Msg("Asset bundle successfully loaded!"); diff --git a/Stickers/ModSettings.cs b/Stickers/ModSettings.cs index 4ed80fe..8c15b5c 100644 --- a/Stickers/ModSettings.cs +++ b/Stickers/ModSettings.cs @@ -5,13 +5,20 @@ namespace NAK.Stickers; public static class ModSettings { + #region Constants & Category + internal const string ModName = nameof(StickerMod); + internal const string SM_SettingsCategory = "Stickers Mod"; - internal const string SM_SelectionCategory = "Sticker Selection"; + private const string SM_SelectionCategory = "Sticker Selection"; private const string DEBUG_SettingsCategory = "Debug Options"; + internal const int MaxStickerSlots = 4; + private static readonly MelonPreferences_Category Category = MelonPreferences.CreateCategory(ModName); + + #endregion Constants & Category #region Hidden Foldout Entries @@ -28,18 +35,14 @@ public static class ModSettings #region Stickers Mod Settings + internal static readonly MelonPreferences_Entry Entry_HapticsOnPlace = + Category.CreateEntry("haptics_on_place", true, "Haptics On Place", "Enable haptic feedback when placing stickers."); + internal static readonly MelonPreferences_Entry Entry_PlayerUpAlignmentThreshold = Category.CreateEntry("player_up_alignment_threshold", 20f, "Player Up Alignment Threshold", "The threshold the controller roll can be within to align perfectly with the player up vector. Set to 0f to always align to controller up."); internal static readonly MelonPreferences_Entry Entry_SelectedSFX = - Category.CreateEntry("selected_sfx", SFXType.LBP, "Selected SFX", "The SFX used when a sticker is placed."); - - internal enum SFXType - { - LBP, - Source, - None - } + Category.CreateEntry("selected_sfx", SFXType.LittleBigPlanetSticker, "Selected SFX", "The SFX used when a sticker is placed."); internal static readonly MelonPreferences_Entry Entry_UsePlaceBinding = Category.CreateEntry("use_binding", true, "Use Place Binding", "Use the place binding to place stickers."); @@ -47,113 +50,13 @@ public static class ModSettings internal static readonly MelonPreferences_Entry Entry_PlaceBinding = Category.CreateEntry("place_binding", KeyBind.G, "Sticker Bind", "The key binding to place stickers."); - internal enum KeyBind - { - // Alphabetic keys - A = KeyCode.A, // 0x00000061 - B = KeyCode.B, // 0x00000062 - C = KeyCode.C, // 0x00000063 - D = KeyCode.D, // 0x00000064 - E = KeyCode.E, // 0x00000065 - F = KeyCode.F, // 0x00000066 - G = KeyCode.G, // 0x00000067 - H = KeyCode.H, // 0x00000068 - I = KeyCode.I, // 0x00000069 - J = KeyCode.J, // 0x0000006A - K = KeyCode.K, // 0x0000006B - L = KeyCode.L, // 0x0000006C - M = KeyCode.M, // 0x0000006D - N = KeyCode.N, // 0x0000006E - O = KeyCode.O, // 0x0000006F - P = KeyCode.P, // 0x00000070 - Q = KeyCode.Q, // 0x00000071 - R = KeyCode.R, // 0x00000072 - S = KeyCode.S, // 0x00000073 - T = KeyCode.T, // 0x00000074 - U = KeyCode.U, // 0x00000075 - V = KeyCode.V, // 0x00000076 - W = KeyCode.W, // 0x00000077 - X = KeyCode.X, // 0x00000078 - Y = KeyCode.Y, // 0x00000079 - Z = KeyCode.Z, // 0x0000007A - - // Mouse Buttons - Mouse0 = KeyCode.Mouse0, // 0x00000143 - Mouse1 = KeyCode.Mouse1, // 0x00000144 - Mouse2 = KeyCode.Mouse2, // 0x00000145 - Mouse3 = KeyCode.Mouse3, // 0x00000146 - Mouse4 = KeyCode.Mouse4, // 0x00000147 - Mouse5 = KeyCode.Mouse5, // 0x00000148 - Mouse6 = KeyCode.Mouse6, // 0x00000149 - - // Special Characters - // Backspace = KeyCode.Backspace, // 0x00000008 - // Tab = KeyCode.Tab, // 0x00000009 - // Clear = KeyCode.Clear, // 0x0000000C - // Return = KeyCode.Return, // 0x0000000D - // Pause = KeyCode.Pause, // 0x00000013 - // Escape = KeyCode.Escape, // 0x0000001B - // Space = KeyCode.Space, // 0x00000020 - // Exclaim = KeyCode.Exclaim, // 0x00000021 - // DoubleQuote = KeyCode.DoubleQuote, // 0x00000022 - // Hash = KeyCode.Hash, // 0x00000023 - // Dollar = KeyCode.Dollar, // 0x00000024 - // Percent = KeyCode.Percent, // 0x00000025 - // Ampersand = KeyCode.Ampersand, // 0x00000026 - // Quote = KeyCode.Quote, // 0x00000027 - // LeftParen = KeyCode.LeftParen, // 0x00000028 - // RightParen = KeyCode.RightParen, // 0x00000029 - // Asterisk = KeyCode.Asterisk, // 0x0000002A - // Plus = KeyCode.Plus, // 0x0000002B - // Comma = KeyCode.Comma, // 0x0000002C - // Minus = KeyCode.Minus, // 0x0000002D - // Period = KeyCode.Period, // 0x0000002E - // Slash = KeyCode.Slash, // 0x0000002F - // Alpha0 = KeyCode.Alpha0, // 0x00000030 - // Alpha1 = KeyCode.Alpha1, // 0x00000031 - // Alpha2 = KeyCode.Alpha2, // 0x00000032 - // Alpha3 = KeyCode.Alpha3, // 0x00000033 - // Alpha4 = KeyCode.Alpha4, // 0x00000034 - // Alpha5 = KeyCode.Alpha5, // 0x00000035 - // Alpha6 = KeyCode.Alpha6, // 0x00000036 - // Alpha7 = KeyCode.Alpha7, // 0x00000037 - // Alpha8 = KeyCode.Alpha8, // 0x00000038 - // Alpha9 = KeyCode.Alpha9, // 0x00000039 - // Colon = KeyCode.Colon, // 0x0000003A - // Semicolon = KeyCode.Semicolon, // 0x0000003B - // Less = KeyCode.Less, // 0x0000003C - // Equals = KeyCode.Equals, // 0x0000003D - // Greater = KeyCode.Greater, // 0x0000003E - // Question = KeyCode.Question, // 0x0000003F - // At = KeyCode.At, // 0x00000040 - // LeftBracket = KeyCode.LeftBracket, // 0x0000005B - // Backslash = KeyCode.Backslash, // 0x0000005C - // RightBracket = KeyCode.RightBracket, // 0x0000005D - // Caret = KeyCode.Caret, // 0x0000005E - // Underscore = KeyCode.Underscore, // 0x0000005F - // BackQuote = KeyCode.BackQuote, // 0x00000060 - // Delete = KeyCode.Delete // 0x0000007F - } - - internal static readonly MelonPreferences_Entry Hidden_SelectedStickerName = - Category.CreateEntry("hidden_selected_sticker", string.Empty, is_hidden: true, display_name: "Selected Sticker", description: "The currently selected sticker name."); + internal static readonly MelonPreferences_Entry Hidden_SelectedStickerNames = + Category.CreateEntry("selected_sticker_name", Array.Empty(), + display_name: "Selected Sticker Name", + description: "The name of the sticker selected for stickering.", + is_hidden: true); #endregion Stickers Mod Settings - - #region Decalery Settings - - internal static readonly MelonPreferences_Entry Decalery_DecalMode = - Category.CreateEntry("decalery_decal_mode", DecaleryMode.GPU, display_name: "Decal Mode", description: "The mode Decalery should use for decal creation. By default GPU should be used. **Note:** Not all content is marked as readable, so only the GPU modes are expected to work properly on UGC."); - - internal enum DecaleryMode - { - CPU, - GPU, - CPUBurst, - GPUIndirect - } - - #endregion Decalery Settings #region Debug Settings @@ -169,8 +72,15 @@ public static class ModSettings internal static void Initialize() { + // ensure sticker slots are initialized to the correct size + string[] selectedStickerNames = Hidden_SelectedStickerNames.Value; + if (selectedStickerNames.Length != MaxStickerSlots) Array.Resize(ref selectedStickerNames, MaxStickerSlots); + Hidden_SelectedStickerNames.Value = selectedStickerNames; + + foreach (var selectedSticker in selectedStickerNames) + StickerMod.Logger.Msg($"Selected Sticker: {selectedSticker}"); + Entry_PlayerUpAlignmentThreshold.OnEntryValueChanged.Subscribe(OnPlayerUpAlignmentThresholdChanged); - Decalery_DecalMode.OnEntryValueChanged.Subscribe(OnDecaleryDecalModeChanged); } #endregion Initialization @@ -181,13 +91,6 @@ public static class ModSettings { Entry_PlayerUpAlignmentThreshold.Value = Mathf.Clamp(newValue, 0f, 180f); } - - private static void OnDecaleryDecalModeChanged(DecaleryMode oldValue, DecaleryMode newValue) - { - DecalManager.SetPreferredMode((DecalUtils.Mode)newValue, newValue == DecaleryMode.GPUIndirect, 0); - if (newValue != DecaleryMode.GPU) StickerMod.Logger.Warning("Decalery is not set to GPU mode. Expect compatibility issues with user generated content when mesh data is not marked as readable."); - StickerSystem.Instance.CleanupAll(); - } #endregion Setting Changed Callbacks } \ No newline at end of file diff --git a/Stickers/Patches.cs b/Stickers/Patches.cs index d13a10e..717ebb2 100644 --- a/Stickers/Patches.cs +++ b/Stickers/Patches.cs @@ -1,5 +1,7 @@ using ABI_RC.Core.InteractionSystem; +using ABI_RC.Core.IO; using ABI_RC.Core.Player; +using ABI_RC.Core.Savior; using HarmonyLib; using UnityEngine; @@ -28,6 +30,20 @@ internal static class ControllerRayPatches if (__instance._hitUIInternal || !__instance._interactDown) return; - StickerSystem.Instance.PlaceStickerFromTransform(__instance.rayDirectionTransform); + StickerSystem.Instance.PlaceStickerFromControllerRay(__instance.rayDirectionTransform, __instance.hand); + } +} + +internal static class ShaderFilterHelperPatches +{ + [HarmonyPrefix] + [HarmonyPatch(typeof(ShaderFilterHelper), nameof(ShaderFilterHelper.SetupFilter))] + private static void Prefix_ShaderFilterHelper_SetupFilter() + { + if (!MetaPort.Instance.settings.GetSettingsBool("ExperimentalShaderLimitEnabled")) + return; + + StickerMod.Logger.Warning("ExperimentalShaderLimitEnabled found to be true. Disabling setting to prevent crashes when spawning stickers!"); + MetaPort.Instance.settings.SetSettingsBool("ExperimentalShaderLimitEnabled", false); } } \ No newline at end of file diff --git a/Stickers/Properties/AssemblyInfo.cs b/Stickers/Properties/AssemblyInfo.cs index 164ca24..994904f 100644 --- a/Stickers/Properties/AssemblyInfo.cs +++ b/Stickers/Properties/AssemblyInfo.cs @@ -27,6 +27,6 @@ using System.Reflection; namespace NAK.Stickers.Properties; internal static class AssemblyInfoParams { - public const string Version = "1.0.0"; + public const string Version = "1.0.1"; public const string Author = "Exterrata & NotAKidoS"; } \ No newline at end of file diff --git a/Stickers/README.md b/Stickers/README.md index cc36730..5db284d 100644 --- a/Stickers/README.md +++ b/Stickers/README.md @@ -1,6 +1,26 @@ # Stickers -Stickers! +Stickers! Allows you to place small images on any surface- synced over Mod Network. + +### How to use +Any image placed in the `UserData/Stickers/` folder will be available to choose from within the BTKUI tab. Once you’ve selected an image, enter Sticker Mode or use the Desktop Binding to start placing the sticker. Remote clients running the mod will automatically request the image data if they do not have it stored locally. + +### Limitations +- Only PNG, JPG, & JPEG images are supported. + - While it would be cool to send gifs, I don't want to abuse Mod Network that much lol. +- Image should be under 256KB in size. +- Image dimensions should be a power of 2 (e.g. 512x512, 1024x1024). + - If the image exceeds the size limit or is not a power of 2 the mod will automatically resize it. + - The automatic resizing may result in loss of quality (or may just fail), so it is recommended to resize the image yourself before placing it in the `UserData/Stickers/` folder. + +## Attributions +- All icons used are by [Gohsantosadrive]() on Flaticon. +- Decal generation system by [Mr F]() on the Unity Asset Store. + +## Notice of partial source-code +This mod is built around a modified version of [Decalery]() from the Unity Asset Store. As such, only partial source code is available in this repository. + +If you are looking for a similar open-source asset to generate decals at runtime, I recommend [Driven Decals (MIT)]() as it is what the mod was built around originally. --- diff --git a/Stickers/Resources/Gohsantosadrive_Icons/Stickers-magnifying-glass.png b/Stickers/Resources/Gohsantosadrive_Icons/Stickers-magnifying-glass.png new file mode 100644 index 0000000..c0d49e1 Binary files /dev/null and b/Stickers/Resources/Gohsantosadrive_Icons/Stickers-magnifying-glass.png differ diff --git a/Stickers/Resources/decalery_shaders.assets b/Stickers/Resources/decalery_shaders.assets index 23dd014..6535c98 100644 Binary files a/Stickers/Resources/decalery_shaders.assets and b/Stickers/Resources/decalery_shaders.assets differ diff --git a/Stickers/Stickers.csproj b/Stickers/Stickers.csproj index a587521..19f88ea 100644 --- a/Stickers/Stickers.csproj +++ b/Stickers/Stickers.csproj @@ -29,6 +29,8 @@ + + diff --git a/Stickers/Stickers/Enums/StickerKeyBinds.cs b/Stickers/Stickers/Enums/StickerKeyBinds.cs new file mode 100644 index 0000000..9de4b42 --- /dev/null +++ b/Stickers/Stickers/Enums/StickerKeyBinds.cs @@ -0,0 +1,40 @@ +using UnityEngine; + +namespace NAK.Stickers; + +internal enum KeyBind +{ + A = KeyCode.A, + B = KeyCode.B, + C = KeyCode.C, + D = KeyCode.D, + E = KeyCode.E, + F = KeyCode.F, + G = KeyCode.G, + H = KeyCode.H, + I = KeyCode.I, + J = KeyCode.J, + K = KeyCode.K, + L = KeyCode.L, + M = KeyCode.M, + N = KeyCode.N, + O = KeyCode.O, + P = KeyCode.P, + Q = KeyCode.Q, + R = KeyCode.R, + S = KeyCode.S, + T = KeyCode.T, + U = KeyCode.U, + V = KeyCode.V, + W = KeyCode.W, + X = KeyCode.X, + Y = KeyCode.Y, + Z = KeyCode.Z, + Mouse0 = KeyCode.Mouse0, + Mouse1 = KeyCode.Mouse1, + Mouse2 = KeyCode.Mouse2, + Mouse3 = KeyCode.Mouse3, + Mouse4 = KeyCode.Mouse4, + Mouse5 = KeyCode.Mouse5, + Mouse6 = KeyCode.Mouse6, +} \ No newline at end of file diff --git a/Stickers/Stickers/Enums/StickerSFXType.cs b/Stickers/Stickers/Enums/StickerSFXType.cs new file mode 100644 index 0000000..e116c9c --- /dev/null +++ b/Stickers/Stickers/Enums/StickerSFXType.cs @@ -0,0 +1,9 @@ +namespace NAK.Stickers; + +internal enum SFXType +{ + LittleBigPlanetSticker, + SourceEngineSpray, + FactorioAlertDestroyed, + None +} \ No newline at end of file diff --git a/Stickers/Stickers/Networking/ModNetwork.Constants.cs b/Stickers/Stickers/Networking/ModNetwork.Constants.cs new file mode 100644 index 0000000..2d58a94 --- /dev/null +++ b/Stickers/Stickers/Networking/ModNetwork.Constants.cs @@ -0,0 +1,16 @@ +using NAK.Stickers.Properties; + +namespace NAK.Stickers.Networking; + +public static partial class ModNetwork +{ + #region Constants + + internal const int MaxTextureSize = 1024 * 256; // 256KB + + private const string ModId = $"MelonMod.NAK.Stickers_v{AssemblyInfoParams.Version}"; + private const int ChunkSize = 1024; // roughly 1KB per ModNetworkMessage + private const int MaxChunkCount = MaxTextureSize / ChunkSize; + + #endregion Constants +} \ No newline at end of file diff --git a/Stickers/Stickers/Networking/ModNetwork.Enums.cs b/Stickers/Stickers/Networking/ModNetwork.Enums.cs new file mode 100644 index 0000000..a3c9e96 --- /dev/null +++ b/Stickers/Stickers/Networking/ModNetwork.Enums.cs @@ -0,0 +1,21 @@ +namespace NAK.Stickers.Networking; + +public static partial class ModNetwork +{ + #region Enums + + // Remote clients will request textures from the sender if they receive PlaceSticker with a textureHash they don't have + + private enum MessageType : byte + { + PlaceSticker = 0, // stickerSlot, textureHash, position, forward, up + ClearSticker = 1, // stickerSlot + ClearAllStickers = 2, // none + StartTexture = 3, // stickerSlot, textureHash, chunkCount, width, height + SendTexture = 4, // chunkIdx, chunkData + EndTexture = 5, // none + RequestTexture = 6 // stickerSlot, textureHash + } + + #endregion Enums +} \ No newline at end of file diff --git a/Stickers/Stickers/Networking/ModNetwork.Events.cs b/Stickers/Stickers/Networking/ModNetwork.Events.cs new file mode 100644 index 0000000..cf414a8 --- /dev/null +++ b/Stickers/Stickers/Networking/ModNetwork.Events.cs @@ -0,0 +1,18 @@ +using MTJobSystem; + +namespace NAK.Stickers.Networking; + +public static partial class ModNetwork +{ + #region Events + + public static Action OnTextureOutboundStateChanged; + + private static void InvokeTextureOutboundStateChanged(bool isSending) + { + MTJobManager.RunOnMainThread("ModNetwork.InvokeTextureOutboundStateChanged", + () => OnTextureOutboundStateChanged?.Invoke(isSending)); + } + + #endregion Events +} \ No newline at end of file diff --git a/Stickers/Stickers/Networking/ModNetwork.Helpers.cs b/Stickers/Stickers/Networking/ModNetwork.Helpers.cs new file mode 100644 index 0000000..04e173d --- /dev/null +++ b/Stickers/Stickers/Networking/ModNetwork.Helpers.cs @@ -0,0 +1,18 @@ +using ABI_RC.Core.Networking; +using DarkRift; + +namespace NAK.Stickers.Networking; + +public static partial class ModNetwork +{ + #region Private Methods + + private static bool IsConnectedToGameNetwork() + { + return NetworkManager.Instance != null + && NetworkManager.Instance.GameNetwork != null + && NetworkManager.Instance.GameNetwork.ConnectionState == ConnectionState.Connected; + } + + #endregion Private Methods +} \ No newline at end of file diff --git a/Stickers/Stickers/Networking/ModNetwork.Inbound.cs b/Stickers/Stickers/Networking/ModNetwork.Inbound.cs new file mode 100644 index 0000000..fbada5b --- /dev/null +++ b/Stickers/Stickers/Networking/ModNetwork.Inbound.cs @@ -0,0 +1,222 @@ +using ABI_RC.Core.Savior; +using ABI_RC.Systems.ModNetwork; +using NAK.Stickers.Utilities; +using UnityEngine; + +namespace NAK.Stickers.Networking; + +public static partial class ModNetwork +{ + #region Inbound Buffers + + private static readonly Dictionary _textureChunkBuffers = new(); + private static readonly Dictionary _receivedChunkCounts = new(); + private static readonly Dictionary _expectedChunkCounts = new(); + private static readonly Dictionary _textureMetadata = new(); + + #endregion Inbound Buffers + + #region Inbound Methods + + private static void HandleMessageReceived(ModNetworkMessage msg) + { + string sender = msg.Sender; + msg.Read(out byte msgTypeRaw); + + if (!Enum.IsDefined(typeof(MessageType), msgTypeRaw)) + return; + + if (_disallowedForSession.Contains(sender)) + return; // ignore messages from disallowed users + + if (MetaPort.Instance.blockedUserIds.Contains(sender)) + return; // ignore messages from blocked users + + LoggerInbound($"Received message from {msg.Sender}, Type: {(MessageType)msgTypeRaw}"); + + switch ((MessageType)msgTypeRaw) + { + case MessageType.PlaceSticker: + HandlePlaceSticker(msg); + break; + + case MessageType.ClearSticker: + HandleClearSticker(msg); + break; + + case MessageType.ClearAllStickers: + HandleClearAllStickers(msg); + break; + + case MessageType.StartTexture: + HandleStartTexture(msg); + break; + + case MessageType.SendTexture: + HandleSendTexture(msg); + break; + + case MessageType.EndTexture: + HandleEndTexture(msg); + break; + + case MessageType.RequestTexture: + HandleRequestTexture(msg); + break; + + default: + LoggerInbound($"Invalid message type received: {msgTypeRaw}"); + break; + } + } + + private static void HandlePlaceSticker(ModNetworkMessage msg) + { + msg.Read(out int stickerSlot); + msg.Read(out Guid textureHash); + msg.Read(out Vector3 position); + msg.Read(out Vector3 forward); + msg.Read(out Vector3 up); + + if (!StickerSystem.Instance.HasTextureHash(msg.Sender, textureHash)) + SendRequestTexture(stickerSlot, textureHash); + + StickerSystem.Instance.OnStickerPlaceReceived(msg.Sender, stickerSlot, position, forward, up); + } + + private static void HandleClearSticker(ModNetworkMessage msg) + { + msg.Read(out int stickerSlot); + StickerSystem.Instance.OnStickerClearReceived(msg.Sender, stickerSlot); + } + + private static void HandleClearAllStickers(ModNetworkMessage msg) + { + StickerSystem.Instance.OnStickerClearAllReceived(msg.Sender); + } + + private static void HandleStartTexture(ModNetworkMessage msg) + { + string sender = msg.Sender; + msg.Read(out int stickerSlot); + msg.Read(out Guid textureHash); + msg.Read(out int chunkCount); + msg.Read(out int width); + msg.Read(out int height); + + if (_textureChunkBuffers.ContainsKey(sender)) + { + LoggerInbound($"Received StartTexture message from {sender} while still receiving texture data!"); + return; + } + + if (StickerSystem.Instance.HasTextureHash(sender, textureHash)) + { + LoggerInbound($"Received StartTexture message from {sender} with existing texture hash {textureHash}, skipping texture data."); + return; + } + + if (chunkCount > MaxChunkCount) + { + LoggerInbound($"Received StartTexture message from {sender} with too many chunks: {chunkCount}", true); + return; + } + + _textureChunkBuffers[sender] = new byte[Mathf.Clamp(chunkCount * ChunkSize, 0, MaxTextureSize)]; + _receivedChunkCounts[sender] = 0; + _expectedChunkCounts[sender] = chunkCount; + _textureMetadata[sender] = (stickerSlot, textureHash, width, height); + + LoggerInbound($"Received StartTexture message from {sender}: Slot: {stickerSlot}, Hash: {textureHash}, Chunks: {chunkCount}, Resolution: {width}x{height}"); + } + + private static void HandleSendTexture(ModNetworkMessage msg) + { + string sender = msg.Sender; + msg.Read(out int chunkIdx); + msg.Read(out byte[] chunkData); + + if (!_textureChunkBuffers.TryGetValue(sender, out var buffer)) + return; + + int startIndex = chunkIdx * ChunkSize; + Array.Copy(chunkData, 0, buffer, startIndex, chunkData.Length); + + _receivedChunkCounts[sender]++; + if (_receivedChunkCounts[sender] < _expectedChunkCounts[sender]) + return; + + (int stickerSlot, Guid Hash, int Width, int Height) metadata = _textureMetadata[sender]; + + // All chunks received, reassemble texture + _textureChunkBuffers.Remove(sender); + _receivedChunkCounts.Remove(sender); + _expectedChunkCounts.Remove(sender); + _textureMetadata.Remove(sender); + + // Validate image + if (!ImageUtility.IsValidImage(buffer)) + { + LoggerInbound($"[Inbound] Received texture data is not a valid image from {sender}!", true); + return; + } + + // Validate data TODO: fix hash??????? + (Guid imageHash, int width, int height) = ImageUtility.ExtractImageInfo(buffer); + if (metadata.Width != width + || metadata.Height != height) + { + LoggerInbound($"Received texture data does not match metadata! Expected: {metadata.Hash} ({metadata.Width}x{metadata.Height}), received: {imageHash} ({width}x{height})", true); + return; + } + + Texture2D texture = new(1,1); + texture.LoadImage(buffer); + texture.Compress(true); + + StickerSystem.Instance.OnPlayerStickerTextureReceived(sender, metadata.Hash, texture, metadata.stickerSlot); + + LoggerInbound($"All chunks received and texture reassembled from {sender}. " + + $"Texture size: {metadata.Width}x{metadata.Height}"); + } + + private static void HandleEndTexture(ModNetworkMessage msg) + { + string sender = msg.Sender; + if (!_textureChunkBuffers.ContainsKey(sender)) + return; + + LoggerInbound($"Received EndTexture message without all chunks received from {sender}! Only {_receivedChunkCounts[sender]} out of {_expectedChunkCounts[sender]} received."); + + _textureChunkBuffers.Remove(sender); + _receivedChunkCounts.Remove(sender); + _expectedChunkCounts.Remove(sender); + _textureMetadata.Remove(sender); + } + + private static void HandleRequestTexture(ModNetworkMessage msg) + { + string sender = msg.Sender; + msg.Read(out int stickerSlot); + msg.Read(out Guid textureHash); + + if (!_isSubscribedToModNetwork || IsSendingTexture) + return; + + if (stickerSlot < 0 || stickerSlot >= _textureStorage.Length) + { + LoggerInbound($"Received RequestTexture message from {sender} with invalid slot {stickerSlot}!"); + return; + } + + if (_textureStorage[stickerSlot].textureHash != textureHash) + { + LoggerInbound($"Received RequestTexture message from {sender} with invalid texture hash {textureHash} for slot {stickerSlot}!"); + return; + } + + SendTexture(stickerSlot); + } + + #endregion Inbound Methods +} \ No newline at end of file diff --git a/Stickers/Stickers/Networking/ModNetwork.Logging.cs b/Stickers/Stickers/Networking/ModNetwork.Logging.cs new file mode 100644 index 0000000..60a7a6f --- /dev/null +++ b/Stickers/Stickers/Networking/ModNetwork.Logging.cs @@ -0,0 +1,22 @@ +namespace NAK.Stickers.Networking; + +public static partial class ModNetwork +{ + #region Network Logging + + private static void LoggerInbound(string message, bool isWarning = false) + { + if (!ModSettings.Debug_NetworkInbound.Value) return; + if (isWarning) StickerMod.Logger.Warning("[Inbound] " + message); + else StickerMod.Logger.Msg("[Inbound] " + message); + } + + private static void LoggerOutbound(string message, bool isWarning = false) + { + if (!ModSettings.Debug_NetworkOutbound.Value) return; + if (isWarning) StickerMod.Logger.Warning("[Outbound] " + message); + else StickerMod.Logger.Msg("[Outbound] " + message); + } + + #endregion Network Logging +} \ No newline at end of file diff --git a/Stickers/Stickers/Networking/ModNetwork.Main.cs b/Stickers/Stickers/Networking/ModNetwork.Main.cs new file mode 100644 index 0000000..64f21b9 --- /dev/null +++ b/Stickers/Stickers/Networking/ModNetwork.Main.cs @@ -0,0 +1,48 @@ +using ABI_RC.Systems.ModNetwork; + +namespace NAK.Stickers.Networking; + +public static partial class ModNetwork +{ + #region Mod Network Internals + + public static bool IsSendingTexture { get; private set; } + private static bool _isSubscribedToModNetwork; + + internal static void Subscribe() + { + ModNetworkManager.Subscribe(ModId, HandleMessageReceived); + + _isSubscribedToModNetwork = ModNetworkManager.IsSubscribed(ModId); + if (!_isSubscribedToModNetwork) StickerMod.Logger.Error("Failed to subscribe to Mod Network! This should not happen."); + } + + #endregion Mod Network Internals + + #region Disallow For Session + + private static readonly HashSet _disallowedForSession = new(); + + public static bool IsPlayerACriminal(string playerID) + { + return _disallowedForSession.Contains(playerID); + } + + public static void HandleDisallowForSession(string playerID, bool isOn) + { + if (string.IsNullOrEmpty(playerID)) return; + + if (isOn) + { + _disallowedForSession.Add(playerID); + StickerMod.Logger.Msg($"Player {playerID} has been disallowed from using stickers for this session."); + } + else + { + _disallowedForSession.Remove(playerID); + StickerMod.Logger.Msg($"Player {playerID} has been allowed to use stickers again."); + } + } + + #endregion Disallow For Session +} \ No newline at end of file diff --git a/Stickers/Stickers/Networking/ModNetwork.Outbound.cs b/Stickers/Stickers/Networking/ModNetwork.Outbound.cs new file mode 100644 index 0000000..fd16aa5 --- /dev/null +++ b/Stickers/Stickers/Networking/ModNetwork.Outbound.cs @@ -0,0 +1,189 @@ +using ABI_RC.Systems.ModNetwork; +using NAK.Stickers.Utilities; +using UnityEngine; + +namespace NAK.Stickers.Networking; + +public static partial class ModNetwork +{ + #region Texture Data + + private static readonly (byte[] textureData, Guid textureHash, int width, int height)[] _textureStorage = new (byte[], Guid, int, int)[ModSettings.MaxStickerSlots]; + + public static bool SetTexture(int stickerSlot, byte[] imageBytes) + { + if (imageBytes == null + || imageBytes.Length == 0 + || imageBytes.Length > MaxTextureSize) + return false; + + (Guid hashGuid, int width, int height) = ImageUtility.ExtractImageInfo(imageBytes); + if (_textureStorage[stickerSlot].textureHash == hashGuid) + { + LoggerOutbound($"Texture data is the same as the current texture for slot {stickerSlot}: {hashGuid}"); + return false; + } + + _textureStorage[stickerSlot] = (imageBytes, hashGuid, width, height); + LoggerOutbound($"Set texture data for slot {stickerSlot}, metadata: {hashGuid} ({width}x{height})"); + + SendTexture(stickerSlot); + + return true; + } + + #endregion Texture Data + + #region Outbound Methods + + public static void SendPlaceSticker(int stickerSlot, Vector3 position, Vector3 forward, Vector3 up) + { + if (!IsConnectedToGameNetwork()) + return; + + using ModNetworkMessage modMsg = new(ModId); + modMsg.Write((byte)MessageType.PlaceSticker); + modMsg.Write(stickerSlot); + modMsg.Write(_textureStorage[stickerSlot].textureHash); + modMsg.Write(position); + modMsg.Write(forward); + modMsg.Write(up); + modMsg.Send(); + + LoggerOutbound($"PlaceSticker: Slot: {stickerSlot}, Hash: {_textureStorage[stickerSlot].textureHash}, Position: {position}, Forward: {forward}, Up: {up}"); + } + + public static void SendClearSticker(int stickerSlot) + { + if (!IsConnectedToGameNetwork()) + return; + + using ModNetworkMessage modMsg = new(ModId); + modMsg.Write((byte)MessageType.ClearSticker); + modMsg.Write(stickerSlot); + modMsg.Send(); + + LoggerOutbound($"ClearSticker: Slot: {stickerSlot}"); + } + + public static void SendClearAllStickers() + { + if (!IsConnectedToGameNetwork()) + return; + + using ModNetworkMessage modMsg = new(ModId); + modMsg.Write((byte)MessageType.ClearAllStickers); + modMsg.Send(); + + LoggerOutbound("ClearAllStickers"); + } + + public static void SendStartTexture(int stickerSlot, Guid textureHash, int chunkCount, int width, int height) + { + if (!IsConnectedToGameNetwork()) + return; + + using ModNetworkMessage modMsg = new(ModId); + modMsg.Write((byte)MessageType.StartTexture); + modMsg.Write(stickerSlot); + modMsg.Write(textureHash); + modMsg.Write(chunkCount); + modMsg.Write(width); + modMsg.Write(height); + modMsg.Send(); + + LoggerOutbound($"StartTexture: Slot: {stickerSlot}, Hash: {textureHash}, Chunks: {chunkCount}, Size: {width}x{height}"); + } + + public static void SendTextureChunk(int chunkIdx, byte[] chunkData) + { + if (!IsConnectedToGameNetwork()) + return; + + using ModNetworkMessage modMsg = new(ModId); + modMsg.Write((byte)MessageType.SendTexture); + modMsg.Write(chunkIdx); + modMsg.Write(chunkData); + modMsg.Send(); + + LoggerOutbound($"SendTextureChunk: Index: {chunkIdx}, Size: {chunkData.Length} bytes"); + } + + public static void SendEndTexture() + { + if (!IsConnectedToGameNetwork()) + return; + + using ModNetworkMessage modMsg = new(ModId); + modMsg.Write((byte)MessageType.EndTexture); + modMsg.Send(); + + LoggerOutbound("EndTexture"); + } + + public static void SendRequestTexture(int stickerSlot, Guid textureHash) + { + if (!IsConnectedToGameNetwork()) + return; + + using ModNetworkMessage modMsg = new(ModId); + modMsg.Write((byte)MessageType.RequestTexture); + modMsg.Write(stickerSlot); + modMsg.Write(textureHash); + modMsg.Send(); + + LoggerOutbound($"RequestTexture: Slot: {stickerSlot}, Hash: {textureHash}"); + } + + public static void SendTexture(int stickerSlot) + { + if (!IsConnectedToGameNetwork() || IsSendingTexture) + return; + + IsSendingTexture = true; + + Task.Run(() => + { + try + { + var textureData = _textureStorage[stickerSlot].textureData; + var textureHash = _textureStorage[stickerSlot].textureHash; + var width = _textureStorage[stickerSlot].width; + var height = _textureStorage[stickerSlot].height; + int totalChunks = Mathf.CeilToInt(textureData.Length / (float)ChunkSize); + if (totalChunks > MaxChunkCount) + { + LoggerOutbound($"Texture data too large to send for slot {stickerSlot}: {textureData.Length} bytes, {totalChunks} chunks", true); + return; + } + + LoggerOutbound($"Sending texture for slot {stickerSlot}: {textureData.Length} bytes, Chunks: {totalChunks}, Resolution: {width}x{height}"); + + SendStartTexture(stickerSlot, textureHash, totalChunks, width, height); + + for (int i = 0; i < textureData.Length; i += ChunkSize) + { + int size = Mathf.Min(ChunkSize, textureData.Length - i); + byte[] chunk = new byte[size]; + Array.Copy(textureData, i, chunk, 0, size); + + SendTextureChunk(i / ChunkSize, chunk); + Thread.Sleep(5); // Simulate network latency + } + + SendEndTexture(); + } + catch (Exception e) + { + LoggerOutbound($"Failed to send texture for slot {stickerSlot}: {e}", true); + } + finally + { + IsSendingTexture = false; + InvokeTextureOutboundStateChanged(false); + } + }); + } + + #endregion Outbound Methods +} \ No newline at end of file diff --git a/Stickers/Stickers/Networking/ModNetwork.cs b/Stickers/Stickers/Networking/ModNetwork.cs deleted file mode 100644 index 46d9c2a..0000000 --- a/Stickers/Stickers/Networking/ModNetwork.cs +++ /dev/null @@ -1,414 +0,0 @@ -using ABI_RC.Core.Networking; -using ABI_RC.Systems.ModNetwork; -using DarkRift; -using NAK.Stickers.Properties; -using NAK.Stickers.Utilities; -using UnityEngine; - -namespace NAK.Stickers.Networking -{ - public static class ModNetwork - { - #region Configuration - - private static bool Debug_NetworkInbound => ModSettings.Debug_NetworkInbound.Value; - private static bool Debug_NetworkOutbound => ModSettings.Debug_NetworkOutbound.Value; - - #endregion Configuration - - #region Constants - - internal const int MaxTextureSize = 1024 * 256; // 256KB - - private const string ModId = $"MelonMod.NAK.Stickers_v{AssemblyInfoParams.Version}"; - private const int ChunkSize = 1024; // roughly 1KB per ModNetworkMessage - private const int MaxChunkCount = MaxTextureSize / ChunkSize; - - #endregion Constants - - #region Enums - - private enum MessageType : byte - { - PlaceSticker = 0, - ClearStickers = 1, - StartTexture = 2, - SendTexture = 3, - EndTexture = 4, - RequestTexture = 5 - } - - #endregion Enums - - #region Mod Network Internals - - internal static Action OnTextureOutboundStateChanged; - - internal static bool IsSendingTexture { get; private set; } - private static bool _isSubscribedToModNetwork; - - private static byte[] _ourTextureData; - private static (Guid hashGuid, int Width, int Height) _ourTextureMetadata; - - private static readonly Dictionary _textureChunkBuffers = new(); - private static readonly Dictionary _receivedChunkCounts = new(); - private static readonly Dictionary _expectedChunkCounts = new(); - private static readonly Dictionary _textureMetadata = new(); - - internal static void Subscribe() - { - ModNetworkManager.Subscribe(ModId, OnMessageReceived); - - _isSubscribedToModNetwork = ModNetworkManager.IsSubscribed(ModId); - if (!_isSubscribedToModNetwork) - StickerMod.Logger.Error("Failed to subscribe to Mod Network!"); - } - - public static void PlaceSticker(Vector3 position, Vector3 forward, Vector3 up) - { - if (!_isSubscribedToModNetwork) - return; - - SendStickerPlace(_ourTextureMetadata.hashGuid, position, forward, up); - } - - public static void ClearStickers() - { - if (!_isSubscribedToModNetwork) - return; - - SendMessage(MessageType.ClearStickers); - } - - public static bool SetTexture(byte[] imageBytes) - { - if (imageBytes == null - || imageBytes.Length == 0 - || imageBytes.Length > MaxTextureSize) - return false; - - (Guid hashGuid, int width, int height) = ImageUtility.ExtractImageInfo(imageBytes); - if (_ourTextureMetadata.hashGuid == hashGuid) - { - StickerMod.Logger.Msg($"[ModNetwork] Texture data is the same as the current texture: {hashGuid}"); - return false; - } - - _ourTextureData = imageBytes; - _ourTextureMetadata = (hashGuid, width, height); - StickerMod.Logger.Msg($"[ModNetwork] Set texture metadata for networking: {hashGuid} ({width}x{height})"); - return true; - } - - public static void SendTexture() - { - if (!IsConnectedToGameNetwork()) - return; - - if (!_isSubscribedToModNetwork || IsSendingTexture) - return; - - if (_ourTextureData == null - || _ourTextureMetadata.hashGuid == Guid.Empty) - return; // no texture to send - - IsSendingTexture = true; - - // Send each chunk of the texture data - Task.Run(() => - { - try - { - if (Debug_NetworkOutbound) - StickerMod.Logger.Msg("[ModNetwork] Sending texture to network"); - - var textureData = _ourTextureData; - (Guid hash, int Width, int Height) textureMetadata = _ourTextureMetadata; - int totalChunks = Mathf.CeilToInt(textureData.Length / (float)ChunkSize); - if (totalChunks > MaxChunkCount) - { - StickerMod.Logger.Error($"[ModNetwork] Texture data too large to send: {textureData.Length} bytes, {totalChunks} chunks"); - return; - } - - if (Debug_NetworkOutbound) - StickerMod.Logger.Msg($"[ModNetwork] Texture data length: {textureData.Length}, total chunks: {totalChunks}, width: {textureMetadata.Width}, height: {textureMetadata.Height}"); - - SendStartTexture(textureMetadata.hash, totalChunks, textureMetadata.Width, textureMetadata.Height); - - for (int i = 0; i < textureData.Length; i += ChunkSize) - { - int size = Mathf.Min(ChunkSize, textureData.Length - i); - byte[] chunk = new byte[size]; - Array.Copy(textureData, i, chunk, 0, size); - - SendTextureChunk(chunk, i / ChunkSize); - Thread.Sleep(5); - } - } - catch (Exception e) - { - if (Debug_NetworkOutbound) StickerMod.Logger.Error($"[ModNetwork] Failed to send texture to network: {e}"); - } - finally - { - IsSendingTexture = false; - SendMessage(MessageType.EndTexture); - } - }); - } - - private static void SendStickerPlace(Guid textureHash, Vector3 position, Vector3 forward, Vector3 up) - { - if (!IsConnectedToGameNetwork()) - return; - - using ModNetworkMessage modMsg = new(ModId); - modMsg.Write((byte)MessageType.PlaceSticker); - modMsg.Write(textureHash); - modMsg.Write(position); - modMsg.Write(forward); - modMsg.Write(up); - modMsg.Send(); - - if (Debug_NetworkOutbound) StickerMod.Logger.Msg($"[Outbound] PlaceSticker: Hash: {textureHash}, Position: {position}, Forward: {forward}, Up: {up}"); - } - - private static void SendStartTexture(Guid hash, int chunkCount, int width, int height) - { - OnTextureOutboundStateChanged?.Invoke(true); - - if (!IsConnectedToGameNetwork()) - return; - - using ModNetworkMessage modMsg = new(ModId); - modMsg.Write((byte)MessageType.StartTexture); - modMsg.Write(hash); - modMsg.Write(chunkCount); - modMsg.Write(width); - modMsg.Write(height); - modMsg.Send(); - - if (Debug_NetworkOutbound) StickerMod.Logger.Msg($"[Outbound] StartTexture sent with {chunkCount} chunks, width: {width}, height: {height}"); - } - - private static void SendTextureChunk(byte[] chunk, int chunkIdx) - { - if (!IsConnectedToGameNetwork()) - return; - - using ModNetworkMessage modMsg = new(ModId); - modMsg.Write((byte)MessageType.SendTexture); - modMsg.Write(chunkIdx); - modMsg.Write(chunk); - modMsg.Send(); - - if (Debug_NetworkOutbound) StickerMod.Logger.Msg($"[Outbound] Sent texture chunk {chunkIdx + 1}"); - } - - private static void SendMessage(MessageType messageType) - { - OnTextureOutboundStateChanged?.Invoke(false); - - if (!IsConnectedToGameNetwork()) - return; - - using ModNetworkMessage modMsg = new(ModId); - modMsg.Write((byte)messageType); - modMsg.Send(); - - if (Debug_NetworkOutbound) StickerMod.Logger.Msg($"[Outbound] MessageType: {messageType}"); - } - - private static void SendTextureRequest(string sender) - { - if (!IsConnectedToGameNetwork()) - return; - - using ModNetworkMessage modMsg = new(ModId); - modMsg.Write((byte)MessageType.RequestTexture); - modMsg.Write(sender); - modMsg.Send(); - - if (Debug_NetworkOutbound) StickerMod.Logger.Msg($"[Outbound] RequestTexture sent to {sender}"); - } - - private static void OnMessageReceived(ModNetworkMessage msg) - { - msg.Read(out byte msgTypeRaw); - - if (!Enum.IsDefined(typeof(MessageType), msgTypeRaw)) - return; - - if (Debug_NetworkInbound) - StickerMod.Logger.Msg($"[Inbound] Sender: {msg.Sender}, MessageType: {(MessageType)msgTypeRaw}"); - - switch ((MessageType)msgTypeRaw) - { - case MessageType.PlaceSticker: - msg.Read(out Guid hash); - msg.Read(out Vector3 receivedPosition); - msg.Read(out Vector3 receivedForward); - msg.Read(out Vector3 receivedUp); - OnStickerPlaceReceived(msg.Sender, hash, receivedPosition, receivedForward, receivedUp); - break; - - case MessageType.ClearStickers: - OnStickersClearReceived(msg.Sender); - break; - - case MessageType.StartTexture: - msg.Read(out Guid startHash); - msg.Read(out int chunkCount); - msg.Read(out int width); - msg.Read(out int height); - OnStartTextureReceived(msg.Sender, startHash, chunkCount, width, height); - break; - - case MessageType.SendTexture: - msg.Read(out int chunkIdx); - msg.Read(out byte[] textureChunk); - OnTextureChunkReceived(msg.Sender, textureChunk, chunkIdx); - break; - - case MessageType.EndTexture: - OnEndTextureReceived(msg.Sender); - break; - - case MessageType.RequestTexture: - OnTextureRequestReceived(msg.Sender); - break; - - default: - if (Debug_NetworkInbound) StickerMod.Logger.Error($"[ModNetwork] Invalid message type received from: {msg.Sender}"); - break; - } - } - - #endregion Mod Network Internals - - #region Private Methods - - private static bool IsConnectedToGameNetwork() - { - return NetworkManager.Instance != null - && NetworkManager.Instance.GameNetwork != null - && NetworkManager.Instance.GameNetwork.ConnectionState == ConnectionState.Connected; - } - - private static void OnStickerPlaceReceived(string sender, Guid textureHash, Vector3 position, Vector3 forward, Vector3 up) - { - Guid localHash = StickerSystem.Instance.GetPlayerStickerTextureHash(sender); - if (localHash != textureHash && textureHash != Guid.Empty) SendTextureRequest(sender); - StickerSystem.Instance.OnPlayerStickerPlace(sender, position, forward, up); - } - - private static void OnStickersClearReceived(string sender) - { - StickerSystem.Instance.OnPlayerStickersClear(sender); - } - - private static void OnStartTextureReceived(string sender, Guid hash, int chunkCount, int width, int height) - { - if (_textureChunkBuffers.ContainsKey(sender)) - { - if (Debug_NetworkInbound) StickerMod.Logger.Warning($"[Inbound] Received StartTexture message from {sender} while still receiving texture data!"); - return; - } - - Guid oldHash = StickerSystem.Instance.GetPlayerStickerTextureHash(sender); - if (oldHash == hash) - { - if (Debug_NetworkInbound) - StickerMod.Logger.Msg($"[Inbound] Received StartTexture message from {sender} with existing texture hash {hash}, skipping texture data."); - return; - } - - if (chunkCount > MaxChunkCount) - { - StickerMod.Logger.Error($"[Inbound] Received StartTexture message from {sender} with too many chunks: {chunkCount}"); - return; - } - - _textureChunkBuffers[sender] = new byte[Mathf.Clamp(chunkCount * ChunkSize, 0, MaxTextureSize)]; - _receivedChunkCounts[sender] = 0; - _expectedChunkCounts[sender] = chunkCount; - _textureMetadata[sender] = (hash, width, height); - - if (Debug_NetworkInbound) - StickerMod.Logger.Msg($"[Inbound] StartTexture received from {sender} with hash: {hash} chunk count: {chunkCount}, width: {width}, height: {height}"); - } - - private static void OnTextureChunkReceived(string sender, byte[] chunk, int chunkIdx) - { - if (!_textureChunkBuffers.TryGetValue(sender, out var buffer)) - return; - - int startIndex = chunkIdx * ChunkSize; - Array.Copy(chunk, 0, buffer, startIndex, chunk.Length); - - _receivedChunkCounts[sender]++; - if (_receivedChunkCounts[sender] < _expectedChunkCounts[sender]) - return; - - (Guid Hash, int Width, int Height) metadata = _textureMetadata[sender]; - - // All chunks received, reassemble texture - _textureChunkBuffers.Remove(sender); - _receivedChunkCounts.Remove(sender); - _expectedChunkCounts.Remove(sender); - _textureMetadata.Remove(sender); - - // Validate image - if (!ImageUtility.IsValidImage(buffer)) - { - StickerMod.Logger.Error($"[Inbound] Received texture data is not a valid image from {sender}!"); - return; - } - - // Validate data TODO: fix hash??????? - (Guid imageHash, int width, int height) = ImageUtility.ExtractImageInfo(buffer); - if (metadata.Width != width - || metadata.Height != height) - { - StickerMod.Logger.Error($"[Inbound] Received texture data does not match metadata! Expected: {metadata.Hash} ({metadata.Width}x{metadata.Height}), received: {imageHash} ({width}x{height})"); - return; - } - - Texture2D texture = new(1,1); - texture.LoadImage(buffer); - texture.Compress(true); - - StickerSystem.Instance.OnPlayerStickerTextureReceived(sender, metadata.Hash, texture); - - if (Debug_NetworkInbound) StickerMod.Logger.Msg($"[Inbound] All chunks received and texture reassembled from {sender}. " + - $"Texture size: {metadata.Width}x{metadata.Height}"); - } - - private static void OnEndTextureReceived(string sender) - { - if (!_textureChunkBuffers.ContainsKey(sender)) - return; - - if (Debug_NetworkInbound) StickerMod.Logger.Error($"[Inbound] Received EndTexture message without all chunks received from {sender}! Only {_receivedChunkCounts[sender]} out of {_expectedChunkCounts[sender]} received."); - - _textureChunkBuffers.Remove(sender); - _receivedChunkCounts.Remove(sender); - _expectedChunkCounts.Remove(sender); - _textureMetadata.Remove(sender); - } - - private static void OnTextureRequestReceived(string sender) - { - if (!_isSubscribedToModNetwork || IsSendingTexture) - return; - - if (_ourTextureData != null && _ourTextureMetadata.hashGuid != Guid.Empty) - SendTexture(); - else - StickerMod.Logger.Warning($"[Inbound] Received texture request from {sender}, but no texture is set!"); - } - - #endregion Private Methods - } -} \ No newline at end of file diff --git a/Stickers/Stickers/StickerData.cs b/Stickers/Stickers/StickerData.cs index d1a0448..670c7d8 100644 --- a/Stickers/Stickers/StickerData.cs +++ b/Stickers/Stickers/StickerData.cs @@ -2,131 +2,213 @@ using UnityEngine; using Object = UnityEngine.Object; -namespace NAK.Stickers; - -public class StickerData +namespace NAK.Stickers { - private const float DECAL_SIZE = 0.25f; - - public float DeathTime; // when a remote player leaves, we need to kill their stickers - - public Guid TextureHash; - public float LastPlacedTime; - - private Vector3 _lastPlacedPosition = Vector3.zero; - - public readonly bool IsLocal; - private readonly DecalType _decal; - private readonly DecalSpawner _spawner; - private readonly AudioSource _audioSource; - - public StickerData(bool isLocal) + public class StickerData { - IsLocal = isLocal; + private const float DECAL_SIZE = 0.25f; + + private static readonly int s_EmissionStrengthID = Shader.PropertyToID("_EmissionStrength"); + + public float DeathTime; // when a remote player leaves, we need to kill their stickers + public float LastPlacedTime; + private Vector3 _lastPlacedPosition = Vector3.zero; + + public readonly bool IsLocal; + private readonly DecalType _decal; + private readonly DecalSpawner[] _decalSpawners; - _decal = ScriptableObject.CreateInstance(); - _decal.decalSettings = new DecalSpawner.InitData + private readonly Guid[] _textureHashes; + private readonly Material[] _materials; + private readonly AudioSource _audioSource; + + public StickerData(bool isLocal, int decalSpawnersCount) { - material = new Material(StickerMod.DecalSimpleShader) + IsLocal = isLocal; + + _decal = ScriptableObject.CreateInstance(); + _decalSpawners = new DecalSpawner[decalSpawnersCount]; + _materials = new Material[decalSpawnersCount]; + _textureHashes = new Guid[decalSpawnersCount]; + + for (int i = 0; i < decalSpawnersCount; i++) { - //color = new Color(Random.value, Random.value, Random.value, 1f), - }, - useShaderReplacement = false, - inheritMaterialProperties = false, - inheritMaterialPropertyBlock = false, - }; - - _spawner = DecalManager.GetSpawner(_decal.decalSettings, 4096, 1024); - - _audioSource = new GameObject("StickerAudioSource").AddComponent(); - _audioSource.spatialBlend = 1f; - _audioSource.volume = 0.5f; - _audioSource.playOnAwake = false; - _audioSource.loop = false; - _audioSource.rolloffMode = AudioRolloffMode.Logarithmic; - _audioSource.maxDistance = 5f; - _audioSource.minDistance = 1f; - _audioSource.outputAudioMixerGroup = RootLogic.Instance.propSfx; // props are close enough to stickers - - if (isLocal) Object.DontDestroyOnLoad(_audioSource.gameObject); // keep audio source through world transitions - } - - public void SetTexture(Guid textureHash, Texture2D texture) - { - if (texture == null) StickerMod.Logger.Warning("Assigning null texture to StickerData!"); - - TextureHash = textureHash; - - texture.wrapMode = TextureWrapMode.Clamp; // noachi said to do, prevents white edges - texture.filterMode = texture.width > 64 || texture.height > 64 - ? FilterMode.Bilinear // smear it cause its fat - : FilterMode.Point; // my minecraft skin looked shit + _materials[i] = new Material(StickerMod.DecalSimpleShader); + _decal.decalSettings = new DecalSpawner.InitData + { + material = _materials[i], + useShaderReplacement = false, + inheritMaterialProperties = false, + inheritMaterialPropertyBlock = false, + }; + _decalSpawners[i] = DecalManager.GetSpawner(_decal.decalSettings, 4096, 1024); + } - if (IsLocal) StickerMod.Logger.Msg($"Set texture filter mode to: {texture.filterMode}"); - - Material material = _decal.decalSettings.material; - - // TODO: fix - if (material.mainTexture != null) Object.Destroy(material.mainTexture); - material.mainTexture = texture; - } - - public void Place(RaycastHit hit, Vector3 forwardDirection, Vector3 upDirection) - { - Transform rootObject = null; - if (hit.rigidbody != null) rootObject = hit.rigidbody.transform; - - _lastPlacedPosition = hit.point; - LastPlacedTime = Time.time; - - // todo: add decal to queue - _spawner.AddDecal( - _lastPlacedPosition, Quaternion.LookRotation(forwardDirection, upDirection), - hit.collider.gameObject, - DECAL_SIZE, DECAL_SIZE, 1f, 1f, 0f, rootObject); - } - - public void Clear() - { - // BUG?: Release does not clear dictionary's, clearing them myself so we can hit same objects again - // maybe it was intended to recycle groups- but rn no work like that - _spawner.Release(); - _spawner.staticGroups.Clear(); - _spawner.movableGroups.Clear(); - } - - public void Cleanup() - { - Clear(); - - // no leaking textures or mats - Material material = _decal.decalSettings.material; - Object.Destroy(material.mainTexture); - Object.Destroy(material); - Object.Destroy(_decal); - } - - public void PlayAudio() - { - _audioSource.transform.position = _lastPlacedPosition; - switch (ModSettings.Entry_SelectedSFX.Value) - { - case ModSettings.SFXType.Source: - _audioSource.PlayOneShot(StickerMod.SourceSFXPlayerSprayer); - break; - case ModSettings.SFXType.LBP: - _audioSource.PlayOneShot(StickerMod.LittleBigPlanetStickerPlace); - break; - case ModSettings.SFXType.None: - break; - default: - throw new ArgumentOutOfRangeException(); + _audioSource = new GameObject("StickerAudioSource").AddComponent(); + _audioSource.spatialBlend = 1f; + _audioSource.volume = 0.5f; + _audioSource.playOnAwake = false; + _audioSource.loop = false; + _audioSource.rolloffMode = AudioRolloffMode.Logarithmic; + _audioSource.maxDistance = 5f; + _audioSource.minDistance = 1f; + _audioSource.outputAudioMixerGroup = RootLogic.Instance.propSfx; // props are close enough to stickers + if (isLocal) Object.DontDestroyOnLoad(_audioSource.gameObject); // keep audio source through world transitions } - } - - public void SetAlpha(float alpha) - { - Material material = _decal.decalSettings.material; - material.color = new Color(material.color.r, material.color.g, material.color.b, alpha); + + public Guid GetTextureHash(int spawnerIndex = 0) + { + if (spawnerIndex < 0 || spawnerIndex >= _decalSpawners.Length) + { + StickerMod.Logger.Warning("Invalid spawner index!"); + return Guid.Empty; + } + + return _textureHashes[spawnerIndex]; + } + + public bool CheckHasTextureHash(Guid textureHash) + { + foreach (Guid hash in _textureHashes) if (hash == textureHash) return true; + return false; + } + + public void SetTexture(Guid textureHash, Texture2D texture, int spawnerIndex = 0) + { + if (spawnerIndex < 0 || spawnerIndex >= _decalSpawners.Length) + { + StickerMod.Logger.Warning("Invalid spawner index!"); + return; + } + + if (texture == null) + { + StickerMod.Logger.Warning("Assigning null texture to StickerData!"); + return; + } + + _textureHashes[spawnerIndex] = textureHash; + + texture.wrapMode = TextureWrapMode.Clamp; // prevents white edges + texture.filterMode = texture.width > 64 || texture.height > 64 + ? FilterMode.Bilinear // smear it cause its fat + : FilterMode.Point; // my minecraft skin looked shit + + if (IsLocal) StickerMod.Logger.Msg($"Set texture filter mode to: {texture.filterMode}"); + + Material material = _materials[spawnerIndex]; + + // Destroy the previous texture to avoid memory leaks + if (material.mainTexture != null) Object.Destroy(material.mainTexture); + material.mainTexture = texture; + } + + public void Place(RaycastHit hit, Vector3 forwardDirection, Vector3 upDirection, int spawnerIndex = 0) + { + if (spawnerIndex < 0 || spawnerIndex >= _decalSpawners.Length) + { + StickerMod.Logger.Warning("Invalid spawner index!"); + return; + } + + Transform rootObject = null; + if (hit.rigidbody != null) rootObject = hit.rigidbody.transform; + + _lastPlacedPosition = hit.point; + LastPlacedTime = Time.time; + + // Add decal to the specified spawner + _decalSpawners[spawnerIndex].AddDecal( + _lastPlacedPosition, Quaternion.LookRotation(forwardDirection, upDirection), + hit.collider.gameObject, + DECAL_SIZE, DECAL_SIZE, 1f, 1f, 0f, rootObject); + } + + public void Clear() + { + foreach (DecalSpawner spawner in _decalSpawners) + { + spawner.Release(); + spawner.staticGroups.Clear(); + spawner.movableGroups.Clear(); + } + } + + public void Clear(int spawnerIndex) + { + if (spawnerIndex < 0 || spawnerIndex >= _decalSpawners.Length) + { + StickerMod.Logger.Warning("Invalid spawner index!"); + return; + } + + _decalSpawners[spawnerIndex].Release(); + _decalSpawners[spawnerIndex].staticGroups.Clear(); + _decalSpawners[spawnerIndex].movableGroups.Clear(); + } + + public void Cleanup() + { + for (int i = 0; i < _decalSpawners.Length; i++) + { + _decalSpawners[i].Release(); + _decalSpawners[i].staticGroups.Clear(); + _decalSpawners[i].movableGroups.Clear(); + + // Clean up textures and materials + if (_materials[i].mainTexture != null) Object.Destroy(_materials[i].mainTexture); + Object.Destroy(_materials[i]); + } + + Object.Destroy(_decal); + } + + public void PlayAudio() + { + _audioSource.transform.position = _lastPlacedPosition; + switch (ModSettings.Entry_SelectedSFX.Value) + { + case SFXType.SourceEngineSpray: + _audioSource.PlayOneShot(StickerMod.SourceSFXPlayerSprayer); + break; + case SFXType.LittleBigPlanetSticker: + _audioSource.PlayOneShot(StickerMod.LittleBigPlanetSFXStickerPlace); + break; + case SFXType.FactorioAlertDestroyed: + _audioSource.PlayOneShot(StickerMod.FactorioSFXAlertDestroyed); + break; + case SFXType.None: + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + public void SetAlpha(float alpha) + { + foreach (Material material in _materials) + { + Color color = material.color; + color.a = alpha; + material.color = color; + } + } + + #region shitty identify + + public void Identify() + { + Color color = Color.HSVToRGB(Time.time % 1f, 1f, 1f); + foreach (Material material in _materials) + material.color = color; // cycle rainbow + } + + public void ResetIdentify() + { + foreach (Material material in _materials) + material.color = Color.white; + } + + #endregion shitty identify } } \ No newline at end of file diff --git a/Stickers/Stickers/StickerSystem.ImageLoading.cs b/Stickers/Stickers/StickerSystem.ImageLoading.cs new file mode 100644 index 0000000..4f5fce4 --- /dev/null +++ b/Stickers/Stickers/StickerSystem.ImageLoading.cs @@ -0,0 +1,152 @@ +using System.Diagnostics; +using MTJobSystem; +using NAK.Stickers.Networking; +using NAK.Stickers.Utilities; +using UnityEngine; + +namespace NAK.Stickers; + +public partial class StickerSystem +{ + #region Actions + + public static event Action OnStickerLoaded; + public static event Action OnStickerLoadFailed; + + private static void InvokeOnImageLoaded(int slotIndex, string imageName) + => MTJobManager.RunOnMainThread("StickersSystem.InvokeOnImageLoaded", + () => OnStickerLoaded?.Invoke(slotIndex, imageName)); + + private static void InvokeOnImageLoadFailed(int slotIndex, string errorMessage) + => MTJobManager.RunOnMainThread("StickersSystem.InvokeOnImageLoadFailed", + () => OnStickerLoadFailed?.Invoke(slotIndex, errorMessage)); + + #endregion Actions + + #region Image Loading + + private static readonly string s_StickersFolderPath = Path.GetFullPath(Application.dataPath + "/../UserData/Stickers/"); + private readonly bool[] _isLoadingImage = new bool[ModSettings.MaxStickerSlots]; + + private void LoadAllImagesAtStartup() + { + string[] selectedStickers = ModSettings.Hidden_SelectedStickerNames.Value; + for (int i = 0; i < ModSettings.MaxStickerSlots; i++) + { + if (i >= selectedStickers.Length || string.IsNullOrEmpty(selectedStickers[i])) continue; + LoadImage(selectedStickers[i], i); + } + } + + public void LoadImage(string imageName, int slotIndex) + { + if (string.IsNullOrEmpty(imageName) || slotIndex < 0 || slotIndex >= _isLoadingImage.Length) + return; + + if (_isLoadingImage[slotIndex]) return; + _isLoadingImage[slotIndex] = true; + + Task.Run(() => + { + try + { + if (!TryLoadImage(imageName, slotIndex, out string errorMessage)) + throw new Exception(errorMessage); + } + catch (Exception ex) + { + //StickerMod.Logger.Error($"Failed to load sticker for slot {slotIndex}: {ex.Message}"); + InvokeOnImageLoadFailed(slotIndex, ex.Message); + } + finally + { + _isLoadingImage[slotIndex] = false; + } + }); + } + + private bool TryLoadImage(string imageName, int slotIndex, out string errorMessage) + { + errorMessage = string.Empty; + + if (ModNetwork.IsSendingTexture) + { + //StickerMod.Logger.Warning("A texture is currently being sent over the network. Cannot load a new image yet."); + errorMessage = "A texture is currently being sent over the network. Cannot load a new image yet."; + return false; + } + + if (!Directory.Exists(s_StickersFolderPath)) Directory.CreateDirectory(s_StickersFolderPath); + + string imagePath = Path.Combine(s_StickersFolderPath, imageName); + FileInfo fileInfo = new(imagePath); + if (!fileInfo.Exists) + { + //StickerMod.Logger.Warning($"Target image does not exist on disk. Path: {imagePath}"); + errorMessage = "Target image does not exist on disk."; + return false; + } + + var bytes = File.ReadAllBytes(imagePath); + + if (!ImageUtility.IsValidImage(bytes)) + { + //StickerMod.Logger.Error("File is not a valid image or is corrupt."); + errorMessage = "File is not a valid image or is corrupt."; + return false; + } + + //StickerMod.Logger.Msg("Loaded image from disk. Size in KB: " + bytes.Length / 1024 + " (" + bytes.Length + " bytes)"); + + if (bytes.Length > ModNetwork.MaxTextureSize) + { + ImageUtility.Resize(ref bytes, 256, 256); + //StickerMod.Logger.Warning("File ate too many cheeseburgers. Attempting experimental resize. Notice: this may cause filesize to increase."); + //StickerMod.Logger.Msg("Resized image. Size in KB: " + bytes.Length / 1024 + " (" + bytes.Length + " bytes)"); + } + + if (ImageUtility.ResizeToNearestPowerOfTwo(ref bytes)) + { + //StickerMod.Logger.Warning("Image resolution was not a power of two. Attempting experimental resize. Notice: this may cause filesize to increase."); + //StickerMod.Logger.Msg("Resized image. Size in KB: " + bytes.Length / 1024 + " (" + bytes.Length + " bytes)"); + } + + if (bytes.Length > ModNetwork.MaxTextureSize) + { + //StickerMod.Logger.Error("File is still too large. Aborting. Size in KB: " + bytes.Length / 1024 + " (" + bytes.Length + " bytes)"); + //StickerMod.Logger.Msg("Please resize the image manually to be smaller than " + ModNetwork.MaxTextureSize / 1024 + " KB and round resolution to nearest power of two."); + errorMessage = "File is still too large. Please resize the image manually to be smaller than " + ModNetwork.MaxTextureSize / 1024 + " KB and round resolution to nearest power of two."; + return false; + } + + //StickerMod.Logger.Msg("Image successfully loaded."); + + MTJobManager.RunOnMainThread("StickersSystem.LoadImage", () => + { + ModSettings.Hidden_SelectedStickerNames.Value[slotIndex] = imageName; + SetTextureSelf(bytes, slotIndex); + + InvokeOnImageLoaded(slotIndex, imageName); + + if (!IsInStickerMode) return; + IsInStickerMode = false; + IsInStickerMode = true; + }); + + return true; + } + + public static void OpenStickersFolder() + { + if (!Directory.Exists(s_StickersFolderPath)) Directory.CreateDirectory(s_StickersFolderPath); + Process.Start(s_StickersFolderPath); + } + + public static string GetStickersFolderPath() + { + if (!Directory.Exists(s_StickersFolderPath)) Directory.CreateDirectory(s_StickersFolderPath); + return s_StickersFolderPath; + } + + #endregion Image Loading +} \ No newline at end of file diff --git a/Stickers/Stickers/StickerSystem.Main.cs b/Stickers/Stickers/StickerSystem.Main.cs new file mode 100644 index 0000000..e609fed --- /dev/null +++ b/Stickers/Stickers/StickerSystem.Main.cs @@ -0,0 +1,71 @@ +using ABI_RC.Core.UI; +using ABI_RC.Systems.GameEventSystem; +using NAK.Stickers.Utilities; +using UnityEngine; + +namespace NAK.Stickers; + +public partial class StickerSystem +{ + #region Singleton + + public static StickerSystem Instance { get; private set; } + + public static void Initialize() + { + if (Instance != null) + return; + + Instance = new StickerSystem(); + + // configure decalery + DecalManager.SetPreferredMode(DecalUtils.Mode.GPU, false, 0); + + // ensure cache folder exists + if (!Directory.Exists(s_StickersFolderPath)) Directory.CreateDirectory(s_StickersFolderPath); + + // listen for game events + CVRGameEventSystem.Initialization.OnPlayerSetupStart.AddListener(Instance.OnPlayerSetupStart); + } + + #endregion Singleton + + #region Data + + private int _selectedStickerSlot; + public int SelectedStickerSlot + { + get => _selectedStickerSlot; + set + { + _selectedStickerSlot = Mathf.Clamp(value, 0, ModSettings.MaxStickerSlots - 1); + IsInStickerMode = IsInStickerMode; // refresh sticker mode + } + } + + private bool _isInStickerMode; + public bool IsInStickerMode + { + get => _isInStickerMode; + set + { + _isInStickerMode = value; + if (_isInStickerMode) CohtmlHud.Instance.SelectPropToSpawn( + StickerCache.GetCohtmlResourcesPath(SelectedStickerName), + Path.GetFileNameWithoutExtension(SelectedStickerName), + "Sticker selected for stickering:"); + else CohtmlHud.Instance.ClearPropToSpawn(); + } + } + + private string SelectedStickerName => ModSettings.Hidden_SelectedStickerNames.Value[_selectedStickerSlot]; + + private const float StickerKillTime = 30f; + private const float StickerCooldown = 0.2f; + private readonly Dictionary _playerStickers = new(); + private const string PlayerLocalId = "_PLAYERLOCAL"; + + private readonly List _deadStickerPool = new(); // for cleanup on player leave + + #endregion Data +} \ No newline at end of file diff --git a/Stickers/Stickers/StickerSystem.PlayerCallbacks.cs b/Stickers/Stickers/StickerSystem.PlayerCallbacks.cs new file mode 100644 index 0000000..f361e0a --- /dev/null +++ b/Stickers/Stickers/StickerSystem.PlayerCallbacks.cs @@ -0,0 +1,98 @@ +using ABI_RC.Core.IO; +using ABI_RC.Core.Networking.IO.Instancing; +using ABI_RC.Core.Player; +using ABI_RC.Systems.GameEventSystem; +using UnityEngine; + +namespace NAK.Stickers; + +public partial class StickerSystem +{ + #region Player Callbacks + + private void OnPlayerSetupStart() + { + CVRGameEventSystem.World.OnUnload.AddListener(_ => Instance.CleanupAllButSelf()); + CVRGameEventSystem.Instance.OnConnected.AddListener((_) => { if (!Instances.IsReconnecting) Instance.ClearStickersSelf(); }); + + CVRGameEventSystem.Player.OnJoinEntity.AddListener(Instance.OnPlayerJoined); + CVRGameEventSystem.Player.OnLeaveEntity.AddListener(Instance.OnPlayerLeft); + SchedulerSystem.AddJob(Instance.OnOccasionalUpdate, 10f, 1f); + LoadAllImagesAtStartup(); + } + + private void OnPlayerJoined(CVRPlayerEntity playerEntity) + { + if (!_playerStickers.TryGetValue(playerEntity.Uuid, out StickerData stickerData)) + return; + + stickerData.DeathTime = -1f; + stickerData.SetAlpha(1f); + _deadStickerPool.Remove(stickerData); + } + + private void OnPlayerLeft(CVRPlayerEntity playerEntity) + { + if (!_playerStickers.TryGetValue(playerEntity.Uuid, out StickerData stickerData)) + return; + + stickerData.DeathTime = Time.time + StickerKillTime; + stickerData.SetAlpha(1f); + _deadStickerPool.Add(stickerData); + } + + private void OnOccasionalUpdate() + { + if (_deadStickerPool.Count == 0) + return; + + for (var i = _deadStickerPool.Count - 1; i >= 0; i--) + { + float currentTime = Time.time; + StickerData stickerData = _deadStickerPool[i]; + if (stickerData == null) + { + _deadStickerPool.RemoveAt(i); + continue; + } + + if (stickerData.DeathTime < 0f) + continue; + + if (currentTime < stickerData.DeathTime) + { + stickerData.SetAlpha(Mathf.Lerp(0f, 1f, (stickerData.DeathTime - currentTime) / StickerKillTime)); + continue; + } + + _playerStickers.Remove(_playerStickers.First(x => x.Value == stickerData).Key); + _deadStickerPool.RemoveAt(i); + stickerData.Cleanup(); + } + } + + #endregion Player Callbacks + + #region Player Callbacks + + public void OnStickerPlaceReceived(string playerId, int stickerSlot, Vector3 position, Vector3 forward, Vector3 up) + => AttemptPlaceSticker(playerId, position, forward, up, alignWithNormal: true, stickerSlot); + + public void OnStickerClearReceived(string playerId, int stickerSlot) + => ClearStickersForPlayer(playerId, stickerSlot); + + public void OnStickerClearAllReceived(string playerId) + => ClearStickersForPlayer(playerId); + + public void OnStickerIdentifyReceived(string playerId) + { + if (!_playerStickers.TryGetValue(playerId, out StickerData stickerData)) + return; + + // todo: make prettier (idk shaders) + SchedulerSystem.AddJob(() => stickerData.Identify(), 0f, 0.1f, 30); + SchedulerSystem.AddJob(() => stickerData.ResetIdentify(), 4f, 1f, 1); + } + + #endregion Player Callbacks +} diff --git a/Stickers/Stickers/StickerSystem.StickerLifecycle.cs b/Stickers/Stickers/StickerSystem.StickerLifecycle.cs new file mode 100644 index 0000000..a24ebd8 --- /dev/null +++ b/Stickers/Stickers/StickerSystem.StickerLifecycle.cs @@ -0,0 +1,160 @@ +using ABI_RC.Core; +using ABI_RC.Core.Player; +using ABI_RC.Systems.InputManagement; +using NAK.Stickers.Networking; +using UnityEngine; + +namespace NAK.Stickers; + +public partial class StickerSystem +{ + #region Sticker Lifecycle + + private StickerData GetOrCreateStickerData(string playerId) + { + if (_playerStickers.TryGetValue(playerId, out StickerData stickerData)) + return stickerData; + + stickerData = new StickerData(playerId == PlayerLocalId, ModSettings.MaxStickerSlots); + _playerStickers[playerId] = stickerData; + return stickerData; + } + + public void PlaceStickerFromControllerRay(Transform transform, CVRHand hand = CVRHand.Left) + { + Vector3 controllerForward = transform.forward; + Vector3 controllerUp = transform.up; + Vector3 playerUp = PlayerSetup.Instance.transform.up; + + // extracting angle of controller ray on forward axis + Vector3 projectedControllerUp = Vector3.ProjectOnPlane(controllerUp, controllerForward).normalized; + Vector3 projectedPlayerUp = Vector3.ProjectOnPlane(playerUp, controllerForward).normalized; + float angle = Vector3.Angle(projectedControllerUp, projectedPlayerUp); + + float angleThreshold = ModSettings.Entry_PlayerUpAlignmentThreshold.Value; + Vector3 targetUp = (angleThreshold != 0f && angle <= angleThreshold) + // leave 0.01% of the controller up vector to prevent issues with alignment on floor & ceiling in Desktop + ? Vector3.Slerp(controllerUp, playerUp, 0.99f) + : controllerUp; + + if (!PlaceStickerSelf(transform.position, transform.forward, targetUp)) + return; + + // do haptic if not lame + if (!ModSettings.Entry_HapticsOnPlace.Value) return; + CVRInputManager.Instance.Vibrate(0f, 0.1f, 10f, 0.1f, hand); + } + + private bool PlaceStickerSelf(Vector3 position, Vector3 forward, Vector3 up, bool alignWithNormal = true) + { + if (!AttemptPlaceSticker(PlayerLocalId, position, forward, up, alignWithNormal, SelectedStickerSlot)) + return false; // failed + + // placed, now network + ModNetwork.SendPlaceSticker(SelectedStickerSlot, position, forward, up); + return true; + } + + private bool AttemptPlaceSticker(string playerId, Vector3 position, Vector3 forward, Vector3 up, bool alignWithNormal = true, int stickerSlot = 0) + { + StickerData stickerData = GetOrCreateStickerData(playerId); + if (Time.time - stickerData.LastPlacedTime < StickerCooldown) + return false; + + // Every layer other than IgnoreRaycast, PlayerLocal, PlayerClone, PlayerNetwork, and UI Internal + const int LayerMask = ~((1 << 2) | (1 << 8) | (1 << 9) | (1 << 10) | (1 << 15)); + if (!Physics.Raycast(position, forward, out RaycastHit hit, + 10f, LayerMask, QueryTriggerInteraction.Ignore)) + return false; + + stickerData.Place(hit, alignWithNormal ? -hit.normal : forward, up, stickerSlot); + stickerData.PlayAudio(); + return true; + } + + public void ClearStickersSelf() + { + ClearStickersForPlayer(PlayerLocalId); + ModNetwork.SendClearAllStickers(); + } + + private void ClearStickersForPlayer(string playerId) + { + if (!_playerStickers.TryGetValue(playerId, out StickerData stickerData)) + return; + + stickerData.Clear(); + } + + private void ClearStickersForPlayer(string playerId, int stickerSlot) + { + if (!_playerStickers.TryGetValue(playerId, out StickerData stickerData)) + return; + + stickerData.Clear(stickerSlot); + } + + private void SetTextureSelf(byte[] imageBytes, int stickerSlot = 0) + { + Texture2D texture = new(1, 1); // placeholder + texture.LoadImage(imageBytes); + texture.Compress(true); // noachi said to do + + OnPlayerStickerTextureReceived(PlayerLocalId, Guid.Empty, texture, stickerSlot); + ModNetwork.SetTexture(stickerSlot, imageBytes); + } + + public void ClearAllStickers() + { + foreach (StickerData stickerData in _playerStickers.Values) + stickerData.Clear(); + + ModNetwork.SendClearAllStickers(); + } + + public void OnPlayerStickerTextureReceived(string playerId, Guid textureHash, Texture2D texture, int stickerSlot = 0) + { + StickerData stickerData = GetOrCreateStickerData(playerId); + stickerData.SetTexture(textureHash, texture, stickerSlot); + } + + public bool HasTextureHash(string playerId, Guid textureHash) + { + StickerData stickerData = GetOrCreateStickerData(playerId); + return stickerData.CheckHasTextureHash(textureHash); + } + + public void CleanupAll() + { + foreach ((_, StickerData data) in _playerStickers) + data.Cleanup(); + + _playerStickers.Clear(); + } + + public void CleanupAllButSelf() + { + StickerData localStickerData = GetOrCreateStickerData(PlayerLocalId); + + foreach ((_, StickerData data) in _playerStickers) + { + if (data.IsLocal) data.Clear(); + else data.Cleanup(); + } + + _playerStickers.Clear(); + _playerStickers[PlayerLocalId] = localStickerData; + } + + public void SelectStickerSlot(int stickerSlot) + { + SelectedStickerSlot = Mathf.Clamp(stickerSlot, 0, ModSettings.MaxStickerSlots - 1); + } + + public int GetCurrentStickerSlot() + { + return SelectedStickerSlot; + } + + #endregion Sticker Lifecycle +} diff --git a/Stickers/Stickers/StickerSystem.cs b/Stickers/Stickers/StickerSystem.cs deleted file mode 100644 index e0176ed..0000000 --- a/Stickers/Stickers/StickerSystem.cs +++ /dev/null @@ -1,406 +0,0 @@ -using System.Diagnostics; -using ABI_RC.Core.IO; -using ABI_RC.Core.Player; -using ABI_RC.Core.Savior; -using ABI_RC.Core.UI; -using ABI_RC.Systems.GameEventSystem; -using MTJobSystem; -using NAK.Stickers.Integrations; -using NAK.Stickers.Networking; -using NAK.Stickers.Utilities; -using UnityEngine; - -namespace NAK.Stickers; - -public class StickerSystem -{ - #region Singleton - - public static StickerSystem Instance { get; private set; } - - public static void Initialize() - { - if (Instance != null) - return; - - Instance = new StickerSystem(); - - // ensure cache folder exists - if (!Directory.Exists(s_StickersSourcePath)) Directory.CreateDirectory(s_StickersSourcePath); - - // configure Decalery - ModSettings.DecaleryMode selectedMode = ModSettings.Decalery_DecalMode.Value; - DecalManager.SetPreferredMode((DecalUtils.Mode)selectedMode, selectedMode == ModSettings.DecaleryMode.GPUIndirect, 0); - if (selectedMode != ModSettings.DecaleryMode.GPU) StickerMod.Logger.Warning("Decalery is not set to GPU mode. Expect compatibility issues with user generated content when mesh data is not marked as readable."); - - // listen for game events - CVRGameEventSystem.Initialization.OnPlayerSetupStart.AddListener(Instance.OnPlayerSetupStart); - } - - #endregion Singleton - - #region Actions - - public static Action OnImageLoadFailed; - - private void InvokeOnImageLoadFailed(string errorMessage) - { - MTJobManager.RunOnMainThread("StickersSystem.InvokeOnImageLoadFailed", () => OnImageLoadFailed?.Invoke(errorMessage)); - } - - #endregion Actions - - #region Data - - private bool _isInStickerMode; - public bool IsInStickerMode - { - get => _isInStickerMode; - set - { - _isInStickerMode = value; - if (_isInStickerMode) CohtmlHud.Instance.SelectPropToSpawn(BtkUiAddon.GetBtkUiCachePath(ModSettings.Hidden_SelectedStickerName.Value), ModSettings.Hidden_SelectedStickerName.Value, "Sticker selected for stickering:"); - else CohtmlHud.Instance.ClearPropToSpawn(); - } - } - - private const float StickerKillTime = 30f; - private const float StickerCooldown = 0.2f; - private readonly Dictionary _playerStickers = new(); - private const string PlayerLocalId = "_PLAYERLOCAL"; - - private readonly List _deadStickerPool = new(); // for cleanup on player leave - - #endregion Data - - #region Game Events - - private void OnPlayerSetupStart() - { - CVRGameEventSystem.World.OnUnload.AddListener(_ => Instance.CleanupAllButSelf()); - CVRGameEventSystem.Player.OnJoinEntity.AddListener(Instance.OnPlayerJoined); - CVRGameEventSystem.Player.OnLeaveEntity.AddListener(Instance.OnPlayerLeft); - SchedulerSystem.AddJob(Instance.OnOccasionalUpdate, 10f, 1f); - - LoadImage(ModSettings.Hidden_SelectedStickerName.Value); - } - - private void OnPlayerJoined(CVRPlayerEntity playerEntity) - { - if (!_playerStickers.TryGetValue(playerEntity.Uuid, out StickerData stickerData)) - return; - - stickerData.DeathTime = -1f; - stickerData.SetAlpha(1f); - _deadStickerPool.Remove(stickerData); - } - - private void OnPlayerLeft(CVRPlayerEntity playerEntity) - { - if (!_playerStickers.TryGetValue(playerEntity.Uuid, out StickerData stickerData)) - return; - - stickerData.DeathTime = Time.time + StickerKillTime; - stickerData.SetAlpha(1f); - _deadStickerPool.Add(stickerData); - } - - private void OnOccasionalUpdate() - { - if (_deadStickerPool.Count == 0) - return; - - for (var i = _deadStickerPool.Count - 1; i >= 0; i--) - { - float currentTime = Time.time; - StickerData stickerData = _deadStickerPool[i]; - if (stickerData.DeathTime < 0f) - continue; - - if (currentTime < stickerData.DeathTime) - { - stickerData.SetAlpha(Mathf.Lerp(0f, 1f, (stickerData.DeathTime - currentTime) / StickerKillTime)); - continue; - } - - _playerStickers.Remove(_playerStickers.First(x => x.Value == stickerData).Key); - _deadStickerPool.RemoveAt(i); - stickerData.Cleanup(); - } - } - - #endregion Game Events - - #region Local Player - - public void PlaceStickerFromTransform(Transform transform) - { - Vector3 controllerForward = transform.forward; - Vector3 controllerUp = transform.up; - Vector3 playerUp = PlayerSetup.Instance.transform.up; - - // extracting angle of controller ray on forward axis - Vector3 projectedControllerUp = Vector3.ProjectOnPlane(controllerUp, controllerForward).normalized; - Vector3 projectedPlayerUp = Vector3.ProjectOnPlane(playerUp, controllerForward).normalized; - float angle = Vector3.Angle(projectedControllerUp, projectedPlayerUp); - - float angleThreshold = ModSettings.Entry_PlayerUpAlignmentThreshold.Value; - Vector3 targetUp = (angleThreshold != 0f && angle <= angleThreshold) - // leave 0.01% of the controller up vector to prevent issues with alignment on floor & ceiling in Desktop - ? Vector3.Slerp(controllerUp, playerUp, 0.99f) - : controllerUp; - - PlaceStickerSelf(transform.position, transform.forward, targetUp); - } - - /// Place own sticker. Network if successful. - private void PlaceStickerSelf(Vector3 position, Vector3 forward, Vector3 up, bool alignWithNormal = true) - { - if (!AttemptPlaceSticker(PlayerLocalId, position, forward, up, alignWithNormal)) - return; // failed - - // placed, now network - ModNetwork.PlaceSticker(position, forward, up); - } - - /// Clear own stickers. Network if successful. - public void ClearStickersSelf() - { - OnPlayerStickersClear(PlayerLocalId); - ModNetwork.ClearStickers(); - } - - /// Set own Texture2D for sticker. Network if cusses. - private void SetTextureSelf(byte[] imageBytes) - { - Texture2D texture = new(1, 1); // placeholder - texture.LoadImage(imageBytes); - texture.Compress(true); // noachi said to do - - OnPlayerStickerTextureReceived(PlayerLocalId, Guid.Empty, texture); - if (ModNetwork.SetTexture(imageBytes)) ModNetwork.SendTexture(); - } - - #endregion Local Player - - #region Public Methods - - /// When a player wants to place a sticker. - public void OnPlayerStickerPlace(string playerId, Vector3 position, Vector3 forward, Vector3 up) - => AttemptPlaceSticker(playerId, position, forward, up); - - /// When a player wants to clear their stickers. - public void OnPlayerStickersClear(string playerId) - => ClearStickersForPlayer(playerId); - - /// Clear all stickers from all players. - public void ClearAllStickers() - { - foreach (StickerData stickerData in _playerStickers.Values) - stickerData.Clear(); - - ModNetwork.ClearStickers(); - } - - public void OnPlayerStickerTextureReceived(string playerId, Guid textureHash, Texture2D texture) - { - StickerData stickerData = GetOrCreateStickerData(playerId); - stickerData.SetTexture(textureHash, texture); - } - - public Guid GetPlayerStickerTextureHash(string playerId) - { - StickerData stickerData = GetOrCreateStickerData(playerId); - return stickerData.TextureHash; - } - - public void CleanupAll() - { - foreach ((_, StickerData data) in _playerStickers) - data.Cleanup(); - - _playerStickers.Clear(); - } - - public void CleanupAllButSelf() - { - StickerData localStickerData = GetOrCreateStickerData(PlayerLocalId); - - foreach ((_, StickerData data) in _playerStickers) - { - if (data.IsLocal) data.Clear(); - else data.Cleanup(); - } - - _playerStickers.Clear(); - _playerStickers[PlayerLocalId] = localStickerData; - } - - - #endregion Public Methods - - #region Private Methods - - private StickerData GetOrCreateStickerData(string playerId) - { - if (_playerStickers.TryGetValue(playerId, out StickerData stickerData)) - return stickerData; - - stickerData = new StickerData(playerId == PlayerLocalId); - _playerStickers[playerId] = stickerData; - return stickerData; - } - - private bool AttemptPlaceSticker(string playerId, Vector3 position, Vector3 forward, Vector3 up, bool alignWithNormal = true) - { - StickerData stickerData = GetOrCreateStickerData(playerId); - if (Time.time - stickerData.LastPlacedTime < StickerCooldown) - return false; - - // Every layer other than IgnoreRaycast, PlayerLocal, PlayerClone, PlayerNetwork, and UI Internal - const int LayerMask = ~((1 << 2) | (1 << 8) | (1 << 9) | (1 << 10) | (1 << 15)); - if (!Physics.Raycast(position, forward, out RaycastHit hit, - 10f, LayerMask, QueryTriggerInteraction.Ignore)) - return false; - - stickerData.Place(hit, alignWithNormal ? -hit.normal : forward, up); - stickerData.PlayAudio(); - return true; - } - - private void ClearStickersForPlayer(string playerId) - { - if (!_playerStickers.TryGetValue(playerId, out StickerData stickerData)) - return; - - stickerData.Clear(); - } - - private void ReleaseStickerData(string playerId) - { - if (!_playerStickers.TryGetValue(playerId, out StickerData stickerData)) - return; - - stickerData.Cleanup(); - _playerStickers.Remove(playerId); - } - - #endregion Private Methods - - #region Image Loading - - private static readonly string s_StickersSourcePath = Application.dataPath + "/../UserData/Stickers/"; - - private bool _isLoadingImage; - - public void LoadImage(string imageName) - { - if (string.IsNullOrEmpty(imageName)) - return; - - if (_isLoadingImage) return; - _isLoadingImage = true; - - Task.Run(() => - { - try - { - if (!TryLoadImage(imageName, out string errorMessage)) - { - InvokeOnImageLoadFailed(errorMessage); - } - } - catch (Exception ex) - { - InvokeOnImageLoadFailed(ex.Message); - } - finally - { - _isLoadingImage = false; - } - }); - } - - private bool TryLoadImage(string imageName, out string errorMessage) - { - errorMessage = string.Empty; - - if (ModNetwork.IsSendingTexture) - { - StickerMod.Logger.Warning("A texture is currently being sent over the network. Cannot load a new image yet."); - errorMessage = "A texture is currently being sent over the network. Cannot load a new image yet."; - return false; - } - - if (!Directory.Exists(s_StickersSourcePath)) Directory.CreateDirectory(s_StickersSourcePath); - - string imagePath = Path.Combine(s_StickersSourcePath, imageName + ".png"); - FileInfo fileInfo = new(imagePath); - if (!fileInfo.Exists) - { - StickerMod.Logger.Warning($"Target image does not exist on disk. Path: {imagePath}"); - errorMessage = "Target image does not exist on disk."; - return false; - } - - var bytes = File.ReadAllBytes(imagePath); - - if (!ImageUtility.IsValidImage(bytes)) - { - StickerMod.Logger.Error("File is not a valid image or is corrupt."); - errorMessage = "File is not a valid image or is corrupt."; - return false; - } - - StickerMod.Logger.Msg("Loaded image from disk. Size in KB: " + bytes.Length / 1024 + " (" + bytes.Length + " bytes)"); - - if (bytes.Length > ModNetwork.MaxTextureSize) - { - ImageUtility.Resize(ref bytes, 256, 256); - StickerMod.Logger.Warning("File ate too many cheeseburgers. Attempting experimental resize. Notice: this may cause filesize to increase."); - StickerMod.Logger.Msg("Resized image. Size in KB: " + bytes.Length / 1024 + " (" + bytes.Length + " bytes)"); - } - - if (ImageUtility.ResizeToNearestPowerOfTwo(ref bytes)) - { - StickerMod.Logger.Warning("Image resolution was not a power of two. Attempting experimental resize. Notice: this may cause filesize to increase."); - StickerMod.Logger.Msg("Resized image. Size in KB: " + bytes.Length / 1024 + " (" + bytes.Length + " bytes)"); - } - - if (bytes.Length > ModNetwork.MaxTextureSize) - { - StickerMod.Logger.Error("File is still too large. Aborting. Size in KB: " + bytes.Length / 1024 + " (" + bytes.Length + " bytes)"); - StickerMod.Logger.Msg("Please resize the image manually to be smaller than " + ModNetwork.MaxTextureSize / 1024 + " KB and round resolution to nearest power of two."); - errorMessage = "File is still too large. Please resize the image manually to be smaller than " + ModNetwork.MaxTextureSize / 1024 + " KB and round resolution to nearest power of two."; - return false; - } - - StickerMod.Logger.Msg("Image successfully loaded."); - - MTJobManager.RunOnMainThread("StickersSystem.LoadImage", () => - { - ModSettings.Hidden_SelectedStickerName.Value = imageName; - SetTextureSelf(bytes); - - if (!IsInStickerMode) return; - IsInStickerMode = false; - IsInStickerMode = true; - }); - - return true; - } - - public static void OpenStickersFolder() - { - if (!Directory.Exists(s_StickersSourcePath)) Directory.CreateDirectory(s_StickersSourcePath); - Process.Start(s_StickersSourcePath); - } - - public static string GetStickersFolderPath() - { - if (!Directory.Exists(s_StickersSourcePath)) Directory.CreateDirectory(s_StickersSourcePath); - return s_StickersSourcePath; - } - - #endregion Image Loading -} \ No newline at end of file diff --git a/Stickers/Stickers/Utilities/StickerCache.cs b/Stickers/Stickers/Utilities/StickerCache.cs new file mode 100644 index 0000000..d01b59e --- /dev/null +++ b/Stickers/Stickers/Utilities/StickerCache.cs @@ -0,0 +1,185 @@ +using BTKUILib.UIObjects.Components; +using MTJobSystem; +using NAK.Stickers.Integrations; +using System.Collections.Concurrent; +using BTKUILib; +using UnityEngine; + +namespace NAK.Stickers.Utilities; + +public static class StickerCache +{ + #region Constants and Fields + + private static readonly string CohtmlResourcesPath = Path.Combine("coui://", "uiresources", "GameUI", "mods", "BTKUI", "images", ModSettings.ModName, "UserImages"); + private static readonly string ThumbnailPath = Path.Combine(Application.dataPath, "StreamingAssets", "Cohtml", "UIResources", "GameUI", "mods", "BTKUI", "images", ModSettings.ModName, "UserImages"); + + private static readonly ConcurrentQueue<(FileInfo, Button)> _filesToGenerateThumbnails = new(); + private static readonly HashSet _filesBeingProcessed = new(); + + private static readonly object _isGeneratingThumbnailsLock = new(); + private static bool _isGeneratingThumbnails; + private static bool IsGeneratingThumbnails { + get { lock (_isGeneratingThumbnailsLock) return _isGeneratingThumbnails; } + set { lock (_isGeneratingThumbnailsLock) { _isGeneratingThumbnails = value; } } + } + + #endregion Constants and Fields + + #region Public Methods + + public static string GetBtkUiIconName(string relativePath) + { + if (string.IsNullOrEmpty(relativePath)) return "Stickers-puzzle"; // default icon when shit fucked + + string relativePathWithoutExtension = relativePath[..^Path.GetExtension(relativePath).Length]; + return "UserImages/" + relativePathWithoutExtension.Replace('\\', '/'); + } + + public static string GetCohtmlResourcesPath(string relativePath) + { + if (string.IsNullOrEmpty(relativePath)) return string.Empty; + + string relativePathWithoutExtension = relativePath[..^Path.GetExtension(relativePath).Length]; + string cachePath = Path.Combine(CohtmlResourcesPath, relativePathWithoutExtension + ".png"); + + // Normalize path + cachePath = cachePath.Replace('\\', '/'); + return cachePath; + } + + public static bool IsThumbnailAvailable(string relativePathWithoutExtension) + { + if (string.IsNullOrEmpty(relativePathWithoutExtension)) return false; + + string thumbnailImagePath = Path.Combine(ThumbnailPath, relativePathWithoutExtension + ".png"); + return File.Exists(thumbnailImagePath); + } + + public static void EnqueueThumbnailGeneration(FileInfo fileInfo, Button button) + { + lock (_filesBeingProcessed) + { + if (!_filesBeingProcessed.Add(fileInfo.FullName)) + return; + + _filesToGenerateThumbnails.Enqueue((fileInfo, button)); + MTJobManager.RunOnMainThread("StartGeneratingThumbnailsIfNeeded", StartGeneratingThumbnailsIfNeeded); + } + } + + #endregion Public Methods + + #region Private Methods + + private static void StartGeneratingThumbnailsIfNeeded() + { + if (IsGeneratingThumbnails) return; + IsGeneratingThumbnails = true; + + StickerMod.Logger.Msg("Starting thumbnail generation task."); + + Task.Run(() => + { + try + { + int generatedThumbnails = 0; + + while (BTKUIAddon.IsPopulatingPage || _filesToGenerateThumbnails.Count > 0) + { + if (!_filesToGenerateThumbnails.TryDequeue(out (FileInfo, Button) fileInfo)) continue; + + bool success = GenerateThumbnail(fileInfo.Item1); + if (success && fileInfo.Item2.ButtonTooltip[5..] == fileInfo.Item1.Name) + { + var iconPath = GetBtkUiIconName(Path.GetRelativePath(StickerSystem.GetStickersFolderPath(), fileInfo.Item1.FullName)); + fileInfo.Item2.ButtonIcon = iconPath; + } + + lock (_filesBeingProcessed) + { + _filesBeingProcessed.Remove(fileInfo.Item1.FullName); + } + + generatedThumbnails++; + } + + StickerMod.Logger.Msg($"Finished thumbnail generation for {generatedThumbnails} files."); + } + catch (Exception e) + { + StickerMod.Logger.Error($"Failed to generate thumbnails: {e.Message}"); + } + finally + { + IsGeneratingThumbnails = false; + } + }); + } + + private static bool GenerateThumbnail(FileSystemInfo fileInfo) + { + string relativePath = Path.GetRelativePath(StickerSystem.GetStickersFolderPath(), fileInfo.FullName); + string relativePathWithoutExtension = relativePath[..^fileInfo.Extension.Length]; + string thumbnailDirectory = Path.GetDirectoryName(Path.Combine(ThumbnailPath, relativePathWithoutExtension + ".png")); + if (thumbnailDirectory == null) + return false; + + if (!Directory.Exists(thumbnailDirectory)) Directory.CreateDirectory(thumbnailDirectory); + + MemoryStream imageStream = LoadStreamFromFile(fileInfo.FullName); + if (imageStream == null) return false; + + try + { + ImageUtility.Resize(ref imageStream, 128, 128); + } + catch (Exception e) + { + StickerMod.Logger.Warning($"Failed to resize image: {e.Message}"); + imageStream.Dispose(); + return false; + } + + PrepareIconFromMemoryStream(ModSettings.ModName, relativePathWithoutExtension, imageStream); + imageStream.Dispose(); + return true; + } + + private static void PrepareIconFromMemoryStream(string modName, string iconPath, MemoryStream destination) + { + if (destination == null) + { + StickerMod.Logger.Error("Mod " + modName + " attempted to prepare " + iconPath + " but the resource stream was null! Yell at the mod author to fix this!"); + } + else + { + iconPath = UIUtils.GetCleanString(iconPath); + iconPath = Path.Combine(ThumbnailPath, iconPath); + File.WriteAllBytes(iconPath + ".png", destination.ToArray()); + } + } + + private static MemoryStream LoadStreamFromFile(string filePath) + { + if (!File.Exists(filePath)) + return null; + + try + { + using FileStream fileStream = new(filePath, FileMode.Open, FileAccess.Read); + + MemoryStream memoryStream = new(); + fileStream.CopyTo(memoryStream); + memoryStream.Position = 0; // Ensure the position is reset before returning + return memoryStream; + } + catch (Exception e) + { + StickerMod.Logger.Warning($"Failed to load stream from {filePath}: {e.Message}"); + return null; + } + } + + #endregion Private Methods +} \ No newline at end of file