Stickers: added some code

This commit is contained in:
NotAKidoS 2024-08-25 06:31:47 -05:00
parent 07d6dff0a9
commit 58079d9390
36 changed files with 2092 additions and 1651 deletions

View file

@ -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<T>() 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>(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<bool> 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<float> 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<string> 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<float> 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<bool> 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<TEnum>(MelonPreferences_Entry<TEnum> entry) where TEnum : Enum
{
MultiSelection multiSelection = new(
entry.DisplayName,
EnumUtils.GetPrettyEnumNames<TEnum>(),
EnumUtils.GetEnumIndex(entry.Value)
)
{
OnOptionUpdated = i => entry.Value = (TEnum)Enum.Parse(typeof(TEnum), Enum.GetNames(typeof(TEnum))[i])
};
return multiSelection;
}
#endregion Melon Preference Extensions
}

View file

@ -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
}

View file

@ -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);
}
}
}

View file

@ -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<string, ImageInfo> _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<string>(stickerFiles);
lock (_cacheLock)
{
if (!_initialClearCacheFolder)
{
_initialClearCacheFolder = true;
DeleteOldIcons(ModSettings.ModName);
}
var keysToRemove = new List<string>();
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<string>(stickerFiles);
lock (_cacheLock)
{
if (!_initialClearCacheFolder)
{
_initialClearCacheFolder = true;
DeleteOldIcons(ModSettings.ModName);
}
var keysToRemove = new List<string>();
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
}
}

View file

@ -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<bool> 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<float> 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<string> 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<float> 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<bool> 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}");
}
}
}

View file

@ -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);
}
}

View file

@ -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;
@ -56,6 +36,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);
openDesktopKeybindButton.OnPress += () => QuickMenuAPI.OpenMultiSelect(_desktopKeybindSelection);

View file

@ -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
}

View file

@ -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
}

View file

@ -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<string> 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<DirectoryInfo> 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<FileInfo> 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
}

View file

@ -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<AudioClip>(LittleBigPlanetSFX_StickerPlace);
if (LittleBigPlanetStickerPlace == null) {
LittleBigPlanetSFXStickerPlace = assetBundle.LoadAsset<AudioClip>(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<AudioClip>(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!");

View file

@ -5,14 +5,21 @@ 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
internal static readonly MelonPreferences_Entry<bool> Hidden_Foldout_SettingsCategory =
@ -28,18 +35,14 @@ public static class ModSettings
#region Stickers Mod Settings
internal static readonly MelonPreferences_Entry<bool> Entry_HapticsOnPlace =
Category.CreateEntry("haptics_on_place", true, "Haptics On Place", "Enable haptic feedback when placing stickers.");
internal static readonly MelonPreferences_Entry<float> 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<SFXType> 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<bool> Entry_UsePlaceBinding =
Category.CreateEntry("use_binding", true, "Use Place Binding", "Use the place binding to place stickers.");
@ -47,114 +50,14 @@ public static class ModSettings
internal static readonly MelonPreferences_Entry<KeyBind> 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<string> 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<string[]> Hidden_SelectedStickerNames =
Category.CreateEntry("selected_sticker_name", Array.Empty<string>(),
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<DecaleryMode> 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
internal static readonly MelonPreferences_Entry<bool> Debug_NetworkInbound =
@ -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
@ -182,12 +92,5 @@ 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
}

View file

@ -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);
}
}

View file

@ -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";
}

View file

@ -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 youve 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](<https://www.flaticon.com/authors/gohsantosadrive>) on Flaticon.
- Decal generation system by [Mr F](<https://assetstore.unity.com/publishers/37453>) on the Unity Asset Store.
## Notice of partial source-code
This mod is built around a modified version of [Decalery](<https://assetstore.unity.com/packages/tools/level-design/decalery-293468>) 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)](<https://github.com/Anatta336/driven-decals>) as it is what the mod was built around originally.
---

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View file

@ -29,6 +29,8 @@
<EmbeddedResource Include="Resources\Gohsantosadrive_Icons\Stickers-rubbish-bin.png" />
<None Remove="Resources\Gohsantosadrive_Icons\Stickers-alphabet.png" />
<EmbeddedResource Include="Resources\Gohsantosadrive_Icons\Stickers-alphabet.png" />
<None Remove="Resources\Gohsantosadrive_Icons\Stickers-magnifying-glass.png" />
<EmbeddedResource Include="Resources\Gohsantosadrive_Icons\Stickers-magnifying-glass.png" />
</ItemGroup>
<ItemGroup>
<Reference Include="BTKUILib">

View file

@ -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,
}

View file

@ -0,0 +1,9 @@
namespace NAK.Stickers;
internal enum SFXType
{
LittleBigPlanetSticker,
SourceEngineSpray,
FactorioAlertDestroyed,
None
}

View file

@ -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
}

View file

@ -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
}

View file

@ -0,0 +1,18 @@
using MTJobSystem;
namespace NAK.Stickers.Networking;
public static partial class ModNetwork
{
#region Events
public static Action<bool> OnTextureOutboundStateChanged;
private static void InvokeTextureOutboundStateChanged(bool isSending)
{
MTJobManager.RunOnMainThread("ModNetwork.InvokeTextureOutboundStateChanged",
() => OnTextureOutboundStateChanged?.Invoke(isSending));
}
#endregion Events
}

View file

@ -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
}

View file

@ -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<string, byte[]> _textureChunkBuffers = new();
private static readonly Dictionary<string, int> _receivedChunkCounts = new();
private static readonly Dictionary<string, int> _expectedChunkCounts = new();
private static readonly Dictionary<string, (int stickerSlot, Guid Hash, int Width, int Height)> _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
}

View file

@ -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
}

View file

@ -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<string> _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
}

View file

@ -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
}

View file

@ -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<bool> 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<string, byte[]> _textureChunkBuffers = new();
private static readonly Dictionary<string, int> _receivedChunkCounts = new();
private static readonly Dictionary<string, int> _expectedChunkCounts = new();
private static readonly Dictionary<string, (Guid Hash, int Width, int Height)> _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
}
}

View file

@ -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;
_decal = ScriptableObject.CreateInstance<DecalType>();
_decal.decalSettings = new DecalSpawner.InitData
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;
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<DecalType>();
_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,
};
_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);
}
_spawner = DecalManager.GetSpawner(_decal.decalSettings, 4096, 1024);
_audioSource = new GameObject("StickerAudioSource").AddComponent<AudioSource>();
_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
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>();
_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
}
}

View file

@ -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<int, string> OnStickerLoaded;
public static event Action<int, string> 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
}

View file

@ -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<string, StickerData> _playerStickers = new();
private const string PlayerLocalId = "_PLAYERLOCAL";
private readonly List<StickerData> _deadStickerPool = new(); // for cleanup on player leave
#endregion Data
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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<string> 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<string, StickerData> _playerStickers = new();
private const string PlayerLocalId = "_PLAYERLOCAL";
private readonly List<StickerData> _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
}

View file

@ -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<string> _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
}