[NAK_CVR_Mods] Unfucked for 2026r182

This commit is contained in:
NotAKid 2026-06-18 22:20:56 -05:00
parent c13dc8375a
commit 281403d68b
209 changed files with 3936 additions and 1122 deletions

View file

@ -0,0 +1,24 @@
using BTKUILib;
using BTKUILib.UIObjects;
using BTKUILib.UIObjects.Components;
using NAK.Stickers.Utilities;
namespace NAK.Stickers.Integrations;
public static partial class BTKUIAddon
{
private static void Setup_OtherOptionsCategory()
{
Category debugCategory = _rootPage.AddMelonCategory(ModSettings.Hidden_Foldout_MiscCategory);
debugCategory.AddMelonToggle(ModSettings.Debug_NetworkInbound);
debugCategory.AddMelonToggle(ModSettings.Debug_NetworkOutbound);
debugCategory.AddMelonToggle(ModSettings.Entry_FriendsOnly);
debugCategory.AddButton("Clear Thumbnail Cache", "Stickers-rubbish-bin", "Clear the cache of all loaded stickers.", ButtonStyle.TextWithIcon)
.OnPress += () => QuickMenuAPI.ShowConfirm("Clear Thumbnail Cache", "Are you sure you want to clear the Cohtml thumbnail cache for all stickers?",
() =>
{
StickerCache.ClearCache();
QuickMenuAPI.ShowAlertToast("Thumbnail cache cleared.", 2);
});
}
}

View file

@ -0,0 +1,122 @@
using BTKUILib;
using BTKUILib.UIObjects;
using BTKUILib.UIObjects.Components;
using BTKUILib.UIObjects.Objects;
namespace NAK.Stickers.Integrations;
public static partial class BTKUIAddon
{
private static Category _ourCategory;
private static Button _placeStickersButton;
private static readonly MultiSelection _sfxSelection =
MultiSelection.CreateMultiSelectionFromMelonPref(ModSettings.Entry_SelectedSFX);
private static readonly MultiSelection _desktopKeybindSelection =
MultiSelection.CreateMultiSelectionFromMelonPref(ModSettings.Entry_PlaceBinding);
private static readonly MultiSelection _tabDoubleClickSelection =
MultiSelection.CreateMultiSelectionFromMelonPref(ModSettings.Entry_TabDoubleClick);
#region Category Setup
private static void Setup_StickersModCategory()
{
_ourCategory = _rootPage.AddMelonCategory(ModSettings.Hidden_Foldout_SettingsCategory);
_placeStickersButton = _ourCategory.AddButton("Place Stickers", "Stickers-magic-wand", "Place stickers via raycast.", ButtonStyle.TextWithIcon);
_placeStickersButton.OnPress += OnPlaceStickersButtonClick;
Button clearSelfStickersButton = _ourCategory.AddButton("Clear Self", "Stickers-eraser", "Clear own stickers.", ButtonStyle.TextWithIcon);
clearSelfStickersButton.OnPress += OnClearSelfStickersButtonClick;
Button clearAllStickersButton = _ourCategory.AddButton("Clear All", "Stickers-rubbish-bin", "Clear all stickers.", ButtonStyle.TextWithIcon);
clearAllStickersButton.OnPress += OnClearAllStickersButtonClick;
Button openStickersFolderButton = _ourCategory.AddButton("Open Stickers Folder", "Stickers-folder", "Open UserData/Stickers folder in explorer. If above 256kb your image will automatically be downscaled for networking reasons.", ButtonStyle.TextWithIcon);
openStickersFolderButton.OnPress += OnOpenStickersFolderButtonClick;
Button openStickerSFXButton = _ourCategory.AddButton("Sticker SFX", "Stickers-headset", "Choose the SFX used when a sticker is placed.", ButtonStyle.TextWithIcon);
openStickerSFXButton.OnPress += () => QuickMenuAPI.OpenMultiSelect(_sfxSelection);
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);
toggleDesktopKeybindButton.OnValueUpdated += (b) =>
{
ModSettings.Entry_UsePlaceBinding.Value = b;
openDesktopKeybindButton.Disabled = !b;
};
Button openTabDoubleClickButton = _ourCategory.AddButton("Tab Double Click", "Stickers-mouse", "Choose the action to perform when double clicking the Stickers tab.", ButtonStyle.TextWithIcon);
openTabDoubleClickButton.OnPress += () => QuickMenuAPI.OpenMultiSelect(_tabDoubleClickSelection);
}
#endregion Category Setup
#region Button Actions
private static void OnPlaceStickersButtonClick()
{
if (!_isOurTabOpened) return;
if (StickerSystem.Instance.IsRestrictedInstance)
{
QuickMenuAPI.ShowAlertToast("Stickers are not allowed in this world!", 2);
return;
}
string mode = StickerSystem.Instance.IsInStickerMode ? "Exiting" : "Entering";
QuickMenuAPI.ShowAlertToast($"{mode} sticker placement mode...", 2);
StickerSystem.Instance.IsInStickerMode = !StickerSystem.Instance.IsInStickerMode;
}
private static void OnClearSelfStickersButtonClick()
{
if (!_isOurTabOpened) return;
QuickMenuAPI.ShowAlertToast("Clearing own stickers in world...", 2);
StickerSystem.Instance.ClearStickersSelf();
}
private static void OnClearAllStickersButtonClick()
{
if (!_isOurTabOpened) return;
QuickMenuAPI.ShowAlertToast("Clearing all stickers in world...", 2);
StickerSystem.Instance.ClearAllStickers();
}
private static void OnOpenStickersFolderButtonClick()
{
if (!_isOurTabOpened) return;
QuickMenuAPI.ShowAlertToast("Opening Stickers folder in Explorer...", 2);
StickerSystem.OpenStickersFolder();
}
public static void OnStickerRestrictionUpdated(bool isRestricted = false) //TODO: add Icon changing, Bono needs to expose the value first.
{
if (_rootPage == null || _placeStickersButton == null)
return;
if (isRestricted)
{
_rootPage.MenuSubtitle = "Stickers... are sadly disabled in this world.";
_placeStickersButton.Disabled = true;
_placeStickersButton.ButtonText = "Stickers Disabled";
_placeStickersButton.ButtonTooltip = "This world is not allowing Stickers.";
_placeStickersButton.ButtonIcon = "Stickers-magic-wand-broken";
return;
}
_rootPage.MenuSubtitle = "Stickers! Double-click the tab to quickly toggle Sticker Mode.";
_placeStickersButton.Disabled = false;
_placeStickersButton.ButtonText = "Place Stickers";
_placeStickersButton.ButtonTooltip = "Place stickers via raycast.";
_placeStickersButton.ButtonIcon = "Stickers-magic-wand";
}
#endregion Button Actions
}

View file

