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