This commit is contained in:
NotAKidoS 2024-08-22 20:53:37 -05:00
parent 61f1a884d2
commit 475593bc1d
25 changed files with 2172 additions and 0 deletions

View file

@ -0,0 +1,80 @@
using ABI_RC.Core.Player;
using BTKUILib;
using BTKUILib.UIObjects;
namespace NAK.Stickers.Integrations;
public static partial class BtkUiAddon
{
private static Page _rootPage;
private static string _rootPageElementID;
private static bool _isOurTabOpened;
private static Action _onOurTabOpened;
public static void Initialize()
{
Prepare_Icons();
Setup_AvatarScaleModTab();
//Setup_PlayerSelectPage();
}
#region Initialization
private static void Prepare_Icons()
{
// All icons used - https://www.flaticon.com/authors/gohsantosadrive
QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-alphabet", GetIconStream("Gohsantosadrive_Icons.Stickers-alphabet.png"));
QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-eraser", GetIconStream("Gohsantosadrive_Icons.Stickers-eraser.png"));
QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-folder", GetIconStream("Gohsantosadrive_Icons.Stickers-folder.png"));
QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-headset", GetIconStream("Gohsantosadrive_Icons.Stickers-headset.png"));
QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-magic-wand", GetIconStream("Gohsantosadrive_Icons.Stickers-magic-wand.png"));
QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-pencil", GetIconStream("Gohsantosadrive_Icons.Stickers-pencil.png"));
QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-puzzle", GetIconStream("Gohsantosadrive_Icons.Stickers-puzzle.png"));
QuickMenuAPI.PrepareIcon(ModSettings.ModName, "Stickers-rubbish-bin", GetIconStream("Gohsantosadrive_Icons.Stickers-rubbish-bin.png"));
}
private static void Setup_AvatarScaleModTab()
{
_rootPage = new Page(ModSettings.ModName, ModSettings.SM_SettingsCategory, true, "Stickers-puzzle")
{
MenuTitle = ModSettings.SM_SettingsCategory,
MenuSubtitle = "Stickers! Double-click the tab to quickly toggle Sticker Mode.",
};
_rootPageElementID = _rootPage.ElementID;
QuickMenuAPI.OnTabChange += OnTabChange;
// QuickMenuAPI.UserJoin += OnUserJoinLeave;
// QuickMenuAPI.UserLeave += OnUserJoinLeave;
// QuickMenuAPI.OnWorldLeave += OnWorldLeave;
Setup_StickersModCategory(_rootPage);
Setup_StickerSelectionCategory(_rootPage);
Setup_DebugOptionsCategory(_rootPage);
}
#endregion
#region Double-Click Place Sticker
private static DateTime lastTime = DateTime.Now;
private static void OnTabChange(string newTab, string previousTab)
{
_isOurTabOpened = newTab == _rootPageElementID;
if (_isOurTabOpened)
{
_onOurTabOpened?.Invoke();
TimeSpan timeDifference = DateTime.Now - lastTime;
if (timeDifference.TotalSeconds <= 0.5)
{
//AvatarScaleManager.Instance.Setting_UniversalScaling = false;
StickerSystem.Instance.IsInStickerMode = !StickerSystem.Instance.IsInStickerMode;
return;
}
}
lastTime = DateTime.Now;
}
#endregion Double-Click Place Sticker
}

View file

@ -0,0 +1,15 @@
using BTKUILib.UIObjects;
namespace NAK.Stickers.Integrations
{
public static partial class BtkUiAddon
{
private static void Setup_DebugOptionsCategory(Page page)
{
Category debugCategory = AddMelonCategory(ref page, ModSettings.Hidden_Foldout_DebugCategory);
AddMelonToggle(ref debugCategory, ModSettings.Debug_NetworkInbound);
AddMelonToggle(ref debugCategory, ModSettings.Debug_NetworkOutbound);
}
}
}

View file