@ -0,0 +1,116 @@
using BTKUILib;
using BTKUILib.UIObjects;
using NAK.Stickers.Networking;
using NAK.Stickers.Utilities;
using System.Reflection;
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 Setup
private static void Setup_Icons()
{
Assembly assembly = Assembly.GetExecutingAssembly();
string assemblyName = assembly.GetName().Name;
// 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-magnifying-glass", GetIconStream("Gohsantosadrive_Icons.Stickers-magnifying-glass.png"));
QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-magic-wand", GetIconStream("Gohsantosadrive_Icons.Stickers-magic-wand.png"));
QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-magic-wand-broken", GetIconStream("Gohsantosadrive_Icons.Stickers-magic-wand-broken.png"));
QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-mouse", GetIconStream("Gohsantosadrive_Icons.Stickers-mouse.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-puzzle-disabled", GetIconStream("Gohsantosadrive_Icons.Stickers-puzzle-disabled.png")); // disabled Sticker Puzzle
QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-rubbish-bin", GetIconStream("Gohsantosadrive_Icons.Stickers-rubbish-bin.png"));
return;
Stream GetIconStream(string iconName) => assembly.GetManifestResourceStream($"{assemblyName}.Resources.{iconName}");
}
private static void Setup_StickerModTab()
{
_rootPage = new Page(ModSettings.ModName, ModSettings.SM_SettingsCategory, true, "Stickers-Puzzle") // sticker icon will be left blank as it is updated on world join, AFTER Icon value is exposed..
{
MenuTitle = ModSettings.SM_SettingsCategory + $" (Network Version v{ModNetwork.NetworkVersion})",
MenuSubtitle = "", // left this blank as it is defined when the world loads
};
_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_OtherOptionsCategory();
}
#endregion Setup
#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)
{
switch (ModSettings.Entry_TabDoubleClick.Value)
{
default:
case TabDoubleClick.ToggleStickerMode:
OnPlaceStickersButtonClick();
break;
case TabDoubleClick.ClearAllStickers:
OnClearAllStickersButtonClick();
break;
case TabDoubleClick.ClearSelfStickers:
OnClearSelfStickersButtonClick();
break;
case TabDoubleClick.None:
break;
}
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,287 @@
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 TextBlock _noFilesTextBlock;
private const int MAX_BUTTONS = 512; // cohtml literally will start to explode
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");
_noFilesTextBlock = _fileCategory.AddTextBlock("No images found in this directory. You can add your own images and subfolders to the `UserData/Stickers/` folder.");
_noFilesTextBlock.Hidden = true;
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);
_rootPage.OpenPage(true); // close the directory browser to artificially limit loading speed
};
_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;
// quick menu notification
QuickMenuAPI.ShowAlertToast($"Selected sticker slot {index + 1}", 1);
}
private static void OpenStickerSelectionForSlot(int index)
{
if (IsPopulatingPage) return;
_curSelectedSticker = index;
_initialDirectory = StickerSystem.GetStickersFolderPath(); // creates folder if needed (lazy fix)
_curDirectoryInfo = new DirectoryInfo(_initialDirectory);
_ourDirectoryBrowserPage.OpenPage(false, true);
}
private static void PopulateMenuItems()
{
//StickerMod.Logger.Msg("Populating menu items.");
try
{
Thread.CurrentThread.IsBackground = false; // working around bug in MTJobManager
var directories = _curDirectoryInfo.GetDirectories();
var files = _curDirectoryInfo.GetFiles();
MTJobManager.RunOnMainThread("PopulateMenuItems", () =>
{
// resize the arrays to the max amount of buttons
int foldersCount = Mathf.Min(directories.Length, MAX_BUTTONS);
if (foldersCount > _folderButtons.Length)
{
int folderEndIdx = _folderButtons.Length;
Array.Resize(ref _folderButtons, foldersCount);
SetupFolderButtons(folderEndIdx);
}
int filesCount = Mathf.Min(files.Length, MAX_BUTTONS);
if (filesCount > _fileButtons.Length)
{
int fileEndIdx = _fileButtons.Length;
Array.Resize(ref _fileButtons, filesCount);
SetupFileButtons(fileEndIdx);
}
_folderCategory.Hidden = foldersCount == 0;
_folderCategory.CategoryName = $"Subdirectories ({foldersCount})";
//_fileCategory.Hidden = filesCount == 0;
_fileCategory.CategoryName = $"Images ({filesCount})";
_noFilesTextBlock.Hidden = filesCount > 0;
});
PopulateFolders(directories);
PopulateFiles(files);
}
catch (Exception e)
{
StickerMod.Logger.Error($"Failed to populate menu items: {e.Message}");
}
finally
{
IsPopulatingPage = false;
Thread.CurrentThread.IsBackground = true; // working around bug in MTJobManager
}
}
private static void PopulateFolders(IReadOnlyList<DirectoryInfo> directories)
{
for (int i = 0; i < _folderButtons.Length; i++)
{
if (i >= directories.Count)
break;
Button button = _folderButtons[i];
//if (button == null) continue; // Array resized, excess buttons are generating
button.ButtonText = directories[i].Name;
button.ButtonTooltip = $"Open {directories[i].Name}";
MTJobManager.RunAsyncOnMainThread("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++)
{
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 button = _fileButtons[i];
//if (button == null) continue; // Array resized, excess buttons are generating
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.RunAsyncOnMainThread("PopulateMenuItems", () => button.Hidden = false);
if (i <= 16) Thread.Sleep(10); // For the pop-in effect
}
}
#endregion Icon Utils
}

View file

@ -0,0 +1,175 @@
using ABI_RC.Core.Player;
using ABI_RC.Core.UI.UIRework.Managers;
using ABI_RC.Systems.InputManagement;
using MelonLoader;
using NAK.Stickers.Integrations;
using NAK.Stickers.Networking;
using UnityEngine;
namespace NAK.Stickers;
public class StickerMod : MelonMod
{
internal static MelonLogger.Instance Logger;
#region Melon Mod Overrides
public override void OnInitializeMelon()
{
Logger = LoggerInstance;
ModNetwork.Subscribe();
ModSettings.Initialize();
StickerSystem.Initialize();
ApplyPatches(typeof(Patches.PlayerSetup_Patches));
ApplyPatches(typeof(Patches.ControllerRay_Patches));
ApplyPatches(typeof(Patches.ShaderFilterHelper_Patches));
ApplyPatches(typeof(Patches.CVRTools_Patches));
LoadAssetBundle();
InitializeIntegration(nameof(BTKUILib), BTKUIAddon.Initialize); // quick menu ui
}
public override void OnUpdate()
{
if (StickerSystem.Instance == null)
return;
if (StickerSystem.Instance.IsInStickerMode)
{
if (Input.mouseScrollDelta.y != 0f
&& Cursor.lockState == CursorLockMode.Locked // prevent scrolling while in menus
&& !CVRInputManager.Instance.zoom) // prevent scrolling while using scroll zoom
StickerSystem.Instance.SelectedStickerSlot += (int)Input.mouseScrollDelta.y;
StickerSystem.Instance.UpdateStickerPreview(); // flashy flash
}
if (!ModSettings.Entry_UsePlaceBinding.Value)
return;
if (!Input.GetKeyDown((KeyCode)ModSettings.Entry_PlaceBinding.Value))
return;
if (CVRInputManager.Instance.EventSystemOverwritten
|| KeyboardManager.Instance.IsViewShown) // BRUH
return; // prevent placing stickers while typing
StickerSystem.Instance.PlaceStickerFromControllerRay(PlayerSetup.Instance.activeCam.transform);
}
public override void OnApplicationQuit()
{
StickerSystem.Instance.CleanupAll();
}
#endregion Melon Mod Overrides
#region Melon Mod Utilities
private static void InitializeIntegration(string modName, Action integrationAction)
{
if (RegisteredMelons.All(it => it.Info.Name != modName))
return;
Logger.Msg($"Initializing {modName} integration.");
integrationAction.Invoke();
}
private void ApplyPatches(Type type)
{
try
{
HarmonyInstance.PatchAll(type);
}
catch (Exception e)
{
LoggerInstance.Msg($"Failed while patching {type.Name}!");
LoggerInstance.Error(e);
}
}
#endregion Melon Mod Utilities
#region Asset Bundle Loading
private const string DecaleryShaderAssets = "decalery_shaders.assets";
private const string DecalerySimpleShader = "Assets/Decalery/Shaders/DecalerySimple.shader";
private const string GPUDecalWriteShader = "Assets/Decalery/fGPUDecalWriteShader.shader";
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 LittleBigPlanetSFXStickerPlace;
internal static AudioClip FactorioSFXAlertDestroyed;
private void LoadAssetBundle()
{
LoggerInstance.Msg($"Loading required asset bundle...");
using Stream resourceStream = MelonAssembly.Assembly.GetManifestResourceStream(DecaleryShaderAssets);
using MemoryStream memoryStream = new();
if (resourceStream == null) {
LoggerInstance.Error($"Failed to load {DecaleryShaderAssets}!");
return;
}
resourceStream.CopyTo(memoryStream);
AssetBundle assetBundle = AssetBundle.LoadFromMemory(memoryStream.ToArray());
if (assetBundle == null) {
LoggerInstance.Error($"Failed to load {DecaleryShaderAssets}! Asset bundle is null!");
return;
}
DecalSimpleShader = assetBundle.LoadAsset<Shader>(DecalerySimpleShader);
if (DecalSimpleShader == null) {
LoggerInstance.Error($"Failed to load {DecalerySimpleShader}! Prefab is null!");
return;
}
DecalSimpleShader.hideFlags |= HideFlags.DontUnloadUnusedAsset;
LoggerInstance.Msg($"Loaded {DecalerySimpleShader}!");
DecalWriterShader = assetBundle.LoadAsset<Shader>(GPUDecalWriteShader);
if (DecalWriterShader == null) {
LoggerInstance.Error($"Failed to load {GPUDecalWriteShader}! Prefab is null!");
return;
}
DecalWriterShader.hideFlags |= HideFlags.DontUnloadUnusedAsset;
LoggerInstance.Msg($"Loaded {GPUDecalWriteShader}!");
SourceSFXPlayerSprayer = assetBundle.LoadAsset<AudioClip>(SourceSFX_PlayerSprayer);
if (SourceSFXPlayerSprayer == null) {
LoggerInstance.Error($"Failed to load {SourceSFX_PlayerSprayer}! Prefab is null!");
return;
}
SourceSFXPlayerSprayer.hideFlags |= HideFlags.DontUnloadUnusedAsset;
LoggerInstance.Msg($"Loaded {SourceSFX_PlayerSprayer}!");
LittleBigPlanetSFXStickerPlace = assetBundle.LoadAsset<AudioClip>(LittleBigPlanetSFX_StickerPlace);
if (LittleBigPlanetSFXStickerPlace == null) {
LoggerInstance.Error($"Failed to load {LittleBigPlanetSFX_StickerPlace}! Prefab is null!");
return;
}
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!");
}
#endregion Asset Bundle Loading
}

View file

@ -0,0 +1,110 @@
using MelonLoader;
using UnityEngine;
namespace NAK.Stickers;
public static class ModSettings
{
#region Constants & Category
internal const string ModName = nameof(StickerMod);
internal const string SM_SettingsCategory = "Stickers Mod";
private const string SM_SelectionCategory = "Sticker Selection";
private const string MISC_SettingsCategory = "Miscellaneous 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 =
Category.CreateEntry("hidden_foldout_config", true, is_hidden: true, display_name: SM_SettingsCategory, description: "Foldout state for Sticker Mod settings.");
internal static readonly MelonPreferences_Entry<bool> Hidden_Foldout_SelectionCategory =
Category.CreateEntry("hidden_foldout_selection", true, is_hidden: true, display_name: SM_SelectionCategory, description: "Foldout state for Sticker selection.");
internal static readonly MelonPreferences_Entry<bool> Hidden_Foldout_MiscCategory =
Category.CreateEntry("hidden_foldout_miscellaneous", false, is_hidden: true, display_name: MISC_SettingsCategory, description: "Foldout state for Miscellaneous settings.");
#endregion Hidden Foldout Entries
#region Stickers Mod Settings
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.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.");
internal static readonly MelonPreferences_Entry<KeyBind> Entry_PlaceBinding =
Category.CreateEntry("place_binding", KeyBind.G, "Sticker Bind", "The key binding to place stickers.");
internal static readonly MelonPreferences_Entry<TabDoubleClick> Entry_TabDoubleClick =
Category.CreateEntry("tab_double_click", TabDoubleClick.ToggleStickerMode, "Tab Double Click", "The action to perform when double clicking the Stickers tab.");
internal static readonly MelonPreferences_Entry<string[]> Hidden_SelectedStickerNames =
Category.CreateEntry("selected_sticker_name", new[] { "", "", "", "" },
display_name: "Selected Sticker Name",
description: "The name of the sticker selected for stickering.",
is_hidden: true);
internal static readonly MelonPreferences_Entry<bool> Entry_FriendsOnly =
Category.CreateEntry("friends_only", false, "Friends Only", "Only allow friends to use stickers.");
internal static readonly MelonPreferences_Entry<StickerSize> Entry_StickerSize =
Category.CreateEntry("sticker_size", StickerSize.Chonk, "Sticker Size", "The size of the sticker when placed.");
internal static readonly MelonPreferences_Entry<float> Entry_StickerOpacity =
Category.CreateEntry("opacity", 1f, "Opacity", "The opacity of the sticker when placed.");
#endregion Stickers Mod Settings
#region Debug Settings
internal static readonly MelonPreferences_Entry<bool> Debug_NetworkInbound =
Category.CreateEntry("debug_inbound", false, display_name: "Debug Inbound", description: "Log inbound Mod Network updates.");
internal static readonly MelonPreferences_Entry<bool> Debug_NetworkOutbound =
Category.CreateEntry("debug_outbound", false, display_name: "Debug Outbound", description: "Log outbound Mod Network updates.");
#endregion Debug Settings
#region Initialization
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);
// ensure theres no null entries so toml shuts the fuck up
for (int i = 0; i < selectedStickerNames.Length; i++)
selectedStickerNames[i] ??= "";
Hidden_SelectedStickerNames.Value = selectedStickerNames;
foreach (var selectedSticker in selectedStickerNames)
StickerMod.Logger.Msg($"Selected Sticker: {selectedSticker}");
Entry_PlayerUpAlignmentThreshold.OnEntryValueChanged.Subscribe(OnPlayerUpAlignmentThresholdChanged);
}
#endregion Initialization
#region Setting Changed Callbacks
private static void OnPlayerUpAlignmentThresholdChanged(float oldValue, float newValue)
{
Entry_PlayerUpAlignmentThreshold.Value = Mathf.Clamp(newValue, 0f, 180f);
}
#endregion Setting Changed Callbacks
}

