[PropsButBetter] Initial release

This commit is contained in:
NotAKid 2026-03-28 01:36:33 -05:00
parent b4421a2149
commit fb37ee3a72
40 changed files with 4001 additions and 0 deletions

287
PropsButBetter/Main.cs Normal file
View 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);
}
}
}
}

View 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.");
}

View 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";
}

View 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>

View 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);
}
}
}

View 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;
}
}

View file

@ -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;
}

View file

@ -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();
}
}

View file

@ -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();
}
}
}

View file

@ -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;
}
}

View 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);
}
}

View 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");
}
}

View 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;
}
}

View 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}");
}
}

View 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();
}
}*/

View 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();
}
}

View 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;
}
}

View 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);
}
}

View 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();
}
}

View 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);
}
}

View 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
View 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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View 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"
}