@ -0,0 +1,356 @@
using BTKUILib;
using BTKUILib.UIObjects;
using BTKUILib.UIObjects.Components;
using MTJobSystem;
using NAK.Stickers.Networking;
using NAK.Stickers.Utilities;
namespace NAK.Stickers.Integrations
{
public static partial class BtkUiAddon
{
private static readonly object _cacheLock = new();
private static bool _initialClearCacheFolder;
private static Category _stickerSelectionCategory;
internal static readonly Dictionary<string, ImageInfo> _cachedImages = new();
public static string GetBtkUiCachePath(string stickerName)
{
if (_cachedImages.TryGetValue(stickerName, out ImageInfo imageInfo))
return "coui://uiresources/GameUI/mods/BTKUI/images/" + ModSettings.ModName + "/UserImages/" + imageInfo.cacheName + ".png";
return string.Empty;
}
internal class ImageInfo
{
public string filePath;
public string cacheName; // BTKUI cache path
public bool wasFoundThisPass;
public bool hasChanged;
public DateTime lastModified;
public Button button;
}
#region Setup
private static void Setup_StickerSelectionCategory(Page page)
{
_onOurTabOpened += UpdateStickerSelectionAsync;
_stickerSelectionCategory = AddMelonCategory(ref page, ModSettings.Hidden_Foldout_SelectionCategory);
ModNetwork.OnTextureOutboundStateChanged += OnSendingTextureOverNetworkChanged; // disable buttons when sending texture over network
StickerSystem.OnImageLoadFailed += OnLoadImageFailed;
GetInitialImageInfo();
}
private static void GetInitialImageInfo()
{
Task.Run(() =>
{
try
{
string path = StickerSystem.GetStickersFolderPath();
if (!Directory.Exists(path))
{
StickerMod.Logger.Warning("Stickers folder not found.");
return;
}
var stickerFiles = Directory.EnumerateFiles(path, "*.png");
var currentFiles = new HashSet<string>(stickerFiles);
lock (_cacheLock)
{
if (!_initialClearCacheFolder)
{
_initialClearCacheFolder = true;
DeleteOldIcons(ModSettings.ModName);
}
var keysToRemove = new List<string>();
foreach (var stickerFile in currentFiles)
{
string stickerName = Path.GetFileNameWithoutExtension(stickerFile);
if (_cachedImages.TryGetValue(stickerName, out ImageInfo imageInfo))
{
imageInfo.wasFoundThisPass = true;
DateTime lastModified = File.GetLastWriteTime(stickerFile);
if (lastModified == imageInfo.lastModified) continue;
imageInfo.hasChanged = true;
imageInfo.lastModified = lastModified;
}
else
{
_cachedImages[stickerName] = new ImageInfo
{
filePath = stickerFile,
wasFoundThisPass = true,
hasChanged = true,
lastModified = File.GetLastWriteTime(stickerFile)
};
}
}
foreach (var kvp in _cachedImages)
{
var imageName = kvp.Key;
ImageInfo imageInfo = kvp.Value;
if (!imageInfo.wasFoundThisPass)
{
MainThreadInvoke(() =>
{
if (imageInfo.button != null)
{
imageInfo.button.Delete();
imageInfo.button = null;
}
});
if (!string.IsNullOrEmpty(imageInfo.cacheName))
{
DeleteOldIcon(ModSettings.ModName, imageInfo.cacheName);
imageInfo.cacheName = string.Empty;
}
keysToRemove.Add(imageName);
}
else if (imageInfo.hasChanged)
{
imageInfo.hasChanged = false;
if (!string.IsNullOrEmpty(imageInfo.cacheName))
{
DeleteOldIcon(ModSettings.ModName, imageInfo.cacheName);
imageInfo.cacheName = string.Empty;
}
MemoryStream imageStream = LoadStreamFromFile(imageInfo.filePath);
if (imageStream == null) continue;
try
{
if (imageStream.Length > 256 * 1024)
ImageUtility.Resize(ref imageStream, 256, 256);
}
catch (Exception e)
{
StickerMod.Logger.Warning($"Failed to resize image: {e.Message}");
}
imageInfo.cacheName = $"{imageName}_{Guid.NewGuid()}";
PrepareIconFromMemoryStream(ModSettings.ModName, imageInfo.cacheName, imageStream);
}
imageInfo.wasFoundThisPass = false;
}
foreach (var key in keysToRemove)
_cachedImages.Remove(key);
}
}
catch (Exception e)
{
StickerMod.Logger.Error($"Failed to update sticker selection: {e.Message}");
}
});
}
private static void UpdateStickerSelectionAsync()
{
Task.Run(() =>
{
try
{
string path = StickerSystem.GetStickersFolderPath();
if (!Directory.Exists(path))
{
StickerMod.Logger.Warning("Stickers folder not found.");
return;
}
var stickerFiles = Directory.EnumerateFiles(path, "*.png");
var currentFiles = new HashSet<string>(stickerFiles);
lock (_cacheLock)
{
if (!_initialClearCacheFolder)
{
_initialClearCacheFolder = true;
DeleteOldIcons(ModSettings.ModName);
}
var keysToRemove = new List<string>();
foreach (var stickerFile in currentFiles)
{
string stickerName = Path.GetFileNameWithoutExtension(stickerFile);
if (_cachedImages.TryGetValue(stickerName, out ImageInfo imageInfo))
{
imageInfo.wasFoundThisPass = true;
if (imageInfo.button == null)
{
imageInfo.hasChanged = true;
continue;
}
DateTime lastModified = File.GetLastWriteTime(stickerFile);
if (lastModified == imageInfo.lastModified) continue;
imageInfo.hasChanged = true;
imageInfo.lastModified = lastModified;
}
else
{
_cachedImages[stickerName] = new ImageInfo
{
filePath = stickerFile,
wasFoundThisPass = true,
hasChanged = true,
lastModified = File.GetLastWriteTime(stickerFile)
};
}
}
foreach (var kvp in _cachedImages)
{
var imageName = kvp.Key;
ImageInfo imageInfo = kvp.Value;
if (!imageInfo.wasFoundThisPass)
{
MainThreadInvoke(() =>
{
if (imageInfo.button != null)
{
imageInfo.button.Delete();
imageInfo.button = null;
}
});
if (!string.IsNullOrEmpty(imageInfo.cacheName))
{
DeleteOldIcon(ModSettings.ModName, imageInfo.cacheName);
imageInfo.cacheName = string.Empty;
}
keysToRemove.Add(imageName);
}
else if (imageInfo.hasChanged)
{
imageInfo.hasChanged = false;
if (!string.IsNullOrEmpty(imageInfo.cacheName))
{
DeleteOldIcon(ModSettings.ModName, imageInfo.cacheName);
imageInfo.cacheName = string.Empty;
}
MemoryStream imageStream = LoadStreamFromFile(imageInfo.filePath);
if (imageStream == null) continue;
try
{
if (imageStream.Length > 256 * 1024)
ImageUtility.Resize(ref imageStream, 256, 256);
}
catch (Exception e)
{
StickerMod.Logger.Warning($"Failed to resize image: {e.Message}");
}
imageInfo.cacheName = $"{imageName}_{Guid.NewGuid()}";
PrepareIconFromMemoryStream(ModSettings.ModName, imageInfo.cacheName, imageStream);
MainThreadInvoke(() =>
{
if (imageInfo.button != null)
imageInfo.button.ButtonIcon = "UserImages/" + imageInfo.cacheName;
else
{
imageInfo.button = _stickerSelectionCategory.AddButton(imageName, "UserImages/" + imageInfo.cacheName, $"Select {imageName}.", ButtonStyle.TextWithIcon);
imageInfo.button.OnPress += () => OnStickerButtonClick(imageInfo);
}
});
}
imageInfo.wasFoundThisPass = false;
}
foreach (var key in keysToRemove)
_cachedImages.Remove(key);
MainThreadInvoke(() =>
{
_stickerSelectionCategory.CategoryName = $"{ModSettings.SM_SelectionCategory} ({_cachedImages.Count})";
});
}
}
catch (Exception e)
{
StickerMod.Logger.Error($"Failed to update sticker selection: {e.Message}");
}
});
}
private static MemoryStream LoadStreamFromFile(string filePath)
{
if (!File.Exists(filePath))
return null;
try
{
//StickerMod.Logger.Msg($"Loaded sticker stream from {filePath}");
using FileStream fileStream = new(filePath, FileMode.Open, FileAccess.Read);
MemoryStream memoryStream = new();
fileStream.CopyTo(memoryStream);
memoryStream.Position = 0; // Ensure the position is reset before returning
return memoryStream;
}
catch (Exception e)
{
StickerMod.Logger.Warning($"Failed to load stream from {filePath}: {e.Message}");
return null;
}
}
private static void MainThreadInvoke(Action action)
=> MTJobManager.RunOnMainThread("fuck", action.Invoke);
#endregion Setup
#region Button Actions
private static void OnStickerButtonClick(ImageInfo imageInfo)
{
// i wish i could highlight it
StickerSystem.Instance.LoadImage(imageInfo.button.ButtonText);
}
#endregion Button Actions
#region Callbacks
private static void OnSendingTextureOverNetworkChanged(bool isSending)
{
MTJobManager.RunAsyncOnMainThread("fuck2", () =>
{
// go through all buttons and disable them
if (_isOurTabOpened && isSending) QuickMenuAPI.ShowAlertToast("Sending Sticker over Mod Network...", 2);
foreach ((_, ImageInfo value) in _cachedImages) value.button.Disabled = isSending;
});
}
private static void OnLoadImageFailed(string reason)
{
if (!_isOurTabOpened) return;
QuickMenuAPI.ShowAlertToast(reason, 2);
}
#endregion Callbacks
}
}

View file

@ -0,0 +1,105 @@
using BTKUILib;
using BTKUILib.UIObjects;
using BTKUILib.UIObjects.Components;
using BTKUILib.UIObjects.Objects;
using UnityEngine;
namespace NAK.Stickers.Integrations;
public static partial class BtkUiAddon
{
private static Category _ourCategory;
private static readonly MultiSelection _sfxSelection =
new(
"Sticker SFX",
new[] { "Little Big Planet", "Source Engine", "None" },
(int)ModSettings.Entry_SelectedSFX.Value
)
{
OnOptionUpdated = i => ModSettings.Entry_SelectedSFX.Value = (ModSettings.SFXType)i
};
private static readonly MultiSelection _desktopKeybindSelection =
new(
"Desktop Keybind",
Enum.GetNames(typeof(KeyCode)),
(int)ModSettings.Entry_PlaceBinding.Value
)
{
OnOptionUpdated = i =>
{
if (Enum.GetValues(typeof(KeyCode)) is string[] options) // inefficient but works
ModSettings.Entry_PlaceBinding.Value = (KeyCode)Enum.Parse(typeof(KeyCode), options[i]);
}
};
#region Category Setup
private static void Setup_StickersModCategory(Page page)
{
//_ourCategory = page.AddCategory(ModSettings.Stickers_SettingsCategory, ModSettings.ModName, true, true, false);
_ourCategory = AddMelonCategory(ref page, ModSettings.Hidden_Foldout_SettingsCategory);
Button 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 openMultiSelectionButton = _ourCategory.AddButton("Sticker SFX", "Stickers-headset", "Choose the SFX used when a sticker is placed.", ButtonStyle.TextWithIcon);
openMultiSelectionButton.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;
};
//AddMelonToggle(ref _ourCategory, ModSettings.Entry_UsePlaceBinding);
}
#endregion Category Setup
#region Button Actions
private static void OnPlaceStickersButtonClick()
{
if (!_isOurTabOpened) 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();
}
#endregion Button Actions
}