View file

@ -0,0 +1,63 @@
using ABI_RC.Core;
using ABI_RC.Core.InteractionSystem;
using ABI_RC.Core.IO;
using ABI_RC.Core.Player;
using ABI_RC.Core.Savior;
using HarmonyLib;
using UnityEngine;
namespace NAK.Stickers.Patches;
internal static class PlayerSetup_Patches
{
[HarmonyPostfix]
[HarmonyPatch(typeof(PlayerSetup), nameof(PlayerSetup.GetCurrentPropSelectionMode))]
private static void Postfix_PlayerSetup_GetCurrentPropSelectionMode(ref PlayerSetup.PropSelectionMode __result)
{
if (StickerSystem.Instance.IsInStickerMode) __result = (PlayerSetup.PropSelectionMode)4;
}
}
internal static class ControllerRay_Patches
{
[HarmonyPrefix]
[HarmonyPatch(typeof(ControllerRay), nameof(ControllerRay.HandlePropSpawn))]
private static void Prefix_ControllerRay_HandlePropSpawn(ref ControllerRay __instance)
{
if (!StickerSystem.Instance.IsInStickerMode)
return;
StickerSystem.Instance.PlaceStickerFromControllerRay(__instance.rayDirectionTransform, __instance.hand, true); // preview
if (__instance._gripDown) StickerSystem.Instance.IsInStickerMode = false;
if (__instance._hitUIInternal || !__instance._interactDown)
return;
StickerSystem.Instance.PlaceStickerFromControllerRay(__instance.rayDirectionTransform, __instance.hand);
}
}
internal static class ShaderFilterHelper_Patches
{
[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);
}
}
internal static class CVRTools_Patches
{
[HarmonyPrefix]
[HarmonyPatch(typeof(CVRTools), nameof(CVRTools.ReplaceShaders), typeof(Material), typeof(string))]
private static bool Prefix_CVRTools_ReplaceShaders(Material material, string fallbackShaderName = "")
{
if (material == null || material.shader == null) return true;
return material.shader != StickerMod.DecalSimpleShader; // prevent replacing decals with fallback shader
}
}

