diff --git a/.github/workflows/verify-build.yml b/.github/workflows/verify-build.yml new file mode 100644 index 00000000..8ed2d388 --- /dev/null +++ b/.github/workflows/verify-build.yml @@ -0,0 +1,36 @@ +name: Verify Successful Build + +on: + pull_request: + types: + - opened + - reopened + - synchronize + branches: + - bleeding-edge + - stable + +jobs: + build: + name: Verify Successful Build + runs-on: ubuntu-latest + + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + + - name: Clone Game Assemblies + run: | + git clone https://x-access-token:${{ secrets.GH_PAT }}@github.com/KaBooMa/ScheduleOneAssemblies.git ./ScheduleOneAssemblies + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + + - name: Restore .NET Dependencies + run: dotnet restore + + - name: Run .NET Build for Mono + run: dotnet build ./S1API/S1API.csproj -c Mono -f netstandard2.1 + + - name: Run .NET Build for Il2Cpp + run: dotnet build ./S1API/S1API.csproj -c Il2Cpp -f netstandard2.1 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 67dd7af1..4ed0e2fc 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ obj/ bin/ *.user -*Assemblies/ \ No newline at end of file + +# Local assembly references +ScheduleOneAssemblies/ \ No newline at end of file diff --git a/S1API/DeadDrops/DeadDropInstance.cs b/S1API/DeadDrops/DeadDropInstance.cs index d1f9e4b9..ee49c249 100644 --- a/S1API/DeadDrops/DeadDropInstance.cs +++ b/S1API/DeadDrops/DeadDropInstance.cs @@ -4,6 +4,7 @@ using S1Economy = ScheduleOne.Economy; #endif +using System.Linq; using S1API.Internal.Abstraction; using S1API.Storages; using UnityEngine; @@ -13,7 +14,7 @@ namespace S1API.DeadDrops /// /// Represents a dead drop in the scene. /// - public class DeadDropInstance : ISaveable + public class DeadDropInstance : IGUIDReference { /// /// INTERNAL: Stores a reference to the game dead drop instance. @@ -32,16 +33,24 @@ public class DeadDropInstance : ISaveable internal DeadDropInstance(S1Economy.DeadDrop deadDrop) => S1DeadDrop = deadDrop; + /// + /// INTERNAL: Gets a dead drop from a GUID value. + /// + /// + /// + internal static DeadDropInstance? GetFromGUID(string guid) => + DeadDropManager.All.FirstOrDefault(deadDrop => deadDrop.GUID == guid); + /// /// The unique identifier assigned for this dead drop. /// public string GUID => S1DeadDrop.GUID.ToString(); - + /// /// The storage container associated with this dead drop. /// - public StorageInstance StorageInstance => + public StorageInstance Storage => _cachedStorage ??= new StorageInstance(S1DeadDrop.Storage); /// diff --git a/S1API/Internal/Abstraction/GUIDReferenceConverter.cs b/S1API/Internal/Abstraction/GUIDReferenceConverter.cs new file mode 100644 index 00000000..88c8796c --- /dev/null +++ b/S1API/Internal/Abstraction/GUIDReferenceConverter.cs @@ -0,0 +1,66 @@ +using System; +using System.Reflection; +using Newtonsoft.Json; +using S1API.Internal.Utils; + +namespace S1API.Internal.Abstraction +{ + /// + /// INTERNAL: JSON Converter to handle GUID referencing classes when saved and loaded. + /// + internal class GUIDReferenceConverter : JsonConverter + { + /// + /// TODO + /// + /// + /// + public override bool CanConvert(Type objectType) => + typeof(IGUIDReference).IsAssignableFrom(objectType); + + /// + /// TODO + /// + /// + /// + /// + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + if (value is IGUIDReference reference) + { + writer.WriteValue(reference.GUID); + } + else + { + writer.WriteNull(); + } + } + + /// + /// TODO + /// + /// + /// + /// + /// + /// + /// + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + string? guid = reader.Value?.ToString(); + if (string.IsNullOrEmpty(guid)) + return null; + + MethodInfo? getGUIDMethod = ReflectionUtils.GetMethod(objectType, "GetFromGUID", BindingFlags.NonPublic | BindingFlags.Static); + if (getGUIDMethod == null) + throw new Exception($"The type {objectType.Name} does not have a valid implementation of the GetFromGUID(string guid) method!"); + + return getGUIDMethod.Invoke(null, new object[] { guid }); + } + + /// + /// TODO + /// + public override bool CanRead => true; + } +} \ No newline at end of file diff --git a/S1API/Internal/Abstraction/IGUIDReference.cs b/S1API/Internal/Abstraction/IGUIDReference.cs new file mode 100644 index 00000000..15579032 --- /dev/null +++ b/S1API/Internal/Abstraction/IGUIDReference.cs @@ -0,0 +1,14 @@ +namespace S1API.Internal.Abstraction +{ + /// + /// INTERNAL: Represents a class that should serialize by GUID instead of values directly. + /// This is important to utilize on instances such as dead drops, item definitions, etc. + /// + internal interface IGUIDReference + { + /// + /// The GUID associated with the object. + /// + public string GUID { get; } + } +} \ No newline at end of file diff --git a/S1API/Internal/Abstraction/IRegisterable.cs b/S1API/Internal/Abstraction/IRegisterable.cs new file mode 100644 index 00000000..7ac0b898 --- /dev/null +++ b/S1API/Internal/Abstraction/IRegisterable.cs @@ -0,0 +1,28 @@ +namespace S1API.Internal.Abstraction +{ + /// + /// INTERNAL: Provides rigidity for registerable instance wrappers. + /// + internal interface IRegisterable + { + /// + /// INTERNAL: Called upon creation of the instance. + /// + void CreateInternal(); + + /// + /// INTERNAL: Called upon destruction of the instance. + /// + void DestroyInternal(); + + /// + /// Called upon creation of the instance. + /// + void OnCreated(); + + /// + /// Called upon destruction of the instance. + /// + void OnDestroyed(); + } +} \ No newline at end of file diff --git a/S1API/Internal/Abstraction/ISaveable.cs b/S1API/Internal/Abstraction/ISaveable.cs index e274edc2..6ac86151 100644 --- a/S1API/Internal/Abstraction/ISaveable.cs +++ b/S1API/Internal/Abstraction/ISaveable.cs @@ -1,14 +1,49 @@ -namespace S1API.Internal.Abstraction +#if (MONO) +using System.Collections.Generic; +#elif (IL2CPP) +using Il2CppSystem.Collections.Generic; +#endif + +using Newtonsoft.Json; + +namespace S1API.Internal.Abstraction { /// - /// INTERNAL: Represents a class that should serialize by GUID instead of values directly. - /// This is important to utilize on instanced objects such as dead drops. + /// INTERNAL: Provides rigidity for saveable instance wrappers. /// - internal interface ISaveable + internal interface ISaveable : IRegisterable { /// - /// The GUID assocated with the object. + /// INTERNAL: Called when saving the instance. /// - public string GUID { get; } + /// Path to save to. + /// Manipulation of the base game saveable lists. + void SaveInternal(string path, ref List extraSaveables); + + /// + /// INTERNAL: Called when loading the instance. + /// + /// + void LoadInternal(string folderPath); + + /// + /// Called when saving the instance. + /// + void OnSaved(); + + /// + /// Called when loading the instance. + /// + void OnLoaded(); + + /// + /// INTERNAL: Standard serialization settings to apply for all saveables. + /// + internal static JsonSerializerSettings SerializerSettings => + new JsonSerializerSettings + { + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + Converters = new System.Collections.Generic.List() { new GUIDReferenceConverter() } + }; } } \ No newline at end of file diff --git a/S1API/Internal/Abstraction/Registerable.cs b/S1API/Internal/Abstraction/Registerable.cs new file mode 100644 index 00000000..60009fb5 --- /dev/null +++ b/S1API/Internal/Abstraction/Registerable.cs @@ -0,0 +1,55 @@ +namespace S1API.Internal.Abstraction +{ + /// + /// INTERNAL: A registerable base class for use internally. + /// Not intended for modder use. + /// + public abstract class Registerable : IRegisterable + { + /// + /// TODO + /// + void IRegisterable.CreateInternal() => + CreateInternal(); + + /// + /// TODO + /// + internal virtual void CreateInternal() => + OnCreated(); + + /// + /// TODO + /// + void IRegisterable.DestroyInternal() => + DestroyInternal(); + + /// + /// TODO + /// + internal virtual void DestroyInternal() => + OnDestroyed(); + + /// + /// TODO + /// + void IRegisterable.OnCreated() => + OnCreated(); + + /// + /// TODO + /// + protected virtual void OnCreated() { } + + /// + /// TODO + /// + void IRegisterable.OnDestroyed() => + OnDestroyed(); + + /// + /// TODO + /// + protected virtual void OnDestroyed() { } + } +} \ No newline at end of file diff --git a/S1API/Saveables/Saveable.cs b/S1API/Internal/Abstraction/Saveable.cs similarity index 62% rename from S1API/Saveables/Saveable.cs rename to S1API/Internal/Abstraction/Saveable.cs index 33e32e1d..b40c61aa 100644 --- a/S1API/Saveables/Saveable.cs +++ b/S1API/Internal/Abstraction/Saveable.cs @@ -1,23 +1,33 @@ -using System; +#if (MONO) using System.Collections.Generic; +#elif (IL2CPP) +using Il2CppSystem.Collections.Generic; +#endif + +using System; using System.IO; using System.Reflection; -using MelonLoader; using Newtonsoft.Json; -using UnityEngine; +using S1API.Internal.Utils; +using S1API.Saveables; -namespace S1API.Saveables +namespace S1API.Internal.Abstraction { /// - /// Generic wrapper for saveable classes. + /// INTERNAL: Generic wrapper for saveable classes. /// Intended for use within the API only. /// - public abstract class Saveable + public abstract class Saveable : Registerable, ISaveable { - internal virtual void InitializeInternal(GameObject gameObject, string guid = "") { } - - internal virtual void StartInternal() => OnStarted(); + /// + /// TODO + /// + void ISaveable.LoadInternal(string folderPath) => + LoadInternal(folderPath); + /// + /// TODO + /// internal virtual void LoadInternal(string folderPath) { FieldInfo[] saveableFields = GetType().GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); @@ -27,7 +37,6 @@ internal virtual void LoadInternal(string folderPath) if (saveableFieldAttribute == null) continue; - MelonLogger.Msg($"Loading field {saveableField.Name}"); string filename = saveableFieldAttribute.SaveName.EndsWith(".json") ? saveableFieldAttribute.SaveName : $"{saveableFieldAttribute.SaveName}.json"; @@ -36,19 +45,27 @@ internal virtual void LoadInternal(string folderPath) if (!File.Exists(saveDataPath)) continue; - MelonLogger.Msg($"reading json for field {saveableField.Name}"); string json = File.ReadAllText(saveDataPath); Type type = saveableField.FieldType; - object? value = JsonConvert.DeserializeObject(json, type); + object? value = JsonConvert.DeserializeObject(json, type, ISaveable.SerializerSettings); saveableField.SetValue(this, value); } OnLoaded(); } - - internal virtual void SaveInternal(string path, ref List extraSaveables) + + /// + /// TODO + /// + void ISaveable.SaveInternal(string folderPath, ref List extraSaveables) => + SaveInternal(folderPath, ref extraSaveables); + + /// + /// TODO + /// + internal virtual void SaveInternal(string folderPath, ref List extraSaveables) { - FieldInfo[] saveableFields = GetType().GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + FieldInfo[] saveableFields = ReflectionUtils.GetAllFields(GetType(), BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); foreach (FieldInfo saveableField in saveableFields) { SaveableField saveableFieldAttribute = saveableField.GetCustomAttribute(); @@ -59,7 +76,7 @@ internal virtual void SaveInternal(string path, ref List extraSaveables) ? saveableFieldAttribute.SaveName : $"{saveableFieldAttribute.SaveName}.json"; - string saveDataPath = Path.Combine(path, saveFileName); + string saveDataPath = Path.Combine(folderPath, saveFileName); object value = saveableField.GetValue(this); if (value == null) @@ -72,7 +89,7 @@ internal virtual void SaveInternal(string path, ref List extraSaveables) extraSaveables.Add(saveFileName); // Write our data - string data = JsonConvert.SerializeObject(value, Formatting.Indented); + string data = JsonConvert.SerializeObject(value, Formatting.Indented, ISaveable.SerializerSettings); File.WriteAllText(saveDataPath, data); } } @@ -80,8 +97,26 @@ internal virtual void SaveInternal(string path, ref List extraSaveables) OnSaved(); } - protected virtual void OnStarted() { } + /// + /// TODO + /// + void ISaveable.OnLoaded() => + OnLoaded(); + + /// + /// TODO + /// protected virtual void OnLoaded() { } + + /// + /// TODO + /// + void ISaveable.OnSaved() => + OnSaved(); + + /// + /// TODO + /// protected virtual void OnSaved() { } } } \ No newline at end of file diff --git a/S1API/Internal/Patches/NPCPatches.cs b/S1API/Internal/Patches/NPCPatches.cs index 2bdc0cd3..c4304982 100644 --- a/S1API/Internal/Patches/NPCPatches.cs +++ b/S1API/Internal/Patches/NPCPatches.cs @@ -14,7 +14,6 @@ using HarmonyLib; using S1API.Internal.Utils; using S1API.NPCs; -using UnityEngine; namespace S1API.Internal.Patches { @@ -29,7 +28,7 @@ internal class NPCPatches /// /// List of all custom NPCs currently created. /// - private static System.Collections.Generic.List _npcs = new System.Collections.Generic.List(); + private static readonly System.Collections.Generic.List NPCs = new System.Collections.Generic.List(); /// /// Patching performed for when game NPCs are loaded. @@ -42,10 +41,8 @@ private static void NPCsLoadersLoad(S1Loaders.NPCsLoader __instance, string main { foreach (Type type in ReflectionUtils.GetDerivedClasses()) { - GameObject gameObject = new GameObject(type.Name); NPC customNPC = (NPC)Activator.CreateInstance(type); - customNPC.InitializeInternal(gameObject); - _npcs.Add(customNPC); + NPCs.Add(customNPC); string npcPath = Path.Combine(mainPath, customNPC.S1NPC.SaveFolderName); customNPC.LoadInternal(npcPath); } @@ -58,21 +55,18 @@ private static void NPCsLoadersLoad(S1Loaders.NPCsLoader __instance, string main [HarmonyPatch(typeof(S1NPCs.NPC), "Start")] [HarmonyPostfix] private static void NPCStart(S1NPCs.NPC __instance) => - _npcs.FirstOrDefault(npc => npc.S1NPC == __instance)?.StartInternal(); + NPCs.FirstOrDefault(npc => npc.S1NPC == __instance)?.CreateInternal(); /// /// Patching performed for when an NPC calls to save data. /// /// Instance of the NPC - /// Path to this NPCs folder. + /// Path to the base NPC folder. /// [HarmonyPatch(typeof(S1NPCs.NPC), "WriteData")] [HarmonyPostfix] - private static void NPCWriteData(S1NPCs.NPC __instance, string parentFolderPath, ref List __result) - { - System.Collections.Generic.List list = __result.ToArray().ToList(); - _npcs.FirstOrDefault(npc => npc.S1NPC == __instance)?.SaveInternal(parentFolderPath, ref list); - } + private static void NPCWriteData(S1NPCs.NPC __instance, string parentFolderPath, ref List __result) => + NPCs.FirstOrDefault(npc => npc.S1NPC == __instance)?.SaveInternal(parentFolderPath, ref __result); /// /// Patching performed for when an NPC is destroyed. @@ -82,12 +76,12 @@ private static void NPCWriteData(S1NPCs.NPC __instance, string parentFolderPath, [HarmonyPostfix] private static void NPCOnDestroy(S1NPCs.NPC __instance) { - _npcs.RemoveAll(npc => npc.S1NPC == __instance); - NPC? npc = _npcs.FirstOrDefault(npc => npc.S1NPC == __instance); + NPCs.RemoveAll(npc => npc.S1NPC == __instance); + NPC? npc = NPCs.FirstOrDefault(npc => npc.S1NPC == __instance); if (npc == null) return; - _npcs.Remove(npc); + NPCs.Remove(npc); } } } \ No newline at end of file diff --git a/S1API/Internal/Patches/QuestPatches.cs b/S1API/Internal/Patches/QuestPatches.cs index dc849e9a..1865b935 100644 --- a/S1API/Internal/Patches/QuestPatches.cs +++ b/S1API/Internal/Patches/QuestPatches.cs @@ -15,8 +15,10 @@ using System.Linq; using HarmonyLib; using Newtonsoft.Json; +using S1API.Internal.Abstraction; using S1API.Internal.Utils; using S1API.Quests; +using UnityEngine; namespace S1API.Internal.Patches { @@ -36,18 +38,10 @@ internal class QuestPatches [HarmonyPostfix] private static void QuestManagerWriteData(S1Quests.QuestManager __instance, string parentFolderPath, ref List __result) { - System.Collections.Generic.List list = __result.ToArray().ToList(); - string questsPath = Path.Combine(parentFolderPath, "Quests"); foreach (Quest quest in QuestManager.Quests) - { - string questDataPath = Path.Combine(questsPath, quest.S1Quest.SaveFolderName); - if (!Directory.Exists(questDataPath)) - Directory.CreateDirectory(questDataPath); - - quest.SaveInternal(questDataPath, ref list); - } + quest.SaveInternal(questsPath, ref __result); } /// @@ -56,7 +50,7 @@ private static void QuestManagerWriteData(S1Quests.QuestManager __instance, stri /// Instance of the quest loader. /// Path to the base Quest folder. [HarmonyPatch(typeof(S1Loaders.QuestsLoader), "Load")] - [HarmonyPrefix] + [HarmonyPostfix] private static void QuestsLoaderLoad(S1Loaders.QuestsLoader __instance, string mainPath) { string[] questDirectories = Directory.GetDirectories(mainPath) @@ -71,18 +65,18 @@ private static void QuestsLoaderLoad(S1Loaders.QuestsLoader __instance, string m if (questDataText == null) continue; - S1Datas.QuestData? baseQuestData = JsonConvert.DeserializeObject(questDataText); + S1Datas.QuestData baseQuestData = JsonUtility.FromJson(questDataText); string questDirectoryPath = Path.Combine(mainPath, questDirectory); string questDataPath = Path.Combine(questDirectoryPath, "QuestData"); if (!__instance.TryLoadFile(questDataPath, out string questText)) continue; - QuestData? questData = JsonConvert.DeserializeObject(questText); - if (questData?.QuestType == null) + QuestData? questData = JsonConvert.DeserializeObject(questText, ISaveable.SerializerSettings); + if (questData?.ClassName == null) continue; - Type? questType = ReflectionUtils.GetTypeByName(questData.QuestType); + Type? questType = ReflectionUtils.GetTypeByName(questData.ClassName); if (questType == null || !typeof(Quest).IsAssignableFrom(questType)) continue; @@ -114,7 +108,7 @@ private static void QuestManagerDeleteUnapprovedFiles(S1Quests.QuestManager __in [HarmonyPatch(typeof(S1Quests.Quest), "Start")] [HarmonyPrefix] private static void QuestStart(S1Quests.Quest __instance) => - QuestManager.Quests.FirstOrDefault(quest => quest.S1Quest == __instance)?.StartInternal(); + QuestManager.Quests.FirstOrDefault(quest => quest.S1Quest == __instance)?.CreateInternal(); /////// TODO: Quests doesn't have OnDestroy. Find another way to clean up // [HarmonyPatch(typeof(S1Quests.Quest), "OnDestroy")] diff --git a/S1API/Internal/Utils/CrossType.cs b/S1API/Internal/Utils/CrossType.cs index 1cab12ef..5ce09684 100644 --- a/S1API/Internal/Utils/CrossType.cs +++ b/S1API/Internal/Utils/CrossType.cs @@ -1,12 +1,9 @@ #if (MONO) using System; -using UnityEngine; # elif (IL2CPP) - using Il2CppSystem; using Il2CppInterop.Runtime; using Il2CppInterop.Runtime.InteropTypes; -using MelonLoader; #endif namespace S1API.Internal.Utils diff --git a/S1API/Internal/Utils/ReflectionUtils.cs b/S1API/Internal/Utils/ReflectionUtils.cs index cfb6c603..ff22e152 100644 --- a/S1API/Internal/Utils/ReflectionUtils.cs +++ b/S1API/Internal/Utils/ReflectionUtils.cs @@ -3,13 +3,6 @@ using System.Linq; using System.Reflection; -#if (IL2CPP) -// using Il2CppInterop.Runtime; -// using Il2CppSystem; -// using Il2CppSystem.Collections.Generic; -#elif (MONO) -#endif - namespace S1API.Internal.Utils { /// @@ -43,11 +36,11 @@ internal static List GetDerivedClasses() } /// - /// Gets all types by their name. + /// INTERNAL: Gets all types by their name. /// /// The name of the type. /// The actual type identified by the name. - public static Type? GetTypeByName(string typeName) + internal static Type? GetTypeByName(string typeName) { Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies(); foreach (Assembly assembly in assemblies) @@ -61,5 +54,43 @@ internal static List GetDerivedClasses() return null; } + + /// + /// INTERNAL: Recursively gets fields from a class down to the object type. + /// + /// The type you want to recursively search. + /// The binding flags to apply during the search. + /// + internal static FieldInfo[] GetAllFields(Type? type, BindingFlags bindingFlags) + { + List fieldInfos = new List(); + while (type != null && type != typeof(object)) + { + fieldInfos.AddRange(type.GetFields(bindingFlags)); + type = type.BaseType; + } + return fieldInfos.ToArray(); + } + + /// + /// INTERNAL: Recursively searches for a method by name from a class down to the object type. + /// + /// The type you want to recursively search. + /// The name of the method you're searching for. + /// The binding flags to apply during the search. + /// + public static MethodInfo? GetMethod(Type? type, string methodName, BindingFlags bindingFlags) + { + while (type != null && type != typeof(object)) + { + MethodInfo? method = type.GetMethod(methodName, bindingFlags); + if (method != null) + return method; + + type = type.BaseType; + } + + return null; + } } } \ No newline at end of file diff --git a/S1API/Items/ItemDefinition.cs b/S1API/Items/ItemDefinition.cs index 943c69f5..b7884c67 100644 --- a/S1API/Items/ItemDefinition.cs +++ b/S1API/Items/ItemDefinition.cs @@ -4,7 +4,6 @@ using S1ItemFramework = ScheduleOne.ItemFramework; #endif -using MelonLoader; using S1API.Internal.Abstraction; namespace S1API.Items @@ -14,7 +13,7 @@ namespace S1API.Items /// NOTE: A definition is "what" the item is. For example, "This is a `Soda`". /// Any instanced items in the game will be a instead. /// - public class ItemDefinition : ISaveable + public class ItemDefinition : IGUIDReference { /// /// INTERNAL: A reference to the item definition in the game. @@ -27,6 +26,14 @@ public class ItemDefinition : ISaveable /// internal ItemDefinition(S1ItemFramework.ItemDefinition s1ItemDefinition) => S1ItemDefinition = s1ItemDefinition; + + /// + /// INTERNAL: Gets an item definition from a GUID. + /// + /// The GUID to look for + /// The applicable item definition, if found. + internal static ItemDefinition GetFromGUID(string guid) => + ItemManager.GetItemDefinition(guid); /// /// Performs an equals check on the game item definition instance. @@ -51,7 +58,6 @@ public override int GetHashCode() => /// Whether the item definitions are the same or not. public static bool operator ==(ItemDefinition? left, ItemDefinition? right) { - MelonLogger.Msg($"Doing a == comparison"); if (ReferenceEquals(left, right)) return true; return left?.S1ItemDefinition == right?.S1ItemDefinition; } @@ -70,7 +76,7 @@ public override int GetHashCode() => /// public virtual string GUID => S1ItemDefinition.ID; - + /// /// The unique identifier assigned to this item definition. /// diff --git a/S1API/Items/ItemSlotInstance.cs b/S1API/Items/ItemSlotInstance.cs index 7afbd21a..5b6d750b 100644 --- a/S1API/Items/ItemSlotInstance.cs +++ b/S1API/Items/ItemSlotInstance.cs @@ -6,7 +6,6 @@ using S1Product = ScheduleOne.Product; #endif -using MelonLoader; using S1API.Internal.Utils; using S1API.Money; using S1API.Products; diff --git a/S1API/NPCs/NPC.cs b/S1API/NPCs/NPC.cs index 4aab1daf..d53dc27d 100644 --- a/S1API/NPCs/NPC.cs +++ b/S1API/NPCs/NPC.cs @@ -12,8 +12,6 @@ using S1Vehicles = Il2CppScheduleOne.Vehicles; using S1Vision = Il2CppScheduleOne.Vision; using S1NPCs = Il2CppScheduleOne.NPCs; -using S1Persistence = Il2CppScheduleOne.Persistence; -using S1Datas = Il2CppScheduleOne.Persistence.Datas; using Il2CppSystem.Collections.Generic; #elif (MONO) using S1DevUtilities = ScheduleOne.DevUtilities; @@ -35,8 +33,8 @@ #endif using System; -using MelonLoader; -using S1API.Saveables; +using System.IO; +using S1API.Internal.Abstraction; using UnityEngine; using UnityEngine.Events; @@ -47,48 +45,39 @@ namespace S1API.NPCs /// public abstract class NPC : Saveable { - /// - /// The first name to assign to the NPC. - /// - protected abstract string FirstName { get; } - - /// - /// The last name to assign to the NPC. - /// - protected abstract string LastName { get; } - - /// - /// The unique identifier to give the NPC. - /// - protected abstract string ID { get; } - /// /// A list of text responses you've added to your NPC. /// protected readonly System.Collections.Generic.List Responses = new System.Collections.Generic.List(); - internal S1NPCs.NPC S1NPC => _s1NPC ?? throw new InvalidOperationException("S1NPC not initialized"); - private S1NPCs.NPC? _s1NPC; + internal readonly S1NPCs.NPC S1NPC; - private GameObject? _gameObject; + private readonly GameObject _gameObject; - internal override void InitializeInternal(GameObject gameObject, string guid = "") + /// + /// Base constructor for a new NPC. + /// Intended to be wrapped in your derived class constructor such as: + /// public class MyNPC : NPC ... + /// public MyNPC() : base(id, fname, lname) { ... } + /// + /// The unique identifier for this NPC + /// + /// + public NPC(string guid, string firstName, string lastName) { - MelonLogger.Msg("Our NPC is awake!"); - _gameObject = gameObject; + _gameObject = new GameObject("NPC"); // Deactivate game object til we're done _gameObject.SetActive(false); // Setup the base NPC class - _s1NPC = _gameObject.AddComponent(); - S1NPC.FirstName = FirstName; - S1NPC.LastName = LastName; - S1NPC.ID = ID; + S1NPC = _gameObject.AddComponent(); + S1NPC.FirstName = firstName; + S1NPC.LastName = lastName; + S1NPC.ID = guid; S1NPC.BakedGUID = Guid.NewGuid().ToString(); S1NPC.MugshotSprite = S1DevUtilities.PlayerSingleton.Instance.AppIcon; - MelonLogger.Msg("Added S1NPC"); // ReSharper disable once UseObjectOrCollectionInitializer S1NPC.ConversationCategories = new List(); @@ -101,14 +90,13 @@ internal override void InitializeInternal(GameObject gameObject, string guid = " MethodInfo createConvoMethod = AccessTools.Method(typeof(S1NPCs.NPC), "CreateMessageConversation"); createConvoMethod.Invoke(S1NPC, null); #endif - MelonLogger.Msg("Setup Convo"); // Add UnityEvents for NPCHealth S1NPC.Health = _gameObject.GetComponent(); S1NPC.Health.onDie = new UnityEvent(); S1NPC.Health.onKnockedOut = new UnityEvent(); S1NPC.Health.Invincible = true; - MelonLogger.Msg("Added Health"); + S1NPC.Health.MaxHealth = 100f; // Awareness behaviour GameObject awarenessObject = new GameObject("NPCAwareness"); @@ -124,14 +112,12 @@ internal override void InitializeInternal(GameObject gameObject, string guid = " S1NPC.awareness.onNoticedPlayerViolatingCurfew = new UnityEvent(); S1NPC.awareness.onNoticedSuspiciousPlayer = new UnityEvent(); S1NPC.awareness.Listener = _gameObject.AddComponent(); - MelonLogger.Msg("Added Awareness"); // Response to actions like gunshots, drug deals, etc. GameObject responsesObject = new GameObject("NPCResponses"); responsesObject.SetActive(false); responsesObject.transform.SetParent(_gameObject.transform); S1NPC.awareness.Responses = responsesObject.AddComponent(); - MelonLogger.Msg("Added behaviour responses"); // Vision cone object and behaviour GameObject visionObject = new GameObject("VisionCone"); @@ -142,7 +128,6 @@ internal override void InitializeInternal(GameObject gameObject, string guid = " // Suspicious ? icon in world space S1NPC.awareness.VisionCone.QuestionMarkPopup = _gameObject.AddComponent(); - MelonLogger.Msg("Added vision cone"); // Interaction behaviour #if (IL2CPP) @@ -151,7 +136,6 @@ internal override void InitializeInternal(GameObject gameObject, string guid = " FieldInfo intObjField = AccessTools.Field(typeof(S1NPCs.NPC), "intObj"); intObjField.SetValue(S1NPC, _gameObject.AddComponent()); #endif - MelonLogger.Msg("Added Interaction"); // Relationship data S1NPC.RelationData = new S1Relation.NPCRelationData(); @@ -165,11 +149,9 @@ void OnUnlockAction(S1Relation.NPCRelationData.EUnlockType unlockType, bool noti } S1NPC.RelationData.onUnlocked += (Action)OnUnlockAction; - MelonLogger.Msg("Added relation data"); // Inventory behaviour S1NPCs.NPCInventory inventory = _gameObject.AddComponent(); - MelonLogger.Msg("Added inventory"); // Pickpocket behaviour inventory.PickpocketIntObj = _gameObject.AddComponent(); @@ -179,29 +161,24 @@ void OnUnlockAction(S1Relation.NPCRelationData.EUnlockType unlockType, bool noti // Register NPC in registry S1NPCs.NPCManager.NPCRegistry.Add(S1NPC); - MelonLogger.Msg("registered"); - // MelonLogger.Msg("Spawning network object..."); // NetworkObject networkObject = gameObject.AddComponent(); // // networkObject.NetworkBehaviours = InstanceFinder.NetworkManager; // PropertyInfo networkBehavioursProperty = AccessTools.Property(typeof(NetworkObject), "NetworkBehaviours"); // networkBehavioursProperty.SetValue(networkObject, new [] { this }); - // MelonLogger.Msg("Custom NPC is awake!"); // Enable our custom gameObjects so they can initialize - MelonLogger.Msg("setting active..."); _gameObject.SetActive(true); visionObject.SetActive(true); responsesObject.SetActive(true); awarenessObject.SetActive(true); - - MelonLogger.Msg("NPC added successfully."); - - base.InitializeInternal(gameObject, guid); } - internal override void StartInternal() + /// + /// INTERNAL: Initializes the responses that have been added / loaded + /// + internal override void CreateInternal() { // Assign responses to our tracked responses foreach (S1Messaging.Response s1Response in S1NPC.MSGConversation.currentResponses) @@ -210,8 +187,14 @@ internal override void StartInternal() Responses.Add(response); OnResponseLoaded(response); } + + base.CreateInternal(); + } - base.StartInternal(); + internal override void SaveInternal(string folderPath, ref List extraSaveables) + { + string npcPath = Path.Combine(folderPath, S1NPC.SaveFolderName); + base.SaveInternal(npcPath, ref extraSaveables); } /// @@ -221,7 +204,7 @@ internal override void StartInternal() /// The message you want the player to see. Unity rich text is allowed. /// Instances of to display. /// The delay between when the message is sent and when the player can reply. - /// Whether or not this should propagate to all players. + /// Whether this should propagate to all players or not. public void SendTextMessage(string message, Response[]? responses = null, float responseDelay = 1f, bool network = true) { S1NPC.SendTextMessage(message); @@ -238,7 +221,6 @@ public void SendTextMessage(string message, Response[]? responses = null, float { Responses.Add(response); responsesList.Add(response.S1Response); - MelonLogger.Msg(response.Label); } S1NPC.MSGConversation.ShowResponses( diff --git a/S1API/PhoneApp/MyAwesomeApp.cs b/S1API/PhoneApp/MyAwesomeApp.cs new file mode 100644 index 00000000..2a5fa577 --- /dev/null +++ b/S1API/PhoneApp/MyAwesomeApp.cs @@ -0,0 +1,20 @@ +using UnityEngine; +using UnityEngine.UI; +using S1API.PhoneApp; + +namespace S1API.PhoneApp +{ + public class MyAwesomeApp : PhoneApp + { + protected override string AppName => "MyAwesomeApp"; + protected override string AppTitle => "My Awesome App"; + protected override string IconLabel => "Awesome"; + protected override string IconFileName => "my_icon.png"; + + protected override void OnCreated(GameObject container) + { + GameObject panel = UIFactory.Panel("MainPanel", container.transform, Color.black); + UIFactory.Text("HelloText", "Hello from My Awesome App!", panel.transform, 22, TextAnchor.MiddleCenter, FontStyle.Bold); + } + } +} diff --git a/S1API/PhoneApp/PhoneApp.cs b/S1API/PhoneApp/PhoneApp.cs new file mode 100644 index 00000000..9242e836 --- /dev/null +++ b/S1API/PhoneApp/PhoneApp.cs @@ -0,0 +1,250 @@ +#if IL2CPP +using UnityEngine; +using UnityEngine.UI; +using UnityEngine.Events; +#elif MONO +using UnityEngine; +using UnityEngine.UI; +using UnityEngine.Events; +#endif + +using System.Collections; +using System.IO; +using MelonLoader; +using Object = UnityEngine.Object; +using MelonLoader.Utils; + +namespace S1API.PhoneApp +{ + /// + /// Base class for defining in-game phone apps. Automatically clones the phone UI, + /// injects your app, and supports icon customization. + /// + public abstract class PhoneApp + { + /// + /// Reference to the player object in the scene. + /// + protected GameObject Player; + + /// + /// The actual app panel instance in the phone UI. + /// + protected GameObject AppPanel; + + /// + /// Whether the app panel was created by this instance. + /// + protected bool AppCreated; + + /// + /// Tracks whether the app icon has been injected into the home screen. + /// + protected bool IconModified; + + /// + /// Prevents double-initialization. + /// + protected bool InitializationStarted; + + /// + /// Unique internal name for the app GameObject. + /// + protected abstract string AppName { get; } + + /// + /// Title text displayed at the top of the app UI. + /// + protected abstract string AppTitle { get; } + + /// + /// Label shown below the app icon on the phone home screen. + /// + protected abstract string IconLabel { get; } + + /// + /// PNG filename for the app icon (must be placed in UserData folder). + /// + protected abstract string IconFileName { get; } + + /// + /// Called after the app is created and a UI container is available. + /// Implement your custom UI here. + /// + /// The GameObject container inside the app panel. + protected abstract void OnCreated(GameObject container); + + /// + /// Begins async setup of the app, including icon and panel creation. + /// Should only be called once per session. + /// + /// Logger to report errors and status. + public void Init(MelonLogger.Instance logger) + { + if (!InitializationStarted) + { + InitializationStarted = true; + MelonCoroutines.Start(DelayedInit(logger)); + } + } + + /// + /// Coroutine that delays setup to ensure all UI elements are ready. + /// + private IEnumerator DelayedInit(MelonLogger.Instance logger) + { + yield return new WaitForSeconds(5f); + + Player = GameObject.Find("Player_Local"); + if (Player == null) + { + logger.Error("Player_Local not found."); + yield break; + } + + GameObject appsCanvas = GameObject.Find("Player_Local/CameraContainer/Camera/OverlayCamera/GameplayMenu/Phone/phone/AppsCanvas"); + if (appsCanvas == null) + { + logger.Error("AppsCanvas not found."); + yield break; + } + + Transform existingApp = appsCanvas.transform.Find(AppName); + if (existingApp != null) + { + AppPanel = existingApp.gameObject; + SetupExistingAppPanel(AppPanel, logger); + } + else + { + Transform templateApp = appsCanvas.transform.Find("ProductManagerApp"); + if (templateApp == null) + { + logger.Error("Template ProductManagerApp not found."); + yield break; + } + + AppPanel = Object.Instantiate(templateApp.gameObject, appsCanvas.transform); + AppPanel.name = AppName; + + Transform containerTransform = AppPanel.transform.Find("Container"); + if (containerTransform != null) + { + GameObject container = containerTransform.gameObject; + ClearContainer(container); + OnCreated(container); + } + + AppCreated = true; + } + + AppPanel.SetActive(false); + + if (!IconModified) + { + IconModified = ModifyAppIcon(IconLabel, IconFileName, logger); + if (IconModified) + logger.Msg("Icon modified."); + } + } + + /// + /// Sets up an existing app panel found in the scene (likely reused from a previous session). + /// + private void SetupExistingAppPanel(GameObject panel, MelonLogger.Instance logger) + { + Transform containerTransform = panel.transform.Find("Container"); + if (containerTransform != null) + { + GameObject container = containerTransform.gameObject; + if (container.transform.childCount < 2) + { + ClearContainer(container); + // TODO: (@omar-akermi) Looks like a method got relabeled. Need to resolve :( + // BuildUI(container); + } + } + + AppCreated = true; + } + + /// + /// Destroys all children of the app container to prepare for UI rebuilding. + /// + private void ClearContainer(GameObject container) + { + for (int i = container.transform.childCount - 1; i >= 0; i--) + Object.Destroy(container.transform.GetChild(i).gameObject); + } + + /// + /// Attempts to clone an app icon from the home screen and customize it. + /// + private bool ModifyAppIcon(string labelText, string fileName, MelonLogger.Instance logger) + { + GameObject parent = GameObject.Find("Player_Local/CameraContainer/Camera/OverlayCamera/GameplayMenu/Phone/phone/HomeScreen/AppIcons/"); + if (parent == null) + { + logger?.Error("AppIcons not found."); + return false; + } + + Transform lastIcon = parent.transform.childCount > 0 ? parent.transform.GetChild(parent.transform.childCount - 1) : null; + if (lastIcon == null) + { + logger?.Error("No icon found to clone."); + return false; + } + + GameObject iconObj = lastIcon.gameObject; + iconObj.name = AppName; + + Transform labelTransform = iconObj.transform.Find("Label"); + Text label = labelTransform?.GetComponent(); + if (label != null) label.text = labelText; + + return ChangeAppIconImage(iconObj, fileName, logger); + } + + /// + /// Loads the icon PNG from disk and applies it to the cloned icon. + /// + private bool ChangeAppIconImage(GameObject iconObj, string filename, MelonLogger.Instance logger) + { + Transform imageTransform = iconObj.transform.Find("Mask/Image"); + Image image = imageTransform?.GetComponent(); + if (image == null) + { + logger?.Error("Image component not found in icon."); + return false; + } + + string path = Path.Combine(MelonEnvironment.UserDataDirectory, filename); + if (!File.Exists(path)) + { + logger?.Error("Icon file not found: " + path); + return false; + } + + try + { + byte[] bytes = File.ReadAllBytes(path); + Texture2D tex = new Texture2D(2, 2); + + if (tex.LoadImage(bytes)) // IL2CPP-safe overload + { + image.sprite = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), new Vector2(0.5f, 0.5f)); + return true; + } + + Object.Destroy(tex); + } + catch (System.Exception e) + { + logger?.Error("Failed to load image: " + e.Message); + } + + return false; + } + } +} diff --git a/S1API/PhoneApp/PhoneAppManager.cs b/S1API/PhoneApp/PhoneAppManager.cs new file mode 100644 index 00000000..073fef9e --- /dev/null +++ b/S1API/PhoneApp/PhoneAppManager.cs @@ -0,0 +1,46 @@ +using System.Collections; +using System.Collections.Generic; +using MelonLoader; +using UnityEngine; + +namespace S1API.PhoneApp +{ + /// + /// Central manager for spawning phone apps. + /// Usage: PhoneAppManager.Register(new MyCustomApp()); + /// + public static class PhoneAppManager + { + private static readonly List registeredApps = new List(); + private static bool initialized = false; + + /// + /// Register your custom app. Should be called from OnApplicationStart(). + /// + public static void Register(PhoneApp app) + { + registeredApps.Add(app); + } + + /// + /// Call this once after the game scene is loaded. + /// Automatically initializes all registered apps. + /// + public static void InitAll(MelonLogger.Instance logger) + { + if (initialized) return; + initialized = true; + MelonCoroutines.Start(DelayedInitAll(logger)); + } + + private static IEnumerator DelayedInitAll(MelonLogger.Instance logger) + { + yield return new WaitForSeconds(5f); + + foreach (var app in registeredApps) + { + app.Init(logger); + } + } + } +} diff --git a/S1API/PhoneApp/UIFactory.cs b/S1API/PhoneApp/UIFactory.cs new file mode 100644 index 00000000..2e9a9d43 --- /dev/null +++ b/S1API/PhoneApp/UIFactory.cs @@ -0,0 +1,138 @@ +using UnityEngine; +using UnityEngine.UI; + +public static class UIFactory +{ + /// + /// Creates a background panel with optional anchors. + /// + public static GameObject Panel(string name, Transform parent, Color bgColor, Vector2? anchorMin = null, Vector2? anchorMax = null, bool fullAnchor = false) + { + GameObject go = new GameObject(name); + go.transform.SetParent(parent, false); + RectTransform rt = go.AddComponent(); + + if (fullAnchor) + { + rt.anchorMin = Vector2.zero; + rt.anchorMax = Vector2.one; + rt.offsetMin = Vector2.zero; + rt.offsetMax = Vector2.zero; + } + else + { + rt.anchorMin = anchorMin ?? Vector2.zero; + rt.anchorMax = anchorMax ?? Vector2.one; + rt.offsetMin = Vector2.zero; + rt.offsetMax = Vector2.zero; + } + + Image bg = go.AddComponent(); + bg.color = bgColor; + + return go; + } + + /// + /// Creates a UI text element. + /// + public static Text Text(string name, string content, Transform parent, int fontSize = 16, TextAnchor anchor = TextAnchor.UpperLeft, FontStyle style = FontStyle.Normal) + { + GameObject go = new GameObject(name); + go.transform.SetParent(parent, false); + + RectTransform rt = go.AddComponent(); + rt.sizeDelta = new Vector2(0f, 30f); + + Text txt = go.AddComponent(); + txt.text = content; + txt.font = Resources.GetBuiltinResource("Arial.ttf"); + txt.fontSize = fontSize; + txt.alignment = anchor; + txt.fontStyle = style; + txt.color = Color.white; + + return txt; + } + + /// + /// Creates a button with label and background color. + /// + public static GameObject Button(string name, string label, Transform parent, Color color) + { + GameObject buttonGO = new GameObject(name); + buttonGO.transform.SetParent(parent, false); + + RectTransform rt = buttonGO.AddComponent(); + rt.sizeDelta = new Vector2(0, 40); + + Image img = buttonGO.AddComponent(); + img.color = color; + + Button btn = buttonGO.AddComponent protected readonly QuestEntry[] QuestEntries = Array.Empty(); - [SaveableField("SOEQuest")] - private QuestData _questData = new QuestData(); + [SaveableField("QuestData")] + private readonly QuestData _questData; - internal string? SaveFolder => _s1Quest?.SaveFolderName; + internal string? SaveFolder => S1Quest.SaveFolderName; - internal S1Quests.Quest S1Quest => _s1Quest ?? throw new InvalidOperationException("S1Quest not initialized"); - private S1Quests.Quest? _s1Quest; - private GameObject? _gameObject; + internal readonly S1Quests.Quest S1Quest; + private readonly GameObject _gameObject; - internal override void InitializeInternal(GameObject gameObject, string guid = "") + /// + /// INTERNAL: Public constructor used for instancing the quest. + /// + public Quest() { - MelonLogger.Msg("Adding Quest Component..."); - _gameObject = gameObject; - _s1Quest = gameObject.AddComponent(); - S1Quest.StaticGUID = guid; + _questData = new QuestData(GetType().Name); + + _gameObject = new GameObject("Quest"); + S1Quest = _gameObject.AddComponent(); + S1Quest.StaticGUID = string.Empty; S1Quest.onActiveState = new UnityEvent(); S1Quest.onComplete = new UnityEvent(); S1Quest.onInitialComplete = new UnityEvent(); @@ -76,8 +81,6 @@ internal override void InitializeInternal(GameObject gameObject, string guid = " S1Quest.onTrackChange = new UnityEvent(); S1Quest.TrackOnBegin = true; S1Quest.AutoCompleteOnAllEntriesComplete = true; - // S1Quest.autoInitialize = false; - MelonLogger.Msg("Assigning auto init..."); #if (MONO) FieldInfo autoInitField = AccessTools.Field(typeof(S1Quests.Quest), "autoInitialize"); autoInitField.SetValue(S1Quest, false); @@ -85,19 +88,17 @@ internal override void InitializeInternal(GameObject gameObject, string guid = " S1Quest.autoInitialize = false; #endif - MelonLogger.Msg("Creating IconPrefab..."); // Setup quest icon prefab GameObject iconPrefabObject = new GameObject("IconPrefab", CrossType.Of(), CrossType.Of(), CrossType.Of() ); - iconPrefabObject.transform.SetParent(gameObject.transform); + iconPrefabObject.transform.SetParent(_gameObject.transform); Image iconImage = iconPrefabObject.GetComponent(); iconImage.sprite = S1Dev.PlayerSingleton.Instance.AppIcon; S1Quest.IconPrefab = iconPrefabObject.GetComponent(); - MelonLogger.Msg("Creating PoIUIPrefab..."); // Setup UI for POI prefab var uiPrefabObject = new GameObject("PoIUIPrefab", CrossType.Of(), @@ -105,9 +106,8 @@ internal override void InitializeInternal(GameObject gameObject, string guid = " CrossType.Of(), CrossType.Of