View file

@ -0,0 +1,104 @@
using System.Reflection;
using System.Security.Cryptography;
using BTKUILib;
using BTKUILib.UIObjects;
using BTKUILib.UIObjects.Components;
using MelonLoader;
using UnityEngine;
namespace NAK.Stickers.Integrations;
public static partial class BtkUiAddon
{
#region Melon Preference Helpers
private static ToggleButton AddMelonToggle(ref Category category, MelonPreferences_Entry<bool> entry)
{
ToggleButton toggle = category.AddToggle(entry.DisplayName, entry.Description, entry.Value);
toggle.OnValueUpdated += b => entry.Value = b;
return toggle;
}
private static SliderFloat AddMelonSlider(ref Category category, MelonPreferences_Entry<float> entry, float min,
float max, int decimalPlaces = 2, bool allowReset = true)
{
SliderFloat slider = category.AddSlider(entry.DisplayName, entry.Description,
Mathf.Clamp(entry.Value, min, max), min, max, decimalPlaces, entry.DefaultValue, allowReset);
slider.OnValueUpdated += f => entry.Value = f;
return slider;
}
private static Button AddMelonStringInput(ref Category category, MelonPreferences_Entry<string> entry, string buttonIcon = "", ButtonStyle buttonStyle = ButtonStyle.TextOnly)
{
Button button = category.AddButton(entry.DisplayName, buttonIcon, entry.Description, buttonStyle);
button.OnPress += () => QuickMenuAPI.OpenKeyboard(entry.Value, s => entry.Value = s);
return button;
}
private static Button AddMelonNumberInput(ref Category category, MelonPreferences_Entry<float> entry, string buttonIcon = "", ButtonStyle buttonStyle = ButtonStyle.TextOnly)
{
Button button = category.AddButton(entry.DisplayName, buttonIcon, entry.Description, buttonStyle);
button.OnPress += () => QuickMenuAPI.OpenNumberInput(entry.DisplayName, entry.Value, f => entry.Value = f);
return button;
}
private static Category AddMelonCategory(ref Page page, MelonPreferences_Entry<bool> entry, bool showHeader = true)
{
Category category = page.AddCategory(entry.DisplayName, showHeader, true, entry.Value);
category.OnCollapse += b => entry.Value = b;
return category;
}
#endregion Melon Preference Helpers
#region Icon Utils
private static Stream GetIconStream(string iconName)
{
Assembly assembly = Assembly.GetExecutingAssembly();
string assemblyName = assembly.GetName().Name;
return assembly.GetManifestResourceStream($"{assemblyName}.Resources.{iconName}");
}
#endregion Icon Utils
public static void PrepareIconFromMemoryStream(string modName, string iconName, MemoryStream destination)
{
if (destination == null)
{
StickerMod.Logger.Error("Mod " + modName + " attempted to prepare " + iconName + " but the resource stream was null! Yell at the mod author to fix this!");
}
else
{
modName = UIUtils.GetCleanString(modName);
string path1 = @"ChilloutVR_Data\StreamingAssets\Cohtml\UIResources\GameUI\mods\BTKUI\images\" + modName + @"\UserImages";
if (!Directory.Exists(path1)) Directory.CreateDirectory(path1);
string path2 = path1 + "\\" + iconName + ".png";
File.WriteAllBytes(path2, destination.ToArray());
}
}
private static void DeleteOldIcon(string modName, string iconName)
{
string directoryPath = Path.Combine("ChilloutVR_Data", "StreamingAssets", "Cohtml", "UIResources", "GameUI", "mods", "BTKUI", "images", modName, "UserImages");
string oldIconPath = Path.Combine(directoryPath, $"{iconName}.png");
if (!File.Exists(oldIconPath))
return;
File.Delete(oldIconPath);
//StickerMod.Logger.Msg($"Deleted old icon: {oldIconPath}");
}
private static void DeleteOldIcons(string modName)
{
string directoryPath = Path.Combine("ChilloutVR_Data", "StreamingAssets", "Cohtml", "UIResources", "GameUI", "mods", "BTKUI", "images", modName, "UserImages");
if (!Directory.Exists(directoryPath))
return;
foreach (string file in Directory.EnumerateFiles(directoryPath, "*.png"))
{
File.Delete(file);
//StickerMod.Logger.Msg($"Deleted old icon: {file}");
}
}
}

144
Stickers/Main.cs Normal file
View file

@ -0,0 +1,144 @@
using ABI_RC.Core.Player;
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.PlayerSetupPatches));
ApplyPatches(typeof(Patches.ControllerRayPatches));
LoadAssetBundle();
InitializeIntegration(nameof(BTKUILib), BtkUiAddon.Initialize); // quick menu ui
}
public override void OnUpdate()
{
if (!ModSettings.Entry_UsePlaceBinding.Value)
return;
if (!Input.GetKeyDown(ModSettings.Entry_PlaceBinding.Value))
return;
StickerSystem.Instance.PlaceStickerFromTransform(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";
internal static Shader DecalSimpleShader;
internal static Shader DecalWriterShader;
internal static AudioClip SourceSFXPlayerSprayer;
internal static AudioClip LittleBigPlanetStickerPlace;
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}!");
LittleBigPlanetStickerPlace = assetBundle.LoadAsset<AudioClip>(LittleBigPlanetSFX_StickerPlace);
if (LittleBigPlanetStickerPlace == null) {
LoggerInstance.Error($"Failed to load {LittleBigPlanetSFX_StickerPlace}! Prefab is null!");
return;
}
LittleBigPlanetStickerPlace.hideFlags |= HideFlags.DontUnloadUnusedAsset;
LoggerInstance.Msg($"Loaded {LittleBigPlanetSFX_StickerPlace}!");
// load
LoggerInstance.Msg("Asset bundle successfully loaded!");
}
#endregion Asset Bundle Loading
}

105
Stickers/ModSettings.cs Normal file
View file