View file

@ -0,0 +1,32 @@
using NAK.Stickers.Properties;
using MelonLoader;
using System.Reflection;
[assembly: AssemblyVersion(AssemblyInfoParams.Version)]
[assembly: AssemblyFileVersion(AssemblyInfoParams.Version)]
[assembly: AssemblyInformationalVersion(AssemblyInfoParams.Version)]
[assembly: AssemblyTitle(nameof(NAK.Stickers))]
[assembly: AssemblyCompany(AssemblyInfoParams.Author)]
[assembly: AssemblyProduct(nameof(NAK.Stickers))]
[assembly: MelonInfo(
typeof(NAK.Stickers.StickerMod),
nameof(NAK.Stickers),
AssemblyInfoParams.Version,
AssemblyInfoParams.Author,
downloadLink: "https://github.com/NotAKidoS/NAK_CVR_Mods/tree/main/Stickers"
)]
[assembly: MelonGame("ChilloutVR", "ChilloutVR")]
[assembly: MelonPlatform(MelonPlatformAttribute.CompatiblePlatforms.WINDOWS_X64)]
[assembly: MelonPlatformDomain(MelonPlatformDomainAttribute.CompatibleDomains.MONO)]
[assembly: MelonColor(255, 246, 25, 99)] // red-pink
[assembly: MelonAuthorColor(255, 158, 21, 32)] // red
[assembly: HarmonyDontPatchAll]
namespace NAK.Stickers.Properties;
internal static class AssemblyInfoParams
{
public const string Version = "1.1.1";
public const string Author = "NotAKidoS";
}

View file

@ -0,0 +1,49 @@
# Stickers
Stickers! Allows you to place small images on any surface. Requires both users to have the mod installed. 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.
### Please read the tooltips
- You have 4 "Sticker Slots".
- You double-click or hold a "Sticker Slot" to select an image from the `UserData/Stickers/` folder.
### 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.
- Target surface must have a renderer on the same GameObject as the collider.
- Terrain is not supported.
- 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.
- Only 512 max images per folder (otherwise Cohtml gets very upset).
- Requires the experimental Shader Safety Settings to be disabled as it will cause crashes when decals attempt to generate on GPU.
- The mod will automatically disable this setting when it is enabled on startup.
### Restrictions
- Full Restriction.
- To disable Stickers for the whole world, name an empty GameObject "**[DisableStickers]**".
- Partial Restriction.
- To keep stickers enabled but not allowing it on certain objects, add the "**[NoSticker]**" tag to the GameObject name.
## 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.
---
Here is the block of text where I tell you this mod is not affiliated with or endorsed by ChilloutVR.
https://docs.chilloutvr.net/official/legal/tos/#6-modding-our-game
> This mod is an independent creation not affiliated with, supported by, or approved by ChilloutVR.
> Use of this mod is done so at the user's own risk and the creator cannot be held responsible for any issues arising from its use.
> To the best of my knowledge, I have adhered to the Modding Guidelines established by ChilloutVR.

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

View file

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DefineConstants>TRACE;TRACE;UNITY_2017_1_OR_NEWER;UNITY_2018_1_OR_NEWER;UNITY_2019_3_OR_NEWER;USE_BURST;USE_NEWMATHS;USE_BURST_REALLY;USEDFORSTICKERMOD;</DefineConstants>
</PropertyGroup>
<ItemGroup>
<Folder Include="ThirdParty\" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Resources\decalery_shaders.assets">
<LogicalName>decalery_shaders.assets</LogicalName>
</EmbeddedResource>
<None Remove="Resources\Gohsantosadrive_Icons\Stickers-eraser.png" />
<EmbeddedResource Include="Resources\Gohsantosadrive_Icons\Stickers-eraser.png" />
<None Remove="Resources\Gohsantosadrive_Icons\Stickers-folder.png" />
<EmbeddedResource Include="Resources\Gohsantosadrive_Icons\Stickers-folder.png" />
<None Remove="Resources\Gohsantosadrive_Icons\Stickers-headset.png" />
<EmbeddedResource Include="Resources\Gohsantosadrive_Icons\Stickers-headset.png" />
<None Remove="Resources\Gohsantosadrive_Icons\Stickers-magic-wand.png" />
<EmbeddedResource Include="Resources\Gohsantosadrive_Icons\Stickers-magic-wand.png" />
<None Remove="Resources\Gohsantosadrive_Icons\Stickers-pencil.png" />
<None Remove="Resources\Gohsantosadrive_Icons\Stickers-puzzle.png" />
<EmbeddedResource Include="Resources\Gohsantosadrive_Icons\Stickers-puzzle.png" />
<None Remove="Resources\Gohsantosadrive_Icons\Stickers-rubbish-bin.png" />
<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" />
<None Remove="Resources\Gohsantosadrive_Icons\Stickers-mouse.png" />
<EmbeddedResource Include="Resources\Gohsantosadrive_Icons\Stickers-mouse.png" />
<None Remove="Resources\Gohsantosadrive_Icons\Stickers-puzzle-disabled.png" />
<EmbeddedResource Include="Resources\Gohsantosadrive_Icons\Stickers-puzzle-disabled.png" />
<None Remove="Resources\Gohsantosadrive_Icons\Stickers-magic-wand-broken.png" />
<EmbeddedResource Include="Resources\Gohsantosadrive_Icons\Stickers-magic-wand-broken.png" />
</ItemGroup>
<ItemGroup>
<Reference Include="BTKUILib">
<HintPath>..\.ManagedLibs\BTKUILib.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

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,34 @@
namespace NAK.Stickers;
public enum StickerSize
{
Jarret,
Bean,
Smol,
ChonkLite,
Chonk, // Default (was Medium)
HeckinChonk,
DoubleHeckinChonk,
TripleCursedUnit,
RealityTearingAbomination,
}
public static class StickerSizeExtensions
{
public static float GetSizeModifier(this StickerSize size)
{
return size switch
{
StickerSize.Jarret => 0.125f,
StickerSize.Bean => 0.2f,
StickerSize.Smol => 0.25f,
StickerSize.ChonkLite => 0.5f,
StickerSize.Chonk => 1f,
StickerSize.HeckinChonk => 2f,
StickerSize.DoubleHeckinChonk => 4f,
StickerSize.TripleCursedUnit => 8f,
StickerSize.RealityTearingAbomination => 16f,
_ => 0.125f,
};
}
}

