diff --git a/S1API/Console/CustomConsoleRegistry.cs b/S1API/Console/CustomConsoleRegistry.cs index 372bcc35..9abc762d 100644 --- a/S1API/Console/CustomConsoleRegistry.cs +++ b/S1API/Console/CustomConsoleRegistry.cs @@ -13,6 +13,8 @@ internal static class CustomConsoleRegistry private static readonly Dictionary registry = new Dictionary(StringComparer.OrdinalIgnoreCase); + internal static IReadOnlyDictionary RegisteredCommands => registry; + internal static void Register(BaseConsoleCommand command) { if (command == null) diff --git a/S1API/Entities/Behaviour/CombatBehaviour.cs b/S1API/Entities/Behaviour/CombatBehaviour.cs index 55f01d0c..af05a165 100644 --- a/S1API/Entities/Behaviour/CombatBehaviour.cs +++ b/S1API/Entities/Behaviour/CombatBehaviour.cs @@ -13,6 +13,7 @@ using UnityEngine; using MelonLoader; using S1API.Entities.Equippables; +using S1API.Internal.Utils; using Object = UnityEngine.Object; namespace S1API.Entities.Behaviour; @@ -22,6 +23,8 @@ namespace S1API.Entities.Behaviour; /// public class CombatBehaviour { + private static readonly Logging.Log Logger = new("CombatBehaviour"); + /// /// INTERNAL: NPC reference /// @@ -57,8 +60,13 @@ public float GiveUpTime /// /// Gets or sets the default weapon asset path for the NPC's combat behaviour. /// This property allows you to specify the weapon that the NPC will use by default. - /// for convenience when setting this property. + /// Use or for convenience when setting this property. + /// Set to an empty string to clear the default weapon. /// + /// + /// Using type-safe + /// or is generally preferred and advised. + /// public string DefaultWeaponAssetPath { get @@ -74,21 +82,28 @@ public string DefaultWeaponAssetPath return; } - var go = Resources.Load(value) as GameObject; - if (go == null) + var go = Resources.Load(value); + if (!CrossType.Is(go, out var gameObject) || gameObject == null) { - Debug.LogError("Could not find weapon at path: " + value); + Logger.Error("Could not find weapon at path: " + value); return; } - var equippable = Object.Instantiate(go).GetComponent(); + var equippable = gameObject.GetComponent() ?? + gameObject.GetComponentInChildren(true); if (equippable == null) { - Debug.LogError("Could not get AvatarEquippable from weapon at path: " + value); + Logger.Error("Could not get AvatarEquippable from weapon at path: " + value); + return; + } + + if (!CrossType.Is(equippable, out var avatarWeapon)) + { + Logger.Error("AvatarEquippable at path is not an AvatarWeapon: " + value); return; } - NPC.S1NPC.Behaviour.CombatBehaviour.DefaultWeapon = equippable as AvatarWeapon; + NPC.S1NPC.Behaviour.CombatBehaviour.DefaultWeapon = avatarWeapon; } } @@ -256,4 +271,4 @@ private string GetAssetPathFromEquippable(Equippable equippable) return avatarEquippable?.AssetPath; } -} \ No newline at end of file +} diff --git a/S1API/Entities/Employees/EmployeeManager.cs b/S1API/Entities/Employees/EmployeeManager.cs new file mode 100644 index 00000000..ee346488 --- /dev/null +++ b/S1API/Entities/Employees/EmployeeManager.cs @@ -0,0 +1,96 @@ +#if IL2CPPMELON +using S1Employees = Il2CppScheduleOne.Employees; +#elif MONOMELON +using S1Employees = ScheduleOne.Employees; +#endif +using System.Collections.Generic; +using S1API.Avatar; +using UnityEngine; + +namespace S1API.Entities.Employees +{ + /// + /// Provides methods for managing employee appearances and related data. + /// + public static class EmployeeManager + { + private static readonly Logging.Log Logger = new("EmployeeManager"); + + /// + /// Gets an employee appearance by index. + /// + /// Whether to choose from male employee appearance pool + /// The index of the appearance to retrieve + /// An representing the employee appearance at the specified index if successful; otherwise, null. + public static EmployeeAppearance? GetAppearance(bool male, int index) + { + if (!S1Employees.EmployeeManager.InstanceExists) + { + Logger.Error("EmployeeManager instance does not exist; cannot get appearance"); + return null; + } + + var appearance = S1Employees.EmployeeManager.Instance.GetAppearance(male, index); + return appearance == null ? null : new EmployeeAppearance(appearance); + } + + /// + /// Gets a random employee appearance. + /// + /// Whether to choose from male employee appearance pool + /// The index of the appearance that was retrieved + /// The avatar settings of the appearance that was retrieved + /// True if an appearance was successfully retrieved; otherwise, false. + public static bool GetRandomAppearance(bool male, out int index, out AvatarSettings? settings) + { + if (!S1Employees.EmployeeManager.InstanceExists) + { + settings = null; + index = -1; + Logger.Error("EmployeeManager instance does not exist; cannot get random appearance"); + return false; + } + + S1Employees.EmployeeManager.Instance.GetRandomAppearance(male, out var i, out var avatarSettings); + if (avatarSettings == null) + { + settings = null; + index = -1; + return false; + } + + index = i; + settings = new AvatarSettings(avatarSettings); + return true; + } + } + + /// + /// Represents an employee appearance, including avatar settings and mugshot sprite. + /// + public class EmployeeAppearance + { + /// + /// INTERNAL: The underlying employee appearance from the base game. + /// + internal S1Employees.EmployeeManager.EmployeeAppearance S1EmployeeAppearance; + + /// + /// Gets the avatar settings associated with this employee appearance. + /// + public AvatarSettings Settings => new(S1EmployeeAppearance.Settings); + + /// + /// Gets the mugshot sprite associated with this employee appearance. + /// + public Sprite Mugshot => S1EmployeeAppearance.Mugshot; + + /// + /// INTERNAL: Initializes a new instance of the EmployeeAppearance class with the specified base game type appearance. + /// + internal EmployeeAppearance(S1Employees.EmployeeManager.EmployeeAppearance s1EmployeeAppearance) + { + S1EmployeeAppearance = s1EmployeeAppearance; + } + } +} diff --git a/S1API/Internal/Patches/ConsolePatches.cs b/S1API/Internal/Patches/ConsolePatches.cs index 78a56bb7..ac395a10 100644 --- a/S1API/Internal/Patches/ConsolePatches.cs +++ b/S1API/Internal/Patches/ConsolePatches.cs @@ -4,11 +4,16 @@ using HarmonyLib; using S1API.Console; using S1API.Internal.Utils; +using UnityEngine; +using Object = UnityEngine.Object; #if (MONOMELON || MONOBEPINEX || IL2CPPBEPINEX) using S1Console = ScheduleOne.Console; - +using S1CommandListScreen = ScheduleOne.CommandListScreen; +using TMPro; #elif (IL2CPPMELON) using S1Console = Il2CppScheduleOne.Console; +using S1CommandListScreen = Il2CppScheduleOne.CommandListScreen; +using Il2CppTMPro; #endif #if (IL2CPPMELON || IL2CPPBEPINEX) @@ -134,5 +139,87 @@ private static bool RouteCustomCommandsIl2Cpp(Il2CppSystem.Collections.Generic.L } } #endif + +#if (MONOMELON || MONOBEPINEX) + private static FieldInfo? _commandEntriesField; +#endif + + /// + /// Custom commands that were added to command list screen, stored here to prevent duplicate additions. + /// + private static HashSet _addedCommandsToList = new(); + + /// + /// Adds custom commands to command list screen. + /// + [HarmonyPatch(typeof(S1CommandListScreen), "Start")] + [HarmonyPostfix] + private static void AddCustomCommandEntries(S1CommandListScreen __instance) + { + try + { + if (__instance == null || __instance.CommandEntryPrefab == null || + __instance.CommandEntryContainer == null) + return; + + _addedCommandsToList.Clear(); +#if (MONOMELON || MONOBEPINEX) + _commandEntriesField ??= + typeof(S1CommandListScreen) + .GetField("commandEntries", BindingFlags.NonPublic | BindingFlags.Instance); + var commandEntries = _commandEntriesField?.GetValue(__instance) as List; +#elif (IL2CPPMELON || IL2CPPBEPINEX) + var commandEntries = __instance?.commandEntries; +#endif + + foreach (var command in CustomConsoleRegistry.RegisteredCommands) + { + var commandKey = command.Key; + try + { + if (_addedCommandsToList.Contains(commandKey)) + continue; + if (IsNativeCommand(commandKey)) + continue; + + var rt = Object.Instantiate(__instance.CommandEntryPrefab, __instance.CommandEntryContainer); + rt.Find("Command").GetComponent().text = + command.Value.CommandWord; + rt.Find("Description").GetComponent().text = + command.Value.CommandDescription; + rt.Find("Example").GetComponent().text = + command.Value.ExampleUsage; + + commandEntries?.Add(rt); + _addedCommandsToList.Add(commandKey); + } + catch (Exception e) + { + Logger.Warning($"[Console] Failed to add command '{commandKey}' to command list screen: {e.Message}"); + } + } + } + catch (Exception e) + { + Logger.Warning($"[Console] Failed to add custom commands to command list screen: {e.Message}"); + } + } + + private static bool IsNativeCommand(string commandKey) + { + if (string.IsNullOrWhiteSpace(commandKey)) + return false; + +#if (MONOMELON || MONOBEPINEX) + _monoCommandsField ??= typeof(S1Console).GetField("commands", BindingFlags.NonPublic | BindingFlags.Static); + var dict = _monoCommandsField?.GetValue(null) as IDictionary; + return dict != null && dict.ContainsKey(commandKey); +#elif (IL2CPPMELON || IL2CPPBEPINEX) + var dict = S1Console.commands; + return dict != null && dict.ContainsKey(commandKey); +#else + return false; +#endif + } } -} \ No newline at end of file +} diff --git a/S1API/PhoneApp/PhoneApp.cs b/S1API/PhoneApp/PhoneApp.cs index fbd37f2e..253b3880 100644 --- a/S1API/PhoneApp/PhoneApp.cs +++ b/S1API/PhoneApp/PhoneApp.cs @@ -51,6 +51,9 @@ public abstract class PhoneApp : Registerable /// app canvas structure in the game's Unity hierarchy. /// private GameObject? _appPanel; + private bool _isDestroying; + + internal bool IsDestroying => _isDestroying; /// /// Indicates whether the application UI has been successfully created and initialized. @@ -187,6 +190,11 @@ protected override void OnCreated() /// protected override void OnDestroyed() { + if (_isDestroying) + return; + + _isDestroying = true; + if (_appPanel != null) { Object.Destroy(_appPanel); @@ -648,6 +656,13 @@ private void Update() } } + private void OnDestroy() + { + // Destroy phone app when button handler is destroyed + if (phoneApp != null && !phoneApp.IsDestroying) + phoneApp.DestroyInternal(); + } + private bool IsHoveringButton() { // This is the same logic as native App.IsHoveringButton() @@ -658,4 +673,4 @@ private bool IsHoveringButton() return false; } } -} \ No newline at end of file +}