Compare commits

..

No commits in common. "21a5d38af43fd467f5e89bb5eeedbe0a82d1f0c3" and "41d582146c2eeed5def58605b417a73e42145825" have entirely different histories.

41 changed files with 0 additions and 4002 deletions

View file

@ -1,287 +0,0 @@
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

@ -1,35 +0,0 @@
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

@ -1,32 +0,0 @@
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

@ -1,35 +0,0 @@
<?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

@ -1,135 +0,0 @@
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

@ -1,87 +0,0 @@
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

@ -1,11 +0,0 @@
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

@ -1,14 +0,0 @@
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

@ -1,42 +0,0 @@
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

@ -1,12 +0,0 @@
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

@ -1,294 +0,0 @@
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

@ -1,777 +0,0 @@
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

@ -1,33 +0,0 @@
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

@ -1,35 +0,0 @@
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

@ -1,858 +0,0 @@
/*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

@ -1,440 +0,0 @@
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

@ -1,259 +0,0 @@
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

@ -1,119 +0,0 @@
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

@ -1,51 +0,0 @@
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

@ -1,256 +0,0 @@
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

@ -1,132 +0,0 @@
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);
}
}

View file

@ -1,31 +0,0 @@
# 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.

Before

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 937 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -1,26 +0,0 @@
{
"_id": -1,
"name": "PropsButBetter",
"modversion": "1.0.0",
"gameversion": "2026r181",
"loaderversion": "0.7.2",
"modtype": "Mod",
"author": "NotAKidoS",
"description": "Prop quality-of-life suite. Adds a few new ways to interact with and manage Props.\n### Whats included:\n\n- **A unified Prop List**\n - Browse All Props, Your Props, or Session history in a clean, cyclable list.\n- **Smarter Prop Selection**\n - Clicking a Prop opens the Quick Menu with quick actions and useful info.\n- **Undo / Redo for Props**\n - Fix mistakes instantly without having to clear all.\n - Desktop: CTRL+Z / CTRL+SHIFT+Z\n - VR: Dedicated Undo / Redo buttons in the Props Quick Menu tab.\n- **Subtle Prop SFX**\n - Clean audio feedback when Props are spawned, removed, or respawned by you.\n- **Placement Visualizer**\n - Preview props before you've placed them while in Prop Select mode.\n\n-# Desktop undo/redo keybinds, Prop SFX, and placement visualizer are configurable in Melon Preferences.",
"searchtags": [
"prop",
"qol",
"undo",
"redo",
"sounds",
"spawn",
"list"
],
"requirements": [
"None"
],
"downloadlink": "https://github.com/NotAKidoS/NAK_CVR_Mods/releases/download/r49/PropsButBetter.dll",
"sourcelink": "https://github.com/NotAKidoS/NAK_CVR_Mods/tree/main/PropsButBetter/",
"changelog": "- Initial release",
"embedcolor": "#f61963"
}

View file

@ -15,7 +15,6 @@
| [FuckToes](FuckToes/README.md) | Prevents VRIK from autodetecting toes in Halfbody or Fullbody. | [Download](https://github.com/NotAKidoS/NAK_CVR_Mods/releases/download/r48/FuckToes.dll) |
| [PlapPlapForAll](PlapPlapForAll/README.md) | Penetrator SFX mod which adds Noach's PlapPlap prefab to any detected DPS setup on avatars that do not already have it. | [Download](https://github.com/NotAKidoS/NAK_CVR_Mods/releases/download/r48/PlapPlapForAll.dll) |
| [PropLoadingHexagon](PropLoadingHexagon/README.md) | https://github.com/NotAKidoS/NAK_CVR_Mods/assets/37721153/a892c765-71c1-47f3-a781-bdb9b60ba117 | [Download](https://github.com/NotAKidoS/NAK_CVR_Mods/releases/download/r48/PropLoadingHexagon.dll) |
| [PropsButBetter](PropsButBetter/README.md) | Prop quality-of-life suite. Adds a few new ways to interact with and manage Props. | No Download |
| [RCCVirtualSteeringWheel](RCCVirtualSteeringWheel/README.md) | Allows you to physically grab rigged RCC steering wheels in VR to provide steering input. No explicit setup required other than defining the Steering Wheel transform within the RCC component. | [Download](https://github.com/NotAKidoS/NAK_CVR_Mods/releases/download/r48/RCCVirtualSteeringWheel.dll) |
| [RelativeSyncJitterFix](RelativeSyncJitterFix/README.md) | Relative sync jitter fix is the single harmony patch that could not make it into the native release of RelativeSync. | [Download](https://github.com/NotAKidoS/NAK_CVR_Mods/releases/download/r48/RelativeSyncJitterFix.dll) |
| [ShareBubbles](ShareBubbles/README.md) | Share Bubbles! Allows you to drop down bubbles containing Avatars & Props. Requires both users to have the mod installed. Synced over Mod Network. | [Download](https://github.com/NotAKidoS/NAK_CVR_Mods/releases/download/r48/ShareBubbles.dll) |