Compare commits
5 commits
41d582146c
...
21a5d38af4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21a5d38af4 | ||
|
|
e0062abef5 | ||
|
|
3b7703fa9b | ||
|
|
1a7c1d7ac8 | ||
|
|
fb37ee3a72 |
287
PropsButBetter/Main.cs
Normal file
|
|
@ -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<CVRSpawnable>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
PropsButBetter/ModSettings.cs
Normal file
|
|
@ -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<bool> 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<bool> EntryUseSFX =
|
||||||
|
Category.CreateEntry("use_sfx", true,
|
||||||
|
"Use SFX", description: "Toggle audio queues for prop spawn, undo, redo, and warning.");
|
||||||
|
|
||||||
|
internal static readonly MelonPreferences_Entry<QuickMenuPropList.PropListMode> 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<bool> 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<bool> 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<PropHelper.PropVisualizerMode> EntryPropSpawnVisualizerMode =
|
||||||
|
Category.CreateEntry("prop_spawn_visualizer_mode", PropHelper.PropVisualizerMode.HologramSource,
|
||||||
|
"Prop Spawn Visualizer Mode", description: "Choose how the visualizer will attempt to render.");
|
||||||
|
}
|
||||||
32
PropsButBetter/Properties/AssemblyInfo.cs
Normal file
|
|
@ -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";
|
||||||
|
}
|
||||||
35
PropsButBetter/PropsButBetter.csproj
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<ItemGroup>
|
||||||
|
<None Remove="Resources\plap plap.assets" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="Resources\SFX\sfx_deny.wav" />
|
||||||
|
<EmbeddedResource Include="Resources\SFX\sfx_redo.wav" />
|
||||||
|
<EmbeddedResource Include="Resources\SFX\sfx_spawn.wav" />
|
||||||
|
<EmbeddedResource Include="Resources\SFX\sfx_undo.wav" />
|
||||||
|
<EmbeddedResource Include="Resources\SFX\sfx_warn.wav" />
|
||||||
|
<None Remove="Resources\reload.png" />
|
||||||
|
<EmbeddedResource Include="Resources\reload.png" />
|
||||||
|
<None Remove="Resources\remove.png" />
|
||||||
|
<EmbeddedResource Include="Resources\remove.png" />
|
||||||
|
<None Remove="Resources\select.png" />
|
||||||
|
<EmbeddedResource Include="Resources\select.png" />
|
||||||
|
<None Remove="Resources\redo.png" />
|
||||||
|
<EmbeddedResource Include="Resources\redo.png" />
|
||||||
|
<None Remove="Resources\undo.png" />
|
||||||
|
<EmbeddedResource Include="Resources\undo.png" />
|
||||||
|
<None Remove="Resources\rubiks-cube.png" />
|
||||||
|
<EmbeddedResource Include="Resources\rubiks-cube.png" />
|
||||||
|
<None Remove="Resources\wand.png" />
|
||||||
|
<EmbeddedResource Include="Resources\wand.png" />
|
||||||
|
<None Remove="Resources\rubiks-cube-clock.png" />
|
||||||
|
<EmbeddedResource Include="Resources\rubiks-cube-clock.png" />
|
||||||
|
<None Remove="Resources\rubiks-cube-star.png" />
|
||||||
|
<EmbeddedResource Include="Resources\rubiks-cube-star.png" />
|
||||||
|
<None Remove="Resources\rubiks-cube-eye.png" />
|
||||||
|
<EmbeddedResource Include="Resources\rubiks-cube-eye.png" />
|
||||||
|
<None Remove="Resources\placeholder.png" />
|
||||||
|
<EmbeddedResource Include="Resources\placeholder.png" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
135
PropsButBetter/PropsButBetter/API/PedestalInfoBatchProcessor.cs
Normal file
|
|
@ -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<PedestalType, Dictionary<string, CachedResponse>> _cache = new()
|
||||||
|
{
|
||||||
|
{ PedestalType.Avatar, new Dictionary<string, CachedResponse>() },
|
||||||
|
{ PedestalType.Prop, new Dictionary<string, CachedResponse>() }
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Dictionary<PedestalType, Dictionary<string, TaskCompletionSource<PedestalInfoResponse>>> _pendingRequests = new()
|
||||||
|
{
|
||||||
|
{ PedestalType.Avatar, new Dictionary<string, TaskCompletionSource<PedestalInfoResponse>>() },
|
||||||
|
{ PedestalType.Prop, new Dictionary<string, TaskCompletionSource<PedestalInfoResponse>>() }
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Dictionary<PedestalType, bool> _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<PedestalInfoResponse> 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<PedestalInfoResponse>();
|
||||||
|
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<string> contentIds;
|
||||||
|
Dictionary<string, TaskCompletionSource<PedestalInfoResponse>> requestBatch;
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
contentIds = _pendingRequests[type].Keys.ToList();
|
||||||
|
requestBatch = new Dictionary<string, TaskCompletionSource<PedestalInfoResponse>>(_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<IEnumerable<PedestalInfoResponse>>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
87
PropsButBetter/PropsButBetter/API/SpawnablesMeta.cs
Normal file
|
|
@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches the metadata for a specific prop/spawnable
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="propId">The ID of the prop to fetch metadata for</param>
|
||||||
|
/// <returns>BaseResponse containing UgcWithFile data</returns>
|
||||||
|
public static async Task<BaseResponse<UgcWithFile>> GetPropMeta(string propId)
|
||||||
|
{
|
||||||
|
// Check authentication
|
||||||
|
if (!AuthManager.IsAuthenticated)
|
||||||
|
{
|
||||||
|
PropsButBetterMod.Logger.Error("Attempted to fetch prop meta while user was not authenticated.");
|
||||||
|
return new BaseResponse<UgcWithFile>("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<UgcWithFile> { Message = "CVR_SUCCESS_PropMeta" }
|
||||||
|
: JsonConvert.DeserializeObject<BaseResponse<UgcWithFile>>(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<BaseResponse<UgcWithFile>>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
294
PropsButBetter/PropsButBetter/Helpers/BoundsUtility.cs
Normal file
|
|
@ -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<byte> bytes;
|
||||||
|
public int vertexCount;
|
||||||
|
public int stride;
|
||||||
|
public int positionOffset;
|
||||||
|
public NativeArray<float3> 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<byte>.Shared.Rent(byteCount);
|
||||||
|
vb.GetData(managedBytes, 0, 0, byteCount);
|
||||||
|
|
||||||
|
var bytes = new NativeArray<byte>(
|
||||||
|
byteCount,
|
||||||
|
Allocator.TempJob,
|
||||||
|
NativeArrayOptions.UninitializedMemory);
|
||||||
|
|
||||||
|
NativeArray<byte>.Copy(managedBytes, bytes, byteCount);
|
||||||
|
ArrayPool<byte>.Shared.Return(managedBytes);
|
||||||
|
|
||||||
|
var minMax = new NativeArray<float3>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates the combined world-space bounds of all Renderers under a GameObject.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="root">The root GameObject to search under.</param>
|
||||||
|
/// <param name="includeInactive">Whether to include inactive GameObjects.</param>
|
||||||
|
/// <returns>Combined bounds, or default if no renderers found.</returns>
|
||||||
|
public static Bounds CalculateCombinedBounds(GameObject root, bool includeInactive = false)
|
||||||
|
{
|
||||||
|
if (!root)
|
||||||
|
return default;
|
||||||
|
|
||||||
|
Renderer[] renderers = root.GetComponentsInChildren<Renderer>(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates tight combined bounds using mesh vertex data instead of renderer bounds.
|
||||||
|
/// More accurate but slower than CalculateCombinedBounds.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="root">The root GameObject to search under.</param>
|
||||||
|
/// <param name="includeInactive">Whether to include inactive GameObjects.</param>
|
||||||
|
/// <returns>Combined tight bounds in world space, or default if no valid meshes found.</returns>
|
||||||
|
public static Bounds CalculateCombinedTightBounds(GameObject root, bool includeInactive = false)
|
||||||
|
{
|
||||||
|
if (!root)
|
||||||
|
return default;
|
||||||
|
|
||||||
|
MeshFilter[] meshFilters = root.GetComponentsInChildren<MeshFilter>(includeInactive);
|
||||||
|
SkinnedMeshRenderer[] skinnedRenderers = root.GetComponentsInChildren<SkinnedMeshRenderer>(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Transforms an AABB from local space to world space, returning a new AABB that fully contains the transformed box.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
777
PropsButBetter/PropsButBetter/Helpers/PropHelper.cs
Normal file
|
|
@ -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<PropHistoryData> SpawnedThisSession { get; } = new(TotalKeptSessionHistory);
|
||||||
|
|
||||||
|
// CVRSyncHelper does not keep track of your own props nicely
|
||||||
|
public static readonly List<CVRSyncHelper.PropData> MyProps = [];
|
||||||
|
|
||||||
|
// We get prop spawn/destroy events with emptied prop data sometimes, so we need to track shit ourselves :D
|
||||||
|
private static readonly Dictionary<CVRSyncHelper.PropData, string> _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<DeletedPropData> _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<Animator> _visualizerAnimators = new List<Animator>();
|
||||||
|
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<Component>(true);
|
||||||
|
_visualizerAnimators.Clear();
|
||||||
|
|
||||||
|
List<Material> _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<Component>(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<MeshFilter>();
|
||||||
|
cubeMeshFilter.sharedMesh = cubeMesh;
|
||||||
|
|
||||||
|
MeshRenderer cubeRenderer = cubeObj.AddComponent<MeshRenderer>();
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
33
PropsButBetter/PropsButBetter/Helpers/RendererHelper.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
using System;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
public static class RendererHelper
|
||||||
|
{
|
||||||
|
private static readonly Func<Renderer, int> _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<Renderer, int>)Delegate.CreateDelegate(
|
||||||
|
typeof(Func<Renderer, int>),
|
||||||
|
null,
|
||||||
|
mi
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int GetMaterialCount(this Renderer renderer)
|
||||||
|
{
|
||||||
|
return _getMaterialCount?.Invoke(renderer) ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
PropsButBetter/PropsButBetter/Helpers/UILibHelper.cs
Normal file
|
|
@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
858
PropsButBetter/PropsButBetter/PropDistanceHider.cs
Normal file
|
|
@ -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<CVRSyncHelper.PropData, PropCache> _propCaches = new();
|
||||||
|
private static readonly List<PropCache> _propCacheList = new();
|
||||||
|
private static readonly List<CVRSyncHelper.PropData> _toRemove = new();
|
||||||
|
|
||||||
|
// Reusable collections for spawn processing (avoid allocations)
|
||||||
|
private static readonly Dictionary<Transform, int> _tempPartLookup = new();
|
||||||
|
private static readonly List<Bounds> _tempBoundsList = new();
|
||||||
|
private static readonly List<Renderer> _tempRenderers = new();
|
||||||
|
private static readonly List<Collider> _tempColliders = new();
|
||||||
|
private static readonly List<Rigidbody> _tempRigidbodies = new();
|
||||||
|
private static readonly List<ParticleSystem> _tempParticleSystems = new();
|
||||||
|
private static readonly List<CVRPickupObject> _tempPickups = new();
|
||||||
|
private static readonly List<CVRAttachment> _tempAttachments = new();
|
||||||
|
private static readonly List<CVRInteractable> _tempInteractables = new();
|
||||||
|
private static readonly List<Behaviour> _tempBehaviours = new();
|
||||||
|
private static readonly List<int> _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<Component>(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();
|
||||||
|
}
|
||||||
|
}*/
|
||||||
440
PropsButBetter/PropsButBetter/QuickMenuPropList.cs
Normal file
|
|
@ -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<string, PropListEntry> _entries = new();
|
||||||
|
private static readonly Stack<string> _removalBuffer = new();
|
||||||
|
|
||||||
|
private static int[] _entrySiblingIndices;
|
||||||
|
private static int _currentLiveUpdateCycle;
|
||||||
|
private static int _lastPropCount;
|
||||||
|
|
||||||
|
private static void UpdateLivePropList()
|
||||||
|
{
|
||||||
|
List<CVRSyncHelper.PropData> 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<CVRSyncHelper.PropData> props) : IComparer<int>
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
259
PropsButBetter/PropsButBetter/QuickMenuPropSelect.cs
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
119
PropsButBetter/PropsButBetter/UIElements/ContentDisplay.cs
Normal file
|
|
@ -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(
|
||||||
|
$"<span style=\"background-color: rgba(255,255,255,0.15);" +
|
||||||
|
$"padding: 6px 14px;" +
|
||||||
|
$"border-radius: 8px;" +
|
||||||
|
$"font-size: 22px;" +
|
||||||
|
$"line-height: 1;" +
|
||||||
|
$"white-space: nowrap;" +
|
||||||
|
$"flex-shrink: 0;" +
|
||||||
|
$"margin-right: 8px;\">" +
|
||||||
|
$"{tagName}</span>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
"<span style=\"color: rgba(255,255,255,0.5); font-style: italic; font-size: 22px;\">No Tags</span>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
string htmlContent = $@"
|
||||||
|
<div style='display: flex; align-items: center;'>
|
||||||
|
<div class='button'
|
||||||
|
data-tooltip='Open details page in Main Menu.'
|
||||||
|
style='width: 300px; height: 300px; margin: 0 48px 0 0; cursor: pointer; position: relative;'
|
||||||
|
onclick='engine.trigger(""QuickMenuPropSelect-OpenDetails"");'>
|
||||||
|
<img src='{UILibHelper.PlaceholderImageCoui}'
|
||||||
|
class='shit' />
|
||||||
|
<img src='{imageUrl}'
|
||||||
|
class='shit shit2'
|
||||||
|
onload='this.style.opacity = ""1"";' />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style='flex: 1; font-size: 36px; line-height: 1.6;'>
|
||||||
|
<div style='margin-bottom: 24px;'>
|
||||||
|
<b>Tags:</b>
|
||||||
|
<div style='
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
'>
|
||||||
|
{tagsBuilder}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<b>File Size:</b>
|
||||||
|
<span style='font-size: 32px;'>
|
||||||
|
{CVRTools.HumanReadableFilesize(metadata.FileSize)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>";
|
||||||
|
|
||||||
|
if (!RootLogic.Instance.IsOnMainThread())
|
||||||
|
RootLogic.Instance.MainThreadQueue.Enqueue(() => _updateFunction.TriggerEvent(_element.ElementID, htmlContent));
|
||||||
|
else
|
||||||
|
_updateFunction.TriggerEvent(_element.ElementID, htmlContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
PropsButBetter/PropsButBetter/UIElements/GlobalButton.cs
Normal file
|
|
@ -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<string, GlobalButton> _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<string>)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();
|
||||||
|
}
|
||||||
|
}
|
||||||
256
PropsButBetter/PropsButBetter/UIElements/PropListEntry.cs
Normal file
|
|
@ -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 = $@"
|
||||||
|
<img src='{UILibHelper.PlaceholderImageCoui}'
|
||||||
|
style='
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: block;
|
||||||
|
background-color: rgba(255,255,255,0.08);
|
||||||
|
'
|
||||||
|
/>
|
||||||
|
<img src='{_currentImageUrl}'
|
||||||
|
style='
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: block;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
'
|
||||||
|
onload='this.style.opacity = 1;'
|
||||||
|
/>";
|
||||||
|
|
||||||
|
string htmlContent = $@"
|
||||||
|
<div class='button'
|
||||||
|
data-tooltip='{_propName} - Spawned by {_spawnerUsername}{tooltipSuffix}'
|
||||||
|
style='
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
height: {rowHeight}px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
{dimStyle}
|
||||||
|
'
|
||||||
|
onclick='engine.call(""PropListEntry-Selected"", ""{_instanceId}"", ""{_propId}"", {(_isDestroyed ? "true" : "false")});'>
|
||||||
|
|
||||||
|
<div style='
|
||||||
|
width: {imageSize}px;
|
||||||
|
height: {imageSize}px;
|
||||||
|
position: relative;
|
||||||
|
margin-right: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
'>
|
||||||
|
<div style='
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
'>
|
||||||
|
{imageHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style='
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
'>
|
||||||
|
|
||||||
|
<div style='
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
'>
|
||||||
|
{_propName}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style='
|
||||||
|
font-size: 26px;
|
||||||
|
color: rgba(255,255,255,0.8);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
'>
|
||||||
|
Spawned By <span style='color: rgba(255,255,255,0.95);'>{_spawnerUsername}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>";
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
132
PropsButBetter/PropsButBetter/UIElements/UndoRedoButtons.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
PropsButBetter/README.md
Normal file
|
|
@ -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.
|
||||||
BIN
PropsButBetter/Resources/SFX/sfx_deny.wav
Normal file
BIN
PropsButBetter/Resources/SFX/sfx_redo.wav
Normal file
BIN
PropsButBetter/Resources/SFX/sfx_spawn.wav
Normal file
BIN
PropsButBetter/Resources/SFX/sfx_undo.wav
Normal file
BIN
PropsButBetter/Resources/SFX/sfx_warn.wav
Normal file
BIN
PropsButBetter/Resources/placeholder.png
Normal file
|
After Width: | Height: | Size: 374 KiB |
BIN
PropsButBetter/Resources/redo.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
PropsButBetter/Resources/reload.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
PropsButBetter/Resources/remove.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
PropsButBetter/Resources/rubiks-cube-calender.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
PropsButBetter/Resources/rubiks-cube-clock.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
PropsButBetter/Resources/rubiks-cube-eye.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
PropsButBetter/Resources/rubiks-cube-star.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
PropsButBetter/Resources/rubiks-cube.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
PropsButBetter/Resources/select.png
Normal file
|
After Width: | Height: | Size: 937 B |
BIN
PropsButBetter/Resources/undo.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
PropsButBetter/Resources/wand.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
26
PropsButBetter/format.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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) |
|
| [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) |
|
| [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) |
|
| [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) |
|
| [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) |
|
| [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) |
|
| [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) |
|
||||||
|
|
|
||||||