@ -0,0 +1,105 @@
using MelonLoader;
using UnityEngine;
namespace NAK.Stickers;
public static class ModSettings
{
internal const string ModName = nameof(StickerMod);
internal const string SM_SettingsCategory = "Stickers Mod";
internal const string SM_SelectionCategory = "Sticker Selection";
private const string DEBUG_SettingsCategory = "Debug Options";
private static readonly MelonPreferences_Category Category =
MelonPreferences.CreateCategory(ModName);
#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_DebugCategory =
Category.CreateEntry("hidden_foldout_debug", false, is_hidden: true, display_name: DEBUG_SettingsCategory, description: "Foldout state for Debug 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.LBP, "Selected SFX", "The SFX used when a sticker is placed.");
internal enum SFXType
{
LBP,
Source,
None
}
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<KeyCode> Entry_PlaceBinding =
Category.CreateEntry("place_binding", KeyCode.G, "Sticker Bind", "The key binding to place stickers.");
internal static readonly MelonPreferences_Entry<string> Hidden_SelectedStickerName =
Category.CreateEntry("hidden_selected_sticker", string.Empty, is_hidden: true, display_name: "Selected Sticker", description: "The currently selected sticker name.");
#endregion Stickers Mod Settings
#region Decalery Settings
internal static readonly MelonPreferences_Entry<DecaleryMode> Decalery_DecalMode =
Category.CreateEntry("decalery_decal_mode", DecaleryMode.GPU, display_name: "Decal Mode", description: "The mode Decalery should use for decal creation. By default GPU should be used. **Note:** Not all content is marked as readable, so only the GPU modes are expected to work properly on UGC.");
internal enum DecaleryMode
{
CPU,
GPU,
CPUBurst,
GPUIndirect
}
#endregion Decalery Settings
#region Debug Settings
internal static readonly MelonPreferences_Entry<bool> Debug_NetworkInbound =
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()
{
Entry_PlayerUpAlignmentThreshold.OnEntryValueChanged.Subscribe(OnPlayerUpAlignmentThresholdChanged);
Decalery_DecalMode.OnEntryValueChanged.Subscribe(OnDecaleryDecalModeChanged);
}
#endregion Initialization
#region Setting Changed Callbacks
private static void OnPlayerUpAlignmentThresholdChanged(float oldValue, float newValue)
{
Entry_PlayerUpAlignmentThreshold.Value = Mathf.Clamp(newValue, 0f, 180f);
}
private static void OnDecaleryDecalModeChanged(DecaleryMode oldValue, DecaleryMode newValue)
{
DecalManager.SetPreferredMode((DecalUtils.Mode)newValue, newValue == DecaleryMode.GPUIndirect, 0);
if (newValue != DecaleryMode.GPU) StickerMod.Logger.Warning("Decalery is not set to GPU mode. Expect compatibility issues with user generated content when mesh data is not marked as readable.");
StickerSystem.Instance.CleanupAll();
}
#endregion Setting Changed Callbacks
}

33
Stickers/Patches.cs Normal file
View file

@ -0,0 +1,33 @@
using ABI_RC.Core.InteractionSystem;
using ABI_RC.Core.Player;
using HarmonyLib;
using UnityEngine;
namespace NAK.Stickers.Patches;
internal static class PlayerSetupPatches
{
[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 ControllerRayPatches
{
[HarmonyPrefix]
[HarmonyPatch(typeof(ControllerRay), nameof(ControllerRay.HandlePropSpawn))]
private static void Prefix_ControllerRay_HandlePropSpawn(ref ControllerRay __instance)
{
if (!StickerSystem.Instance.IsInStickerMode)
return;
if (__instance._gripDown) StickerSystem.Instance.IsInStickerMode = false;
if (__instance._hitUIInternal || !__instance._interactDown)
return;
StickerSystem.Instance.PlaceStickerFromTransform(__instance.rayDirectionTransform);
}
}

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("Alpha Blend Interactive", "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.0.0";
public const string Author = "Exterrata & NotAKidoS";
}

14
Stickers/README.md Normal file
View file

@ -0,0 +1,14 @@
# Stickers
Stickers!
---
Here is the block of text where I tell you this mod is not affiliated with or endorsed by ABI.
https://documentation.abinteractive.net/official/legal/tos/#7-modding-our-games
> This mod is an independent creation not affiliated with, supported by, or approved by Alpha Blend Interactive.
> 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 Alpha Blend Interactive.

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: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 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.

38
Stickers/Stickers.csproj Normal file
View file

@ -0,0 +1,38 @@
<?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;</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" />
<EmbeddedResource Include="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" />
</ItemGroup>
<ItemGroup>
<Reference Include="BTKUILib">
<HintPath>..\.ManagedLibs\BTKUILib.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View file

@ -0,0 +1,414 @@
using ABI_RC.Core.Networking;
using ABI_RC.Systems.ModNetwork;
using DarkRift;
using NAK.Stickers.Properties;
using NAK.Stickers.Utilities;
using UnityEngine;
namespace NAK.Stickers.Networking
{
public static class ModNetwork
{
#region Configuration
private static bool Debug_NetworkInbound => ModSettings.Debug_NetworkInbound.Value;
private static bool Debug_NetworkOutbound => ModSettings.Debug_NetworkOutbound.Value;
#endregion Configuration
#region Constants
internal const int MaxTextureSize = 1024 * 256; // 256KB
private const string ModId = $"MelonMod.NAK.Stickers_v{AssemblyInfoParams.Version}";
private const int ChunkSize = 1024; // roughly 1KB per ModNetworkMessage
private const int MaxChunkCount = MaxTextureSize / ChunkSize;
#endregion Constants
#region Enums
private enum MessageType : byte
{
PlaceSticker = 0,
ClearStickers = 1,
StartTexture = 2,
SendTexture = 3,
EndTexture = 4,
RequestTexture = 5
}
#endregion Enums
#region Mod Network Internals
internal static Action<bool> OnTextureOutboundStateChanged;
internal static bool IsSendingTexture { get; private set; }
private static bool _isSubscribedToModNetwork;
private static byte[] _ourTextureData;
private static (Guid hashGuid, int Width, int Height) _ourTextureMetadata;
private static readonly Dictionary<string, byte[]> _textureChunkBuffers = new();
private static readonly Dictionary<string, int> _receivedChunkCounts = new();
private static readonly Dictionary<string, int> _expectedChunkCounts = new();
private static readonly Dictionary<string, (Guid Hash, int Width, int Height)> _textureMetadata = new();
internal static void Subscribe()
{
ModNetworkManager.Subscribe(ModId, OnMessageReceived);
_isSubscribedToModNetwork = ModNetworkManager.IsSubscribed(ModId);
if (!_isSubscribedToModNetwork)
StickerMod.Logger.Error("Failed to subscribe to Mod Network!");
}
public static void PlaceSticker(Vector3 position, Vector3 forward, Vector3 up)
{
if (!_isSubscribedToModNetwork)
return;
SendStickerPlace(_ourTextureMetadata.hashGuid, position, forward, up);
}
public static void ClearStickers()
{
if (!_isSubscribedToModNetwork)
return;
SendMessage(MessageType.ClearStickers);
}
public static bool SetTexture(byte[] imageBytes)
{
if (imageBytes == null
|| imageBytes.Length == 0
|| imageBytes.Length > MaxTextureSize)
return false;
(Guid hashGuid, int width, int height) = ImageUtility.ExtractImageInfo(imageBytes);
if (_ourTextureMetadata.hashGuid == hashGuid)
{
StickerMod.Logger.Msg($"[ModNetwork] Texture data is the same as the current texture: {hashGuid}");
return false;
}
_ourTextureData = imageBytes;
_ourTextureMetadata = (hashGuid, width, height);
StickerMod.Logger.Msg($"[ModNetwork] Set texture metadata for networking: {hashGuid} ({width}x{height})");
return true;
}
public static void SendTexture()
{
if (!IsConnectedToGameNetwork())
return;
if (!_isSubscribedToModNetwork || IsSendingTexture)
return;
if (_ourTextureData == null
|| _ourTextureMetadata.hashGuid == Guid.Empty)
return; // no texture to send
IsSendingTexture = true;
// Send each chunk of the texture data
Task.Run(() =>
{
try
{
if (Debug_NetworkOutbound)
StickerMod.Logger.Msg("[ModNetwork] Sending texture to network");
var textureData = _ourTextureData;
(Guid hash, int Width, int Height) textureMetadata = _ourTextureMetadata;
int totalChunks = Mathf.CeilToInt(textureData.Length / (float)ChunkSize);
if (totalChunks > MaxChunkCount)
{
StickerMod.Logger.Error($"[ModNetwork] Texture data too large to send: {textureData.Length} bytes, {totalChunks} chunks");
return;
}
if (Debug_NetworkOutbound)
StickerMod.Logger.Msg($"[ModNetwork] Texture data length: {textureData.Length}, total chunks: {totalChunks}, width: {textureMetadata.Width}, height: {textureMetadata.Height}");
SendStartTexture(textureMetadata.hash, totalChunks, textureMetadata.Width, textureMetadata.Height);
for (int i = 0; i < textureData.Length; i += ChunkSize)
{
int size = Mathf.Min(ChunkSize, textureData.Length - i);
byte[] chunk = new byte[size];
Array.Copy(textureData, i, chunk, 0, size);
SendTextureChunk(chunk, i / ChunkSize);
Thread.Sleep(5);
}
}
catch (Exception e)
{
if (Debug_NetworkOutbound) StickerMod.Logger.Error($"[ModNetwork] Failed to send texture to network: {e}");
}
finally
{
IsSendingTexture = false;
SendMessage(MessageType.EndTexture);
}
});
}
private static void SendStickerPlace(Guid textureHash, Vector3 position, Vector3 forward, Vector3 up)
{
if (!IsConnectedToGameNetwork())
return;
using ModNetworkMessage modMsg = new(ModId);
modMsg.Write((byte)MessageType.PlaceSticker);
modMsg.Write(textureHash);
modMsg.Write(position);
modMsg.Write(forward);
modMsg.Write(up);
modMsg.Send();
if (Debug_NetworkOutbound) StickerMod.Logger.Msg($"[Outbound] PlaceSticker: Hash: {textureHash}, Position: {position}, Forward: {forward}, Up: {up}");
}
private static void SendStartTexture(Guid hash, int chunkCount, int width, int height)
{
OnTextureOutboundStateChanged?.Invoke(true);
if (!IsConnectedToGameNetwork())
return;
using ModNetworkMessage modMsg = new(ModId);
modMsg.Write((byte)MessageType.StartTexture);
modMsg.Write(hash);
modMsg.Write(chunkCount);
modMsg.Write(width);
modMsg.Write(height);
modMsg.Send();
if (Debug_NetworkOutbound) StickerMod.Logger.Msg($"[Outbound] StartTexture sent with {chunkCount} chunks, width: {width}, height: {height}");
}
private static void SendTextureChunk(byte[] chunk, int chunkIdx)
{
if (!IsConnectedToGameNetwork())
return;
using ModNetworkMessage modMsg = new(ModId);
modMsg.Write((byte)MessageType.SendTexture);
modMsg.Write(chunkIdx);
modMsg.Write(chunk);
modMsg.Send();
if (Debug_NetworkOutbound) StickerMod.Logger.Msg($"[Outbound] Sent texture chunk {chunkIdx + 1}");
}
private static void SendMessage(MessageType messageType)
{
OnTextureOutboundStateChanged?.Invoke(false);
if (!IsConnectedToGameNetwork())
return;
using ModNetworkMessage modMsg = new(ModId);
modMsg.Write((byte)messageType);
modMsg.Send();
if (Debug_NetworkOutbound) StickerMod.Logger.Msg($"[Outbound] MessageType: {messageType}");
}
private static void SendTextureRequest(string sender)
{
if (!IsConnectedToGameNetwork())
return;
using ModNetworkMessage modMsg = new(ModId);
modMsg.Write((byte)MessageType.RequestTexture);
modMsg.Write(sender);
modMsg.Send();
if (Debug_NetworkOutbound) StickerMod.Logger.Msg($"[Outbound] RequestTexture sent to {sender}");
}
private static void OnMessageReceived(ModNetworkMessage msg)
{
msg.Read(out byte msgTypeRaw);
if (!Enum.IsDefined(typeof(MessageType), msgTypeRaw))
return;
if (Debug_NetworkInbound)
StickerMod.Logger.Msg($"[Inbound] Sender: {msg.Sender}, MessageType: {(MessageType)msgTypeRaw}");
switch ((MessageType)msgTypeRaw)
{
case MessageType.PlaceSticker:
msg.Read(out Guid hash);
msg.Read(out Vector3 receivedPosition);
msg.Read(out Vector3 receivedForward);
msg.Read(out Vector3 receivedUp);
OnStickerPlaceReceived(msg.Sender, hash, receivedPosition, receivedForward, receivedUp);
break;
case MessageType.ClearStickers:
OnStickersClearReceived(msg.Sender);
break;
case MessageType.StartTexture:
msg.Read(out Guid startHash);
msg.Read(out int chunkCount);
msg.Read(out int width);
msg.Read(out int height);
OnStartTextureReceived(msg.Sender, startHash, chunkCount, width, height);
break;
case MessageType.SendTexture:
msg.Read(out int chunkIdx);
msg.Read(out byte[] textureChunk);
OnTextureChunkReceived(msg.Sender, textureChunk, chunkIdx);
break;
case MessageType.EndTexture:
OnEndTextureReceived(msg.Sender);
break;
case MessageType.RequestTexture:
OnTextureRequestReceived(msg.Sender);
break;
default:
if (Debug_NetworkInbound) StickerMod.Logger.Error($"[ModNetwork] Invalid message type received from: {msg.Sender}");
break;
}
}
#endregion Mod Network Internals
#region Private Methods
private static bool IsConnectedToGameNetwork()
{
return NetworkManager.Instance != null
&& NetworkManager.Instance.GameNetwork != null
&& NetworkManager.Instance.GameNetwork.ConnectionState == ConnectionState.Connected;
}
private static void OnStickerPlaceReceived(string sender, Guid textureHash, Vector3 position, Vector3 forward, Vector3 up)
{
Guid localHash = StickerSystem.Instance.GetPlayerStickerTextureHash(sender);
if (localHash != textureHash && textureHash != Guid.Empty) SendTextureRequest(sender);
StickerSystem.Instance.OnPlayerStickerPlace(sender, position, forward, up);
}
private static void OnStickersClearReceived(string sender)
{
StickerSystem.Instance.OnPlayerStickersClear(sender);
}
private static void OnStartTextureReceived(string sender, Guid hash, int chunkCount, int width, int height)
{
if (_textureChunkBuffers.ContainsKey(sender))
{
if (Debug_NetworkInbound) StickerMod.Logger.Warning($"[Inbound] Received StartTexture message from {sender} while still receiving texture data!");
return;
}
Guid oldHash = StickerSystem.Instance.GetPlayerStickerTextureHash(sender);
if (oldHash == hash)
{
if (Debug_NetworkInbound)
StickerMod.Logger.Msg($"[Inbound] Received StartTexture message from {sender} with existing texture hash {hash}, skipping texture data.");
return;
}
if (chunkCount > MaxChunkCount)
{
StickerMod.Logger.Error($"[Inbound] Received StartTexture message from {sender} with too many chunks: {chunkCount}");
return;
}
_textureChunkBuffers[sender] = new byte[Mathf.Clamp(chunkCount * ChunkSize, 0, MaxTextureSize)];
_receivedChunkCounts[sender] = 0;
_expectedChunkCounts[sender] = chunkCount;
_textureMetadata[sender] = (hash, width, height);
if (Debug_NetworkInbound)
StickerMod.Logger.Msg($"[Inbound] StartTexture received from {sender} with hash: {hash} chunk count: {chunkCount}, width: {width}, height: {height}");
}
private static void OnTextureChunkReceived(string sender, byte[] chunk, int chunkIdx)
{
if (!_textureChunkBuffers.TryGetValue(sender, out var buffer))
return;
int startIndex = chunkIdx * ChunkSize;
Array.Copy(chunk, 0, buffer, startIndex, chunk.Length);
_receivedChunkCounts[sender]++;
if (_receivedChunkCounts[sender] < _expectedChunkCounts[sender])
return;
(Guid Hash, int Width, int Height) metadata = _textureMetadata[sender];
// All chunks received, reassemble texture
_textureChunkBuffers.Remove(sender);
_receivedChunkCounts.Remove(sender);
_expectedChunkCounts.Remove(sender);
_textureMetadata.Remove(sender);
// Validate image
if (!ImageUtility.IsValidImage(buffer))
{
StickerMod.Logger.Error($"[Inbound] Received texture data is not a valid image from {sender}!");
return;
}
// Validate data TODO: fix hash???????
(Guid imageHash, int width, int height) = ImageUtility.ExtractImageInfo(buffer);
if (metadata.Width != width
|| metadata.Height != height)
{
StickerMod.Logger.Error($"[Inbound] Received texture data does not match metadata! Expected: {metadata.Hash} ({metadata.Width}x{metadata.Height}), received: {imageHash} ({width}x{height})");
return;
}
Texture2D texture = new(1,1);
texture.LoadImage(buffer);
texture.Compress(true);
StickerSystem.Instance.OnPlayerStickerTextureReceived(sender, metadata.Hash, texture);
if (Debug_NetworkInbound) StickerMod.Logger.Msg($"[Inbound] All chunks received and texture reassembled from {sender}. " +
$"Texture size: {metadata.Width}x{metadata.Height}");
}
private static void OnEndTextureReceived(string sender)
{
if (!_textureChunkBuffers.ContainsKey(sender))
return;
if (Debug_NetworkInbound) StickerMod.Logger.Error($"[Inbound] Received EndTexture message without all chunks received from {sender}! Only {_receivedChunkCounts[sender]} out of {_expectedChunkCounts[sender]} received.");
_textureChunkBuffers.Remove(sender);
_receivedChunkCounts.Remove(sender);
_expectedChunkCounts.Remove(sender);
_textureMetadata.Remove(sender);
}
private static void OnTextureRequestReceived(string sender)
{
if (!_isSubscribedToModNetwork || IsSendingTexture)
return;
if (_ourTextureData != null && _ourTextureMetadata.hashGuid != Guid.Empty)
SendTexture();
else
StickerMod.Logger.Warning($"[Inbound] Received texture request from {sender}, but no texture is set!");
}
#endregion Private Methods
}
}

View file

@ -0,0 +1,132 @@
using ABI_RC.Core;
using UnityEngine;
using Object = UnityEngine.Object;
namespace NAK.Stickers;
public class StickerData
{
private const float DECAL_SIZE = 0.25f;
public float DeathTime; // when a remote player leaves, we need to kill their stickers
public Guid TextureHash;
public float LastPlacedTime;
private Vector3 _lastPlacedPosition = Vector3.zero;
public readonly bool IsLocal;
private readonly DecalType _decal;
private readonly DecalSpawner _spawner;
private readonly AudioSource _audioSource;
public StickerData(bool isLocal)
{
IsLocal = isLocal;
_decal = ScriptableObject.CreateInstance<DecalType>();
_decal.decalSettings = new DecalSpawner.InitData
{
material = new Material(StickerMod.DecalSimpleShader)
{
//color = new Color(Random.value, Random.value, Random.value, 1f),
},
useShaderReplacement = false,
inheritMaterialProperties = false,
inheritMaterialPropertyBlock = false,
};
_spawner = DecalManager.GetSpawner(_decal.decalSettings, 4096, 1024);
_audioSource = new GameObject("StickerAudioSource").AddComponent<AudioSource>();
_audioSource.spatialBlend = 1f;
_audioSource.volume = 0.5f;
_audioSource.playOnAwake = false;
_audioSource.loop = false;
_audioSource.rolloffMode = AudioRolloffMode.Logarithmic;
_audioSource.maxDistance = 5f;
_audioSource.minDistance = 1f;
_audioSource.outputAudioMixerGroup = RootLogic.Instance.propSfx; // props are close enough to stickers
if (isLocal) Object.DontDestroyOnLoad(_audioSource.gameObject); // keep audio source through world transitions
}
public void SetTexture(Guid textureHash, Texture2D texture)
{
if (texture == null) StickerMod.Logger.Warning("Assigning null texture to StickerData!");
TextureHash = textureHash;
texture.wrapMode = TextureWrapMode.Clamp; // noachi said to do, prevents white edges
texture.filterMode = texture.width > 64 || texture.height > 64
? FilterMode.Bilinear // smear it cause its fat
: FilterMode.Point; // my minecraft skin looked shit
if (IsLocal) StickerMod.Logger.Msg($"Set texture filter mode to: {texture.filterMode}");
Material material = _decal.decalSettings.material;
// TODO: fix
if (material.mainTexture != null) Object.Destroy(material.mainTexture);
material.mainTexture = texture;
}
public void Place(RaycastHit hit, Vector3 forwardDirection, Vector3 upDirection)
{
Transform rootObject = null;
if (hit.rigidbody != null) rootObject = hit.rigidbody.transform;
_lastPlacedPosition = hit.point;
LastPlacedTime = Time.time;
// todo: add decal to queue
_spawner.AddDecal(
_lastPlacedPosition, Quaternion.LookRotation(forwardDirection, upDirection),
hit.collider.gameObject,
DECAL_SIZE, DECAL_SIZE, 1f, 1f, 0f, rootObject);
}
public void Clear()
{
// BUG?: Release does not clear dictionary's, clearing them myself so we can hit same objects again
// maybe it was intended to recycle groups- but rn no work like that
_spawner.Release();
_spawner.staticGroups.Clear();
_spawner.movableGroups.Clear();
}
public void Cleanup()
{
Clear();
// no leaking textures or mats
Material material = _decal.decalSettings.material;
Object.Destroy(material.mainTexture);
Object.Destroy(material);
Object.Destroy(_decal);
}
public void PlayAudio()
{
_audioSource.transform.position = _lastPlacedPosition;
switch (ModSettings.Entry_SelectedSFX.Value)
{
case ModSettings.SFXType.Source:
_audioSource.PlayOneShot(StickerMod.SourceSFXPlayerSprayer);
break;
case ModSettings.SFXType.LBP:
_audioSource.PlayOneShot(StickerMod.LittleBigPlanetStickerPlace);
break;
case ModSettings.SFXType.None:
break;
default:
throw new ArgumentOutOfRangeException();
}
}
public void SetAlpha(float alpha)
{
Material material = _decal.decalSettings.material;
material.color = new Color(material.color.r, material.color.g, material.color.b, alpha);
}
}

View file

@ -0,0 +1,406 @@
using System.Diagnostics;
using ABI_RC.Core.IO;
using ABI_RC.Core.Player;
using ABI_RC.Core.Savior;
using ABI_RC.Core.UI;
using ABI_RC.Systems.GameEventSystem;
using MTJobSystem;
using NAK.Stickers.Integrations;
using NAK.Stickers.Networking;
using NAK.Stickers.Utilities;
using UnityEngine;
namespace NAK.Stickers;
public class StickerSystem
{
#region Singleton
public static StickerSystem Instance { get; private set; }
public static void Initialize()
{
if (Instance != null)
return;
Instance = new StickerSystem();
// ensure cache folder exists
if (!Directory.Exists(s_StickersSourcePath)) Directory.CreateDirectory(s_StickersSourcePath);
// configure Decalery
ModSettings.DecaleryMode selectedMode = ModSettings.Decalery_DecalMode.Value;
DecalManager.SetPreferredMode((DecalUtils.Mode)selectedMode, selectedMode == ModSettings.DecaleryMode.GPUIndirect, 0);
if (selectedMode != ModSettings.DecaleryMode.GPU) StickerMod.Logger.Warning("Decalery is not set to GPU mode. Expect compatibility issues with user generated content when mesh data is not marked as readable.");
// listen for game events
CVRGameEventSystem.Initialization.OnPlayerSetupStart.AddListener(Instance.OnPlayerSetupStart);
}
#endregion Singleton
#region Actions
public static Action<string> OnImageLoadFailed;
private void InvokeOnImageLoadFailed(string errorMessage)
{
MTJobManager.RunOnMainThread("StickersSystem.InvokeOnImageLoadFailed", () => OnImageLoadFailed?.Invoke(errorMessage));
}
#endregion Actions
#region Data
private bool _isInStickerMode;
public bool IsInStickerMode
{
get => _isInStickerMode;
set
{
_isInStickerMode = value;
if (_isInStickerMode) CohtmlHud.Instance.SelectPropToSpawn(BtkUiAddon.GetBtkUiCachePath(ModSettings.Hidden_SelectedStickerName.Value), ModSettings.Hidden_SelectedStickerName.Value, "Sticker selected for stickering:");
else CohtmlHud.Instance.ClearPropToSpawn();
}
}
private const float StickerKillTime = 30f;
private const float StickerCooldown = 0.2f;
private readonly Dictionary<string, StickerData> _playerStickers = new();
private const string PlayerLocalId = "_PLAYERLOCAL";
private readonly List<StickerData> _deadStickerPool = new(); // for cleanup on player leave
#endregion Data
#region Game Events
private void OnPlayerSetupStart()
{
CVRGameEventSystem.World.OnUnload.AddListener(_ => Instance.CleanupAllButSelf());
CVRGameEventSystem.Player.OnJoinEntity.AddListener(Instance.OnPlayerJoined);
CVRGameEventSystem.Player.OnLeaveEntity.AddListener(Instance.OnPlayerLeft);
SchedulerSystem.AddJob(Instance.OnOccasionalUpdate, 10f, 1f);
LoadImage(ModSettings.Hidden_SelectedStickerName.Value);
}
private void OnPlayerJoined(CVRPlayerEntity playerEntity)
{
if (!_playerStickers.TryGetValue(playerEntity.Uuid, out StickerData stickerData))
return;
stickerData.DeathTime = -1f;
stickerData.SetAlpha(1f);
_deadStickerPool.Remove(stickerData);
}
private void OnPlayerLeft(CVRPlayerEntity playerEntity)
{
if (!_playerStickers.TryGetValue(playerEntity.Uuid, out StickerData stickerData))
return;
stickerData.DeathTime = Time.time + StickerKillTime;
stickerData.SetAlpha(1f);
_deadStickerPool.Add(stickerData);
}
private void OnOccasionalUpdate()
{
if (_deadStickerPool.Count == 0)
return;
for (var i = _deadStickerPool.Count - 1; i >= 0; i--)
{
float currentTime = Time.time;
StickerData stickerData = _deadStickerPool[i];
if (stickerData.DeathTime < 0f)
continue;
if (currentTime < stickerData.DeathTime)
{
stickerData.SetAlpha(Mathf.Lerp(0f, 1f, (stickerData.DeathTime - currentTime) / StickerKillTime));
continue;
}
_playerStickers.Remove(_playerStickers.First(x => x.Value == stickerData).Key);
_deadStickerPool.RemoveAt(i);
stickerData.Cleanup();
}
}
#endregion Game Events
#region Local Player
public void PlaceStickerFromTransform(Transform transform)
{
Vector3 controllerForward = transform.forward;
Vector3 controllerUp = transform.up;
Vector3 playerUp = PlayerSetup.Instance.transform.up;
// extracting angle of controller ray on forward axis
Vector3 projectedControllerUp = Vector3.ProjectOnPlane(controllerUp, controllerForward).normalized;
Vector3 projectedPlayerUp = Vector3.ProjectOnPlane(playerUp, controllerForward).normalized;
float angle = Vector3.Angle(projectedControllerUp, projectedPlayerUp);
float angleThreshold = ModSettings.Entry_PlayerUpAlignmentThreshold.Value;
Vector3 targetUp = (angleThreshold != 0f && angle <= angleThreshold)
// leave 0.01% of the controller up vector to prevent issues with alignment on floor & ceiling in Desktop
? Vector3.Slerp(controllerUp, playerUp, 0.99f)
: controllerUp;
PlaceStickerSelf(transform.position, transform.forward, targetUp);
}
/// Place own sticker. Network if successful.
private void PlaceStickerSelf(Vector3 position, Vector3 forward, Vector3 up, bool alignWithNormal = true)
{
if (!AttemptPlaceSticker(PlayerLocalId, position, forward, up, alignWithNormal))
return; // failed
// placed, now network
ModNetwork.PlaceSticker(position, forward, up);
}
/// Clear own stickers. Network if successful.
public void ClearStickersSelf()
{
OnPlayerStickersClear(PlayerLocalId);
ModNetwork.ClearStickers();
}
/// Set own Texture2D for sticker. Network if cusses.
private void SetTextureSelf(byte[] imageBytes)
{
Texture2D texture = new(1, 1); // placeholder
texture.LoadImage(imageBytes);
texture.Compress(true); // noachi said to do
OnPlayerStickerTextureReceived(PlayerLocalId, Guid.Empty, texture);
if (ModNetwork.SetTexture(imageBytes)) ModNetwork.SendTexture();
}
#endregion Local Player
#region Public Methods
/// When a player wants to place a sticker.
public void OnPlayerStickerPlace(string playerId, Vector3 position, Vector3 forward, Vector3 up)
=> AttemptPlaceSticker(playerId, position, forward, up);
/// When a player wants to clear their stickers.
public void OnPlayerStickersClear(string playerId)
=> ClearStickersForPlayer(playerId);
/// Clear all stickers from all players.
public void ClearAllStickers()
{
foreach (StickerData stickerData in _playerStickers.Values)
stickerData.Clear();
ModNetwork.ClearStickers();
}
public void OnPlayerStickerTextureReceived(string playerId, Guid textureHash, Texture2D texture)
{
StickerData stickerData = GetOrCreateStickerData(playerId);
stickerData.SetTexture(textureHash, texture);
}
public Guid GetPlayerStickerTextureHash(string playerId)
{
StickerData stickerData = GetOrCreateStickerData(playerId);
return stickerData.TextureHash;
}
public void CleanupAll()
{
foreach ((_, StickerData data) in _playerStickers)
data.Cleanup();
_playerStickers.Clear();
}
public void CleanupAllButSelf()
{
StickerData localStickerData = GetOrCreateStickerData(PlayerLocalId);
foreach ((_, StickerData data) in _playerStickers)
{
if (data.IsLocal) data.Clear();
else data.Cleanup();
}
_playerStickers.Clear();
_playerStickers[PlayerLocalId] = localStickerData;
}
#endregion Public Methods
#region Private Methods
private StickerData GetOrCreateStickerData(string playerId)
{
if (_playerStickers.TryGetValue(playerId, out StickerData stickerData))
return stickerData;
stickerData = new StickerData(playerId == PlayerLocalId);
_playerStickers[playerId] = stickerData;
return stickerData;
}
private bool AttemptPlaceSticker(string playerId, Vector3 position, Vector3 forward, Vector3 up, bool alignWithNormal = true)
{
StickerData stickerData = GetOrCreateStickerData(playerId);
if (Time.time - stickerData.LastPlacedTime < StickerCooldown)
return false;
// Every layer other than IgnoreRaycast, PlayerLocal, PlayerClone, PlayerNetwork, and UI Internal
const int LayerMask = ~((1 << 2) | (1 << 8) | (1 << 9) | (1 << 10) | (1 << 15));
if (!Physics.Raycast(position, forward, out RaycastHit hit,
10f, LayerMask, QueryTriggerInteraction.Ignore))
return false;
stickerData.Place(hit, alignWithNormal ? -hit.normal : forward, up);
stickerData.PlayAudio();
return true;
}
private void ClearStickersForPlayer(string playerId)
{
if (!_playerStickers.TryGetValue(playerId, out StickerData stickerData))
return;
stickerData.Clear();
}
private void ReleaseStickerData(string playerId)
{
if (!_playerStickers.TryGetValue(playerId, out StickerData stickerData))
return;
stickerData.Cleanup();
_playerStickers.Remove(playerId);
}
#endregion Private Methods
#region Image Loading
private static readonly string s_StickersSourcePath = Application.dataPath + "/../UserData/Stickers/";
private bool _isLoadingImage;
public void LoadImage(string imageName)
{
if (string.IsNullOrEmpty(imageName))
return;
if (_isLoadingImage) return;
_isLoadingImage = true;
Task.Run(() =>
{
try
{
if (!TryLoadImage(imageName, out string errorMessage))
{
InvokeOnImageLoadFailed(errorMessage);
}
}
catch (Exception ex)
{
InvokeOnImageLoadFailed(ex.Message);
}
finally
{
_isLoadingImage = false;
}
});
}
private bool TryLoadImage(string imageName, out string errorMessage)
{
errorMessage = string.Empty;
if (ModNetwork.IsSendingTexture)
{
StickerMod.Logger.Warning("A texture is currently being sent over the network. Cannot load a new image yet.");
errorMessage = "A texture is currently being sent over the network. Cannot load a new image yet.";
return false;
}
if (!Directory.Exists(s_StickersSourcePath)) Directory.CreateDirectory(s_StickersSourcePath);
string imagePath = Path.Combine(s_StickersSourcePath, imageName + ".png");
FileInfo fileInfo = new(imagePath);
if (!fileInfo.Exists)
{
StickerMod.Logger.Warning($"Target image does not exist on disk. Path: {imagePath}");
errorMessage = "Target image does not exist on disk.";
return false;
}
var bytes = File.ReadAllBytes(imagePath);
if (!ImageUtility.IsValidImage(bytes))
{
StickerMod.Logger.Error("File is not a valid image or is corrupt.");
errorMessage = "File is not a valid image or is corrupt.";
return false;
}
StickerMod.Logger.Msg("Loaded image from disk. Size in KB: " + bytes.Length / 1024 + " (" + bytes.Length + " bytes)");
if (bytes.Length > ModNetwork.MaxTextureSize)
{
ImageUtility.Resize(ref bytes, 256, 256);
StickerMod.Logger.Warning("File ate too many cheeseburgers. Attempting experimental resize. Notice: this may cause filesize to increase.");
StickerMod.Logger.Msg("Resized image. Size in KB: " + bytes.Length / 1024 + " (" + bytes.Length + " bytes)");
}
if (ImageUtility.ResizeToNearestPowerOfTwo(ref bytes))
{
StickerMod.Logger.Warning("Image resolution was not a power of two. Attempting experimental resize. Notice: this may cause filesize to increase.");
StickerMod.Logger.Msg("Resized image. Size in KB: " + bytes.Length / 1024 + " (" + bytes.Length + " bytes)");
}
if (bytes.Length > ModNetwork.MaxTextureSize)
{
StickerMod.Logger.Error("File is still too large. Aborting. Size in KB: " + bytes.Length / 1024 + " (" + bytes.Length + " bytes)");
StickerMod.Logger.Msg("Please resize the image manually to be smaller than " + ModNetwork.MaxTextureSize / 1024 + " KB and round resolution to nearest power of two.");
errorMessage = "File is still too large. Please resize the image manually to be smaller than " + ModNetwork.MaxTextureSize / 1024 + " KB and round resolution to nearest power of two.";
return false;
}
StickerMod.Logger.Msg("Image successfully loaded.");
MTJobManager.RunOnMainThread("StickersSystem.LoadImage", () =>
{
ModSettings.Hidden_SelectedStickerName.Value = imageName;
SetTextureSelf(bytes);
if (!IsInStickerMode) return;
IsInStickerMode = false;
IsInStickerMode = true;
});
return true;
}
public static void OpenStickersFolder()
{
if (!Directory.Exists(s_StickersSourcePath)) Directory.CreateDirectory(s_StickersSourcePath);
Process.Start(s_StickersSourcePath);
}
public static string GetStickersFolderPath()
{
if (!Directory.Exists(s_StickersSourcePath)) Directory.CreateDirectory(s_StickersSourcePath);
return s_StickersSourcePath;
}
#endregion Image Loading
}

View file

@ -0,0 +1,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);
}
}

23
Stickers/format.json Normal file
View file

@ -0,0 +1,23 @@
{
"_id": 220,
"name": "PropLoadingHexagon",
"modversion": "1.0.0",
"gameversion": "2024r175",
"loaderversion": "0.6.1",
"modtype": "Mod",
"author": "Exterrata & NotAKidoS",
"description": "Adds a hexagon indicator to downloading props. Indicator is styled to look similar to Portals.\n\nCan use Delete Mode on loading hexagons to cancel stuck downloads. Setting is provided to display the hexagon for Blocked/Filtered props.",
"searchtags": [
"prop",
"spawn",
"indicator",
"loading"
],
"requirements": [
"None"
],
"downloadlink": "https://github.com/NotAKidoS/NAK_CVR_Mods/releases/download/r34/PropLoadingHexagon.dll",
"sourcelink": "https://github.com/NotAKidoS/NAK_CVR_Mods/tree/main/PropLoadingHexagon/",
"changelog": "- Initial release",
"embedcolor": "#f61963"
}