From e35cfa4bcd6bad19c457544cc0c1ccd6a89cc7d6 Mon Sep 17 00:00:00 2001 From: NotAKidoS <37721153+NotAKidoS@users.noreply.github.com> Date: Mon, 5 Aug 2024 19:22:16 -0500 Subject: [PATCH] CustomSpawnPoint: initial release --- CustomSpawnPoint/CustomSpawnPoint.csproj | 2 + CustomSpawnPoint/Main.cs | 29 +++ CustomSpawnPoint/Properties/AssemblyInfo.cs | 32 +++ CustomSpawnPoint/README.md | 14 ++ CustomSpawnPoint/SpawnPointManager.cs | 265 ++++++++++++++++++++ CustomSpawnPoint/format.json | 24 ++ NAK_CVR_Mods.sln | 12 + 7 files changed, 378 insertions(+) create mode 100644 CustomSpawnPoint/CustomSpawnPoint.csproj create mode 100644 CustomSpawnPoint/Main.cs create mode 100644 CustomSpawnPoint/Properties/AssemblyInfo.cs create mode 100644 CustomSpawnPoint/README.md create mode 100644 CustomSpawnPoint/SpawnPointManager.cs create mode 100644 CustomSpawnPoint/format.json diff --git a/CustomSpawnPoint/CustomSpawnPoint.csproj b/CustomSpawnPoint/CustomSpawnPoint.csproj new file mode 100644 index 0000000..e94f9dc --- /dev/null +++ b/CustomSpawnPoint/CustomSpawnPoint.csproj @@ -0,0 +1,2 @@ + + diff --git a/CustomSpawnPoint/Main.cs b/CustomSpawnPoint/Main.cs new file mode 100644 index 0000000..dbf3f6f --- /dev/null +++ b/CustomSpawnPoint/Main.cs @@ -0,0 +1,29 @@ +using System.Reflection; +using ABI_RC.Core.InteractionSystem; +using HarmonyLib; +using MelonLoader; + +namespace NAK.CustomSpawnPoint; + +public class CustomSpawnPointMod : MelonMod +{ + internal static MelonLogger.Instance Logger; + + public override void OnInitializeMelon() + { + Logger = LoggerInstance; + + SpawnPointManager.Init(); + + HarmonyInstance.Patch( // listen for world details page request + typeof(ViewManager).GetMethod(nameof(ViewManager.RequestWorldDetailsPage)), + new HarmonyMethod(typeof(CustomSpawnPointMod).GetMethod(nameof(OnRequestWorldDetailsPage), + BindingFlags.NonPublic | BindingFlags.Static)) + ); + } + + private static void OnRequestWorldDetailsPage(string worldId) + { + SpawnPointManager.OnRequestWorldDetailsPage(worldId); + } +} \ No newline at end of file diff --git a/CustomSpawnPoint/Properties/AssemblyInfo.cs b/CustomSpawnPoint/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..867796d --- /dev/null +++ b/CustomSpawnPoint/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +using MelonLoader; +using NAK.CustomSpawnPoint.Properties; +using System.Reflection; + +[assembly: AssemblyVersion(AssemblyInfoParams.Version)] +[assembly: AssemblyFileVersion(AssemblyInfoParams.Version)] +[assembly: AssemblyInformationalVersion(AssemblyInfoParams.Version)] +[assembly: AssemblyTitle(nameof(NAK.CustomSpawnPoint))] +[assembly: AssemblyCompany(AssemblyInfoParams.Author)] +[assembly: AssemblyProduct(nameof(NAK.CustomSpawnPoint))] + +[assembly: MelonInfo( + typeof(NAK.CustomSpawnPoint.CustomSpawnPointMod), + nameof(NAK.CustomSpawnPoint), + AssemblyInfoParams.Version, + AssemblyInfoParams.Author, + downloadLink: "https://github.com/NotAKidoS/NAK_CVR_Mods/tree/main/CustomSpawnPoint" +)] + +[assembly: MelonGame("Alpha Blend Interactive", "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.CustomSpawnPoint.Properties; +internal static class AssemblyInfoParams +{ + public const string Version = "1.0.0"; + public const string Author = "NotAKidoS"; +} \ No newline at end of file diff --git a/CustomSpawnPoint/README.md b/CustomSpawnPoint/README.md new file mode 100644 index 0000000..bb8230b --- /dev/null +++ b/CustomSpawnPoint/README.md @@ -0,0 +1,14 @@ +# CustomSpawnPoint + +Replaces the unused Images button in the World Details page with a button to set a custom spawn point. + +--- + +Here is the block of text where I tell you this mod is not affiliated with or endorsed by ABI. +https://documentation.abinteractive.net/official/legal/tos/#7-modding-our-games + +> This mod is an independent creation 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. diff --git a/CustomSpawnPoint/SpawnPointManager.cs b/CustomSpawnPoint/SpawnPointManager.cs new file mode 100644 index 0000000..0cf82b9 --- /dev/null +++ b/CustomSpawnPoint/SpawnPointManager.cs @@ -0,0 +1,265 @@ +using ABI_RC.Core.InteractionSystem; +using ABI_RC.Core.Player; +using ABI_RC.Systems.GameEventSystem; +using ABI.CCK.Components; +using UnityEngine; +using Object = UnityEngine.Object; +using ABI_RC.Core; +using Newtonsoft.Json; + +namespace NAK.CustomSpawnPoint +{ + internal static class SpawnPointManager + { + #region Fields + + private static string currentWorldId = string.Empty; + private static SpawnPointData? currentSpawnPoint; + + private static string requestedWorldId = string.Empty; + private static SpawnPointData? requestedSpawnPoint; + + private static Dictionary spawnPoints = new(); + private static readonly string jsonFilePath = Path.Combine("UserData", "customspawnpoints.json"); + + private static GameObject[] customSpawnPointsArray; + private static GameObject[] originalSpawnPointsArray; + + #endregion Fields + + #region Initialization + + internal static void Init() + { + LoadSpawnpoints(); + CVRGameEventSystem.World.OnLoad.AddListener(OnWorldLoaded); + CVRGameEventSystem.World.OnUnload.AddListener(OnWorldUnloaded); + MelonLoader.MelonCoroutines.Start(WaitMainMenuUi()); + } + + private static System.Collections.IEnumerator WaitMainMenuUi() + { + while (ViewManager.Instance == null) + yield return null; + while (ViewManager.Instance.gameMenuView == null) + yield return null; + while (ViewManager.Instance.gameMenuView.Listener == null) + yield return null; + + ViewManager.Instance.OnUiConfirm.AddListener(OnClearSpawnpointConfirm); + ViewManager.Instance.gameMenuView.Listener.FinishLoad += (_) => + { + ViewManager.Instance.gameMenuView.View._view.ExecuteScript(spawnpointJs); + }; + ViewManager.Instance.gameMenuView.Listener.ReadyForBindings += () => + { + // listen for setting the spawn point on our custom button + ViewManager.Instance.gameMenuView.View.BindCall("NAKCallSetSpawnpoint", SetSpawnPoint); + }; + + // create our custom spawn point object + GameObject customSpawnPointObject = new("[CustomSpawnPoint]"); + Object.DontDestroyOnLoad(customSpawnPointObject); + + // add to array so we can easily replace worlds spawn points + customSpawnPointsArray = new[] { customSpawnPointObject }; + } + + #endregion Initialization + + #region Game Events + + private static void OnWorldLoaded(string worldId) + { + CVRWorld world = CVRWorld.Instance; + if (world == null) return; + + CustomSpawnPointMod.Logger.Msg("World loaded: " + worldId); + + currentWorldId = worldId; + currentSpawnPoint = spawnPoints.GetValueOrDefault(currentWorldId); + originalSpawnPointsArray ??= world.spawns; // cache the original spawn points array, if null its fine + + if (currentSpawnPoint.HasValue) + { + UpdateCustomSpawnPointTransform(currentSpawnPoint.Value); + world.spawns = customSpawnPointsArray; // set the custom spawn points array + + // CVRWorld.Awake already moved player, but OnWorldLoaded is invoked in OnEnable + RootLogic.Instance.SpawnOnWorldInstance(); + } + } + + private static void OnWorldUnloaded(string worldId) + { + ClearCurrentWorldState(); + } + + internal static void OnRequestWorldDetailsPage(string worldId) + { + CustomSpawnPointMod.Logger.Msg("Requesting world details page for world: " + worldId); + + requestedWorldId = worldId; + requestedSpawnPoint = spawnPoints.TryGetValue(requestedWorldId, out SpawnPointData spawnPoint) ? spawnPoint : null; + + bool hasSpawnpoint = requestedSpawnPoint.HasValue; + UpdateMenuButtonState(hasSpawnpoint, worldId == currentWorldId && CVRWorld.Instance != null && CVRWorld.Instance.allowFlying); + } + + private static void OnClearSpawnpointConfirm(string id, string value, string data) + { + if (id != "nak_clear_spawnpoint") return; + if (value == "true") ClearSpawnPoint(); + } + + #endregion Game Events + + #region Spawnpoint Management + + public static void SetSpawnPoint() + => SetSpawnPointForWorld(currentWorldId); + + public static void ClearSpawnPoint() + => ClearSpawnPointForWorld(currentWorldId); + + private static void SetSpawnPointForWorld(string worldId) + { + CustomSpawnPointMod.Logger.Msg("Setting spawn point for world: " + worldId); + + Vector3 playerPosition = PlayerSetup.Instance.GetPlayerPosition(); + Quaternion playerRotation = PlayerSetup.Instance.GetPlayerRotation(); + + // Update or create the spawn point data + SpawnPointData spawnPoint = new() + { + Position = playerPosition, + Rotation = playerRotation.eulerAngles + }; + + spawnPoints[worldId] = spawnPoint; + + // update the current world state if applicable + if (worldId == currentWorldId) + { + currentSpawnPoint = spawnPoint; + UpdateCustomSpawnPointTransform(spawnPoint); + // update the custom spawn points array + if (CVRWorld.Instance != null) CVRWorld.Instance.spawns = customSpawnPointsArray; + ViewManager.Instance.NotifyUser("(Local) Client", "Set custom spawnpoint", 2f); + } + + SaveSpawnpoints(); + UpdateMenuButtonState(true, worldId == currentWorldId); + } + + private static void ClearSpawnPointForWorld(string worldId) + { + CustomSpawnPointMod.Logger.Msg("Clearing spawn point for world: " + worldId); + + if (spawnPoints.ContainsKey(worldId)) + spawnPoints.Remove(worldId); + + if (worldId == currentWorldId) + { + currentSpawnPoint = null; + + // restore the original spawn points array + if (CVRWorld.Instance != null) CVRWorld.Instance.spawns = originalSpawnPointsArray; + ViewManager.Instance.NotifyUser("(Local) Client", "Cleared custom spawnpoint", 2f); + } + + SaveSpawnpoints(); + UpdateMenuButtonState(false, worldId == currentWorldId); + } + + private static void UpdateCustomSpawnPointTransform(SpawnPointData spawnPoint) + { + customSpawnPointsArray[0].transform.SetPositionAndRotation(spawnPoint.Position, Quaternion.Euler(spawnPoint.Rotation)); + } + + private static void UpdateMenuButtonState(bool hasSpawnpoint, bool isInWorld) + { + ViewManager.Instance.gameMenuView.View.TriggerEvent("NAKUpdateSpawnpointStatus", hasSpawnpoint.ToString(), isInWorld.ToString()); + } + + private static void ClearCurrentWorldState() + { + currentWorldId = string.Empty; + currentSpawnPoint = null; + originalSpawnPointsArray = null; + } + + #endregion Spawnpoint Management + + #region JSON Management + + private static void LoadSpawnpoints() + { + if (File.Exists(jsonFilePath)) + { + string json = File.ReadAllText(jsonFilePath); + spawnPoints = JsonConvert.DeserializeObject>(json); + } + else + { + SaveSpawnpoints(); // create the file if it doesn't exist + } + } + + private static void SaveSpawnpoints() + { + File.WriteAllText(jsonFilePath, JsonConvert.SerializeObject(spawnPoints, Formatting.Indented, + new JsonSerializerSettings { ReferenceLoopHandling = ReferenceLoopHandling.Ignore } // death + )); + } + + #endregion JSON Management + + #region Spawnpoint JS + + private const string spawnpointJs = @" +let hasSpawnpointForThisWorld = false; +let spawnpointButton = null; + +// replace the screenshot button with ours +spawnpointButton = document.querySelector('.action-btn.button.data-worldPreload.row2.col2.disabled'); +if (spawnpointButton) { + spawnpointButton.classList.remove('disabled'); + spawnpointButton.innerHTML = 'Spawnpoint'; + spawnpointButton.setAttribute('onclick', 'onClickSpawnpointButton();'); +} + +function onClickSpawnpointButton(){ + if (spawnpointButton.classList.contains('disabled')) { + return; + } + + if (hasSpawnpointForThisWorld) { + uiConfirmShow(""Custom Spawnpoint"", ""Are you sure you want to clear your spawnpoint?"", ""nak_clear_spawnpoint"", """"); + } else { + engine.call('NAKCallSetSpawnpoint'); + } +} + +engine.on('NAKUpdateSpawnpointStatus', function (hasSpawnpoint, isInWorld) { + hasSpawnpointForThisWorld = hasSpawnpoint === 'True'; + if (spawnpointButton) { + spawnpointButton.classList.toggle('disabled', isInWorld !== 'True'); + } +}); +"; + + #endregion Spawnpoint JS + } + + #region Serializable + + [Serializable] + public struct SpawnPointData + { + public Vector3 Position; + public Vector3 Rotation; + } + + #endregion Serializable +} \ No newline at end of file diff --git a/CustomSpawnPoint/format.json b/CustomSpawnPoint/format.json new file mode 100644 index 0000000..d357fb0 --- /dev/null +++ b/CustomSpawnPoint/format.json @@ -0,0 +1,24 @@ +{ + "_id": -1, + "name": "CustomSpawnPoint", + "modversion": "1.0.0", + "gameversion": "2024r175", + "loaderversion": "0.6.1", + "modtype": "Mod", + "author": "NotAKidoS", + "description": "Replaces the unused Images button in the World Details page with a button to set a custom spawn point.", + "searchtags": [ + "spawn", + "point", + "custom", + "world", + "load" + ], + "requirements": [ + "None" + ], + "downloadlink": "https://github.com/NotAKidoS/NAK_CVR_Mods/releases/download/r36/CustomSpawnPoint.dll", + "sourcelink": "https://github.com/NotAKidoS/NAK_CVR_Mods/tree/main/CustomSpawnPoint/", + "changelog": "- Initial Release", + "embedcolor": "#f61963" +} \ No newline at end of file diff --git a/NAK_CVR_Mods.sln b/NAK_CVR_Mods.sln index 7d1fef1..f4d81c0 100644 --- a/NAK_CVR_Mods.sln +++ b/NAK_CVR_Mods.sln @@ -81,6 +81,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ASTExtension", "ASTExtensio EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HeadLookLockingInputFix", "HeadLookLockingInputFix\HeadLookLockingInputFix.csproj", "{AAE8A952-2764-43CF-A5D7-515924CA2D9F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AvatarQueueSystemTweaks", "AvatarQueueSystemTweaks\AvatarQueueSystemTweaks.csproj", "{D178E422-283B-4FB3-89A6-AA4FB9F87E2F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CustomSpawnPoint", "CustomSpawnPoint\CustomSpawnPoint.csproj", "{51CA34CA-7684-4819-AC9E-89DFAD63E9AB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -243,6 +247,14 @@ Global {AAE8A952-2764-43CF-A5D7-515924CA2D9F}.Debug|Any CPU.Build.0 = Debug|Any CPU {AAE8A952-2764-43CF-A5D7-515924CA2D9F}.Release|Any CPU.ActiveCfg = Release|Any CPU {AAE8A952-2764-43CF-A5D7-515924CA2D9F}.Release|Any CPU.Build.0 = Release|Any CPU + {D178E422-283B-4FB3-89A6-AA4FB9F87E2F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D178E422-283B-4FB3-89A6-AA4FB9F87E2F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D178E422-283B-4FB3-89A6-AA4FB9F87E2F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D178E422-283B-4FB3-89A6-AA4FB9F87E2F}.Release|Any CPU.Build.0 = Release|Any CPU + {51CA34CA-7684-4819-AC9E-89DFAD63E9AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {51CA34CA-7684-4819-AC9E-89DFAD63E9AB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {51CA34CA-7684-4819-AC9E-89DFAD63E9AB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {51CA34CA-7684-4819-AC9E-89DFAD63E9AB}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE