diff --git a/CHANGELOG.md b/CHANGELOG.md index c759746..2078f1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,37 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] - 2026-10-02 +## [1.1.1-dev-alpha] - 2026-10-02 + +### Added + +* `SceneLoadWaiter.GetPlayer()` — extracted helper that resolves the player `GameObject` from `Deadshot.GameManager`. +* `SceneLoadWaiter.DisableAllCameras()` — extracted helper that disables all cameras except the player's `Head/MainCamera`. +* `SceneLoadWaiter.DisableMenuAndSceneRenderers()` — extracted helper that hides `MainMenu` canvases and `C1L2` scene renderers, excluding the player. +* `SceneLoadWaiter.GetCustomScene()` — extracted helper that scans loaded scenes by name and returns the matching `Scene`. +* `SceneLoadWaiter.FindCustomPlayerSpawn(Scene)` — updated to accept the already-resolved `Scene` instead of re-scanning for it. +* Added null guards and restored deep recursive traversal in `FindChildRecursive` to support nested hierarchy spawn points. +* Player `CharacterController` is now restored in a `finally` block, ensuring it is re-enabled even if player placement throws. +* `SceneManager.BaseGameplaySceneName` constant to avoid hardcoded scene name strings. +* `AsyncOperation` tracking for asynchronous scene unloads to prevent reload race conditions. + +### Changed + +* `SceneLoadWaiter.InitializePlayer()` refactored to delegate player, camera, and renderer setup to dedicated helper methods. +* Corrected the renderer-filtering scene name from `C1L1` to `C1L2`. +* `SceneManager.LoadScene()` cleans up existing waiters and passes any pending unload operation to the new waiter. +* `SceneLoadWaiter` destroys its `GameObject` upon placement completion or spawn search timeout. +* `EventManager.Update()` converted from a static method to an instance lifecycle method on `MonoBehaviour`. + +### Fixed + +* Resolved persistent `SceneLoadWaiter` memory leak and CPU overhead after scene load. +* Fixed broken child traversal in `FindChildRecursive` failing on nested spawn points. +* Fixed race condition where reloading an already loaded scene attempted placement before unloader completed. +* Fixed `EventManager.Update()` not being invoked by Unity's engine loop. +* Removed stray blank lines from `EventManager.LevelPlaytimeEvent()`. + +## [1.1.0-dev-alpha] - 2026-10-02 ### Added - `SceneLoadWaiter` MonoBehaviour for additive custom-scene loading while preserving the existing gameplay scene and player. diff --git a/Events/EventManager.cs b/Events/EventManager.cs index 8cd7121..7bc6fcf 100644 --- a/Events/EventManager.cs +++ b/Events/EventManager.cs @@ -8,24 +8,17 @@ namespace DeadshotModAPI; public class EventManager : MonoBehaviour { - public static void Update() + internal void Update() { - LevelEvent.Update(); + LevelEvent.LevelPlaytimeEvent(); } public static class LevelEvent { private static float _lastLevelTime; - public static event Action LevelTimeChanged; - public static event Action LevelCompleted; - internal static void Update() - { - LevelPlaytimeEvent(); - } - internal static void LevelPlaytimeEvent() { try diff --git a/Plugin.cs b/Plugin.cs index 1dc3a7a..7613150 100644 --- a/Plugin.cs +++ b/Plugin.cs @@ -4,7 +4,7 @@ namespace DeadshotModAPI; -[BepInPlugin("com.subaka.deadshotmodapi", "Deadshot Mod API", "v1.0.0-dev-alpha")] +[BepInPlugin("com.subaka.deadshotmodapi", "Deadshot Mod API", "v1.1.1-dev-alpha")] public class Plugin : BasePlugin { @@ -15,6 +15,5 @@ public override void Load() harmony.PatchAll(); AddComponent(); AddComponent(); - AddComponent(); } } \ No newline at end of file diff --git a/SceneManager/SceneLoadWaiter.cs b/SceneManager/SceneLoadWaiter.cs new file mode 100644 index 0000000..72aced2 --- /dev/null +++ b/SceneManager/SceneLoadWaiter.cs @@ -0,0 +1,372 @@ +using System; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace DeadshotModAPI; + +internal class SceneLoadWaiter : MonoBehaviour +{ + private const int MaxSceneLoadAttempts = 300; + private const int MaxSpawnSearchAttempts = 300; + + private string _sceneName = string.Empty; + private AsyncOperation _unloadOperation; + private GameObject _player; + private bool _initialized; + private bool _sceneLoadRequested; + private bool _playerPlaced; + private int _sceneLoadAttempts; + private int _spawnSearchAttempts; + + internal void Initialize(string sceneName, AsyncOperation unloadOperation = null) + { + try + { + Logger.Log($"SceneLoadWaiter initialized with scene: '{sceneName}'"); + + if (string.IsNullOrWhiteSpace(sceneName)) + { + Logger.Error("SceneLoadWaiter received an empty scene name."); + Destroy(gameObject); + return; + } + + _sceneName = sceneName; + _unloadOperation = unloadOperation; + } + catch (Exception ex) + { + Logger.Error($"SceneLoadWaiter.Initialize failed: {ex}"); + } + } + + internal void Update() + { + try + { + if (string.IsNullOrEmpty(_sceneName)) + { + return; + } + + if (_unloadOperation != null) + { + if (!_unloadOperation.isDone) + { + return; + } + + _unloadOperation = null; + } + + if (!_initialized) + { + InitializePlayer(); + } + + Scene customScene = GetCustomScene(); + + if (!customScene.IsValid() || !customScene.isLoaded) + { + if (!_sceneLoadRequested) + { + _sceneLoadRequested = true; + Logger.Log($"Loading custom scene: {_sceneName}"); + + try + { + UnityEngine.SceneManagement.SceneManager.LoadScene(_sceneName, LoadSceneMode.Additive); + } + catch (Exception ex) + { + _sceneLoadRequested = false; + Logger.Error($"Failed to load custom scene '{_sceneName}': {ex}"); + Destroy(gameObject); + return; + } + } + else + { + _sceneLoadAttempts++; + if (_sceneLoadAttempts > MaxSceneLoadAttempts) + { + Logger.Error($"Timed out waiting for scene '{_sceneName}' to load."); + Destroy(gameObject); + return; + } + } + + return; + } + + if (_player == null) + { + _initialized = false; + return; + } + + GameObject spawnPoint = FindCustomPlayerSpawn(customScene); + + if (spawnPoint == null) + { + _spawnSearchAttempts++; + if (_spawnSearchAttempts > MaxSpawnSearchAttempts) + { + Logger.Error($"Could not find CustomPlayerSpawn in scene '{_sceneName}'."); + Destroy(gameObject); + } + + return; + } + + CharacterController controller = _player.GetComponent(); + + if (controller == null) + { + Logger.Error("Failed to find player controller."); + Destroy(gameObject); + return; + } + + DisableAllCameras(); + DisableMenuAndSceneRenderers(); + + controller.enabled = false; + + try + { + _player.transform.SetPositionAndRotation( + spawnPoint.transform.position, + spawnPoint.transform.rotation + ); + } + finally + { + controller.enabled = true; + } + + _playerPlaced = true; + Logger.Log($"Moved player to CustomPlayerSpawn: {spawnPoint.transform.position}"); + Destroy(gameObject); + } + catch (Exception ex) + { + Logger.Error($"SceneLoadWaiter.Update failed: {ex}"); + } + } + + internal void OnDestroy() + { + SceneManager.IsSceneLoading = false; + + if (!_playerPlaced) + { + RestoreMenuAndSceneRenderers(); + } + } + + private void InitializePlayer() + { + try + { + if (!GetPlayer()) + { + return; + } + + _initialized = true; + } + catch (Exception ex) + { + Logger.Error($"SceneLoadWaiter.InitializePlayer failed: {ex}"); + } + } + + private bool GetPlayer() + { + Deadshot.GameManager gameManager = Deadshot.GameManager.INSTANCE; + + if (gameManager == null || gameManager.PlayerManager == null) + { + return false; + } + + _player = gameManager.PlayerManager.gameObject; + + if (_player == null) + { + return false; + } + + Logger.Log($"Found player: {_player.name}"); + return true; + } + + private bool DisableAllCameras() + { + Transform playerCameraTransform = _player.transform.Find("Head/MainCamera"); + + if (playerCameraTransform == null) + { + return false; + } + + Camera playerCamera = playerCameraTransform.GetComponent(); + + if (playerCamera == null) + { + return false; + } + + foreach (Camera camera in FindObjectsByType( + FindObjectsInactive.Include, + FindObjectsSortMode.None)) + { + if (camera == null) + { + continue; + } + + camera.enabled = camera == playerCamera; + } + + return true; + } + + private void DisableMenuAndSceneRenderers() + { + foreach (Canvas canvas in FindObjectsByType( + FindObjectsInactive.Include, + FindObjectsSortMode.None)) + { + if (canvas == null) + { + continue; + } + + if (canvas.gameObject.scene.name == "MainMenu") + { + canvas.enabled = false; + } + } + + foreach (Renderer renderer in FindObjectsByType( + FindObjectsInactive.Include, + FindObjectsSortMode.None)) + { + if (renderer == null) + { + continue; + } + + if (renderer.gameObject.scene.name != SceneManager.BaseGameplaySceneName) + { + continue; + } + + if (renderer.transform.IsChildOf(_player.transform)) + { + continue; + } + + renderer.enabled = false; + } + } + + private void RestoreMenuAndSceneRenderers() + { + foreach (Renderer renderer in FindObjectsByType( + FindObjectsInactive.Include, + FindObjectsSortMode.None)) + { + if (renderer == null) + { + continue; + } + + if (renderer.gameObject.scene.name == SceneManager.BaseGameplaySceneName) + { + renderer.enabled = true; + } + } + } + + private Scene GetCustomScene() + { + for (int i = 0; i < UnityEngine.SceneManagement.SceneManager.sceneCount; i++) + { + Scene scene = UnityEngine.SceneManagement.SceneManager.GetSceneAt(i); + + if (scene.name == _sceneName) + { + return scene; + } + } + + return default; + } + + private static GameObject FindCustomPlayerSpawn(Scene customScene) + { + try + { + if (!customScene.IsValid() || !customScene.isLoaded) + { + return null; + } + + foreach (GameObject root in customScene.GetRootGameObjects()) + { + if (root == null) + { + continue; + } + + GameObject spawn = FindChildRecursive(root.transform, "CustomPlayerSpawn"); + + if (spawn != null) + { + return spawn; + } + } + } + catch (Exception ex) + { + Logger.Error($"SceneLoadWaiter.FindCustomPlayerSpawn failed: {ex}"); + } + + return null; + } + + private static GameObject FindChildRecursive(Transform parent, string name) + { + if (parent == null) + { + return null; + } + + if (parent.name == name) + { + return parent.gameObject; + } + + for (int i = 0; i < parent.childCount; i++) + { + Transform child = parent.GetChild(i); + + if (child == null) + { + continue; + } + + GameObject result = FindChildRecursive(child, name); + + if (result != null) + { + return result; + } + } + + return null; + } +} \ No newline at end of file diff --git a/SceneManager/SceneManager.cs b/SceneManager/SceneManager.cs index b7e72cd..aa88d5d 100644 --- a/SceneManager/SceneManager.cs +++ b/SceneManager/SceneManager.cs @@ -1,8 +1,7 @@ using System; using System.Collections.Generic; -using System.Reflection; using UnityEngine; -using Deadshot.Player; +using UnityEngine.SceneManagement; namespace DeadshotModAPI; @@ -11,6 +10,10 @@ namespace DeadshotModAPI; /// public static class SceneManager { + internal const string BaseGameplaySceneName = "C1L2"; + + internal static bool IsSceneLoading { get; set; } + /// /// Loads a scene while keeping Deadshot's gameplay scene loaded /// so the existing player and gameplay systems remain available. @@ -26,21 +29,28 @@ public static void LoadScene(string sceneName) return; } + if (string.Equals(sceneName, BaseGameplaySceneName, StringComparison.OrdinalIgnoreCase)) + { + Logger.Error($"Cannot load '{BaseGameplaySceneName}' as a custom scene; it is the base gameplay scene."); + return; + } + + if (IsSceneLoading) + { + Logger.Log($"Scene load already in progress; ignoring duplicate request for '{sceneName}'."); + return; + } + try { - // ----------------------------------------------------- - // CHECK IF THIS SCENE IS ALREADY LOADED - // ----------------------------------------------------- + IsSceneLoading = true; - UnityEngine.SceneManagement.Scene existingScene = - default; + AsyncOperation unloadOperation = null; + Scene existingScene = default; - for (int i = 0; - i < UnityEngine.SceneManagement.SceneManager.sceneCount; - i++) + for (int i = 0; i < UnityEngine.SceneManagement.SceneManager.sceneCount; i++) { - UnityEngine.SceneManagement.Scene scene = - UnityEngine.SceneManagement.SceneManager.GetSceneAt(i); + Scene scene = UnityEngine.SceneManagement.SceneManager.GetSceneAt(i); if (scene.name == sceneName) { @@ -49,88 +59,41 @@ public static void LoadScene(string sceneName) } } - // ----------------------------------------------------- - // IF ALREADY LOADED, UNLOAD IT FIRST. - // - // This allows F6 to reload the custom scene instead - // of creating another copy/waiter. - // ----------------------------------------------------- - - if (existingScene.IsValid() && - existingScene.isLoaded) + if (existingScene.IsValid() && existingScene.isLoaded) { - Logger.Log( - $"Scene '{sceneName}' is already loaded. " + - "Unloading before reloading." - ); - - UnityEngine.SceneManagement.SceneManager.UnloadSceneAsync( - existingScene - ); - - // The next waiter will wait until the scene is gone - // before loading it again. + Logger.Log($"Scene '{sceneName}' is already loaded. Unloading before reloading."); + unloadOperation = UnityEngine.SceneManagement.SceneManager.UnloadSceneAsync(existingScene); } - // ----------------------------------------------------- - // LOAD C1L2 IF NEEDED - // ----------------------------------------------------- - - bool c1l2Loaded = false; + bool baseGameplaySceneLoaded = false; - for (int i = 0; - i < UnityEngine.SceneManagement.SceneManager.sceneCount; - i++) + for (int i = 0; i < UnityEngine.SceneManagement.SceneManager.sceneCount; i++) { - UnityEngine.SceneManagement.Scene scene = - UnityEngine.SceneManagement.SceneManager.GetSceneAt(i); + Scene scene = UnityEngine.SceneManagement.SceneManager.GetSceneAt(i); - if (scene.name == "C1L2" && - scene.isLoaded) + if (scene.name == BaseGameplaySceneName && scene.isLoaded) { - c1l2Loaded = true; + baseGameplaySceneLoaded = true; break; } } - if (!c1l2Loaded) + if (!baseGameplaySceneLoaded) { - Logger.Log( - "Loading Deadshot gameplay scene: C1L2" - ); + Logger.Log($"Loading Deadshot gameplay scene: {BaseGameplaySceneName}"); - UnityEngine.SceneManagement.SceneManager.LoadScene( - "C1L2", - UnityEngine.SceneManagement.LoadSceneMode.Additive - ); - - UnityEngine.SceneManagement.SceneManager.LoadScene( - "C1L2", - UnityEngine.SceneManagement.LoadSceneMode.Additive - ); + UnityEngine.SceneManagement.SceneManager.LoadScene(BaseGameplaySceneName, LoadSceneMode.Additive); } - // ----------------------------------------------------- - // CREATE WAITER - // ----------------------------------------------------- - - var waiterObject = - new GameObject("DeadshotModAPI_SceneLoadWaiter"); - - UnityEngine.Object.DontDestroyOnLoad( - waiterObject - ); - - SceneLoadWaiter waiter = - waiterObject.AddComponent(); - - waiter.Initialize(sceneName); + var waiterObject = new GameObject("DeadshotModAPI_SceneLoadWaiter"); + UnityEngine.Object.DontDestroyOnLoad(waiterObject); + SceneLoadWaiter waiter = waiterObject.AddComponent(); + waiter.Initialize(sceneName, unloadOperation); } catch (Exception ex) { - Logger.Error( - $"Failed to load scene '{sceneName}': {ex}" - ); + IsSceneLoading = false; + Logger.Error($"Failed to load scene '{sceneName}': {ex}"); } } @@ -150,382 +113,19 @@ internal static void LoadModsBundle() "DeadshotModApi/deadshotapi_assets" }; - List bundles = - bundleManager.LoadAssetBundles(bundlesFiles); + List bundles = bundleManager.LoadAssetBundles(bundlesFiles); - if (bundles == null) + if (bundles == null || bundles.Count == 0) { - Logger.Error( - "Failed to load asset bundles." - ); - + Logger.Error("Failed to load asset bundles."); return; } - Logger.Info( - $"Loaded {bundles.Count} asset bundles." - ); + Logger.Info($"Loaded {bundles.Count} asset bundles."); } catch (Exception ex) { - Logger.Error( - $"Failed to load Mods Menu bundle: {ex}" - ); - } - } -} - -// TODO: Review ChatGPT Code for SceneLoadWaiter, then fix issues! -internal class SceneLoadWaiter : MonoBehaviour -{ - private string _sceneName = string.Empty; - private GameObject _player; - private bool _initialized; - private bool _sceneLoadRequested; - private bool _playerPlaced; - private int _startupFrames; - - internal void Initialize(string sceneName) - { - Logger.Log( - $"SceneLoadWaiter initialized with scene: '{sceneName}'" - ); - - if (string.IsNullOrWhiteSpace(sceneName)) - { - Logger.Error( - "SceneLoadWaiter received an empty scene name." - ); - - Destroy(gameObject); - return; - } - - _sceneName = sceneName; - } - - internal void Update() - { - - // --------------------------------------------------------- - // WAIT FOR DEADSHOT TO FINISH INITIALIZING - // --------------------------------------------------------- - - if (_startupFrames < 30) - { - _startupFrames++; - return; - } - - // --------------------------------------------------------- - // INITIALIZE PLAYER - // --------------------------------------------------------- - - if (!_initialized) - { - InitializePlayer(); - - if (_player == null) - { - return; - } - - _initialized = true; - - Logger.Log( - "Player initialization complete." - ); - } - - // --------------------------------------------------------- - // FIND CUSTOM SCENE - // --------------------------------------------------------- - - UnityEngine.SceneManagement.Scene customScene = - default; - - for (int i = 0; - i < UnityEngine.SceneManagement.SceneManager.sceneCount; - i++) - { - UnityEngine.SceneManagement.Scene scene = - UnityEngine.SceneManagement.SceneManager.GetSceneAt(i); - - if (scene.name == _sceneName) - { - customScene = scene; - break; - } - } - - // --------------------------------------------------------- - // LOAD CUSTOM SCENE - // --------------------------------------------------------- - - if (!customScene.IsValid() || - !customScene.isLoaded) - { - if (!_sceneLoadRequested) - { - _sceneLoadRequested = true; - - Logger.Log( - $"Loading custom scene: {_sceneName}" - ); - - UnityEngine.SceneManagement.SceneManager.LoadScene( - _sceneName, - UnityEngine.SceneManagement.LoadSceneMode.Additive - ); - } - - return; - } - - // --------------------------------------------------------- - // CUSTOM SCENE LOADED - // --------------------------------------------------------- - - if (_playerPlaced) - { - return; - } - - Logger.Log( - $"Custom scene loaded: {_sceneName}" - ); - - GameObject spawnPoint = - FindCustomPlayerSpawn(); - - if (spawnPoint == null) - { - Logger.Error( - "Could not find CustomPlayerSpawn." - ); - - return; - } - - // --------------------------------------------------------- - // TELEPORT - // --------------------------------------------------------- - - CharacterController controller = - _player.GetComponent(); - - if (controller == null) - { - Logger.Log("Failed to find player controller."); - return; - } - - controller.enabled = false; - - _player.transform.SetPositionAndRotation( - spawnPoint.transform.position, - spawnPoint.transform.rotation - ); - - if (controller != null) - { - controller.enabled = true; - } - - Logger.Log( - $"Moved player to CustomPlayerSpawn: " + - $"{spawnPoint.transform.position}" - ); - - _playerPlaced = true; - - Logger.Log( - $"Grounded={controller?.isGrounded} | " + - $"Velocity={controller?.velocity} | " + - $"Position={_player.transform.position}" - ); - } - - private void InitializePlayer() - { - Camera _playerCamera; - - Deadshot.GameManager gameManager = - Deadshot.GameManager.INSTANCE; - - if (gameManager == null) - { - return; - } - - if (gameManager.PlayerManager == null) - { - return; - } - - _player = - gameManager.PlayerManager.gameObject; - - if (_player == null) - { - return; - } - - Logger.Log( - $"Found player: {_player.name}" - ); - - Transform playerCameraTransform = - _player.transform.Find( - "Head/MainCamera" - ); - - if (playerCameraTransform == null) - { - Logger.Error( - "Could not find Player/Head/MainCamera." - ); - - return; - } - - _playerCamera = - playerCameraTransform.GetComponent(); - - if (_playerCamera == null) - { - Logger.Error( - "Player MainCamera has no Camera component." - ); - - return; - } - - // --------------------------------------------------------- - // CAMERA - // --------------------------------------------------------- - - foreach (Camera camera in - UnityEngine.Object.FindObjectsByType( - FindObjectsInactive.Include, - FindObjectsSortMode.None)) - { - camera.enabled = - camera == _playerCamera; - } - - // --------------------------------------------------------- - // HIDE MAIN MENU - // --------------------------------------------------------- - - foreach (Canvas canvas in - UnityEngine.Object.FindObjectsByType( - FindObjectsInactive.Include, - FindObjectsSortMode.None)) - { - if (canvas.gameObject.scene.name == "MainMenu") - { - canvas.enabled = false; - } - } - - // --------------------------------------------------------- - // HIDE C1L1 VISUALS - // - // Keep ALL C1L1 COLLIDERS AND GAMEPLAY OBJECTS ALIVE. - // --------------------------------------------------------- - - foreach (Renderer renderer in - UnityEngine.Object.FindObjectsByType( - FindObjectsInactive.Include, - FindObjectsSortMode.None)) - { - if (renderer.gameObject.scene.name != "C1L1") - { - continue; - } - - if (renderer.transform.IsChildOf( - _player.transform)) - { - continue; - } - - renderer.enabled = false; - } - } - - private GameObject FindCustomPlayerSpawn() - { - UnityEngine.SceneManagement.Scene customScene = - default; - - for (int i = 0; - i < UnityEngine.SceneManagement.SceneManager.sceneCount; - i++) - { - UnityEngine.SceneManagement.Scene scene = - UnityEngine.SceneManagement.SceneManager.GetSceneAt(i); - - if (scene.name == _sceneName) - { - customScene = scene; - break; - } - } - - if (!customScene.IsValid() || - !customScene.isLoaded) - { - return null; - } - - foreach (GameObject root in - customScene.GetRootGameObjects()) - { - GameObject spawn = - FindChildRecursive( - root.transform, - "CustomPlayerSpawn" - ); - - if (spawn != null) - { - return spawn; - } - } - - return null; - } - - private static GameObject FindChildRecursive( - Transform parent, - string name) - { - if (parent.name == name) - { - return parent.gameObject; + Logger.Error($"Failed to load Mods Menu bundle: {ex}"); } - - for (int i = 0; - i < parent.childCount; - i++) - { - Transform child = - parent.GetChild(i); - - GameObject result = - FindChildRecursive( - child, - name - ); - - if (result != null) - { - return result; - } - } - - return null; } } \ No newline at end of file