[PropsButBetter] Initial release
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/r48/PropsButBetter.dll",
|
||||
"sourcelink": "https://github.com/NotAKidoS/NAK_CVR_Mods/tree/main/PropsButBetter/",
|
||||
"changelog": "- Initial release",
|
||||
"embedcolor": "#f61963"
|
||||
}
|
||||