View file

@ -0,0 +1,9 @@
namespace NAK.Stickers;
internal enum TabDoubleClick
{
ToggleStickerMode,
ClearAllStickers,
ClearSelfStickers,
None
}

View file

@ -0,0 +1,15 @@
namespace NAK.Stickers.Networking;
public static partial class ModNetwork
{
#region Constants
internal const int MaxTextureSize = 1024 * 256; // 256KB
internal const string NetworkVersion = "1.0.3"; // change each time network protocol changes
private const string ModId = $"MelonMod.NAK.Stickers_v{NetworkVersion}";
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,260 @@
using ABI_RC.Core.Networking.IO.Social;
using ABI_RC.Core.Player;
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 Reset Method
public static void Reset()
{
_textureChunkBuffers.Clear();
_receivedChunkCounts.Clear();
_expectedChunkCounts.Clear();
_textureMetadata.Clear();
LoggerInbound("ModNetwork inbound buffers and metadata have been reset.");
}
#endregion Reset Method
#region Inbound Methods
private static bool ShouldReceiveFromSender(string sender)
{
if (_disallowedForSession.Contains(sender))
return false; // ignore messages from disallowed users
if (MetaPort.Instance.blockedUserIds.Contains(sender))
return false; // ignore messages from blocked users
if (ModSettings.Entry_FriendsOnly.Value && !Friends.FriendsWith(sender))
return false; // ignore messages from non-friends if friends only is enabled
if (StickerSystem.Instance.IsRestrictedInstance) // ignore messages from users when the world is restricted. This also includes older or modified version of Stickers mod.
return false;
if (!CVRPlayerManager.Instance.GetPlayerPuppetMaster(sender, out PuppetMaster _))
return false; // ignore messages from players that don't exist
return true;
}
private static void HandleMessageReceived(ModNetworkMessage msg)
{
try
{
string sender = msg.Sender;
msg.Read(out byte msgTypeRaw);
if (!Enum.IsDefined(typeof(MessageType), msgTypeRaw))
return;
if (!ShouldReceiveFromSender(sender))
return;
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;
}
}
catch (Exception e)
{
LoggerInbound($"Error handling message from {msg.Sender}: {e.Message}", true);
}
}
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);
msg.Read(out int size);
msg.Read(out float opacity);
if (!StickerSystem.Instance.HasTextureHash(msg.Sender, textureHash))
{
SendRequestTexture(stickerSlot, textureHash);
StickerSystem.Instance.ClearStickersForPlayer(msg.Sender, stickerSlot); // Ensure no exploit
}
StickerSystem.Instance.OnStickerPlaceReceived(msg.Sender, stickerSlot, position, forward, up, (StickerSize)size, opacity);
}
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;
}
_textureMetadata[sender] = (stickerSlot, textureHash, width, height);
_textureChunkBuffers[sender] = new byte[Mathf.Clamp(chunkCount * ChunkSize, 0, MaxTextureSize)];
_expectedChunkCounts[sender] = Mathf.Clamp(chunkCount, 0, MaxChunkCount);
_receivedChunkCounts[sender] = 0;
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,77 @@
using ABI_RC.Systems.ModNetwork;
namespace NAK.Stickers.Networking;
public static partial class ModNetwork
{
#region Mod Network Internals
// private static bool _isEnabled = true;
//
// public static bool IsEnabled
// {
// get => _isEnabled;
// set
// {
// if (_isEnabled == value)
// return;
//
// _isEnabled = value;
//
// if (_isEnabled) Subscribe();
// else Unsubscribe();
//
// Reset(); // reset buffers and metadata
// }
// }
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.");
else StickerMod.Logger.Msg("Subscribed to Mod Network.");
}
private static void Unsubscribe()
{
ModNetworkManager.Unsubscribe(ModId);
_isSubscribedToModNetwork = ModNetworkManager.IsSubscribed(ModId);
if (_isSubscribedToModNetwork) StickerMod.Logger.Error("Failed to unsubscribe from Mod Network! This should not happen.");
else StickerMod.Logger.Msg("Unsubscribed from Mod Network.");
}
#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,218 @@
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, StickerSize size, float opacity)
{
if (!_isSubscribedToModNetwork)
return;
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.Write((int)size);
modMsg.Write(opacity);
modMsg.Send();
LoggerOutbound($"PlaceSticker: Slot: {stickerSlot}, Hash: {_textureStorage[stickerSlot].textureHash}, Position: {position}, Forward: {forward}, Up: {up}");
}
public static void SendClearSticker(int stickerSlot)
{
if (!_isSubscribedToModNetwork)
return;
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 (!_isSubscribedToModNetwork)
return;
if (!IsConnectedToGameNetwork())
return;
using ModNetworkMessage modMsg = new(ModId);
modMsg.Write((byte)MessageType.ClearAllStickers);
modMsg.Send();
LoggerOutbound("ClearAllStickers");
}
private static void SendStartTexture(int stickerSlot, Guid textureHash, int chunkCount, int width, int height)
{
if (!_isSubscribedToModNetwork)
return;
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 (!_isSubscribedToModNetwork)
return;
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 (!_isSubscribedToModNetwork)
return;
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 (!_isSubscribedToModNetwork)
return;
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 (!_isSubscribedToModNetwork)
return;
if (!IsConnectedToGameNetwork() || IsSendingTexture)
return;
IsSendingTexture = true;
Task.Run(() =>
{
try
{
Thread.CurrentThread.IsBackground = false; // working around bug in MTJobManager
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);
Thread.CurrentThread.IsBackground = true; // working around bug in MTJobManager
}
});
}
#endregion Outbound Methods
}

View file

