diff --git a/Abstracts/CustomCharacterModel.cs b/Abstracts/CustomCharacterModel.cs index 123ebea2..34cfd2d2 100644 --- a/Abstracts/CustomCharacterModel.cs +++ b/Abstracts/CustomCharacterModel.cs @@ -13,10 +13,17 @@ using BaseLib.Patches.UI; using BaseLib.Utils.NodeFactories; using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Models.Characters; using MegaCrit.Sts2.Core.Multiplayer.Game.Lobby; using MegaCrit.Sts2.Core.Nodes.RestSite; using MegaCrit.Sts2.Core.Nodes.Screens.CharacterSelect; +using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu; using MegaCrit.Sts2.Core.Nodes.Screens.Shops; +using MegaCrit.Sts2.Core.Saves; +using MegaCrit.Sts2.Core.Saves.Managers; +using MegaCrit.Sts2.Core.Saves.Runs; +using MegaCrit.Sts2.Core.Timeline; +using MegaCrit.Sts2.Core.Timeline.Epochs; namespace BaseLib.Abstracts; @@ -106,8 +113,42 @@ public CustomCharacterModel() public override float CastAnimDelay => 0.25f; protected override CharacterModel? UnlocksAfterRunAs => null; - - + + // These epochs are ordered here based on the numbers the base game uses 1->7 + // Renamed to better show what unlocks them. + + /// + /// This Epoch usually unlocks the character.

