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