@ -0,0 +1,293 @@
using ABI_RC.Core;
using UnityEngine;
using Object = UnityEngine.Object;
namespace NAK.Stickers
{
public class StickerData
{
private const float DECAL_SIZE = 0.25f;
public readonly string PlayerId;
public float LastPlacedTime;
public float DeathTime = -1f;
public float IdentifyTime = -1f;
private Vector3 _lastPlacedPosition = Vector3.zero;
private readonly DecalSpawner[] _decalSpawners;
private readonly Guid[] _textureHashes;
private readonly Material[] _materials;
private readonly AudioSource _audioSource;
public StickerData(string playerId, int decalSpawnersCount)
{
PlayerId = playerId;
_decalSpawners = new DecalSpawner[decalSpawnersCount];
_materials = new Material[decalSpawnersCount];
_textureHashes = new Guid[decalSpawnersCount];
for (int i = 0; i < decalSpawnersCount; i++)
{
_materials[i] = new Material(StickerMod.DecalSimpleShader);
DecalSpawner.InitData decalSettings = new()
{
material = _materials[i],
useShaderReplacement = false,
inheritMaterialProperties = false,
inheritMaterialPropertyBlock = false,
};
_decalSpawners[i] = DecalManager.GetSpawner(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
// this is a hack so judge and fuck off lol
if (PlayerId == StickerSystem.PlayerLocalId)
{
Object.DontDestroyOnLoad(_audioSource.gameObject); // keep audio source through world transitions
_previewMaterial = new Material(StickerMod.DecalSimpleShader);
_previewDecalSpawner = DecalManager.GetSpawner(new DecalSpawner.InitData
{
material = _previewMaterial, // default material
useShaderReplacement = false,
inheritMaterialProperties = false,
inheritMaterialPropertyBlock = false,
}, 4096, 1024);
}
}
// 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
Material material = _materials[spawnerIndex];
// destroy the previous texture to avoid memory leaks
if (material.mainTexture != null) Object.Destroy(material.mainTexture);
material.mainTexture = texture;
// update the preview as well i guess cause lame
if (_previewMaterial != null && _previewSpawnerIndex != spawnerIndex)
_previewMaterial.mainTexture = texture;
}
public void Place(RaycastHit hit, Vector3 forwardDirection, Vector3 upDirection, int spawnerIndex = 0, StickerSize size = StickerSize.Chonk, float opacity = 1f)
{
if (spawnerIndex < 0 || spawnerIndex >= _decalSpawners.Length)
{
StickerMod.Logger.Warning("Invalid spawner index!");
return;
}
// for performance it is best to let them batch, but we also want movable objects to always
// be decaled instead of floating in-scene (so avatars/props are always considered movable)
Transform rootObject = null;
GameObject hitGO = hit.transform.gameObject;
if (hitGO.scene.buildIndex == 4 // additive (dynamic) content
|| hitGO.GetComponentInParent<Animator>() != null // potentially movable
|| hitGO.GetComponentInParent<Rigidbody>() != null) // movable
rootObject = hitGO.transform;
_lastPlacedPosition = hit.point;
LastPlacedTime = Time.time;
float sizeScale = size.GetSizeModifier();
// Add decal to the specified spawner
_decalSpawners[spawnerIndex].AddDecal(
_lastPlacedPosition, Quaternion.LookRotation(forwardDirection, upDirection),
hitGO,
DECAL_SIZE * sizeScale, DECAL_SIZE * sizeScale, 1f, opacity, 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] == null) continue;
if (_materials[i].mainTexture != null) Object.Destroy(_materials[i].mainTexture);
Object.Destroy(_materials[i]);
}
if (_audioSource != null) Object.Destroy(_audioSource.gameObject);
if (_previewDecalSpawner != null) // local player only
{
_previewDecalSpawner.Release();
_previewDecalSpawner.staticGroups.Clear();
_previewDecalSpawner.movableGroups.Clear();
Object.Destroy(_previewMaterial);
}
}
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)
{
if (material == null) continue;
Color color = material.color;
color.a = alpha;
material.color = color;
}
}
#region Sticker Preview
private const float FLASH_FREQUENCY = 2f;
private readonly DecalSpawner _previewDecalSpawner;
private readonly Material _previewMaterial;
private int _previewSpawnerIndex = -1;
private float _flashTime;
public void PlacePreview(RaycastHit hit, Vector3 forwardDirection, Vector3 upDirection, int spawnerIndex = 0, StickerSize size = StickerSize.Chonk)
{
if (spawnerIndex < 0 || spawnerIndex >= _decalSpawners.Length)
{
StickerMod.Logger.Warning("Invalid spawner index for preview!");
return;
}
if (_previewDecalSpawner == null)
return; // uh fuck
// place at hit pos
Transform rootObject = null;
GameObject hitGO = hit.transform.gameObject;
if (hitGO.scene.buildIndex == 4 // additive (dynamic) content
|| hitGO.GetComponentInParent<Animator>() != null // potentially movable
|| hitGO.GetComponentInParent<Rigidbody>() != null) // movable
rootObject = hitGO.transform;
float sizeScale = size.GetSizeModifier();
_previewDecalSpawner.AddDecal(
hit.point, Quaternion.LookRotation(forwardDirection, upDirection),
hitGO,
DECAL_SIZE * sizeScale, DECAL_SIZE * sizeScale, 1f, 1f, 0f, rootObject);
}
public void UpdatePreview(int spawnerIndex)
{
if (_previewSpawnerIndex != spawnerIndex)
{
_previewSpawnerIndex = spawnerIndex; // update the preview image
_previewMaterial.mainTexture = _materials[spawnerIndex].mainTexture;
}
_flashTime += Time.deltaTime;
float baseAlpha = (Mathf.Sin(_flashTime * 2f * Mathf.PI / FLASH_FREQUENCY) + 1f) / 2f;
float alpha = Mathf.Lerp(0.5f, 0.8f, baseAlpha); // 50% to 80% alpha
Color color = _previewMaterial.color;
color.a = alpha;
_previewMaterial.color = color;
}
public void ClearPreview()
{
if (_previewDecalSpawner == null)
return;
_previewDecalSpawner.Release();
_previewDecalSpawner.staticGroups.Clear();
_previewDecalSpawner.movableGroups.Clear();
}
#endregion Sticker Preview
}
}

View file

@ -0,0 +1,159 @@
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
{
Thread.CurrentThread.IsBackground = false; // working around bug in MTJobManager
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;
Thread.CurrentThread.IsBackground = true; // working around bug in MTJobManager
}
});
}
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;
}
public static void EnsureStickersFolderExists()
{
if (!Directory.Exists(s_StickersFolderPath)) Directory.CreateDirectory(s_StickersFolderPath);
}
#endregion Image Loading
}

View file

