stickers
80
Stickers/Integrations/BTKUI/BtkUiAddon.cs
Normal 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
|
||||
}
|
15
Stickers/Integrations/BTKUI/BtkUiAddon_CAT_DebugOptions.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
356
Stickers/Integrations/BTKUI/BtkUiAddon_CAT_StickerSelection.cs
Normal 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
|
||||
}
|
||||
}
|
105
Stickers/Integrations/BTKUI/BtkUiAddon_CAT_StickersMod.cs
Normal 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
|
||||
}
|
104
Stickers/Integrations/BTKUI/BtkuiAddon_Utils.cs
Normal 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
|
@ -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
|
@ -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
|
@ -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);
|
||||
}
|
||||
}
|
32
Stickers/Properties/AssemblyInfo.cs
Normal 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
|
@ -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.
|
BIN
Stickers/Resources/Gohsantosadrive_Icons/Stickers-alphabet.png
Normal file
After Width: | Height: | Size: 35 KiB |
BIN
Stickers/Resources/Gohsantosadrive_Icons/Stickers-eraser.png
Normal file
After Width: | Height: | Size: 27 KiB |
BIN
Stickers/Resources/Gohsantosadrive_Icons/Stickers-folder.png
Normal file
After Width: | Height: | Size: 30 KiB |
BIN
Stickers/Resources/Gohsantosadrive_Icons/Stickers-headset.png
Normal file
After Width: | Height: | Size: 48 KiB |
BIN
Stickers/Resources/Gohsantosadrive_Icons/Stickers-magic-wand.png
Normal file
After Width: | Height: | Size: 66 KiB |
BIN
Stickers/Resources/Gohsantosadrive_Icons/Stickers-pencil.png
Normal file
After Width: | Height: | Size: 49 KiB |
BIN
Stickers/Resources/Gohsantosadrive_Icons/Stickers-puzzle.png
Normal file
After Width: | Height: | Size: 44 KiB |
After Width: | Height: | Size: 60 KiB |
BIN
Stickers/Resources/decalery_shaders.assets
Normal file
38
Stickers/Stickers.csproj
Normal 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>
|
414
Stickers/Stickers/Networking/ModNetwork.cs
Normal 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
|
||||
}
|
||||
}
|
132
Stickers/Stickers/StickerData.cs
Normal 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);
|
||||
}
|
||||
}
|
406
Stickers/Stickers/StickerSystem.cs
Normal 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
|
||||
}
|
171
Stickers/Stickers/Utilities/ImageUtility.cs
Normal 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
|
@ -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"
|
||||
}
|