+ /// When overriden, automatically unlocks the Epoch based on . + /// If that is null, it unlocks after the vey first Ironclad run similar to The Silent + ///
+ public virtual EpochModel? UnlockEpoch => null; + /// + /// This Epoch usually unlocks cards. + /// + public virtual EpochModel? Act1Epoch => null; + /// + /// This Epoch usually unlocks relics. + /// + public virtual EpochModel? Act2Epoch => null; + /// + /// This Epoch usually unlocks potions. + /// + public virtual EpochModel? Act3Epoch => null; + /// + /// This Epoch usually unlocks cards. + /// + public virtual EpochModel? FifteenElitesEpoch => null; + /// + /// This Epoch usually unlocks relics. + /// + public virtual EpochModel? FifteenBossesEpoch => null; + /// + /// This Epoch usually unlocks cards. + /// + public virtual EpochModel? AscensionOneEpoch => null; + + /// /// Override to provide a custom NCreatureVisuals scene. /// If not overridden, an NCreatureVisuals will be generated from CustomVisualPath. @@ -208,6 +249,153 @@ public void RegisterSceneConversions() CustomEnergyCounterPath?.RegisterSceneForConversion(); } + + + + // The following two patch classes need access to "UnlocksAfterRunAs" + [HarmonyPatch] + private static class CharacterUnlockEpochLogic + { + // We check it every NMainMenu load because of profile switching, deletion, importing + // TODO: There might be a better place to hook this up to. -> Run once on game start and then only when profile changes + // It is a likely scenario that anyone installing mods already has a save file with epochs unlocked. + // So we check for that and unlock the character epoch immediately. + [HarmonyPatch(typeof(NMainMenu), nameof(NMainMenu._Ready))] + [HarmonyPrefix] + private static void CheckUnlockConditions() + { + foreach (var characterModel in CustomContentDictionary.CustomCharacters + .Where(c => c.UnlockEpoch is not null)) + { + if(SaveManager.Instance.IsEpochRevealed(characterModel.UnlockEpoch!.Id) + || SaveManager.Instance.Progress.IsEpochObtained(characterModel.UnlockEpoch!.Id)) continue; + + switch (characterModel.UnlocksAfterRunAs) + { + case null: + case Ironclad: + if(SaveManager.Instance.IsEpochRevealed()) + SaveManager.Instance.ObtainEpochOverride(characterModel.UnlockEpoch!.Id, EpochState.Obtained); + break; + case Silent: + if(SaveManager.Instance.IsEpochRevealed()) + SaveManager.Instance.ObtainEpochOverride(characterModel.UnlockEpoch!.Id, EpochState.Obtained); + break; + case Regent: + if(SaveManager.Instance.IsEpochRevealed()) + SaveManager.Instance.ObtainEpochOverride(characterModel.UnlockEpoch!.Id, EpochState.Obtained); + break; + case Necrobinder: + if(SaveManager.Instance.IsEpochRevealed()) + SaveManager.Instance.ObtainEpochOverride(characterModel.UnlockEpoch!.Id, EpochState.Obtained); + break; + case Defect: + if(SaveManager.Instance.IsEpochRevealed()) + SaveManager.Instance.ObtainEpochOverride(characterModel.UnlockEpoch!.Id, EpochState.Obtained); + break; + } + + if (characterModel.UnlocksAfterRunAs is not CustomCharacterModel unlocksAfterCustomCharacter) + continue; + if(unlocksAfterCustomCharacter.UnlockEpoch is not null + && SaveManager.Instance.IsEpochRevealed(unlocksAfterCustomCharacter.UnlockEpoch.Id)) + SaveManager.Instance.ObtainEpochOverride(characterModel.UnlockEpoch!.Id, EpochState.Obtained); + } + } + } + + [HarmonyPatch] + private static class EpochInsertions + { + [HarmonyPatch(typeof(NeowEpoch), nameof(NeowEpoch.QueueUnlocks))] + [HarmonyPrefix] + private static void QueueUnlocksInsert() + { + // Ironclad doesn't have an Ironclad1Epoch so we put them here too + foreach (var epochModel in CustomContentDictionary.CustomCharacters + .Where(c => c.UnlockEpoch is not null && c.UnlocksAfterRunAs is null or Ironclad) + .Select(c => c.UnlockEpoch)) + { + SaveManager.Instance.ObtainEpochOverride(epochModel!.Id, EpochState.ObtainedNoSlot); + } + } + + + // Does not support unlocking after custom characters + // in CustomCharacterModel because it needs to access "UnlocksAfterRunAs" + [HarmonyPatch] + private static class TimelineExpansionPatch + { + private static IEnumerable TargetMethods() + { + yield return AccessTools.Method(typeof(NeowEpoch), nameof(NeowEpoch.GetTimelineExpansion)); + yield return AccessTools.Method(typeof(Silent1Epoch), nameof(Silent1Epoch.GetTimelineExpansion)); + yield return AccessTools.Method(typeof(Regent1Epoch), nameof(Regent1Epoch.GetTimelineExpansion)); + yield return AccessTools.Method(typeof(Necrobinder1Epoch), nameof(Necrobinder1Epoch.GetTimelineExpansion)); + yield return AccessTools.Method(typeof(Defect1Epoch), nameof(Defect1Epoch.GetTimelineExpansion)); + } + + [HarmonyPostfix] + private static void Postfix(MethodBase __originalMethod, ref EpochModel[] __result) + { + var unlockSource = __originalMethod.DeclaringType switch + { + var t when t == typeof(NeowEpoch) => typeof(Ironclad), // Ironclad special case + var t when t == typeof(Silent1Epoch) => typeof(Silent), + var t when t == typeof(Regent1Epoch) => typeof(Regent), + var t when t == typeof(Necrobinder1Epoch) => typeof(Necrobinder), + var t when t == typeof(Defect1Epoch) => typeof(Defect), + _ => null + }; + + if (unlockSource is null) + { + BaseLibMain.Logger.Warn("BaseLib currently only supports base characters"); + return; + } + + __result = + [ + .. __result, + .. CustomContentDictionary.CustomCharacters + .Where(c => + c.UnlockEpoch is not null && + (unlockSource == typeof(Ironclad) + ? c.UnlocksAfterRunAs is null or Ironclad + : c.UnlocksAfterRunAs is not null + && c.UnlocksAfterRunAs == (CharacterModel)ModelDb.Get(unlockSource))) + .Select(c => c.UnlockEpoch!) + ]; + } + } + + [HarmonyPatch] + private static class PostRunUnlock + { + [HarmonyPatch(typeof(ProgressSaveManager), "PostRunUnlockCharacterEpochCheck")] + [HarmonyPrefix] + private static void UnlockCustomCharacterEpoch(ProgressSaveManager __instance, SerializablePlayer serializablePlayer, SerializableRun serializableRun) + { + var customCharacters = CustomContentDictionary.CustomCharacters + .Where(c => c.UnlockEpoch is not null) + .Where(c => c.UnlocksAfterRunAs is not null or Ironclad); + foreach (var customCharacter in customCharacters) + { + if (customCharacter.UnlocksAfterRunAs!.Id != serializablePlayer.CharacterId) continue; + var res = CustomEpochModel.CustomEpochPatches.TryObtainEpochPostRunMethod?.Invoke(__instance, + [customCharacter.UnlockEpoch, serializablePlayer, serializableRun]); + if(res is true) + BaseLibMain.Logger.Info($"Epoch {customCharacter.UnlockEpoch!.Id} obtained for playing a run as {serializablePlayer.CharacterId}."); + } + + + + } + + } + } + + } public readonly struct CustomEnergyCounter(Func pathFunc, Color outlineColor, Color burstColor) { diff --git a/Abstracts/CustomEpochEra.cs b/Abstracts/CustomEpochEra.cs new file mode 100644 index 00000000..16ced37a --- /dev/null +++ b/Abstracts/CustomEpochEra.cs @@ -0,0 +1,118 @@ +using System.Reflection; +using Godot; +using HarmonyLib; +using MegaCrit.Sts2.addons.mega_text; +using MegaCrit.Sts2.Core.Assets; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Localization; +using MegaCrit.Sts2.Core.Nodes.Screens.Timeline; +using MegaCrit.Sts2.Core.Timeline; + +namespace BaseLib.Abstracts; + +// Has no entry in CustomContentDictionary since "EpochEra" is actually just an Enum +// and this class exists only to hold all information needed to bypass a lot of +// base games assumptions on how to set eras up. +/// +/// Setup for a custom era column. Still requires a "[CustomEnum] EpochEra" for dictionary entry +/// +public abstract class CustomEpochEra +{ + /// + /// Points to a new [CustomEnum] EpochEra. + /// + public abstract EpochEra CustomEra { get; } + + /// + /// The base game Era used as a frame of reference for where to insert the custom era. + /// + public abstract EpochEra ReferenceEra { get; } + /// + /// The direction in which the custom era will be inserted. + /// + public abstract RelativeEraDirection Direction { get; } + + + // Duplicate numbers from different mods (or the same mod) won't cause any issues here. + // They will simply be placed next to each other in a random order. + // If a mod creator wants to make sure their eras are directly next to each other without + // eras from a different mod mixed in, just pick a random starting number. + // E.g. Don't use 0,1,2 but -300,-299,-298 or 3247822, 3247823, 3247824. + /// + /// Used for ordering multiple custom eras at the same Reference Era and . + /// Larger numbers are further away from the reference era. + /// + public virtual int DirectionDepth => 0; + + /// + /// The icon displayed on the timeline line at the bottom of the era. + /// + public abstract string EraIconPath { get; } + public Texture2D EraIconTexture => PreloadManager.Cache.GetTexture2D(EraIconPath); + /// + /// Stops the game from auto-sizing the era icon. + /// + public virtual bool UseOriginalImageSize => false; + /// + /// Stops the game from applying the default tint to the era icon. + /// + public virtual bool DisableTinting => false; + + + [HarmonyPatch] + private static class Patches + { + private static readonly FieldInfo? NEraColumnIconField = AccessTools.Field(typeof(NEraColumn), "_icon"); + private static readonly FieldInfo? NEraColumnNameField = AccessTools.Field(typeof(NEraColumn), "_name"); + private static readonly FieldInfo? NEraColumnYearField = AccessTools.Field(typeof(NEraColumn), "_year"); + [HarmonyPostfix] + [HarmonyPatch(typeof(NEraColumn), nameof(NEraColumn.Init))] + private static void ReplaceIconForCustomEraColumn(NEraColumn __instance, EpochSlotData epochSlot) + { + // We assume in a custom Era can only be CustomEpochs + if (epochSlot.Model is not CustomEpochModel customEpochModel) return; + if (customEpochModel.CustomEra is null) return; + if (NEraColumnIconField?.GetValue(__instance) is not TextureRect textureRect) return; + if (NEraColumnNameField?.GetValue(__instance) is not MegaLabel name) return; + if (NEraColumnYearField?.GetValue(__instance) is not MegaLabel year) return; + + if (textureRect.Texture is null) + textureRect.Visible = true; // set to false at some point if the original method cant get a proper texture + textureRect.Texture = customEpochModel.CustomEra.EraIconTexture; + if (customEpochModel.CustomEra.UseOriginalImageSize) + { + var originalSize = customEpochModel.CustomEra.EraIconTexture.GetSize(); + textureRect.Size = originalSize; + textureRect.Position -= (originalSize / 2) - new Vector2(24, 24); + // large textures would begin overlapping the bottom epochs + textureRect.MouseFilter = Control.MouseFilterEnum.Ignore; + } + if (customEpochModel.CustomEra.DisableTinting) + { + textureRect.Modulate = new Color("ffffff"); + } + var slugifiedName = StringHelper.Slugify(customEpochModel.CustomEra.GetType().Name); + name.SetTextAutoSize(new LocString("eras", slugifiedName + ".name").GetFormattedText()); + year.SetTextAutoSize(new LocString("eras", slugifiedName + ".year").GetFormattedText()); + } + } +} + +/// +/// The direction in which a Custom Era will be inserted, in reference to its Reference Era +/// +public enum RelativeEraDirection +{ + /// + /// Shouldn't be used. + /// + None, + /// + /// Insert to the left of reference Era. + /// + Before, + /// + /// Insert to the right of reference Era. + /// + After +} \ No newline at end of file diff --git a/Abstracts/CustomEpochModel.cs b/Abstracts/CustomEpochModel.cs new file mode 100644 index 00000000..e14d0b81 --- /dev/null +++ b/Abstracts/CustomEpochModel.cs @@ -0,0 +1,329 @@ +using System.Reflection; +using System.Reflection.Emit; +using BaseLib.Extensions; +using BaseLib.Patches; +using BaseLib.Patches.Content; +using BaseLib.Utils; +using HarmonyLib; +using MegaCrit.Sts2.Core.Entities.Players; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Models; +using MegaCrit.Sts2.Core.Models.Characters; +using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu; +using MegaCrit.Sts2.Core.Nodes.Screens.Timeline; +using MegaCrit.Sts2.Core.Saves; +using MegaCrit.Sts2.Core.Saves.Managers; +using MegaCrit.Sts2.Core.Saves.Runs; +using MegaCrit.Sts2.Core.Timeline; +using MegaCrit.Sts2.Core.Timeline.Epochs; +using MegaCrit.Sts2.Core.Unlocks; + +namespace BaseLib.Abstracts; + +/// +/// Despite the name these are not stored in +/// +public abstract class CustomEpochModel : EpochModel, ICustomModel +{ + + // TODO: Prefixing Mod Id. + // No ModelDb entry so the ModelDb.GetEntry prefix patch won't work + /// + /// If you override this, add your mod prefix to it! + /// + public override string Id => $"{GetType().GetPrefix()}{StringHelper.Slugify(GetType().Name).ToUpperInvariant()}"; + + /// + /// The large image displayed when clicking the Epoch. + /// + public abstract string CustomRealPortraitPath { get; } // png + + /// + /// The small image displayed on the Timeline. + /// + public abstract string CustomPackedPortraitPath { get; } // tres + + /// + /// Override only if this Epoch is part of a Custom Era. + /// + public virtual CustomEpochEra? CustomEra => null; + + /// + /// Set to true if your epoch unlocks cards so it can be added to + /// + protected virtual bool UnlocksCards => false; + + [HarmonyPatch] + private static class PropertyRedirections + { + [HarmonyPatch(typeof(EpochModel), "RealPortraitPath", MethodType.Getter)] + [HarmonyPrefix] + private static bool CustomEpochRealPortraitPath(EpochModel __instance, ref string? __result) + { + if (__instance is not CustomEpochModel customEpoch) return true; + __result = customEpoch.CustomRealPortraitPath; + return false; + } + + [HarmonyPatch(typeof(EpochModel), "PackedPortraitPath", MethodType.Getter)] + [HarmonyPrefix] + private static bool CustomEpochPackedPortraitPath(EpochModel __instance, ref string? __result) + { + if (__instance is not CustomEpochModel customEpoch) return true; + __result = customEpoch.CustomPackedPortraitPath; + return false; + } + } + + /// + /// Should only be called once from + /// + internal static void FillEpochDictionaries(List models) + { + BaseLibMain.Logger.Info("Inserting CustomEpochs into dictionaries"); + var epochModelType = typeof(EpochModel); + var epochTypeDictionary = (AccessTools.Field(epochModelType, "_epochTypeDictionary").GetValue(null) as Dictionary)!; + var typeToIdDictionary = (AccessTools.Field(epochModelType, "_typeToIdDictionary").GetValue(null) as Dictionary)!; + foreach (var customEpochModel in models) + { + CustomEpochHandler.InsertIntoAllEpochs(customEpochModel); + var type = customEpochModel.GetType(); + BaseLibMain.Logger.Debug($"CustomEpoch Type: {type.Name} | Id: {customEpochModel.Id} "); + CustomContentDictionary.AddEpoch(customEpochModel); + epochTypeDictionary[customEpochModel.Id] = type; + typeToIdDictionary[type] = customEpochModel.Id; + } + } + + + [HarmonyPatch] + public static class CustomEpochPatches + { + // We manipulate EraPositions only in the live Timeline, not anywhere where it would be permanent. + [HarmonyPatch] + public static class DuplicateEraPositionHandler + { + private static readonly SpireField OverwrittenEraPositions = new(() => true); + + [HarmonyTranspiler] + [HarmonyPatch(typeof(NEraColumn), nameof(NEraColumn.AddSlot))] + private static List AdjustNEpochSlotEraPosition(IEnumerable instructions) + { + var enforceUniqueEraPosition = typeof(DuplicateEraPositionHandler).Method(nameof(EnforceUniqueEraPosition)); + var matcher = new CodeMatcher(instructions) + .MatchStartForward([ + new CodeMatch(OpCodes.Stloc_0), + ]) + .ThrowIfInvalid("Could not find position to insert EraPosition duplicate fix") + .InsertAfter([ + new CodeInstruction(OpCodes.Ldarg_0), + new CodeInstruction(OpCodes.Ldloc_0), + new CodeInstruction(OpCodes.Call, enforceUniqueEraPosition), + ]); + return matcher.InstructionEnumeration().ToList(); + } + + private static void EnforceUniqueEraPosition(NEraColumn nEraColumn, NEpochSlot nEpochSlot) + { + var allCurrentNEpochSlots = nEraColumn.GetChildren().OfType().ToList(); + var oldEraPosition = nEpochSlot.eraPosition; + while (allCurrentNEpochSlots.Any(o => o.eraPosition == nEpochSlot.eraPosition)) + nEpochSlot.eraPosition++; + if (oldEraPosition == nEpochSlot.eraPosition) return; + OverwrittenEraPositions.Set(nEpochSlot, true); + BaseLibMain.Logger.Info($"Moved EpochSlot position for Epoch {nEpochSlot.model.Id} from {oldEraPosition} to {nEpochSlot.eraPosition}"); + } + + + // The game checks for a match with the position, however we manipulated (some of) them above. + // Replaces the ".position == .position" check with an ".Id == .Id" check. + [HarmonyPatch(typeof(NTimelineScreen), nameof(NTimelineScreen.InitScreen), MethodType.Async)] + [HarmonyTranspiler] + private static List ResolveSetStateForNewEraPositions(IEnumerable instructions) + { + var getActualEraPosition = typeof(DuplicateEraPositionHandler).Method(nameof(GetActualEraPosition)); + var matcher = new CodeMatcher(instructions) + .MatchStartForward([ + new CodeMatch(OpCodes.Isinst), + ]) + .ThrowIfInvalid("Could not find correct position to begin replacing EraPosition comparision check") + .Advance(2); + var nEpochSlot = matcher.Instruction.operand; + matcher.Advance(1); + var target = (Label)matcher.Instruction.operand; + matcher.Advance(1) + .RemoveInstructions(2); + var epochModel = matcher.Instruction.operand; + matcher.RemoveInstructions(3) + .Insert([ + new CodeInstruction(OpCodes.Ldloc, nEpochSlot), + new CodeInstruction(OpCodes.Ldloc, epochModel), + new CodeInstruction(OpCodes.Call, getActualEraPosition), + new CodeInstruction(OpCodes.Brfalse_S, target), + ]); + return matcher.InstructionEnumeration().ToList(); + } + private static bool GetActualEraPosition(NEpochSlot nEpochSlot, EpochModel epochModel) + { + return nEpochSlot.model.Id == epochModel.Id; + } + } + + + [HarmonyPatch(typeof(SaveManager), "GetCardUnlockEpochIds")] + [HarmonyPostfix] + private static void InsertCardUnlockCustomEpochs(ref string[] __result) + { + __result = [.. __result, .. CustomContentDictionary.CustomEpochs.Where(e => e.UnlocksCards).Select(e => e.Id)]; + } + + [HarmonyPatch(typeof(UnlockState), nameof(UnlockState.Characters), MethodType.Getter)] + [HarmonyPostfix] + private static void LockCharactersWithUnlockEpoch(UnlockState __instance, ref IEnumerable __result) + { + var unlockedEpochIds = (AccessTools.Field(typeof(UnlockState), "_unlockedEpochIds").GetValue(__instance) as HashSet)!; + var characterModels = __result as CharacterModel[] ?? __result.ToArray(); + __result = characterModels.Except(characterModels.OfType() + .Where(c => c.UnlockEpoch is not null && !unlockedEpochIds.Contains(c.UnlockEpoch.Id))); + } + + + private static readonly MethodInfo? TryObtainEpochMidRunMethod = typeof(ProgressSaveManager) + .GetMethod("TryObtainEpochMidRun", BindingFlags.Instance | BindingFlags.NonPublic); + // used once in CustomCharacterModel + internal static readonly MethodInfo? TryObtainEpochPostRunMethod = typeof(ProgressSaveManager) + .GetMethod("TryObtainEpochPostRun", BindingFlags.Instance | BindingFlags.NonPublic); + private static readonly MethodInfo? GetEliteEncountersMethod = typeof(ProgressSaveManager) + .GetMethod("GetEliteEncounters", BindingFlags.Static | BindingFlags.NonPublic); + + + [HarmonyPatch] + public static class CharacterEpochsUnlockPatches + { + // CharUnlock is actually the beat Act1-3 epoch unlocks, not unlocking a character + // The UnlockCharacter Epoch is patched in CustomCharacterModel as it requires access to a private property. + [HarmonyPatch(typeof(ProgressSaveManager), "ObtainCharUnlockEpoch")] + [HarmonyPrefix] + [HarmonyPriority(Priority.Last)] + private static bool SkipCharUnlockEpochIfUnsupported(ProgressSaveManager __instance, Player localPlayer, int act) + { + if (localPlayer.Character is not CustomCharacterModel ccm) + return true; + switch (act) + { + case 0: + if (ccm.Act1Epoch is not null) + TryObtainEpochMidRunMethod?.Invoke(__instance, [ccm.Act1Epoch, localPlayer]); + break; + case 1: + if (ccm.Act2Epoch is not null) + TryObtainEpochMidRunMethod?.Invoke(__instance, [ccm.Act2Epoch, localPlayer]); + break; + case 2: + if (ccm.Act3Epoch is not null) + TryObtainEpochMidRunMethod?.Invoke(__instance, [ccm.Act3Epoch, localPlayer]); + break; + case 3: + BaseLibMain.Logger.Warn("BaseLib does not support Act 4 yet."); + break; + default: + BaseLibMain.Logger.Warn($"Unsupported Act: {act}"); + break; + } + + return false; + } + + + // This and the two following patches currently copy the entire methods logic into the prefix. + // In the case of 15 bosses and elites it might be better to just transpile at the "throw" to + // check for a custom character with an epoch. Since these two methods store the epoch object + // in a variable, unlike the Act1-3 check which runs a switch on the act number and then builds + // the Id using strings. + + [HarmonyPatch(typeof(ProgressSaveManager), "CheckFifteenBossesDefeatedEpoch")] + [HarmonyPrefix] + [HarmonyPriority(Priority.Last)] + private static bool SkipBossEpochIfUnsupported(ProgressSaveManager __instance, Player localPlayer) + { + if (localPlayer.Character is not CustomCharacterModel customCharacterModel) + return true; + if (!localPlayer.Character.IsPlayable || customCharacterModel.FifteenBossesEpoch is null) + return false; + + var bossEncounters = ModelDb.Acts.SelectMany(a => a.AllBossEncounters.Select(e => e.Id)).ToHashSet(); + + var num = 0; + foreach (var encounterStats in __instance.Progress.EncounterStats.Values) + { + if (!bossEncounters.Contains(encounterStats.Id)) continue; + foreach (var fightStat in encounterStats.FightStats.Where(fightStat => fightStat.Character == customCharacterModel.Id)) + { + num += fightStat.Wins; + break; + } + } + + if (num < 15) + return false; + var res = TryObtainEpochMidRunMethod?.Invoke(__instance, [customCharacterModel.FifteenBossesEpoch, localPlayer]); + if (res is true) + BaseLibMain.Logger.Info($"Epoch obtained for beating 15 Bosses"); + return false; + } + + [HarmonyPatch(typeof(ProgressSaveManager), "CheckFifteenElitesDefeatedEpoch")] + [HarmonyPrefix] + [HarmonyPriority(Priority.Last)] + private static bool SkipEliteEpochIfUnsupported(ProgressSaveManager __instance, Player localPlayer) + { + if (localPlayer.Character is not CustomCharacterModel customCharacterModel) + return true; + if (!localPlayer.Character.IsPlayable || customCharacterModel.FifteenElitesEpoch is null) + return false; + + var eliteEncountersObject = GetEliteEncountersMethod?.Invoke(null, []) ?? throw new NullReferenceException(); + if (eliteEncountersObject is not HashSet eliteEncounters) return false; + + var num = 0; + foreach (var encounterStats in __instance.Progress.EncounterStats.Values) + { + if (!eliteEncounters.Contains(encounterStats.Id)) continue; + foreach (var fightStat in encounterStats.FightStats.Where(fightStat => fightStat.Character == customCharacterModel.Id)) + { + num += fightStat.Wins; + break; + } + } + + if (num < 15) + return false; + var res = TryObtainEpochMidRunMethod?.Invoke(__instance, [customCharacterModel.FifteenElitesEpoch, localPlayer]); + if (res is true) + BaseLibMain.Logger.Info($"Epoch obtained for beating 15 Elites"); + return false; + } + + [HarmonyPatch(typeof(ProgressSaveManager), "CheckAscensionOneCompleted")] + [HarmonyPrefix] + [HarmonyPriority(Priority.Last)] + private static bool SkipAscensionOneEpochIfUnsupported(ProgressSaveManager __instance, SerializablePlayer serializablePlayer, SerializableRun serializableRun) + { + var characterModel = ModelDb.GetById(serializablePlayer.CharacterId!); + if (characterModel is not CustomCharacterModel customCharacterModel) + return true; + if (serializableRun.Ascension != 1 || customCharacterModel.AscensionOneEpoch is null) + return false; + var res = TryObtainEpochPostRunMethod?.Invoke(__instance, [customCharacterModel.AscensionOneEpoch, serializablePlayer, serializableRun]); + if (res is true) + BaseLibMain.Logger.Info($"Epoch obtained for beating Ascension 1"); + return false; + } + + } + + + + + } +} \ No newline at end of file diff --git a/Abstracts/CustomStoryModel.cs b/Abstracts/CustomStoryModel.cs new file mode 100644 index 00000000..9accd1d5 --- /dev/null +++ b/Abstracts/CustomStoryModel.cs @@ -0,0 +1,44 @@ +using BaseLib.Extensions; +using BaseLib.Patches; +using BaseLib.Patches.Content; +using HarmonyLib; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Models; +using MegaCrit.Sts2.Core.Timeline; + +namespace BaseLib.Abstracts; + + + +/// +/// Despite the name these are not stored in +/// +public abstract class CustomStoryModel : StoryModel, ICustomModel +{ + + // TODO: Prefixing Mod Id. + // Id gets slugified in a place where we can't easily add the prefix after like with ModelDb.GetEntry + // Id not uppercased/Slugified because game will Slugify it later! + /// + /// Must match 1 to 1 with where used.
+ /// This currently does not automatically add the Mod Prefix! + ///
+ protected override string Id => $"{GetType().GetPrefix()}{GetType().Name}"; + + /// + /// Should only be called once from + /// + internal static void FillStoryDictionaries(List models) + { + BaseLibMain.Logger.Info("Inserting CustomStories into dictionaries"); + var storyTypeDictionary = (AccessTools.Field(typeof(StoryModel), "_storyTypeDictionary").GetValue(null) as Dictionary)!; + foreach (var customStoryModel in models) + { + var type = customStoryModel.GetType(); + BaseLibMain.Logger.Debug($"CustomStory Type: {type.Name} | Id: {customStoryModel.Id} | Saved in dict as: {StringHelper.Slugify(customStoryModel.Id)}"); + CustomContentDictionary.AddStory(customStoryModel); + // slugify it to match what the game does for lookup even if it currently removes the prefix '-' + storyTypeDictionary[StringHelper.Slugify(customStoryModel.Id)] = type; + } + } +} \ No newline at end of file diff --git a/Patches/Compatibility/UnknownCharacterPatches.cs b/Patches/Compatibility/UnknownCharacterPatches.cs index 57a613ce..fbe94395 100644 --- a/Patches/Compatibility/UnknownCharacterPatches.cs +++ b/Patches/Compatibility/UnknownCharacterPatches.cs @@ -63,34 +63,6 @@ private static void SkipUnknownCharacter(SaveManager __instance, ref bool __resu } } - //ProgressSaveManager - //TODO - allow custom epochs? For now there's a lot of things to add to support that. - [HarmonyPatch(typeof(ProgressSaveManager), "ObtainCharUnlockEpoch")] - private class SkipCharUnlockEpoch - { - [HarmonyPrefix] - private static bool SkipIfUnsupported(Player localPlayer) - { - return localPlayer.Character is not ICustomModel; - } - } - [HarmonyPatch(typeof(ProgressSaveManager), "CheckFifteenBossesDefeatedEpoch")] - private class SkipBossEpochCheck - { - [HarmonyPrefix] - private static bool SkipIfUnsupported(Player localPlayer) - { - return localPlayer.Character is not ICustomModel; - } - } - [HarmonyPatch(typeof(ProgressSaveManager), "CheckFifteenElitesDefeatedEpoch")] - private class SkipEliteEpochCheck - { - [HarmonyPrefix] - private static bool SkipIfUnsupported(Player localPlayer) - { - return localPlayer.Character is not ICustomModel; - } - } + } \ No newline at end of file diff --git a/Patches/Content/ContentPatches.cs b/Patches/Content/ContentPatches.cs index 8dc8c0a5..3c7378ff 100644 --- a/Patches/Content/ContentPatches.cs +++ b/Patches/Content/ContentPatches.cs @@ -15,6 +15,7 @@ using MegaCrit.Sts2.Core.Rooms; using MegaCrit.Sts2.Core.Runs; using MegaCrit.Sts2.Core.Saves; +using MegaCrit.Sts2.Core.Timeline; using MegaCrit.Sts2.Core.Timeline.Epochs; using MegaCrit.Sts2.Core.Unlocks; @@ -39,6 +40,8 @@ public static class CustomContentDictionary ///
public static readonly List SharedCustomEvents = []; public static readonly List CustomActs = []; + public static readonly List CustomEpochs = []; + public static readonly List CustomStories = []; static CustomContentDictionary() { @@ -123,6 +126,19 @@ public static void AddCharacter(CustomCharacterModel character) } } + public static void AddEpoch(CustomEpochModel epochModel) + { + if (!RegisterType(epochModel.GetType())) return; + CustomEpochs.Add(epochModel); + } + + public static void AddStory(CustomStoryModel storyModel) + { + if (!RegisterType(storyModel.GetType())) return; + + CustomStories.Add(storyModel); + } + private static bool IsValidPool(Type modelType, Type poolType) { var basePoolType = poolType.BaseType; @@ -408,3 +424,26 @@ static IEnumerable AddCustomEvents(IEnumerable result, A } } } + +[HarmonyPatch] +public class EpochModelCustomEpochsPatch +{ + [HarmonyPatch(typeof(EpochModel), nameof(EpochModel.AllEpochIds), MethodType.Getter)] + [HarmonyPostfix] + private static void InsertEpochIds(ref IReadOnlyList __result) + { + __result = [.. __result, .. CustomContentDictionary.CustomEpochs.Select(x => x.Id)]; + } + + // Not needed anymore. + // The reason for this is that when 'ModelIdSerializationCache' Initializes, this harmony patch isn't + // running yet. + // So instead we insert them during PostModInit directly into the _allEpochs list. + + // [HarmonyPatch(typeof(EpochModel), nameof(EpochModel.AllEpochs), MethodType.Getter)] + // [HarmonyPostfix] + // private static void InsertEpochTypes(ref IReadOnlyList __result) + // { + // __result = [.. __result, .. CustomContentDictionary.CustomEpochs.Select(x => x.GetType())]; + // } +} \ No newline at end of file diff --git a/Patches/Content/CustomEpochEraPatches.cs b/Patches/Content/CustomEpochEraPatches.cs new file mode 100644 index 00000000..46766da4 --- /dev/null +++ b/Patches/Content/CustomEpochEraPatches.cs @@ -0,0 +1,351 @@ +using System.Reflection; +using System.Reflection.Emit; +using BaseLib.Abstracts; +using Godot; +using HarmonyLib; +using MegaCrit.Sts2.addons.mega_text; +using MegaCrit.Sts2.Core.Extensions; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Localization; +using MegaCrit.Sts2.Core.Nodes.Screens.Timeline; +using MegaCrit.Sts2.Core.Timeline; + +namespace BaseLib.Patches.Content; + +/// +/// Massive patch to handle insertion of entirely new Eras and their respective Epochs +/// +[HarmonyPatch] +public class AddCustomEpochSlots +{ + /// + /// Our custom Epochs that belong to a custom Era.
+ /// Removed from in a Prefix and later inserted via Transpiler. + ///
+ private static List _customEpochSlotData = []; + /// + /// If too many Epochs are in one Era (more than 5) the epochSlotsContainer must be offset + /// to properly align the Timeline line at the bottom. + /// + private static Vector2 _epochSlotsContainerPositionOffset = new(0, 0); + + private static readonly FieldInfo? UniqueEpochErasField = AccessTools.Field(typeof(NTimelineScreen), "_uniqueEpochEras"); + private static readonly FieldInfo? EpochSlotContainerField = AccessTools.Field(typeof(NTimelineScreen), "_epochSlotContainer"); + private static readonly FieldInfo? SlotsContainerField = AccessTools.Field(typeof(NTimelineScreen), "_slotsContainer"); + + + [HarmonyPrefix] + [HarmonyPatch(typeof(NTimelineScreen), nameof(NTimelineScreen.AddEpochSlots))] + private static void RemoveSelectCustomEpochsFromDefaultLogic(NTimelineScreen __instance, ref List slotsToAdd) + { + _customEpochSlotData = []; + var allSlots = slotsToAdd; + slotsToAdd = allSlots.Where(s => s.Model is not CustomEpochModel model || model.CustomEra is null).ToList(); + _customEpochSlotData = allSlots.Where(s => s.Model is CustomEpochModel { CustomEra: not null }).ToList(); + //_customEpochSlotData = allSlots.Except(slotsToAdd).ToList(); + } + + [HarmonyTranspiler] + [HarmonyPatch(typeof(NTimelineScreen), nameof(NTimelineScreen.AddEpochSlots), MethodType.Async)] + private static List InsertCustomEraColumnsAndEpochs(IEnumerable instructions, MethodBase originalMethod) + { + var customEpochEraHandler = typeof(AddCustomEpochSlots).Method(nameof(CustomEpochEraHandler)); + var thisField = AccessTools.GetDeclaredFields(originalMethod.DeclaringType).First(f => f.FieldType == typeof(NTimelineScreen)); + + var matcher = new CodeMatcher(instructions) + .MatchStartForward([ + new CodeMatch(OpCodes.Ldloca_S), + new CodeMatch(OpCodes.Ldstr, " Created "), + ]) + .ThrowIfInvalid("Could not find position to insert custom epoch era Handler") + .Insert([ + new CodeInstruction(OpCodes.Ldarg_0), + new CodeInstruction(OpCodes.Ldfld, thisField), + new CodeInstruction(OpCodes.Ldloc_2), + new CodeInstruction(OpCodes.Call, customEpochEraHandler), + ]); + return matcher.InstructionEnumeration().ToList(); + } + + private static void CustomEpochEraHandler(NTimelineScreen instance, List newlyCreatedColumns) + { + BaseLibMain.Logger.VeryDebug($"Called {nameof(CustomEpochEraHandler)}"); + + if (UniqueEpochErasField is null || EpochSlotContainerField is null) return; + var uniqueEpochErasObject = UniqueEpochErasField.GetValue(instance); + var epochSlotContainerObject = EpochSlotContainerField.GetValue(instance); + if (uniqueEpochErasObject is not Dictionary uniqueEpochEras) return; + if (epochSlotContainerObject is not HBoxContainer epochSlotContainer) return; + + var customEraColumnData = SortSlotsIntoCustomEraColumns(); + + var originalNEraColumns = epochSlotContainer.GetChildren().OfType().ToList(); + var ourCustomColumns = new Dictionary(); + + foreach (var columnData in customEraColumnData) + { + var customEpochEra = columnData.CustomEpochEra.CustomEra; + // for every slot inside that column + for (var epochSlotDataIndex = 0; epochSlotDataIndex < columnData.EpochSlotData.Count; epochSlotDataIndex++) + { + var customEpochModel = columnData.CustomEpochs[epochSlotDataIndex]; // data and model count should always be equal + if (customEpochModel.CustomEra is null) continue; // Should never be true + + if (uniqueEpochEras.TryGetValue(customEpochEra, out var nEraColumn)) + { + nEraColumn.AddSlot(columnData.EpochSlotData[epochSlotDataIndex]); + } + else + { + var newNEraColumn = NEraColumn.Create(columnData.EpochSlotData[epochSlotDataIndex]); + ourCustomColumns.Add(columnData.CustomEpochEra.CustomEra, columnData.CustomEpochEra); + + // children are being modified so grab it again every time + var nEraColumns = epochSlotContainer.GetChildren().OfType().ToList(); + + var validEraEntryPoint = FindValidCustomEraEntryPoint(nEraColumns, customEpochModel, columnData, originalNEraColumns); + BaseLibMain.Logger.Debug($"ValidEraEntryPoint: Direction={validEraEntryPoint.RelativeInsertDirection} | Index={validEraEntryPoint.NEraColumnIndex} | Depth={validEraEntryPoint.PositionDepth}"); + + // Insert custom era + var searchBefore = validEraEntryPoint.RelativeInsertDirection == RelativeEraDirection.Before; + var curDepth = searchBefore ? -1 : 1; + var curIndex = validEraEntryPoint.NEraColumnIndex + curDepth; + while (curIndex >= 0 && curIndex < nEraColumns.Count) + { + if (originalNEraColumns.Contains(nEraColumns[curIndex])) + { + InsertNewEraColumn(epochSlotContainer, newlyCreatedColumns, uniqueEpochEras, customEpochEra, newNEraColumn, searchBefore ? curIndex + 1 : curIndex); + break; + } + + if (ourCustomColumns.TryGetValue(nEraColumns[curIndex].era, out var era)) + { + if (era.DirectionDepth > validEraEntryPoint.PositionDepth) + { + InsertNewEraColumn(epochSlotContainer, newlyCreatedColumns, uniqueEpochEras, customEpochEra, newNEraColumn, searchBefore ? curIndex + 1 : curIndex); + break; + } + } + + curDepth += Math.Sign(curDepth); + curIndex = validEraEntryPoint.NEraColumnIndex + curDepth; + } + + if (curIndex == -1) // insert at the very left + InsertNewEraColumn(epochSlotContainer, newlyCreatedColumns, uniqueEpochEras, customEpochEra, newNEraColumn, 0); + if (curIndex == nEraColumns.Count) // insert at the very right + InsertNewEraColumn(epochSlotContainer, newlyCreatedColumns, uniqueEpochEras, customEpochEra, newNEraColumn, nEraColumns.Count); + } + } + } + + // I originally wanted to do this by referencing the state before and after inserting the custom epochs, + // but the container has not yet updated its size, so instead we do it manually + var largestEpochCount = epochSlotContainer.GetChildren().OfType().ToList().Select(nEraColumn => nEraColumn.GetChildCount() - 1).Prepend(0).Max(); + var overCount = largestEpochCount - 5; + if (overCount <= 0) return; + const float epochSize = -24f; + const float spacing = -32f; + _epochSlotsContainerPositionOffset = new Vector2(0, overCount * epochSize + (overCount - 1) * spacing); + epochSlotContainer.Position += _epochSlotsContainerPositionOffset; + } + + /// + /// Finds a new entry point that keeps the expected position of the custom era in the Timeline the same.
+ /// Eras are only added to the Timeline if at least one Epoch from it is visible. + /// This means our reference Era might not exist.
+ /// During Timeline unlocks custom eras might misalign when new base eras get added. This is fixed automatically when + /// exiting and re-entering the Timeline. -> Not worth fixing. + ///
+ /// Tuple of relevant information to place custom era in Timeline + private static (RelativeEraDirection RelativeInsertDirection, int PositionDepth, int NEraColumnIndex) + FindValidCustomEraEntryPoint(List nEraColumns, CustomEpochModel customEpochModel, + CustomEraColumnData columnData, List originalNEraColumns) + { + var relativeInsertDirection = columnData.CustomEpochEra.Direction; + var positionDepth = customEpochModel.CustomEra!.DirectionDepth; + var referenceEpochEra = columnData.CustomEpochEra.ReferenceEra; + + var nEraColumnIndex = nEraColumns.FirstIndex(e => e.era == referenceEpochEra); + if (nEraColumnIndex != -1) return (relativeInsertDirection, positionDepth, nEraColumnIndex); + + BaseLibMain.Logger.Info($"The required era has not been unlocked yet. Finding alternative entry point."); + for (var i = 0; i < originalNEraColumns.Count; i++) + { + var reverse = customEpochModel.CustomEra.Direction != RelativeEraDirection.Before; + var index = reverse ? originalNEraColumns.Count - i - 1 : 1; + + if ((!reverse && originalNEraColumns[index].era < referenceEpochEra) || + (reverse && originalNEraColumns[index].era > referenceEpochEra)) continue; + + nEraColumnIndex = nEraColumns.IndexOf(originalNEraColumns[index]); + break; + } + + if (nEraColumnIndex != -1) return (relativeInsertDirection, positionDepth, nEraColumnIndex); + // We reached the last era and still nothing. Insert at the end with reversed direction and depth + nEraColumnIndex = nEraColumns.IndexOf((columnData.CustomEpochEra.Direction == RelativeEraDirection.Before) ? originalNEraColumns.Last() : originalNEraColumns.First()); + relativeInsertDirection = relativeInsertDirection == RelativeEraDirection.Before ? RelativeEraDirection.After : RelativeEraDirection.Before; + positionDepth *= -1; + return (relativeInsertDirection, positionDepth, nEraColumnIndex); + } + + private static void InsertNewEraColumn(HBoxContainer epochSlotContainer, List newlyCreatedColumns, + Dictionary uniqueEpochEras, EpochEra epochEra, NEraColumn newNEraColumn, int index) + { + epochSlotContainer.AddChildSafely(newNEraColumn); + epochSlotContainer.MoveChildSafely(newNEraColumn, index); + newlyCreatedColumns.Add(newNEraColumn); + uniqueEpochEras.Add(epochEra, newNEraColumn); + } + + + private static List SortSlotsIntoCustomEraColumns() + { + List columns = []; + foreach (var epochSlotData in _customEpochSlotData) + { + if (epochSlotData.Model is not CustomEpochModel customEpochModel) continue; + if (customEpochModel.CustomEra is null) continue; + + if (columns.Any(c => c.CustomEpochEra == customEpochModel.CustomEra)) + { + var columnData = columns.First(c => c.CustomEpochEra == customEpochModel.CustomEra); + columnData.EpochSlotData.Add(epochSlotData); + columnData.CustomEpochs.Add(customEpochModel); + } + else + { + var newColumnData = new CustomEraColumnData(customEpochModel.CustomEra); + newColumnData.EpochSlotData.Add(epochSlotData); + newColumnData.CustomEpochs.Add(customEpochModel); + columns.Add(newColumnData); + } + } + + return columns; + } + + private struct CustomEraColumnData(CustomEpochEra customEpochEra) + { + public CustomEpochEra CustomEpochEra { get; init; } = customEpochEra; + public List EpochSlotData = []; + public List CustomEpochs = []; // Save Epochs separate from Data so we don't have to re-cast to the custom Model. + } + + + [HarmonyPatch] + public static class NTimelineScreenVerticalDragging + { + private static bool _allowHorizontalDrag = false; + private static bool _allowVerticalDrag = false; + + [HarmonyPostfix] + [HarmonyPatch(typeof(NTimelineScreen), nameof(NTimelineScreen.SetScreenDraggability))] + private static void ShouldScreenBeDraggableBecauseOfColumnHeight(NTimelineScreen __instance) + { + var slotsContainerFieldObject = SlotsContainerField?.GetValue(__instance); + if (slotsContainerFieldObject is not NSlotsContainer nSlotsContainer) return; + var epochSlotContainerObject = EpochSlotContainerField?.GetValue(__instance); + if (epochSlotContainerObject is not HBoxContainer epochSlotContainer) return; + + _allowHorizontalDrag = nSlotsContainer.MouseFilter == Control.MouseFilterEnum.Stop; + _allowVerticalDrag = false; + if (epochSlotContainer.GetChildren().OfType().ToList().Select(nEraColumn => nEraColumn.GetChildCount() - 1).Prepend(0).Max() <= 5) return; + nSlotsContainer.MouseFilter = Control.MouseFilterEnum.Stop; + _allowVerticalDrag = true; + } + + + private static readonly FieldInfo? DragStartPositionField = AccessTools.Field(typeof(NSlotsContainer), "_dragStartPosition"); + private static readonly FieldInfo? TargetPositionField = AccessTools.Field(typeof(NSlotsContainer), "_targetPosition"); + private static readonly FieldInfo? IsDraggingField = AccessTools.Field(typeof(NSlotsContainer), "_isDragging"); + private static readonly FieldInfo? EpochSlotsField = AccessTools.Field(typeof(NSlotsContainer), "_epochSlots"); + private static readonly FieldInfo? WhatsMovedField = AccessTools.Field(typeof(NSlotsContainer), "_whatsMoved"); + + [HarmonyTranspiler] + [HarmonyPatch(typeof(NSlotsContainer), "ProcessPanEvent")] + private static List AdjustProcessPanEventForVertical(IEnumerable instructions) + { + var setTargetPosition = typeof(NTimelineScreenVerticalDragging).Method(nameof(SetTargetPosition)); + var matcher = new CodeMatcher(instructions) + .MatchStartForward([ + new CodeMatch(OpCodes.Ldloc_1), + new CodeMatch(OpCodes.Callvirt), + new CodeMatch(OpCodes.Ldarg_0), + ]) + .ThrowIfInvalid("Could not find correct position") + .RemoveUntilForward([ + new CodeMatch(OpCodes.Ldarg_0), + new CodeMatch(OpCodes.Ldloc_1), + new CodeMatch(OpCodes.Callvirt), + ]) + .Insert([ + new CodeInstruction(OpCodes.Ldarg_0), + new CodeInstruction(OpCodes.Ldloc_1), + new CodeInstruction(OpCodes.Call, setTargetPosition), + ]); + return matcher.InstructionEnumeration().ToList(); + } + + private static void SetTargetPosition(NSlotsContainer instance, InputEventMouseMotion eventMouseMotion) + { + // This code runs a lot. Might be better to hold a reference in a conditional weak table? + var dragStartPositionObject = DragStartPositionField?.GetValue(instance); + if (dragStartPositionObject is not Vector2 dragStartPosition) throw new NullReferenceException(); + var targetPositionObject = TargetPositionField?.GetValue(instance); + if (targetPositionObject is not Vector2 targetPosition) throw new NullReferenceException(); + + var allowDrag = new Vector2(_allowHorizontalDrag ? 1 : 0, _allowVerticalDrag ? 1 : 0); + TargetPositionField?.SetValue(instance, targetPosition + (eventMouseMotion.Position - dragStartPosition) * allowDrag); + } + + + [HarmonyPostfix] + [HarmonyPatch(typeof(NSlotsContainer), nameof(NSlotsContainer._Process))] + private static void LerpToMinMaxHeights(NSlotsContainer __instance, double delta) + { + var isDraggingObject = IsDraggingField?.GetValue(__instance); + if (isDraggingObject is not bool isDragging) return; + if (!isDragging || !_allowVerticalDrag) return; + + var targetPositionObject = TargetPositionField?.GetValue(__instance); + if (targetPositionObject is not Vector2 targetPosition) throw new NullReferenceException(); + var epochSlotsObject = EpochSlotsField?.GetValue(__instance); + if (epochSlotsObject is not Control epochSlots) throw new NullReferenceException(); + var whatsMovedObject = WhatsMovedField?.GetValue(__instance); + if (whatsMovedObject is not Control whatsMoved) throw new NullReferenceException(); + + var num = targetPosition.Y; + var upperBound = epochSlots.Size.Y - 700; // - whatsMoved.Size.Y; //27 1080 + var lowerBound = -550; // epochSlots.Position.Y + epochSlots.Size.Y - whatsMoved.Size.Y; + if (num > upperBound) + num = Mathf.Lerp(num, upperBound, (float)delta * 36f); + else if (num < lowerBound) + num = Mathf.Lerp(num, lowerBound, (float)delta * 36f); + TargetPositionField?.SetValue(__instance, new Vector2(targetPosition.X, num)); + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(NSlotsContainer), nameof(NSlotsContainer.Reset))] + private static void ResetWhatsMovedYPosition(NSlotsContainer __instance) + { + if (!_allowVerticalDrag) return; + var whatsMovedObject = WhatsMovedField?.GetValue(__instance); + if (whatsMovedObject is not Control whatsMoved) throw new NullReferenceException(); + whatsMoved.Position = new Vector2(whatsMoved.Position.X, -540f); + } + + + [HarmonyPostfix] + [HarmonyPatch(typeof(NTimelineScreen), "ResetScreen")] + private static void ResetEpochSlotContainerPosition(NTimelineScreen __instance) + { + if (_epochSlotsContainerPositionOffset == new Vector2(0, 0)) return; + var epochSlotContainerObject = EpochSlotContainerField?.GetValue(__instance); + if (epochSlotContainerObject is not HBoxContainer epochSlotContainer) return; + epochSlotContainer.Position -= _epochSlotsContainerPositionOffset; + _epochSlotsContainerPositionOffset = new Vector2(0, 0); + } + } +} \ No newline at end of file diff --git a/Patches/Fixes/CardRewardSerializationPatches.cs b/Patches/Fixes/CardRewardSerializationPatches.cs index 1a3e8baa..defb0941 100644 --- a/Patches/Fixes/CardRewardSerializationPatches.cs +++ b/Patches/Fixes/CardRewardSerializationPatches.cs @@ -12,6 +12,8 @@ namespace BaseLib.Patches.Fixes; +// COMMENTED OUT TEMPORARILY; AN ACTUAL FIX IS NECESSARY + internal sealed class RewardExtData { [JsonPropertyName("flags")] diff --git a/Patches/PostModInitPatch.cs b/Patches/PostModInitPatch.cs index db2586a8..4f6cb846 100644 --- a/Patches/PostModInitPatch.cs +++ b/Patches/PostModInitPatch.cs @@ -1,5 +1,6 @@ using System.Reflection; using BaseLib.Abstracts; +using BaseLib.Extensions; using BaseLib.Patches.Content; using BaseLib.Patches.Features; using BaseLib.Patches.Saves; @@ -64,13 +65,15 @@ private static void EarlyPostInit() //Loads custom message types into custom message type maps CustomMessageWrapper.Initialize(); CustomTargetedMessageWrapper.Initialize(); - + Harmony harmony = new("PostModInit"); AddActContent.Patch(harmony); - + + InsertEpochSystemRelevantInformationIntoDictionaries(); + ModInterop interop = new(); - + foreach (var type in ReflectionHelper.ModTypes) { interop.ProcessType(harmony, type); @@ -164,8 +167,8 @@ private static void LatePostInit() else if (!SavePatchUtils.IsHolderTypeBaseSupported(prop.DeclaringType)) { var endMsg = ExtendedSaveTypes.IsSaveHolderSupported(type) - ? "change to a SavedSpireField for BaseLib to save it." - : "this type is currently also unsupported by BaseLib for saved values."; + ? "change to a SavedSpireField for BaseLib to save it." + : "this type is currently also unsupported by BaseLib for saved values."; BaseLibMain.Logger.Warn($"SavedProperty {prop.Name} will not work on type {type.Name}; {endMsg}"); } else @@ -197,10 +200,10 @@ private static void LatePostInit() private static void CheckSpecialSpireField(FieldInfo field) { Type fType = field.FieldType; - + if (!fType.IsGenericType) return; - + var genericTypeDef = fType.GetGenericTypeDefinition(); if (genericTypeDef != typeof(SavedSpireField<,>) && @@ -209,6 +212,15 @@ private static void CheckSpecialSpireField(FieldInfo field) field.GetValue(null); //Trigger field initialization } + + private static void InsertEpochSystemRelevantInformationIntoDictionaries() + { + CustomStoryModel.FillStoryDictionaries(ReflectionUtils.GetListOfInstantiatedSubclassesFromAllAssemblies()); + CustomEpochModel.FillEpochDictionaries(ReflectionUtils.GetListOfInstantiatedSubclassesFromAllAssemblies()); + + } + + /// /// Registers custom scene paths. diff --git a/Utils/CustomEpochHandler.cs b/Utils/CustomEpochHandler.cs new file mode 100644 index 00000000..76ea57c9 --- /dev/null +++ b/Utils/CustomEpochHandler.cs @@ -0,0 +1,27 @@ +using HarmonyLib; +using MegaCrit.Sts2.Core.Timeline; + +namespace BaseLib.Utils; + +public static class CustomEpochHandler +{ + + private static readonly List AllEpochs = AccessTools.StaticFieldRefAccess>(typeof(EpochModel), "_allEpochs"); + + /// + /// Insert the epoch type into + /// + public static void InsertIntoAllEpochs(EpochModel model) + { + if (!AllEpochs.Contains(model.GetType())) + AllEpochs.Add(model.GetType()); + } + /// + /// Insert the epochs type into + /// + public static void InsertIntoAllEpochs(IEnumerable models) + { + foreach (var model in models) + InsertIntoAllEpochs(model); + } +} \ No newline at end of file diff --git a/Utils/ReflectionUtils.cs b/Utils/ReflectionUtils.cs index 96139c96..480673c1 100644 --- a/Utils/ReflectionUtils.cs +++ b/Utils/ReflectionUtils.cs @@ -1,4 +1,5 @@ -using System.Reflection; +using System.Reflection; +using System.Runtime.CompilerServices; namespace BaseLib.Utils; @@ -61,4 +62,92 @@ public static class ReflectionUtils return (obj, value) => backingField.SetValue(obj, value); } } + + /// + /// Returns a list of instantiated objects. One for (and of) each class that inherits from the specified superclass.
+ /// Classes require a parameterless constructor. + ///
+ /// The Type of the Superclass + /// + public static List GetListOfInstantiatedSubclassesFromAllAssemblies() where T : class + { + var baseType = typeof(T); + var instances = new List(); + var assemblies = AppDomain.CurrentDomain.GetAssemblies(); + + foreach (var assembly in assemblies) + { + if (assembly.IsDynamic) continue; + + Type[] types; + try + { + types = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException e) + { + types = e.Types.Where(t => t != null).ToArray()!; + } + catch (Exception) + { + continue; + } + + foreach (var type in types) + { + if (type is not { IsClass: true, IsAbstract: false } || !baseType.IsAssignableFrom(type)) continue; + try + { + if (type.GetConstructor(Type.EmptyTypes) != null && Activator.CreateInstance(type) is T instance) + instances.Add(instance); + } + catch (Exception ex) + { + BaseLibMain.Logger.Error($"Mod assembly {assembly.GetName().Name} failed to instantiate type {type.FullName} for base {baseType.Name}. Error: {ex.Message}"); + } + } + } + + return instances; + } + + /// + /// Returns a list of instantiated objects. One for (and of) each class that inherits from the specified superclass within the calling assembly.
+ /// Classes require a parameterless constructor. + ///
+ /// The Type of the Superclass + /// + [MethodImpl(MethodImplOptions.NoInlining)] + public static List GetListOfInstantiatedSubclassesFromCurrentAssemblies() where T : class + { + var baseType = typeof(T); + var instances = new List(); + var assembly = Assembly.GetCallingAssembly(); + + Type[] types; + try + { + types = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException e) + { + types = e.Types.Where(t => t != null).ToArray()!; + } + + foreach (var type in types) + { + if (type is not { IsClass: true, IsAbstract: false } || !baseType.IsAssignableFrom(type)) continue; + try + { + if (type.GetConstructor(Type.EmptyTypes) != null && Activator.CreateInstance(type) is T instance) + instances.Add(instance); + } + catch (Exception ex) + { + BaseLibMain.Logger.Error($"Mod assembly {assembly.GetName().Name} failed to instantiate type {type.FullName} for base {baseType.Name}. Error: {ex.Message}"); + } + } + + return instances; + } } \ No newline at end of file