diff --git a/PropsButBetter/Main.cs b/PropsButBetter/Main.cs new file mode 100644 index 0000000..2778b33 --- /dev/null +++ b/PropsButBetter/Main.cs @@ -0,0 +1,287 @@ +using System.Reflection; +using ABI_RC.Core.InteractionSystem; +using ABI_RC.Core.Player; +using ABI_RC.Core.Util; +using ABI_RC.Systems.InputManagement.InputModules; +using ABI_RC.Systems.UI.UILib; +using ABI.CCK.Components; +using HarmonyLib; +using MelonLoader; +using UnityEngine; + +namespace NAK.PropsButBetter; + +public class PropsButBetterMod : MelonMod +{ + internal static MelonLogger.Instance Logger; + + public override void OnInitializeMelon() + { + Logger = LoggerInstance; + + // Replace prop select method + HarmonyInstance.Patch( + typeof(ControllerRay).GetMethod(nameof(ControllerRay.HandleSpawnableClicked), + BindingFlags.NonPublic | BindingFlags.Instance), + prefix: new HarmonyMethod(typeof(PropsButBetterMod).GetMethod(nameof(OnPreHandleSpawnableClicked), + BindingFlags.NonPublic | BindingFlags.Static)) + ); + // Replace player select method + HarmonyInstance.Patch( + typeof(ControllerRay).GetMethod(nameof(ControllerRay.HandlePlayerClicked), + BindingFlags.NonPublic | BindingFlags.Instance), + prefix: new HarmonyMethod(typeof(PropsButBetterMod).GetMethod(nameof(OnPreHandlePlayerClicked), + BindingFlags.NonPublic | BindingFlags.Static)) + ); + + // Desktop keybindings for undo/redo + HarmonyInstance.Patch( + typeof(CVRInputModule_Keyboard).GetMethod(nameof(CVRInputModule_Keyboard.Update_Binds), + BindingFlags.Public | BindingFlags.Instance), + postfix: new HarmonyMethod(typeof(PropsButBetterMod).GetMethod(nameof(OnUpdateKeyboardBinds), + BindingFlags.NonPublic | BindingFlags.Static)) + ); + + HarmonyInstance.Patch( // delete my props in reverse order for redo + typeof(CVRSyncHelper).GetMethod(nameof(CVRSyncHelper.DeleteMyProps), + BindingFlags.Public | BindingFlags.Static), + prefix: new HarmonyMethod(typeof(PropHelper).GetMethod(nameof(PropHelper.OnPreDeleteMyProps), + BindingFlags.Public | BindingFlags.Static)) + ); + HarmonyInstance.Patch( // delete all props in reverse order for redo + typeof(CVRSyncHelper).GetMethod(nameof(CVRSyncHelper.DeleteAllProps), + BindingFlags.Public | BindingFlags.Static), + prefix: new HarmonyMethod(typeof(PropHelper).GetMethod(nameof(PropHelper.OnPreDeleteAllProps), + BindingFlags.Public | BindingFlags.Static)) + ); + HarmonyInstance.Patch( // prop spawn sfx + typeof(CVRSyncHelper).GetMethod(nameof(CVRSyncHelper.SpawnProp), + BindingFlags.Public | BindingFlags.Static), + postfix: new HarmonyMethod(typeof(PropHelper).GetMethod(nameof(PropHelper.OnTrySpawnProp), + BindingFlags.Public | BindingFlags.Static)) + ); + + HarmonyInstance.Patch( + typeof(PlayerSetup).GetMethod(nameof(PlayerSetup.SelectPropToSpawn), + BindingFlags.Public | BindingFlags.Instance), + postfix: new HarmonyMethod(typeof(PropHelper).GetMethod(nameof(PropHelper.OnSelectPropToSpawn), + BindingFlags.Public | BindingFlags.Static)) + ); + + HarmonyInstance.Patch( + typeof(PlayerSetup).GetMethod(nameof(PlayerSetup.EnterPropDeleteMode), + BindingFlags.Public | BindingFlags.Instance), + postfix: new HarmonyMethod(typeof(PropHelper).GetMethod(nameof(PropHelper.OnClearPropToSpawn), + BindingFlags.Public | BindingFlags.Static)) + ); + + HarmonyInstance.Patch( + typeof(PlayerSetup).GetMethod(nameof(PlayerSetup.ClearPropToSpawn), + BindingFlags.Public | BindingFlags.Instance), + postfix: new HarmonyMethod(typeof(PropHelper).GetMethod(nameof(PropHelper.OnClearPropToSpawn), + BindingFlags.Public | BindingFlags.Static)) + ); + + HarmonyInstance.Patch( + typeof(ControllerRay).GetMethod(nameof(ControllerRay.HandlePropSpawn), + BindingFlags.NonPublic | BindingFlags.Instance), + postfix: new HarmonyMethod(typeof(PropHelper).GetMethod(nameof(PropHelper.OnHandlePropSpawn), + BindingFlags.Public | BindingFlags.Static)) + ); + + UILibHelper.LoadIcons(); + QuickMenuPropList.BuildUI(); + QuickMenuPropSelect.BuildUI(); + PropHelper.Initialize(); + + // Bono approved hack + QuickMenuAPI.InjectCSSStyle(""" + .shit { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 10px; + display: block; + position: absolute; + top: 3px; + left: 3px; + } + + .shit2 { + opacity: 0; + transition: opacity 0.3s ease-in-out; + } + + """); + + // Build ui once quick menu is loaded + QuickMenuAPI.OnMenuGenerated += _ => + { + QuickMenuPropSelect.ListenForQM(); + PropListEntry.ListenForQM(); + GlobalButton.ListenForQM(); + UndoRedoButtons.ListenForButtons(); + }; + + SetupDefaultAudioClips(); + } + + /*public override void OnUpdate() + { + PropDistanceHider.Tick(); + }*/ + + // ReSharper disable once RedundantAssignment + private static bool OnPreHandleSpawnableClicked(ref ControllerRay __instance, ref CVRSpawnable __result) + { + __result = null; + + CVRSpawnable spawnable = __instance.hitTransform.GetComponentInParent(); + if (spawnable == null) return false; + + PlayerSetup.PropSelectionMode selectionMode = PlayerSetup.Instance.GetCurrentPropSelectionMode(); + switch (selectionMode) + { + case PlayerSetup.PropSelectionMode.None: + { + // Click a prop while a menu is open to open the details menu + if (__instance._interactDown + && __instance.CanSelectPlayersAndProps() + && spawnable.TryGetComponent(out CVRAssetInfo assetInfo)) + { + // Direct to Main Menu if it is open + if (ViewManager.Instance.IsViewShown) + { + ViewManager.Instance.GetPropDetails(assetInfo.objectId); + ViewManager.Instance.UiStateToggle(true); + } + // Direct to Quick Menu by default + else + { + QuickMenuPropSelect.ShowInfo(spawnable.PropData); + } + + __instance._interactDown = false; // Consume the click + } + break; + } + case PlayerSetup.PropSelectionMode.Delete: + { + // Click a prop while in delete mode to delete it + if (__instance._interactDown + && !spawnable.IsSpawnedByAdmin()) + { + spawnable.Delete(); + __instance._interactDown = false; // Consume the click + return false; // Don't return the spawnable, it's been deleted + } + break; + } + } + + // Return normal prop hover + __result = spawnable; + return false; + } + + private static bool OnPreHandlePlayerClicked(ref ControllerRay __instance, ref PlayerBase __result) + { + if (!ModSettings.EntryFixPlayerSelectRedirect.Value) + return true; + + __result = null; + + if (PlayerSetup.Instance.GetCurrentPropSelectionMode() + != PlayerSetup.PropSelectionMode.None) + return false; + + PlayerBase playerBase = null; + if (__instance.CanSelectPlayersAndProps()) + { + bool foundPlayerDescriptor = __instance.hitTransform.TryGetComponent(out playerBase); + if (!foundPlayerDescriptor && __instance.hitTransform.TryGetComponent(out CameraIndicator cameraIndicator)) + { + PlayerBase cameraOwner = cameraIndicator.ownerPlayer; + if (!playerBase.IsLocalPlayer) + { + playerBase = cameraOwner; + foundPlayerDescriptor = true; + } + } + + if (foundPlayerDescriptor && __instance._interactDown) + { + // Direct to Main Menu if it is open + if (ViewManager.Instance.IsViewShown) + { + ViewManager.Instance.RequestUserDetailsPage(playerBase.PlayerId); + ViewManager.Instance.UiStateToggle(true); + } + // Direct to Quick Menu by default + else + { + QuickMenuAPI.OpenPlayerListByUserID(playerBase.PlayerId); + CVR_MenuManager.Instance.ToggleQuickMenu(true); + } + } + } + + __result = playerBase; + return false; + } + + private static void OnUpdateKeyboardBinds() + { + if (!ModSettings.EntryUseUndoRedoKeybinds.Value) + return; + + if (Input.GetKey(KeyCode.LeftControl)) + { + if (Input.GetKey(KeyCode.LeftShift) + && Input.GetKeyDown(KeyCode.Z)) + PropHelper.RedoProp(); + else if (Input.GetKeyDown(KeyCode.Z)) + PropHelper.UndoProp(); + } + } + + private void SetupDefaultAudioClips() + { + // PropUndo and audio folders do not exist, create them if dont exist yet + var path = Application.streamingAssetsPath + $"/Cohtml/UIResources/GameUI/mods/{nameof(PropsButBetter)}/audio/"; + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + LoggerInstance.Msg("Created audio directory!"); + } + + // copy embedded resources to this folder if they do not exist + string[] clipNames = { "sfx_spawn.wav", "sfx_undo.wav", "sfx_redo.wav", "sfx_warn.wav", "sfx_deny.wav" }; + Assembly executingAssembly = Assembly.GetExecutingAssembly(); + + foreach (var clipName in clipNames) + { + var clipPath = Path.Combine(path, clipName); + if (!File.Exists(clipPath)) + { + // read the clip data from embedded resources + byte[] clipData; + var resourceName = $"{nameof(PropsButBetter)}.Resources.SFX." + clipName; + using (Stream stream = executingAssembly.GetManifestResourceStream(resourceName)) + { + clipData = new byte[stream!.Length]; + // ReSharper disable once MustUseReturnValue + stream.Read(clipData, 0, clipData.Length); + } + + // write the clip data to the file + using (FileStream fileStream = new(clipPath, FileMode.CreateNew)) + { + fileStream.Write(clipData, 0, clipData.Length); + } + + LoggerInstance.Msg("Placed missing sfx in audio folder: " + clipName); + } + } + } +} \ No newline at end of file diff --git a/PropsButBetter/ModSettings.cs b/PropsButBetter/ModSettings.cs new file mode 100644 index 0000000..224677e --- /dev/null +++ b/PropsButBetter/ModSettings.cs @@ -0,0 +1,35 @@ +using MelonLoader; + +namespace NAK.PropsButBetter; + +public static class ModSettings +{ + public const string ModName = nameof(PropsButBetter); + + private static readonly MelonPreferences_Category Category = + MelonPreferences.CreateCategory(ModName); + + internal static readonly MelonPreferences_Entry EntryUseUndoRedoKeybinds = + Category.CreateEntry("use_undo_redo_keybinds", true, + "Use Undo/Redo Keybinds", description: "Whether to use the Desktop keybinds to undo/redo Props (CTRL+Z, CTRL+SHIFT+Z)."); + + internal static readonly MelonPreferences_Entry EntryUseSFX = + Category.CreateEntry("use_sfx", true, + "Use SFX", description: "Toggle audio queues for prop spawn, undo, redo, and warning."); + + internal static readonly MelonPreferences_Entry HiddenPropListMode = + Category.CreateEntry("prop_list_mode", QuickMenuPropList.PropListMode.AllProps, + "Prop List Mode", description: "The current prop list mode.", is_hidden: true); + + internal static readonly MelonPreferences_Entry EntryFixPlayerSelectRedirect = + Category.CreateEntry("fix_player_select_redirect", true, + "Fix Player Select Redirect", description: "Whether to fix the player select redirect. Enabling this makes it consistent with Prop Select."); + + internal static readonly MelonPreferences_Entry EntryPropSpawnVisualizer = + Category.CreateEntry("prop_spawn_visualizer", true, + "Prop Spawn Visualizer", description: "Use the experimental probably fucked up prop spawn visualizer."); + + internal static readonly MelonPreferences_Entry EntryPropSpawnVisualizerMode = + Category.CreateEntry("prop_spawn_visualizer_mode", PropHelper.PropVisualizerMode.HologramSource, + "Prop Spawn Visualizer Mode", description: "Choose how the visualizer will attempt to render."); +} \ No newline at end of file diff --git a/PropsButBetter/Properties/AssemblyInfo.cs b/PropsButBetter/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..151db86 --- /dev/null +++ b/PropsButBetter/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +using NAK.PropsButBetter.Properties; +using MelonLoader; +using System.Reflection; + +[assembly: AssemblyVersion(AssemblyInfoParams.Version)] +[assembly: AssemblyFileVersion(AssemblyInfoParams.Version)] +[assembly: AssemblyInformationalVersion(AssemblyInfoParams.Version)] +[assembly: AssemblyTitle(nameof(NAK.PropsButBetter))] +[assembly: AssemblyCompany(AssemblyInfoParams.Author)] +[assembly: AssemblyProduct(nameof(NAK.PropsButBetter))] + +[assembly: MelonInfo( + typeof(NAK.PropsButBetter.PropsButBetterMod), + nameof(NAK.PropsButBetter), + AssemblyInfoParams.Version, + AssemblyInfoParams.Author, + downloadLink: "https://github.com/NotAKidoS/NAK_CVR_Mods/tree/main/PropsButBetter" +)] + +[assembly: MelonGame("ChilloutVR", "ChilloutVR")] +[assembly: MelonPlatform(MelonPlatformAttribute.CompatiblePlatforms.WINDOWS_X64)] +[assembly: MelonPlatformDomain(MelonPlatformDomainAttribute.CompatibleDomains.MONO)] +[assembly: MelonColor(255, 246, 25, 99)] // red-pink +[assembly: MelonAuthorColor(255, 158, 21, 32)] // red +[assembly: HarmonyDontPatchAll] + +namespace NAK.PropsButBetter.Properties; +internal static class AssemblyInfoParams +{ + public const string Version = "1.0.0"; + public const string Author = "NotAKidoS"; +} \ No newline at end of file diff --git a/PropsButBetter/PropsButBetter.csproj b/PropsButBetter/PropsButBetter.csproj new file mode 100644 index 0000000..f3feb00 --- /dev/null +++ b/PropsButBetter/PropsButBetter.csproj @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PropsButBetter/PropsButBetter/API/PedestalInfoBatchProcessor.cs b/PropsButBetter/PropsButBetter/API/PedestalInfoBatchProcessor.cs new file mode 100644 index 0000000..b43660d --- /dev/null +++ b/PropsButBetter/PropsButBetter/API/PedestalInfoBatchProcessor.cs @@ -0,0 +1,135 @@ +using ABI_RC.Core.Networking.API; +using ABI_RC.Core.Networking.API.Responses; + +namespace NAK.PropsButBetter; + +public enum PedestalType +{ + Avatar, + Prop +} + +public static class PedestalInfoBatchProcessor +{ + private class CachedResponse + { + public PedestalInfoResponse Response; + public DateTime CachedAt; + } + + private static readonly Dictionary> _cache = new() + { + { PedestalType.Avatar, new Dictionary() }, + { PedestalType.Prop, new Dictionary() } + }; + + private static readonly Dictionary>> _pendingRequests = new() + { + { PedestalType.Avatar, new Dictionary>() }, + { PedestalType.Prop, new Dictionary>() } + }; + + private static readonly Dictionary _isBatchProcessing = new() + { + { PedestalType.Avatar, false }, + { PedestalType.Prop, false } + }; + + private static readonly object _lock = new(); + private const float BATCH_DELAY = 2f; + private const float CACHE_DURATION = 300f; // 5 minutes + + public static Task QueuePedestalInfoRequest(PedestalType type, string contentId, bool skipDelayIfNotCached = false) + { + lock (_lock) + { + // Check cache first + if (_cache[type].TryGetValue(contentId, out var cached)) + { + if ((DateTime.UtcNow - cached.CachedAt).TotalSeconds < CACHE_DURATION) + return Task.FromResult(cached.Response); + + _cache[type].Remove(contentId); + } + + // Check if already pending + var requests = _pendingRequests[type]; + if (requests.TryGetValue(contentId, out var existingTcs)) + return existingTcs.Task; + + // Queue new request + var tcs = new TaskCompletionSource(); + requests[contentId] = tcs; + + if (!_isBatchProcessing[type]) + { + _isBatchProcessing[type] = true; + ProcessBatchAfterDelay(type, skipDelayIfNotCached); + } + + return tcs.Task; + } + } + + private static async void ProcessBatchAfterDelay(PedestalType type, bool skipDelayIfNotCached) + { + if (!skipDelayIfNotCached) + await Task.Delay(TimeSpan.FromSeconds(BATCH_DELAY)); + + List contentIds; + Dictionary> requestBatch; + + lock (_lock) + { + contentIds = _pendingRequests[type].Keys.ToList(); + requestBatch = new Dictionary>(_pendingRequests[type]); + _pendingRequests[type].Clear(); + _isBatchProcessing[type] = false; + } + + try + { + ApiConnection.ApiOperation operation = type switch + { + PedestalType.Avatar => ApiConnection.ApiOperation.AvatarPedestal, + PedestalType.Prop => ApiConnection.ApiOperation.PropPedestal, + _ => throw new ArgumentException($"Unsupported pedestal type: {type}") + }; + + var response = await ApiConnection.MakeRequest>(operation, contentIds); + + if (response?.Data != null) + { + var responseDict = response.Data.ToDictionary(info => info.Id); + var now = DateTime.UtcNow; + + lock (_lock) + { + foreach (var kvp in requestBatch) + { + if (responseDict.TryGetValue(kvp.Key, out var info)) + { + _cache[type][kvp.Key] = new CachedResponse { Response = info, CachedAt = now }; + kvp.Value.SetResult(info); + } + else + { + kvp.Value.SetException(new Exception($"Content info not found for ID: {kvp.Key}")); + } + } + } + } + else + { + Exception exception = new($"Failed to fetch {type} info batch"); + foreach (var tcs in requestBatch.Values) + tcs.SetException(exception); + } + } + catch (Exception ex) + { + foreach (var tcs in requestBatch.Values) + tcs.SetException(ex); + } + } +} \ No newline at end of file diff --git a/PropsButBetter/PropsButBetter/API/SpawnablesMeta.cs b/PropsButBetter/PropsButBetter/API/SpawnablesMeta.cs new file mode 100644 index 0000000..ed6e3e0 --- /dev/null +++ b/PropsButBetter/PropsButBetter/API/SpawnablesMeta.cs @@ -0,0 +1,87 @@ +using System.Net; +using ABI_RC.Core; +using ABI_RC.Core.Networking; +using ABI_RC.Core.Networking.API; +using ABI_RC.Core.Networking.API.Responses; +using ABI_RC.Core.Savior; +using NAK.PropsButBetter; +using Newtonsoft.Json; + +public static class PropApiHelper +{ + /// + /// Fetches the metadata for a specific prop/spawnable + /// + /// The ID of the prop to fetch metadata for + /// BaseResponse containing UgcWithFile data + public static async Task> GetPropMeta(string propId) + { + // Check authentication + if (!AuthManager.IsAuthenticated) + { + PropsButBetterMod.Logger.Error("Attempted to fetch prop meta while user was not authenticated."); + return new BaseResponse("The user is not Authenticated.") + { + IsSuccessStatusCode = false, + HttpStatusCode = HttpStatusCode.Unauthorized, + }; + } + + // Construct the URL + string requestUrl = $"{ApiConnection.APIAddress}/{ApiConnection.APIVersion}/spawnables/{propId}/meta"; + + // Validate URL for security + if (!CVRTools.IsSafeAbsoluteHttpUrl(requestUrl, out var uri)) + { + PropsButBetterMod.Logger.Error($"Invalid URI was constructed! URI: {requestUrl}"); + return null; + } + + // Create the HTTP request + var request = new HttpRequestMessage(HttpMethod.Get, uri); + + // Add mature content header + string allowMatureContent = MetaPort.Instance.matureContentAllowed ? "true" : "false"; + request.Headers.Add(ApiConnection.HeaderMatureContent, allowMatureContent); + + try + { + // Send the request + HttpResponseMessage response = await ApiConnection.Client.SendAsync(request); + + // Handle successful response + if (response.IsSuccessStatusCode) + { + var contentStr = await response.Content.ReadAsStringAsync(); + + var baseResponse = string.IsNullOrWhiteSpace(contentStr) + ? new BaseResponse { Message = "CVR_SUCCESS_PropMeta" } + : JsonConvert.DeserializeObject>(contentStr); + + baseResponse.HttpStatusCode = response.StatusCode; + baseResponse.IsSuccessStatusCode = true; + + return baseResponse; + } + + // Handle failed response + PropsButBetterMod.Logger.Warning($"Request failed with status {response.StatusCode} [{(int)response.StatusCode}]"); + + string rawResponseBody = await response.Content.ReadAsStringAsync(); + var res = JsonConvert.DeserializeObject>(rawResponseBody); + + if (res != null) + { + res.HttpStatusCode = response.StatusCode; + res.IsSuccessStatusCode = false; + return res; + } + } + catch (Exception e) + { + PropsButBetterMod.Logger.Error($"Failed to fetch prop meta: {e.Message}"); + } + + return null; + } +} \ No newline at end of file diff --git a/PropsButBetter/PropsButBetter/Extensions/CVRSpawnableExtensions.cs b/PropsButBetter/PropsButBetter/Extensions/CVRSpawnableExtensions.cs new file mode 100644 index 0000000..b76a1aa --- /dev/null +++ b/PropsButBetter/PropsButBetter/Extensions/CVRSpawnableExtensions.cs @@ -0,0 +1,11 @@ +using ABI_RC.Core.Util; +using ABI.CCK.Components; + +namespace NAK.PropsButBetter; + +public static class CVRSpawnableExtensions +{ + public static bool IsSpawnedByAdmin(this CVRSpawnable spawnable) + => spawnable.ownerId is CVRSyncHelper.OWNERID_SYSTEM + or CVRSyncHelper.OWNERID_LOCALSERVER; +} \ No newline at end of file diff --git a/PropsButBetter/PropsButBetter/Extensions/PlayerSetupExtensions.cs b/PropsButBetter/PropsButBetter/Extensions/PlayerSetupExtensions.cs new file mode 100644 index 0000000..9750219 --- /dev/null +++ b/PropsButBetter/PropsButBetter/Extensions/PlayerSetupExtensions.cs @@ -0,0 +1,14 @@ +using ABI_RC.Core.Player; + +namespace NAK.PropsButBetter; + +public static class PlayerSetupExtensions +{ + public static void ToggleDeleteMode(this PlayerSetup playerSetup) + { + if (playerSetup.propGuidForSpawn == PlayerSetup.PropModeDeleteString) + playerSetup.ClearPropToSpawn(); + else + playerSetup.EnterPropDeleteMode(); + } +} \ No newline at end of file diff --git a/PropsButBetter/PropsButBetter/Extensions/PropDataExtensions.cs b/PropsButBetter/PropsButBetter/Extensions/PropDataExtensions.cs new file mode 100644 index 0000000..db302f4 --- /dev/null +++ b/PropsButBetter/PropsButBetter/Extensions/PropDataExtensions.cs @@ -0,0 +1,42 @@ +using ABI_RC.Core.Networking; +using ABI_RC.Core.Util; + +namespace NAK.PropsButBetter; + +public static class PropDataExtensions +{ + extension(CVRSyncHelper.PropData prop) + { + public bool IsSpawnedByMe() + => prop.SpawnedBy == AuthManager.UserId; + + public void CopyFrom(CVRSyncHelper.PropData sourceData) + { + prop.ObjectId = sourceData.ObjectId; + prop.InstanceId = sourceData.InstanceId; + prop.PositionX = sourceData.PositionX; + prop.PositionY = sourceData.PositionY; + prop.PositionZ = sourceData.PositionZ; + prop.RotationX = sourceData.RotationX; + prop.RotationY = sourceData.RotationY; + prop.RotationZ = sourceData.RotationZ; + prop.ScaleX = sourceData.ScaleX; + prop.ScaleY = sourceData.ScaleY; + prop.ScaleZ = sourceData.ScaleZ; + prop.CustomFloatsAmount = sourceData.CustomFloatsAmount; + prop.CustomFloats = sourceData.CustomFloats; + prop.SpawnedBy = sourceData.SpawnedBy; + prop.syncedBy = sourceData.syncedBy; + prop.syncType = sourceData.syncType; + prop.ContentMetadata = sourceData.ContentMetadata; + prop.CanFireDecommissionEvents = sourceData.CanFireDecommissionEvents; + } + + public void RecycleSafe() + { + if (prop.IsSpawnedByMe()) + CVRSyncHelper.DeleteMyPropByInstanceIdOverNetwork(prop.InstanceId); + prop.Recycle(); + } + } +} \ No newline at end of file diff --git a/PropsButBetter/PropsButBetter/Extensions/TextBlockExtensions.cs b/PropsButBetter/PropsButBetter/Extensions/TextBlockExtensions.cs new file mode 100644 index 0000000..abeadbb --- /dev/null +++ b/PropsButBetter/PropsButBetter/Extensions/TextBlockExtensions.cs @@ -0,0 +1,12 @@ +using ABI_RC.Systems.UI.UILib.UIObjects.Components; + +namespace NAK.PropsButBetter; + +public static class TextBlockExtensions +{ + public static void SetHiddenIfNeeded(this TextBlock textBlock, bool hidden) + { + // Don't invoke a view trigger event needlessly + if (textBlock.Hidden != hidden) textBlock.Hidden = hidden; + } +} \ No newline at end of file diff --git a/PropsButBetter/PropsButBetter/Helpers/BoundsUtility.cs b/PropsButBetter/PropsButBetter/Helpers/BoundsUtility.cs new file mode 100644 index 0000000..443b644 --- /dev/null +++ b/PropsButBetter/PropsButBetter/Helpers/BoundsUtility.cs @@ -0,0 +1,294 @@ +using Unity.Burst; +using Unity.Collections; +using Unity.Jobs; +using Unity.Mathematics; +using UnityEngine; +using UnityEngine.Rendering; +using System.Buffers; +using Unity.Collections.LowLevel.Unsafe; + +public static class MeshBoundsUtility +{ + [BurstCompile] + unsafe struct BoundsJob : IJob + { + [ReadOnly] public NativeArray bytes; + public int vertexCount; + public int stride; + public int positionOffset; + public NativeArray minMax; + + public void Execute() + { + byte* basePtr = (byte*)bytes.GetUnsafeReadOnlyPtr(); + + float3 min = *(float3*)(basePtr + positionOffset); + float3 max = min; + + for (int i = 1; i < vertexCount; i++) + { + float3 p = *(float3*)(basePtr + i * stride + positionOffset); + min = math.min(min, p); + max = math.max(max, p); + } + + minMax[0] = min; + minMax[1] = max; + } + } + + public static Bounds CalculateTightBounds(Mesh mesh) + { + if (!mesh || mesh.vertexCount == 0) + return default; + + var attrs = mesh.GetVertexAttributes(); + + int stream = -1; + int positionOffset = 0; + + for (int i = 0; i < attrs.Length; i++) + { + var a = attrs[i]; + + if (a.attribute == VertexAttribute.Position) + { + stream = a.stream; + break; + } + + if (stream == -1 || a.stream == stream) + { + int size = a.format switch + { + VertexAttributeFormat.Float32 => 4, + VertexAttributeFormat.Float16 => 2, + VertexAttributeFormat.UNorm8 => 1, + VertexAttributeFormat.SNorm8 => 1, + VertexAttributeFormat.UNorm16 => 2, + VertexAttributeFormat.SNorm16 => 2, + VertexAttributeFormat.UInt8 => 1, + VertexAttributeFormat.SInt8 => 1, + VertexAttributeFormat.UInt16 => 2, + VertexAttributeFormat.SInt16 => 2, + VertexAttributeFormat.UInt32 => 4, + VertexAttributeFormat.SInt32 => 4, + _ => 0 + }; + + positionOffset += size * a.dimension; + } + } + + if (stream < 0) + return default; + + using GraphicsBuffer vb = mesh.GetVertexBuffer(stream); + int stride = vb.stride; + int byteCount = vb.count * stride; + + // REQUIRED: managed array + byte[] managedBytes = ArrayPool.Shared.Rent(byteCount); + vb.GetData(managedBytes, 0, 0, byteCount); + + var bytes = new NativeArray( + byteCount, + Allocator.TempJob, + NativeArrayOptions.UninitializedMemory); + + NativeArray.Copy(managedBytes, bytes, byteCount); + ArrayPool.Shared.Return(managedBytes); + + var minMax = new NativeArray(2, Allocator.TempJob); + + new BoundsJob + { + bytes = bytes, + vertexCount = mesh.vertexCount, + stride = stride, + positionOffset = positionOffset, + minMax = minMax + }.Run(); + + bytes.Dispose(); + + float3 min = minMax[0]; + float3 max = minMax[1]; + minMax.Dispose(); + + return new Bounds((min + max) * 0.5f, max - min); + } + + public static bool TryCalculateRendererBounds(Renderer renderer, out Bounds worldBounds) + { + worldBounds = default; + switch (renderer) + { + case MeshRenderer mr: + { + if (!renderer.TryGetComponent(out MeshFilter mf)) + return false; + + Mesh sharedMesh = mf.sharedMesh; + if (!sharedMesh) + return false; + + Bounds local = CalculateTightBounds(sharedMesh); + if (local.size == Vector3.zero) + return false; + + worldBounds = TransformBounds(mr.transform, local); + return true; + } + + case SkinnedMeshRenderer smr: + { + Mesh sharedMesh = smr.sharedMesh; + if (!sharedMesh) + return false; + + Bounds local = CalculateTightBounds(sharedMesh); + if (local.size == Vector3.zero) + return false; + + worldBounds = TransformBounds(smr.transform, local); + return true; + } + + default: + { + worldBounds = renderer.bounds; + return true; + } + } + } + + /// + /// Calculates the combined world-space bounds of all Renderers under a GameObject. + /// + /// The root GameObject to search under. + /// Whether to include inactive GameObjects. + /// Combined bounds, or default if no renderers found. + public static Bounds CalculateCombinedBounds(GameObject root, bool includeInactive = false) + { + if (!root) + return default; + + Renderer[] renderers = root.GetComponentsInChildren(includeInactive); + + if (renderers.Length == 0) + return default; + + // Find first valid bounds + int startIndex = 0; + Bounds combined = default; + + for (int i = 0; i < renderers.Length; i++) + { + if (renderers[i] && renderers[i].enabled) + { + combined = renderers[i].bounds; + startIndex = i + 1; + break; + } + } + + // Encapsulate remaining renderers + for (int i = startIndex; i < renderers.Length; i++) + { + if (renderers[i] && renderers[i].enabled) + { + combined.Encapsulate(renderers[i].bounds); + } + } + + return combined; + } + + /// + /// Calculates tight combined bounds using mesh vertex data instead of renderer bounds. + /// More accurate but slower than CalculateCombinedBounds. + /// + /// The root GameObject to search under. + /// Whether to include inactive GameObjects. + /// Combined tight bounds in world space, or default if no valid meshes found. + public static Bounds CalculateCombinedTightBounds(GameObject root, bool includeInactive = false) + { + if (!root) + return default; + + MeshFilter[] meshFilters = root.GetComponentsInChildren(includeInactive); + SkinnedMeshRenderer[] skinnedRenderers = root.GetComponentsInChildren(includeInactive); + + bool hasAny = false; + Bounds combined = default; + + // Process MeshFilters + foreach (var mf in meshFilters) + { + if (!mf || !mf.sharedMesh) + continue; + + Bounds localBounds = CalculateTightBounds(mf.sharedMesh); + if (localBounds.size == Vector3.zero) + continue; + + Bounds worldBounds = TransformBounds(mf.transform, localBounds); + + if (!hasAny) + { + combined = worldBounds; + hasAny = true; + } + else + { + combined.Encapsulate(worldBounds); + } + } + + // Process SkinnedMeshRenderers + foreach (var smr in skinnedRenderers) + { + if (!smr || !smr.sharedMesh) + continue; + + // For skinned meshes, use the current baked bounds or renderer bounds + // since vertex positions change with animation + Bounds worldBounds = smr.bounds; + + if (!hasAny) + { + combined = worldBounds; + hasAny = true; + } + else + { + combined.Encapsulate(worldBounds); + } + } + + return combined; + } + + /// + /// Transforms an AABB from local space to world space, returning a new AABB that fully contains the transformed box. + /// + private static Bounds TransformBounds(Transform transform, Bounds localBounds) + { + Vector3 center = transform.TransformPoint(localBounds.center); + Vector3 extents = localBounds.extents; + + // Transform each axis extent by the absolute value of the rotation/scale matrix + Vector3 axisX = transform.TransformVector(extents.x, 0, 0); + Vector3 axisY = transform.TransformVector(0, extents.y, 0); + Vector3 axisZ = transform.TransformVector(0, 0, extents.z); + + Vector3 worldExtents = new Vector3( + Mathf.Abs(axisX.x) + Mathf.Abs(axisY.x) + Mathf.Abs(axisZ.x), + Mathf.Abs(axisX.y) + Mathf.Abs(axisY.y) + Mathf.Abs(axisZ.y), + Mathf.Abs(axisX.z) + Mathf.Abs(axisY.z) + Mathf.Abs(axisZ.z) + ); + + return new Bounds(center, worldExtents * 2f); + } +} diff --git a/PropsButBetter/PropsButBetter/Helpers/PropHelper.cs b/PropsButBetter/PropsButBetter/Helpers/PropHelper.cs new file mode 100644 index 0000000..1905506 --- /dev/null +++ b/PropsButBetter/PropsButBetter/Helpers/PropHelper.cs @@ -0,0 +1,777 @@ +using System.Collections; +using ABI_RC.Core; +using ABI_RC.Core.AudioEffects; +using ABI_RC.Core.EventSystem; +using ABI_RC.Core.InteractionSystem; +using ABI_RC.Core.IO; +using ABI_RC.Core.Networking; +using ABI_RC.Core.Networking.API.Responses; +using ABI_RC.Core.Networking.GameServer; +using ABI_RC.Core.Player; +using ABI_RC.Core.Savior; +using ABI_RC.Core.Util; +using ABI_RC.Core.Util.AssetFiltering; +using ABI_RC.Systems.GameEventSystem; +using ABI_RC.Systems.Gravity; +using ABI.CCK.Components; +using DarkRift; +using MelonLoader; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace NAK.PropsButBetter; + +public static class PropHelper +{ + public const int TotalKeptSessionHistory = 30; + + public static List SpawnedThisSession { get; } = new(TotalKeptSessionHistory); + + // CVRSyncHelper does not keep track of your own props nicely + public static readonly List MyProps = []; + + // We get prop spawn/destroy events with emptied prop data sometimes, so we need to track shit ourselves :D + private static readonly Dictionary _propToInstanceId = []; + + private static int MaxPropsForUser = 20; + + private const int RedoTimeoutLimit = 120; // seconds + private static readonly int RedoHistoryLimit = Mathf.Max(MaxPropsForUser, CVRSyncHelper.MyPropCount); + + private static readonly List _myDeletedProps = []; + + // audio clip names, InterfaceAudio adds "PropsButBetter" prefix + public const string SFX_Spawn = $"{nameof(PropsButBetter)}_sfx_spawn"; + public const string SFX_Undo = $"{nameof(PropsButBetter)}_sfx_undo"; + public const string SFX_Redo = $"{nameof(PropsButBetter)}_sfx_redo"; + public const string SFX_Warn = $"{nameof(PropsButBetter)}_sfx_warn"; + public const string SFX_Deny = $"{nameof(PropsButBetter)}_sfx_deny"; + + public static void PlaySound(string sound) + { + if (ModSettings.EntryUseSFX.Value) + InterfaceAudio.PlayModule(sound); + } + + public static bool IsPropSpawnAllowed() + => MetaPort.Instance.worldAllowProps + && MetaPort.Instance.settings.GetSettingsBool("ContentFilterPropsEnabled") + && NetworkManager.Instance.GameNetwork.ConnectionState == ConnectionState.Connected; + + public static bool IsAtPropLimit() + => MyProps.Count >= MaxPropsForUser; + + public static bool CanUndo() + => MyProps.Count > 0; + + public static bool CanRedo() + { + int deletedPropsCount = _myDeletedProps.Count; + return deletedPropsCount > 0 + && _myDeletedProps[deletedPropsCount-1].IsWithinTimeLimit + && !IsAtPropLimit() && IsPropSpawnAllowed(); + } + + public static void UndoProp() + { + CVRSyncHelper.PropData latestPropData = MyProps.LastOrDefault(); + if (latestPropData == null) + { + PlaySound(SFX_Warn); + return; + } + latestPropData.RecycleSafe(); + } + + public static void RedoProp() + { + int index = _myDeletedProps.Count - 1; + if (index < 0) + { + PlaySound(SFX_Warn); + return; + } + + if (!IsPropSpawnAllowed() || IsAtPropLimit()) + { + PlaySound(SFX_Deny); + return; + } + + // Only allow redoing props if they were deleted within the timeout + DeletedPropData deletedProp = _myDeletedProps[index]; + if (deletedProp.IsWithinTimeLimit) + { + _skipBecauseIsRedo = true; + CVRSyncHelper.SpawnProp(deletedProp.PropId, deletedProp.Position, deletedProp.Rotation); + _skipBecauseIsRedo = false; + _myDeletedProps.RemoveAt(index); + PlaySound(SFX_Redo); + } + else + { + // If latest prop is too old, same with rest + ClearUndo(); + PlaySound(SFX_Warn); + } + } + + public static void ClearUndo() + { + _myDeletedProps.Clear(); + } + + public static bool RemoveMyProps() + { + int propsCount = MyProps.Count; + if (propsCount == 0) + { + PlaySound(SFX_Warn); + return false; + } + + for (int i = propsCount - 1; i >= 0; i--) + { + CVRSyncHelper.PropData prop = MyProps[i]; + prop.RecycleSafe(); + } + + return true; + } + + public static bool RemoveOthersProps() + { + int propsCount = CVRSyncHelper.Props.Count; + if (propsCount == 0) + { + PlaySound(SFX_Warn); + return false; + } + + for (int i = propsCount - 1; i >= 0; i--) + { + CVRSyncHelper.PropData prop = CVRSyncHelper.Props[i]; + if (!prop.IsSpawnedByMe()) prop.RecycleSafe(); + } + + return true; + } + + public static bool RemoveAllProps() + { + int propsCount = CVRSyncHelper.Props.Count; + if (propsCount == 0) + { + PlaySound(SFX_Warn); + return false; + } + + for (int i = propsCount - 1; i >= 0; i--) + { + CVRSyncHelper.PropData prop = CVRSyncHelper.Props[i]; + prop.RecycleSafe(); + } + + return true; + } + + private static void OnPropSpawned(string assetId, CVRSyncHelper.PropData prop) + { + // Skip broken props + string instanceId = prop.InstanceId; + if (string.IsNullOrEmpty(instanceId)) + return; + + // Ignore the visualizer prop + if (instanceId == _lastVisualizerInstanceId) + { + OnVisualizerPropSpawned(); + return; + } + + // PropDistanceHider.OnPropSpawned(prop); + + // Track ourselves for later + _propToInstanceId.TryAdd(prop, prop.InstanceId); + + // Track our props + if (prop.IsSpawnedByMe()) MyProps.Add(prop); + + // Check if this prop was reloaded + int spawnedThisSessionCount = SpawnedThisSession.Count; + for (int i = 0; i < spawnedThisSessionCount; i++) + { + if (SpawnedThisSession[i].InstanceId == instanceId) + { + SpawnedThisSession[i].IsDestroyed = false; + return; // No need to add + } + } + + // Track for session history + PropHistoryData historyData = new() + { + PropId = prop.ObjectId, + PropName = prop.ContentMetadata.AssetName, + SpawnerName = CVRPlayerManager.Instance.TryGetPlayerName(prop.SpawnedBy), + InstanceId = instanceId, + IsDestroyed = false + }; + + // Insert at beginning for newest first + SpawnedThisSession.Insert(0, historyData); + + // Keep only the most recent entries + if (spawnedThisSessionCount >= TotalKeptSessionHistory) + SpawnedThisSession.RemoveAt(spawnedThisSessionCount - 1); + } + + private static void OnPropDestroyed(string assetId, CVRSyncHelper.PropData prop) + { + // Only handle props which we tracked the spawn for + if (!_propToInstanceId.Remove(prop, out string instanceId)) + return; + + // PropDistanceHider.OnPropDestroyed(prop); + + // Track our props + if (MyProps.Remove(prop)) + { + // Track the deleted prop for undo + if (_myDeletedProps.Count >= RedoHistoryLimit) + _myDeletedProps.RemoveAt(0); + + DeletedPropData deletedProp = new(prop); + _myDeletedProps.Add(deletedProp); + + PlaySound(SFX_Undo); + } + + // Track for session history + int spawnedThisSessionCount = SpawnedThisSession.Count; + for (int i = 0; i < spawnedThisSessionCount; i++) + { + if (SpawnedThisSession[i].InstanceId == instanceId) + { + SpawnedThisSession[i].IsDestroyed = true; + break; + } + } + } + + private static void OnGSInfoUpdate(GSInfoUpdate update, GSInfoChanged changed) + { + if (changed == GSInfoChanged.MaxPropsPerUser) + MaxPropsForUser = update.MaxPropsPerUser; + } + + private static void OnWorldUnload(string _) => ClearUndo(); + + public static void Initialize() + { + CVRGameEventSystem.Spawnable.OnPropSpawned.AddListener(OnPropSpawned); + CVRGameEventSystem.Spawnable.OnPropDestroyed.AddListener(OnPropDestroyed); + GSInfoHandler.OnGSInfoUpdate += OnGSInfoUpdate; + CVRGameEventSystem.World.OnUnload.AddListener(OnWorldUnload); + } + + // Replacement methods for CVRSyncHelper + + public static bool OnPreDeleteMyProps() + { + bool removedProps = RemoveMyProps(); + + if (removedProps) + ViewManager.Instance.NotifyUser("(Synced) Client", "Removed all my props", 1f); + else + ViewManager.Instance.NotifyUser("(Local) Client", "No props to remove", 1f); + + return false; + } + + public static bool OnPreDeleteAllProps() + { + bool removedProps = RemoveAllProps(); + + if (removedProps) + ViewManager.Instance.NotifyUser("(Synced) Client", "Removed all spawned props", 1f); + else + ViewManager.Instance.NotifyUser("(Local) Client", "No props to remove", 1f); + + return false; + } + + private static bool _skipBecauseIsRedo; // Hack + public static void OnTrySpawnProp() + { + if (_skipBecauseIsRedo) return; + if (!IsPropSpawnAllowed() || IsAtPropLimit()) + { + PlaySound(SFX_Deny); + return; + } + PlaySound(SFX_Spawn); + } + + // Prop history for session + public class PropHistoryData + { + public string PropId; + public string PropName; + public string SpawnerName; + public string InstanceId; + public bool IsDestroyed; + } + + // Deleted prop info for undo history + private class DeletedPropData + { + public readonly string PropId; + public readonly Vector3 Position; + public readonly Quaternion Rotation; + public readonly float TimeDeleted; + + public bool IsWithinTimeLimit => Time.time - TimeDeleted <= RedoTimeoutLimit; + + public DeletedPropData(CVRSyncHelper.PropData propData) + { + CVRSpawnable spawnable = propData.Spawnable; + + if (spawnable == null) + { + // use original spawn position and rotation / last known position and rotation + Position = new Vector3(propData.PositionX, propData.PositionY, propData.PositionZ); + Rotation = Quaternion.Euler(new Vector3(propData.RotationX, propData.RotationY, propData.RotationZ)); + } + else + { + Transform spawnableTransform = spawnable.transform; + Position = spawnableTransform.position; + Rotation = spawnableTransform.rotation; + + // Offset spawn height so game can account for it later + Position.y -= spawnable.spawnHeight; + } + + PropId = propData.ObjectId; + TimeDeleted = Time.time; + } + } + + // Visualizer +// Add this enum at the top of your file or in a separate file +public enum PropVisualizerMode +{ + SourceRenderers, + HologramSource, + HologramBounds +} + +// Modified code: + +private static string _lastVisualizerInstanceId; +private static object _currentFetchCoroutine; +private static bool _cancelPendingFetch; +private static CVRSyncHelper.PropData _visualizerPropData; +private static GameObject _visualizerGameObject; +private static List _visualizerAnimators = new List(); +private static object _animatorPulseCoroutine; + +public static bool IsVisualizerActive => _visualizerGameObject != null && !string.IsNullOrEmpty(_lastVisualizerInstanceId); + +public static void OnSelectPropToSpawn(string guid, string propImage, string propName) +{ + // Cancel any existing fetch + OnClearPropToSpawn(); + + if (!ModSettings.EntryPropSpawnVisualizer.Value) + return; + + _cancelPendingFetch = false; + _currentFetchCoroutine = MelonCoroutines.Start(FetchAndSpawnProp(guid)); +} + +private static IEnumerator FetchAndSpawnProp(string guid) +{ + PropsButBetterMod.Logger.Msg($"Fetching prop meta for {guid}..."); + + // Start the async task + var task = PropApiHelper.GetPropMeta(guid); + + // Wait for completion + while (!task.IsCompleted) + { + if (_cancelPendingFetch) + { + PropsButBetterMod.Logger.Msg("Fetch cancelled by user"); + _currentFetchCoroutine = null; + yield break; + } + yield return null; + } + + // Check for cancellation after task completes + if (_cancelPendingFetch) + { + PropsButBetterMod.Logger.Msg("Fetch cancelled by user"); + _currentFetchCoroutine = null; + yield break; + } + + // Check for errors + if (task.IsFaulted) + { + PropsButBetterMod.Logger.Error($"Failed to fetch prop meta: {task.Exception?.Message}"); + _currentFetchCoroutine = null; + yield break; + } + + var response = task.Result; + + // Validate response + if (response == null || !response.IsSuccessStatusCode) + { + PropsButBetterMod.Logger.Error($"Failed to fetch prop meta: {response?.Message ?? "Unknown error"}"); + _currentFetchCoroutine = null; + yield break; + } + + UgcWithFile propMeta = response.Data; + + if (propMeta == null) + { + PropsButBetterMod.Logger.Error("Prop meta data was null"); + _currentFetchCoroutine = null; + yield break; + } + + // Create metadata + AssetManagement.UgcMetadata metadata = new() + { + AssetName = propMeta.Name, + AssetId = propMeta.Id, + FileSize = propMeta.FileSize, + FileKey = propMeta.FileKey, + FileHash = propMeta.FileHash, + TagsData = new UgcContentTags(propMeta.Tags), + CompatibilityVersion = propMeta.CompatibilityVersion, + EncryptionAlgorithm = propMeta.EncryptionAlgorithm + }; + + // Generate instance ID for visualizer + _lastVisualizerInstanceId = Guid.NewGuid().ToString(); + + // Register prop data + CVRSyncHelper.PropData newPropData = CVRSyncHelper.PropData.PropDataPool.GetObject(); + newPropData.InstanceId = _lastVisualizerInstanceId; + newPropData.ContentMetadata = metadata; + newPropData.ObjectId = guid; + newPropData.SpawnedBy = "SYSTEM"; + + CVRSyncHelper.Props.Add(newPropData); + _visualizerPropData = newPropData; // Cache it + + // Queue download + CVRDownloadManager.Instance.QueueTask(metadata, DownloadTask.ObjectType.Prop, + propMeta.FileLocation, propMeta.FileId, _lastVisualizerInstanceId, spawnerId: "SYSTEM"); + + PropsButBetterMod.Logger.Msg($"Queued prop '{propMeta.Name}' for download"); + + _currentFetchCoroutine = null; +} + +public static void OnVisualizerPropSpawned() +{ + if (_visualizerPropData == null || _visualizerPropData.Wrapper == null) + return; + + Transform rootTransform = _visualizerPropData.Wrapper.transform; + _visualizerGameObject = rootTransform.gameObject; + + PropVisualizerMode mode = ModSettings.EntryPropSpawnVisualizerMode.Value; + + if (mode == PropVisualizerMode.HologramBounds) + { + ProcessHologramBoundsMode(); + } + else + { + ProcessSourceRenderersMode(); + } + + // Start the animator pulse coroutine + if (_visualizerAnimators.Count > 0) + _animatorPulseCoroutine = MelonCoroutines.Start(PulseAnimators()); + + PropsButBetterMod.Logger.Msg($"Visualizer prop spawned in {mode} mode"); +} + +private static void ProcessSourceRenderersMode() +{ + var allComponents = _visualizerGameObject.GetComponentsInChildren(true); + _visualizerAnimators.Clear(); + + List _sharedMaterials = new(); + + foreach (var component in allComponents) + { + if (component == null) continue; + + // Keep these components + if (component is Transform) continue; + if (component is Renderer renderer) + { + if (ModSettings.EntryPropSpawnVisualizerMode.Value == PropVisualizerMode.HologramSource) + { + int materialCount = renderer.GetMaterialCount(); + // Resize sharedMaterials list efficiently + if (_sharedMaterials.Count < materialCount) + { + for (int i = _sharedMaterials.Count; i < materialCount; i++) + _sharedMaterials.Add(MetaPort.Instance.hologrammMaterial); + } + else if (_sharedMaterials.Count > materialCount) + { + _sharedMaterials.RemoveRange(materialCount, _sharedMaterials.Count - materialCount); + } + renderer.SetSharedMaterials(_sharedMaterials); + } + continue; + } + if (component is MeshFilter) continue; + if (component is CVRSpawnable) continue; + if (component is CVRAssetInfo) continue; + if (component is ParticleSystem) continue; + if (component is Animator animator) + { + animator.enabled = false; + _visualizerAnimators.Add(animator); + continue; + } + + // Remove everything else + component.DestroyComponentWithRequirements(); + } +} + +private static void ProcessHologramBoundsMode() +{ + var allComponents = _visualizerGameObject.GetComponentsInChildren(true); + _visualizerAnimators.Clear(); + + Mesh cubeMesh = PrimitiveHelper.GetPrimitiveMesh(PrimitiveType.Cube); + + foreach (var component in allComponents) + { + if (component == null) continue; + + // Handle animators + if (component is Animator animator) + { + animator.enabled = false; + _visualizerAnimators.Add(animator); + continue; + } + + // Handle renderers - create hologram cubes for visible ones + if (component is Renderer renderer) + { + // Check if renderer is visible + bool isVisible = renderer.enabled && + renderer.gameObject.activeInHierarchy && + renderer.shadowCastingMode != UnityEngine.Rendering.ShadowCastingMode.ShadowsOnly; + + if (isVisible) + { + Mesh mesh = null; + if (renderer is SkinnedMeshRenderer smr) + mesh = smr.sharedMesh; + else if (renderer.TryGetComponent(out MeshFilter filter)) + mesh = filter.sharedMesh; + + if (mesh != null) + { + // Get bounds of mesh + Bounds localBounds = MeshBoundsUtility.CalculateTightBounds(mesh); + + PropsButBetterMod.Logger.Msg(localBounds); + + // Create child GameObject for the cube + GameObject cubeObj = new GameObject("HologramCube"); + cubeObj.transform.SetParent(renderer.transform, false); + + // Add mesh filter and renderer to child + MeshFilter cubeMeshFilter = cubeObj.AddComponent(); + cubeMeshFilter.sharedMesh = cubeMesh; + + MeshRenderer cubeRenderer = cubeObj.AddComponent(); + cubeRenderer.sharedMaterial = MetaPort.Instance.hologrammMaterial; + + // Account for lossy scale when setting the cube's scale + /*Vector3 sourceScale = renderer.transform.lossyScale; + Bounds scaledBounds = new( + Vector3.Scale(localBounds.center, sourceScale), + Vector3.Scale(localBounds.size, sourceScale) + );*/ + + cubeObj.transform.localPosition = localBounds.center; + cubeObj.transform.localRotation = Quaternion.identity; + cubeObj.transform.localScale = localBounds.size; + } + else + { + PropsButBetterMod.Logger.Msg("No mesh on " + renderer.gameObject.name); + } + } + } + + // Keep these components + if (component is Transform) continue; + if (component is CVRSpawnable) continue; + if (component is CVRAssetInfo) continue; + if (component is MeshFilter) continue; + + // Remove everything else + try { component.DestroyComponentWithRequirements(); } + catch (Exception) + { + // ignored + } + } +} + +private static IEnumerator PulseAnimators() +{ + const float skipDuration = 10f; + const float skipRealTime = 5f; + + const float pulseAmplitude = 2f; // seconds forward/back + const float pulseFrequency = 0.2f; // Hz (1 cycle every 2s) + + float t = 0f; + float lastPos = 0f; + + while (true) + { + if (_visualizerAnimators == null || _visualizerAnimators.Count == 0) + yield break; + + t += Time.deltaTime; + + // 0 → 1 blend from "skip" to "pulse" + float blend = Mathf.SmoothStep(0f, 1f, t / skipRealTime); + + // Linear fast-forward (position-based, not speed-based) + float skipPos = Mathf.Lerp( + 0f, + skipDuration, + Mathf.Clamp01(t / skipRealTime) + ); + + // Continuous oscillation around skipDuration + float pulsePos = + skipDuration + + Mathf.Sin(t * Mathf.PI * 2f * pulseFrequency) * pulseAmplitude; + + // Final blended position + float currentPos = Mathf.Lerp(skipPos, pulsePos, blend); + + float delta = currentPos - lastPos; + lastPos = currentPos; + + foreach (var animator in _visualizerAnimators) + { + if (animator != null) + animator.Update(delta); + } + + yield return null; + } +} + +public static void OnHandlePropSpawn(ref ControllerRay __instance) +{ + if (!__instance.uiActive) return; // Skip offhand + if (!IsVisualizerActive) return; // No visualizer active + + Vector3 spawnPos = __instance.Hit.point; + Quaternion spawnRot = GetPropSpawnRotation(spawnPos); + + // Position active visualizer at this position + _visualizerGameObject.transform.position = spawnPos; + _visualizerGameObject.transform.rotation = spawnRot; +} + +private static Quaternion GetPropSpawnRotation(Vector3 spawnPos) +{ + Vector3 playerPos = PlayerSetup.Instance.GetPlayerPosition(); + Vector3 directionToPlayer = (playerPos - spawnPos).normalized; + + // Get gravity direction for objects + GravitySystem.GravityResult result = GravitySystem.TryGetResultingGravity(spawnPos, false); + Vector3 gravityDirection = result.AppliedGravity.normalized; + + // If there is no gravity, use transform.up + if (gravityDirection == Vector3.zero) + gravityDirection = -PlayerSetup.Instance.transform.up; + + Vector3 projectedDirectionToPlayer = Vector3.ProjectOnPlane(directionToPlayer, gravityDirection).normalized; + + if (projectedDirectionToPlayer == Vector3.zero) + { + projectedDirectionToPlayer = Vector3.Cross(gravityDirection, Vector3.right).normalized; + if (projectedDirectionToPlayer == Vector3.zero) + projectedDirectionToPlayer = Vector3.Cross(gravityDirection, Vector3.forward).normalized; + } + + Vector3 right = Vector3.Cross(gravityDirection, projectedDirectionToPlayer).normalized; + Vector3 finalForward = Vector3.Cross(right, gravityDirection).normalized; + + if (Vector3.Dot(finalForward, directionToPlayer) < 0) + finalForward *= -1; + + Quaternion rotation = Quaternion.LookRotation(finalForward, -gravityDirection); + return rotation; +} + +public static void OnClearPropToSpawn() +{ + // Stop animator pulse coroutine + if (_animatorPulseCoroutine != null) + { + MelonCoroutines.Stop(_animatorPulseCoroutine); + _animatorPulseCoroutine = null; + } + + // Clear animator list + _visualizerAnimators?.Clear(); + + // Stop any pending fetch coroutine + if (_currentFetchCoroutine != null) + { + _cancelPendingFetch = true; + MelonCoroutines.Stop(_currentFetchCoroutine); + _currentFetchCoroutine = null; + PropsButBetterMod.Logger.Msg("Stopped pending fetch operation"); + } + + if (string.IsNullOrEmpty(_lastVisualizerInstanceId)) + return; + + // Cancel pending attachment in download queue + CVRDownloadManager.Instance.CancelAttachment(_lastVisualizerInstanceId); + + // Find and remove prop data + if (_visualizerPropData != null) + { + CVRSyncHelper.Props.Remove(_visualizerPropData); + _visualizerPropData.Recycle(); // this removes the gameobject if spawned + _visualizerPropData = null; + } + + _visualizerGameObject = null; + _lastVisualizerInstanceId = null; + + PropsButBetterMod.Logger.Msg("Cleared prop visualizer"); +} +} \ No newline at end of file diff --git a/PropsButBetter/PropsButBetter/Helpers/RendererHelper.cs b/PropsButBetter/PropsButBetter/Helpers/RendererHelper.cs new file mode 100644 index 0000000..6c5bee4 --- /dev/null +++ b/PropsButBetter/PropsButBetter/Helpers/RendererHelper.cs @@ -0,0 +1,33 @@ +using System; +using System.Reflection; +using System.Runtime.CompilerServices; +using UnityEngine; + +public static class RendererHelper +{ + private static readonly Func _getMaterialCount; + + static RendererHelper() + { + // Find the private method + MethodInfo mi = typeof(Renderer).GetMethod( + "GetMaterialCount", + BindingFlags.Instance | BindingFlags.NonPublic + ); + + if (mi != null) + { + // Create a fast delegate + _getMaterialCount = (Func)Delegate.CreateDelegate( + typeof(Func), + null, + mi + ); + } + } + + public static int GetMaterialCount(this Renderer renderer) + { + return _getMaterialCount?.Invoke(renderer) ?? 0; + } +} \ No newline at end of file diff --git a/PropsButBetter/PropsButBetter/Helpers/UILibHelper.cs b/PropsButBetter/PropsButBetter/Helpers/UILibHelper.cs new file mode 100644 index 0000000..f5fb890 --- /dev/null +++ b/PropsButBetter/PropsButBetter/Helpers/UILibHelper.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using ABI_RC.Systems.UI.UILib; + +namespace NAK.PropsButBetter; + +public static class UILibHelper +{ + public static string PlaceholderImageCoui => GetIconCoui(ModSettings.ModName, $"{ModSettings.ModName}-placeholder"); + + public static string GetIconCoui(string modName, string iconName) + { + modName = UIUtils.GetCleanString(modName); + return $"coui://uiresources/GameUI/UILib/Images/{modName}/{iconName}.png"; + } + + internal static void LoadIcons() + { + // Load all icons + Assembly assembly = Assembly.GetExecutingAssembly(); + string assemblyName = assembly.GetName().Name; + + QuickMenuAPI.PrepareIcon(ModSettings.ModName, $"{ModSettings.ModName}-remove", GetIconStream("remove.png")); + QuickMenuAPI.PrepareIcon(ModSettings.ModName, $"{ModSettings.ModName}-reload", GetIconStream("reload.png")); + QuickMenuAPI.PrepareIcon(ModSettings.ModName, $"{ModSettings.ModName}-select", GetIconStream("select.png")); + QuickMenuAPI.PrepareIcon(ModSettings.ModName, $"{ModSettings.ModName}-undo", GetIconStream("undo.png")); + QuickMenuAPI.PrepareIcon(ModSettings.ModName, $"{ModSettings.ModName}-redo", GetIconStream("redo.png")); + QuickMenuAPI.PrepareIcon(ModSettings.ModName, $"{ModSettings.ModName}-wand", GetIconStream("wand.png")); + QuickMenuAPI.PrepareIcon(ModSettings.ModName, $"{ModSettings.ModName}-rubiks-cube", GetIconStream("rubiks-cube.png")); + QuickMenuAPI.PrepareIcon(ModSettings.ModName, $"{ModSettings.ModName}-rubiks-cube-eye", GetIconStream("rubiks-cube-eye.png")); + QuickMenuAPI.PrepareIcon(ModSettings.ModName, $"{ModSettings.ModName}-rubiks-cube-star", GetIconStream("rubiks-cube-star.png")); + QuickMenuAPI.PrepareIcon(ModSettings.ModName, $"{ModSettings.ModName}-rubiks-cube-clock", GetIconStream("rubiks-cube-clock.png")); + QuickMenuAPI.PrepareIcon(ModSettings.ModName, $"{ModSettings.ModName}-placeholder", GetIconStream("placeholder.png")); + Stream GetIconStream(string iconName) => assembly.GetManifestResourceStream($"{assemblyName}.Resources.{iconName}"); + } +} \ No newline at end of file diff --git a/PropsButBetter/PropsButBetter/PropDistanceHider.cs b/PropsButBetter/PropsButBetter/PropDistanceHider.cs new file mode 100644 index 0000000..907a5b9 --- /dev/null +++ b/PropsButBetter/PropsButBetter/PropDistanceHider.cs @@ -0,0 +1,858 @@ +/*using ABI_RC.Core.Player; +using ABI_RC.Core.Util; +using ABI_RC.Systems.RuntimeDebug; +using ABI.CCK.Components; +using UnityEngine; + +namespace NAK.PropsButBetter; + +public static class PropDistanceHider +{ + public enum HiderMode + { + Disabled, + DisableRendering, + } + + private class PropPart + { + public Transform Transform; + public Transform BoundsCenter; // Child transform at local bounds center + public float BoundsRadius; // Local extents magnitude + } + + private class PropCache + { + public CVRSyncHelper.PropData PropData; + + public PropPart[] Parts; + public int PartCount; + + // Renderers (not Behaviours, have enabled) + public Renderer[] Renderers; + public int RendererCount; + public bool[] RendererEnabled; + + // Colliders (not Behaviours, have enabled) + public Collider[] Colliders; + public int ColliderCount; + public bool[] ColliderEnabled; + + // Rigidbodies (no enabled, use isKinematic) + public Rigidbody[] Rigidbodies; + public int RigidbodyCount; + public bool[] RigidbodyWasKinematic; + + // ParticleSystems (not Behaviours, no enabled, use Play/Stop) + public ParticleSystem[] ParticleSystems; + public int ParticleSystemCount; + public bool[] PsWasPlaying; + public bool[] PsPlayOnAwake; + public float[] PsTime; + public double[] PsStartTime; + public uint[] PsRandomSeed; + public bool[] PsUseAutoSeed; + + // CVRPickupObject - must drop before disabling + public CVRPickupObject[] Pickups; + public int PickupCount; + + // CVRAttachment - must deattach before disabling + public CVRAttachment[] Attachments; + public int AttachmentCount; + + // CVRInteractable - must disable before other behaviours + public CVRInteractable[] Interactables; + public int InteractableCount; + public bool[] InteractableEnabled; + + // Behaviour state tracking + public Behaviour[] Behaviours; + public int BehaviourCount; + public bool[] BehaviourEnabled; + + // Animator state + public bool[] AnimWasEnabled; + public double[] AnimStartTime; + + // AudioSource state + public bool[] AudioWasPlaying; + public bool[] AudioLoop; + public bool[] AudioPlayOnAwake; + public double[] AudioStartDsp; + public float[] AudioStartTime; + + public bool IsHidden; + } + + public static float Hysteresis = 2f; + + private static readonly Dictionary _propCaches = new(); + private static readonly List _propCacheList = new(); + private static readonly List _toRemove = new(); + + // Reusable collections for spawn processing (avoid allocations) + private static readonly Dictionary _tempPartLookup = new(); + private static readonly List _tempBoundsList = new(); + private static readonly List _tempRenderers = new(); + private static readonly List _tempColliders = new(); + private static readonly List _tempRigidbodies = new(); + private static readonly List _tempParticleSystems = new(); + private static readonly List _tempPickups = new(); + private static readonly List _tempAttachments = new(); + private static readonly List _tempInteractables = new(); + private static readonly List _tempBehaviours = new(); + private static readonly List _tempRendererPartIndices = new(); + + public static void OnPropSpawned(CVRSyncHelper.PropData propData) + { + GameObject propObject = propData.Wrapper; + if (!propObject) + return; + + CVRSpawnable spawnable = propData.Spawnable; + if (!spawnable) + return; + + Transform spawnableTransform = spawnable.transform; + var subSyncs = spawnable.subSyncs; + int subSyncCount = subSyncs?.Count ?? 0; + + // Build parts array (root + subsyncs) + int partCount = 1 + subSyncCount; + PropPart[] parts = new PropPart[partCount]; + + // Clear temp collections + _tempPartLookup.Clear(); + _tempRenderers.Clear(); + _tempColliders.Clear(); + _tempRigidbodies.Clear(); + _tempParticleSystems.Clear(); + _tempPickups.Clear(); + _tempAttachments.Clear(); + _tempInteractables.Clear(); + _tempBehaviours.Clear(); + _tempRendererPartIndices.Clear(); + + // Root spawnable part + parts[0] = new PropPart { Transform = spawnableTransform, BoundsCenter = null, BoundsRadius = 0f }; + _tempPartLookup[spawnableTransform] = 0; + + // Subsync parts + for (int i = 0; i < subSyncCount; i++) + { + CVRSpawnableSubSync subSync = subSyncs?[i]; + if (subSync == null) continue; + + Transform subSyncTransform = subSync.transform; + parts[i + 1] = new PropPart { Transform = subSyncTransform, BoundsCenter = null, BoundsRadius = 0f }; + _tempPartLookup[subSyncTransform] = i + 1; + } + + // Get all components once and categorize + Component[] allComponents = propObject.GetComponentsInChildren(true); + int componentCount = allComponents.Length; + + for (int i = 0; i < componentCount; i++) + { + Component comp = allComponents[i]; + if (!comp) continue; + + if (comp is Renderer renderer) + { + _tempRenderers.Add(renderer); + _tempRendererPartIndices.Add(FindOwningPartIndex(renderer.transform)); + } + else if (comp is Collider collider) + { + _tempColliders.Add(collider); + } + else if (comp is Rigidbody rigidbody) + { + _tempRigidbodies.Add(rigidbody); + } + else if (comp is ParticleSystem particleSystem) + { + _tempParticleSystems.Add(particleSystem); + } + else if (comp is CVRPickupObject pickup) + { + _tempPickups.Add(pickup); + } + else if (comp is CVRAttachment attachment) + { + _tempAttachments.Add(attachment); + } + else if (comp is CVRInteractable interactable) + { + _tempInteractables.Add(interactable); + } + else if (comp is Behaviour behaviour) + { + // Skip the spawnable as it needs to be enabled to sync moving + if (behaviour is CVRSpawnable) + continue; + + _tempBehaviours.Add(behaviour); + } + } + + // Copy to final arrays + int rendererCount = _tempRenderers.Count; + int colliderCount = _tempColliders.Count; + int rigidbodyCount = _tempRigidbodies.Count; + int particleSystemCount = _tempParticleSystems.Count; + int pickupCount = _tempPickups.Count; + int attachmentCount = _tempAttachments.Count; + int interactableCount = _tempInteractables.Count; + int behaviourCount = _tempBehaviours.Count; + + Renderer[] renderers = new Renderer[rendererCount]; + int[] rendererPartIndices = new int[rendererCount]; + Collider[] colliders = new Collider[colliderCount]; + Rigidbody[] rigidbodies = new Rigidbody[rigidbodyCount]; + ParticleSystem[] particleSystems = new ParticleSystem[particleSystemCount]; + CVRPickupObject[] pickups = new CVRPickupObject[pickupCount]; + CVRAttachment[] attachments = new CVRAttachment[attachmentCount]; + CVRInteractable[] interactables = new CVRInteractable[interactableCount]; + Behaviour[] behaviours = new Behaviour[behaviourCount]; + + for (int i = 0; i < rendererCount; i++) + { + renderers[i] = _tempRenderers[i]; + rendererPartIndices[i] = _tempRendererPartIndices[i]; + } + + for (int i = 0; i < colliderCount; i++) + colliders[i] = _tempColliders[i]; + + for (int i = 0; i < rigidbodyCount; i++) + rigidbodies[i] = _tempRigidbodies[i]; + + for (int i = 0; i < particleSystemCount; i++) + particleSystems[i] = _tempParticleSystems[i]; + + for (int i = 0; i < pickupCount; i++) + pickups[i] = _tempPickups[i]; + + for (int i = 0; i < attachmentCount; i++) + attachments[i] = _tempAttachments[i]; + + for (int i = 0; i < interactableCount; i++) + interactables[i] = _tempInteractables[i]; + + for (int i = 0; i < behaviourCount; i++) + behaviours[i] = _tempBehaviours[i]; + + // Calculate bounds per part in local space + for (int p = 0; p < partCount; p++) + { + PropPart part = parts[p]; + Transform partTransform = part.Transform; + if (!partTransform) + continue; + + _tempBoundsList.Clear(); + + for (int i = 0; i < rendererCount; i++) + { + if (rendererPartIndices[i] != p) + continue; + + // We are ignoring particle systems because their bounds are cooked. + // Their initial bounds seem leftover from whatever was last in-editor. + Renderer renderer = renderers[i]; + if (!renderer || renderer is ParticleSystemRenderer) + continue; + + Bounds worldBounds = renderer.bounds; + + // Convert bounds to part's local space + Vector3 localCenter = partTransform.InverseTransformPoint(worldBounds.center); + Vector3 localExtents = partTransform.InverseTransformVector(worldBounds.extents); + localExtents = new Vector3( + Mathf.Abs(localExtents.x), + Mathf.Abs(localExtents.y), + Mathf.Abs(localExtents.z) + ); + + Bounds localBounds = new Bounds(localCenter, localExtents * 2f); + _tempBoundsList.Add(localBounds); + } + + int boundsCount = _tempBoundsList.Count; + if (boundsCount > 0) + { + // Combine all renderer local bounds into one + Bounds combined = _tempBoundsList[0]; + for (int i = 1; i < boundsCount; i++) + combined.Encapsulate(_tempBoundsList[i]); + + // Include the prop root as an enforced bounds point. + // This makes some props which have their contents really far away at least visible on spawn + // even with an aggressive distance hider configuration. Ex: the big fish ship + if (p == 0 && ModSettings.EntryIncludePropRootInBounds.Value) + combined.Encapsulate(-partTransform.localPosition); + + // Create the BoundsCenter object + GameObject boundsCenterObj = new GameObject("_BoundsCenter"); + Transform boundsCenterObjTransform = boundsCenterObj.transform; + boundsCenterObjTransform.SetParent(partTransform, false); + boundsCenterObjTransform.localPosition = combined.center; + boundsCenterObjTransform.localScale = Vector3.one; + + float radius = 0f; + Vector3 c = combined.center; + Vector3 e = combined.extents; + radius = Mathf.Max(radius, (c + new Vector3( e.x, e.y, e.z) - c).magnitude); + radius = Mathf.Max(radius, (c + new Vector3( e.x, e.y, -e.z) - c).magnitude); + radius = Mathf.Max(radius, (c + new Vector3( e.x, -e.y, e.z) - c).magnitude); + radius = Mathf.Max(radius, (c + new Vector3( e.x, -e.y, -e.z) - c).magnitude); + radius = Mathf.Max(radius, (c + new Vector3(-e.x, e.y, e.z) - c).magnitude); + radius = Mathf.Max(radius, (c + new Vector3(-e.x, e.y, -e.z) - c).magnitude); + radius = Mathf.Max(radius, (c + new Vector3(-e.x, -e.y, e.z) - c).magnitude); + radius = Mathf.Max(radius, (c + new Vector3(-e.x, -e.y, -e.z) - c).magnitude); + + part.BoundsRadius = radius; + part.BoundsCenter = boundsCenterObjTransform; + } + } + + // Clear temp collections + _tempPartLookup.Clear(); + _tempBoundsList.Clear(); + _tempRenderers.Clear(); + _tempColliders.Clear(); + _tempRigidbodies.Clear(); + _tempParticleSystems.Clear(); + _tempPickups.Clear(); + _tempAttachments.Clear(); + _tempInteractables.Clear(); + _tempBehaviours.Clear(); + _tempRendererPartIndices.Clear(); + + var cache = new PropCache + { + PropData = propData, + Parts = parts, + PartCount = partCount, + Renderers = renderers, + RendererCount = rendererCount, + RendererEnabled = new bool[rendererCount], + Colliders = colliders, + ColliderCount = colliderCount, + ColliderEnabled = new bool[colliderCount], + Rigidbodies = rigidbodies, + RigidbodyCount = rigidbodyCount, + RigidbodyWasKinematic = new bool[rigidbodyCount], + ParticleSystems = particleSystems, + ParticleSystemCount = particleSystemCount, + PsWasPlaying = new bool[particleSystemCount], + PsPlayOnAwake = new bool[particleSystemCount], + PsTime = new float[particleSystemCount], + PsStartTime = new double[particleSystemCount], + PsRandomSeed = new uint[particleSystemCount], + PsUseAutoSeed = new bool[particleSystemCount], + Pickups = pickups, + PickupCount = pickupCount, + Attachments = attachments, + AttachmentCount = attachmentCount, + Interactables = interactables, + InteractableCount = interactableCount, + InteractableEnabled = new bool[interactableCount], + Behaviours = behaviours, + BehaviourCount = behaviourCount, + BehaviourEnabled = new bool[behaviourCount], + AnimWasEnabled = new bool[behaviourCount], + AnimStartTime = new double[behaviourCount], + AudioWasPlaying = new bool[behaviourCount], + AudioLoop = new bool[behaviourCount], + AudioPlayOnAwake = new bool[behaviourCount], + AudioStartDsp = new double[behaviourCount], + AudioStartTime = new float[behaviourCount], + IsHidden = false + }; + + _propCaches[propData] = cache; + _propCacheList.Add(cache); + } + + private static int FindOwningPartIndex(Transform transform) + { + Transform current = transform; + while (current) + { + if (_tempPartLookup.TryGetValue(current, out int partIndex)) + return partIndex; + current = current.parent; + } + return 0; // Default to root + } + + public static void OnPropDestroyed(CVRSyncHelper.PropData propData) + { + if (!_propCaches.TryGetValue(propData, out PropCache cache)) + return; + + if (cache.IsHidden) + ShowProp(cache); + + _propCaches.Remove(propData); + _propCacheList.Remove(cache); + } + + public static void Tick() + { + HiderMode mode = ModSettings.EntryPropDistanceHiderMode.Value; + if (mode == HiderMode.Disabled) + return; + + PlayerSetup playerSetup = PlayerSetup.Instance; + if (!playerSetup) + return; + + Camera activeCam = playerSetup.activeCam; + if (!activeCam) + return; + + Vector3 playerPos = activeCam.transform.position; + float hideDistance = ModSettings.EntryPropDistanceHiderDistance.Value; + float hysteresis = Hysteresis; + float hideDistSqr = hideDistance * hideDistance; + float showDist = hideDistance - hysteresis; + float showDistSqr = showDist * showDist; + + int cacheCount = _propCacheList.Count; + for (int i = 0; i < cacheCount; i++) + { + PropCache cache = _propCacheList[i]; + PropPart[] parts = cache.Parts; + int partCount = cache.PartCount; + + bool isHidden = cache.IsHidden; + float threshold = isHidden ? showDistSqr : hideDistSqr; + + bool anyPartValid = false; + bool anyPartWithinThreshold = false; + + // Debug gizmos for all parts + if (ModSettings.DebugPropMeasuredBounds.Value) + { + Quaternion playerRot = activeCam.transform.rotation; + Vector3 viewOffset = new Vector3(0f, -0.15f, 0f); + Vector3 debugLineStartPos = playerPos + playerRot * viewOffset; + for (int p = 0; p < partCount; p++) + { + PropPart part = parts[p]; + Transform boundsCenter = part.BoundsCenter; + + if (!boundsCenter) + continue; + + Vector3 boundsCenterPos = boundsCenter.position; + + // Compute world-space radius based on runtime scale (largest axis) + Vector3 lossyScale = boundsCenter.lossyScale; + float radius = part.BoundsRadius * Mathf.Max(lossyScale.x, Mathf.Max(lossyScale.y, lossyScale.z)); + + float distToCenter = Vector3.Distance(playerPos, boundsCenterPos); + float distToEdge = distToCenter - radius; + + // RuntimeGizmos DrawSphere is off by 2x + Color boundsColor = isHidden ? Color.red : Color.green; + RuntimeGizmos.DrawSphere(boundsCenterPos, radius * 2f, boundsColor, opacity: 0.2f); + RuntimeGizmos.DrawLineFromTo(debugLineStartPos, boundsCenterPos, 0.01f, boundsColor, opacity: 0.1f); + RuntimeGizmos.DrawText(boundsCenterPos, $"{distToEdge:F1}m {(isHidden ? "[Hidden]" : "[Visible]")}", 0.1f, boundsColor, opacity: 1f); + } + } + + for (int p = 0; p < partCount; p++) + { + PropPart part = parts[p]; + Transform boundsCenter = part.BoundsCenter; + + if (!boundsCenter) + continue; + + anyPartValid = true; + + // World-space radius with runtime scale + Vector3 lossyScale = boundsCenter.lossyScale; + float radius = part.BoundsRadius * Mathf.Max(lossyScale.x, Mathf.Max(lossyScale.y, lossyScale.z)); + + float distToCenter = Vector3.Distance(playerPos, boundsCenter.position); + float distToEdge = distToCenter - radius; + float distToEdgeSqr = distToEdge * distToEdge; + + if (distToEdge < 0f) + distToEdgeSqr = -distToEdgeSqr; + + if (distToEdgeSqr < threshold) + { + anyPartWithinThreshold = true; + break; + } + } + + if (!anyPartValid) + { + _toRemove.Add(cache.PropData); + continue; + } + + if (!isHidden && !anyPartWithinThreshold) + { + HideProp(cache); + } + else if (isHidden && anyPartWithinThreshold) + { + ShowProp(cache); + } + } + + int removeCount = _toRemove.Count; + if (removeCount > 0) + { + for (int i = 0; i < removeCount; i++) + { + CVRSyncHelper.PropData propData = _toRemove[i]; + if (_propCaches.TryGetValue(propData, out PropCache cache)) + { + _propCaches.Remove(propData); + _propCacheList.Remove(cache); + } + } + _toRemove.Clear(); + } + } + + private static void HideProp(PropCache cache) + { + cache.IsHidden = true; + + double dsp = AudioSettings.dspTime; + double time = Time.timeAsDouble; + + // Drop all pickups first to avoid race conditions + CVRPickupObject[] pickups = cache.Pickups; + int pickupCount = cache.PickupCount; + for (int i = 0; i < pickupCount; i++) + { + CVRPickupObject pickup = pickups[i]; + if (!pickup) continue; + pickup.ControllerRay = null; + } + + // Deattach all attachments before disabling + CVRAttachment[] attachments = cache.Attachments; + int attachmentCount = cache.AttachmentCount; + for (int i = 0; i < attachmentCount; i++) + { + CVRAttachment attachment = attachments[i]; + if (!attachment) continue; + attachment.DeAttach(); + } + + // Disable interactables before other behaviours + CVRInteractable[] interactables = cache.Interactables; + bool[] interactableEnabled = cache.InteractableEnabled; + int interactableCount = cache.InteractableCount; + for (int i = 0; i < interactableCount; i++) + { + CVRInteractable interactable = interactables[i]; + if (!interactable) continue; + interactableEnabled[i] = interactable.enabled; + interactable.enabled = false; + } + + // Snapshot and disable renderers + Renderer[] renderers = cache.Renderers; + bool[] rendererEnabled = cache.RendererEnabled; + int rendererCount = cache.RendererCount; + for (int i = 0; i < rendererCount; i++) + { + Renderer renderer = renderers[i]; + if (!renderer) continue; + rendererEnabled[i] = renderer.enabled; + renderer.enabled = false; + } + + // Snapshot and disable colliders + Collider[] colliders = cache.Colliders; + bool[] colliderEnabled = cache.ColliderEnabled; + int colliderCount = cache.ColliderCount; + for (int i = 0; i < colliderCount; i++) + { + Collider collider = colliders[i]; + if (!collider) continue; + colliderEnabled[i] = collider.enabled; + collider.enabled = false; + } + + // Snapshot and set rigidbodies to kinematic + Rigidbody[] rigidbodies = cache.Rigidbodies; + bool[] rigidbodyWasKinematic = cache.RigidbodyWasKinematic; + int rigidbodyCount = cache.RigidbodyCount; + for (int i = 0; i < rigidbodyCount; i++) + { + Rigidbody rb = rigidbodies[i]; + if (!rb) continue; + rigidbodyWasKinematic[i] = rb.isKinematic; + rb.isKinematic = true; + } + + // Snapshot and stop particle systems + ParticleSystem[] particleSystems = cache.ParticleSystems; + bool[] psWasPlaying = cache.PsWasPlaying; + bool[] psPlayOnAwake = cache.PsPlayOnAwake; + float[] psTime = cache.PsTime; + double[] psStartTime = cache.PsStartTime; + uint[] psRandomSeed = cache.PsRandomSeed; + bool[] psUseAutoSeed = cache.PsUseAutoSeed; + int particleSystemCount = cache.ParticleSystemCount; + for (int i = 0; i < particleSystemCount; i++) + { + ParticleSystem ps = particleSystems[i]; + if (!ps) continue; + + var main = ps.main; + psPlayOnAwake[i] = main.playOnAwake; + + if (ps.isPlaying) + { + psWasPlaying[i] = true; + psTime[i] = ps.time; + psStartTime[i] = time; + } + else + { + psWasPlaying[i] = false; + } + + psUseAutoSeed[i] = ps.useAutoRandomSeed; + psRandomSeed[i] = ps.randomSeed; + + main.playOnAwake = false; + ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); + } + + // Snapshot and disable behaviours + Behaviour[] behaviours = cache.Behaviours; + bool[] behaviourEnabled = cache.BehaviourEnabled; + bool[] animWasEnabled = cache.AnimWasEnabled; + double[] animStartTime = cache.AnimStartTime; + bool[] audioWasPlaying = cache.AudioWasPlaying; + bool[] audioLoop = cache.AudioLoop; + bool[] audioPlayOnAwake = cache.AudioPlayOnAwake; + double[] audioStartDsp = cache.AudioStartDsp; + float[] audioStartTime = cache.AudioStartTime; + int behaviourCount = cache.BehaviourCount; + + for (int i = 0; i < behaviourCount; i++) + { + Behaviour b = behaviours[i]; + if (!b) continue; + + behaviourEnabled[i] = b.enabled; + + if (!b.enabled) continue; + + if (b is Animator animator) + { + animWasEnabled[i] = true; + animStartTime[i] = time; + animator.enabled = false; + continue; + } + + if (b is AudioSource audio) + { + audioLoop[i] = audio.loop; + audioPlayOnAwake[i] = audio.playOnAwake; + + if (audio.isPlaying) + { + audioWasPlaying[i] = true; + audioStartTime[i] = audio.time; + audioStartDsp[i] = dsp - audio.time; + } + else + { + audioWasPlaying[i] = false; + } + + audio.Stop(); + audio.playOnAwake = false; + audio.enabled = false; + continue; + } + + b.enabled = false; + } + } + + private static void ShowProp(PropCache cache) + { + double dsp = AudioSettings.dspTime; + double time = Time.timeAsDouble; + + cache.IsHidden = false; + + // Restore renderers + Renderer[] renderers = cache.Renderers; + bool[] rendererEnabled = cache.RendererEnabled; + int rendererCount = cache.RendererCount; + for (int i = 0; i < rendererCount; i++) + { + Renderer renderer = renderers[i]; + if (!renderer) continue; + renderer.enabled = rendererEnabled[i]; + } + + // Restore colliders + Collider[] colliders = cache.Colliders; + bool[] colliderEnabled = cache.ColliderEnabled; + int colliderCount = cache.ColliderCount; + for (int i = 0; i < colliderCount; i++) + { + Collider collider = colliders[i]; + if (!collider) continue; + collider.enabled = colliderEnabled[i]; + } + + // Restore rigidbodies + Rigidbody[] rigidbodies = cache.Rigidbodies; + bool[] rigidbodyWasKinematic = cache.RigidbodyWasKinematic; + int rigidbodyCount = cache.RigidbodyCount; + for (int i = 0; i < rigidbodyCount; i++) + { + Rigidbody rb = rigidbodies[i]; + if (!rb) continue; + rb.isKinematic = rigidbodyWasKinematic[i]; + } + + // Restore particle systems + ParticleSystem[] particleSystems = cache.ParticleSystems; + bool[] psWasPlaying = cache.PsWasPlaying; + bool[] psPlayOnAwake = cache.PsPlayOnAwake; + float[] psTime = cache.PsTime; + double[] psStartTime = cache.PsStartTime; + uint[] psRandomSeed = cache.PsRandomSeed; + bool[] psUseAutoSeed = cache.PsUseAutoSeed; + int particleSystemCount = cache.ParticleSystemCount; + for (int i = 0; i < particleSystemCount; i++) + { + ParticleSystem ps = particleSystems[i]; + if (!ps) continue; + + if (psWasPlaying[i]) + { + ps.useAutoRandomSeed = false; + ps.randomSeed = psRandomSeed[i]; + + float originalTime = psTime[i]; + float elapsed = (float)(time - psStartTime[i]); + float simulateTime = originalTime + elapsed; + + ps.Simulate( + simulateTime, + withChildren: true, + restart: true, + fixedTimeStep: false + ); + + ps.Play(true); + + ps.useAutoRandomSeed = psUseAutoSeed[i]; + } + + var main = ps.main; + main.playOnAwake = psPlayOnAwake[i]; + } + + // Restore behaviours + Behaviour[] behaviours = cache.Behaviours; + bool[] behaviourEnabled = cache.BehaviourEnabled; + bool[] animWasEnabled = cache.AnimWasEnabled; + double[] animStartTime = cache.AnimStartTime; + bool[] audioWasPlaying = cache.AudioWasPlaying; + bool[] audioLoop = cache.AudioLoop; + bool[] audioPlayOnAwake = cache.AudioPlayOnAwake; + double[] audioStartDsp = cache.AudioStartDsp; + int behaviourCount = cache.BehaviourCount; + + for (int i = 0; i < behaviourCount; i++) + { + Behaviour b = behaviours[i]; + if (!b) continue; + + bool wasEnabled = behaviourEnabled[i]; + + if (b is Animator animator) + { + animator.enabled = wasEnabled; + + if (wasEnabled && animWasEnabled[i]) + { + float delta = (float)(time - animStartTime[i]); + if (delta > 0f) + animator.Update(delta); + } + + continue; + } + + if (b is AudioSource audio) + { + audio.enabled = wasEnabled; + + if (wasEnabled && audioWasPlaying[i] && audio.clip) + { + double elapsed = dsp - audioStartDsp[i]; + float clipLen = audio.clip.length; + + float t = audioLoop[i] ? (float)(elapsed % clipLen) : (float)elapsed; + if (t < clipLen) + { + audio.time = t; + audio.Play(); + } + } + + audio.playOnAwake = audioPlayOnAwake[i]; + continue; + } + + b.enabled = wasEnabled; + } + + // Restore interactables after other behaviours + CVRInteractable[] interactables = cache.Interactables; + bool[] interactableEnabled = cache.InteractableEnabled; + int interactableCount = cache.InteractableCount; + for (int i = 0; i < interactableCount; i++) + { + CVRInteractable interactable = interactables[i]; + if (!interactable) continue; + interactable.enabled = interactableEnabled[i]; + } + } + + public static void RefreshAll() + { + int count = _propCacheList.Count; + for (int i = 0; i < count; i++) + { + PropCache cache = _propCacheList[i]; + if (cache.IsHidden) + ShowProp(cache); + } + } + + public static void Clear() + { + RefreshAll(); + _propCaches.Clear(); + _propCacheList.Clear(); + } +}*/ \ No newline at end of file diff --git a/PropsButBetter/PropsButBetter/QuickMenuPropList.cs b/PropsButBetter/PropsButBetter/QuickMenuPropList.cs new file mode 100644 index 0000000..e23ae5a --- /dev/null +++ b/PropsButBetter/PropsButBetter/QuickMenuPropList.cs @@ -0,0 +1,440 @@ +using ABI_RC.Core.InteractionSystem; +using ABI_RC.Core.IO; +using ABI_RC.Core.Player; +using ABI_RC.Core.Util; +using ABI_RC.Systems.UI.UILib; +using ABI_RC.Systems.UI.UILib.UIObjects; +using ABI_RC.Systems.UI.UILib.UIObjects.Components; +using UnityEngine; + +namespace NAK.PropsButBetter; + +public static class QuickMenuPropList +{ + public const int TotalPropListModes = 3; + public enum PropListMode + { + AllProps, + MyProps, + History, + } + + private static PropListMode _currentPropListMode = PropListMode.AllProps; + + private static string _propListCategoryName; + private static string _propsListPopulatedText; + private static string _propListEmptyText; + + private static Page _page; + private static Category _actionsCategory; + private static Category _propListCategory; + + private static Button _enterDeleteModeButton; + private static Button _deleteMyPropsButton; + private static Button _deleteOthersPropsButton; + private static Button _cyclePropListModeButton; + private static TextBlock _propListTextBlock; + + private static UndoRedoButtons _undoRedoButtons; + private static ScheduledJob _updateJob; + + private static DateTime _lastTabChangedTime = DateTime.Now; + private static string _rootPageElementID; + private static bool _isOurTabOpened; + + public static void BuildUI() + { + QuickMenuAPI.OnTabChange += OnTabChange; + + _page = Page.GetOrCreatePage(nameof(PropsButBetter), nameof(QuickMenuPropList), isRootPage: true, "PropsButBetter-rubiks-cube"); + _page.MenuTitle = "Prop List"; + _page.MenuSubtitle = "You can see all Props currently spawned in here!"; + _page.OnPageOpen += OnPageOpened; + _page.OnPageClosed += OnPageClosed; + + _rootPageElementID = _page.ElementID; + + _actionsCategory = _page.AddCategory("Quick Actions", false, false); + + _undoRedoButtons = new UndoRedoButtons(); + _undoRedoButtons.OnUndo += OnUndo; + _undoRedoButtons.OnRedo += OnRedo; + + _enterDeleteModeButton = _actionsCategory.AddButton("Delete Mode", "PropsButBetter-wand", "Enters Prop delete mode."); + _enterDeleteModeButton.OnPress += OnDeleteMode; + + _deleteMyPropsButton = _actionsCategory.AddButton("Delete My Props", "PropsButBetter-remove", "Deletes all Props spawned by you. This will remove them for everyone."); + _deleteMyPropsButton.OnPress += OnDeleteMyProps; + + _deleteOthersPropsButton = _actionsCategory.AddButton("Remove Others Props", "PropsButBetter-remove", "Removes all Props spawned by other players only for you. This does not delete them for everyone."); + _deleteOthersPropsButton.OnPress += OnRemoveOthersProps; + + _cyclePropListModeButton = _actionsCategory.AddButton("Cycle Prop List Mode", "PropsButBetter-rubiks-cube", "Cycles the Prop List display mode."); + _cyclePropListModeButton.OnPress += OnCyclePropListMode; + + _propListCategory = _page.AddCategory("All Props", true, true); + _propListTextBlock = _propListCategory.AddTextBlock(string.Empty); + + SetPropListMode(ModSettings.HiddenPropListMode.Value); + } + + private static void OnPageOpened() + { + _undoRedoButtons.SetUndoHidden(false); + _undoRedoButtons.SetRedoHidden(false); + } + + private static void OnPageClosed() + { + _undoRedoButtons.SetUndoHidden(true); + _undoRedoButtons.SetRedoHidden(true); + } + + private static void OnPageUpdate() + { + // Don't run while the menu is closed, it's needless + if (!CVR_MenuManager.Instance.IsViewShown) + return; + + ForceUndoRedoButtonUpdate(); + ForcePropListUpdate(); + } + + private static void ForceUndoRedoButtonUpdate() + { + _undoRedoButtons.SetUndoDisabled(!PropHelper.CanUndo()); + _undoRedoButtons.SetRedoDisabled(!PropHelper.CanRedo()); + } + + private static void ForcePropListUpdate() + { + if (_currentPropListMode == PropListMode.History) + UpdateHistoryPropList(); + else + UpdateLivePropList(); + } + + private static readonly Dictionary _entries = new(); + private static readonly Stack _removalBuffer = new(); + + private static int[] _entrySiblingIndices; + private static int _currentLiveUpdateCycle; + private static int _lastPropCount; + + private static void UpdateLivePropList() + { + List props; + switch (_currentPropListMode) + { + case PropListMode.AllProps: props = CVRSyncHelper.Props; break; + case PropListMode.MyProps: props = PropHelper.MyProps; break; + case PropListMode.History: + default: return; + } + + int propCount = props.Count; + + bool hasPropCountChanged = propCount != _lastPropCount; + bool needsRunRemovalPass = _lastPropCount > propCount; + bool noProps = propCount == 0; + + if (hasPropCountChanged) + { + _lastPropCount = propCount; + _propListCategory.CategoryName = $"{_propListCategoryName} ({propCount})"; + + if (noProps) + { + ClearPropList(); + _propListTextBlock.Text = _propListEmptyText; + } + else + { + // Resize our arrays + Array.Resize(ref _entrySiblingIndices, propCount); + for (int i = 0; i < propCount; i++) _entrySiblingIndices[i] = i; // Reset order for sort + _propListTextBlock.Text = _propsListPopulatedText; + } + } + + if (noProps) + { + // No need to continue update + return; + } + + // Sort props by distance + if (propCount > 1) + { + Vector3 playerPos = PlayerSetup.Instance.activeCam.transform.position; + DistanceComparerStruct comparer = new(playerPos, props); + Array.Sort(_entrySiblingIndices, 0, propCount, comparer); + } + + // Increment the live update cycle count + _currentLiveUpdateCycle += 1; + + // Sort or create the menu entries we need + int index = 1; // Leave the no props text area as the first child + for (int i = 0; i < propCount; i++) + { + var prop = props[_entrySiblingIndices[i]]; + string id = prop.InstanceId; + + if (!_entries.TryGetValue(id, out var entry)) + { + string username = CVRPlayerManager.Instance.TryGetPlayerName(prop.SpawnedBy); + entry = new PropListEntry( + id, + prop.ObjectId, + prop.ContentMetadata.AssetName, + username, + _propListCategory + ); + _entries[id] = entry; + } + + // Set last updated cycle and sort the menu entry + entry.LastUpdatedCycle = _currentLiveUpdateCycle; + entry.SetChildIndexIfNeeded(index++); + } + + if (needsRunRemovalPass) + { + // Iterate all entries now and remove all which did not get fishy flipped + foreach ((string instanceId, PropListEntry entry) in _entries) + { + if (entry.LastUpdatedCycle != _currentLiveUpdateCycle) + { + _removalBuffer.Push(instanceId); + entry.Destroy(); + } + } + + // Remove all which have been scheduled for death + int toRemoveCount = _removalBuffer.Count; + for (int i = 0; i < toRemoveCount; i++) + { + string toRemove = _removalBuffer.Pop(); + _entries.Remove(toRemove); + } + } + } + + private static void UpdateHistoryPropList() + { + int historyCount = PropHelper.SpawnedThisSession.Count; + + bool hasPropCountChanged = historyCount != _lastPropCount; + bool noProps = historyCount == 0; + + if (hasPropCountChanged) + { + _lastPropCount = historyCount; + _propListCategory.CategoryName = $"{_propListCategoryName} ({historyCount})"; + + if (noProps) + { + ClearPropList(); + _propListTextBlock.Text = _propListEmptyText; + } + else + { + _propListTextBlock.Text = _propsListPopulatedText; + + } + } + + if (noProps) + { + // No need to continue update + return; + } + + // Increment the live update cycle count + _currentLiveUpdateCycle += 1; + + // Process history entries in order (newest first since list is already ordered that way) + int index = 1; // Leave the no props text area as the first child + for (int i = 0; i < historyCount; i++) + { + var historyData = PropHelper.SpawnedThisSession[i]; + string id = historyData.InstanceId; + + if (!_entries.TryGetValue(id, out var entry)) + { + entry = new PropListEntry( + historyData.InstanceId, + historyData.PropId, + historyData.PropName, + historyData.SpawnerName, + _propListCategory + ); + _entries[id] = entry; + } + + // Update destroyed state + entry.SetIsDestroyed(historyData.IsDestroyed); + + // Set last updated cycle and sort the menu entry + entry.LastUpdatedCycle = _currentLiveUpdateCycle; + entry.SetChildIndexIfNeeded(index++); + } + + // Remove entries that are no longer in history + foreach ((string instanceId, PropListEntry entry) in _entries) + { + if (entry.LastUpdatedCycle != _currentLiveUpdateCycle) + { + _removalBuffer.Push(instanceId); + entry.Destroy(); + } + } + + // Remove all which have been scheduled for death + int toRemoveCount = _removalBuffer.Count; + for (int i = 0; i < toRemoveCount; i++) + { + string toRemove = _removalBuffer.Pop(); + _entries.Remove(toRemove); + } + } + + private static void ClearPropList() + { + _lastPropCount = -1; // Forces rebuild of prop list + foreach (PropListEntry entry in _entries.Values) entry.Destroy(); + _entries.Clear(); + } + + private static void RebuildPropList() + { + ClearPropList(); + ForcePropListUpdate(); + } + + private readonly struct DistanceComparerStruct(Vector3 playerPos, IReadOnlyList props) : IComparer + { + public int Compare(int a, int b) + { + var pa = props[a]; + var pb = props[b]; + + float dx = pa.PositionX - playerPos.x; + float dy = pa.PositionY - playerPos.y; + float dz = pa.PositionZ - playerPos.z; + float da = dx * dx + dy * dy + dz * dz; + + dx = pb.PositionX - playerPos.x; + dy = pb.PositionY - playerPos.y; + dz = pb.PositionZ - playerPos.z; + float db = dx * dx + dy * dy + dz * dz; + + return da.CompareTo(db); + } + } + + private static void OnTabChange(string newTab, string previousTab) + { + bool isOurTabOpened = newTab == _rootPageElementID; + + // Check for change + if (isOurTabOpened != _isOurTabOpened) + { + _isOurTabOpened = isOurTabOpened; + if (_isOurTabOpened) + { + if (_updateJob == null) _updateJob = BetterScheduleSystem.AddJob(OnPageUpdate, 0f, 1f); + OnPageUpdate(); + } + else + { + if (_updateJob != null) BetterScheduleSystem.RemoveJob(_updateJob); + _updateJob = null; + } + } + + if (!_isOurTabOpened) return; + + TimeSpan timeDifference = DateTime.Now - _lastTabChangedTime; + if (timeDifference.TotalSeconds <= 0.5) + { + OnDeleteMode(); + return; + } + _lastTabChangedTime = DateTime.Now; + } + + private static void OnUndo() + { + PropHelper.UndoProp(); + ForceUndoRedoButtonUpdate(); + } + + private static void OnRedo() + { + PropHelper.RedoProp(); + ForceUndoRedoButtonUpdate(); + } + + private static void OnDeleteMode() + { + PlayerSetup.Instance.ToggleDeleteMode(); + } + + private static void OnDeleteMyProps() + { + PropHelper.RemoveMyProps(); + + // Force a page update + ForcePropListUpdate(); + } + + private static void OnRemoveOthersProps() + { + PropHelper.RemoveOthersProps(); + + // Force a page update + ForcePropListUpdate(); + } + + private static void OnCyclePropListMode() + => CyclePropListMode(); + + public static void CyclePropListMode() + { + int nextMode = (int)_currentPropListMode + 1; + SetPropListMode((PropListMode)(nextMode < 0 ? TotalPropListModes - 1 : nextMode % TotalPropListModes)); + } + + public static void SetPropListMode(PropListMode mode) + { + _currentPropListMode = mode; + ModSettings.HiddenPropListMode.Value = mode; + + switch (_currentPropListMode) + { + case PropListMode.AllProps: + _propListCategoryName = "All Props"; + _propsListPopulatedText = "These Props are sorted by distance to you."; + _propListEmptyText = "No Props are spawned in the World."; + _cyclePropListModeButton.ButtonIcon = "PropsButBetter-rubiks-cube-eye"; + break; + case PropListMode.MyProps: + _propListCategoryName = "My Props"; + _propsListPopulatedText = "These Props are sorted by distance to you."; + _propListEmptyText = "You have not spawned any Props."; + _cyclePropListModeButton.ButtonIcon = "PropsButBetter-rubiks-cube-star"; + break; + case PropListMode.History: + _propListCategoryName = "Spawned This Session"; + _propsListPopulatedText = "Showing last 30 Props which have been spawned this session."; + _propListEmptyText = "No Props have been spawned this session."; + _cyclePropListModeButton.ButtonIcon = "PropsButBetter-rubiks-cube-clock"; + break; + } + + // Force rebuild of the props list + RebuildPropList(); + } +} \ No newline at end of file diff --git a/PropsButBetter/PropsButBetter/QuickMenuPropSelect.cs b/PropsButBetter/PropsButBetter/QuickMenuPropSelect.cs new file mode 100644 index 0000000..4916536 --- /dev/null +++ b/PropsButBetter/PropsButBetter/QuickMenuPropSelect.cs @@ -0,0 +1,259 @@ +using ABI_RC.Core; +using ABI_RC.Core.EventSystem; +using ABI_RC.Core.InteractionSystem; +using ABI_RC.Core.IO; +using ABI_RC.Core.IO.AssetManagement; +using ABI_RC.Core.Networking.API.Responses; +using ABI_RC.Core.Player; +using ABI_RC.Core.Savior; +using ABI_RC.Core.Util; +using ABI_RC.Core.Util.Encryption; +using ABI_RC.Systems.UI.UILib; +using ABI_RC.Systems.UI.UILib.Components; +using ABI_RC.Systems.UI.UILib.UIObjects; +using ABI_RC.Systems.UI.UILib.UIObjects.Components; +using ABI.CCK.Components; +using UnityEngine; + +namespace NAK.PropsButBetter; + +public static class QuickMenuPropSelect +{ + private static Page _page; + private static Category _contentInfoCategory; + private static ContentDisplay _contentDisplay; + + private static Category _contentActionsCategory; + private static Button _deletePropButton; + private static Button _selectPropButton; + private static Button _reloadPropButton; + private static Button _selectPlayerButton; + + // Page State + private static string _currentSelectedPropId; + private static CVRSyncHelper.PropData _currentSelectedPropData; + private static PedestalInfoResponse _currentSpawnableResponse; + + private static CancellationTokenSource _fetchPropDetailsCts; + + public static void BuildUI() + { + _page = Page.GetOrCreatePage(nameof(PropsButBetter), "Prop Info", isRootPage: false, noTab: true); + _page.OnPageClosed += () => _fetchPropDetailsCts?.Cancel(); + // _page.InPlayerlist = true; // TODO: Investigate removal of page forehead + + _contentInfoCategory = _page.AddCategory("Content Info", false, false); + _contentDisplay = new ContentDisplay(_contentInfoCategory); + + _contentActionsCategory = _page.AddCategory("Quick Actions", true, true); + + _selectPropButton = _contentActionsCategory.AddButton("Spawn Prop", "PropsButBetter-select", "Select the Prop for spawning."); + _selectPropButton.OnPress += OnSelectProp; + + _reloadPropButton = _contentActionsCategory.AddButton("Reload Prop", "PropsButBetter-reload", "Respawns the selected Prop."); + _reloadPropButton.OnPress += OnReloadProp; + + _deletePropButton = _contentActionsCategory.AddButton("Delete Prop", "PropsButBetter-remove", "Delete the selected Prop.", ButtonStyle.TextWithIcon); + _deletePropButton.OnPress += OnDeleteProp; + + _selectPlayerButton = _contentActionsCategory.AddButton("Select Spawner", "PropsButBetter-remove", "Select the spawner of the Prop.", ButtonStyle.FullSizeImage); + _selectPlayerButton.OnPress += OnSelectPlayer; + } + + public static void ListenForQM() + { + CVR_MenuManager.Instance.cohtmlView.View.RegisterForEvent("QuickMenuPropSelect-OpenDetails", (Action)OnOpenDetails); + } + + public static void ShowInfo(CVRSyncHelper.PropData propData) + { + if (propData == null) return; + + _currentSelectedPropData = propData; + + CVR_MenuManager.Instance.ToggleQuickMenu(true); + _page.OpenPage(false, false); + + _currentSelectedPropId = propData.ObjectId; + + AssetManagement.UgcMetadata metadata = propData.ContentMetadata; + + _page.MenuTitle = $"{metadata.AssetName}"; + _page.MenuSubtitle = $"Spawned by {CVRPlayerManager.Instance.TryGetPlayerName(propData.SpawnedBy)}"; + + // Reset stuff + _fetchPropDetailsCts?.Cancel(); + _fetchPropDetailsCts = new CancellationTokenSource(); + _currentSpawnableResponse = null; + _reloadPropButton.Disabled = false; + _deletePropButton.Disabled = false; + + // Display metadata immediately with placeholder image + _contentDisplay.SetContent(metadata); + + // Set player pfp on button + string spawnedBy = _currentSelectedPropData.SpawnedBy; + if (CVRPlayerManager.Instance.UserIdToPlayerEntity.TryGetValue(spawnedBy, out var playerEntity)) + { + _selectPlayerButton.ButtonIcon = playerEntity.ApiProfileImageUrl; + _selectPlayerButton.ButtonText = playerEntity.Username; + } + // Check if this is our own pro + else if (spawnedBy == MetaPort.Instance.ownerId) + { + _selectPlayerButton.ButtonIcon = PlayerSetup.Instance.AuthInfo.Image.ToString(); + _selectPlayerButton.ButtonText = PlayerSetup.Instance.AuthInfo.Username; + } + // Prop is spawned by a user we cannot see... likely blocked + else + { + _selectPlayerButton.ButtonIcon = string.Empty; + _selectPlayerButton.ButtonText = "Unknown Player"; + } + + // Keep disabled until fetch + _selectPropButton.ButtonTooltip = "Select the Prop for spawning."; + _selectPropButton.Disabled = true; + + // Fetch image and update display + CVRTools.Run(async () => + { + try + { + var response = await PedestalInfoBatchProcessor.QueuePedestalInfoRequest( + PedestalType.Prop, + _currentSelectedPropId, + skipDelayIfNotCached: true // Not expecting need for batched response here + ); + + _currentSpawnableResponse = response; + string spawnableImageUrl = ImageCache.QueueProcessImage(response.ImageUrl, fallback: response.ImageUrl); + bool isPermittedToSpawn = response.IsPublished || response.Permitted; + + RootLogic.Instance.MainThreadQueue.Enqueue(() => + { + if (_fetchPropDetailsCts.IsCancellationRequested) return; + // Update with image URL for crossfade and enable button + _contentDisplay.SetContent(metadata, spawnableImageUrl); + if (isPermittedToSpawn) + _selectPropButton.Disabled = false; + else + _selectPropButton.ButtonTooltip = "Lacking permission to spawn Prop."; + }); + } + catch (Exception ex) when (ex is OperationCanceledException) { } + }, _fetchPropDetailsCts.Token); + } + + private static void OnOpenDetails() + { + ViewManager.Instance.GetPropDetails(_currentSelectedPropId); + } + + private static void OnSelectProp() + { + if (_currentSpawnableResponse != null) + { + string imageUrl = ImageCache.QueueProcessImage(_currentSpawnableResponse.ImageUrl, fallback: _currentSpawnableResponse.ImageUrl); + PlayerSetup.Instance.SelectPropToSpawn(_currentSpawnableResponse.Id, imageUrl, _currentSpawnableResponse.Name); + } + } + + private static void OnSelectPlayer() + { + string spawnedBy = _currentSelectedPropData.SpawnedBy; + // Check if this is a remote player and they exist + if (CVRPlayerManager.Instance.TryGetConnected(spawnedBy)) + { + QuickMenuAPI.OpenPlayerListByUserID(spawnedBy); + } + // Check if this is ourselves + else if (spawnedBy == MetaPort.Instance.ownerId) + { + PlayerList.Instance.OpenPlayerActionPage(PlayerList.Instance._localUserObject); + } + // User is not real + else + { + ViewManager.Instance.RequestUserDetailsPage(spawnedBy); + ViewManager.Instance.TriggerPushNotification("Opened profile page as user was not found in Instance.", 2f); + } + } + + private static void OnDeleteProp() + { + CVRSpawnable spawnable = _currentSelectedPropData.Spawnable; + if (spawnable && !spawnable.IsSpawnedByAdmin()) + { + spawnable.Delete(); + + // Disable now unusable buttons + _reloadPropButton.Disabled = true; + _deletePropButton.Disabled = true; + } + } + + private static void OnReloadProp() + { + CVRSpawnable spawnable = _currentSelectedPropData.Spawnable; + if (spawnable.IsSpawnedByAdmin()) + return; // Can't call delete + + CVRSyncHelper.PropData oldData = _currentSelectedPropData; + + // If this prop has not yet fully reloaded do not attempt another reload + if (!oldData.Spawnable) return; + + // Find our cached task in the download manager + // TODO: Noticed issue, download manager doesn't keep track of cached items properly. + // We are comparing the asset id instead of instance id because the download manager doesn't cache + // multiple for the same asset id. + DownloadTask ourTask = null; + foreach ((string assetId, DownloadTask downloadTask) in CVRDownloadManager.Instance._cachedTasks) + { + if (downloadTask.Type != DownloadTask.ObjectType.Prop + || assetId != oldData.ObjectId) continue; + ourTask = downloadTask; + break; + } + if (ourTask == null) return; + + // Create new prop data from the old one + CVRSyncHelper.PropData newData = CVRSyncHelper.PropData.PropDataPool.GetObject(); + newData.CopyFrom(oldData); + + // Prep spawnable for partial delete + spawnable.SpawnedByMe = false; // Prevent telling GS about delete + spawnable.instanceId = null; // Prevent OnDestroy from recycling our new prop data later in frame + + // Destroy the prop + oldData.Recycle(); + + // Add our new prop data to sync helper + CVRSyncHelper.Props.Add(newData); + newData.CanFireDecommissionEvents = true; + + // Do mega jank + CVREncryptionRouter router = null; + string filePath = CacheManager.Instance.GetCachePath(newData.ContentMetadata.AssetId, ourTask.FileId); + if (!string.IsNullOrEmpty(filePath)) router = new CVREncryptionRouter(filePath, newData.ContentMetadata); + + PropQueueSystem.Instance.AddCoroutine( + CoroutineUtil.RunThrowingIterator( + CVRObjectLoader.Instance.InstantiateSpawnableFromBundle( + newData.ContentMetadata.AssetId, + newData.ContentMetadata.FileHash, + newData.InstanceId, + router, + newData.ContentMetadata.TagsData, + newData.ContentMetadata.CompatibilityVersion, + string.Empty, + newData.SpawnedBy), + Debug.LogError + ) + ); + + // Update backing selected prop data + _currentSelectedPropData = newData; + } +} \ No newline at end of file diff --git a/PropsButBetter/PropsButBetter/UIElements/ContentDisplay.cs b/PropsButBetter/PropsButBetter/UIElements/ContentDisplay.cs new file mode 100644 index 0000000..9bc8205 --- /dev/null +++ b/PropsButBetter/PropsButBetter/UIElements/ContentDisplay.cs @@ -0,0 +1,119 @@ +using System.Text; +using ABI_RC.Core; +using ABI_RC.Core.EventSystem; +using ABI_RC.Systems.UI.UILib.UIObjects; +using ABI_RC.Systems.UI.UILib.UIObjects.Components; +using ABI_RC.Systems.UI.UILib.UIObjects.Objects; + +namespace NAK.PropsButBetter; + +public class ContentDisplay +{ + private readonly CustomElement _element; + private readonly CustomEngineOnFunction _updateFunction; + + public ContentDisplay(Category parentCategory) + { + _element = new CustomElement( + """{"t": "div", "c": "col-12", "a":{"id":"CVRUI-QMUI-Custom-[UUID]"}}""", + ElementType.InCategoryElement, + parentCategory: parentCategory + ); + parentCategory.AddCustomElement(_element); + + _updateFunction = new CustomEngineOnFunction( + "updateContentDisplay", + """ + var elem = document.getElementById(elementId); + if(elem) { + elem.innerHTML = htmlContent; + } + """, + new Parameter("elementId", typeof(string), true, false), + new Parameter("htmlContent", typeof(string), true, false) + ); + + _element.AddEngineOnFunction(_updateFunction); + } + + public void SetContent(AssetManagement.UgcMetadata metadata, string imageUrl = "") + { + StringBuilder tagsBuilder = new StringBuilder(); + + void AddTag(bool condition, string tagName) + { + if (condition) + { + tagsBuilder.Append( + $"" + + $"{tagName}" + ); + } + } + + AddTag(metadata.TagsData.Gore, "Gore"); + AddTag(metadata.TagsData.Horror, "Horror"); + AddTag(metadata.TagsData.Jumpscare, "Jumpscare"); + AddTag(metadata.TagsData.Explicit, "Explicit"); + AddTag(metadata.TagsData.Suggestive, "Suggestive"); + AddTag(metadata.TagsData.Violence, "Violence"); + AddTag(metadata.TagsData.FlashingEffects, "Flashing Effects"); + AddTag(metadata.TagsData.LoudAudio, "Loud Audio"); + AddTag(metadata.TagsData.ScreenEffects, "Screen Effects"); + AddTag(metadata.TagsData.LongRangeAudio, "Long Range Audio"); + + if (tagsBuilder.Length == 0) + { + tagsBuilder.Append( + "No Tags" + ); + } + + string htmlContent = $@" +
+
+ + +
+ +
+
+ Tags: +
+ {tagsBuilder} +
+
+ +
+ File Size: + + {CVRTools.HumanReadableFilesize(metadata.FileSize)} + +
+
+
"; + + if (!RootLogic.Instance.IsOnMainThread()) + RootLogic.Instance.MainThreadQueue.Enqueue(() => _updateFunction.TriggerEvent(_element.ElementID, htmlContent)); + else + _updateFunction.TriggerEvent(_element.ElementID, htmlContent); + } +} \ No newline at end of file diff --git a/PropsButBetter/PropsButBetter/UIElements/GlobalButton.cs b/PropsButBetter/PropsButBetter/UIElements/GlobalButton.cs new file mode 100644 index 0000000..3880239 --- /dev/null +++ b/PropsButBetter/PropsButBetter/UIElements/GlobalButton.cs @@ -0,0 +1,51 @@ +using ABI_RC.Core.InteractionSystem; +using ABI_RC.Systems.UI.UILib.UIObjects.Components; + +namespace NAK.PropsButBetter; + +public class GlobalButton +{ + private static readonly Dictionary _buttonsByElementId = new(); + + private readonly CustomElement _element; + + public event Action OnPress; + + public bool Disabled + { + get => _element.Disabled; + set => _element.Disabled = value; + } + + public static void ListenForQM() + { + CVR_MenuManager.Instance.cohtmlView.View.BindCall("GlobalButton-Click", (Action)HandleButtonClick); + } + + public GlobalButton(string iconName, string tooltip, int x, int y, int width = 80, int height = 80) + { + _element = new CustomElement( + $$$"""{"c": "whatever-i-want", "s": [{"c": "icon", "a": {"style": "background-image: url('UILib/Images/{{{iconName}}}.png'); background-size: contain; background-repeat: no-repeat; width: 100%; height: 100%;"}}], "x": "GlobalButton-Click", "a": {"id": "CVRUI-QMUI-Custom-[UUID]", "style": "position: absolute; left: {{{x}}}px; top: {{{y}}}px; width: {{{width}}}px; height: {{{height}}}px; margin: 0;", "data-tooltip": "{{{tooltip}}}"}}""", + ElementType.GlobalElement + ); + _element.AddAction("GlobalButton-Click", "engine.call(\"GlobalButton-Click\", e.currentTarget.id);"); + _element.OnElementGenerated += OnElementGenerated; + } + + private void OnElementGenerated() + { + _buttonsByElementId[_element.ElementID] = this; + } + + private static void HandleButtonClick(string elementId) + { + if (_buttonsByElementId.TryGetValue(elementId, out GlobalButton button)) + button.OnPress?.Invoke(); + } + + public void Delete() + { + _buttonsByElementId.Remove(_element.ElementID); + _element.Delete(); + } +} \ No newline at end of file diff --git a/PropsButBetter/PropsButBetter/UIElements/PropListEntry.cs b/PropsButBetter/PropsButBetter/UIElements/PropListEntry.cs new file mode 100644 index 0000000..1fcb509 --- /dev/null +++ b/PropsButBetter/PropsButBetter/UIElements/PropListEntry.cs @@ -0,0 +1,256 @@ +using ABI_RC.Core; +using ABI_RC.Core.InteractionSystem; +using ABI_RC.Core.IO; +using ABI_RC.Core.Util; +using ABI_RC.Systems.UI.UILib.UIObjects; +using ABI_RC.Systems.UI.UILib.UIObjects.Components; +using ABI_RC.Systems.UI.UILib.UIObjects.Objects; + +namespace NAK.PropsButBetter; + +public class PropListEntry +{ + private readonly CustomElement _element; + private readonly CustomEngineOnFunction _updateFunction; + private readonly CustomEngineOnFunction _setChildIndexFunction; + private CancellationTokenSource _cts; + + private readonly string _instanceId; + private readonly string _propId; + private readonly string _propName; + private readonly string _spawnerUsername; + private string _currentImageUrl; + private int _childIndex; + private bool _isDestroyed; + + // Used to track if this entry is still needed or not by QuickMenuPropList + internal int LastUpdatedCycle; + + public PropListEntry(string instanceId, string contentId, string propName, string spawnerUsername, Category parentCategory) + { + _instanceId = instanceId; + _propId = contentId; + _propName = propName; + _spawnerUsername = spawnerUsername; + _currentImageUrl = string.Empty; + _isDestroyed = false; + _cts = new CancellationTokenSource(); + + _element = new CustomElement( + """{"t": "div", "c": "col-6", "a":{"id":"CVRUI-QMUI-Custom-[UUID]"}}""", + ElementType.InCategoryElement, + parentCategory: parentCategory + ); + + _updateFunction = new CustomEngineOnFunction( + "updatePropListEntry", + """ + var elem = document.getElementById(elementId); + if(elem) { + elem.innerHTML = htmlContent; + } + """, + new Parameter("elementId", typeof(string), true, false), + new Parameter("htmlContent", typeof(string), true, false) + ); + + _setChildIndexFunction = new CustomEngineOnFunction( + "setPropListEntryIndex", + """ + var elem = document.getElementById(elementId2); + if(elem && elem.parentNode) { + var parent = elem.parentNode; + var children = Array.from(parent.children); + if(index < children.length && children[index] !== elem) { + parent.insertBefore(elem, children[index]); + } else if(index >= children.length) { + parent.appendChild(elem); + } + } + """, + new Parameter("elementId2", typeof(string), true, false), + new Parameter("index", typeof(int), true, false) + ); + + _element.AddEngineOnFunction(_updateFunction); + _element.AddEngineOnFunction(_setChildIndexFunction); + _element.OnElementGenerated += UpdateDisplay; + parentCategory.AddCustomElement(_element); + + FetchImageAsync(contentId); + } + + private async void FetchImageAsync(string contentId) + { + try + { + var response = await PedestalInfoBatchProcessor.QueuePedestalInfoRequest(PedestalType.Prop, contentId); + + if (_cts.IsCancellationRequested) return; + + string imageUrl = ImageCache.QueueProcessImage(response.ImageUrl, fallback: response.ImageUrl); + SetImage(imageUrl); + } + catch (OperationCanceledException) { } + catch (Exception) { } + } + + public void SetImage(string imageUrl) + { + _currentImageUrl = imageUrl; + UpdateDisplay(); + } + + public void SetIsDestroyed(bool isDestroyed) + { + if (_isDestroyed != isDestroyed) + { + _isDestroyed = isDestroyed; + UpdateDisplay(); + } + } + + public void SetChildIndexIfNeeded(int index) + { + if (_childIndex == index) return; + _childIndex = index; + + if (!RootLogic.Instance.IsOnMainThread()) + RootLogic.Instance.MainThreadQueue.Enqueue(() => _setChildIndexFunction.TriggerEvent(_element.ElementID, index)); + else + _setChildIndexFunction.TriggerEvent(_element.ElementID, index); + } + + public void Destroy() + { + _cts?.Cancel(); + _cts?.Dispose(); + _cts = null; + _element.Delete(); + } + + private void UpdateDisplay() + { + const int rowHeight = 160; + const int imageSize = rowHeight - 32; + + string dimStyle = _isDestroyed ? "opacity: 0.4; filter: grayscale(0.6);" : ""; + string tooltipSuffix = _isDestroyed ? " (Despawned)" : ""; + + string imageHtml = $@" + + "; + + string htmlContent = $@" +
+ +
+
+ {imageHtml} +
+
+ +
+ +
+ {_propName} +
+ +
+ Spawned By {_spawnerUsername} +
+ +
+
"; + + if (!RootLogic.Instance.IsOnMainThread()) + RootLogic.Instance.MainThreadQueue.Enqueue(() => _updateFunction.TriggerEvent(_element.ElementID, htmlContent)); + else + _updateFunction.TriggerEvent(_element.ElementID, htmlContent); + } + + public static void ListenForQM() + { + CVR_MenuManager.Instance.cohtmlView.View.BindCall("PropListEntry-Selected", OnSelect); + } + + private static void OnSelect(string instanceId, string propId, bool isDestroyed) + { + // If the prop is destroyed, open the details page + if (isDestroyed) + { + ViewManager.Instance.GetPropDetails(propId); + return; + } + + // Otherwise show the live prop info + var propData = CVRSyncHelper.Props.Find(prop => prop.InstanceId == instanceId); + QuickMenuPropSelect.ShowInfo(propData); + } +} \ No newline at end of file diff --git a/PropsButBetter/PropsButBetter/UIElements/UndoRedoButtons.cs b/PropsButBetter/PropsButBetter/UIElements/UndoRedoButtons.cs new file mode 100644 index 0000000..d194cef --- /dev/null +++ b/PropsButBetter/PropsButBetter/UIElements/UndoRedoButtons.cs @@ -0,0 +1,132 @@ +using ABI_RC.Core.InteractionSystem; +using ABI_RC.Systems.UI.UILib; + +namespace NAK.PropsButBetter; + +public class UndoRedoButtons +{ + private const string UndoButtonId = "PropsButBetter-UndoButton"; + private const string RedoButtonId = "PropsButBetter-RedoButton"; + + public event Action OnUndo; + public event Action OnRedo; + + private static UndoRedoButtons _instance; + + public static void ListenForButtons() + { + CVR_MenuManager.Instance.cohtmlView.View.BindCall("UndoRedoButtons-Undo", (Action)HandleUndoStatic); + CVR_MenuManager.Instance.cohtmlView.View.BindCall("UndoRedoButtons-Redo", (Action)HandleRedoStatic); + } + + public UndoRedoButtons() + { + _instance = this; + QuickMenuAPI.OnMenuGenerated += OnMenuGenerate; + if (CVR_MenuManager.IsReadyStatic) GenerateCohtml(); + } + + private void OnMenuGenerate(CVR_MenuManager _) + => GenerateCohtml(); + + private void GenerateCohtml() + { + string script = $@" +(function() {{ + var root = document.getElementById('CVRUI-QMUI-Root'); + if (!root) return; + + // Remove existing buttons if they exist + var existingUndo = document.getElementById('{UndoButtonId}'); + if (existingUndo) existingUndo.remove(); + var existingRedo = document.getElementById('{RedoButtonId}'); + if (existingRedo) existingRedo.remove(); + + // Create undo button + var undoButton = document.createElement('div'); + undoButton.id = '{UndoButtonId}'; + undoButton.className = 'button'; + undoButton.setAttribute('data-tooltip', 'Undo last Prop spawn.'); + undoButton.setAttribute('onclick', ""engine.call('UndoRedoButtons-Undo');""); + undoButton.style.cssText = 'position: absolute; left: 900px; top: 25px; width: 140px; height: 140px; margin: 0; z-index: 9999; cursor: pointer;'; + + var undoIcon = document.createElement('div'); + undoIcon.style.cssText = 'background-image: url(""UILib/Images/PropsButBetter/PropsButBetter-undo.png""); background-size: contain; background-repeat: no-repeat; width: 100%; height: 100%; pointer-events: none;'; + undoButton.appendChild(undoIcon); + + // Create redo button + var redoButton = document.createElement('div'); + redoButton.id = '{RedoButtonId}'; + redoButton.className = 'button'; + redoButton.setAttribute('data-tooltip', 'Redo last Prop spawn.'); + redoButton.setAttribute('onclick', ""engine.call('UndoRedoButtons-Redo');""); + redoButton.style.cssText = 'position: absolute; left: 1060px; top: 25px; width: 140px; height: 140px; margin: 0; z-index: 9999; cursor: pointer;'; + + var redoIcon = document.createElement('div'); + redoIcon.style.cssText = 'background-image: url(""UILib/Images/PropsButBetter/PropsButBetter-redo.png""); background-size: contain; background-repeat: no-repeat; width: 100%; height: 100%; pointer-events: none;'; + redoButton.appendChild(redoIcon); + + // Append to root + root.appendChild(undoButton); + root.appendChild(redoButton); +}})(); +"; + CVR_MenuManager.Instance.cohtmlView.View._view.ExecuteScript(script); + SetUndoHidden(true); + SetRedoHidden(true); + } + + private static void HandleUndoStatic() + { + _instance?.OnUndo?.Invoke(); + } + + private static void HandleRedoStatic() + { + _instance?.OnRedo?.Invoke(); + } + + private bool _isUndoDisabled; + public void SetUndoDisabled(bool disabled) + { + if (_isUndoDisabled == disabled) return; + _isUndoDisabled = disabled; + if (!CVR_MenuManager.IsReadyStatic) return; + CVR_MenuManager.Instance.cohtmlView.View.InternalView.TriggerEvent("CVRUI-QMUI-SetDisabled", UndoButtonId, disabled); + } + + private bool _isRedoDisabled; + public void SetRedoDisabled(bool disabled) + { + if (_isRedoDisabled == disabled) return; + _isRedoDisabled = disabled; + if (!CVR_MenuManager.IsReadyStatic) return; + CVR_MenuManager.Instance.cohtmlView.View.InternalView.TriggerEvent("CVRUI-QMUI-SetDisabled", RedoButtonId, disabled); + } + + private bool _isUndoHidden; + public void SetUndoHidden(bool hidden) + { + if (_isUndoHidden == hidden) return; + _isUndoHidden = hidden; + if (!CVR_MenuManager.IsReadyStatic) return; + CVR_MenuManager.Instance.cohtmlView.View.InternalView.TriggerEvent("CVRUI-QMUI-SetHidden", UndoButtonId, hidden); + } + + private bool _isRedoHidden; + public void SetRedoHidden(bool hidden) + { + if (_isRedoHidden == hidden) return; + _isRedoHidden = hidden; + if (!CVR_MenuManager.IsReadyStatic) return; + CVR_MenuManager.Instance.cohtmlView.View.InternalView.TriggerEvent("CVRUI-QMUI-SetHidden", RedoButtonId, hidden); + } + + public void Cleanup() + { + QuickMenuAPI.OnMenuGenerated -= OnMenuGenerate; + if (!CVR_MenuManager.IsReadyStatic) return; + CVR_MenuManager.Instance.cohtmlView.View.InternalView.TriggerEvent("CVRUI-QMUI-DeleteElement", UndoButtonId); + CVR_MenuManager.Instance.cohtmlView.View.InternalView.TriggerEvent("CVRUI-QMUI-DeleteElement", RedoButtonId); + } +} \ No newline at end of file diff --git a/PropsButBetter/README.md b/PropsButBetter/README.md new file mode 100644 index 0000000..83db097 --- /dev/null +++ b/PropsButBetter/README.md @@ -0,0 +1,31 @@ +# PropsButBetter + +Prop quality-of-life suite. Adds a few new ways to interact with and manage Props. + +### Whats included: + +- **A unified Prop List** + - Browse All Props, Your Props, or Session history in a clean, cyclable list. +- **Smarter Prop Selection** + - Clicking a Prop opens the Quick Menu with quick actions and useful info. +- **Undo / Redo for Props** + - Fix mistakes instantly without having to clear all. + - Desktop: CTRL+Z / CTRL+SHIFT+Z + - VR: Dedicated Undo / Redo buttons in the Props Quick Menu tab. +- **Subtle Prop SFX** + - Clean audio feedback when Props are spawned, removed, or respawned by you. +- **Placement Visualizer** + - Preview props before you've placed them while in Prop Select mode. + +-# Desktop undo/redo keybinds, Prop SFX, and placement visualizer are configurable in Melon Preferences. + +--- + +Here is the block of text where I tell you this mod is not affiliated or endorsed by ABI. +https://documentation.abinteractive.net/official/legal/tos/#7-modding-our-games + +> This mod is an independent creation and is 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. \ No newline at end of file diff --git a/PropsButBetter/Resources/SFX/sfx_deny.wav b/PropsButBetter/Resources/SFX/sfx_deny.wav new file mode 100644 index 0000000..0a8b4ba Binary files /dev/null and b/PropsButBetter/Resources/SFX/sfx_deny.wav differ diff --git a/PropsButBetter/Resources/SFX/sfx_redo.wav b/PropsButBetter/Resources/SFX/sfx_redo.wav new file mode 100644 index 0000000..41bb0cf Binary files /dev/null and b/PropsButBetter/Resources/SFX/sfx_redo.wav differ diff --git a/PropsButBetter/Resources/SFX/sfx_spawn.wav b/PropsButBetter/Resources/SFX/sfx_spawn.wav new file mode 100644 index 0000000..ab2aef3 Binary files /dev/null and b/PropsButBetter/Resources/SFX/sfx_spawn.wav differ diff --git a/PropsButBetter/Resources/SFX/sfx_undo.wav b/PropsButBetter/Resources/SFX/sfx_undo.wav new file mode 100644 index 0000000..84f0cf7 Binary files /dev/null and b/PropsButBetter/Resources/SFX/sfx_undo.wav differ diff --git a/PropsButBetter/Resources/SFX/sfx_warn.wav b/PropsButBetter/Resources/SFX/sfx_warn.wav new file mode 100644 index 0000000..8e96b57 Binary files /dev/null and b/PropsButBetter/Resources/SFX/sfx_warn.wav differ diff --git a/PropsButBetter/Resources/placeholder.png b/PropsButBetter/Resources/placeholder.png new file mode 100644 index 0000000..0d75334 Binary files /dev/null and b/PropsButBetter/Resources/placeholder.png differ diff --git a/PropsButBetter/Resources/redo.png b/PropsButBetter/Resources/redo.png new file mode 100644 index 0000000..61ab3bd Binary files /dev/null and b/PropsButBetter/Resources/redo.png differ diff --git a/PropsButBetter/Resources/reload.png b/PropsButBetter/Resources/reload.png new file mode 100644 index 0000000..61f20fe Binary files /dev/null and b/PropsButBetter/Resources/reload.png differ diff --git a/PropsButBetter/Resources/remove.png b/PropsButBetter/Resources/remove.png new file mode 100644 index 0000000..0f24d17 Binary files /dev/null and b/PropsButBetter/Resources/remove.png differ diff --git a/PropsButBetter/Resources/rubiks-cube-calender.png b/PropsButBetter/Resources/rubiks-cube-calender.png new file mode 100644 index 0000000..9b36a1e Binary files /dev/null and b/PropsButBetter/Resources/rubiks-cube-calender.png differ diff --git a/PropsButBetter/Resources/rubiks-cube-clock.png b/PropsButBetter/Resources/rubiks-cube-clock.png new file mode 100644 index 0000000..6494f6e Binary files /dev/null and b/PropsButBetter/Resources/rubiks-cube-clock.png differ diff --git a/PropsButBetter/Resources/rubiks-cube-eye.png b/PropsButBetter/Resources/rubiks-cube-eye.png new file mode 100644 index 0000000..4a73d36 Binary files /dev/null and b/PropsButBetter/Resources/rubiks-cube-eye.png differ diff --git a/PropsButBetter/Resources/rubiks-cube-star.png b/PropsButBetter/Resources/rubiks-cube-star.png new file mode 100644 index 0000000..2c9802b Binary files /dev/null and b/PropsButBetter/Resources/rubiks-cube-star.png differ diff --git a/PropsButBetter/Resources/rubiks-cube.png b/PropsButBetter/Resources/rubiks-cube.png new file mode 100644 index 0000000..623e92f Binary files /dev/null and b/PropsButBetter/Resources/rubiks-cube.png differ diff --git a/PropsButBetter/Resources/select.png b/PropsButBetter/Resources/select.png new file mode 100644 index 0000000..89716a6 Binary files /dev/null and b/PropsButBetter/Resources/select.png differ diff --git a/PropsButBetter/Resources/undo.png b/PropsButBetter/Resources/undo.png new file mode 100644 index 0000000..37905d3 Binary files /dev/null and b/PropsButBetter/Resources/undo.png differ diff --git a/PropsButBetter/Resources/wand.png b/PropsButBetter/Resources/wand.png new file mode 100644 index 0000000..93edcc9 Binary files /dev/null and b/PropsButBetter/Resources/wand.png differ diff --git a/PropsButBetter/format.json b/PropsButBetter/format.json new file mode 100644 index 0000000..6c4012e --- /dev/null +++ b/PropsButBetter/format.json @@ -0,0 +1,26 @@ +{ + "_id": -1, + "name": "PropsButBetter", + "modversion": "1.0.0", + "gameversion": "2026r181", + "loaderversion": "0.7.2", + "modtype": "Mod", + "author": "NotAKidoS", + "description": "Prop quality-of-life suite. Adds a few new ways to interact with and manage Props.\n### Whats included:\n\n- **A unified Prop List**\n - Browse All Props, Your Props, or Session history in a clean, cyclable list.\n- **Smarter Prop Selection**\n - Clicking a Prop opens the Quick Menu with quick actions and useful info.\n- **Undo / Redo for Props**\n - Fix mistakes instantly without having to clear all.\n - Desktop: CTRL+Z / CTRL+SHIFT+Z\n - VR: Dedicated Undo / Redo buttons in the Props Quick Menu tab.\n- **Subtle Prop SFX**\n - Clean audio feedback when Props are spawned, removed, or respawned by you.\n- **Placement Visualizer**\n - Preview props before you've placed them while in Prop Select mode.\n\n-# Desktop undo/redo keybinds, Prop SFX, and placement visualizer are configurable in Melon Preferences.", + "searchtags": [ + "prop", + "qol", + "undo", + "redo", + "sounds", + "spawn", + "list" + ], + "requirements": [ + "None" + ], + "downloadlink": "https://github.com/NotAKidoS/NAK_CVR_Mods/releases/download/r49/PropsButBetter.dll", + "sourcelink": "https://github.com/NotAKidoS/NAK_CVR_Mods/tree/main/PropsButBetter/", + "changelog": "- Initial release", + "embedcolor": "#f61963" +} \ No newline at end of file diff --git a/README.md b/README.md index 73175b8..79bc57d 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ | [FuckToes](FuckToes/README.md) | Prevents VRIK from autodetecting toes in Halfbody or Fullbody. | [Download](https://github.com/NotAKidoS/NAK_CVR_Mods/releases/download/r48/FuckToes.dll) | | [PlapPlapForAll](PlapPlapForAll/README.md) | Penetrator SFX mod which adds Noach's PlapPlap prefab to any detected DPS setup on avatars that do not already have it. | [Download](https://github.com/NotAKidoS/NAK_CVR_Mods/releases/download/r48/PlapPlapForAll.dll) | | [PropLoadingHexagon](PropLoadingHexagon/README.md) | https://github.com/NotAKidoS/NAK_CVR_Mods/assets/37721153/a892c765-71c1-47f3-a781-bdb9b60ba117 | [Download](https://github.com/NotAKidoS/NAK_CVR_Mods/releases/download/r48/PropLoadingHexagon.dll) | +| [PropsButBetter](PropsButBetter/README.md) | Prop quality-of-life suite. Adds a few new ways to interact with and manage Props. | No Download | | [RCCVirtualSteeringWheel](RCCVirtualSteeringWheel/README.md) | Allows you to physically grab rigged RCC steering wheels in VR to provide steering input. No explicit setup required other than defining the Steering Wheel transform within the RCC component. | [Download](https://github.com/NotAKidoS/NAK_CVR_Mods/releases/download/r48/RCCVirtualSteeringWheel.dll) | | [RelativeSyncJitterFix](RelativeSyncJitterFix/README.md) | Relative sync jitter fix is the single harmony patch that could not make it into the native release of RelativeSync. | [Download](https://github.com/NotAKidoS/NAK_CVR_Mods/releases/download/r48/RelativeSyncJitterFix.dll) | | [ShareBubbles](ShareBubbles/README.md) | Share Bubbles! Allows you to drop down bubbles containing Avatars & Props. Requires both users to have the mod installed. Synced over Mod Network. | [Download](https://github.com/NotAKidoS/NAK_CVR_Mods/releases/download/r48/ShareBubbles.dll) |