Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 190 additions & 2 deletions Abstracts/CustomCharacterModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.

/// <summary>
/// This Epoch usually unlocks the character. <br></br>
/// When overriden, automatically unlocks the Epoch based on <see cref="UnlocksAfterRunAs"/>.
/// If that is null, it unlocks after the vey first Ironclad run similar to <see cref="Silent.UnlocksAfterRunAs">The Silent</see>
/// </summary>
public virtual EpochModel? UnlockEpoch => null;
/// <summary>
/// This Epoch usually unlocks cards.
/// </summary>
public virtual EpochModel? Act1Epoch => null;
/// <summary>
/// This Epoch usually unlocks relics.
/// </summary>
public virtual EpochModel? Act2Epoch => null;
/// <summary>
/// This Epoch usually unlocks potions.
/// </summary>
public virtual EpochModel? Act3Epoch => null;
/// <summary>
/// This Epoch usually unlocks cards.
/// </summary>
public virtual EpochModel? FifteenElitesEpoch => null;
/// <summary>
/// This Epoch usually unlocks relics.
/// </summary>
public virtual EpochModel? FifteenBossesEpoch => null;
/// <summary>
/// This Epoch usually unlocks cards.
/// </summary>
public virtual EpochModel? AscensionOneEpoch => null;


/// <summary>
/// Override to provide a custom NCreatureVisuals scene.
/// If not overridden, an NCreatureVisuals will be generated from CustomVisualPath.
Expand Down Expand Up @@ -208,6 +249,153 @@ public void RegisterSceneConversions()

CustomEnergyCounterPath?.RegisterSceneForConversion<NEnergyCounter>();
}



// 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<NeowEpoch>())
SaveManager.Instance.ObtainEpochOverride(characterModel.UnlockEpoch!.Id, EpochState.Obtained);
break;
case Silent:
if(SaveManager.Instance.IsEpochRevealed<Silent1Epoch>())
SaveManager.Instance.ObtainEpochOverride(characterModel.UnlockEpoch!.Id, EpochState.Obtained);
break;
case Regent:
if(SaveManager.Instance.IsEpochRevealed<Regent1Epoch>())
SaveManager.Instance.ObtainEpochOverride(characterModel.UnlockEpoch!.Id, EpochState.Obtained);
break;
case Necrobinder:
if(SaveManager.Instance.IsEpochRevealed<Necrobinder1Epoch>())
SaveManager.Instance.ObtainEpochOverride(characterModel.UnlockEpoch!.Id, EpochState.Obtained);
break;
case Defect:
if(SaveManager.Instance.IsEpochRevealed<Defect1Epoch>())
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<MethodBase> 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<int, string> pathFunc, Color outlineColor, Color burstColor) {
Expand Down
118 changes: 118 additions & 0 deletions Abstracts/CustomEpochEra.cs
Original file line number Diff line number Diff line change
@@ -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.
/// <summary>
/// Setup for a custom era column. Still requires a "[CustomEnum] EpochEra" for dictionary entry
/// </summary>
public abstract class CustomEpochEra
{
/// <summary>
/// Points to a new [CustomEnum] EpochEra.
/// </summary>
public abstract EpochEra CustomEra { get; }

/// <summary>
/// The base game Era used as a frame of reference for where to insert the custom era.
/// </summary>
public abstract EpochEra ReferenceEra { get; }
/// <summary>
/// The direction in which the custom era will be inserted.
/// </summary>
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.
/// <summary>
/// Used for ordering multiple custom eras at the same <see cref="ReferenceEra">Reference Era</see> and <seealso cref="Direction"/>.
/// Larger numbers are further away from the reference era.
/// </summary>
public virtual int DirectionDepth => 0;

/// <summary>
/// The icon displayed on the timeline line at the bottom of the era.
/// </summary>
public abstract string EraIconPath { get; }
public Texture2D EraIconTexture => PreloadManager.Cache.GetTexture2D(EraIconPath);
/// <summary>
/// Stops the game from auto-sizing the era icon.
/// </summary>
public virtual bool UseOriginalImageSize => false;
/// <summary>
/// Stops the game from applying the default tint to the era icon.
/// </summary>
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());
}
}
}

/// <summary>
/// The direction in which a <see cref="CustomEpochEra">Custom Era</see> will be inserted, in reference to its <see cref="CustomEpochEra.ReferenceEra">Reference Era</see>
/// </summary>
public enum RelativeEraDirection
{
/// <summary>
/// Shouldn't be used.
/// </summary>
None,
/// <summary>
/// Insert to the left of reference Era.
/// </summary>
Before,
/// <summary>
/// Insert to the right of reference Era.
/// </summary>
After
}
Loading