@ -0,0 +1,140 @@
using ABI_RC.Core.IO;
using ABI_RC.Core.Networking.IO.Instancing;
using ABI_RC.Core.UI;
using ABI_RC.Systems.GameEventSystem;
using NAK.Stickers.Networking;
using NAK.Stickers.Utilities;
using ABI.CCK.Components;
using NAK.Stickers.Integrations;
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
EnsureStickersFolderExists();
// listen for game events
CVRGameEventSystem.Initialization.OnPlayerSetupStart.AddListener(Instance.OnPlayerSetupStart);
}
#endregion Singleton
#region Callback Registration
private void OnPlayerSetupStart()
{
// TODO: this can be spammed by world author toggling CVRWorld.enabled state
CVRGameEventSystem.World.OnLoad.AddListener(_ => OnWorldLoad());
CVRGameEventSystem.World.OnUnload.AddListener(_ => OnWorldUnload());
CVRGameEventSystem.Instance.OnConnected.AddListener((_) => { if (!Instances.IsReconnecting) OnInitialConnection(); });
CVRGameEventSystem.Player.OnJoinEntity.AddListener(Instance.OnPlayerJoined);
CVRGameEventSystem.Player.OnLeaveEntity.AddListener(Instance.OnPlayerLeft);
BetterScheduleSystem.AddJob(Instance.OnUpdate, 10f, -1);
LoadAllImagesAtStartup();
}
#endregion Callback Registration
#region Game Events
private void OnInitialConnection()
{
ClearStickersSelf(); // clear stickers on remotes just in case we rejoined
ModNetwork.Reset(); // reset network buffers and metadata
}
private void OnWorldLoad()
{
CVRDataStore worldDS = CVRWorld.Instance.DataStore;
// IsRestrictedInstance = worldDS && worldDS.GetValue<bool>("StickersMod-ForceDisable");
if (IsRestrictedInstance) StickerMod.Logger.Msg("Stickers are restricted by the world author.");
BTKUIAddon.OnStickerRestrictionUpdated(IsRestrictedInstance);
}
private void OnWorldUnload()
{
IsRestrictedInstance = false;
CleanupAllButSelf();
}
#endregion Game Events
#region Data
// private bool _isEnabled = true;
//
// public bool IsEnabled
// {
// get => _isEnabled;
// set
// {
// if (_isEnabled == value)
// return;
//
// _isEnabled = value;
// if (!_isEnabled) ClearAllStickers();
// ModNetwork.IsEnabled = _isEnabled;
// }
// }
public bool IsRestrictedInstance { get; internal set; }
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();
internal const string PlayerLocalId = "_PLAYERLOCAL";
private int _selectedStickerSlot;
public int SelectedStickerSlot
{
get => _selectedStickerSlot;
set
{
_selectedStickerSlot = value < 0 ? ModSettings.MaxStickerSlots - 1 : value % ModSettings.MaxStickerSlots;
IsInStickerMode = IsInStickerMode; // refresh sticker mode
}
}
private bool _isInStickerMode;
public bool IsInStickerMode
{
get => _isInStickerMode;
set
{
_isInStickerMode = value && !IsRestrictedInstance; // ensure cannot enter when restricted
if (_isInStickerMode)
{
CohtmlHud.Instance.SelectPropToSpawn(
StickerCache.GetCohtmlResourcesPath(SelectedStickerName),
Path.GetFileNameWithoutExtension(SelectedStickerName),
"Sticker selected for stickering:");
}
else
{
CohtmlHud.Instance.ClearPropToSpawn();
ClearStickerPreview();
}
}
}
#endregion Data
}

View file

@ -0,0 +1,85 @@
using ABI_RC.Core.Player;
using UnityEngine;
namespace NAK.Stickers;
public partial class StickerSystem
{
#region Player Callbacks
private void OnPlayerJoined(CVRPlayerEntity playerEntity)
{
if (!_playerStickers.TryGetValue(playerEntity.Uuid, out StickerData stickerData))
return;
stickerData.DeathTime = -1f;
stickerData.SetAlpha(1f);
}
private void OnPlayerLeft(CVRPlayerEntity playerEntity)
{
if (!_playerStickers.TryGetValue(playerEntity.Uuid, out StickerData stickerData))
return;
stickerData.DeathTime = Time.time + StickerKillTime;
stickerData.SetAlpha(1f);
}
private void OnUpdate()
{
float currentTime = Time.time;
for (int i = 0; i < _playerStickers.Values.Count; i++)
{
StickerData stickerData = _playerStickers.Values.ElementAt(i);
if (stickerData.DeathTime > 0f)
{
if (currentTime < stickerData.DeathTime)
{
stickerData.SetAlpha(Mathf.Lerp(0f, 1f, (stickerData.DeathTime - currentTime) / StickerKillTime));
continue;
}
stickerData.Cleanup();
_playerStickers.Remove(stickerData.PlayerId);
continue;
}
if (stickerData.IdentifyTime > 0)
{
if (currentTime < stickerData.IdentifyTime)
{
// blink alpha 3 times but not completely off
stickerData.SetAlpha(Mathf.Lerp(0.2f, 1f, Mathf.PingPong((stickerData.IdentifyTime - currentTime) * 2f, 1f)));
continue;
}
stickerData.SetAlpha(1f);
stickerData.IdentifyTime = -1;
}
}
}
#endregion Player Callbacks
#region Sticker Callbacks
public void OnStickerPlaceReceived(string playerId, int stickerSlot, Vector3 position, Vector3 forward, Vector3 up, StickerSize size, float opacity)
=> AttemptPlaceSticker(playerId, size, opacity, 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;
stickerData.IdentifyTime = Time.time + 3f;
}
#endregion Sticker Callbacks
}

View file

@ -0,0 +1,195 @@
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, ModSettings.MaxStickerSlots);
_playerStickers[playerId] = stickerData;
return stickerData;
}
public void PlaceStickerFromControllerRay(Transform transform, CVRHand hand = CVRHand.Left, bool isPreview = false)
{
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 (isPreview)
{
PlaceStickerPreview(transform.position, controllerForward, targetUp);
return;
}
if (!PlaceStickerSelf(transform.position, transform.forward, targetUp))
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, ModSettings.Entry_StickerSize.Value, ModSettings.Entry_StickerOpacity.Value, position, forward, up, alignWithNormal, SelectedStickerSlot))
return false; // failed
// placed, now network
ModNetwork.SendPlaceSticker(SelectedStickerSlot, position, forward, up, ModSettings.Entry_StickerSize.Value, ModSettings.Entry_StickerOpacity.Value);
return true;
}
private bool AttemptPlaceSticker(string playerId, StickerSize size, float opacity, Vector3 position, Vector3 forward, Vector3 up, bool alignWithNormal = true, int stickerSlot = 0, bool isPreview = false)
{
// if the world contained a gameobject with the [DisableStickers] name and restricted the instance disable stickers!
if (IsRestrictedInstance)
return false;
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;
// if gameobject name starts with [NoSticker] then don't place sticker
if (hit.transform.gameObject.name.StartsWith("[NoSticker]"))
return false;
if (isPreview)
{
stickerData.PlacePreview(hit, alignWithNormal ? -hit.normal : forward, up, stickerSlot, size);
return true;
}
stickerData.Place(hit, alignWithNormal ? -hit.normal : forward, up, stickerSlot, size, opacity);
stickerData.PlayAudio();
return true;
}
public void ClearStickersSelf()
{
ClearStickersForPlayer(PlayerLocalId);
ModNetwork.SendClearAllStickers();
}
public void ClearStickerSelf(int stickerSlot)
{
ClearStickersForPlayer(PlayerLocalId, stickerSlot);
ModNetwork.SendClearSticker(stickerSlot);
}
private void ClearStickersForPlayer(string playerId)
{
if (!_playerStickers.TryGetValue(playerId, out StickerData stickerData))
return;
stickerData.Clear();
}
public 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
ClearStickerSelf(stickerSlot); // clear placed stickers in-scene so we can't replace an entire wall at once
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 == localStickerData) data.Clear();
else data.Cleanup();
}
_playerStickers.Clear();
_playerStickers[PlayerLocalId] = localStickerData;
}
public void PlaceStickerPreview(Vector3 position, Vector3 forward, Vector3 up)
{
AttemptPlaceSticker(PlayerLocalId, ModSettings.Entry_StickerSize.Value, ModSettings.Entry_StickerOpacity.Value, position, forward, up, true, SelectedStickerSlot, true);
}
public void UpdateStickerPreview()
{
if (!IsInStickerMode) return;
StickerData localStickerData = GetOrCreateStickerData(PlayerLocalId);
localStickerData.ClearPreview(); // clear prior frames sticker preview
localStickerData.UpdatePreview(SelectedStickerSlot);
}
public void ClearStickerPreview()
{
StickerData localStickerData = GetOrCreateStickerData(PlayerLocalId);
localStickerData.ClearPreview();
}
#endregion Sticker Lifecycle
}

