diff --git a/PropUndoButton/Main.cs b/PropUndoButton/Main.cs new file mode 100644 index 0000000..34dc00f --- /dev/null +++ b/PropUndoButton/Main.cs @@ -0,0 +1,364 @@ +using ABI.CCK.Components; +using ABI_RC.Core.AudioEffects; +using ABI_RC.Core.Networking; +using ABI_RC.Core.Savior; +using ABI_RC.Core.Util; +using DarkRift; +using MelonLoader; +using System.Reflection; +using UnityEngine; + +namespace NAK.Melons.PropUndoButton; + +// https://pixabay.com/sound-effects/selection-sounds-73225/ + +public class PropUndoButton : MelonMod +{ + public static List deletedProps = new List(); + + private static readonly MelonPreferences_Category Category = + MelonPreferences.CreateCategory(nameof(PropUndoButton)); + + public static readonly MelonPreferences_Entry EntryEnabled = + Category.CreateEntry("Enabled", true, description: "Toggle Undo Prop Button."); + + public static readonly MelonPreferences_Entry EntryUseSFX = + Category.CreateEntry("Use SFX", true, description: "Toggle audio queues for prop spawn, undo, redo, and warning."); + + // audio clip names, InterfaceAudio adds "PropUndo_" prefix + public const string sfx_spawn = "PropUndo_sfx_spawn"; + public const string sfx_undo = "PropUndo_sfx_undo"; + public const string sfx_redo = "PropUndo_sfx_redo"; + public const string sfx_warn = "PropUndo_sfx_warn"; + public const string sfx_deny = "PropUndo_sfx_deny"; + + public const int redoHistoryLimit = 20; // amount that can be in history at once + public const int redoTimeoutLimit = 120; // seconds + + public override void OnInitializeMelon() + { + HarmonyInstance.Patch( // delete my props in reverse order for redo + typeof(CVRSyncHelper).GetMethod(nameof(CVRSyncHelper.DeleteMyProps)), + prefix: new HarmonyLib.HarmonyMethod(typeof(PropUndoButton).GetMethod(nameof(OnDeleteMyProps), BindingFlags.NonPublic | BindingFlags.Static)) + ); + HarmonyInstance.Patch( // delete all props in reverse order for redo + typeof(CVRSyncHelper).GetMethod(nameof(CVRSyncHelper.DeleteAllProps)), + prefix: new HarmonyLib.HarmonyMethod(typeof(PropUndoButton).GetMethod(nameof(OnDeleteAllProps), BindingFlags.NonPublic | BindingFlags.Static)) + ); + HarmonyInstance.Patch( // prop spawn sfx + typeof(CVRSyncHelper).GetMethod(nameof(CVRSyncHelper.SpawnProp)), + postfix: new HarmonyLib.HarmonyMethod(typeof(PropUndoButton).GetMethod(nameof(OnSpawnProp), BindingFlags.NonPublic | BindingFlags.Static)) + ); + HarmonyInstance.Patch( // prop delete sfx, log for possible redo + typeof(CVRSyncHelper).GetMethod(nameof(CVRSyncHelper.DeletePropByInstanceId)), + postfix: new HarmonyLib.HarmonyMethod(typeof(PropUndoButton).GetMethod(nameof(OnDeletePropByInstanceId), BindingFlags.NonPublic | BindingFlags.Static)) + ); + HarmonyInstance.Patch( // desktop input patch so we don't run in menus/gui + typeof(InputModuleMouseKeyboard).GetMethod(nameof(InputModuleMouseKeyboard.UpdateInput)), + postfix: new HarmonyLib.HarmonyMethod(typeof(PropUndoButton).GetMethod(nameof(OnUpdateInput), BindingFlags.NonPublic | BindingFlags.Static)) + ); + HarmonyInstance.Patch( // clear redo list on world change + typeof(CVRWorld).GetMethod(nameof(CVRWorld.ConfigureWorld)), + postfix: new HarmonyLib.HarmonyMethod(typeof(PropUndoButton).GetMethod(nameof(OnWorldLoad), BindingFlags.NonPublic | BindingFlags.Static)) + ); + + SetupDefaultAudioClips(); + } + + private void SetupDefaultAudioClips() + { + // PropUndo and audio folders do not exist, create them if dont exist yet + string path = Application.streamingAssetsPath + "/Cohtml/UIResources/GameUI/mods/PropUndo/audio/"; + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + LoggerInstance.Msg("Created PropUndo/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" }; + foreach (string clipName in clipNames) + { + string clipPath = Path.Combine(path, clipName); + if (!File.Exists(clipPath)) + { + // read the clip data from embedded resources + byte[] clipData = null; + string resourceName = "PropUndoButton.SFX." + clipName; + using (Stream stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName)) + { + clipData = new byte[stream.Length]; + stream.Read(clipData, 0, clipData.Length); + } + + // write the clip data to the file + using (FileStream fileStream = new FileStream(clipPath, FileMode.CreateNew)) + { + fileStream.Write(clipData, 0, clipData.Length); + } + + LoggerInstance.Msg("Placed missing sfx in audio folder: " + clipName); + } + } + } + + private static void OnWorldLoad() => deletedProps.Clear(); + + private static void OnUpdateInput() + { + if (!EntryEnabled.Value) return; + + if (Input.GetKey(KeyCode.LeftControl)) + { + if (Input.GetKey(KeyCode.LeftShift) && Input.GetKeyDown(KeyCode.Z)) + { + RedoProp(); + } + else if (Input.GetKeyDown(KeyCode.Z)) + { + UndoProp(); + } + } + } + + private static void OnSpawnProp() + { + if (!EntryEnabled.Value) return; + + if (!IsPropSpawnAllowed() || IsAtPropLimit()) + { + PlayAudioModule(sfx_deny); + return; + } + + PlayAudioModule(sfx_spawn); + } + + private static void OnDeletePropByInstanceId(string instanceId) + { + if (!EntryEnabled.Value || string.IsNullOrEmpty(instanceId)) return; + + var propData = GetPropByInstanceIdAndOwnerId(instanceId); + if (propData == null) return; + + AddDeletedProp(propData); + PlayAudioModule(sfx_undo); + } + + private static void AddDeletedProp(CVRSyncHelper.PropData propData) + { + if (deletedProps.Count >= redoHistoryLimit) + deletedProps.RemoveAt(0); + + DeletedProp deletedProp = new DeletedProp(propData); + deletedProps.Add(deletedProp); + } + + // delete in reverse order for undo to work as expected + private static bool OnDeleteMyProps() + { + if (!EntryEnabled.Value) return true; + + List propsList = GetAllPropsByOwnerId(); + + if (propsList.Count == 0) + { + PlayAudioModule(sfx_warn); + return false; + } + + for (int i = propsList.Count - 1; i >= 0; i--) + { + CVRSyncHelper.PropData propData = propsList[i]; + SafeDeleteProp(propData); + } + + return false; + } + + // delete in reverse order for undo to work as expected + private static bool OnDeleteAllProps() + { + if (!EntryEnabled.Value) return true; + + CVRSyncHelper.PropData[] propsList = CVRSyncHelper.Props.ToArray(); + if (propsList.Length == 0) + { + PlayAudioModule(sfx_warn); + return false; + } + + for (int i = propsList.Length - 1; i >= 0; i--) + { + CVRSyncHelper.PropData propData = propsList[i]; + DeleteProp(propData); + } + + return false; + } + + private static void UndoProp() + { + var propData = GetLatestPropByOwnerId(); + if (propData == null) + { + PlayAudioModule(sfx_warn); + return; + } + + SafeDeleteProp(propData); + } + + public static void RedoProp() + { + int index = deletedProps.Count - 1; + if (index < 0) + { + PlayAudioModule(sfx_warn); + return; + } + + if (!IsPropSpawnAllowed() || IsAtPropLimit()) + { + PlayAudioModule(sfx_deny); + return; + } + + // only allow redo of prop spawned in last minute + DeletedProp deletedProp = deletedProps[index]; + if (Time.time - deletedProp.timeDeleted <= redoTimeoutLimit) + { + SendRedoProp(deletedProp.propGuid, deletedProp.position, deletedProp.rotation); + deletedProps.RemoveAt(index); + PlayAudioModule(sfx_redo); + } + else + { + // if latest prop is too old, same with rest + deletedProps.Clear(); + PlayAudioModule(sfx_warn); + } + } + + // original spawn prop method does not let you specify rotation + public static void SendRedoProp(string propGuid, Vector3 position, Vector3 rotation) + { + using (DarkRiftWriter darkRiftWriter = DarkRiftWriter.Create()) + { + darkRiftWriter.Write(propGuid); + darkRiftWriter.Write(position.x); + darkRiftWriter.Write(position.y); + darkRiftWriter.Write(position.z); + darkRiftWriter.Write(rotation.x); + darkRiftWriter.Write(rotation.y); + darkRiftWriter.Write(rotation.z); + darkRiftWriter.Write(1f); + darkRiftWriter.Write(1f); + darkRiftWriter.Write(1f); + darkRiftWriter.Write(0f); + using (Message message = Message.Create(10050, darkRiftWriter)) + { + NetworkManager.Instance.GameNetwork.SendMessage(message, SendMode.Reliable); + } + } + } + + public static void PlayAudioModule(string module) + { + if (EntryUseSFX.Value) + { + InterfaceAudio.PlayModule(module); + } + } + + private static void DeleteProp(CVRSyncHelper.PropData propData) + { + if (propData.Spawnable != null) + { + propData.Spawnable.Delete(); + } + else + { + if (propData.Wrapper != null) + UnityEngine.Object.DestroyImmediate(propData.Wrapper); + propData.Recycle(); + } + } + + private static void SafeDeleteProp(CVRSyncHelper.PropData propData) + { + //fixes getting props stuck in limbo state if spawn & delete fast + if (propData.Spawnable == null && propData.Wrapper != null) + { + UnityEngine.Object.DestroyImmediate(propData.Wrapper); + propData.Recycle(); + } + else if (propData.Spawnable != null) + { + propData.Spawnable.Delete(); + } + else + { + PlayAudioModule(sfx_deny); + } + + //if an undo attempt is made right after spawning a prop, the + //spawnable & wrapper will both be null, so the delete request + //will be ignored- same with Delete All button in Menu now + + //so the bug causing props to get stuck is due to the propData + //being wiped before the prop spawnable & wrapper were created + + //i am unsure if i should attempt an undo of second in line prop, + //just in case some issue happens and causes the mod to lock up here. + //It should be fine though, as reloading world should clear propData...? + } + + private static bool IsPropSpawnAllowed() + { + return MetaPort.Instance.worldAllowProps + && MetaPort.Instance.settings.GetSettingsBool("ContentFilterPropsEnabled", false) + && NetworkManager.Instance.GameNetwork.ConnectionState == ConnectionState.Connected; + } + + public static bool IsAtPropLimit(int limit = 20) + { + return GetAllPropsByOwnerId().Count >= limit; + } + + private static CVRSyncHelper.PropData GetPropByInstanceIdAndOwnerId(string instanceId) + { + return CVRSyncHelper.Props.Find(propData => propData.InstanceId == instanceId && propData.SpawnedBy == MetaPort.Instance.ownerId); + } + + private static CVRSyncHelper.PropData GetLatestPropByOwnerId() + { + return CVRSyncHelper.Props.LastOrDefault(propData => propData.SpawnedBy == MetaPort.Instance.ownerId); + } + + private static List GetAllPropsByOwnerId() + { + return CVRSyncHelper.Props.FindAll(propData => propData.SpawnedBy == MetaPort.Instance.ownerId); + } + + public class DeletedProp + { + public string propGuid; + public Vector3 position; + public Vector3 rotation; + public float timeDeleted; + + public DeletedProp(CVRSyncHelper.PropData propData) + { + // Offset spawn height so game can account for it later + Transform spawnable = propData.Spawnable.transform; + Vector3 position = spawnable.position; + position.y -= propData.Spawnable.spawnHeight; + + this.propGuid = propData.ObjectId; + this.position = position; + this.rotation = spawnable.rotation.eulerAngles; + this.timeDeleted = Time.time; + } + } +} \ No newline at end of file diff --git a/PropUndoButton/PropUndoButton.csproj b/PropUndoButton/PropUndoButton.csproj new file mode 100644 index 0000000..76fb8b1 --- /dev/null +++ b/PropUndoButton/PropUndoButton.csproj @@ -0,0 +1,57 @@ + + + + + net472 + enable + latest + false + True + + + + + + + + + + + + + + + + + + + + + C:\Users\krist\Documents\GitHub\_ChilloutVR Modding\_ManagedLibs\0Harmony.dll + + + C:\Users\krist\Documents\GitHub\_ChilloutVR Modding\_ManagedLibs\Assembly-CSharp.dll + + + C:\Users\krist\Documents\GitHub\_ChilloutVR Modding\_ManagedLibs\Assembly-CSharp-firstpass.dll + + + C:\Users\krist\Documents\GitHub\_ChilloutVR Modding\_ManagedLibs\DarkRift.dll + + + C:\Users\krist\Documents\GitHub\_ChilloutVR Modding\_ManagedLibs\MelonLoader.dll + + + C:\Users\krist\Documents\GitHub\_ChilloutVR Modding\_ManagedLibs\UnityEngine.CoreModule.dll + + + C:\Users\krist\Documents\GitHub\_ChilloutVR Modding\_ManagedLibs\UnityEngine.InputLegacyModule.dll + + + + + + + + + diff --git a/PropUndoButton/PropUndoButton.sln b/PropUndoButton/PropUndoButton.sln new file mode 100644 index 0000000..0d15d70 --- /dev/null +++ b/PropUndoButton/PropUndoButton.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.2.32630.192 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PropUndoButton", "PropUndoButton.csproj", "{240E33F2-E4DD-458D-8E79-96073872CE65}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {240E33F2-E4DD-458D-8E79-96073872CE65}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {240E33F2-E4DD-458D-8E79-96073872CE65}.Debug|Any CPU.Build.0 = Debug|Any CPU + {240E33F2-E4DD-458D-8E79-96073872CE65}.Release|Any CPU.ActiveCfg = Release|Any CPU + {240E33F2-E4DD-458D-8E79-96073872CE65}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {C8F281CB-C5D8-4C4B-A7F6-9F1702C436EB} + EndGlobalSection +EndGlobal diff --git a/PropUndoButton/Properties/AssemblyInfo.cs b/PropUndoButton/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..a20c993 --- /dev/null +++ b/PropUndoButton/Properties/AssemblyInfo.cs @@ -0,0 +1,30 @@ +using NAK.Melons.PropUndoButton.Properties; +using MelonLoader; +using System.Reflection; + +[assembly: AssemblyVersion(AssemblyInfoParams.Version)] +[assembly: AssemblyFileVersion(AssemblyInfoParams.Version)] +[assembly: AssemblyInformationalVersion(AssemblyInfoParams.Version)] +[assembly: AssemblyTitle(nameof(NAK.Melons.PropUndoButton))] +[assembly: AssemblyCompany(AssemblyInfoParams.Author)] +[assembly: AssemblyProduct(nameof(NAK.Melons.PropUndoButton))] + +[assembly: MelonInfo( + typeof(NAK.Melons.PropUndoButton.PropUndoButton), + nameof(NAK.Melons.PropUndoButton), + AssemblyInfoParams.Version, + AssemblyInfoParams.Author, + downloadLink: "https://github.com/NotAKidOnSteam/UndoPropButton" +)] + +[assembly: MelonGame("Alpha Blend Interactive", "ChilloutVR")] +[assembly: MelonPlatform(MelonPlatformAttribute.CompatiblePlatforms.WINDOWS_X64)] +[assembly: MelonPlatformDomain(MelonPlatformDomainAttribute.CompatibleDomains.MONO)] +[assembly: HarmonyDontPatchAll] + +namespace NAK.Melons.PropUndoButton.Properties; +internal static class AssemblyInfoParams +{ + public const string Version = "1.0.1"; + public const string Author = "NotAKidoS"; +} \ No newline at end of file diff --git a/PropUndoButton/SFX/sfx_deny.wav b/PropUndoButton/SFX/sfx_deny.wav new file mode 100644 index 0000000..0a8b4ba Binary files /dev/null and b/PropUndoButton/SFX/sfx_deny.wav differ diff --git a/PropUndoButton/SFX/sfx_redo.wav b/PropUndoButton/SFX/sfx_redo.wav new file mode 100644 index 0000000..41bb0cf Binary files /dev/null and b/PropUndoButton/SFX/sfx_redo.wav differ diff --git a/PropUndoButton/SFX/sfx_spawn.wav b/PropUndoButton/SFX/sfx_spawn.wav new file mode 100644 index 0000000..ab2aef3 Binary files /dev/null and b/PropUndoButton/SFX/sfx_spawn.wav differ diff --git a/PropUndoButton/SFX/sfx_undo.wav b/PropUndoButton/SFX/sfx_undo.wav new file mode 100644 index 0000000..84f0cf7 Binary files /dev/null and b/PropUndoButton/SFX/sfx_undo.wav differ diff --git a/PropUndoButton/SFX/sfx_warn.wav b/PropUndoButton/SFX/sfx_warn.wav new file mode 100644 index 0000000..8e96b57 Binary files /dev/null and b/PropUndoButton/SFX/sfx_warn.wav differ diff --git a/PropUndoButton/format.json b/PropUndoButton/format.json new file mode 100644 index 0000000..e9cc453 --- /dev/null +++ b/PropUndoButton/format.json @@ -0,0 +1,23 @@ +{ + "_id": 147, + "name": "PropUndoButton", + "modversion": "1.0.1", + "gameversion": "2022r170", + "loaderversion": "0.5.7", + "modtype": "Mod", + "author": "NotAKidoS", + "description": "**CTRL+Z** to undo latest spawned prop. **CTRL+SHIFT+Z** to redo deleted prop.\nIncludes optional SFX for prop spawn, undo, redo, warn, and deny, which can be disabled in settings.\n\nYou can replace the sfx in 'ChilloutVR\\ChilloutVR_Data\\StreamingAssets\\Cohtml\\UIResources\\GameUI\\mods\\PropUndo\\audio'.", + "searchtags": [ + "prop", + "undo", + "bind", + "button" + ], + "requirements": [ + "None" + ], + "downloadlink": "https://github.com/NotAKidOnSteam/PropUndoButton/releases/download/v1.0.1/PropUndoButton.dll", + "sourcelink": "https://github.com/NotAKidOnSteam/PropUndoButton/", + "changelog": "- Initial Release\n- Added redo button.\n- Mitigated issue of props getting stuck locally if deleting them before they fully spawn.\n- Lowered SFX volume to match existing UI sounds.", + "embedcolor": "#00FFFF" +} \ No newline at end of file diff --git a/README.md b/README.md index ebda711..0363326 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,20 @@ https://user-images.githubusercontent.com/37721153/188521473-9d180795-785a-4ba0- ![image](https://user-images.githubusercontent.com/37721153/213652852-63ef50da-f6b2-4d69-a28d-0414e3d51792.png) --- +# PropUndoButton + +**CTRL+Z** to undo latest spawned prop. **CTRL+SHIFT+Z** to redo deleted prop. + +Includes optional SFX for prop spawn, undo, redo, warn, and deny, which can be disabled in settings. + +You can replace the sfx in `"ChilloutVR\ChilloutVR_Data\StreamingAssets\Cohtml\UIResources\GameUI\mods\PropUndo\audio"`. + +https://user-images.githubusercontent.com/37721153/231351589-07f794f3-f542-4cb4-b034-5c1902f86758.mp4 + +## Relevant Feedback Posts: +https://feedback.abinteractive.net/p/z-for-undo-in-game + +--- 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