View file

@ -0,0 +1,171 @@
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.Security.Cryptography;
namespace NAK.Stickers.Utilities;
// i do not know shit about images
// TODO: optimize Image usage, attempt create, reuse when valid
public static class ImageUtility
{
public static bool IsValidImage(byte[] image)
{
try
{
using MemoryStream stream = new(image);
using Image img = Image.FromStream(stream);
return img.Width > 0 && img.Height > 0;
}
catch (Exception e)
{
StickerMod.Logger.Error($"[ImageUtility] Failed to validate image: {e}");
return false;
}
}
public static void Resize(ref byte[] image, int width, int height)
{
MemoryStream stream = new(image);
Resize(ref stream, width, height);
image = stream.ToArray();
stream.Dispose();
}
public static void Resize(ref MemoryStream stream, int width, int height)
{
using Image source = Image.FromStream(stream);
using Bitmap bitmap = new(width, height);
Rectangle destRect = new(0, 0, width, height);
using (Graphics graphics = Graphics.FromImage(bitmap))
{
graphics.CompositingMode = CompositingMode.SourceCopy;
graphics.CompositingQuality = CompositingQuality.HighQuality;
graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
graphics.SmoothingMode = SmoothingMode.HighQuality;
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
using (ImageAttributes wrapMode = new())
{
wrapMode.SetWrapMode(WrapMode.TileFlipXY);
graphics.DrawImage(source, destRect, 0, 0, source.Width, source.Height, GraphicsUnit.Pixel, wrapMode);
}
}
stream.Dispose();
stream = new MemoryStream();
SaveBitmapPreservingFormat(bitmap, stream);
}
private static void SaveBitmapPreservingFormat(Bitmap bitmap, Stream stream)
{
bool hasTransparency = ImageHasTransparency(bitmap);
if (!hasTransparency)
{
ImageCodecInfo jpegEncoder = ImageCodecInfo.GetImageEncoders().First(x => x.FormatID == ImageFormat.Jpeg.Guid);
EncoderParameters jpegEncoderParameters = new(1)
{
Param = new[]
{
new EncoderParameter(Encoder.Quality, 80L) // basically, fuck you, get smaller
}
};
bitmap.Save(stream, jpegEncoder, jpegEncoderParameters);
}
else
{
ImageCodecInfo pngEncoder = ImageCodecInfo.GetImageEncoders().First(x => x.FormatID == ImageFormat.Png.Guid);
EncoderParameters pngEncoderParameters = new(1)
{
Param = new[]
{
//new EncoderParameter(Encoder.Quality, 50L),
new EncoderParameter(Encoder.Compression, (long)EncoderValue.CompressionLZW), // PNG compression
new EncoderParameter(Encoder.ColorDepth, bitmap.PixelFormat == PixelFormat.Format32bppArgb ? 32L : 24L),
new EncoderParameter(Encoder.ScanMethod, (long)EncoderValue.ScanMethodInterlaced),
}
};
bitmap.Save(stream, pngEncoder, pngEncoderParameters);
}
}
private static bool ImageHasTransparency(Bitmap bitmap)
{
for (int y = 0; y < bitmap.Height; y++)
for (int x = 0; x < bitmap.Width; x++)
if (bitmap.GetPixel(x, y).A < 255)
return true;
return false; // no transparency found
}
public static bool IsPowerOfTwo(byte[] image)
{
using MemoryStream stream = new(image);
using Image source = Image.FromStream(stream);
return IsPowerOfTwo(source.Width) && IsPowerOfTwo(source.Height);
}
private static bool IsPowerOfTwo(int value)
{
return (value > 0) && (value & (value - 1)) == 0;
}
public static int NearestPowerOfTwo(int value)
{
return (int)Math.Pow(2, Math.Ceiling(Math.Log(value, 2)));
}
public static int FlooredPowerOfTwo(int value)
{
return (int)Math.Pow(2, Math.Floor(Math.Log(value, 2)));
}
public static bool ResizeToNearestPowerOfTwo(ref byte[] image)
{
using MemoryStream stream = new(image);
using Image source = Image.FromStream(stream);
if (IsPowerOfTwo(source.Width) && IsPowerOfTwo(source.Height))
return false; // already power of two
int newWidth = NearestPowerOfTwo(source.Width);
int newHeight = NearestPowerOfTwo(source.Height);
Resize(ref image, newWidth, newHeight);
return true;
}
// making the assumption that the above could potentially put an image over my filesize limit
public static bool ResizeToFlooredPowerOfTwo(byte[] image)
{
using MemoryStream stream = new(image);
using Image source = Image.FromStream(stream);
if (IsPowerOfTwo(source.Width) && IsPowerOfTwo(source.Height))
return false; // already power of two
int newWidth = FlooredPowerOfTwo(source.Width);
int newHeight = FlooredPowerOfTwo(source.Height);
Resize(ref image, newWidth, newHeight);
return true;
}
public static (Guid hashGuid, int width, int height) ExtractImageInfo(byte[] image)
{
using MemoryStream stream = new(image);
using Image img = Image.FromStream(stream);
int width = img.Width;
int height = img.Height;
stream.Position = 0;
using SHA256 sha256 = SHA256.Create();
byte[] hashBytes = sha256.ComputeHash(stream);
Guid hashGuid = new(hashBytes.Take(16).ToArray());
return (hashGuid, width, height);
}
}

View file

@ -0,0 +1,192 @@
using BTKUILib.UIObjects.Components;
using NAK.Stickers.Integrations;
using System.Collections.Concurrent;
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));
StartGeneratingThumbnailsIfNeeded();
}
}
public static void ClearCache()
{
if (!Directory.Exists(ThumbnailPath)) return;
Directory.Delete(ThumbnailPath, true);
StickerMod.Logger.Msg("Cleared thumbnail cache.");
}
#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
{
Thread.CurrentThread.IsBackground = false; // working around bug in MTJobManager
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;
Thread.CurrentThread.IsBackground = true; // working around bug in MTJobManager
}
});
}
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());
//StickerMod.Logger.Msg("Prepared icon: " + iconPath);
}
}
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
}

View file

@ -0,0 +1,23 @@
{
"_id": 232,
"name": "Stickers",
"modversion": "1.1.1",
"gameversion": "2025r181",
"loaderversion": "0.7.2",
"modtype": "Mod",
"author": "NotAKidoS",
"description": "Stickers! Allows you to place small images on any surface. Requires both users to have the mod installed. Synced over Mod Network.\n\nLimitations:\n- Image should be under 256KB in size.\n- Image dimensions should be a power of 2 (e.g. 512x512, 1024x1024).\n - If the image exceeds the size limit or is not a power of 2 the mod will automatically resize it.\n - 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.\n\n-# More information can be found on the [README](https://github.com/NotAKidoS/NAK_CVR_Mods/blob/main/Stickers/README.md).",
"searchtags": [
"stickers",
"images",
"decals",
"sprites"
],
"requirements": [
"None"
],
"downloadlink": "https://github.com/NotAKidoS/NAK_CVR_Mods/releases/download/r48/Stickers.dll",
"sourcelink": "https://github.com/NotAKidoS/NAK_CVR_Mods/tree/main/Stickers/",
"changelog": "- Rebuilt for CVR 2025r181\n- Reworked disabling system",
"embedcolor": "#f61963"
}